diff --git a/README.md b/README.md index f85663ab..00f49b64 100644 --- a/README.md +++ b/README.md @@ -311,6 +311,13 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `branch`: Branch name (string, optional) - `sha`: File SHA if updating (string, optional) +- **list_branches** - List branches in a GitHub repository + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `page`: Page number (number, optional) + - `perPage`: Results per page (number, optional) + - **push_files** - Push multiple files in a single commit - `owner`: Repository owner (string, required) diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 56500eaf..1d74dcfb 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -150,6 +150,69 @@ func ListCommits(getClient GetClientFn, t translations.TranslationHelperFunc) (t } } +// ListBranches creates a tool to list branches in a GitHub repository. +func ListBranches(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("list_branches", + mcp.WithDescription(t("TOOL_LIST_BRANCHES_DESCRIPTION", "List branches in a GitHub repository")), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + WithPagination(), + ), + func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { + owner, err := requiredParam[string](request, "owner") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + repo, err := requiredParam[string](request, "repo") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + pagination, err := OptionalPaginationParams(request) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &github.BranchListOptions{ + ListOptions: github.ListOptions{ + Page: pagination.page, + PerPage: pagination.perPage, + }, + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + branches, resp, err := client.Repositories.ListBranches(ctx, owner, repo, opts) + if err != nil { + return nil, fmt.Errorf("failed to list branches: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return mcp.NewToolResultError(fmt.Sprintf("failed to list branches: %s", string(body))), nil + } + + r, err := json.Marshal(branches) + if err != nil { + return nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + // CreateOrUpdateFile creates a tool to create or update a file in a GitHub repository. func CreateOrUpdateFile(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { return mcp.NewTool("create_or_update_file", diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 20f96dde..2410a31d 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -1423,3 +1423,113 @@ func Test_PushFiles(t *testing.T) { }) } } + +func Test_ListBranches(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := ListBranches(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "list_branches", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.Properties, "owner") + assert.Contains(t, tool.InputSchema.Properties, "repo") + assert.Contains(t, tool.InputSchema.Properties, "page") + assert.Contains(t, tool.InputSchema.Properties, "perPage") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo"}) + + // Setup mock branches for success case + mockBranches := []*github.Branch{ + { + Name: github.Ptr("main"), + Commit: &github.RepositoryCommit{SHA: github.Ptr("abc123")}, + }, + { + Name: github.Ptr("develop"), + Commit: &github.RepositoryCommit{SHA: github.Ptr("def456")}, + }, + } + + // Test cases + tests := []struct { + name string + args map[string]interface{} + mockResponses []mock.MockBackendOption + wantErr bool + errContains string + }{ + { + name: "success", + args: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "page": float64(2), + }, + mockResponses: []mock.MockBackendOption{ + mock.WithRequestMatch( + mock.GetReposBranchesByOwnerByRepo, + mockBranches, + ), + }, + wantErr: false, + }, + { + name: "missing owner", + args: map[string]interface{}{ + "repo": "repo", + }, + mockResponses: []mock.MockBackendOption{}, + wantErr: false, + errContains: "missing required parameter: owner", + }, + { + name: "missing repo", + args: map[string]interface{}{ + "owner": "owner", + }, + mockResponses: []mock.MockBackendOption{}, + wantErr: false, + errContains: "missing required parameter: repo", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create mock client + mockClient := github.NewClient(mock.NewMockedHTTPClient(tt.mockResponses...)) + _, handler := ListBranches(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + // Create request + request := createMCPRequest(tt.args) + + // Call handler + result, err := handler(context.Background(), request) + if tt.wantErr { + require.Error(t, err) + if tt.errContains != "" { + assert.Contains(t, err.Error(), tt.errContains) + } + return + } + + require.NoError(t, err) + require.NotNil(t, result) + + if tt.errContains != "" { + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, tt.errContains) + return + } + + textContent := getTextResult(t, result) + require.NotEmpty(t, textContent.Text) + + // Verify response + var branches []*github.Branch + err = json.Unmarshal([]byte(textContent.Text), &branches) + require.NoError(t, err) + assert.Len(t, branches, 2) + assert.Equal(t, "main", *branches[0].Name) + assert.Equal(t, "develop", *branches[1].Name) + }) + } +} diff --git a/pkg/github/server.go b/pkg/github/server.go index 490a8105..f7eea97e 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -70,6 +70,7 @@ func NewServer(getClient GetClientFn, version string, readOnly bool, t translati s.AddTool(GetFileContents(getClient, t)) s.AddTool(GetCommit(getClient, t)) s.AddTool(ListCommits(getClient, t)) + s.AddTool(ListBranches(getClient, t)) if !readOnly { s.AddTool(CreateOrUpdateFile(getClient, t)) s.AddTool(CreateRepository(getClient, t))