diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 638713c700..5e579aaafd 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -46,7 +46,7 @@ jobs: # https://github.com/sigstore/cosign-installer - name: Install cosign if: github.event_name != 'pull_request' - uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 #v4.1.0 + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 #v4.1.2 with: cosign-release: "v2.2.4" @@ -60,7 +60,7 @@ jobs: # https://github.com/docker/login-action - name: Log into registry ${{ env.REGISTRY }} if: github.event_name != 'pull_request' - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index f8eddc076c..4ce1c9d341 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -35,7 +35,7 @@ jobs: run: go mod download - name: Run GoReleaser - uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a + uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 with: distribution: goreleaser # GoReleaser version diff --git a/Dockerfile b/Dockerfile index 5036ba8b9d..70603aed03 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ COPY ui/ ./ui/ RUN mkdir -p ./pkg/github/ui_dist && \ cd ui && npm run build -FROM golang:1.25.9-alpine@sha256:5caaf1cca9dc351e13deafbc3879fd4754801acba8653fa9540cea125d01a71f AS build +FROM golang:1.25.10-alpine@sha256:8d22e29d960bc50cd025d93d5b7c7d220b1ee9aa7a239b3c8f55a57e987e8d45 AS build ARG VERSION="dev" # Set the working directory diff --git a/README.md b/README.md index 5f9baa780e..1030f83ca0 100644 --- a/README.md +++ b/README.md @@ -730,6 +730,23 @@ The following sets of tools are available: comment-discussion Discussions +- **discussion_comment_write** - Manage discussion comments + - **Required OAuth Scopes**: `repo` + - `body`: Comment content (required for 'add', 'reply', and 'update' methods) (string, optional) + - `commentNodeID`: The Node ID of the discussion comment (required for 'reply', 'update', 'delete', 'mark_answer', and 'unmark_answer' methods). For 'reply', this is the top-level comment to reply to; GitHub Discussions only support one level of nesting. (string, optional) + - `discussionNumber`: Discussion number (required for 'add' and 'reply' methods) (number, optional) + - `method`: Write operation to perform on a discussion comment. + Options are: + - 'add' - adds a new top-level comment to a discussion. + - 'reply' - replies to a top-level discussion comment (GitHub Discussions only support one level of nesting). + - 'update' - updates an existing discussion comment. + - 'delete' - deletes a discussion comment. + - 'mark_answer' - marks a discussion comment as the answer (Q&A only). + - 'unmark_answer' - unmarks a discussion comment as the answer (Q&A only). + (string, required) + - `owner`: Repository owner (required for 'add' and 'reply' methods) (string, optional) + - `repo`: Repository name (required for 'add' and 'reply' methods) (string, optional) + - **get_discussion** - Get discussion - **Required OAuth Scopes**: `repo` - `discussionNumber`: Discussion Number (number, required) @@ -740,6 +757,7 @@ The following sets of tools are available: - **Required OAuth Scopes**: `repo` - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) - `discussionNumber`: Discussion Number (number, required) + - `includeReplies`: When true, each top-level comment will include its replies nested within it (up to 100 replies per comment, which is the GitHub API maximum). Defaults to false. (boolean, optional) - `owner`: Repository owner (string, required) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) @@ -1100,7 +1118,7 @@ The following sets of tools are available: 3. get_status - Get combined commit status of a head commit in a pull request. 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned. 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results. - 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. + 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. Use with pagination parameters to control the number of results returned. 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned. 8. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR. (string, required) @@ -1256,6 +1274,14 @@ The following sets of tools are available: - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) +- **list_repository_collaborators** - List repository collaborators + - **Required OAuth Scopes**: `repo` + - `affiliation`: Filter by affiliation. Can be one of: 'outside' (outside collaborators), 'direct' (all with permissions regardless of org membership), 'all' (all collaborators). Default: 'all' (string, optional) + - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (default 1, min 1) (number, optional) + - `perPage`: Results per page for pagination (default 30, min 1, max 100) (number, optional) + - `repo`: Repository name (string, required) + - **list_tags** - List tags - **Required OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) @@ -1413,6 +1439,11 @@ The following sets of tools are available: Copilot Spaces +- **Authentication note** + - Fine-grained PATs are not hidden by classic PAT scope filtering, so these tools may still appear even when the token cannot use them. + - For org-owned spaces, fine-grained PATs must be installed on the owning organization and include `organization_copilot_spaces: read`. + - If an org-owned space contains repository-backed resources, the token must also have access to every referenced repository or the space may be treated as not found. + - **get_copilot_space** - Get Copilot Space - `owner`: The owner of the space. (string, required) - `name`: The name of the space. (string, required) diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index ad40ecad02..73d5f271c9 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -18,7 +18,7 @@ import ( "github.com/github/github-mcp-server/internal/ghmcp" "github.com/github/github-mcp-server/pkg/github" "github.com/github/github-mcp-server/pkg/translations" - gogithub "github.com/google/go-github/v82/github" + gogithub "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" ) diff --git a/go.mod b/go.mod index 89cafc377d..3d7ad06a58 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.0 require ( github.com/go-chi/chi/v5 v5.2.5 github.com/go-viper/mapstructure/v2 v2.5.0 - github.com/google/go-github/v82 v82.0.0 + github.com/google/go-github/v87 v87.0.0 github.com/google/jsonschema-go v0.4.2 github.com/josephburnett/jd/v2 v2.5.0 github.com/lithammer/fuzzysearch v1.1.8 diff --git a/go.sum b/go.sum index 615b4e9c0c..defedd4819 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-github/v82 v82.0.0 h1:OH09ESON2QwKCUVMYmMcVu1IFKFoaZHwqYaUtr/MVfk= -github.com/google/go-github/v82 v82.0.0/go.mod h1:hQ6Xo0VKfL8RZ7z1hSfB4fvISg0QqHOqe9BP0qo+WvM= +github.com/google/go-github/v87 v87.0.0 h1:9Ck3dcOxWJyfsN8tzdah4YvmqB/7ZsstMglv/PkOsl0= +github.com/google/go-github/v87 v87.0.0/go.mod h1:hGUoT5pwm/ck5uLL+wroSVQfg8mpe+buxllCcGV4VaM= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index b1925bffd3..6c8c3934d5 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -24,18 +24,19 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - gogithub "github.com/google/go-github/v82/github" + gogithub "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" ) // githubClients holds all the GitHub API clients created for a server instance. type githubClients struct { - rest *gogithub.Client - gql *githubv4.Client - gqlHTTP *http.Client // retained for middleware to modify transport - raw *raw.Client - repoAccess *lockdown.RepoAccessCache + rest *gogithub.Client + restUATransp *transport.UserAgentTransport + gql *githubv4.Client + gqlHTTP *http.Client // retained for middleware to modify transport + raw *raw.Client + repoAccess *lockdown.RepoAccessCache } // createGitHubClients creates all the GitHub API clients needed by the server. @@ -61,10 +62,18 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv } // Construct REST client - restClient := gogithub.NewClient(nil).WithAuthToken(cfg.Token) - restClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", cfg.Version) - restClient.BaseURL = restURL - restClient.UploadURL = uploadURL + restUATransport := &transport.UserAgentTransport{ + Transport: http.DefaultTransport, + Agent: fmt.Sprintf("github-mcp-server/%s", cfg.Version), + } + restClient, err := gogithub.NewClient( + gogithub.WithHTTPClient(&http.Client{Transport: restUATransport}), + gogithub.WithAuthToken(cfg.Token), + gogithub.WithEnterpriseURLs(restURL.String(), uploadURL.String()), + ) + if err != nil { + return nil, fmt.Errorf("failed to create REST client: %w", err) + } // Construct GraphQL client // We use NewEnterpriseClient unconditionally since we already parsed the API host @@ -80,7 +89,10 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv gqlClient := githubv4.NewEnterpriseClient(graphQLURL.String(), gqlHTTPClient) // Create raw content client (shares REST client's HTTP transport) - rawClient := raw.NewClient(restClient, rawURL) + rawClient, err := raw.NewClient(restClient, rawURL) + if err != nil { + return nil, fmt.Errorf("failed to create raw client: %w", err) + } // Set up repo access cache for lockdown mode var repoAccessCache *lockdown.RepoAccessCache @@ -95,11 +107,12 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv } return &githubClients{ - rest: restClient, - gql: gqlClient, - gqlHTTP: gqlHTTPClient, - raw: rawClient, - repoAccess: repoAccessCache, + rest: restClient, + restUATransp: restUATransport, + gql: gqlClient, + gqlHTTP: gqlHTTPClient, + raw: rawClient, + repoAccess: repoAccessCache, }, nil } @@ -170,7 +183,7 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se github.RegisterUIResources(ghServer) } - ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg, clients.rest, clients.gqlHTTP)) + ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg, clients.restUATransp, clients.gqlHTTP)) return ghServer, nil } @@ -345,7 +358,7 @@ func createFeatureChecker(enabledFeatures []string, insidersMode bool) inventory } } -func addUserAgentsMiddleware(cfg github.MCPServerConfig, restClient *gogithub.Client, gqlHTTPClient *http.Client) func(next mcp.MethodHandler) mcp.MethodHandler { +func addUserAgentsMiddleware(cfg github.MCPServerConfig, restUATransp *transport.UserAgentTransport, gqlHTTPClient *http.Client) func(next mcp.MethodHandler) mcp.MethodHandler { return func(next mcp.MethodHandler) mcp.MethodHandler { return func(ctx context.Context, method string, request mcp.Request) (result mcp.Result, err error) { if method != "initialize" { @@ -368,7 +381,7 @@ func addUserAgentsMiddleware(cfg github.MCPServerConfig, restClient *gogithub.Cl userAgent += " (insiders)" } - restClient.UserAgent = userAgent + restUATransp.Agent = userAgent gqlHTTPClient.Transport = &transport.UserAgentTransport{ Transport: gqlHTTPClient.Transport, diff --git a/pkg/errors/error.go b/pkg/errors/error.go index d757651592..7c1f28e660 100644 --- a/pkg/errors/error.go +++ b/pkg/errors/error.go @@ -6,7 +6,7 @@ import ( "net/http" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/errors/error_test.go b/pkg/errors/error_test.go index e33d5bd39e..7459569f2a 100644 --- a/pkg/errors/error_test.go +++ b/pkg/errors/error_test.go @@ -6,7 +6,7 @@ import ( "net/http" "testing" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/github/__toolsnaps__/discussion_comment_write.snap b/pkg/github/__toolsnaps__/discussion_comment_write.snap new file mode 100644 index 0000000000..5edadfaeaa --- /dev/null +++ b/pkg/github/__toolsnaps__/discussion_comment_write.snap @@ -0,0 +1,48 @@ +{ + "annotations": { + "destructiveHint": true, + "title": "Manage discussion comments" + }, + "description": "Write operations for discussion comments.\nSupports adding top-level comments, replying to existing comments, updating comment content, deleting comments, and marking or unmarking comments as the answer.", + "inputSchema": { + "properties": { + "body": { + "description": "Comment content (required for 'add', 'reply', and 'update' methods)", + "type": "string" + }, + "commentNodeID": { + "description": "The Node ID of the discussion comment (required for 'reply', 'update', 'delete', 'mark_answer', and 'unmark_answer' methods). For 'reply', this is the top-level comment to reply to; GitHub Discussions only support one level of nesting.", + "type": "string" + }, + "discussionNumber": { + "description": "Discussion number (required for 'add' and 'reply' methods)", + "type": "number" + }, + "method": { + "description": "Write operation to perform on a discussion comment.\nOptions are:\n- 'add' - adds a new top-level comment to a discussion.\n- 'reply' - replies to a top-level discussion comment (GitHub Discussions only support one level of nesting).\n- 'update' - updates an existing discussion comment.\n- 'delete' - deletes a discussion comment.\n- 'mark_answer' - marks a discussion comment as the answer (Q\u0026A only).\n- 'unmark_answer' - unmarks a discussion comment as the answer (Q\u0026A only).\n", + "enum": [ + "add", + "reply", + "update", + "delete", + "mark_answer", + "unmark_answer" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner (required for 'add' and 'reply' methods)", + "type": "string" + }, + "repo": { + "description": "Repository name (required for 'add' and 'reply' methods)", + "type": "string" + } + }, + "required": [ + "method" + ], + "type": "object" + }, + "name": "discussion_comment_write" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_discussion_comments.snap b/pkg/github/__toolsnaps__/get_discussion_comments.snap index f9e6095650..422fc40bf7 100644 --- a/pkg/github/__toolsnaps__/get_discussion_comments.snap +++ b/pkg/github/__toolsnaps__/get_discussion_comments.snap @@ -14,6 +14,10 @@ "description": "Discussion Number", "type": "number" }, + "includeReplies": { + "description": "When true, each top-level comment will include its replies nested within it (up to 100 replies per comment, which is the GitHub API maximum). Defaults to false.", + "type": "boolean" + }, "owner": { "description": "Repository owner", "type": "string" diff --git a/pkg/github/__toolsnaps__/list_repository_collaborators.snap b/pkg/github/__toolsnaps__/list_repository_collaborators.snap new file mode 100644 index 0000000000..629e4bdf1c --- /dev/null +++ b/pkg/github/__toolsnaps__/list_repository_collaborators.snap @@ -0,0 +1,45 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "List repository collaborators" + }, + "description": "List collaborators of a GitHub repository. Results are paginated; the response includes `nextPage`, `prevPage`, `firstPage`, and `lastPage` fields. To get the next page, use the `nextPage` value as the `page` parameter.", + "inputSchema": { + "properties": { + "affiliation": { + "description": "Filter by affiliation. Can be one of: 'outside' (outside collaborators), 'direct' (all with permissions regardless of org membership), 'all' (all collaborators). Default: 'all'", + "enum": [ + "outside", + "direct", + "all" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "page": { + "description": "Page number for pagination (default 1, min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (default 30, min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "name": "list_repository_collaborators" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/pull_request_read.snap b/pkg/github/__toolsnaps__/pull_request_read.snap index 9bb14cc076..26b4f14ca9 100644 --- a/pkg/github/__toolsnaps__/pull_request_read.snap +++ b/pkg/github/__toolsnaps__/pull_request_read.snap @@ -7,7 +7,7 @@ "inputSchema": { "properties": { "method": { - "description": "Action to specify what pull request data needs to be retrieved from GitHub. \nPossible options: \n 1. get - Get details of a specific pull request.\n 2. get_diff - Get the diff of a pull request.\n 3. get_status - Get combined commit status of a head commit in a pull request.\n 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.\n 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results.\n 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method.\n 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned.\n 8. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR.\n", + "description": "Action to specify what pull request data needs to be retrieved from GitHub. \nPossible options: \n 1. get - Get details of a specific pull request.\n 2. get_diff - Get the diff of a pull request.\n 3. get_status - Get combined commit status of a head commit in a pull request.\n 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.\n 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results.\n 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. Use with pagination parameters to control the number of results returned.\n 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned.\n 8. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR.\n", "enum": [ "get", "get_diff", diff --git a/pkg/github/__toolsnaps__/update_issue_type.snap b/pkg/github/__toolsnaps__/update_issue_type.snap index 6354a42e16..237603a6ed 100644 --- a/pkg/github/__toolsnaps__/update_issue_type.snap +++ b/pkg/github/__toolsnaps__/update_issue_type.snap @@ -20,6 +20,11 @@ "description": "Repository owner (username or organization)", "type": "string" }, + "rationale": { + "description": "One concise sentence explaining what specifically about the issue led you to choose this type. State the concrete signal (e.g. 'Reports a crash when saving' → bug, 'Asks for dark mode support' → feature).", + "maxLength": 280, + "type": "string" + }, "repo": { "description": "Repository name", "type": "string" diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 85afed6e1b..a7ce039d83 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -16,7 +16,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -989,10 +989,10 @@ func runWorkflow(ctx context.Context, client *github.Client, owner, repo, workfl var workflowType string if workflowIDInt, parseErr := strconv.ParseInt(workflowID, 10, 64); parseErr == nil { - resp, err = client.Actions.CreateWorkflowDispatchEventByID(ctx, owner, repo, workflowIDInt, event) + _, resp, err = client.Actions.CreateWorkflowDispatchEventByID(ctx, owner, repo, workflowIDInt, event) workflowType = "workflow_id" } else { - resp, err = client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowID, event) + _, resp, err = client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowID, event) workflowType = "workflow_file" } diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 6eba71b8b3..371bbbe9dc 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -86,7 +86,7 @@ func Test_ActionsList_ListWorkflows(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -136,7 +136,7 @@ func Test_ActionsList_ListWorkflowRuns(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -185,7 +185,7 @@ func Test_ActionsList_ListWorkflowRuns(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -241,7 +241,7 @@ func Test_ActionsGet_GetWorkflow(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -284,7 +284,7 @@ func Test_ActionsGet_GetWorkflowRun(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -412,7 +412,7 @@ func Test_ActionsRunTrigger_RunWorkflow(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -449,7 +449,7 @@ func Test_ActionsRunTrigger_CancelWorkflowRun(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -480,7 +480,7 @@ func Test_ActionsRunTrigger_CancelWorkflowRun(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -504,7 +504,7 @@ func Test_ActionsRunTrigger_CancelWorkflowRun(t *testing.T) { t.Run("missing run_id for non-run_workflow methods", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -556,7 +556,7 @@ func Test_ActionsGetJobLogs_SingleJob(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, ContentWindowSize: 5000, @@ -618,7 +618,7 @@ func Test_ActionsGetJobLogs_FailedJobs(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, ContentWindowSize: 5000, @@ -668,7 +668,7 @@ func Test_ActionsGetJobLogs_FailedJobs(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, ContentWindowSize: 5000, diff --git a/pkg/github/code_scanning.go b/pkg/github/code_scanning.go index 34249b2129..2deefd321c 100644 --- a/pkg/github/code_scanning.go +++ b/pkg/github/code_scanning.go @@ -11,7 +11,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/code_scanning_test.go b/pkg/github/code_scanning_test.go index 7a3c16fd15..64c61766ed 100644 --- a/pkg/github/code_scanning_test.go +++ b/pkg/github/code_scanning_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -80,7 +80,7 @@ func Test_GetCodeScanningAlert(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -206,7 +206,7 @@ func Test_ListCodeScanningAlerts(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index 365a019ab6..2b17be86d1 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -10,7 +10,7 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -99,7 +99,7 @@ func Test_GetMe(t *testing.T) { deps = stubDeps{clientFn: stubClientFnErr(tc.clientErr), obsv: stubExporters()} } else { obs := stubExporters() - deps = BaseDeps{Client: github.NewClient(tc.mockedClient), Obsv: obs} + deps = BaseDeps{Client: mustNewGHClient(t, tc.mockedClient), Obsv: obs} } handler := serverTool.Handler(deps) @@ -155,7 +155,7 @@ func Test_GetMe_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode disabled omits ifc label from result meta", func(t *testing.T) { deps := BaseDeps{ - Client: github.NewClient(mockedHTTPClient), + Client: mustNewGHClient(t, mockedHTTPClient), Flags: FeatureFlags{InsidersMode: false}, } handler := serverTool.Handler(deps) @@ -170,7 +170,7 @@ func Test_GetMe_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode enabled includes ifc label in result meta", func(t *testing.T) { deps := BaseDeps{ - Client: github.NewClient(mockedHTTPClient), + Client: mustNewGHClient(t, mockedHTTPClient), Flags: FeatureFlags{InsidersMode: true}, } handler := serverTool.Handler(deps) @@ -192,10 +192,7 @@ func Test_GetMe_IFC_InsidersMode(t *testing.T) { require.NoError(t, err) assert.Equal(t, "trusted", ifcMap["integrity"]) - confList, ok := ifcMap["confidentiality"].([]any) - require.True(t, ok, "confidentiality should be a list") - require.Len(t, confList, 1) - assert.Equal(t, "public", confList[0]) + assert.Equal(t, "public", ifcMap["confidentiality"]) }) } @@ -329,7 +326,7 @@ func Test_GetTeams(t *testing.T) { name: "successful get teams", makeDeps: func() ToolDependencies { return BaseDeps{ - Client: github.NewClient(httpClientWithUser()), + Client: mustNewGHClient(t, httpClientWithUser()), GQLClient: gqlClientForTestuser(), } }, @@ -354,7 +351,7 @@ func Test_GetTeams(t *testing.T) { name: "no teams found", makeDeps: func() ToolDependencies { return BaseDeps{ - Client: github.NewClient(httpClientWithUser()), + Client: mustNewGHClient(t, httpClientWithUser()), GQLClient: gqlClientNoTeams(), } }, @@ -375,7 +372,7 @@ func Test_GetTeams(t *testing.T) { name: "get user fails", makeDeps: func() ToolDependencies { return BaseDeps{ - Client: github.NewClient(httpClientUserFails()), + Client: mustNewGHClient(t, httpClientUserFails()), Obsv: stubExporters(), } }, @@ -387,7 +384,7 @@ func Test_GetTeams(t *testing.T) { name: "getting GraphQL client fails", makeDeps: func() ToolDependencies { return stubDeps{ - clientFn: stubClientFnFromHTTP(httpClientWithUser()), + clientFn: stubClientFnFromHTTP(t, httpClientWithUser()), gqlClientFn: stubGQLClientFnErr("GraphQL client error"), obsv: stubExporters(), } diff --git a/pkg/github/copilot.go b/pkg/github/copilot.go index d95357e738..017bb98bc9 100644 --- a/pkg/github/copilot.go +++ b/pkg/github/copilot.go @@ -17,7 +17,7 @@ import ( "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/go-viper/mapstructure/v2" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/copilot_test.go b/pkg/github/copilot_test.go index 0a1d5ef3b6..b86f26f474 100644 --- a/pkg/github/copilot_test.go +++ b/pkg/github/copilot_test.go @@ -10,7 +10,7 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -932,7 +932,7 @@ func Test_RequestCopilotReview(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := RequestCopilotReview(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, diff --git a/pkg/github/dependabot.go b/pkg/github/dependabot.go index 541cc5c1e7..ccb36f4839 100644 --- a/pkg/github/dependabot.go +++ b/pkg/github/dependabot.go @@ -12,7 +12,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/dependabot_test.go b/pkg/github/dependabot_test.go index 6c9b95ca36..2196b6b13f 100644 --- a/pkg/github/dependabot_test.go +++ b/pkg/github/dependabot_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -89,7 +89,7 @@ func Test_GetDependabotAlert(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} handler := toolDef.Handler(deps) @@ -243,7 +243,7 @@ func Test_ListDependabotAlerts(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} handler := toolDef.Handler(deps) diff --git a/pkg/github/dependencies.go b/pkg/github/dependencies.go index aad213e4e5..16be84efb4 100644 --- a/pkg/github/dependencies.go +++ b/pkg/github/dependencies.go @@ -18,7 +18,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - gogithub "github.com/google/go-github/v82/github" + gogithub "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" ) @@ -320,10 +320,14 @@ func (d *RequestDeps) GetClient(ctx context.Context) (*gogithub.Client, error) { } // Construct REST client - restClient := gogithub.NewClient(nil).WithAuthToken(token) - restClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", d.version) - restClient.BaseURL = baseRestURL - restClient.UploadURL = uploadURL + restClient, err := gogithub.NewClient( + gogithub.WithAuthToken(token), + gogithub.WithUserAgent(fmt.Sprintf("github-mcp-server/%s", d.version)), + gogithub.WithEnterpriseURLs(baseRestURL.String(), uploadURL.String()), + ) + if err != nil { + return nil, fmt.Errorf("failed to create REST client: %w", err) + } return restClient, nil } @@ -370,7 +374,10 @@ func (d *RequestDeps) GetRawClient(ctx context.Context) (*raw.Client, error) { return nil, fmt.Errorf("failed to get Raw URL: %w", err) } - rawClient := raw.NewClient(client, rawURL) + rawClient, err := raw.NewClient(client, rawURL) + if err != nil { + return nil, fmt.Errorf("failed to create raw client: %w", err) + } return rawClient, nil } diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index 700560b475..514a2d030d 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -4,13 +4,14 @@ import ( "context" "encoding/json" "fmt" + "strings" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/go-viper/mapstructure/v2" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" @@ -405,6 +406,10 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve Type: "number", Description: "Discussion Number", }, + "includeReplies": { + Type: "boolean", + Description: "When true, each top-level comment will include its replies nested within it (up to 100 replies per comment, which is the GitHub API maximum). Defaults to false.", + }, }, Required: []string{"owner", "repo", "discussionNumber"}, }), @@ -421,6 +426,11 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve return utils.NewToolResultError(err.Error()), nil, nil } + includeReplies, err := OptionalParam[bool](args, "includeReplies") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + // Get pagination parameters and convert to GraphQL format pagination, err := OptionalCursorPaginationParams(args) if err != nil { @@ -447,24 +457,6 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil } - var q struct { - Repository struct { - Discussion struct { - Comments struct { - Nodes []struct { - Body githubv4.String - } - PageInfo struct { - HasNextPage githubv4.Boolean - HasPreviousPage githubv4.Boolean - StartCursor githubv4.String - EndCursor githubv4.String - } - TotalCount int - } `graphql:"comments(first: $first, after: $after)"` - } `graphql:"discussion(number: $discussionNumber)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - } vars := map[string]any{ "owner": githubv4.String(params.Owner), "repo": githubv4.String(params.Repo), @@ -476,25 +468,111 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve } else { vars["after"] = (*githubv4.String)(nil) } - if err := client.Query(ctx, &q, vars); err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + + var comments []MinimalDiscussionComment + var pageInfo struct { + HasNextPage githubv4.Boolean + HasPreviousPage githubv4.Boolean + StartCursor githubv4.String + EndCursor githubv4.String } + var totalCount int - var comments []*github.IssueComment - for _, c := range q.Repository.Discussion.Comments.Nodes { - comments = append(comments, &github.IssueComment{Body: github.Ptr(string(c.Body))}) + if includeReplies { + var q struct { + Repository struct { + Discussion struct { + Comments struct { + Nodes []struct { + ID githubv4.ID + Body githubv4.String + IsAnswer githubv4.Boolean + Replies struct { + Nodes []struct { + ID githubv4.ID + Body githubv4.String + IsAnswer githubv4.Boolean + } + TotalCount int + } `graphql:"replies(first: 100)"` + } + PageInfo struct { + HasNextPage githubv4.Boolean + HasPreviousPage githubv4.Boolean + StartCursor githubv4.String + EndCursor githubv4.String + } + TotalCount int + } `graphql:"comments(first: $first, after: $after)"` + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + if err := client.Query(ctx, &q, vars); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + for _, c := range q.Repository.Discussion.Comments.Nodes { + comment := MinimalDiscussionComment{ + ID: fmt.Sprintf("%v", c.ID), + Body: string(c.Body), + IsAnswer: bool(c.IsAnswer), + ReplyTotalCount: c.Replies.TotalCount, + } + for _, r := range c.Replies.Nodes { + comment.Replies = append(comment.Replies, MinimalDiscussionComment{ + ID: fmt.Sprintf("%v", r.ID), + Body: string(r.Body), + IsAnswer: bool(r.IsAnswer), + }) + } + comments = append(comments, comment) + } + pageInfo = q.Repository.Discussion.Comments.PageInfo + totalCount = q.Repository.Discussion.Comments.TotalCount + } else { + var q struct { + Repository struct { + Discussion struct { + Comments struct { + Nodes []struct { + ID githubv4.ID + Body githubv4.String + IsAnswer githubv4.Boolean + } + PageInfo struct { + HasNextPage githubv4.Boolean + HasPreviousPage githubv4.Boolean + StartCursor githubv4.String + EndCursor githubv4.String + } + TotalCount int + } `graphql:"comments(first: $first, after: $after)"` + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + if err := client.Query(ctx, &q, vars); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + for _, c := range q.Repository.Discussion.Comments.Nodes { + comments = append(comments, MinimalDiscussionComment{ + ID: fmt.Sprintf("%v", c.ID), + Body: string(c.Body), + IsAnswer: bool(c.IsAnswer), + }) + } + pageInfo = q.Repository.Discussion.Comments.PageInfo + totalCount = q.Repository.Discussion.Comments.TotalCount } // Create response with pagination info response := map[string]any{ "comments": comments, "pageInfo": map[string]any{ - "hasNextPage": q.Repository.Discussion.Comments.PageInfo.HasNextPage, - "hasPreviousPage": q.Repository.Discussion.Comments.PageInfo.HasPreviousPage, - "startCursor": string(q.Repository.Discussion.Comments.PageInfo.StartCursor), - "endCursor": string(q.Repository.Discussion.Comments.PageInfo.EndCursor), + "hasNextPage": pageInfo.HasNextPage, + "hasPreviousPage": pageInfo.HasPreviousPage, + "startCursor": string(pageInfo.StartCursor), + "endCursor": string(pageInfo.EndCursor), }, - "totalCount": q.Repository.Discussion.Comments.TotalCount, + "totalCount": totalCount, } out, err := json.Marshal(response) @@ -507,6 +585,409 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve ) } +func DiscussionCommentWrite(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataDiscussions, + mcp.Tool{ + Name: "discussion_comment_write", + Description: t("TOOL_DISCUSSION_COMMENT_WRITE_DESCRIPTION", `Write operations for discussion comments. +Supports adding top-level comments, replying to existing comments, updating comment content, deleting comments, and marking or unmarking comments as the answer.`), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_DISCUSSION_COMMENT_WRITE_USER_TITLE", "Manage discussion comments"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: `Write operation to perform on a discussion comment. +Options are: +- 'add' - adds a new top-level comment to a discussion. +- 'reply' - replies to a top-level discussion comment (GitHub Discussions only support one level of nesting). +- 'update' - updates an existing discussion comment. +- 'delete' - deletes a discussion comment. +- 'mark_answer' - marks a discussion comment as the answer (Q&A only). +- 'unmark_answer' - unmarks a discussion comment as the answer (Q&A only). +`, + Enum: []any{"add", "reply", "update", "delete", "mark_answer", "unmark_answer"}, + }, + "owner": { + Type: "string", + Description: "Repository owner (required for 'add' and 'reply' methods)", + }, + "repo": { + Type: "string", + Description: "Repository name (required for 'add' and 'reply' methods)", + }, + "discussionNumber": { + Type: "number", + Description: "Discussion number (required for 'add' and 'reply' methods)", + }, + "body": { + Type: "string", + Description: "Comment content (required for 'add', 'reply', and 'update' methods)", + }, + "commentNodeID": { + Type: "string", + Description: "The Node ID of the discussion comment (required for 'reply', 'update', 'delete', 'mark_answer', and 'unmark_answer' methods). For 'reply', this is the top-level comment to reply to; GitHub Discussions only support one level of nesting.", + }, + }, + Required: []string{"method"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil + } + + switch method { + case "add": + return addDiscussionComment(ctx, client, args) + case "reply": + return replyToDiscussionComment(ctx, client, args) + case "update": + return updateDiscussionComment(ctx, client, args) + case "delete": + return deleteDiscussionComment(ctx, client, args) + case "mark_answer": + return markDiscussionCommentAsAnswer(ctx, client, args) + case "unmark_answer": + return unmarkDiscussionCommentAsAnswer(ctx, client, args) + default: + return utils.NewToolResultError("invalid method, must be one of: 'add', 'reply', 'update', 'delete', 'mark_answer', 'unmark_answer'"), nil, nil + } + }) +} + +func addDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + discussionNumber, err := RequiredInt(args, "discussionNumber") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + body, err := RequiredParam[string](args, "body") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + // Get the discussion's node ID using its number + var q struct { + Repository struct { + Discussion struct { + ID githubv4.ID + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "discussionNumber": githubv4.Int(discussionNumber), // #nosec G115 - discussion numbers are always small positive integers + } + if err := client.Query(ctx, &q, vars); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + input := githubv4.AddDiscussionCommentInput{ + DiscussionID: q.Repository.Discussion.ID, + Body: githubv4.String(body), + } + + var mutation struct { + AddDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"addDiscussionComment(input: $input)"` + } + + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + comment := mutation.AddDiscussionComment.Comment + out, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%v", comment.ID), + URL: string(comment.URL), + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal comment: %w", err) + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func requiredCommentNodeID(args map[string]any) (string, error) { + commentNodeID, err := RequiredParam[string](args, "commentNodeID") + if err != nil { + return "", err + } + if strings.TrimSpace(commentNodeID) == "" { + return "", fmt.Errorf("commentNodeID cannot be blank") + } + return commentNodeID, nil +} + +func replyToDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + commentNodeID, err := requiredCommentNodeID(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + discussionNumber, err := RequiredInt(args, "discussionNumber") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + body, err := RequiredParam[string](args, "body") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + // The GitHub API silently ignores an invalid ReplyToID and creates a top-level + // comment instead of returning an error, so we validate upfront that the node + // exists and is a DiscussionComment to give callers a clear failure. + var nodeQuery struct { + Node struct { + DiscussionComment struct { + ID *githubv4.ID + Discussion struct { + ID githubv4.ID + } `graphql:"discussion"` + } `graphql:"... on DiscussionComment"` + } `graphql:"node(id: $replyToID)"` + } + if err := client.Query(ctx, &nodeQuery, map[string]any{ + "replyToID": githubv4.ID(commentNodeID), + }); err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to validate commentNodeID: %v", err)), nil, nil + } + if nodeQuery.Node.DiscussionComment.ID == nil || *nodeQuery.Node.DiscussionComment.ID == "" { + return utils.NewToolResultError(fmt.Sprintf("commentNodeID %q does not resolve to a valid discussion comment", commentNodeID)), nil, nil + } + + // Get the discussion's node ID using its number + var q struct { + Repository struct { + Discussion struct { + ID githubv4.ID + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "discussionNumber": githubv4.Int(discussionNumber), // #nosec G115 - discussion numbers are always small positive integers + } + if err := client.Query(ctx, &q, vars); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if nodeQuery.Node.DiscussionComment.Discussion.ID != q.Repository.Discussion.ID { + return utils.NewToolResultError( + fmt.Sprintf("commentNodeID %q does not belong to discussion #%d in %s/%s", commentNodeID, discussionNumber, owner, repo), + ), nil, nil + } + + replyToID := githubv4.ID(commentNodeID) + input := githubv4.AddDiscussionCommentInput{ + DiscussionID: nodeQuery.Node.DiscussionComment.Discussion.ID, + Body: githubv4.String(body), + ReplyToID: &replyToID, + } + + var mutation struct { + AddDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"addDiscussionComment(input: $input)"` + } + + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + comment := mutation.AddDiscussionComment.Comment + out, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%v", comment.ID), + URL: string(comment.URL), + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal comment: %w", err) + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func updateDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + commentNodeID, err := requiredCommentNodeID(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + body, err := RequiredParam[string](args, "body") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + input := githubv4.UpdateDiscussionCommentInput{ + CommentID: githubv4.ID(commentNodeID), + Body: githubv4.String(body), + } + + var mutation struct { + UpdateDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"updateDiscussionComment(input: $input)"` + } + + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + comment := mutation.UpdateDiscussionComment.Comment + out, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%v", comment.ID), + URL: string(comment.URL), + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal comment: %w", err) + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func deleteDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + commentNodeID, err := requiredCommentNodeID(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + input := githubv4.DeleteDiscussionCommentInput{ + ID: githubv4.ID(commentNodeID), + } + + var mutation struct { + DeleteDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"deleteDiscussionComment(input: $input)"` + } + + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + comment := mutation.DeleteDiscussionComment.Comment + out, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%v", comment.ID), + URL: string(comment.URL), + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal comment: %w", err) + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func markDiscussionCommentAsAnswer(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + commentNodeID, err := requiredCommentNodeID(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + input := githubv4.MarkDiscussionCommentAsAnswerInput{ + ID: githubv4.ID(commentNodeID), + } + var mutation struct { + MarkDiscussionCommentAsAnswer struct { + Discussion struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"markDiscussionCommentAsAnswer(input: $input)"` + } + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + out, err := json.Marshal(struct { + DiscussionID string `json:"discussionID"` + DiscussionURL string `json:"discussionURL"` + }{ + DiscussionID: fmt.Sprintf("%v", mutation.MarkDiscussionCommentAsAnswer.Discussion.ID), + DiscussionURL: string(mutation.MarkDiscussionCommentAsAnswer.Discussion.URL), + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal discussion: %w", err) + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func unmarkDiscussionCommentAsAnswer(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + commentNodeID, err := requiredCommentNodeID(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + input := githubv4.UnmarkDiscussionCommentAsAnswerInput{ + ID: githubv4.ID(commentNodeID), + } + var mutation struct { + UnmarkDiscussionCommentAsAnswer struct { + Discussion struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"unmarkDiscussionCommentAsAnswer(input: $input)"` + } + if err := client.Mutate(ctx, &mutation, input, nil); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + out, err := json.Marshal(struct { + DiscussionID string `json:"discussionID"` + DiscussionURL string `json:"discussionURL"` + }{ + DiscussionID: fmt.Sprintf("%v", mutation.UnmarkDiscussionCommentAsAnswer.Discussion.ID), + DiscussionURL: string(mutation.UnmarkDiscussionCommentAsAnswer.Discussion.URL), + }) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal discussion: %w", err) + } + + return utils.NewToolResultText(string(out)), nil, nil +} + func ListDiscussionCategories(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataDiscussions, diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index 692ef2ec83..36fdb6c43a 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -9,7 +9,7 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -647,10 +647,11 @@ func Test_GetDiscussionComments(t *testing.T) { assert.Contains(t, schema.Properties, "owner") assert.Contains(t, schema.Properties, "repo") assert.Contains(t, schema.Properties, "discussionNumber") + assert.Contains(t, schema.Properties, "includeReplies") assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "discussionNumber"}) // Use exact string query that matches implementation output - qGetComments := "query($after:String$discussionNumber:Int!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){comments(first: $first, after: $after){nodes{body},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}}" + qGetComments := "query($after:String$discussionNumber:Int!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){comments(first: $first, after: $after){nodes{id,body,isAnswer},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}}" // Variables matching what GraphQL receives after JSON marshaling/unmarshaling vars := map[string]any{ @@ -666,8 +667,8 @@ func Test_GetDiscussionComments(t *testing.T) { "discussion": map[string]any{ "comments": map[string]any{ "nodes": []map[string]any{ - {"body": "This is the first comment"}, - {"body": "This is the second comment"}, + {"id": "DC_id1", "body": "This is the first comment"}, + {"id": "DC_id2", "body": "This is the second comment"}, }, "pageInfo": map[string]any{ "hasNextPage": false, @@ -701,7 +702,10 @@ func Test_GetDiscussionComments(t *testing.T) { // (Lines removed) var response struct { - Comments []*github.IssueComment `json:"comments"` + Comments []struct { + ID string `json:"id"` + Body string `json:"body"` + } `json:"comments"` PageInfo struct { HasNextPage bool `json:"hasNextPage"` HasPreviousPage bool `json:"hasPreviousPage"` @@ -713,17 +717,17 @@ func Test_GetDiscussionComments(t *testing.T) { err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) assert.Len(t, response.Comments, 2) - expectedBodies := []string{"This is the first comment", "This is the second comment"} - for i, comment := range response.Comments { - assert.Equal(t, expectedBodies[i], *comment.Body) - } + assert.Equal(t, "DC_id1", response.Comments[0].ID) + assert.Equal(t, "This is the first comment", response.Comments[0].Body) + assert.Equal(t, "DC_id2", response.Comments[1].ID) + assert.Equal(t, "This is the second comment", response.Comments[1].Body) } func Test_GetDiscussionCommentsWithStringNumber(t *testing.T) { // Test that WeakDecode handles string discussionNumber from MCP clients toolDef := GetDiscussionComments(translations.NullTranslationHelper) - qGetComments := "query($after:String$discussionNumber:Int!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){comments(first: $first, after: $after){nodes{body},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}}" + qGetComments := "query($after:String$discussionNumber:Int!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){comments(first: $first, after: $after){nodes{id,body,isAnswer},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}}" vars := map[string]any{ "owner": "owner", @@ -738,7 +742,7 @@ func Test_GetDiscussionCommentsWithStringNumber(t *testing.T) { "discussion": map[string]any{ "comments": map[string]any{ "nodes": []map[string]any{ - {"body": "First comment"}, + {"id": "DC_id3", "body": "First comment"}, }, "pageInfo": map[string]any{ "hasNextPage": false, @@ -777,6 +781,7 @@ func Test_GetDiscussionCommentsWithStringNumber(t *testing.T) { } require.NoError(t, json.Unmarshal([]byte(textContent.Text), &out)) assert.Len(t, out.Comments, 1) + assert.Equal(t, "DC_id3", out.Comments[0]["id"]) assert.Equal(t, "First comment", out.Comments[0]["body"]) } @@ -924,3 +929,896 @@ func Test_ListDiscussionCategories(t *testing.T) { }) } } + +func Test_DiscussionCommentWrite(t *testing.T) { + t.Parallel() + + toolDef := DiscussionCommentWrite(translations.NullTranslationHelper) + tool := toolDef.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "discussion_comment_write", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.False(t, tool.Annotations.ReadOnlyHint, "discussion_comment_write should not be read-only") + require.NotNil(t, tool.Annotations.DestructiveHint) + assert.True(t, *tool.Annotations.DestructiveHint, "discussion_comment_write should be destructive") + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.Contains(t, schema.Properties, "method") + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "discussionNumber") + assert.Contains(t, schema.Properties, "body") + assert.Contains(t, schema.Properties, "commentNodeID") + assert.ElementsMatch(t, schema.Required, []string{"method"}) + + runDiscussionCommentWriteTests(t, []discussionCommentWriteTestCase{ + { + name: "method: missing", + requestArgs: map[string]any{}, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "missing required parameter: method", + }, + { + name: "invalid method", + requestArgs: map[string]any{ + "method": "invalid", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "invalid method, must be one of: 'add', 'reply', 'update', 'delete', 'mark_answer', 'unmark_answer'", + }, + }) +} + +func Test_DiscussionCommentWrite_Add(t *testing.T) { + t.Parallel() + + discussionQueryMatcher := discussionCommentWriteDiscussionQueryMatcher( + 1, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussion": map[string]any{ + "id": "D_kwDOTest123", + }, + }, + }), + ) + + runDiscussionCommentWriteTests(t, []discussionCommentWriteTestCase{ + { + name: "add: successful comment creation", + requestArgs: map[string]any{ + "method": "add", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + "body": "This is a test comment", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + discussionQueryMatcher, + githubv4mock.NewMutationMatcher( + struct { + AddDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"addDiscussionComment(input: $input)"` + }{}, + githubv4.AddDiscussionCommentInput{ + DiscussionID: githubv4.ID("D_kwDOTest123"), + Body: githubv4.String("This is a test comment"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "addDiscussionComment": map[string]any{ + "comment": map[string]any{ + "id": "DC_kwDOComment456", + "url": "https://github.com/owner/repo/discussions/1#discussioncomment-456", + }, + }, + }), + ), + ), + expectedID: "DC_kwDOComment456", + expectedURL: "https://github.com/owner/repo/discussions/1#discussioncomment-456", + }, + { + name: "add: discussion not found", + requestArgs: map[string]any{ + "method": "add", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(999), + "body": "This is a comment", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Discussion struct { + ID githubv4.ID + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "discussionNumber": githubv4.Int(999), + }, + githubv4mock.ErrorResponse("Could not resolve to a Discussion with the number of 999."), + ), + ), + expectToolError: true, + expectedErrMsg: "Could not resolve to a Discussion with the number of 999.", + }, + { + name: "add: mutation failure", + requestArgs: map[string]any{ + "method": "add", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + "body": "This is a comment", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + discussionQueryMatcher, + githubv4mock.NewMutationMatcher( + struct { + AddDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"addDiscussionComment(input: $input)"` + }{}, + githubv4.AddDiscussionCommentInput{ + DiscussionID: githubv4.ID("D_kwDOTest123"), + Body: githubv4.String("This is a comment"), + }, + nil, + githubv4mock.ErrorResponse("insufficient permissions to comment on this discussion"), + ), + ), + expectToolError: true, + expectedErrMsg: "insufficient permissions to comment on this discussion", + }, + { + name: "add: missing body", + requestArgs: map[string]any{ + "method": "add", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "missing required parameter: body", + }, + }) +} + +func Test_DiscussionCommentWrite_Reply(t *testing.T) { + t.Parallel() + + discussionQueryMatcher := discussionCommentWriteDiscussionQueryMatcher( + 1, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussion": map[string]any{ + "id": "D_kwDOTest123", + }, + }, + }), + ) + + runDiscussionCommentWriteTests(t, []discussionCommentWriteTestCase{ + { + name: "reply: successful reply to comment", + requestArgs: map[string]any{ + "method": "reply", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + "body": "This is a reply", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + discussionCommentWriteReplyValidationQueryMatcher( + "DC_kwDOComment456", + githubv4mock.DataResponse(map[string]any{ + "node": map[string]any{ + "id": "DC_kwDOComment456", + "discussion": map[string]any{ + "id": "D_kwDOTest123", + }, + }, + }), + ), + discussionQueryMatcher, + githubv4mock.NewMutationMatcher( + struct { + AddDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"addDiscussionComment(input: $input)"` + }{}, + githubv4.AddDiscussionCommentInput{ + DiscussionID: githubv4.ID("D_kwDOTest123"), + Body: githubv4.String("This is a reply"), + ReplyToID: githubv4ptr("DC_kwDOComment456"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "addDiscussionComment": map[string]any{ + "comment": map[string]any{ + "id": "DC_kwDOReply789", + "url": "https://github.com/owner/repo/discussions/1#discussioncomment-789", + }, + }, + }), + ), + ), + expectedID: "DC_kwDOReply789", + expectedURL: "https://github.com/owner/repo/discussions/1#discussioncomment-789", + }, + { + name: "reply: missing commentNodeID", + requestArgs: map[string]any{ + "method": "reply", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + "body": "This is a reply", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "missing required parameter: commentNodeID", + }, + { + name: "reply: whitespace-only commentNodeID is rejected", + requestArgs: map[string]any{ + "method": "reply", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + "body": "This is a reply", + "commentNodeID": " ", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "commentNodeID cannot be blank", + }, + { + name: "reply: invalid commentNodeID returns error", + requestArgs: map[string]any{ + "method": "reply", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + "body": "This is a reply", + "commentNodeID": "DC_kwDOInvalid", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + discussionCommentWriteReplyValidationQueryMatcher( + "DC_kwDOInvalid", + githubv4mock.DataResponse(map[string]any{ + "node": nil, + }), + ), + ), + expectToolError: true, + expectedErrMsg: `commentNodeID "DC_kwDOInvalid" does not resolve to a valid discussion comment`, + }, + { + name: "reply: comment from another discussion is rejected", + requestArgs: map[string]any{ + "method": "reply", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + "body": "This is a reply", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + discussionCommentWriteReplyValidationQueryMatcher( + "DC_kwDOComment456", + githubv4mock.DataResponse(map[string]any{ + "node": map[string]any{ + "id": "DC_kwDOComment456", + "discussion": map[string]any{ + "id": "D_kwDOOtherDiscussion456", + }, + }, + }), + ), + discussionQueryMatcher, + ), + expectToolError: true, + expectedErrMsg: `commentNodeID "DC_kwDOComment456" does not belong to discussion #1 in owner/repo`, + }, + { + name: "reply: validation query failure", + requestArgs: map[string]any{ + "method": "reply", + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + "body": "This is a reply", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + discussionCommentWriteReplyValidationQueryMatcher( + "DC_kwDOComment456", + githubv4mock.ErrorResponse("Could not resolve to a node with the global id of 'DC_kwDOComment456'."), + ), + ), + expectToolError: true, + expectedErrMsg: "failed to validate commentNodeID: Could not resolve to a node with the global id of 'DC_kwDOComment456'.", + }, + }) +} + +func Test_DiscussionCommentWrite_Update(t *testing.T) { + t.Parallel() + + runDiscussionCommentWriteTests(t, []discussionCommentWriteTestCase{ + { + name: "update: successful comment update", + requestArgs: map[string]any{ + "method": "update", + "commentNodeID": "DC_kwDOComment456", + "body": "Updated comment text", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + UpdateDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"updateDiscussionComment(input: $input)"` + }{}, + githubv4.UpdateDiscussionCommentInput{ + CommentID: githubv4.ID("DC_kwDOComment456"), + Body: githubv4.String("Updated comment text"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateDiscussionComment": map[string]any{ + "comment": map[string]any{ + "id": "DC_kwDOComment456", + "url": "https://github.com/owner/repo/discussions/1#discussioncomment-456", + }, + }, + }), + ), + ), + expectedID: "DC_kwDOComment456", + expectedURL: "https://github.com/owner/repo/discussions/1#discussioncomment-456", + }, + { + name: "update: comment not found", + requestArgs: map[string]any{ + "method": "update", + "commentNodeID": "DC_kwDOInvalid", + "body": "Updated comment text", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + UpdateDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"updateDiscussionComment(input: $input)"` + }{}, + githubv4.UpdateDiscussionCommentInput{ + CommentID: githubv4.ID("DC_kwDOInvalid"), + Body: githubv4.String("Updated comment text"), + }, + nil, + githubv4mock.ErrorResponse("Could not resolve to a node with the global id of 'DC_kwDOInvalid'."), + ), + ), + expectToolError: true, + expectedErrMsg: "Could not resolve to a node with the global id of 'DC_kwDOInvalid'.", + }, + { + name: "update: insufficient permissions", + requestArgs: map[string]any{ + "method": "update", + "commentNodeID": "DC_kwDOComment456", + "body": "Updated comment text", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + UpdateDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"updateDiscussionComment(input: $input)"` + }{}, + githubv4.UpdateDiscussionCommentInput{ + CommentID: githubv4.ID("DC_kwDOComment456"), + Body: githubv4.String("Updated comment text"), + }, + nil, + githubv4mock.ErrorResponse("insufficient permissions to update this discussion comment"), + ), + ), + expectToolError: true, + expectedErrMsg: "insufficient permissions to update this discussion comment", + }, + { + name: "update: missing commentNodeID", + requestArgs: map[string]any{ + "method": "update", + "body": "Updated comment text", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "missing required parameter: commentNodeID", + }, + { + name: "update: whitespace-only commentNodeID is rejected", + requestArgs: map[string]any{ + "method": "update", + "commentNodeID": " ", + "body": "Updated comment text", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "commentNodeID cannot be blank", + }, + { + name: "update: missing body", + requestArgs: map[string]any{ + "method": "update", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "missing required parameter: body", + }, + }) +} + +func Test_DiscussionCommentWrite_Delete(t *testing.T) { + t.Parallel() + + runDiscussionCommentWriteTests(t, []discussionCommentWriteTestCase{ + { + name: "delete: successful comment delete", + requestArgs: map[string]any{ + "method": "delete", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + DeleteDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"deleteDiscussionComment(input: $input)"` + }{}, + githubv4.DeleteDiscussionCommentInput{ + ID: githubv4.ID("DC_kwDOComment456"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "deleteDiscussionComment": map[string]any{ + "comment": map[string]any{ + "id": "DC_kwDOComment456", + "url": "https://github.com/owner/repo/discussions/1#discussioncomment-456", + }, + }, + }), + ), + ), + expectedID: "DC_kwDOComment456", + expectedURL: "https://github.com/owner/repo/discussions/1#discussioncomment-456", + }, + { + name: "delete: comment not found", + requestArgs: map[string]any{ + "method": "delete", + "commentNodeID": "DC_kwDOInvalid", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + DeleteDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"deleteDiscussionComment(input: $input)"` + }{}, + githubv4.DeleteDiscussionCommentInput{ + ID: githubv4.ID("DC_kwDOInvalid"), + }, + nil, + githubv4mock.ErrorResponse("Could not resolve to a node with the global id of 'DC_kwDOInvalid'."), + ), + ), + expectToolError: true, + expectedErrMsg: "Could not resolve to a node with the global id of 'DC_kwDOInvalid'.", + }, + { + name: "delete: insufficient permissions", + requestArgs: map[string]any{ + "method": "delete", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + DeleteDiscussionComment struct { + Comment struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"deleteDiscussionComment(input: $input)"` + }{}, + githubv4.DeleteDiscussionCommentInput{ + ID: githubv4.ID("DC_kwDOComment456"), + }, + nil, + githubv4mock.ErrorResponse("insufficient permissions to delete this discussion comment"), + ), + ), + expectToolError: true, + expectedErrMsg: "insufficient permissions to delete this discussion comment", + }, + { + name: "delete: missing commentNodeID", + requestArgs: map[string]any{ + "method": "delete", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "missing required parameter: commentNodeID", + }, + }) +} + +func Test_DiscussionCommentWrite_MarkAnswer(t *testing.T) { + t.Parallel() + + runDiscussionCommentWriteTests(t, []discussionCommentWriteTestCase{ + { + name: "mark_answer: successful mark as answer", + requestArgs: map[string]any{ + "method": "mark_answer", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + MarkDiscussionCommentAsAnswer struct { + Discussion struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"markDiscussionCommentAsAnswer(input: $input)"` + }{}, + githubv4.MarkDiscussionCommentAsAnswerInput{ + ID: githubv4.ID("DC_kwDOComment456"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "markDiscussionCommentAsAnswer": map[string]any{ + "discussion": map[string]any{ + "id": "D_kwDOTest123", + "url": "https://github.com/owner/repo/discussions/1", + }, + }, + }), + ), + ), + expectedDiscussionID: "D_kwDOTest123", + expectedDiscussionURL: "https://github.com/owner/repo/discussions/1", + }, + { + name: "mark_answer: mutation failure", + requestArgs: map[string]any{ + "method": "mark_answer", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + MarkDiscussionCommentAsAnswer struct { + Discussion struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"markDiscussionCommentAsAnswer(input: $input)"` + }{}, + githubv4.MarkDiscussionCommentAsAnswerInput{ + ID: githubv4.ID("DC_kwDOComment456"), + }, + nil, + githubv4mock.ErrorResponse("discussion is not a Q&A discussion"), + ), + ), + expectToolError: true, + expectedErrMsg: "discussion is not a Q&A discussion", + }, + { + name: "mark_answer: whitespace-only commentNodeID is rejected", + requestArgs: map[string]any{ + "method": "mark_answer", + "commentNodeID": " ", + }, + mockedClient: githubv4mock.NewMockedHTTPClient(), + expectToolError: true, + expectedErrMsg: "commentNodeID cannot be blank", + }, + }) +} + +func Test_DiscussionCommentWrite_UnmarkAnswer(t *testing.T) { + t.Parallel() + + runDiscussionCommentWriteTests(t, []discussionCommentWriteTestCase{ + { + name: "unmark_answer: successful unmark as answer", + requestArgs: map[string]any{ + "method": "unmark_answer", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + UnmarkDiscussionCommentAsAnswer struct { + Discussion struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"unmarkDiscussionCommentAsAnswer(input: $input)"` + }{}, + githubv4.UnmarkDiscussionCommentAsAnswerInput{ + ID: githubv4.ID("DC_kwDOComment456"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "unmarkDiscussionCommentAsAnswer": map[string]any{ + "discussion": map[string]any{ + "id": "D_kwDOTest123", + "url": "https://github.com/owner/repo/discussions/1", + }, + }, + }), + ), + ), + expectedDiscussionID: "D_kwDOTest123", + expectedDiscussionURL: "https://github.com/owner/repo/discussions/1", + }, + { + name: "unmark_answer: mutation failure", + requestArgs: map[string]any{ + "method": "unmark_answer", + "commentNodeID": "DC_kwDOComment456", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + UnmarkDiscussionCommentAsAnswer struct { + Discussion struct { + ID githubv4.ID + URL githubv4.String `graphql:"url"` + } + } `graphql:"unmarkDiscussionCommentAsAnswer(input: $input)"` + }{}, + githubv4.UnmarkDiscussionCommentAsAnswerInput{ + ID: githubv4.ID("DC_kwDOComment456"), + }, + nil, + githubv4mock.ErrorResponse("insufficient permissions"), + ), + ), + expectToolError: true, + expectedErrMsg: "insufficient permissions", + }, + }) +} + +type discussionCommentWriteTestCase struct { + name string + requestArgs map[string]any + mockedClient *http.Client + expectToolError bool + expectedErrMsg string + expectedID string + expectedURL string + expectedDiscussionID string + expectedDiscussionURL string +} + +func runDiscussionCommentWriteTests(t *testing.T, tests []discussionCommentWriteTestCase) { + t.Helper() + + toolDef := DiscussionCommentWrite(translations.NullTranslationHelper) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gqlClient := githubv4.NewClient(tc.mockedClient) + deps := BaseDeps{GQLClient: gqlClient} + handler := toolDef.Handler(deps) + + req := createMCPRequest(tc.requestArgs) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + + text := getTextResult(t, res).Text + + if tc.expectToolError { + require.True(t, res.IsError) + assert.Contains(t, text, tc.expectedErrMsg) + return + } + + require.False(t, res.IsError) + + if tc.expectedDiscussionID != "" { + var response struct { + DiscussionID string `json:"discussionID"` + DiscussionURL string `json:"discussionURL"` + } + require.NoError(t, json.Unmarshal([]byte(text), &response)) + assert.Equal(t, tc.expectedDiscussionID, response.DiscussionID) + assert.Equal(t, tc.expectedDiscussionURL, response.DiscussionURL) + } else { + var response MinimalResponse + require.NoError(t, json.Unmarshal([]byte(text), &response)) + assert.Equal(t, tc.expectedID, response.ID) + assert.Equal(t, tc.expectedURL, response.URL) + } + }) + } +} + +func discussionCommentWriteDiscussionQueryMatcher(discussionNumber int32, response githubv4mock.GQLResponse) githubv4mock.Matcher { + return githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Discussion struct { + ID githubv4.ID + } `graphql:"discussion(number: $discussionNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "discussionNumber": githubv4.Int(discussionNumber), + }, + response, + ) +} + +func discussionCommentWriteReplyValidationQueryMatcher(commentNodeID string, response githubv4mock.GQLResponse) githubv4mock.Matcher { + return githubv4mock.NewQueryMatcher( + struct { + Node struct { + DiscussionComment struct { + ID *githubv4.ID + Discussion struct { + ID githubv4.ID + } `graphql:"discussion"` + } `graphql:"... on DiscussionComment"` + } `graphql:"node(id: $replyToID)"` + }{}, + map[string]any{ + "replyToID": githubv4.ID(commentNodeID), + }, + response, + ) +} + +func githubv4ptr(id githubv4.ID) *githubv4.ID { + return &id +} + +func Test_GetDiscussionCommentsWithReplies(t *testing.T) { + t.Parallel() + + toolDef := GetDiscussionComments(translations.NullTranslationHelper) + + qWithReplies := "query($after:String$discussionNumber:Int!$first:Int!$owner:String!$repo:String!){repository(owner: $owner, name: $repo){discussion(number: $discussionNumber){comments(first: $first, after: $after){nodes{id,body,isAnswer,replies(first: 100){nodes{id,body,isAnswer},totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}}" + + vars := map[string]any{ + "owner": "owner", + "repo": "repo", + "discussionNumber": float64(1), + "first": float64(30), + "after": (*string)(nil), + } + + mockResponse := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "discussion": map[string]any{ + "comments": map[string]any{ + "nodes": []map[string]any{ + { + "id": "DC_id1", + "body": "Top-level comment", + "replies": map[string]any{ + "nodes": []map[string]any{ + {"id": "DC_reply1", "body": "Reply to first comment", "isAnswer": true}, + }, + "totalCount": 1, + }, + }, + { + "id": "DC_id2", + "body": "Another top-level comment", + "replies": map[string]any{ + "nodes": []map[string]any{}, + "totalCount": 0, + }, + }, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 2, + }, + }, + }, + }) + + matcher := githubv4mock.NewQueryMatcher(qWithReplies, vars, mockResponse) + httpClient := githubv4mock.NewMockedHTTPClient(matcher) + gqlClient := githubv4.NewClient(httpClient) + deps := BaseDeps{GQLClient: gqlClient} + handler := toolDef.Handler(deps) + + reqParams := map[string]any{ + "owner": "owner", + "repo": "repo", + "discussionNumber": int32(1), + "includeReplies": true, + } + req := createMCPRequest(reqParams) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + + text := getTextResult(t, res).Text + require.False(t, res.IsError, "expected no error, got: %s", text) + + var response struct { + Comments []MinimalDiscussionComment `json:"comments"` + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + } `json:"pageInfo"` + TotalCount int `json:"totalCount"` + } + require.NoError(t, json.Unmarshal([]byte(text), &response)) + assert.Len(t, response.Comments, 2) + + assert.Equal(t, "DC_id1", response.Comments[0].ID) + assert.Equal(t, "Top-level comment", response.Comments[0].Body) + require.Len(t, response.Comments[0].Replies, 1) + assert.Equal(t, "DC_reply1", response.Comments[0].Replies[0].ID) + assert.Equal(t, "Reply to first comment", response.Comments[0].Replies[0].Body) + assert.True(t, response.Comments[0].Replies[0].IsAnswer) + assert.Equal(t, 1, response.Comments[0].ReplyTotalCount) + + assert.Equal(t, "DC_id2", response.Comments[1].ID) + assert.Empty(t, response.Comments[1].Replies) + assert.Equal(t, 0, response.Comments[1].ReplyTotalCount) +} diff --git a/pkg/github/gists.go b/pkg/github/gists.go index a0bc1b0855..de577af04d 100644 --- a/pkg/github/gists.go +++ b/pkg/github/gists.go @@ -12,7 +12,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/gists_test.go b/pkg/github/gists_test.go index 74cd45d276..342cd0c8f5 100644 --- a/pkg/github/gists_test.go +++ b/pkg/github/gists_test.go @@ -9,7 +9,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -141,7 +141,7 @@ func Test_ListGists(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -252,7 +252,7 @@ func Test_GetGist(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -392,7 +392,7 @@ func Test_CreateGist(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -545,7 +545,7 @@ func Test_UpdateGist(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } diff --git a/pkg/github/git.go b/pkg/github/git.go index 33a1f94efa..515d8b65f8 100644 --- a/pkg/github/git.go +++ b/pkg/github/git.go @@ -11,7 +11,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/git_test.go b/pkg/github/git_test.go index cef65c9ef4..1ad7147507 100644 --- a/pkg/github/git_test.go +++ b/pkg/github/git_test.go @@ -9,7 +9,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -125,7 +125,7 @@ func Test_GetRepositoryTree(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go index 6623894e43..72ed1939d5 100644 --- a/pkg/github/granular_tools_test.go +++ b/pkg/github/granular_tools_test.go @@ -3,13 +3,14 @@ package github import ( "context" "net/http" + "strings" "testing" "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/translations" - gogithub "github.com/google/go-github/v82/github" + gogithub "github.com/google/go-github/v87/github" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -175,7 +176,7 @@ func TestGranularCreateIssue(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := gogithub.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} serverTool := GranularCreateIssue(translations.NullTranslationHelper) handler := serverTool.Handler(deps) @@ -195,7 +196,7 @@ func TestGranularCreateIssue(t *testing.T) { } func TestGranularUpdateIssueTitle(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, &gogithub.Issue{ Number: gogithub.Ptr(42), Title: gogithub.Ptr("New Title"), @@ -217,7 +218,7 @@ func TestGranularUpdateIssueTitle(t *testing.T) { } func TestGranularUpdateIssueBody(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ "body": "Updated body", }).andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{ @@ -241,7 +242,7 @@ func TestGranularUpdateIssueBody(t *testing.T) { } func TestGranularUpdateIssueAssignees(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ "assignees": []any{"user1", "user2"}, }).andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), @@ -262,7 +263,7 @@ func TestGranularUpdateIssueAssignees(t *testing.T) { } func TestGranularUpdateIssueLabels(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ "labels": []any{"bug", "enhancement"}, }).andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), @@ -283,7 +284,7 @@ func TestGranularUpdateIssueLabels(t *testing.T) { } func TestGranularUpdateIssueMilestone(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ "milestone": float64(5), }).andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), @@ -304,24 +305,103 @@ func TestGranularUpdateIssueMilestone(t *testing.T) { } func TestGranularUpdateIssueType(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ - "type": "bug", - }).andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), - })) - deps := BaseDeps{Client: client} - serverTool := GranularUpdateIssueType(translations.NullTranslationHelper) - handler := serverTool.Handler(deps) + tests := []struct { + name string + requestArgs map[string]any + expectedReq map[string]any + }{ + { + name: "type only", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "issue_type": "bug", + }, + expectedReq: map[string]any{ + "type": "bug", + }, + }, + { + name: "type with rationale", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "issue_type": "feature", + "rationale": " This issue requests a new capability ", + }, + expectedReq: map[string]any{ + "type": map[string]any{ + "value": "feature", + "rationale": "This issue requests a new capability", + }, + }, + }, + } - request := createMCPRequest(map[string]any{ - "owner": "owner", - "repo": "repo", - "issue_number": float64(1), - "issue_type": "bug", - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - require.NoError(t, err) - assert.False(t, result.IsError) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq). + andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), + })) + deps := BaseDeps{Client: client} + serverTool := GranularUpdateIssueType(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + } +} + +func TestGranularUpdateIssueTypeInvalidRationale(t *testing.T) { + tests := []struct { + name string + requestArgs map[string]any + expectedErrText string + }{ + { + name: "rationale wrong type", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "issue_type": "feature", + "rationale": float64(123), + }, + expectedErrText: "parameter rationale is not of type string, is float64", + }, + { + name: "rationale too long", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "issue_type": "feature", + "rationale": strings.Repeat("a", 281), + }, + expectedErrText: "parameter rationale must be 280 characters or less", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(nil))} + serverTool := GranularUpdateIssueType(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrText) + }) + } } func TestGranularUpdateIssueState(t *testing.T) { @@ -360,7 +440,7 @@ func TestGranularUpdateIssueState(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq). andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{ Number: gogithub.Ptr(1), @@ -382,7 +462,7 @@ func TestGranularUpdateIssueState(t *testing.T) { // --- Pull request granular tool handler tests --- func TestGranularUpdatePullRequestTitle(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{ "title": "New PR Title", }).andThen(mockResponse(t, http.StatusOK, &gogithub.PullRequest{ @@ -406,7 +486,7 @@ func TestGranularUpdatePullRequestTitle(t *testing.T) { } func TestGranularUpdatePullRequestBody(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{ "body": "Updated description", }).andThen(mockResponse(t, http.StatusOK, &gogithub.PullRequest{ @@ -430,7 +510,7 @@ func TestGranularUpdatePullRequestBody(t *testing.T) { } func TestGranularUpdatePullRequestState(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{ "state": "closed", }).andThen(mockResponse(t, http.StatusOK, &gogithub.PullRequest{ @@ -454,7 +534,7 @@ func TestGranularUpdatePullRequestState(t *testing.T) { } func TestGranularRequestPullRequestReviewers(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, &gogithub.PullRequest{Number: gogithub.Ptr(1)}), })) deps := BaseDeps{Client: client} diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index ff752f5f37..4181f102e4 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + gogithub "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" testifymock "github.com/stretchr/testify/mock" @@ -39,6 +40,7 @@ const ( GetReposSubscriptionByOwnerByRepo = "GET /repos/{owner}/{repo}/subscription" PutReposSubscriptionByOwnerByRepo = "PUT /repos/{owner}/{repo}/subscription" DeleteReposSubscriptionByOwnerByRepo = "DELETE /repos/{owner}/{repo}/subscription" + ListCollaborators = "GET /repos/{owner}/{repo}/collaborators" // Git endpoints GetReposGitTreesByOwnerByRepoByTree = "GET /repos/{owner}/{repo}/git/trees/{tree}" @@ -178,6 +180,22 @@ type expectations struct { requestBody any } +// mustNewGHClient creates a new GitHub client for testing. +// If httpClient is nil, a client with no options is created. +// The test fails immediately if client creation fails. +func mustNewGHClient(t *testing.T, httpClient *http.Client) *gogithub.Client { + t.Helper() + var client *gogithub.Client + var err error + if httpClient == nil { + client, err = gogithub.NewClient() + } else { + client, err = gogithub.NewClient(gogithub.WithHTTPClient(httpClient)) + } + require.NoError(t, err) + return client +} + // expect is a helper function to create a partial mock that expects various // request behaviors, such as path, query parameters, and request body. func expect(t *testing.T, e expectations) *partialMock { @@ -219,9 +237,15 @@ func expectRequestBody(t *testing.T, expectedRequestBody any) *partialMock { type partialMock struct { t *testing.T - expectedPath string - expectedQueryParams map[string]string - expectedRequestBody any + expectedPath string + expectedQueryParams map[string]string + expectedRequestBody any + expectedHeaderContains map[string]string +} + +func (p *partialMock) withHeaders(headers map[string]string) *partialMock { + p.expectedHeaderContains = headers + return p } func (p *partialMock) andThen(responseHandler http.HandlerFunc) http.HandlerFunc { @@ -246,6 +270,12 @@ func (p *partialMock) andThen(responseHandler http.HandlerFunc) http.HandlerFunc require.Equal(p.t, p.expectedRequestBody, unmarshaledRequestBody) } + if p.expectedHeaderContains != nil { + for k, v := range p.expectedHeaderContains { + require.Contains(p.t, r.Header.Get(k), v, "expected header %q to contain %q", k, v) + } + } + responseHandler(w, r) } } diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 81161626bb..52a024c298 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -10,12 +10,13 @@ import ( "time" ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/sanitize" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" @@ -130,6 +131,7 @@ type IssueFragment struct { // Common interface for all issue query types type IssueQueryResult interface { GetIssueFragment() IssueQueryFragment + GetIsPrivate() bool } type IssueQueryFragment struct { @@ -146,28 +148,32 @@ type IssueQueryFragment struct { // ListIssuesQuery is the root query structure for fetching issues with optional label filtering. type ListIssuesQuery struct { Repository struct { - Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction})"` + Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction})"` + IsPrivate githubv4.Boolean } `graphql:"repository(owner: $owner, name: $repo)"` } // ListIssuesQueryTypeWithLabels is the query structure for fetching issues with optional label filtering. type ListIssuesQueryTypeWithLabels struct { Repository struct { - Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction})"` + Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction})"` + IsPrivate githubv4.Boolean } `graphql:"repository(owner: $owner, name: $repo)"` } // ListIssuesQueryWithSince is the query structure for fetching issues without label filtering but with since filtering. type ListIssuesQueryWithSince struct { Repository struct { - Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` + Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` + IsPrivate githubv4.Boolean } `graphql:"repository(owner: $owner, name: $repo)"` } // ListIssuesQueryTypeWithLabelsWithSince is the query structure for fetching issues with both label and since filtering. type ListIssuesQueryTypeWithLabelsWithSince struct { Repository struct { - Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` + Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` + IsPrivate githubv4.Boolean } `graphql:"repository(owner: $owner, name: $repo)"` } @@ -176,18 +182,28 @@ func (q *ListIssuesQueryTypeWithLabels) GetIssueFragment() IssueQueryFragment { return q.Repository.Issues } +func (q *ListIssuesQueryTypeWithLabels) GetIsPrivate() bool { return bool(q.Repository.IsPrivate) } + func (q *ListIssuesQuery) GetIssueFragment() IssueQueryFragment { return q.Repository.Issues } +func (q *ListIssuesQuery) GetIsPrivate() bool { return bool(q.Repository.IsPrivate) } + func (q *ListIssuesQueryWithSince) GetIssueFragment() IssueQueryFragment { return q.Repository.Issues } +func (q *ListIssuesQueryWithSince) GetIsPrivate() bool { return bool(q.Repository.IsPrivate) } + func (q *ListIssuesQueryTypeWithLabelsWithSince) GetIssueFragment() IssueQueryFragment { return q.Repository.Issues } +func (q *ListIssuesQueryTypeWithLabelsWithSince) GetIsPrivate() bool { + return bool(q.Repository.IsPrivate) +} + func getIssueQueryType(hasLabels bool, hasSince bool) any { switch { case hasLabels && hasSince: @@ -280,19 +296,37 @@ Options are: return utils.NewToolResultErrorFromErr("failed to get GitHub graphql client", err), nil, nil } + // attachIFC adds the IFC label to a successful tool result when + // InsidersMode is enabled. If the visibility lookup fails the + // label is omitted rather than misclassifying the result. + attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult { + if r == nil || r.IsError || !deps.GetFlags(ctx).InsidersMode { + return r + } + isPrivate, err := FetchRepoIsPrivate(ctx, client, owner, repo) + if err != nil { + return r + } + if r.Meta == nil { + r.Meta = mcp.Meta{} + } + r.Meta["ifc"] = ifc.LabelListIssues(isPrivate) + return r + } + switch method { case "get": result, err := GetIssue(ctx, client, deps, owner, repo, issueNumber) - return result, nil, err + return attachIFC(result), nil, err case "get_comments": result, err := GetIssueComments(ctx, client, deps, owner, repo, issueNumber, pagination) - return result, nil, err + return attachIFC(result), nil, err case "get_sub_issues": result, err := GetSubIssues(ctx, client, deps, owner, repo, issueNumber, pagination) - return result, nil, err + return attachIFC(result), nil, err case "get_labels": result, err := GetIssueLabels(ctx, gqlClient, owner, repo, issueNumber) - return result, nil, err + return attachIFC(result), nil, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } @@ -418,11 +452,9 @@ func GetSubIssues(ctx context.Context, client *github.Client, deps ToolDependenc } featureFlags := deps.GetFlags(ctx) - opts := &github.IssueListOptions{ - ListOptions: github.ListOptions{ - Page: pagination.Page, - PerPage: pagination.PerPage, - }, + opts := &github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, } subIssues, resp, err := client.SubIssue.ListByIssue(ctx, owner, repo, int64(issueNumber), opts) @@ -527,7 +559,6 @@ func GetIssueLabels(ctx context.Context, client *githubv4.Client, owner string, } return utils.NewToolResultText(string(out)), nil - } // ListIssueTypes creates a tool to list defined issue types for an organization. This can be used to understand supported issue type values for creating or updating issues. @@ -822,7 +853,6 @@ func AddSubIssue(ctx context.Context, client *github.Client, owner string, repo } return utils.NewToolResultText(string(r)), nil - } func RemoveSubIssue(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, subIssueID int) (*mcp.CallToolResult, error) { @@ -962,11 +992,97 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool { }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - result, err := searchHandler(ctx, deps.GetClient, args, "issue", "failed to search issues") + var options []searchOption + if deps.GetFlags(ctx).InsidersMode { + options = append(options, withSearchPostProcess(searchIssuesIFCPostProcess(deps))) + } + result, err := searchHandler(ctx, deps.GetClient, args, "issue", "failed to search issues", options...) return result, nil, err }) } +// searchIssuesIFCPostProcess returns a searchPostProcessFn that attaches the +// IFC label for a search_issues result. It looks up the visibility (and, for +// private repos, collaborators) of every repository represented in the search +// payload and joins the labels via ifc.LabelSearchIssues. If any per-repo +// lookup fails the label is omitted to avoid misclassifying the result. +func searchIssuesIFCPostProcess(deps ToolDependencies) searchPostProcessFn { + return func(ctx context.Context, result *github.IssuesSearchResult, callResult *mcp.CallToolResult) { + if callResult == nil || callResult.IsError || result == nil { + return + } + + client, err := deps.GetClient(ctx) + if err != nil { + return + } + + uniqueRepos := uniqueSearchIssuesRepos(result) + visibilities := make([]bool, 0, len(uniqueRepos)) + for _, r := range uniqueRepos { + isPrivate, err := FetchRepoIsPrivate(ctx, client, r.owner, r.repo) + if err != nil { + return + } + visibilities = append(visibilities, isPrivate) + } + + if callResult.Meta == nil { + callResult.Meta = mcp.Meta{} + } + callResult.Meta["ifc"] = ifc.LabelSearchIssues(visibilities) + } +} + +type searchIssuesRepoRef struct { + owner string + repo string +} + +// uniqueSearchIssuesRepos extracts the owner/repo pairs of every issue in the +// search result, preserving order of first appearance and deduplicating. +func uniqueSearchIssuesRepos(result *github.IssuesSearchResult) []searchIssuesRepoRef { + if result == nil { + return nil + } + seen := make(map[string]struct{}) + var out []searchIssuesRepoRef + for _, issue := range result.Issues { + if issue == nil { + continue + } + owner, repo, ok := parseRepositoryURL(issue.GetRepositoryURL()) + if !ok { + continue + } + key := owner + "/" + repo + if _, dup := seen[key]; dup { + continue + } + seen[key] = struct{}{} + out = append(out, searchIssuesRepoRef{owner: owner, repo: repo}) + } + return out +} + +// parseRepositoryURL extracts the owner and repo from a GitHub API repository +// URL of the form https://api.github.com/repos/{owner}/{repo}. +func parseRepositoryURL(repoURL string) (string, string, bool) { + if repoURL == "" { + return "", "", false + } + const marker = "/repos/" + idx := strings.LastIndex(repoURL, marker) + if idx < 0 { + return "", "", false + } + parts := strings.Split(strings.Trim(repoURL[idx+len(marker):], "/"), "/") + if len(parts) < 2 || parts[0] == "" || parts[1] == "" { + return "", "", false + } + return parts[0], parts[1], true +} + // IssueWrite creates a tool to create a new or update an existing issue in a GitHub repository. // IssueWriteUIResourceURI is the URI for the issue_write tool's MCP App UI resource. const IssueWriteUIResourceURI = "ui://github-mcp-server/issue-write" @@ -1568,11 +1684,20 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { } var resp MinimalIssuesResponse + var isPrivate bool if queryResult, ok := issueQuery.(IssueQueryResult); ok { resp = convertToMinimalIssuesResponse(queryResult.GetIssueFragment()) + isPrivate = queryResult.GetIsPrivate() } - return MarshalledTextResult(resp), nil, nil + result := MarshalledTextResult(resp) + if deps.GetFlags(ctx).InsidersMode { + if result.Meta == nil { + result.Meta = mcp.Meta{} + } + result.Meta["ifc"] = ifc.LabelListIssues(isPrivate) + } + return result, nil, nil }) } diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go index fe3b4bcc9b..5b335bd443 100644 --- a/pkg/github/issues_granular.go +++ b/pkg/github/issues_granular.go @@ -12,7 +12,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" @@ -309,27 +309,131 @@ func GranularUpdateIssueMilestone(t translations.TranslationHelperFunc) inventor ) } +// issueTypeWithRationale represents the object form of the issue type field, +// allowing a rationale to be sent alongside the type name. +type issueTypeWithRationale struct { + Value string `json:"value"` + Rationale string `json:"rationale"` +} + +// issueTypeUpdateRequest is a custom request body for updating an issue type +// with an optional rationale, using the object form that the REST API accepts. +type issueTypeUpdateRequest struct { + Type issueTypeWithRationale `json:"type"` +} + // GranularUpdateIssueType creates a tool to update an issue's type. func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.ServerTool { - return issueUpdateTool(t, - "update_issue_type", - "Update the type of an existing issue (e.g. 'bug', 'feature').", - "Update Issue Type", - map[string]*jsonschema.Schema{ - "issue_type": { - Type: "string", - Description: "The issue type to set", + st := NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: "update_issue_type", + Description: t("TOOL_UPDATE_ISSUE_TYPE_DESCRIPTION", "Update the type of an existing issue (e.g. 'bug', 'feature')."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_UPDATE_ISSUE_TYPE_USER_TITLE", "Update Issue Type"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(false), + OpenWorldHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner (username or organization)", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "issue_number": { + Type: "number", + Description: "The issue number to update", + Minimum: jsonschema.Ptr(1.0), + }, + "issue_type": { + Type: "string", + Description: "The issue type to set", + }, + "rationale": { + Type: "string", + Description: "One concise sentence explaining what specifically about the issue led you to choose this type. " + + "State the concrete signal (e.g. 'Reports a crash when saving' → bug, 'Asks for dark mode support' → feature).", + MaxLength: jsonschema.Ptr(280), + }, + }, + Required: []string{"owner", "repo", "issue_number", "issue_type"}, }, }, - []string{"issue_type"}, - func(args map[string]any) (*github.IssueRequest, error) { + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + issueNumber, err := RequiredInt(args, "issue_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } issueType, err := RequiredParam[string](args, "issue_type") if err != nil { - return nil, err + return utils.NewToolResultError(err.Error()), nil, nil + } + rationale, err := OptionalParam[string](args, "rationale") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + rationale = strings.TrimSpace(rationale) + if len([]rune(rationale)) > 280 { + return utils.NewToolResultError("parameter rationale must be 280 characters or less"), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + var body any + if rationale != "" { + body = &issueTypeUpdateRequest{ + Type: issueTypeWithRationale{ + Value: issueType, + Rationale: rationale, + }, + } + } else { + body = &github.IssueRequest{Type: &issueType} + } + + apiURL := fmt.Sprintf("repos/%s/%s/issues/%d", owner, repo, issueNumber) + req, err := client.NewRequest(ctx, "PATCH", apiURL, body) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil } - return &github.IssueRequest{Type: &issueType}, nil + + issue := &github.Issue{} + resp, err := client.Do(req, issue) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update issue", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%d", issue.GetID()), + URL: issue.GetHTMLURL(), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + return utils.NewToolResultText(string(r)), nil, nil }, ) + st.FeatureFlagEnable = FeatureFlagIssuesGranular + return st } // GranularUpdateIssueState creates a tool to update an issue's state. diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 9c20824746..6b4042bac5 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -14,7 +14,7 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -225,7 +225,7 @@ func Test_GetIssue(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) var restClient *github.Client if tc.restPermission != "" { @@ -275,6 +275,123 @@ func Test_GetIssue(t *testing.T) { } } +func Test_IssueRead_IFC_InsidersMode(t *testing.T) { + t.Parallel() + + serverTool := IssueRead(translations.NullTranslationHelper) + + mockIssue := &github.Issue{ + Number: github.Ptr(1), + Title: github.Ptr("Test"), + Body: github.Ptr("body"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/octocat/repo/issues/1"), + User: &github.User{Login: github.Ptr("u")}, + } + + mockComments := []*github.IssueComment{ + {Body: github.Ptr("hello"), User: &github.User{Login: github.Ptr("u")}}, + } + + makeMockClient := func(isPrivate bool, repoStatus int) *http.Client { + handlers := map[string]http.HandlerFunc{ + GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue), + GetReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockComments), + } + if repoStatus != 0 && repoStatus != http.StatusOK { + handlers[GetReposByOwnerByRepo] = mockResponse(t, repoStatus, "boom") + } else { + handlers[GetReposByOwnerByRepo] = mockResponse(t, http.StatusOK, map[string]any{ + "name": "repo", + "private": isPrivate, + }) + } + return MockHTTPClientWithHandlers(handlers) + } + + getReq := map[string]any{ + "method": "get", + "owner": "octocat", + "repo": "repo", + "issue_number": float64(1), + } + commentsReq := map[string]any{ + "method": "get_comments", + "owner": "octocat", + "repo": "repo", + "issue_number": float64(1), + } + + t.Run("insiders mode disabled omits ifc label", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(false, 0)), + Flags: FeatureFlags{InsidersMode: false}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(getReq) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + assert.Nil(t, result.Meta) + }) + + t.Run("insiders mode enabled on public repo emits public untrusted", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(false, 0)), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(getReq) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "public", ifcMap["confidentiality"]) + }) + + t.Run("insiders mode enabled on private repo with get_comments emits private untrusted", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(true, 0)), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(commentsReq) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "private", ifcMap["confidentiality"]) + }) + + t.Run("insiders mode skips ifc label when visibility lookup fails", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(false, http.StatusInternalServerError)), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(getReq) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, "tool call should still succeed when visibility lookup fails") + + if result.Meta != nil { + _, hasIFC := result.Meta["ifc"] + assert.False(t, hasIFC, "ifc label should be omitted when visibility lookup fails") + } + }) +} + func Test_AddIssueComment(t *testing.T) { // Verify tool definition once serverTool := AddIssueComment(translations.NullTranslationHelper) @@ -344,7 +461,7 @@ func Test_AddIssueComment(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -381,7 +498,6 @@ func Test_AddIssueComment(t *testing.T) { require.NoError(t, err) assert.Equal(t, fmt.Sprintf("%d", tc.expectedComment.GetID()), minimalResponse.ID) assert.Equal(t, tc.expectedComment.GetHTMLURL(), minimalResponse.URL) - }) } } @@ -647,7 +763,7 @@ func Test_SearchIssues(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -693,6 +809,172 @@ func Test_SearchIssues(t *testing.T) { } } +func Test_SearchIssues_IFC_InsidersMode(t *testing.T) { + t.Parallel() + + serverTool := SearchIssues(translations.NullTranslationHelper) + + makeIssue := func(owner, repo string, number int) *github.Issue { + return &github.Issue{ + Number: github.Ptr(number), + Title: github.Ptr("issue"), + State: github.Ptr("open"), + RepositoryURL: github.Ptr("https://api.github.com/repos/" + owner + "/" + repo), + User: &github.User{Login: github.Ptr("u")}, + } + } + + type repoFixture struct { + owner string + repo string + isPrivate bool + repoStatus int + } + + repoHandlers := func(repos []repoFixture) map[string]http.HandlerFunc { + repoByPath := map[string]repoFixture{} + for _, r := range repos { + repoByPath["/repos/"+r.owner+"/"+r.repo] = r + } + return map[string]http.HandlerFunc{ + GetReposByOwnerByRepo: func(w http.ResponseWriter, req *http.Request) { + r, ok := repoByPath[req.URL.Path] + if !ok { + w.WriteHeader(http.StatusNotFound) + return + } + if r.repoStatus != 0 && r.repoStatus != http.StatusOK { + w.WriteHeader(r.repoStatus) + return + } + body, _ := json.Marshal(map[string]any{ + "name": r.repo, + "private": r.isPrivate, + }) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(body) + }, + } + } + + makeMockClient := func(searchResult *github.IssuesSearchResult, repos []repoFixture) *http.Client { + handlers := repoHandlers(repos) + handlers[GetSearchIssues] = mockResponse(t, http.StatusOK, searchResult) + return MockHTTPClientWithHandlers(handlers) + } + + reqParams := map[string]any{"query": "bug"} + + t.Run("insiders mode disabled omits ifc label", func(t *testing.T) { + searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{makeIssue("octocat", "public-repo", 1)}} + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(searchResult, []repoFixture{{owner: "octocat", repo: "public-repo"}})), + Flags: FeatureFlags{InsidersMode: false}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Nil(t, result.Meta) + }) + + t.Run("insiders mode all public emits public untrusted", func(t *testing.T) { + searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{makeIssue("octocat", "public-repo", 1)}} + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(searchResult, []repoFixture{{owner: "octocat", repo: "public-repo"}})), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "public", ifcMap["confidentiality"]) + }) + + t.Run("insiders mode mixed public and private emits private untrusted", func(t *testing.T) { + searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{ + makeIssue("octocat", "private-repo", 1), + makeIssue("octocat", "public-repo", 2), + }} + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(searchResult, []repoFixture{ + {owner: "octocat", repo: "private-repo", isPrivate: true}, + {owner: "octocat", repo: "public-repo"}, + })), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "private", ifcMap["confidentiality"]) + }) + + t.Run("insiders mode skips ifc label when visibility lookup fails", func(t *testing.T) { + searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{makeIssue("octocat", "broken", 1)}} + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(searchResult, []repoFixture{ + {owner: "octocat", repo: "broken", repoStatus: http.StatusInternalServerError}, + })), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, "tool call should still succeed when visibility lookup fails") + + if result.Meta != nil { + _, hasIFC := result.Meta["ifc"] + assert.False(t, hasIFC, "ifc label should be omitted when visibility lookup fails") + } + }) + + t.Run("insiders mode empty results emits public untrusted", func(t *testing.T) { + searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{}} + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(searchResult, nil)), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "public", ifcMap["confidentiality"]) + }) +} + +func unmarshalIFC(t *testing.T, ifcLabel any) map[string]any { + t.Helper() + require.NotNil(t, ifcLabel, "ifc label should be present") + ifcJSON, err := json.Marshal(ifcLabel) + require.NoError(t, err) + var ifcMap map[string]any + require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap)) + return ifcMap +} + func Test_CreateIssue(t *testing.T) { // Verify tool definition once serverTool := IssueWrite(translations.NullTranslationHelper) @@ -808,7 +1090,7 @@ func Test_CreateIssue(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) gqlClient := githubv4.NewClient(nil) deps := BaseDeps{ Client: client, @@ -862,7 +1144,7 @@ func Test_IssueWrite_InsidersMode_UIGate(t *testing.T) { serverTool := IssueWrite(translations.NullTranslationHelper) - client := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PostReposIssuesByOwnerByRepo: mockResponse(t, http.StatusCreated, mockIssue), })) @@ -944,7 +1226,7 @@ func Test_IssueWrite_InsidersMode_UIGate(t *testing.T) { }) completedReason := IssueClosedStateReasonCompleted - closeClient := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + closeClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue), })) closeGQLClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( @@ -1117,6 +1399,7 @@ func Test_ListIssues(t *testing.T) { }, "totalCount": 2, }, + "isPrivate": false, }, }) @@ -1132,6 +1415,7 @@ func Test_ListIssues(t *testing.T) { }, "totalCount": 2, }, + "isPrivate": false, }, }) @@ -1147,6 +1431,7 @@ func Test_ListIssues(t *testing.T) { }, "totalCount": 1, }, + "isPrivate": false, }, }) @@ -1272,8 +1557,8 @@ func Test_ListIssues(t *testing.T) { } // Define the actual query strings that match the implementation - qBasicNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" - qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount}}}" + qBasicNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -1349,6 +1634,132 @@ func Test_ListIssues(t *testing.T) { } } +func Test_ListIssues_IFC_InsidersMode(t *testing.T) { + t.Parallel() + + serverTool := ListIssues(translations.NullTranslationHelper) + + mockIssues := []map[string]any{ + { + "number": 1, + "title": "An issue", + "body": "body", + "state": "OPEN", + "databaseId": 1, + "createdAt": "2023-01-01T00:00:00Z", + "updatedAt": "2023-01-01T00:00:00Z", + "author": map[string]any{"login": "user1"}, + "labels": map[string]any{"nodes": []map[string]any{}}, + "comments": map[string]any{"totalCount": 0}, + }, + } + + pageInfo := map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + } + + makeResponse := func(isPrivate bool) githubv4mock.GQLResponse { + return githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issues": map[string]any{ + "nodes": mockIssues, + "pageInfo": pageInfo, + "totalCount": 1, + }, + "isPrivate": isPrivate, + }, + }) + } + + query := "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + + vars := map[string]any{ + "owner": "octocat", + "repo": "hello", + "states": []any{"OPEN", "CLOSED"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + } + + reqParams := map[string]any{"owner": "octocat", "repo": "hello"} + + t.Run("insiders mode disabled omits ifc label from result meta", func(t *testing.T) { + matcher := githubv4mock.NewQueryMatcher(query, vars, makeResponse(false)) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) + deps := BaseDeps{ + GQLClient: gqlClient, + Flags: FeatureFlags{InsidersMode: false}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + assert.Nil(t, result.Meta, "result meta should be nil when insiders mode is disabled") + }) + + t.Run("insiders mode enabled on public repo emits public untrusted label", func(t *testing.T) { + matcher := githubv4mock.NewQueryMatcher(query, vars, makeResponse(false)) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) + deps := BaseDeps{ + GQLClient: gqlClient, + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcLabel, ok := result.Meta["ifc"] + require.True(t, ok, "result meta should contain ifc key") + + ifcJSON, err := json.Marshal(ifcLabel) + require.NoError(t, err) + var ifcMap map[string]any + require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap)) + + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "public", ifcMap["confidentiality"]) + }) + + t.Run("insiders mode enabled on private repo emits private untrusted label", func(t *testing.T) { + matcher := githubv4mock.NewQueryMatcher(query, vars, makeResponse(true)) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) + deps := BaseDeps{ + GQLClient: gqlClient, + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcLabel, ok := result.Meta["ifc"] + require.True(t, ok, "result meta should contain ifc key") + + ifcJSON, err := json.Marshal(ifcLabel) + require.NoError(t, err) + var ifcMap map[string]any + require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap)) + + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "private", ifcMap["confidentiality"]) + }) +} + func Test_UpdateIssue(t *testing.T) { // Verify tool definition serverTool := IssueWrite(translations.NullTranslationHelper) @@ -1780,7 +2191,7 @@ func Test_UpdateIssue(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup clients with mocks - restClient := github.NewClient(tc.mockedRESTClient) + restClient := mustNewGHClient(t, tc.mockedRESTClient) gqlClient := githubv4.NewClient(tc.mockedGQLClient) deps := BaseDeps{ Client: restClient, @@ -2006,7 +2417,7 @@ func Test_GetIssueComments(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) var restClient *github.Client if tc.lockdownEnabled { restClient = mockRESTPermissionServer(t, "read", map[string]string{ @@ -2135,7 +2546,7 @@ func Test_GetIssueLabels(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { gqlClient := githubv4.NewClient(tc.mockedClient) - client := github.NewClient(nil) + client := mustNewGHClient(t, nil) deps := BaseDeps{ Client: client, GQLClient: gqlClient, @@ -2342,7 +2753,7 @@ func Test_AddSubIssue(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -2563,7 +2974,7 @@ func Test_GetSubIssues(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) gqlClient := githubv4.NewClient(nil) deps := BaseDeps{ Client: client, @@ -2782,7 +3193,7 @@ func Test_RemoveSubIssue(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -3042,7 +3453,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -3158,7 +3569,7 @@ func Test_ListIssueTypes(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index a8757c51c3..65a18ade88 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -3,7 +3,7 @@ package github import ( "time" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/github/github-mcp-server/pkg/sanitize" ) @@ -51,6 +51,31 @@ type MinimalSearchRepositoriesResult struct { Items []MinimalRepository `json:"items"` } +// MinimalDiscussionComment is the trimmed output type for discussion comment objects. +type MinimalDiscussionComment struct { + ID string `json:"id"` + Body string `json:"body"` + IsAnswer bool `json:"isAnswer,omitempty"` + Replies []MinimalDiscussionComment `json:"replies,omitempty"` + ReplyTotalCount int `json:"replyTotalCount,omitempty"` +} + +// MinimalCodeSearchResult is the trimmed output type for code search results. +type MinimalCodeSearchResult struct { + TotalCount int `json:"total_count"` + IncompleteResults bool `json:"incomplete_results"` + Items []MinimalCodeResult `json:"items"` +} + +// MinimalCodeResult is the trimmed output type for a single code search hit. +type MinimalCodeResult struct { + Name string `json:"name"` + Path string `json:"path"` + SHA string `json:"sha"` + Repository string `json:"repository"` + TextMatches []*github.TextMatch `json:"text_matches,omitempty"` +} + // MinimalCommitAuthor represents commit author information. type MinimalCommitAuthor struct { Name string `json:"name,omitempty"` @@ -138,6 +163,13 @@ type MinimalResponse struct { URL string `json:"url"` } +// MinimalCollaborator is the trimmed output type for repository collaborators. +type MinimalCollaborator struct { + Login string `json:"login"` + ID int64 `json:"id"` + RoleName string `json:"role_name"` +} + type MinimalProject struct { ID *int64 `json:"id,omitempty"` NodeID *string `json:"node_id,omitempty"` diff --git a/pkg/github/notifications.go b/pkg/github/notifications.go index ddd3023932..61d8f40b2e 100644 --- a/pkg/github/notifications.go +++ b/pkg/github/notifications.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "net/http" - "strconv" "time" ghErrors "github.com/github/github-mcp-server/pkg/errors" @@ -14,7 +13,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -209,13 +208,7 @@ func DismissNotification(t translations.TranslationHelperFunc) inventory.ServerT var resp *github.Response switch state { case "done": - // for some inexplicable reason, the API seems to have threadID as int64 and string depending on the endpoint - var threadIDInt int64 - threadIDInt, err = strconv.ParseInt(threadID, 10, 64) - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid threadID format: %v", err)), nil, nil - } - resp, err = client.Activity.MarkThreadDone(ctx, threadIDInt) + resp, err = client.Activity.MarkThreadDone(ctx, threadID) case "read": resp, err = client.Activity.MarkThreadRead(ctx, threadID) default: diff --git a/pkg/github/notifications_test.go b/pkg/github/notifications_test.go index 030367d067..bcfc28abc2 100644 --- a/pkg/github/notifications_test.go +++ b/pkg/github/notifications_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -108,7 +108,7 @@ func Test_ListNotifications(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -232,7 +232,7 @@ func Test_ManageNotificationSubscription(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -386,7 +386,7 @@ func Test_ManageRepositoryNotificationSubscription(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -456,7 +456,6 @@ func Test_DismissNotification(t *testing.T) { expectError bool expectRead bool expectDone bool - expectInvalid bool expectedErrMsg string }{ { @@ -495,16 +494,6 @@ func Test_DismissNotification(t *testing.T) { expectError: false, expectDone: true, }, - { - name: "invalid threadID format", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "threadID": "notanumber", - "state": "done", - }, - expectError: false, - expectInvalid: true, - }, { name: "missing required threadID", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), @@ -534,7 +523,7 @@ func Test_DismissNotification(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -552,8 +541,6 @@ func Test_DismissNotification(t *testing.T) { assert.Contains(t, text, "missing required parameter: threadID") case tc.requestArgs["state"] == nil: assert.Contains(t, text, "missing required parameter: state") - case tc.name == "invalid threadID format": - assert.Contains(t, text, "invalid threadID format") case tc.name == "invalid state value": assert.Contains(t, text, "Invalid state. Must be one of: read, done.") default: @@ -571,9 +558,6 @@ func Test_DismissNotification(t *testing.T) { if tc.expectDone { assert.Contains(t, textContent.Text, "Notification marked as done") } - if tc.expectInvalid { - assert.Contains(t, textContent.Text, "invalid threadID format") - } }) } } @@ -647,7 +631,7 @@ func Test_MarkAllNotificationsRead(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -725,7 +709,7 @@ func Test_GetNotificationDetails(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } diff --git a/pkg/github/params.go b/pkg/github/params.go index 1b45d61bd8..ecdc8c3549 100644 --- a/pkg/github/params.go +++ b/pkg/github/params.go @@ -6,7 +6,7 @@ import ( "math" "strconv" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" ) diff --git a/pkg/github/params_test.go b/pkg/github/params_test.go index 2254b737eb..b00efeb10c 100644 --- a/pkg/github/params_test.go +++ b/pkg/github/params_test.go @@ -5,7 +5,7 @@ import ( "math" "testing" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/stretchr/testify/assert" ) diff --git a/pkg/github/projects.go b/pkg/github/projects.go index dcb9193eca..a5953f3be5 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -13,7 +13,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" @@ -618,16 +618,11 @@ func listProjects(ctx context.Context, client *github.Client, args map[string]an var resp *github.Response var projects []*github.ProjectV2 - var queryPtr *string - - if queryStr != "" { - queryPtr = &queryStr - } minimalProjects := []MinimalProject{} opts := &github.ListProjectsOptions{ ListProjectsPaginationOptions: pagination, - Query: queryPtr, + Query: queryStr, } // If owner_type not provided, fetch from both user and org @@ -801,17 +796,12 @@ func listProjectItems(ctx context.Context, client *github.Client, args map[strin var resp *github.Response var projectItems []*github.ProjectV2Item - var queryPtr *string - - if queryStr != "" { - queryPtr = &queryStr - } opts := &github.ListProjectItemsOptions{ Fields: fields, ListProjectsOptions: github.ListProjectsOptions{ ListProjectsPaginationOptions: pagination, - Query: queryPtr, + Query: queryStr, }, } @@ -1387,16 +1377,9 @@ func extractPaginationOptionsFromArgs(args map[string]any) (github.ListProjectsP } opts := github.ListProjectsPaginationOptions{ - PerPage: &perPage, - } - - // Only set After/Before if they have non-empty values - if after != "" { - opts.After = &after - } - - if before != "" { - opts.Before = &before + PerPage: perPage, + After: after, + Before: before, } return opts, nil diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 9b0e07292f..512506476c 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -9,7 +9,6 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - gh "github.com/google/go-github/v82/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -100,7 +99,7 @@ func Test_ProjectsList_ListProjects(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -140,7 +139,7 @@ func Test_ProjectsList_ListProjectFields(t *testing.T) { GetOrgsProjectsV2FieldsByProject: mockResponse(t, http.StatusOK, fields), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -167,7 +166,7 @@ func Test_ProjectsList_ListProjectFields(t *testing.T) { t.Run("missing project_number", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -196,7 +195,7 @@ func Test_ProjectsList_ListProjectItems(t *testing.T) { GetOrgsProjectsV2ItemsByProject: mockResponse(t, http.StatusOK, items), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -249,7 +248,7 @@ func Test_ProjectsGet_GetProject(t *testing.T) { GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -274,7 +273,7 @@ func Test_ProjectsGet_GetProject(t *testing.T) { t.Run("unknown method", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -304,7 +303,7 @@ func Test_ProjectsGet_GetProjectField(t *testing.T) { GetOrgsProjectsV2FieldsByProjectByFieldID: mockResponse(t, http.StatusOK, field), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -330,7 +329,7 @@ func Test_ProjectsGet_GetProjectField(t *testing.T) { t.Run("missing field_id", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -360,7 +359,7 @@ func Test_ProjectsGet_GetProjectItem(t *testing.T) { GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, item), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -386,7 +385,7 @@ func Test_ProjectsGet_GetProjectItem(t *testing.T) { t.Run("missing item_id", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -711,7 +710,7 @@ func Test_ProjectsWrite_UpdateProjectItem(t *testing.T) { PatchOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, updatedItem), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -741,7 +740,7 @@ func Test_ProjectsWrite_UpdateProjectItem(t *testing.T) { t.Run("missing updated_field", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -772,7 +771,7 @@ func Test_ProjectsWrite_DeleteProjectItem(t *testing.T) { }), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -795,7 +794,7 @@ func Test_ProjectsWrite_DeleteProjectItem(t *testing.T) { t.Run("missing item_id", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -864,7 +863,7 @@ func Test_ProjectsList_ListProjectStatusUpdates(t *testing.T) { gqlClient := githubv4.NewClient(gqlMockedClient) deps := BaseDeps{ - Client: gh.NewClient(restClient), + Client: mustNewGHClient(t, restClient), GQLClient: gqlClient, } handler := toolDef.Handler(deps) diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 9c2a098755..3653c906ba 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -8,7 +8,7 @@ import ( "net/http" "github.com/go-viper/mapstructure/v2" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" @@ -36,7 +36,7 @@ Possible options: 3. get_status - Get combined commit status of a head commit in a pull request. 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned. 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results. - 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. + 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. Use with pagination parameters to control the number of results returned. 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned. 8. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR. `, @@ -124,7 +124,7 @@ Possible options: result, err := GetPullRequestReviewComments(ctx, gqlClient, deps, owner, repo, pullNumber, cursorPagination) return result, nil, err case "get_reviews": - result, err := GetPullRequestReviews(ctx, client, deps, owner, repo, pullNumber) + result, err := GetPullRequestReviews(ctx, client, deps, owner, repo, pullNumber, pagination) return result, nil, err case "get_comments": result, err := GetIssueComments(ctx, client, deps, owner, repo, pullNumber, pagination) @@ -478,14 +478,17 @@ func GetPullRequestReviewComments(ctx context.Context, gqlClient *githubv4.Clien return MarshalledTextResult(convertToMinimalReviewThreadsResponse(query)), nil } -func GetPullRequestReviews(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { +func GetPullRequestReviews(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { cache, err := deps.GetRepoAccessCache(ctx) if err != nil { return nil, fmt.Errorf("failed to get repo access cache: %w", err) } ff := deps.GetFlags(ctx) - reviews, resp, err := client.PullRequests.ListReviews(ctx, owner, repo, pullNumber, nil) + reviews, resp, err := client.PullRequests.ListReviews(ctx, owner, repo, pullNumber, &github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get pull request reviews", diff --git a/pkg/github/pullrequests_granular.go b/pkg/github/pullrequests_granular.go index 4a616f1b25..30d7f78d62 100644 --- a/pkg/github/pullrequests_granular.go +++ b/pkg/github/pullrequests_granular.go @@ -12,7 +12,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - gogithub "github.com/google/go-github/v82/github" + gogithub "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 4f0ec9493b..29339ee7db 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -10,7 +10,7 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -95,7 +95,7 @@ func Test_GetPullRequest(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient()) deps := BaseDeps{ Client: client, @@ -327,7 +327,7 @@ func Test_UpdatePullRequest(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) gqlClient := githubv4.NewClient(nil) deps := BaseDeps{ Client: client, @@ -511,7 +511,7 @@ func Test_UpdatePullRequest_Draft(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // For draft-only tests, we need to mock both GraphQL and the final REST GET call - restClient := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockUpdatedPR), })) gqlClient := githubv4.NewClient(tc.mockedClient) @@ -641,7 +641,7 @@ func Test_ListPullRequests(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := ListPullRequests(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -759,7 +759,7 @@ func Test_MergePullRequest(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := MergePullRequest(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -1038,7 +1038,7 @@ func Test_SearchPullRequests(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := SearchPullRequests(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -1197,7 +1197,7 @@ func Test_GetPullRequestFiles(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := PullRequestRead(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -1357,7 +1357,7 @@ func Test_GetPullRequestStatus(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := PullRequestRead(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -1513,7 +1513,7 @@ func Test_GetPullRequestCheckRuns(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := PullRequestRead(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -1641,7 +1641,7 @@ func Test_UpdatePullRequestBranch(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := UpdatePullRequestBranch(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -1949,7 +1949,7 @@ func Test_GetPullRequestComments(t *testing.T) { flags := stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled}) serverTool := PullRequestRead(translations.NullTranslationHelper) deps := BaseDeps{ - Client: github.NewClient(nil), + Client: mustNewGHClient(t, nil), GQLClient: gqlClient, RepoAccessCache: cache, Flags: flags, @@ -2050,13 +2050,39 @@ func Test_GetPullRequestReviews(t *testing.T) { expectError: false, expectedReviews: mockReviews, }, + { + name: "successful reviews fetch with pagination", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsReviewsByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockReviews), + ), + }), + requestArgs: map[string]any{ + "method": "get_reviews", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "page": float64(2), + "perPage": float64(10), + }, + expectError: false, + expectedReviews: mockReviews, + }, { name: "reviews fetch fails", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposPullsReviewsByOwnerByRepoByPullNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), + GetReposPullsReviewsByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{ + "page": "1", + "per_page": "30", + }).andThen( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), }), requestArgs: map[string]any{ "method": "get_reviews", @@ -2107,7 +2133,7 @@ func Test_GetPullRequestReviews(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) var restClient *github.Client if tc.lockdownEnabled { restClient = mockRESTPermissionServer(t, "read", map[string]string{ @@ -2274,7 +2300,7 @@ func Test_CreatePullRequest(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := CreatePullRequest(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -2330,7 +2356,7 @@ func Test_CreatePullRequest_InsidersMode_UIGate(t *testing.T) { serverTool := CreatePullRequest(translations.NullTranslationHelper) - client := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PostReposPullsByOwnerByRepo: mockResponse(t, http.StatusCreated, mockPR), })) @@ -3346,7 +3372,7 @@ index 5d6e7b2..8a4f5c3 100644 t.Parallel() // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := PullRequestRead(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -3583,7 +3609,7 @@ func TestAddReplyToPullRequestComment(t *testing.T) { t.Parallel() // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := AddReplyToPullRequestComment(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 0ebacc6668..2ca1cf3a7a 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -10,12 +10,13 @@ import ( "strings" ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/octicons" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -653,6 +654,20 @@ func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool ) } +// FetchRepoIsPrivate returns whether a repository is private. It is a thin +// wrapper around the GitHub Repositories.Get endpoint provided as a shared +// helper for IFC label computation across tools. +func FetchRepoIsPrivate(ctx context.Context, client *github.Client, owner, repo string) (bool, error) { + r, resp, err := client.Repositories.Get(ctx, owner, repo) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return false, err + } + return r.GetPrivate(), nil +} + // GetFileContents creates a tool to get the contents of a file or directory from a GitHub repository. func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( @@ -725,6 +740,36 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultError("failed to get GitHub client"), nil, nil } + // attachIFC adds the IFC label to a successful tool result when + // InsidersMode is enabled. The visibility lookup is performed + // lazily on first use and cached because GetFileContents has + // many possible return paths and would otherwise re-fetch on + // each. If the visibility lookup fails we skip the label rather + // than misclassify the result; the failure is not cached so a + // later return path can retry. + var ( + ifcLabelKnown bool + ifcIsPrivate bool + ) + attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult { + if r == nil || r.IsError || !deps.GetFlags(ctx).InsidersMode { + return r + } + if !ifcLabelKnown { + isPrivate, err := FetchRepoIsPrivate(ctx, client, owner, repo) + if err != nil { + return r + } + ifcIsPrivate = isPrivate + ifcLabelKnown = true + } + if r.Meta == nil { + r.Meta = mcp.Meta{} + } + r.Meta["ifc"] = ifc.LabelGetFileContents(ifcIsPrivate) + return r + } + rawOpts, fallbackUsed, err := resolveGitReference(ctx, client, owner, repo, ref, sha) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to resolve git reference: %s", err)), nil, nil @@ -746,7 +791,8 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool // The path does not point to a file or directory. // Instead let's try to find it in the Git Tree by matching the end of the path. if err != nil || (fileContent == nil && dirContent == nil) { - return matchFiles(ctx, client, owner, repo, ref, path, rawOpts, 0) + res, data, err := matchFiles(ctx, client, owner, repo, ref, path, rawOpts, 0) + return attachIFC(res), data, err } if fileContent != nil && fileContent.SHA != nil { @@ -776,7 +822,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool Text: "", MIMEType: "text/plain", } - return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded empty file (SHA: %s)%s", fileSHA, successNote), result), nil, nil + return attachIFC(utils.NewToolResultResource(fmt.Sprintf("successfully downloaded empty file (SHA: %s)%s", fileSHA, successNote), result)), nil, nil } // For files >= 1MB, return a ResourceLink instead of content @@ -789,10 +835,10 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool Title: fmt.Sprintf("File: %s", path), Size: &size, } - return utils.NewToolResultResourceLink( + return attachIFC(utils.NewToolResultResourceLink( fmt.Sprintf("File %s is too large to display (%d bytes). Use the download URL to fetch the content: %s (SHA: %s)%s", path, fileSize, fileContent.GetDownloadURL(), fileSHA, successNote), - resourceLink), nil, nil + resourceLink)), nil, nil } // For files < 1MB, get content directly from Contents API @@ -820,7 +866,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool Text: content, MIMEType: contentType, } - return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded text file (SHA: %s)%s", fileSHA, successNote), result), nil, nil + return attachIFC(utils.NewToolResultResource(fmt.Sprintf("successfully downloaded text file (SHA: %s)%s", fileSHA, successNote), result)), nil, nil } // Binary content - encode as base64 blob @@ -830,14 +876,14 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool Blob: []byte(blobContent), MIMEType: contentType, } - return utils.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)%s", fileSHA, successNote), result), nil, nil + return attachIFC(utils.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)%s", fileSHA, successNote), result)), nil, nil } else if dirContent != nil { // file content or file SHA is nil which means it's a directory r, err := json.Marshal(dirContent) if err != nil { return utils.NewToolResultError("failed to marshal response"), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + return attachIFC(utils.NewToolResultText(string(r))), nil, nil } return utils.NewToolResultError("failed to get file contents"), nil, nil @@ -2156,3 +2202,111 @@ func UnstarRepository(t translations.TranslationHelperFunc) inventory.ServerTool }, ) } + +// ListRepositoryCollaborators creates a tool to list collaborators of a GitHub repository. +func ListRepositoryCollaborators(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataRepos, + mcp.Tool{ + Name: "list_repository_collaborators", + Description: t("TOOL_LIST_REPOSITORY_COLLABORATORS_DESCRIPTION", "List collaborators of a GitHub repository. Results are paginated; the response includes `nextPage`, `prevPage`, `firstPage`, and `lastPage` fields. To get the next page, use the `nextPage` value as the `page` parameter."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_LIST_REPOSITORY_COLLABORATORS_USER_TITLE", "List repository collaborators"), + ReadOnlyHint: true, + }, + InputSchema: func() *jsonschema.Schema { + schema := WithPagination(&jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "affiliation": { + Type: "string", + Description: "Filter by affiliation. Can be one of: 'outside' (outside collaborators), 'direct' (all with permissions regardless of org membership), 'all' (all collaborators). Default: 'all'", + Enum: []any{"outside", "direct", "all"}, + }, + }, + Required: []string{"owner", "repo"}, + }) + schema.Properties["page"].Description = "Page number for pagination (default 1, min 1)" + schema.Properties["perPage"].Description = "Results per page for pagination (default 30, min 1, max 100)" + return schema + }(), + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + affiliation, err := OptionalParam[string](args, "affiliation") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + opts := &github.ListCollaboratorsOptions{ + Affiliation: affiliation, + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + } + + collaborators, resp, err := client.Repositories.ListCollaborators(ctx, owner, repo, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list collaborators", + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list collaborators", resp, body), nil, nil + } + + result := make([]MinimalCollaborator, 0, len(collaborators)) + for _, c := range collaborators { + result = append(result, MinimalCollaborator{ + Login: c.GetLogin(), + ID: c.GetID(), + RoleName: c.GetRoleName(), + }) + } + + response := map[string]any{ + "items": result, + "nextPage": resp.NextPage, + "prevPage": resp.PrevPage, + "firstPage": resp.FirstPage, + "lastPage": resp.LastPage, + } + + return MarshalledTextResult(response), nil, nil + }, + ) +} diff --git a/pkg/github/repositories_helper.go b/pkg/github/repositories_helper.go index a347ebdd6c..be377f773e 100644 --- a/pkg/github/repositories_helper.go +++ b/pkg/github/repositories_helper.go @@ -10,7 +10,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index c21709dad4..a44bad65b6 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -14,7 +14,7 @@ import ( "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" @@ -412,8 +412,9 @@ func Test_GetFileContents(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) - mockRawClient := raw.NewClient(client, &url.URL{Scheme: "https", Host: "raw.example.com", Path: "/"}) + client := mustNewGHClient(t, tc.mockedClient) + mockRawClient, err := raw.NewClient(client, &url.URL{Scheme: "https", Host: "raw.example.com", Path: "/"}) + require.NoError(t, err) deps := BaseDeps{ Client: client, RawClient: mockRawClient, @@ -477,6 +478,149 @@ func Test_GetFileContents(t *testing.T) { } } +func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { + t.Parallel() + + serverTool := GetFileContents(translations.NullTranslationHelper) + + mockRawContent := []byte("hello") + + makeMockClient := func(isPrivate bool) *http.Client { + return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, map[string]any{ + "name": "repo", + "default_branch": "main", + "private": isPrivate, + }), + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + encodedContent := base64.StdEncoding.EncodeToString(mockRawContent) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("README.md"), + Path: github.Ptr("README.md"), + SHA: github.Ptr("abc123"), + Type: github.Ptr("file"), + Content: github.Ptr(encodedContent), + Size: github.Ptr(len(mockRawContent)), + Encoding: github.Ptr("base64"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }, + }) + } + + reqParams := map[string]any{ + "owner": "octocat", + "repo": "repo", + "path": "README.md", + "ref": "refs/heads/main", + } + + t.Run("insiders mode disabled omits ifc label from result meta", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(false)), + Flags: FeatureFlags{InsidersMode: false}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + assert.Nil(t, result.Meta, "result meta should be nil when insiders mode is disabled") + }) + + t.Run("insiders mode enabled on public repo emits public untrusted label", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(false)), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcLabel, ok := result.Meta["ifc"] + require.True(t, ok, "result meta should contain ifc key") + + ifcJSON, err := json.Marshal(ifcLabel) + require.NoError(t, err) + var ifcMap map[string]any + require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap)) + + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "public", ifcMap["confidentiality"]) + }) + + t.Run("insiders mode enabled on private repo emits private trusted label", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(true)), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcLabel, ok := result.Meta["ifc"] + require.True(t, ok, "result meta should contain ifc key") + + ifcJSON, err := json.Marshal(ifcLabel) + require.NoError(t, err) + var ifcMap map[string]any + require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap)) + + assert.Equal(t, "trusted", ifcMap["integrity"]) + assert.Equal(t, "private", ifcMap["confidentiality"]) + }) + + t.Run("insiders mode skips ifc label when visibility lookup fails", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposByOwnerByRepo: mockResponse(t, http.StatusInternalServerError, "boom"), + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + encodedContent := base64.StdEncoding.EncodeToString(mockRawContent) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("README.md"), + Path: github.Ptr("README.md"), + SHA: github.Ptr("abc123"), + Type: github.Ptr("file"), + Content: github.Ptr(encodedContent), + Size: github.Ptr(len(mockRawContent)), + Encoding: github.Ptr("base64"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }, + }) + deps := BaseDeps{ + Client: mustNewGHClient(t, mockedClient), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, "tool call should still succeed when visibility lookup fails") + + if result.Meta != nil { + _, hasIFC := result.Meta["ifc"] + assert.False(t, hasIFC, "ifc label should be omitted when visibility lookup fails") + } + }) +} + func Test_ForkRepository(t *testing.T) { // Verify tool definition once serverTool := ForkRepository(translations.NullTranslationHelper) @@ -547,7 +691,7 @@ func Test_ForkRepository(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -719,7 +863,7 @@ func Test_CreateBranch(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -845,7 +989,7 @@ func Test_GetCommit(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -1136,7 +1280,7 @@ func Test_ListCommits(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -1493,7 +1637,7 @@ func Test_CreateOrUpdateFile(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -1682,7 +1826,7 @@ func Test_CreateRepository(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -2420,7 +2564,7 @@ func Test_PushFiles(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -2541,7 +2685,7 @@ func Test_ListBranches(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create mock client - mockClient := github.NewClient(NewMockedHTTPClient(tt.mockResponses...)) + mockClient := mustNewGHClient(t, NewMockedHTTPClient(tt.mockResponses...)) deps := BaseDeps{ Client: mockClient, } @@ -2729,7 +2873,7 @@ func Test_DeleteFile(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -2856,7 +3000,7 @@ func Test_ListTags(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -3047,7 +3191,7 @@ func Test_GetTag(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -3173,7 +3317,7 @@ func Test_ListReleases(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -3199,6 +3343,7 @@ func Test_ListReleases(t *testing.T) { }) } } + func Test_GetLatestRelease(t *testing.T) { serverTool := GetLatestRelease(translations.NullTranslationHelper) tool := serverTool.Tool @@ -3264,7 +3409,7 @@ func Test_GetLatestRelease(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -3412,7 +3557,7 @@ func Test_GetReleaseByTag(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -3857,7 +4002,7 @@ func Test_resolveGitReference(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockSetup()) + client := mustNewGHClient(t, tc.mockSetup()) opts, _, err := resolveGitReference(ctx, client, owner, repo, tc.ref, tc.sha) if tc.expectError { @@ -4003,7 +4148,7 @@ func Test_ListStarredRepositories(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -4104,7 +4249,7 @@ func Test_StarRepository(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -4195,7 +4340,7 @@ func Test_UnstarRepository(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -4224,3 +4369,149 @@ func Test_UnstarRepository(t *testing.T) { }) } } + +func Test_ListRepositoryCollaborators(t *testing.T) { + // Verify tool definition once + serverTool := ListRepositoryCollaborators(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + + assert.Equal(t, "list_repository_collaborators", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.True(t, tool.Annotations.ReadOnlyHint) + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "affiliation") + assert.Contains(t, schema.Properties, "page") + assert.Contains(t, schema.Properties, "perPage") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"}) + + mockCollaborators := []*github.User{ + { + Login: github.Ptr("user1"), + ID: github.Ptr(int64(101)), + RoleName: github.Ptr("admin"), + }, + { + Login: github.Ptr("user2"), + ID: github.Ptr(int64(102)), + RoleName: github.Ptr("write"), + }, + } + + tests := []struct { + name string + args map[string]any + mockResponses []MockBackendOption + wantErr bool + errContains string + }{ + { + name: "success", + args: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + mockResponses: []MockBackendOption{ + WithRequestMatch( + ListCollaborators, + mockCollaborators, + ), + }, + }, + { + name: "success with affiliation filter", + args: map[string]any{ + "owner": "owner", + "repo": "repo", + "affiliation": "direct", + }, + mockResponses: []MockBackendOption{ + WithRequestMatch( + ListCollaborators, + mockCollaborators, + ), + }, + }, + { + name: "missing owner", + args: map[string]any{ + "repo": "repo", + }, + mockResponses: []MockBackendOption{}, + errContains: "missing required parameter: owner", + }, + { + name: "missing repo", + args: map[string]any{ + "owner": "owner", + }, + mockResponses: []MockBackendOption{}, + errContains: "missing required parameter: repo", + }, + { + name: "empty collaborators returns empty array", + args: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + mockResponses: []MockBackendOption{ + WithRequestMatch( + ListCollaborators, + []*github.User{}, + ), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := mustNewGHClient(t, NewMockedHTTPClient(tt.mockResponses...)) + deps := BaseDeps{ + Client: mockClient, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(tt.args) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + 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) + + var response struct { + Items []MinimalCollaborator `json:"items"` + NextPage int `json:"nextPage"` + PrevPage int `json:"prevPage"` + FirstPage int `json:"firstPage"` + LastPage int `json:"lastPage"` + } + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + + if tt.name == "empty collaborators returns empty array" { + assert.Empty(t, response.Items) + return + } + + collaborators := response.Items + assert.Len(t, collaborators, 2) + assert.Equal(t, "user1", collaborators[0].Login) + assert.Equal(t, int64(101), collaborators[0].ID) + assert.Equal(t, "admin", collaborators[0].RoleName) + assert.Equal(t, "user2", collaborators[1].Login) + assert.Equal(t, int64(102), collaborators[1].ID) + assert.Equal(t, "write", collaborators[1].RoleName) + }) + } +} diff --git a/pkg/github/repository_resource.go b/pkg/github/repository_resource.go index be86cc4519..3ab4cf3906 100644 --- a/pkg/github/repository_resource.go +++ b/pkg/github/repository_resource.go @@ -17,7 +17,7 @@ import ( "github.com/github/github-mcp-server/pkg/octicons" "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/yosida95/uritemplate/v3" ) diff --git a/pkg/github/repository_resource_completions.go b/pkg/github/repository_resource_completions.go index ff9e23398a..18e7eb5f01 100644 --- a/pkg/github/repository_resource_completions.go +++ b/pkg/github/repository_resource_completions.go @@ -6,7 +6,7 @@ import ( "fmt" "strings" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/repository_resource_completions_test.go b/pkg/github/repository_resource_completions_test.go index e5f1a35f93..33df2761e6 100644 --- a/pkg/github/repository_resource_completions_test.go +++ b/pkg/github/repository_resource_completions_test.go @@ -6,7 +6,7 @@ import ( "fmt" "testing" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/repository_resource_test.go b/pkg/github/repository_resource_test.go index f0fba30dfb..cb57bae545 100644 --- a/pkg/github/repository_resource_test.go +++ b/pkg/github/repository_resource_test.go @@ -8,7 +8,6 @@ import ( "testing" "github.com/github/github-mcp-server/pkg/raw" - "github.com/google/go-github/v82/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" ) @@ -246,8 +245,9 @@ func Test_repositoryResourceContents(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - mockRawClient := raw.NewClient(client, base) + client := mustNewGHClient(t, tc.mockedClient) + mockRawClient, err := raw.NewClient(client, base) + require.NoError(t, err) deps := BaseDeps{ Client: client, RawClient: mockRawClient, @@ -290,8 +290,9 @@ func Test_repositoryResourceContentsHandler_NetworkError(t *testing.T) { networkErr := errors.New("network error: connection refused") httpClient := &http.Client{Transport: &errorTransport{err: networkErr}} - client := github.NewClient(httpClient) - mockRawClient := raw.NewClient(client, base) + client := mustNewGHClient(t, httpClient) + mockRawClient, err := raw.NewClient(client, base) + require.NoError(t, err) deps := BaseDeps{ Client: client, RawClient: mockRawClient, diff --git a/pkg/github/search.go b/pkg/github/search.go index d5ddb4a72a..a4acc44489 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -8,11 +8,12 @@ import ( "net/http" ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -161,11 +162,37 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo } } - return utils.NewToolResultText(string(r)), nil, nil + callResult := utils.NewToolResultText(string(r)) + if deps.GetFlags(ctx).InsidersMode { + attachSearchRepositoriesIFCLabel(result.Repositories, callResult) + } + return callResult, nil, nil }, ) } +// attachSearchRepositoriesIFCLabel joins per-repository IFC labels across +// every matched repository and attaches the result to callResult. Visibility +// is read directly from the search response — no extra API call. The join +// math is shared with search_issues via ifc.LabelSearchIssues: integrity is +// always untrusted; confidentiality is private if any matched repository is +// private, otherwise public. +func attachSearchRepositoriesIFCLabel(repos []*github.Repository, callResult *mcp.CallToolResult) { + if callResult == nil || callResult.IsError { + return + } + + visibilities := make([]bool, 0, len(repos)) + for _, repo := range repos { + visibilities = append(visibilities, repo.GetPrivate()) + } + + if callResult.Meta == nil { + callResult.Meta = mcp.Meta{} + } + callResult.Meta["ifc"] = ifc.LabelSearchIssues(visibilities) +} + // SearchCode creates a tool to search for code across GitHub repositories. func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { schema := &jsonschema.Schema{ @@ -220,8 +247,9 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { } opts := &github.SearchOptions{ - Sort: sort, - Order: order, + Sort: sort, + Order: order, + TextMatch: true, ListOptions: github.ListOptions{ PerPage: pagination.PerPage, Page: pagination.Page, @@ -251,7 +279,27 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to search code", resp, body), nil, nil } - r, err := json.Marshal(result) + minimalItems := make([]MinimalCodeResult, 0, len(result.CodeResults)) + for _, code := range result.CodeResults { + item := MinimalCodeResult{ + Name: code.GetName(), + Path: code.GetPath(), + SHA: code.GetSHA(), + TextMatches: code.TextMatches, + } + if code.Repository != nil { + item.Repository = code.Repository.GetFullName() + } + minimalItems = append(minimalItems, item) + } + + minimalResult := &MinimalCodeSearchResult{ + TotalCount: result.GetTotal(), + IncompleteResults: result.GetIncompleteResults(), + Items: minimalItems, + } + + r, err := json.Marshal(minimalResult) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index 85eb21bcb5..74a3ca52fc 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -123,7 +123,7 @@ func Test_SearchRepositories(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -163,9 +163,119 @@ func Test_SearchRepositories(t *testing.T) { assert.Equal(t, *tc.expectedResult.Repositories[i].FullName, repo.FullName) assert.Equal(t, *tc.expectedResult.Repositories[i].HTMLURL, repo.HTMLURL) } + }) + } +} + +func Test_SearchRepositories_IFC_InsidersMode(t *testing.T) { + t.Parallel() + + serverTool := SearchRepositories(translations.NullTranslationHelper) + + type repoFixture struct { + owner string + name string + isPrivate bool + } + makeRepo := func(r repoFixture) *github.Repository { + return &github.Repository{ + ID: github.Ptr(int64(1)), + Name: github.Ptr(r.name), + FullName: github.Ptr(r.owner + "/" + r.name), + Private: github.Ptr(r.isPrivate), + Owner: &github.User{Login: github.Ptr(r.owner)}, + } + } + + makeMockClient := func(repos []repoFixture) *http.Client { + searchResult := &github.RepositoriesSearchResult{ + Total: github.Ptr(len(repos)), + IncompleteResults: github.Ptr(false), + } + for _, r := range repos { + searchResult.Repositories = append(searchResult.Repositories, makeRepo(r)) + } + return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchRepositories: mockResponse(t, http.StatusOK, searchResult), }) } + + reqParams := map[string]any{"query": "octocat"} + + t.Run("insiders mode disabled omits ifc label", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient([]repoFixture{{owner: "octocat", name: "public-repo"}})), + Flags: FeatureFlags{InsidersMode: false}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Nil(t, result.Meta) + }) + + t.Run("insiders mode all public emits public untrusted", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient([]repoFixture{ + {owner: "octocat", name: "public-a"}, + {owner: "octocat", name: "public-b"}, + })), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "public", ifcMap["confidentiality"]) + }) + + t.Run("insiders mode any private match emits private untrusted", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient([]repoFixture{ + {owner: "octocat", name: "private-repo", isPrivate: true}, + {owner: "octocat", name: "public-repo"}, + })), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "private", ifcMap["confidentiality"]) + }) + + t.Run("insiders mode empty results emits public untrusted", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(nil)), + Flags: FeatureFlags{InsidersMode: true}, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcMap := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "public", ifcMap["confidentiality"]) + }) } func Test_SearchRepositories_FullOutput(t *testing.T) { @@ -194,7 +304,7 @@ func Test_SearchRepositories_FullOutput(t *testing.T) { ), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) serverTool := SearchRepositories(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -252,22 +362,35 @@ func Test_SearchCode(t *testing.T) { IncompleteResults: github.Ptr(false), CodeResults: []*github.CodeResult{ { - Name: github.Ptr("file1.go"), - Path: github.Ptr("path/to/file1.go"), - SHA: github.Ptr("abc123def456"), - HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/path/to/file1.go"), - Repository: &github.Repository{Name: github.Ptr("repo"), FullName: github.Ptr("owner/repo")}, + Name: github.Ptr("file1.go"), + Path: github.Ptr("path/to/file1.go"), + SHA: github.Ptr("abc123def456"), + Repository: &github.Repository{ + Name: github.Ptr("repo"), + FullName: github.Ptr("owner/repo"), + }, + TextMatches: []*github.TextMatch{ + { + Fragment: github.Ptr("func main() { fmt.Println(\"hello\") }"), + }, + }, }, { - Name: github.Ptr("file2.go"), - Path: github.Ptr("path/to/file2.go"), - SHA: github.Ptr("def456abc123"), - HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/path/to/file2.go"), - Repository: &github.Repository{Name: github.Ptr("repo"), FullName: github.Ptr("owner/repo")}, + Name: github.Ptr("file2.go"), + Path: github.Ptr("path/to/file2.go"), + SHA: github.Ptr("def456abc123"), + Repository: &github.Repository{ + Name: github.Ptr("repo"), + FullName: github.Ptr("owner/repo"), + }, }, }, } + textMatchAcceptHeader := map[string]string{ + "Accept": "text-match", + } + tests := []struct { name string mockedClient *http.Client @@ -285,7 +408,7 @@ func Test_SearchCode(t *testing.T) { "order": "desc", "page": "1", "per_page": "30", - }).andThen( + }).withHeaders(textMatchAcceptHeader).andThen( mockResponse(t, http.StatusOK, mockSearchResult), ), }), @@ -306,7 +429,7 @@ func Test_SearchCode(t *testing.T) { "q": "fmt.Println language:go", "page": "1", "per_page": "30", - }).andThen( + }).withHeaders(textMatchAcceptHeader).andThen( mockResponse(t, http.StatusOK, mockSearchResult), ), }), @@ -335,7 +458,7 @@ func Test_SearchCode(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -359,22 +482,28 @@ func Test_SearchCode(t *testing.T) { require.NoError(t, err) require.False(t, result.IsError) - // Parse the result and get the text content if no error textContent := getTextResult(t, result) - // Unmarshal and verify the result - var returnedResult github.CodeSearchResult + var returnedResult MinimalCodeSearchResult err = json.Unmarshal([]byte(textContent.Text), &returnedResult) require.NoError(t, err) - assert.Equal(t, *tc.expectedResult.Total, *returnedResult.Total) - assert.Equal(t, *tc.expectedResult.IncompleteResults, *returnedResult.IncompleteResults) - assert.Len(t, returnedResult.CodeResults, len(tc.expectedResult.CodeResults)) - for i, code := range returnedResult.CodeResults { - assert.Equal(t, *tc.expectedResult.CodeResults[i].Name, *code.Name) - assert.Equal(t, *tc.expectedResult.CodeResults[i].Path, *code.Path) - assert.Equal(t, *tc.expectedResult.CodeResults[i].SHA, *code.SHA) - assert.Equal(t, *tc.expectedResult.CodeResults[i].HTMLURL, *code.HTMLURL) - assert.Equal(t, *tc.expectedResult.CodeResults[i].Repository.FullName, *code.Repository.FullName) + assert.Equal(t, *tc.expectedResult.Total, returnedResult.TotalCount) + assert.Equal(t, *tc.expectedResult.IncompleteResults, returnedResult.IncompleteResults) + assert.Len(t, returnedResult.Items, len(tc.expectedResult.CodeResults)) + for i, code := range returnedResult.Items { + assert.Equal(t, tc.expectedResult.CodeResults[i].GetName(), code.Name) + assert.Equal(t, tc.expectedResult.CodeResults[i].GetPath(), code.Path) + assert.Equal(t, tc.expectedResult.CodeResults[i].GetSHA(), code.SHA) + assert.Equal(t, tc.expectedResult.CodeResults[i].Repository.GetFullName(), code.Repository) + } + + // Verify text matches are included when present + if len(tc.expectedResult.CodeResults[0].TextMatches) > 0 { + require.NotEmpty(t, returnedResult.Items[0].TextMatches) + assert.Equal(t, + tc.expectedResult.CodeResults[0].TextMatches[0].GetFragment(), + returnedResult.Items[0].TextMatches[0].GetFragment(), + ) } }) } @@ -520,7 +649,7 @@ func Test_SearchUsers(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -683,7 +812,7 @@ func Test_SearchOrgs(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go index c5502f6308..ac3aec90c9 100644 --- a/pkg/github/search_utils.go +++ b/pkg/github/search_utils.go @@ -10,7 +10,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -37,13 +37,35 @@ func hasTypeFilter(query string) bool { return hasFilter(query, "type") } +// searchPostProcessFn is invoked after a successful search response, before +// the call result is returned. It may attach additional metadata (such as IFC +// labels) to the call result based on the search payload. +type searchPostProcessFn func(ctx context.Context, result *github.IssuesSearchResult, callResult *mcp.CallToolResult) + +type searchConfig struct { + postProcess searchPostProcessFn +} + +type searchOption func(*searchConfig) + +// withSearchPostProcess registers a callback invoked after a successful search +// response. The callback may mutate the call result (e.g. to attach _meta.ifc). +func withSearchPostProcess(fn searchPostProcessFn) searchOption { + return func(c *searchConfig) { c.postProcess = fn } +} + func searchHandler( ctx context.Context, getClient GetClientFn, args map[string]any, searchType string, errorPrefix string, + options ...searchOption, ) (*mcp.CallToolResult, error) { + cfg := searchConfig{} + for _, opt := range options { + opt(&cfg) + } query, err := RequiredParam[string](args, "query") if err != nil { return utils.NewToolResultError(err.Error()), nil @@ -113,5 +135,9 @@ func searchHandler( return utils.NewToolResultErrorFromErr(errorPrefix+": failed to marshal response", err), nil } - return utils.NewToolResultText(string(r)), nil + callResult := utils.NewToolResultText(string(r)) + if cfg.postProcess != nil { + cfg.postProcess(ctx, result, callResult) + } + return callResult, nil } diff --git a/pkg/github/secret_scanning.go b/pkg/github/secret_scanning.go index 676c2c1625..5cbe52c42a 100644 --- a/pkg/github/secret_scanning.go +++ b/pkg/github/secret_scanning.go @@ -12,7 +12,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/secret_scanning_test.go b/pkg/github/secret_scanning_test.go index 7c53de35c5..1aa451e053 100644 --- a/pkg/github/secret_scanning_test.go +++ b/pkg/github/secret_scanning_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -79,7 +79,7 @@ func Test_GetSecretScanningAlert(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -211,7 +211,7 @@ func Test_ListSecretScanningAlerts(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } diff --git a/pkg/github/security_advisories.go b/pkg/github/security_advisories.go index e86e220eaf..ec84e27b15 100644 --- a/pkg/github/security_advisories.go +++ b/pkg/github/security_advisories.go @@ -12,7 +12,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/security_advisories_test.go b/pkg/github/security_advisories_test.go index 3d4df43e63..f45c2e4210 100644 --- a/pkg/github/security_advisories_test.go +++ b/pkg/github/security_advisories_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -92,7 +92,7 @@ func Test_ListGlobalSecurityAdvisories(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} handler := toolDef.Handler(deps) @@ -204,7 +204,7 @@ func Test_GetGlobalSecurityAdvisory(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} handler := toolDef.Handler(deps) @@ -337,7 +337,7 @@ func Test_ListRepositorySecurityAdvisories(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} handler := toolDef.Handler(deps) @@ -467,7 +467,7 @@ func Test_ListOrgRepositorySecurityAdvisories(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} handler := toolDef.Handler(deps) diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go index 264ffa50fe..7af388f731 100644 --- a/pkg/github/server_test.go +++ b/pkg/github/server_test.go @@ -16,7 +16,7 @@ import ( "github.com/github/github-mcp-server/pkg/observability/metrics" "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/translations" - gogithub "github.com/google/go-github/v82/github" + gogithub "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -80,9 +80,10 @@ func stubExporters() observability.Exporters { return obs } -func stubClientFnFromHTTP(httpClient *http.Client) func(context.Context) (*gogithub.Client, error) { +func stubClientFnFromHTTP(t *testing.T, httpClient *http.Client) func(context.Context) (*gogithub.Client, error) { + t.Helper() return func(_ context.Context) (*gogithub.Client, error) { - return gogithub.NewClient(httpClient), nil + return mustNewGHClient(t, httpClient), nil } } @@ -110,7 +111,7 @@ func stubRepoAccessCache(restClient *gogithub.Client, ttl time.Duration) *lockdo func mockRESTPermissionServer(t *testing.T, defaultPerm string, overrides map[string]string) *gogithub.Client { t.Helper() - return gogithub.NewClient(MockHTTPClientWithHandler(func(w http.ResponseWriter, r *http.Request) { + return mustNewGHClient(t, MockHTTPClientWithHandler(func(w http.ResponseWriter, r *http.Request) { perm := defaultPerm for user, p := range overrides { if strings.Contains(r.URL.Path, "/collaborators/"+user+"/") { diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 559088f6d6..f4c653bf8d 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -7,7 +7,7 @@ import ( "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/shurcooL/githubv4" ) @@ -199,6 +199,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { ListStarredRepositories(t), StarRepository(t), UnstarRepository(t), + ListRepositoryCollaborators(t), // Git tools GetRepositoryTree(t), @@ -258,6 +259,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { ListDiscussions(t), GetDiscussion(t), GetDiscussionComments(t), + DiscussionCommentWrite(t), ListDiscussionCategories(t), // Actions tools diff --git a/pkg/ifc/ifc.go b/pkg/ifc/ifc.go index cf0d72114f..e6eeb407bc 100644 --- a/pkg/ifc/ifc.go +++ b/pkg/ifc/ifc.go @@ -13,17 +13,96 @@ const ( type Confidentiality string const ( - ConfidentialityPublic Confidentiality = "public" + ConfidentialityPublic Confidentiality = "public" + ConfidentialityPrivate Confidentiality = "private" ) type SecurityLabel struct { - Integrity Integrity `json:"integrity"` - Confidentiality []Confidentiality `json:"confidentiality"` + Integrity Integrity `json:"integrity"` + Confidentiality Confidentiality `json:"confidentiality"` } -func LabelGetMe() SecurityLabel { +// PublicTrusted returns a label for trusted, publicly readable data. +func PublicTrusted() SecurityLabel { return SecurityLabel{ Integrity: IntegrityTrusted, - Confidentiality: []Confidentiality{ConfidentialityPublic}, + Confidentiality: ConfidentialityPublic, + } +} + +// PublicUntrusted returns a label for untrusted, publicly readable data. +func PublicUntrusted() SecurityLabel { + return SecurityLabel{ + Integrity: IntegrityUntrusted, + Confidentiality: ConfidentialityPublic, + } +} + +// PrivateTrusted returns a label for trusted data restricted to the readers +// of the originating repository. The reader set is opaque on the wire (a +// single "private" marker); the client engine resolves the concrete readers +// from the GitHub API on demand at egress decision time. +func PrivateTrusted() SecurityLabel { + return SecurityLabel{ + Integrity: IntegrityTrusted, + Confidentiality: ConfidentialityPrivate, + } +} + +// PrivateUntrusted returns a label for untrusted data restricted to the +// readers of the originating repository. See PrivateTrusted for the reader +// resolution model. +func PrivateUntrusted() SecurityLabel { + return SecurityLabel{ + Integrity: IntegrityUntrusted, + Confidentiality: ConfidentialityPrivate, + } +} + +func LabelGetMe() SecurityLabel { + return PublicTrusted() +} + +// LabelListIssues returns the IFC label for a list_issues result. +// Public repositories are universally readable; private repositories are +// restricted to their collaborators (resolved client-side from the marker). +// Issue contents are attacker-controllable, so integrity is always untrusted. +func LabelListIssues(isPrivate bool) SecurityLabel { + if isPrivate { + return PrivateUntrusted() + } + return PublicUntrusted() +} + +// LabelGetFileContents returns the IFC label for a get_file_contents result. +// Public repository file contents may be authored by anyone via pull requests +// and are therefore untrusted. In private repositories only collaborators can +// land changes, so contents are treated as trusted. +func LabelGetFileContents(isPrivate bool) SecurityLabel { + if isPrivate { + return PrivateTrusted() + } + return PublicUntrusted() +} + +// LabelSearchIssues returns the IFC label for a multi-repository search +// result, joining per-repository labels across all matched repositories. +// Used by both search_issues and search_repositories. +// +// Integrity is always untrusted because results expose user-authored content. +// +// Confidentiality follows the IFC meet (greatest lower bound): if any matched +// repository is private the joined label is private; otherwise public. The +// reader set is opaque (the "private" marker); the client engine resolves +// concrete readers on demand at egress decision time. +// +// An empty result set is treated as public-untrusted (no repository data is +// leaked). +func LabelSearchIssues(repoVisibilities []bool) SecurityLabel { + for _, isPrivate := range repoVisibilities { + if isPrivate { + return PrivateUntrusted() + } } + return PublicUntrusted() } diff --git a/pkg/ifc/ifc_test.go b/pkg/ifc/ifc_test.go new file mode 100644 index 0000000000..669f5ff0cc --- /dev/null +++ b/pkg/ifc/ifc_test.go @@ -0,0 +1,51 @@ +package ifc + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestLabelSearchIssues(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + visibilities []bool + wantConfidential Confidentiality + }{ + { + name: "empty result is treated as public", + wantConfidential: ConfidentialityPublic, + }, + { + name: "single public repo", + visibilities: []bool{false}, + wantConfidential: ConfidentialityPublic, + }, + { + name: "all public repos stay public", + visibilities: []bool{false, false, false}, + wantConfidential: ConfidentialityPublic, + }, + { + name: "any private match flips to private", + visibilities: []bool{false, true, false}, + wantConfidential: ConfidentialityPrivate, + }, + { + name: "all private repos stay private", + visibilities: []bool{true, true}, + wantConfidential: ConfidentialityPrivate, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + label := LabelSearchIssues(tc.visibilities) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, tc.wantConfidential, label.Confidentiality) + }) + } +} diff --git a/pkg/lockdown/lockdown.go b/pkg/lockdown/lockdown.go index 6edb4469d9..f787875b2e 100644 --- a/pkg/lockdown/lockdown.go +++ b/pkg/lockdown/lockdown.go @@ -8,7 +8,7 @@ import ( "sync" "time" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/muesli/cache2go" "github.com/shurcooL/githubv4" ) diff --git a/pkg/lockdown/lockdown_test.go b/pkg/lockdown/lockdown_test.go index 55e755a3ec..bb8887e709 100644 --- a/pkg/lockdown/lockdown_test.go +++ b/pkg/lockdown/lockdown_test.go @@ -4,13 +4,12 @@ import ( "encoding/json" "net/http" "net/http/httptest" - "net/url" "sync" "testing" "time" "github.com/github/github-mcp-server/internal/githubv4mock" - gogithub "github.com/google/go-github/v82/github" + gogithub "github.com/google/go-github/v87/github" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/require" ) @@ -81,8 +80,8 @@ func newMockRepoAccessCache(t *testing.T, ttl time.Duration) (*RepoAccessCache, _ = json.NewEncoder(w).Encode(resp) })) t.Cleanup(restServer.Close) - restClient := gogithub.NewClient(nil) - restClient.BaseURL, _ = url.Parse(restServer.URL + "/") + restClient, err := gogithub.NewClient(gogithub.WithEnterpriseURLs(restServer.URL+"/", restServer.URL+"/")) + require.NoError(t, err) return NewRepoAccessCache(gqlClient, restClient, WithTTL(ttl)), counting } diff --git a/pkg/raw/raw.go b/pkg/raw/raw.go index df9cd0ad11..4f794ac1f6 100644 --- a/pkg/raw/raw.go +++ b/pkg/raw/raw.go @@ -6,7 +6,7 @@ import ( "net/http" "net/url" - gogithub "github.com/google/go-github/v82/github" + gogithub "github.com/google/go-github/v87/github" ) // GetRawClientFn is a function type that returns a RawClient instance. @@ -19,19 +19,19 @@ type Client struct { } // NewClient creates a new instance of the raw API Client with the provided GitHub client and provided URL. -func NewClient(client *gogithub.Client, rawURL *url.URL) *Client { - client = gogithub.NewClient(client.Client()) - client.BaseURL = rawURL - return &Client{client: client, url: rawURL} -} - -func (c *Client) newRequest(ctx context.Context, method string, urlStr string, body any, opts ...gogithub.RequestOption) (*http.Request, error) { - req, err := c.client.NewRequest(method, urlStr, body, opts...) +func NewClient(client *gogithub.Client, rawURL *url.URL) (*Client, error) { + newClient, err := gogithub.NewClient( + gogithub.WithHTTPClient(client.Client()), + gogithub.WithEnterpriseURLs(rawURL.String(), rawURL.String()), + ) if err != nil { return nil, err } - req = req.WithContext(ctx) - return req, nil + return &Client{client: newClient, url: rawURL}, nil +} + +func (c *Client) newRequest(ctx context.Context, method string, urlStr string, body any, opts ...gogithub.RequestOption) (*http.Request, error) { + return c.client.NewRequest(ctx, method, urlStr, body, opts...) } func (c *Client) refURL(owner, repo, ref, path string) string { diff --git a/pkg/raw/raw_test.go b/pkg/raw/raw_test.go index 6897f492f6..60137684d7 100644 --- a/pkg/raw/raw_test.go +++ b/pkg/raw/raw_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/stretchr/testify/require" ) @@ -108,8 +108,10 @@ func TestGetRawContent(t *testing.T) { body: tc.body, }, } - ghClient := github.NewClient(mockedClient) - client := NewClient(ghClient, base) + ghClient, err := github.NewClient(github.WithHTTPClient(mockedClient)) + require.NoError(t, err) + client, err := NewClient(ghClient, base) + require.NoError(t, err) resp, err := client.GetRawContent(context.Background(), tc.owner, tc.repo, tc.path, tc.opts) defer func() { _ = resp.Body.Close() @@ -133,8 +135,10 @@ func TestGetRawContent(t *testing.T) { func TestUrlFromOpts(t *testing.T) { base, _ := url.Parse("https://raw.example.com/") - ghClient := github.NewClient(nil) - client := NewClient(ghClient, base) + ghClient, err := github.NewClient(github.WithHTTPClient(&http.Client{})) + require.NoError(t, err) + client, err := NewClient(ghClient, base) + require.NoError(t, err) tests := []struct { name string diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index 2e5ca59ec2..2aebd6fa0b 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -17,7 +17,7 @@ The following packages are included for the amd64, arm64 architectures. - [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.2.5/LICENSE)) - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.5.0/LICENSE)) - - [github.com/google/go-github/v82/github](https://pkg.go.dev/github.com/google/go-github/v82/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v82.0.0/LICENSE)) + - [github.com/google/go-github/v87/github](https://pkg.go.dev/github.com/google/go-github/v87/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v87.0.0/LICENSE)) - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.2.0/LICENSE)) - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.2/LICENSE)) - [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index d818469896..4e68656673 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -17,7 +17,7 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.2.5/LICENSE)) - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.5.0/LICENSE)) - - [github.com/google/go-github/v82/github](https://pkg.go.dev/github.com/google/go-github/v82/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v82.0.0/LICENSE)) + - [github.com/google/go-github/v87/github](https://pkg.go.dev/github.com/google/go-github/v87/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v87.0.0/LICENSE)) - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.2.0/LICENSE)) - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.2/LICENSE)) - [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index 6efed3338c..91b314dcb5 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -17,7 +17,7 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.2.5/LICENSE)) - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.5.0/LICENSE)) - - [github.com/google/go-github/v82/github](https://pkg.go.dev/github.com/google/go-github/v82/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v82.0.0/LICENSE)) + - [github.com/google/go-github/v87/github](https://pkg.go.dev/github.com/google/go-github/v87/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v87.0.0/LICENSE)) - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.2.0/LICENSE)) - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.2/LICENSE)) - [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) diff --git a/third-party/github.com/google/go-github/v82/github/LICENSE b/third-party/github.com/google/go-github/v87/github/LICENSE similarity index 100% rename from third-party/github.com/google/go-github/v82/github/LICENSE rename to third-party/github.com/google/go-github/v87/github/LICENSE