diff --git a/README.md b/README.md index 352bb50eb..80f513f78 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,73 @@ 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. Once Docker is installed, you will also need to ensure Docker is running. The image is public; if you get errors on pull, you may have an expired token and need to `docker logout ghcr.io`. -3. Lastly you will need to [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)). +3. [Create a GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new). + Each tool requires specific permissions to function. See the [Required Token Permissions](#required-token-permissions) section below for details. + 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)). + +## Required Token Permissions + +Each tool requires specific GitHub Personal Access Token permissions to function. Below are the required permissions for each tool category: + +### Users +- **get_me** + - Required permissions: + - `read:user` - Read access to profile info + +### Issues +- **get_issue**, **get_issue_comments**, **list_issues** + - Required permissions: + - `repo` - Full control of private repositories (for private repos) + - `public_repo` - Access public repositories (for public repos) + +- **create_issue**, **add_issue_comment**, **update_issue** + - Required permissions: + - `repo` - Full control of private repositories (for private repos) + - `public_repo` - Access public repositories (for public repos) + - `write:discussion` - Write access to repository discussions (if using discussions) + +- **add_sub_issue**, **get_sub_issues** + - Required permissions: + - `repo` - Full control of private repositories (for private repos) + - `public_repo` - Access public repositories (for public repos) + +### Pull Requests +- **get_pull_request**, **list_pull_requests**, **get_pull_request_files**, **get_pull_request_status** + - Required permissions: + - `repo` - Full control of private repositories (for private repos) + - `public_repo` - Access public repositories (for public repos) + +- **merge_pull_request**, **update_pull_request_branch**, **create_pull_request**, **update_pull_request** + - Required permissions: + - `repo` - Full control of private repositories (for private repos) + - `public_repo` - Access public repositories (for public repos) + - `write:discussion` - Write access to repository discussions (if using discussions) + +### Repositories +- **get_file_contents**, **search_repositories**, **list_commits** + - Required permissions: + - `repo` - Full control of private repositories (for private repos) + - `public_repo` - Access public repositories (for public repos) + +- **create_or_update_file**, **push_files**, **create_repository**, **fork_repository**, **create_branch** + - Required permissions: + - `repo` - Full control of private repositories (for private repos) + - `public_repo` - Access public repositories (for public repos) + - `delete_repo` - Delete repositories (if needed) + +### Search +- **search_code**, **search_users** + - Required permissions: + - No special permissions required for public data + - `repo` - Required for searching private repositories + +### Code Scanning +- **get_code_scanning_alert**, **list_code_scanning_alerts** + - Required permissions: + - `security_events` - Read and write security events + - `repo` - Full control of private repositories (for private repos) + +Note: For organization repositories, additional organization-specific permissions may be required. ## Installation @@ -60,7 +125,6 @@ For manual installation, add the following JSON block to your User Settings (JSO Optionally, you can add a similar example (i.e. without the mcp key) to a file called `.vscode/mcp.json` in your workspace. This will allow you to share the configuration with others. - ```json { "inputs": [ @@ -88,7 +152,6 @@ Optionally, you can add a similar example (i.e. without the mcp key) to a file c } } } - ``` More about using MCP server tools in VS Code's [agent mode documentation](https://code.visualstudio.com/docs/copilot/chat/mcp-servers). @@ -320,6 +383,19 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `issue_number`: Issue number (number, required) - `body`: Comment text (string, required) +- **add_sub_issue** - Add a sub-issue to an existing issue + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `parent_issue_number`: Parent issue number (number, required) + - `child_issue_number`: Child issue number to add as sub-issue (number, required) + +- **get_sub_issues** - Get sub-issues of an issue + + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `issue_number`: Issue number (number, required) + - **list_issues** - List and filter repository issues - `owner`: Repository owner (string, required) @@ -476,12 +552,6 @@ 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) - `repo`: Repository name (string, required) @@ -519,7 +589,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `branch`: New branch name (string, required) - `sha`: SHA to create branch from (string, required) -- **list_commits** - Get a list of commits of a branch in a repository +- **list_commits** - Gets commits of a branch in a repository - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `sha`: Branch name, tag, or commit SHA (string, optional) @@ -527,12 +597,7 @@ export GITHUB_MCP_TOOL_ADD_ISSUE_COMMENT_DESCRIPTION="an alternative description - `page`: Page number (number, optional) - `perPage`: Results per page (number, optional) -- **get_commit** - Get details for a commit from a repository - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `sha`: Commit SHA, branch name, or tag name (string, required) - - `page`: Page number, for files in the commit (number, optional) - - `perPage`: Results per page, for files in the commit (number, optional) +### Search - **search_code** - Search for code across GitHub repositories - `query`: Search query (string, required) @@ -640,4 +705,4 @@ The exported Go API of this module should currently be considered unstable, and ## License -This project is licensed under the terms of the MIT open source license. Please refer to [MIT](./LICENSE) for the full terms. +This project is licensed under the terms of the MIT open source license. Please refer to [MIT](./LICENSE) for the full terms. \ No newline at end of file diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 7c8451d39..c77a5bf85 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -53,6 +53,8 @@ func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool if err != nil { return nil, fmt.Errorf("failed to get GitHub client: %w", err) } + + // Get issue details issue, resp, err := client.Issues.Get(ctx, owner, repo, issueNumber) if err != nil { return nil, fmt.Errorf("failed to get issue: %w", err) @@ -67,6 +69,37 @@ func GetIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool return mcp.NewToolResultError(fmt.Sprintf("failed to get issue: %s", string(body))), nil } + // Get sub-issues + url := fmt.Sprintf("repos/%v/%v/issues/%d/sub-issues", owner, repo, issueNumber) + req, err := client.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + var subIssues []*github.Issue + resp, err = client.Do(ctx, req, &subIssues) + if err == nil && resp.StatusCode == http.StatusOK { + // Only include sub-issues if the request was successful + // Create a custom response struct that includes sub-issues + type IssueWithSubIssues struct { + *github.Issue + SubIssues []*github.Issue `json:"sub_issues,omitempty"` + } + + issueWithSubs := &IssueWithSubIssues{ + Issue: issue, + SubIssues: subIssues, + } + + r, err := json.Marshal(issueWithSubs) + if err != nil { + return nil, fmt.Errorf("failed to marshal issue with sub-issues: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } + + // If getting sub-issues failed, just return the main issue r, err := json.Marshal(issue) if err != nil { return nil, fmt.Errorf("failed to marshal issue: %w", err) @@ -711,6 +744,159 @@ func GetIssueComments(getClient GetClientFn, t translations.TranslationHelperFun } } +// AddSubIssue creates a tool to add a sub-issue to an existing issue. +func AddSubIssue(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("add_sub_issue", + mcp.WithDescription(t("TOOL_ADD_SUB_ISSUE_DESCRIPTION", "Add a sub-issue to an existing issue")), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("parent_issue_number", + mcp.Required(), + mcp.Description("Parent issue number"), + ), + mcp.WithNumber("child_issue_number", + mcp.Required(), + mcp.Description("Child issue number to add as sub-issue"), + ), + ), + 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 + } + parentIssueNumber, err := RequiredInt(request, "parent_issue_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + childIssueNumber, err := RequiredInt(request, "child_issue_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // First verify both issues exist + _, resp, err := client.Issues.Get(ctx, owner, repo, parentIssueNumber) + if err != nil { + return nil, fmt.Errorf("failed to get parent issue: %w", err) + } + if resp.StatusCode != http.StatusOK { + return mcp.NewToolResultError("parent issue not found"), nil + } + + _, resp, err = client.Issues.Get(ctx, owner, repo, childIssueNumber) + if err != nil { + return nil, fmt.Errorf("failed to get child issue: %w", err) + } + if resp.StatusCode != http.StatusOK { + return mcp.NewToolResultError("child issue not found"), nil + } + + // Add sub-issue relationship + url := fmt.Sprintf("repos/%v/%v/issues/%d/sub-issues/%d", owner, repo, parentIssueNumber, childIssueNumber) + req, err := client.NewRequest("POST", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + resp, err = client.Do(ctx, req, nil) + if err != nil { + return nil, fmt.Errorf("failed to add sub-issue: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + 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 add sub-issue: %s", string(body))), nil + } + + return mcp.NewToolResultText(fmt.Sprintf("Successfully added issue #%d as a sub-issue of #%d", childIssueNumber, parentIssueNumber)), nil + } +} + +// GetSubIssues creates a tool to get sub-issues of a specific issue. +func GetSubIssues(getClient GetClientFn, t translations.TranslationHelperFunc) (tool mcp.Tool, handler server.ToolHandlerFunc) { + return mcp.NewTool("get_sub_issues", + mcp.WithDescription(t("TOOL_GET_SUB_ISSUES_DESCRIPTION", "Get sub-issues of a specific issue")), + mcp.WithString("owner", + mcp.Required(), + mcp.Description("Repository owner"), + ), + mcp.WithString("repo", + mcp.Required(), + mcp.Description("Repository name"), + ), + mcp.WithNumber("issue_number", + mcp.Required(), + mcp.Description("Parent issue number"), + ), + ), + 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 + } + issueNumber, err := RequiredInt(request, "issue_number") + if err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + + client, err := getClient(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + // Get sub-issues + url := fmt.Sprintf("repos/%v/%v/issues/%d/sub-issues", owner, repo, issueNumber) + req, err := client.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + var subIssues []*github.Issue + resp, err := client.Do(ctx, req, &subIssues) + if err != nil { + return nil, fmt.Errorf("failed to get sub-issues: %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 get sub-issues: %s", string(body))), nil + } + + r, err := json.Marshal(subIssues) + if err != nil { + return nil, fmt.Errorf("failed to marshal sub-issues: %w", err) + } + + return mcp.NewToolResultText(string(r)), nil + } +} + // parseISOTimestamp parses an ISO 8601 timestamp string into a time.Time object. // Returns the parsed time or an error if parsing fails. // Example formats supported: "2023-01-15T14:30:00Z", "2023-01-15" diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 61ca0ae7a..6ae0c8dcc 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -15,6 +15,18 @@ import ( "github.com/stretchr/testify/require" ) +// Custom endpoint patterns for sub-issues +var ( + PostReposSubIssuesByOwnerByRepoByParentIssueNumberByChildIssueNumber = mock.EndpointPattern{ + Pattern: "POST /repos/{owner}/{repo}/issues/{parent_issue_number}/sub-issues/{child_issue_number}", + Method: "POST", + } + GetReposSubIssuesByOwnerByRepoByIssueNumber = mock.EndpointPattern{ + Pattern: "GET /repos/{owner}/{repo}/issues/{issue_number}/sub-issues", + Method: "GET", + } +) + func Test_GetIssue(t *testing.T) { // Verify tool definition once mockClient := github.NewClient(nil) @@ -76,6 +88,33 @@ func Test_GetIssue(t *testing.T) { expectError: true, expectedErrMsg: "failed to get issue", }, + { + name: "successful issue retrieval with sub-issues", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposIssuesByOwnerByRepoByIssueNumber, + mockIssue, + ), + mock.WithRequestMatchHandler( + GetReposSubIssuesByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusOK, []*github.Issue{ + { + Number: github.Ptr(43), + Title: github.Ptr("Sub Issue"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/43"), + }, + }), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectError: false, + expectedIssue: mockIssue, + }, } for _, tc := range tests { @@ -1130,3 +1169,276 @@ func Test_GetIssueComments(t *testing.T) { }) } } + +func Test_AddSubIssue(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := AddSubIssue(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "add_sub_issue", 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, "parent_issue_number") + assert.Contains(t, tool.InputSchema.Properties, "child_issue_number") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "parent_issue_number", "child_issue_number"}) + + // Setup mock issues for success case + mockParentIssue := &github.Issue{ + Number: github.Ptr(42), + Title: github.Ptr("Parent Issue"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), + } + + mockChildIssue := &github.Issue{ + Number: github.Ptr(43), + Title: github.Ptr("Child Issue"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/43"), + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedErrMsg string + }{ + { + name: "successful sub-issue addition", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposIssuesByOwnerByRepoByIssueNumber, + mockParentIssue, + ), + mock.WithRequestMatch( + mock.GetReposIssuesByOwnerByRepoByIssueNumber, + mockChildIssue, + ), + mock.WithRequestMatchHandler( + PostReposSubIssuesByOwnerByRepoByParentIssueNumberByChildIssueNumber, + mockResponse(t, http.StatusCreated, nil), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "parent_issue_number": float64(42), + "child_issue_number": float64(43), + }, + expectError: false, + }, + { + name: "parent issue not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposIssuesByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "parent_issue_number": float64(999), + "child_issue_number": float64(43), + }, + expectError: false, + expectedErrMsg: "parent issue not found", + }, + { + name: "child issue not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposIssuesByOwnerByRepoByIssueNumber, + mockParentIssue, + ), + mock.WithRequestMatchHandler( + mock.GetReposIssuesByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "parent_issue_number": float64(42), + "child_issue_number": float64(999), + }, + expectError: false, + expectedErrMsg: "child issue not found", + }, + { + name: "sub-issue addition fails", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatch( + mock.GetReposIssuesByOwnerByRepoByIssueNumber, + mockParentIssue, + ), + mock.WithRequestMatch( + mock.GetReposIssuesByOwnerByRepoByIssueNumber, + mockChildIssue, + ), + mock.WithRequestMatchHandler( + PostReposSubIssuesByOwnerByRepoByParentIssueNumberByChildIssueNumber, + mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Issues cannot be nested more than one level deep"}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "parent_issue_number": float64(42), + "child_issue_number": float64(43), + }, + expectError: false, + expectedErrMsg: "failed to add sub-issue", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := AddSubIssue(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + if tc.expectedErrMsg != "" { + require.NotNil(t, result) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.NotNil(t, result) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "Successfully added issue") + }) + } +} + +func Test_GetSubIssues(t *testing.T) { + // Verify tool definition once + mockClient := github.NewClient(nil) + tool, _ := GetSubIssues(stubGetClientFn(mockClient), translations.NullTranslationHelper) + + assert.Equal(t, "get_sub_issues", 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, "issue_number") + assert.ElementsMatch(t, tool.InputSchema.Required, []string{"owner", "repo", "issue_number"}) + + // Setup mock issues for success case + mockSubIssues := []*github.Issue{ + { + Number: github.Ptr(43), + Title: github.Ptr("Sub Issue 1"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/43"), + }, + { + Number: github.Ptr(44), + Title: github.Ptr("Sub Issue 2"), + State: github.Ptr("closed"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/44"), + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedIssues []*github.Issue + expectedErrMsg string + }{ + { + name: "successful sub-issues retrieval", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + GetReposSubIssuesByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusOK, mockSubIssues), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(42), + }, + expectError: false, + expectedIssues: mockSubIssues, + }, + { + name: "parent issue not found", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + GetReposSubIssuesByOwnerByRepoByIssueNumber, + mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(999), + }, + expectError: false, + expectedErrMsg: "failed to get sub-issues", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup client with mock + client := github.NewClient(tc.mockedClient) + _, handler := GetSubIssues(stubGetClientFn(client), translations.NullTranslationHelper) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(context.Background(), request) + + // Verify results + if tc.expectError { + require.Error(t, err) + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + + if tc.expectedErrMsg != "" { + require.NotNil(t, result) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + textContent := getTextResult(t, result) + + // Unmarshal and verify the result + var returnedIssues []*github.Issue + err = json.Unmarshal([]byte(textContent.Text), &returnedIssues) + require.NoError(t, err) + + assert.Len(t, returnedIssues, len(tc.expectedIssues)) + for i, issue := range returnedIssues { + assert.Equal(t, *tc.expectedIssues[i].Number, *issue.Number) + assert.Equal(t, *tc.expectedIssues[i].Title, *issue.Title) + assert.Equal(t, *tc.expectedIssues[i].State, *issue.State) + assert.Equal(t, *tc.expectedIssues[i].HTMLURL, *issue.HTMLURL) + } + }) + } +} diff --git a/pkg/github/server.go b/pkg/github/server.go index e4c241716..24c630a07 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" + "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v69/github" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" @@ -11,7 +12,13 @@ import ( // NewServer creates a new GitHub MCP server with the specified GH client and logger. -func NewServer(version string, opts ...server.ServerOption) *server.MCPServer { +func NewServer( + version string, + getClient GetClientFn, + t translations.TranslationHelperFunc, + readOnly bool, + opts ...server.ServerOption, +) *server.MCPServer { // Add default options defaultOpts := []server.ServerOption{ server.WithToolCapabilities(true), @@ -26,6 +33,68 @@ func NewServer(version string, opts ...server.ServerOption) *server.MCPServer { version, opts..., ) + + // Add GitHub Resources + s.AddResourceTemplate(GetRepositoryResourceContent(getClient, t)) + s.AddResourceTemplate(GetRepositoryResourceBranchContent(getClient, t)) + s.AddResourceTemplate(GetRepositoryResourceCommitContent(getClient, t)) + s.AddResourceTemplate(GetRepositoryResourceTagContent(getClient, t)) + s.AddResourceTemplate(GetRepositoryResourcePrContent(getClient, t)) + + // Add GitHub tools - Issues + s.AddTool(GetIssue(getClient, t)) + s.AddTool(SearchIssues(getClient, t)) + s.AddTool(ListIssues(getClient, t)) + s.AddTool(GetIssueComments(getClient, t)) + s.AddTool(GetSubIssues(getClient, t)) + if !readOnly { + s.AddTool(CreateIssue(getClient, t)) + s.AddTool(AddIssueComment(getClient, t)) + s.AddTool(UpdateIssue(getClient, t)) + s.AddTool(AddSubIssue(getClient, t)) + } + + // Add GitHub tools - Pull Requests + s.AddTool(GetPullRequest(getClient, t)) + s.AddTool(ListPullRequests(getClient, t)) + s.AddTool(GetPullRequestFiles(getClient, t)) + s.AddTool(GetPullRequestStatus(getClient, t)) + s.AddTool(GetPullRequestComments(getClient, t)) + s.AddTool(GetPullRequestReviews(getClient, t)) + if !readOnly { + s.AddTool(MergePullRequest(getClient, t)) + s.AddTool(UpdatePullRequestBranch(getClient, t)) + s.AddTool(CreatePullRequestReview(getClient, t)) + s.AddTool(CreatePullRequest(getClient, t)) + s.AddTool(UpdatePullRequest(getClient, t)) + s.AddTool(AddPullRequestReviewComment(getClient, t)) + } + + // Add GitHub tools - Repositories + s.AddTool(SearchRepositories(getClient, t)) + 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)) + s.AddTool(ForkRepository(getClient, t)) + s.AddTool(CreateBranch(getClient, t)) + s.AddTool(PushFiles(getClient, t)) + } + + // Add GitHub tools - Search + s.AddTool(SearchCode(getClient, t)) + s.AddTool(SearchUsers(getClient, t)) + + // Add GitHub tools - Users + s.AddTool(GetMe(getClient, t)) + + // Add GitHub tools - Code Scanning + s.AddTool(GetCodeScanningAlert(getClient, t)) + s.AddTool(ListCodeScanningAlerts(getClient, t)) + return s }