From cec491c385f43e662e0c5b87c52adf8b1d852a3a Mon Sep 17 00:00:00 2001 From: Arya Soni Date: Mon, 7 Apr 2025 11:55:52 +0100 Subject: [PATCH 1/5] feat: Add list_branches tool to view repository branches (#141) --- pkg/github/repositories.go | 67 +++++++++++++++++++ pkg/github/repositories_test.go | 115 ++++++++++++++++++++++++++++++++ pkg/github/server.go | 1 + 3 files changed, 183 insertions(+) diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 112eb3740..2225052c3 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -610,3 +610,70 @@ func pushFiles(client *github.Client, t translations.TranslationHelperFunc) (too return mcp.NewToolResultText(string(r)), nil } } + +// listBranches creates a tool to list branches in a repository. +func listBranches(client *github.Client, 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"), + ), + mcp.WithNumber("page", + mcp.Description("Page number"), + ), + mcp.WithNumber("perPage", + mcp.Description("Results per page"), + ), + ), + 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 + } + page, err := optionalIntParamWithDefault(request, "page", 1) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + perPage, err := optionalIntParamWithDefault(request, "per_page", 30) + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + opts := &github.BranchListOptions{ + ListOptions: github.ListOptions{ + Page: page, + PerPage: perPage, + }, + } + + 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 + } +} diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index bb6579f85..a2e87aeec 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -1293,3 +1293,118 @@ func Test_PushFiles(t *testing.T) { }) } } + +func Test_ListBranches(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := listBranches(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")}, + }, + } + + // Define test cases + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedErrMsg string + }{ + { + name: "success", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposBranchesByOwnerByRepo, + mockBranches, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + }, + { + name: "missing owner", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "repo": "repo", + }, + expectError: false, + expectedErrMsg: "owner is required", + }, + { + name: "missing repo", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner": "owner", + }, + expectError: false, + expectedErrMsg: "repo is required", + }, + { + name: "repository not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposBranchesByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "nonexistent-repo", + }, + expectError: true, + expectedErrMsg: "failed to list branches", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := github.NewClient(tt.mockedClient) + _, handler := listBranches(client, translations.NullTranslationHelper) + + // Create call request using helper function + request := createMCPRequest(tt.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + if tt.expectError { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedErrMsg) + } else { + if tt.expectedErrMsg != "" { + assert.NotNil(t, result) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, tt.expectedErrMsg) + } else { + assert.NoError(t, err) + assert.NotNil(t, result) + textContent := getTextResult(t, result) + assert.NotEmpty(t, textContent.Text) + } + } + }) + } +} diff --git a/pkg/github/server.go b/pkg/github/server.go index 66dbfd1ca..22dc2a3a3 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -59,6 +59,7 @@ func NewServer(client *github.Client, readOnly bool, t translations.TranslationH s.AddTool(searchRepositories(client, t)) s.AddTool(getFileContents(client, t)) s.AddTool(listCommits(client, t)) + s.AddTool(listBranches(client, t)) if !readOnly { s.AddTool(createOrUpdateFile(client, t)) s.AddTool(createRepository(client, t)) From 8e72b1b0c0f87956053cb62a516c405908dc4292 Mon Sep 17 00:00:00 2001 From: Arya Soni Date: Mon, 7 Apr 2025 11:57:45 +0100 Subject: [PATCH 2/5] fix: Update error message expectations in list_branches tests --- pkg/github/repositories_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index a2e87aeec..da9d0eec3 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -1348,7 +1348,7 @@ func Test_ListBranches(t *testing.T) { "repo": "repo", }, expectError: false, - expectedErrMsg: "owner is required", + expectedErrMsg: "missing required parameter: owner", }, { name: "missing repo", @@ -1357,7 +1357,7 @@ func Test_ListBranches(t *testing.T) { "owner": "owner", }, expectError: false, - expectedErrMsg: "repo is required", + expectedErrMsg: "missing required parameter: repo", }, { name: "repository not found", From 2bfcb8f367da3bffde978157f7f7642ce206f599 Mon Sep 17 00:00:00 2001 From: Arya Soni Date: Mon, 7 Apr 2025 22:43:49 +0100 Subject: [PATCH 3/5] docs: Add list_branches to README and update pagination handling --- README.md | 6 ++++++ pkg/github/repositories.go | 17 ++++------------- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index cf53ce2a1..0141e4cd5 100644 --- a/README.md +++ b/README.md @@ -350,6 +350,12 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `page`: Page number (number, optional) - `perPage`: Results per page (number, 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) + ### Search - **search_code** - Search for code across GitHub repositories diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 9a21022da..f73b8989f 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -613,12 +613,7 @@ func listBranches(client *github.Client, t translations.TranslationHelperFunc) ( mcp.Required(), mcp.Description("Repository name"), ), - mcp.WithNumber("page", - mcp.Description("Page number"), - ), - mcp.WithNumber("perPage", - mcp.Description("Results per page"), - ), + withPagination(), ), func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) { owner, err := requiredParam[string](request, "owner") @@ -629,19 +624,15 @@ func listBranches(client *github.Client, t translations.TranslationHelperFunc) ( if err != nil { return mcp.NewToolResultError(err.Error()), nil } - page, err := optionalIntParamWithDefault(request, "page", 1) - if err != nil { - return mcp.NewToolResultError(err.Error()), nil - } - perPage, err := optionalIntParamWithDefault(request, "per_page", 30) + pagination, err := optionalPaginationParams(request) if err != nil { return mcp.NewToolResultError(err.Error()), nil } opts := &github.BranchListOptions{ ListOptions: github.ListOptions{ - Page: page, - PerPage: perPage, + Page: pagination.page, + PerPage: pagination.perPage, }, } From 0ef1113f2dafabbac7992854b692d07f343c42da Mon Sep 17 00:00:00 2001 From: Arya Soni Date: Mon, 7 Apr 2025 23:07:18 +0100 Subject: [PATCH 4/5] docs: Clearly specify required GitHub token permissions per action (#128) --- README.md | 79 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 56 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 0141e4cd5..f120c677f 100644 --- a/README.md +++ b/README.md @@ -16,9 +16,39 @@ automation and interaction capabilities for developers and tools. 1. To run the server in a container, you will need to have [Docker](https://www.docker.com/) installed. 2. [Create a GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new). -The MCP server can use many of the GitHub APIs, so enable the permissions that you feel comfortable granting your AI tools (to learn more about access tokens, please check out the [documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)). - - + The MCP server requires specific permissions for different tools. Here are the required permissions for each tool: + + ### User Tools + - **get_me** - Get details of the authenticated user + - Required permissions: `read:user` + + ### Issues Tools + - **get_issue**, **get_issue_comments**, **list_issues** - Read issue data + - Required permissions: `repo` (for private repos) or no permissions (for public repos) + + - **create_issue**, **add_issue_comment**, **update_issue** - Create/modify issues + - Required permissions: `repo` (for private repos) or `public_repo` (for public repos) + + ### Pull Request Tools + - **get_pull_request**, **list_pull_requests**, **get_pull_request_files**, **get_pull_request_status**, **get_pull_request_comments**, **get_pull_request_reviews** - Read PR data + - Required permissions: `repo` (for private repos) or no permissions (for public repos) + + - **merge_pull_request**, **update_pull_request_branch**, **create_pull_request_review**, **create_pull_request** - Create/modify PRs + - Required permissions: `repo` (for private repos) or `public_repo` (for public repos) + + ### Repository Tools + - **search_repositories** - Search repositories + - Required permissions: No permissions required for public repos, `repo` for private repos + + - **get_file_contents**, **list_commits**, **list_branches** - Read repository data + - Required permissions: `repo` (for private repos) or no permissions (for public repos) + + - **create_or_update_file**, **push_files**, **create_repository** - Create/modify repository content + - Required permissions: `repo` (for private repos) or `public_repo` (for public repos) + + ### Search Tools + - **search_issues** - Search issues and pull requests + - Required permissions: No permissions required for public data, `repo` for private data ## Installation @@ -144,23 +174,24 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - **get_me** - Get details of the authenticated user - No parameters required + - Required permissions: `read:user` ### Issues - **get_issue** - Gets the contents of an issue within a repository - + - Required permissions: `repo` (private repos) or none (public repos) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `issue_number`: Issue number (number, required) - **get_issue_comments** - Get comments for a GitHub issue - + - Required permissions: `repo` (private repos) or none (public repos) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `issue_number`: Issue number (number, required) - **create_issue** - Create a new issue in a GitHub repository - + - Required permissions: `repo` (private repos) or `public_repo` (public repos) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `title`: Issue title (string, required) @@ -169,14 +200,14 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `labels`: Labels to apply to this issue (string[], optional) - **add_issue_comment** - Add a comment to an issue - + - Required permissions: `repo` (private repos) or `public_repo` (public repos) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `issue_number`: Issue number (number, required) - `body`: Comment text (string, required) - **list_issues** - List and filter repository issues - + - Required permissions: `repo` (private repos) or none (public repos) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `state`: Filter by state ('open', 'closed', 'all') (string, optional) @@ -188,7 +219,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `perPage`: Results per page (number, optional) - **update_issue** - Update an existing issue in a GitHub repository - + - Required permissions: `repo` (private repos) or `public_repo` (public repos) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `issue_number`: Issue number to update (number, required) @@ -209,23 +240,25 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description ### Pull Requests - **get_pull_request** - Get details of a specific pull request - + - Required permissions: `repo` (private repos) or none (public repos) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `pullNumber`: Pull request number (number, required) - **list_pull_requests** - List and filter repository pull requests - + - Required permissions: `repo` (private repos) or none (public repos) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - - `state`: PR state (string, optional) - - `sort`: Sort field (string, optional) - - `direction`: Sort direction (string, optional) + - `state`: Filter by state ('open', 'closed', 'all') (string, optional) + - `head`: Filter by head user/org and branch (string, optional) + - `base`: Filter by base branch (string, optional) + - `sort`: Sort by ('created', 'updated', 'popularity', 'long-running') (string, optional) + - `direction`: Sort direction ('asc', 'desc') (string, optional) - `perPage`: Results per page (number, optional) - `page`: Page number (number, optional) - **merge_pull_request** - Merge a pull request - + - Required permissions: `repo` (private repos) or `public_repo` (public repos) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `pullNumber`: Pull request number (number, required) @@ -234,7 +267,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `merge_method`: Merge method (string, optional) - **get_pull_request_files** - Get the list of files changed in a pull request - + - Required permissions: `repo` (private repos) or none (public repos) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `pullNumber`: Pull request number (number, required) @@ -287,10 +320,10 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `draft`: Create as draft PR (boolean, optional) - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) -### Repositories +### Repository - **create_or_update_file** - Create or update a single file in a repository - + - Required permissions: `repo` (private repos) or `public_repo` (public repos) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `path`: File path (string, required) @@ -300,7 +333,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `sha`: File SHA if updating (string, optional) - **push_files** - Push multiple files in a single commit - + - Required permissions: `repo` (private repos) or `public_repo` (public repos) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `branch`: Branch to push to (string, required) @@ -308,7 +341,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `message`: Commit message (string, required) - **search_repositories** - Search for GitHub repositories - + - Required permissions: No permissions required for public repos, `repo` for private repos - `query`: Search query (string, required) - `sort`: Sort field (string, optional) - `order`: Sort order (string, optional) @@ -316,18 +349,18 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `perPage`: Results per page (number, optional) - **create_repository** - Create a new GitHub repository - + - Required permissions: `repo` (private repos) or `public_repo` (public repos) - `name`: Repository name (string, required) - `description`: Repository description (string, optional) - `private`: Whether the repository is private (boolean, optional) - `autoInit`: Auto-initialize with README (boolean, optional) - **get_file_contents** - Get contents of a file or directory - + - Required permissions: `repo` (private repos) or none (public repos) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `path`: File path (string, required) - - `ref`: Git reference (string, optional) + - `ref`: Git reference (branch, tag, commit) (string, optional) - **fork_repository** - Fork a repository From 9b07680b2804dd0f25910c24d2b1d80e4c8f83dc Mon Sep 17 00:00:00 2001 From: Arya Soni Date: Wed, 9 Apr 2025 18:25:20 +0200 Subject: [PATCH 5/5] refactor: Rename listBranches to ListBranches for consistency --- pkg/github/repositories.go | 26 ++++++++++---------------- 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index f73b8989f..ae9c0c9cf 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -601,32 +601,34 @@ func pushFiles(client *github.Client, t translations.TranslationHelperFunc) (too } } -// listBranches creates a tool to list branches in a repository. -func listBranches(client *github.Client, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { +// ListBranches creates a tool to list branches in a repository. +func ListBranches(client *github.Client, 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.Required(), ), mcp.WithString("repo", - mcp.Required(), mcp.Description("Repository name"), + mcp.Required(), ), 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 + return nil, err } + repo, err := requiredParam[string](request, "repo") if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return nil, err } + pagination, err := optionalPaginationParams(request) if err != nil { - return mcp.NewToolResultError(err.Error()), nil + return nil, err } opts := &github.BranchListOptions{ @@ -642,17 +644,9 @@ func listBranches(client *github.Client, t translations.TranslationHelperFunc) ( } 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 nil, fmt.Errorf("failed to marshal branches: %w", err) } return mcp.NewToolResultText(string(r)), nil