diff --git a/README.md b/README.md index cf53ce2a1..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 @@ -350,6 +383,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 5b8725d1d..ae9c0c9cf 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -600,3 +600,55 @@ 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.Description("Repository owner"), + mcp.Required(), + ), + mcp.WithString("repo", + 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 nil, err + } + + repo, err := requiredParam[string](request, "repo") + if err != nil { + return nil, err + } + + pagination, err := optionalPaginationParams(request) + if err != nil { + return nil, err + } + + opts := &github.BranchListOptions{ + ListOptions: github.ListOptions{ + Page: pagination.page, + PerPage: pagination.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() }() + + r, err := json.Marshal(branches) + if err != nil { + return nil, fmt.Errorf("failed to marshal branches: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index f7ed8e718..7a28b3d36 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: "missing required parameter: owner", + }, + { + name: "missing repo", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]interface{}{ + "owner": "owner", + }, + expectError: false, + expectedErrMsg: "missing required parameter: repo", + }, + { + 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 bf3583b92..a0acc500c 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -59,6 +59,7 @@ func NewServer(client *github.Client, version string, readOnly bool, t translati 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))