From 1decd77b69b568cd70f8b627c909cf4be1da35b1 Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Wed, 24 Dec 2025 15:19:11 +0100 Subject: [PATCH 01/70] remove claude web --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 059256aaa..e2b5af23a 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ Alternatively, to manually configure VS Code, choose the appropriate JSON block ### Install in other MCP hosts - **[GitHub Copilot in other IDEs](/docs/installation-guides/install-other-copilot-ides.md)** - Installation for JetBrains, Visual Studio, Eclipse, and Xcode with GitHub Copilot -- **[Claude Applications](/docs/installation-guides/install-claude.md)** - Installation guide for Claude Web, Claude Desktop and Claude Code CLI +- **[Claude Applications](/docs/installation-guides/install-claude.md)** - Installation guide for Claude Desktop and Claude Code CLI - **[Cursor](/docs/installation-guides/install-cursor.md)** - Installation guide for Cursor IDE - **[Windsurf](/docs/installation-guides/install-windsurf.md)** - Installation guide for Windsurf IDE From 43bea59a195da31e1797d709b81fee79ed933de5 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Mon, 29 Dec 2025 10:25:17 +0000 Subject: [PATCH 02/70] Change list workflow runs to allow empty resource id to list all runs in repo (#1682) * change list workflow runs to allow empty resource id to list all runs in repo * update docs --- README.md | 2 +- pkg/github/__toolsnaps__/actions_list.snap | 22 ++++++------- pkg/github/actions.go | 20 ++++++------ pkg/github/actions_test.go | 36 +++++++++++++++++++--- 4 files changed, 55 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index e2b5af23a..9376b6c09 100644 --- a/README.md +++ b/README.md @@ -509,7 +509,7 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - `resource_id`: The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID: - Do not provide any resource ID for 'list_workflows' method. - - Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' method. + - Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' method, or omit to list all workflow runs in the repository. - Provide a workflow run ID for 'list_workflow_jobs' and 'list_workflow_run_artifacts' methods. (string, optional) - `workflow_jobs_filter`: Filters for workflow jobs. **ONLY** used when method is 'list_workflow_jobs' (object, optional) diff --git a/pkg/github/__toolsnaps__/actions_list.snap b/pkg/github/__toolsnaps__/actions_list.snap index 3968a6eae..4bd029388 100644 --- a/pkg/github/__toolsnaps__/actions_list.snap +++ b/pkg/github/__toolsnaps__/actions_list.snap @@ -6,11 +6,6 @@ "description": "Tools for listing GitHub Actions resources.\nUse this tool to list workflows in a repository, or list workflow runs, jobs, and artifacts for a specific workflow or workflow run.\n", "inputSchema": { "type": "object", - "required": [ - "method", - "owner", - "repo" - ], "properties": { "method": { "type": "string", @@ -43,11 +38,10 @@ }, "resource_id": { "type": "string", - "description": "The unique identifier of the resource. This will vary based on the \"method\" provided, so ensure you provide the correct ID:\n- Do not provide any resource ID for 'list_workflows' method.\n- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' method.\n- Provide a workflow run ID for 'list_workflow_jobs' and 'list_workflow_run_artifacts' methods.\n" + "description": "The unique identifier of the resource. This will vary based on the \"method\" provided, so ensure you provide the correct ID:\n- Do not provide any resource ID for 'list_workflows' method.\n- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' method, or omit to list all workflow runs in the repository.\n- Provide a workflow run ID for 'list_workflow_jobs' and 'list_workflow_run_artifacts' methods.\n" }, "workflow_jobs_filter": { "type": "object", - "description": "Filters for workflow jobs. **ONLY** used when method is 'list_workflow_jobs'", "properties": { "filter": { "type": "string", @@ -57,11 +51,11 @@ "all" ] } - } + }, + "description": "Filters for workflow jobs. **ONLY** used when method is 'list_workflow_jobs'" }, "workflow_runs_filter": { "type": "object", - "description": "Filters for workflow runs. **ONLY** used when method is 'list_workflow_runs'", "properties": { "actor": { "type": "string", @@ -120,9 +114,15 @@ "waiting" ] } - } + }, + "description": "Filters for workflow runs. **ONLY** used when method is 'list_workflow_runs'" } - } + }, + "required": [ + "method", + "owner", + "repo" + ] }, "name": "actions_list" } \ No newline at end of file diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 6c7cdc367..1547c3251 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -1463,7 +1463,7 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an Type: "string", Description: `The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID: - Do not provide any resource ID for 'list_workflows' method. -- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' method. +- Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' method, or omit to list all workflow runs in the repository. - Provide a workflow run ID for 'list_workflow_jobs' and 'list_workflow_run_artifacts' methods. `, }, @@ -1586,18 +1586,18 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an switch method { case actionsMethodListWorkflows: // Do nothing, no resource ID needed + case actionsMethodListWorkflowRuns: + // resource_id is optional for list_workflow_runs + // If not provided, list all workflow runs in the repository default: if resourceID == "" { return utils.NewToolResultError(fmt.Sprintf("missing required parameter for method %s: resource_id", method)), nil, nil } - // For list_workflow_runs, resource_id could be a filename or numeric ID - // For other actions, resource ID must be an integer - if method != actionsMethodListWorkflowRuns { - resourceIDInt, parseErr = strconv.ParseInt(resourceID, 10, 64) - if parseErr != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid resource_id, must be an integer for method %s: %v", method, parseErr)), nil, nil - } + // resource ID must be an integer for jobs and artifacts + resourceIDInt, parseErr = strconv.ParseInt(resourceID, 10, 64) + if parseErr != nil { + return utils.NewToolResultError(fmt.Sprintf("invalid resource_id, must be an integer for method %s: %v", method, parseErr)), nil, nil } } @@ -2063,7 +2063,9 @@ func listWorkflowRuns(ctx context.Context, client *github.Client, args map[strin var workflowRuns *github.WorkflowRuns var resp *github.Response - if workflowIDInt, parseErr := strconv.ParseInt(resourceID, 10, 64); parseErr == nil { + if resourceID == "" { + workflowRuns, resp, err = client.Actions.ListRepositoryWorkflowRuns(ctx, owner, repo, listWorkflowRunsOptions) + } else if workflowIDInt, parseErr := strconv.ParseInt(resourceID, 10, 64); parseErr == nil { workflowRuns, resp, err = client.Actions.ListWorkflowRunsByID(ctx, owner, repo, workflowIDInt, listWorkflowRunsOptions) } else { workflowRuns, resp, err = client.Actions.ListWorkflowRunsByFileName(ctx, owner, repo, resourceID, listWorkflowRunsOptions) diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index f2d336e21..7319feddf 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -1997,8 +1997,33 @@ func Test_ActionsList_ListWorkflowRuns(t *testing.T) { assert.NotNil(t, response.TotalCount) }) - t.Run("missing resource_id for list_workflow_runs", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient() + t.Run("list all workflow runs without resource_id", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.GetReposActionsRunsByOwnerByRepo, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + runs := &github.WorkflowRuns{ + TotalCount: github.Ptr(2), + WorkflowRuns: []*github.WorkflowRun{ + { + ID: github.Ptr(int64(123)), + Name: github.Ptr("CI"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + }, + { + ID: github.Ptr(int64(456)), + Name: github.Ptr("Deploy"), + Status: github.Ptr("in_progress"), + Conclusion: nil, + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(runs) + }), + ), + ) client := github.NewClient(mockedClient) deps := BaseDeps{ @@ -2014,10 +2039,13 @@ func Test_ActionsList_ListWorkflowRuns(t *testing.T) { result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) - require.True(t, result.IsError) + require.False(t, result.IsError) textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, "missing required parameter") + var response github.WorkflowRuns + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, 2, *response.TotalCount) }) } From 73a8f98bdcefad5d6cb9c351a178535b49cfc097 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 29 Dec 2025 16:22:33 +0000 Subject: [PATCH 03/70] build(deps): bump actions/github-script from 7 to 8 Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 8. - [Release notes](https://github.com/actions/github-script/releases) - [Commits](https://github.com/actions/github-script/compare/v7...v8) --- updated-dependencies: - dependency-name: actions/github-script dependency-version: '8' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/license-check.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml index ce2fa26fb..916eb5f28 100644 --- a/.github/workflows/license-check.yml +++ b/.github/workflows/license-check.yml @@ -63,7 +63,7 @@ jobs: - name: Check if already commented if: steps.changes.outcome == 'failure' && steps.push.outcome == 'failure' id: check_comment - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | const { data: comments } = await github.rest.issues.listComments({ @@ -81,7 +81,7 @@ jobs: - name: Comment with instructions if cannot push if: steps.changes.outcome == 'failure' && steps.push.outcome == 'failure' && steps.check_comment.outputs.already_commented == 'false' - uses: actions/github-script@v7 + uses: actions/github-script@v8 with: script: | await github.rest.issues.createComment({ From af5a6dff42ad2f7349e76e1239297c7a00614deb Mon Sep 17 00:00:00 2001 From: s-sanjay <7111850+s-sanjay@users.noreply.github.com> Date: Sun, 28 Dec 2025 21:51:06 +0530 Subject: [PATCH 04/70] Update README.md to hyperlink Open AI Codex installation guide This guide was already added as part of https://github.com/github/github-mcp-server/pull/1340. Update README.md to point to this --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 9376b6c09..0f17b0a3a 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ Alternatively, to manually configure VS Code, choose the appropriate JSON block ### Install in other MCP hosts - **[GitHub Copilot in other IDEs](/docs/installation-guides/install-other-copilot-ides.md)** - Installation for JetBrains, Visual Studio, Eclipse, and Xcode with GitHub Copilot - **[Claude Applications](/docs/installation-guides/install-claude.md)** - Installation guide for Claude Desktop and Claude Code CLI +- **[Codex](/docs/installation-guides/install-codex.md)** - Installation guide for Open AI Codex - **[Cursor](/docs/installation-guides/install-cursor.md)** - Installation guide for Cursor IDE - **[Windsurf](/docs/installation-guides/install-windsurf.md)** - Installation guide for Windsurf IDE From 587d829a1b2d7b53654fc261fcda69f4616f6ba5 Mon Sep 17 00:00:00 2001 From: majiayu000 <1835304752@qq.com> Date: Tue, 30 Dec 2025 21:25:34 +0800 Subject: [PATCH 05/70] docs: add Docker image name to Prerequisites section Add explicit Docker image URL (ghcr.io/github/github-mcp-server) to the Prerequisites section for better discoverability. Fixes #1505 Signed-off-by: majiayu000 <1835304752@qq.com> --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0f17b0a3a..8ab95c8dc 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ GitHub Enterprise Server does not support remote server hosting. Please refer to ### Prerequisites 1. To run the server in a container, you will need to have [Docker](https://www.docker.com/) installed. -2. Once Docker is installed, you will also need to ensure Docker is running. The image is public; if you get errors on pull, you may have an expired token and need to `docker logout ghcr.io`. +2. Once Docker is installed, you will also need to ensure Docker is running. The Docker image is available at `ghcr.io/github/github-mcp-server`. The image is public; if you get errors on pull, you may have an expired token and need to `docker logout ghcr.io`. 3. Lastly you will need to [Create a GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new). The MCP server can use many of the GitHub APIs, so enable the permissions that you feel comfortable granting your AI tools (to learn more about access tokens, please check out the [documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)). From 953d26f9c082dff842073f57fbc6207cbc918bf8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 31 Dec 2025 18:39:44 +0000 Subject: [PATCH 06/70] fix: use gh pr checkout to handle fork PRs in license-check workflow Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com> --- .github/workflows/license-check.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml index 916eb5f28..940773275 100644 --- a/.github/workflows/license-check.yml +++ b/.github/workflows/license-check.yml @@ -25,8 +25,12 @@ jobs: steps: - name: Check out code uses: actions/checkout@v6 - with: - ref: ${{ github.head_ref }} + + # Check out the actual PR branch so we can push changes back if needed + - name: Check out PR branch + env: + GH_TOKEN: ${{ github.token }} + run: gh pr checkout ${{ github.event.pull_request.number }} - name: Set up Go uses: actions/setup-go@v6 From 2cc6911d4b389736f2683477bff87624289acb73 Mon Sep 17 00:00:00 2001 From: majiayu000 <1835304752@qq.com> Date: Tue, 30 Dec 2025 21:31:24 +0800 Subject: [PATCH 07/70] refactor: use consistent snake_case for issue_number parameter Change the parameter name in assign_copilot_to_issue tool from 'issueNumber' (camelCase) to 'issue_number' (snake_case) to match the naming convention used by all other tools in the issues toolset. This improves API consistency and makes the tool parameters more predictable for users and AI models. Fixes #1239 Signed-off-by: majiayu000 <1835304752@qq.com> --- .../assign_copilot_to_issue.snap | 14 +++++----- pkg/github/issues.go | 10 +++---- pkg/github/issues_test.go | 28 +++++++++---------- 3 files changed, 26 insertions(+), 26 deletions(-) diff --git a/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap b/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap index 22c380055..354600147 100644 --- a/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap +++ b/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap @@ -6,13 +6,8 @@ "description": "Assign Copilot to a specific issue in a GitHub repository.\n\nThis tool can help with the following outcomes:\n- a Pull Request created with source code changes to resolve the issue\n\n\nMore information can be found at:\n- https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot\n", "inputSchema": { "type": "object", - "required": [ - "owner", - "repo", - "issueNumber" - ], "properties": { - "issueNumber": { + "issue_number": { "type": "number", "description": "Issue number" }, @@ -24,7 +19,12 @@ "type": "string", "description": "Repository name" } - } + }, + "required": [ + "owner", + "repo", + "issue_number" + ] }, "name": "assign_copilot_to_issue", "icons": [ diff --git a/pkg/github/issues.go b/pkg/github/issues.go index f06dc2d9d..626e2c2e9 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1626,19 +1626,19 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server Type: "string", Description: "Repository name", }, - "issueNumber": { + "issue_number": { Type: "number", Description: "Issue number", }, }, - Required: []string{"owner", "repo", "issueNumber"}, + Required: []string{"owner", "repo", "issue_number"}, }, }, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { var params struct { - Owner string - Repo string - IssueNumber int32 + Owner string `mapstructure:"owner"` + Repo string `mapstructure:"repo"` + IssueNumber int32 `mapstructure:"issue_number"` } if err := mapstructure.Decode(args, ¶ms); err != nil { return utils.NewToolResultError(err.Error()), nil, nil diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index b810cede3..694b991dc 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -2175,8 +2175,8 @@ func TestAssignCopilotToIssue(t *testing.T) { assert.NotEmpty(t, tool.Description) assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") - assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issueNumber") - assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "issueNumber"}) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "issue_number"}) var pageOfFakeBots = func(n int) []struct{} { // We don't _really_ need real bots here, just objects that count as entries for the page @@ -2197,9 +2197,9 @@ func TestAssignCopilotToIssue(t *testing.T) { { name: "successful assignment when there are no existing assignees", requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "issueNumber": float64(123), + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), }, mockedClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( @@ -2286,9 +2286,9 @@ func TestAssignCopilotToIssue(t *testing.T) { { name: "successful assignment when there are existing assignees", requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "issueNumber": float64(123), + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), }, mockedClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( @@ -2386,9 +2386,9 @@ func TestAssignCopilotToIssue(t *testing.T) { { name: "copilot bot not on first page of suggested actors", requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "issueNumber": float64(123), + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), }, mockedClient: githubv4mock.NewMockedHTTPClient( // First page of suggested actors @@ -2512,9 +2512,9 @@ func TestAssignCopilotToIssue(t *testing.T) { { name: "copilot not a suggested actor", requestArgs: map[string]any{ - "owner": "owner", - "repo": "repo", - "issueNumber": float64(123), + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), }, mockedClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( From 30712de5a6e313d05f822d35c36c684ddcabf97b Mon Sep 17 00:00:00 2001 From: lif <1835304752@qq.com> Date: Wed, 31 Dec 2025 23:28:45 +0800 Subject: [PATCH 08/70] docs: regenerate README after parameter rename Update auto-generated documentation to reflect the issueNumber -> issue_number parameter rename in assign_copilot_to_issue tool. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8ab95c8dc..29795dc8f 100644 --- a/README.md +++ b/README.md @@ -758,7 +758,7 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - **assign_copilot_to_issue** - Assign Copilot to issue - - `issueNumber`: Issue number (number, required) + - `issue_number`: Issue number (number, required) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) From 762845a8b74ca0e3c6f84ae1f1c25c4ee6a0dd1a Mon Sep 17 00:00:00 2001 From: Adam Holt Date: Fri, 2 Jan 2026 15:19:06 +0100 Subject: [PATCH 09/70] Add API Error annotations to GitHub issue errors (#1566) * Add API Error annotations to GitHub issue errors * Return an error back. --------- Co-authored-by: Matt Holloway --- pkg/errors/error.go | 8 ++++++++ pkg/github/issues.go | 16 ++++++++++++---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/pkg/errors/error.go b/pkg/errors/error.go index d17fedd92..93ea852a8 100644 --- a/pkg/errors/error.go +++ b/pkg/errors/error.go @@ -120,6 +120,14 @@ func NewGitHubAPIErrorToCtx(ctx context.Context, message string, resp *github.Re return ctx, nil } +func NewGitHubGraphQLErrorToCtx(ctx context.Context, message string, err error) (context.Context, error) { + graphQLErr := newGitHubGraphQLError(message, err) + if ctx != nil { + _, _ = addGitHubGraphQLErrorToContext(ctx, graphQLErr) // Explicitly ignore error for graceful handling + } + return ctx, nil +} + func addGitHubAPIErrorToContext(ctx context.Context, err *GitHubAPIError) (context.Context, error) { if val, ok := ctx.Value(GitHubErrorKey{}).(*GitHubCtxErrors); ok { val.api = append(val.api, err) // append the error to the existing slice in the context diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 626e2c2e9..23d16b172 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1175,7 +1175,11 @@ func CreateIssue(ctx context.Context, client *github.Client, owner string, repo issue, resp, err := client.Issues.Create(ctx, owner, repo, issueRequest) if err != nil { - return utils.NewToolResultErrorFromErr("failed to create issue", err), nil + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to create issue", + resp, + err, + ), nil } defer func() { _ = resp.Body.Close() }() @@ -1522,7 +1526,11 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { issueQuery := getIssueQueryType(hasLabels, hasSince) if err := client.Query(ctx, issueQuery, vars); err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + return ghErrors.NewGitHubGraphQLErrorResponse( + ctx, + "failed to list issues", + err, + ), nil, nil } // Extract and convert all issue nodes using the common interface @@ -1683,7 +1691,7 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server var query suggestedActorsQuery err := client.Query(ctx, &query, variables) if err != nil { - return nil, nil, err + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get suggested actors", err), nil, nil } // Iterate all the returned nodes looking for the copilot bot, which is supposed to have the @@ -1729,7 +1737,7 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server } if err := client.Query(ctx, &getIssueQuery, variables); err != nil { - return utils.NewToolResultError(fmt.Sprintf("failed to get issue ID: %v", err)), nil, nil + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get issue ID", err), nil, nil } // Finally, do the assignment. Just for reference, assigning copilot to an issue that it is already From 92bdc286bb60aea0548b0de9e4e7ca625f394132 Mon Sep 17 00:00:00 2001 From: Kun Chen Date: Wed, 13 Aug 2025 13:39:11 -0700 Subject: [PATCH 10/70] add docs for Rovo Dev CLI installation --- README.md | 1 + .../install-rovo-dev-cli.md | 32 +++++++++++++++++++ 2 files changed, 33 insertions(+) create mode 100644 docs/installation-guides/install-rovo-dev-cli.md diff --git a/README.md b/README.md index 29795dc8f..f2ef810ce 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ Alternatively, to manually configure VS Code, choose the appropriate JSON block - **[Codex](/docs/installation-guides/install-codex.md)** - Installation guide for Open AI Codex - **[Cursor](/docs/installation-guides/install-cursor.md)** - Installation guide for Cursor IDE - **[Windsurf](/docs/installation-guides/install-windsurf.md)** - Installation guide for Windsurf IDE +- **[Rovo Dev CLI](/docs/installation-guides/install-rovo-dev-cli.md)** - Installation guide for Rovo Dev CLI > **Note:** Each MCP host application needs to configure a GitHub App or OAuth App to support remote access via OAuth. Any host application that supports remote MCP servers should support the remote GitHub server with PAT authentication. Configuration details and support levels vary by host. Make sure to refer to the host application's documentation for more info. diff --git a/docs/installation-guides/install-rovo-dev-cli.md b/docs/installation-guides/install-rovo-dev-cli.md new file mode 100644 index 000000000..e6660bfe4 --- /dev/null +++ b/docs/installation-guides/install-rovo-dev-cli.md @@ -0,0 +1,32 @@ +# Install GitHub MCP Server in Rovo Dev CLI + +## Prerequisites + +1. Rovo Dev CLI installed (latest version) +2. [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new) with appropriate scopes + +## MCP Server Setup + +Uses GitHub's hosted server at https://api.githubcopilot.com/mcp/. + +### Install steps + +1. Run `acli rovodev mcp` to open the MCP configuration for Rovo Dev CLI +2. Add configuration by following example below. +3. Replace `YOUR_GITHUB_PAT` with your actual [GitHub Personal Access Token](https://github.com/settings/tokens) +4. Save the file and restart Rovo Dev CLI with `acli rovodev` + +### Example configuration + +```json +{ + "mcpServers": { + "github": { + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "Authorization": "Bearer YOUR_GITHUB_PAT" + } + } + } +} +``` From 116c5742553ca36fc09ba683aa42a9fdfd25ff89 Mon Sep 17 00:00:00 2001 From: majiayu000 <1835304752@qq.com> Date: Fri, 2 Jan 2026 07:59:32 +0800 Subject: [PATCH 11/70] fix: filterToolsByName returns all matching tools for feature flag filtering When multiple tools share the same name but have different feature flags (like GetJobLogs and ActionsGetJobLogs both named "get_job_logs"), filterToolsByName was only returning the first match. This caused the remote server to fail with "unknown tool" error when the first matching tool was disabled by feature flags, even though another variant was enabled. The fix modifies filterToolsByName to return ALL tools with matching names, allowing the feature flag filtering in AvailableTools to select the correct variant based on the enabled flags. Fixes #1714 Signed-off-by: majiayu000 <1835304752@qq.com> --- pkg/inventory/filters.go | 15 +++++++++--- pkg/inventory/registry_test.go | 45 ++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/pkg/inventory/filters.go b/pkg/inventory/filters.go index 991001a64..c5156e61a 100644 --- a/pkg/inventory/filters.go +++ b/pkg/inventory/filters.go @@ -178,22 +178,29 @@ func (r *Inventory) AvailablePrompts(ctx context.Context) []ServerPrompt { // filterToolsByName returns tools matching the given name, checking deprecated aliases. // Uses linear scan - optimized for single-lookup per-request scenarios (ForMCPRequest). +// Returns ALL tools matching the name to support feature-flagged tool variants +// (e.g., GetJobLogs and ActionsGetJobLogs both use name "get_job_logs" but are +// controlled by different feature flags). func (r *Inventory) filterToolsByName(name string) []ServerTool { - // First check for exact match + var result []ServerTool + // Check for exact matches - multiple tools may share the same name with different feature flags for i := range r.tools { if r.tools[i].Tool.Name == name { - return []ServerTool{r.tools[i]} + result = append(result, r.tools[i]) } } + if len(result) > 0 { + return result + } // Check if name is a deprecated alias if canonical, isAlias := r.deprecatedAliases[name]; isAlias { for i := range r.tools { if r.tools[i].Tool.Name == canonical { - return []ServerTool{r.tools[i]} + result = append(result, r.tools[i]) } } } - return []ServerTool{} + return result } // filterResourcesByURI returns resource templates matching the given URI pattern. diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go index 41e94b8d9..742ad3646 100644 --- a/pkg/inventory/registry_test.go +++ b/pkg/inventory/registry_test.go @@ -1643,3 +1643,48 @@ func TestFilteringOrder(t *testing.T) { } } } + +func TestForMCPRequest_ToolsCall_FeatureFlaggedVariants(t *testing.T) { + // Simulate the get_job_logs scenario: two tools with the same name but different feature flags + // - "get_job_logs" with FeatureFlagDisable (available when flag is OFF) + // - "get_job_logs" with FeatureFlagEnable (available when flag is ON) + tools := []ServerTool{ + mockToolWithFlags("get_job_logs", "actions", true, "", "consolidated_flag"), // disabled when flag is ON + mockToolWithFlags("get_job_logs", "actions", true, "consolidated_flag", ""), // enabled when flag is ON + mockTool("other_tool", "actions", true), + } + + // Test 1: Flag is OFF - first tool variant should be available + regFlagOff := NewBuilder(). + SetTools(tools). + WithToolsets([]string{"all"}). + Build() + filteredOff := regFlagOff.ForMCPRequest(MCPMethodToolsCall, "get_job_logs") + availableOff := filteredOff.AvailableTools(context.Background()) + if len(availableOff) != 1 { + t.Fatalf("Flag OFF: Expected 1 tool, got %d", len(availableOff)) + } + if availableOff[0].FeatureFlagDisable != "consolidated_flag" { + t.Errorf("Flag OFF: Expected tool with FeatureFlagDisable, got FeatureFlagEnable=%q, FeatureFlagDisable=%q", + availableOff[0].FeatureFlagEnable, availableOff[0].FeatureFlagDisable) + } + + // Test 2: Flag is ON - second tool variant should be available + checker := func(_ context.Context, flag string) (bool, error) { + return flag == "consolidated_flag", nil + } + regFlagOn := NewBuilder(). + SetTools(tools). + WithToolsets([]string{"all"}). + WithFeatureChecker(checker). + Build() + filteredOn := regFlagOn.ForMCPRequest(MCPMethodToolsCall, "get_job_logs") + availableOn := filteredOn.AvailableTools(context.Background()) + if len(availableOn) != 1 { + t.Fatalf("Flag ON: Expected 1 tool, got %d", len(availableOn)) + } + if availableOn[0].FeatureFlagEnable != "consolidated_flag" { + t.Errorf("Flag ON: Expected tool with FeatureFlagEnable, got FeatureFlagEnable=%q, FeatureFlagDisable=%q", + availableOn[0].FeatureFlagEnable, availableOn[0].FeatureFlagDisable) + } +} From cac11f2d342775e0905c84ab8f81c82e377bbc7f Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Fri, 2 Jan 2026 10:54:21 +0000 Subject: [PATCH 12/70] exclude tools requiring ff from docs --- README.md | 43 -------------------------- cmd/github-mcp-server/generate_docs.go | 5 +-- pkg/inventory/registry.go | 27 ++++++++++++++++ 3 files changed, 30 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index f2ef810ce..af92cfd0b 100644 --- a/README.md +++ b/README.md @@ -492,40 +492,6 @@ The following sets of tools are available: workflow Actions -- **actions_get** - Get details of GitHub Actions resources (workflows, workflow runs, jobs, and artifacts) - - `method`: The method to execute (string, required) - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `resource_id`: The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID: - - Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' method. - - Provide a workflow run ID for 'get_workflow_run', 'get_workflow_run_usage', and 'get_workflow_run_logs_url' methods. - - Provide an artifact ID for 'download_workflow_run_artifact' method. - - Provide a job ID for 'get_workflow_job' method. - (string, required) - -- **actions_list** - List GitHub Actions workflows in a repository - - `method`: The action to perform (string, required) - - `owner`: Repository owner (string, required) - - `page`: Page number for pagination (default: 1) (number, optional) - - `per_page`: Results per page for pagination (default: 30, max: 100) (number, optional) - - `repo`: Repository name (string, required) - - `resource_id`: The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID: - - Do not provide any resource ID for 'list_workflows' method. - - Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' method, or omit to list all workflow runs in the repository. - - Provide a workflow run ID for 'list_workflow_jobs' and 'list_workflow_run_artifacts' methods. - (string, optional) - - `workflow_jobs_filter`: Filters for workflow jobs. **ONLY** used when method is 'list_workflow_jobs' (object, optional) - - `workflow_runs_filter`: Filters for workflow runs. **ONLY** used when method is 'list_workflow_runs' (object, optional) - -- **actions_run_trigger** - Trigger GitHub Actions workflow actions - - `inputs`: Inputs the workflow accepts. Only used for 'run_workflow' method. (object, optional) - - `method`: The method to execute (string, required) - - `owner`: Repository owner (string, required) - - `ref`: The git reference for the workflow. The reference can be a branch or tag name. Required for 'run_workflow' method. (string, optional) - - `repo`: Repository name (string, required) - - `run_id`: The ID of the workflow run. Required for all methods except 'run_workflow'. (number, optional) - - `workflow_id`: The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml). Required for 'run_workflow' method. (string, optional) - - **cancel_workflow_run** - Cancel workflow run - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) @@ -550,15 +516,6 @@ The following sets of tools are available: - `run_id`: Workflow run ID (required when using failed_only) (number, optional) - `tail_lines`: Number of lines to return from the end of the log (number, optional) -- **get_job_logs** - Get GitHub Actions workflow job logs - - `failed_only`: When true, gets logs for all failed jobs in the workflow run specified by run_id. Requires run_id to be provided. (boolean, optional) - - `job_id`: The unique identifier of the workflow job. Required when getting logs for a single job. (number, optional) - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `return_content`: Returns actual log content instead of URLs (boolean, optional) - - `run_id`: The unique identifier of the workflow run. Required when failed_only is true to get logs for all failed jobs in the run. (number, optional) - - `tail_lines`: Number of lines to return from the end of the log (number, optional) - - **get_workflow_run** - Get workflow run - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index b40e3e2f4..4be0076bc 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -153,9 +153,10 @@ func generateToolsetsDoc(i *inventory.Inventory) string { } func generateToolsDoc(r *inventory.Inventory) string { - // AllTools() returns tools sorted by toolset ID then tool name. + // AllToolsForDocs() returns tools sorted by toolset ID then tool name, + // excluding tools that require feature flags (not available to regular users). // We iterate once, grouping by toolset as we encounter them. - tools := r.AllTools() + tools := r.AllToolsForDocs() if len(tools) == 0 { return "" } diff --git a/pkg/inventory/registry.go b/pkg/inventory/registry.go index f3691e38a..d9e73370b 100644 --- a/pkg/inventory/registry.go +++ b/pkg/inventory/registry.go @@ -266,6 +266,33 @@ func (r *Inventory) AllTools() []ServerTool { return result } +// AllToolsForDocs returns tools suitable for documentation, sorted deterministically. +// This excludes tools that require a feature flag to be enabled (FeatureFlagEnable), +// since those are not available to regular users and shouldn't appear in public docs. +// Tools that are disabled by a feature flag (FeatureFlagDisable) are still included +// since they are available by default. +func (r *Inventory) AllToolsForDocs() []ServerTool { + var result []ServerTool + for i := range r.tools { + tool := &r.tools[i] + // Skip tools that require a feature flag to enable + if tool.FeatureFlagEnable != "" { + continue + } + result = append(result, *tool) + } + + // Sort deterministically: by toolset ID, then by tool name + sort.Slice(result, func(i, j int) bool { + if result[i].Toolset.ID != result[j].Toolset.ID { + return result[i].Toolset.ID < result[j].Toolset.ID + } + return result[i].Tool.Name < result[j].Tool.Name + }) + + return result +} + // AvailableToolsets returns the unique toolsets that have tools, in sorted order. // This is the ordered intersection of toolsets with reality - only toolsets that // actually contain tools are returned, sorted by toolset ID. From 6f7bf271edb61353d65d389de0ce0a1c72c10fd9 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Fri, 2 Jan 2026 13:24:01 +0000 Subject: [PATCH 13/70] refactor docs toolset gen --- cmd/github-mcp-server/generate_docs.go | 15 ++++++++------ pkg/inventory/registry.go | 27 -------------------------- 2 files changed, 9 insertions(+), 33 deletions(-) diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index 4be0076bc..7590666b5 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "net/url" "os" @@ -50,8 +51,10 @@ func generateReadmeDocs(readmePath string) error { // Create translation helper t, _ := translations.TranslationHelper() - // Build inventory - stateless, no dependencies needed for doc generation - r := github.NewInventory(t).Build() + // Build inventory with all toolsets enabled and no feature checker (all flags return false). + // This includes tools from all toolsets, but excludes tools with FeatureFlagEnable + // (not available to regular users) while including tools with FeatureFlagDisable. + r := github.NewInventory(t).WithToolsets([]string{"all"}).Build() // Generate toolsets documentation toolsetsDoc := generateToolsetsDoc(r) @@ -153,10 +156,10 @@ func generateToolsetsDoc(i *inventory.Inventory) string { } func generateToolsDoc(r *inventory.Inventory) string { - // AllToolsForDocs() returns tools sorted by toolset ID then tool name, - // excluding tools that require feature flags (not available to regular users). - // We iterate once, grouping by toolset as we encounter them. - tools := r.AllToolsForDocs() + // Use AvailableTools with the inventory's feature checker (returns false for all flags), + // which excludes tools requiring a feature flag (FeatureFlagEnable) while keeping + // tools that are disabled by feature flags (available by default). + tools := r.AvailableTools(context.Background()) if len(tools) == 0 { return "" } diff --git a/pkg/inventory/registry.go b/pkg/inventory/registry.go index d9e73370b..f3691e38a 100644 --- a/pkg/inventory/registry.go +++ b/pkg/inventory/registry.go @@ -266,33 +266,6 @@ func (r *Inventory) AllTools() []ServerTool { return result } -// AllToolsForDocs returns tools suitable for documentation, sorted deterministically. -// This excludes tools that require a feature flag to be enabled (FeatureFlagEnable), -// since those are not available to regular users and shouldn't appear in public docs. -// Tools that are disabled by a feature flag (FeatureFlagDisable) are still included -// since they are available by default. -func (r *Inventory) AllToolsForDocs() []ServerTool { - var result []ServerTool - for i := range r.tools { - tool := &r.tools[i] - // Skip tools that require a feature flag to enable - if tool.FeatureFlagEnable != "" { - continue - } - result = append(result, *tool) - } - - // Sort deterministically: by toolset ID, then by tool name - sort.Slice(result, func(i, j int) bool { - if result[i].Toolset.ID != result[j].Toolset.ID { - return result[i].Toolset.ID < result[j].Toolset.ID - } - return result[i].Tool.Name < result[j].Tool.Name - }) - - return result -} - // AvailableToolsets returns the unique toolsets that have tools, in sorted order. // This is the ordered intersection of toolsets with reality - only toolsets that // actually contain tools are returned, sorted by toolset ID. From 67f3427d39b9477a3693485ab7709470bd198c86 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Fri, 2 Jan 2026 13:47:17 +0000 Subject: [PATCH 14/70] Update cmd/github-mcp-server/generate_docs.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cmd/github-mcp-server/generate_docs.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index 7590666b5..f7adc822a 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -51,8 +51,6 @@ func generateReadmeDocs(readmePath string) error { // Create translation helper t, _ := translations.TranslationHelper() - // Build inventory with all toolsets enabled and no feature checker (all flags return false). - // This includes tools from all toolsets, but excludes tools with FeatureFlagEnable // (not available to regular users) while including tools with FeatureFlagDisable. r := github.NewInventory(t).WithToolsets([]string{"all"}).Build() From 905a08f8fbca93fe1d2d07b2fc3ff01f7d055e71 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Fri, 2 Jan 2026 13:47:26 +0000 Subject: [PATCH 15/70] Update cmd/github-mcp-server/generate_docs.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- cmd/github-mcp-server/generate_docs.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index f7adc822a..65c01c8fa 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -154,9 +154,6 @@ func generateToolsetsDoc(i *inventory.Inventory) string { } func generateToolsDoc(r *inventory.Inventory) string { - // Use AvailableTools with the inventory's feature checker (returns false for all flags), - // which excludes tools requiring a feature flag (FeatureFlagEnable) while keeping - // tools that are disabled by feature flags (available by default). tools := r.AvailableTools(context.Background()) if len(tools) == 0 { return "" From 2b352ab0b914cde8c16d44200a7cace9f400be71 Mon Sep 17 00:00:00 2001 From: Ksenia Bobrova Date: Mon, 5 Jan 2026 13:27:32 +0100 Subject: [PATCH 16/70] Improvements to push_files tool (#1676) * Fallback to default branch in get_file_contents when main doesn't exist * Addressing review comments * Improvements to push_files tool * Fixed copilot comments * Addressing review comments * Remove debug statement --- pkg/github/repositories.go | 329 ++++++------------------ pkg/github/repositories_helper.go | 329 ++++++++++++++++++++++++ pkg/github/repositories_test.go | 404 +++++++++++++++++++++++++++++- 3 files changed, 801 insertions(+), 261 deletions(-) create mode 100644 pkg/github/repositories_helper.go diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index c31bb7df2..1ab33a57c 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -12,7 +12,6 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" "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/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" @@ -1279,28 +1278,74 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { } // Get the reference for the branch + var repositoryIsEmpty bool + var branchNotFound bool ref, resp, err := client.Git.GetRef(ctx, owner, repo, "refs/heads/"+branch) if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get branch reference", - resp, - err, - ), nil, nil + ghErr, isGhErr := err.(*github.ErrorResponse) + if isGhErr { + if ghErr.Response.StatusCode == http.StatusConflict && ghErr.Message == "Git Repository is empty." { + repositoryIsEmpty = true + } else if ghErr.Response.StatusCode == http.StatusNotFound { + branchNotFound = true + } + } + + if !repositoryIsEmpty && !branchNotFound { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get branch reference", + resp, + err, + ), nil, nil + } + } + // Only close resp if it's not nil and not an error case where resp might be nil + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() } - defer func() { _ = resp.Body.Close() }() - // Get the commit object that the branch points to - baseCommit, resp, err := client.Git.GetCommit(ctx, owner, repo, *ref.Object.SHA) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get base commit", - resp, - err, - ), nil, nil + var baseCommit *github.Commit + if !repositoryIsEmpty { + if branchNotFound { + ref, err = createReferenceFromDefaultBranch(ctx, client, owner, repo, branch) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to create branch from default: %v", err)), nil, nil + } + } + + // Get the commit object that the branch points to + baseCommit, resp, err = client.Git.GetCommit(ctx, owner, repo, *ref.Object.SHA) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get base commit", + resp, + err, + ), nil, nil + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + } else { + var base *github.Commit + // Repository is empty, need to initialize it first + ref, base, err = initializeRepository(ctx, client, owner, repo) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to initialize repository: %v", err)), nil, nil + } + + defaultBranch := strings.TrimPrefix(*ref.Ref, "refs/heads/") + if branch != defaultBranch { + // Create the requested branch from the default branch + ref, err = createReferenceFromDefaultBranch(ctx, client, owner, repo, branch) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to create branch from default: %v", err)), nil, nil + } + } + + baseCommit = base } - defer func() { _ = resp.Body.Close() }() - // Create tree entries for all files + // Create tree entries for all files (or remaining files if empty repo) var entries []*github.TreeEntry for _, file := range filesObj { @@ -1328,7 +1373,7 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { }) } - // Create a new tree with the file entries + // Create a new tree with the file entries (baseCommit is now guaranteed to exist) newTree, resp, err := client.Git.CreateTree(ctx, owner, repo, *baseCommit.Tree.SHA, entries) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, @@ -1337,9 +1382,11 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { err, ), nil, nil } - defer func() { _ = resp.Body.Close() }() + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } - // Create a new commit + // Create a new commit (baseCommit always has a value now) commit := github.Commit{ Message: github.Ptr(message), Tree: newTree, @@ -1353,7 +1400,9 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { err, ), nil, nil } - defer func() { _ = resp.Body.Close() }() + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } // Update the reference to point to the new commit ref.Object.SHA = newCommit.SHA @@ -1770,244 +1819,6 @@ func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool ) } -// matchFiles searches for files in the Git tree that match the given path. -// It's used when GetContents fails or returns unexpected results. -func matchFiles(ctx context.Context, client *github.Client, owner, repo, ref, path string, rawOpts *raw.ContentOpts, rawAPIResponseCode int) (*mcp.CallToolResult, any, error) { - // Step 1: Get Git Tree recursively - tree, response, err := client.Git.GetTree(ctx, owner, repo, ref, true) - if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, - "failed to get git tree", - response, - err, - ), nil, nil - } - defer func() { _ = response.Body.Close() }() - - // Step 2: Filter tree for matching paths - const maxMatchingFiles = 3 - matchingFiles := filterPaths(tree.Entries, path, maxMatchingFiles) - if len(matchingFiles) > 0 { - matchingFilesJSON, err := json.Marshal(matchingFiles) - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("failed to marshal matching files: %s", err)), nil, nil - } - resolvedRefs, err := json.Marshal(rawOpts) - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("failed to marshal resolved refs: %s", err)), nil, nil - } - if rawAPIResponseCode > 0 { - return utils.NewToolResultText(fmt.Sprintf("Resolved potential matches in the repository tree (resolved refs: %s, matching files: %s), but the content API returned an unexpected status code %d.", string(resolvedRefs), string(matchingFilesJSON), rawAPIResponseCode)), nil, nil - } - return utils.NewToolResultText(fmt.Sprintf("Resolved potential matches in the repository tree (resolved refs: %s, matching files: %s).", string(resolvedRefs), string(matchingFilesJSON))), nil, nil - } - return utils.NewToolResultError("Failed to get file contents. The path does not point to a file or directory, or the file does not exist in the repository."), nil, nil -} - -// filterPaths filters the entries in a GitHub tree to find paths that -// match the given suffix. -// maxResults limits the number of results returned to first maxResults entries, -// a maxResults of -1 means no limit. -// It returns a slice of strings containing the matching paths. -// Directories are returned with a trailing slash. -func filterPaths(entries []*github.TreeEntry, path string, maxResults int) []string { - // Remove trailing slash for matching purposes, but flag whether we - // only want directories. - dirOnly := false - if strings.HasSuffix(path, "/") { - dirOnly = true - path = strings.TrimSuffix(path, "/") - } - - matchedPaths := []string{} - for _, entry := range entries { - if len(matchedPaths) == maxResults { - break // Limit the number of results to maxResults - } - if dirOnly && entry.GetType() != "tree" { - continue // Skip non-directory entries if dirOnly is true - } - entryPath := entry.GetPath() - if entryPath == "" { - continue // Skip empty paths - } - if strings.HasSuffix(entryPath, path) { - if entry.GetType() == "tree" { - entryPath += "/" // Return directories with a trailing slash - } - matchedPaths = append(matchedPaths, entryPath) - } - } - return matchedPaths -} - -// looksLikeSHA returns true if the string appears to be a Git commit SHA. -// A SHA is a 40-character hexadecimal string. -func looksLikeSHA(s string) bool { - if len(s) != 40 { - return false - } - for _, c := range s { - if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') { - return false - } - } - return true -} - -// resolveGitReference takes a user-provided ref and sha and resolves them into a -// definitive commit SHA and its corresponding fully-qualified reference. -// -// The resolution logic follows a clear priority: -// -// 1. If a specific commit `sha` is provided, it takes precedence and is used directly, -// and all reference resolution is skipped. -// -// 1a. If `sha` is empty but `ref` looks like a commit SHA (40 hexadecimal characters), -// it is returned as-is without any API calls or reference resolution. -// -// 2. If no `sha` is provided and `ref` does not look like a SHA, the function resolves -// the `ref` string into a fully-qualified format (e.g., "refs/heads/main") by trying -// the following steps in order: -// a). **Empty Ref:** If `ref` is empty, the repository's default branch is used. -// b). **Fully-Qualified:** If `ref` already starts with "refs/", it's considered fully -// qualified and used as-is. -// c). **Partially-Qualified:** If `ref` starts with "heads/" or "tags/", it is -// prefixed with "refs/" to make it fully-qualified. -// d). **Short Name:** Otherwise, the `ref` is treated as a short name. The function -// first attempts to resolve it as a branch ("refs/heads/"). If that -// returns a 404 Not Found error, it then attempts to resolve it as a tag -// ("refs/tags/"). -// -// 3. **Final Lookup:** Once a fully-qualified ref is determined, a final API call -// is made to fetch that reference's definitive commit SHA. -// -// Any unexpected (non-404) errors during the resolution process are returned -// immediately. All API errors are logged with rich context to aid diagnostics. -func resolveGitReference(ctx context.Context, githubClient *github.Client, owner, repo, ref, sha string) (*raw.ContentOpts, bool, error) { - // 1) If SHA explicitly provided, it's the highest priority. - if sha != "" { - return &raw.ContentOpts{Ref: "", SHA: sha}, false, nil - } - - // 1a) If sha is empty but ref looks like a SHA, return it without changes - if looksLikeSHA(ref) { - return &raw.ContentOpts{Ref: "", SHA: ref}, false, nil - } - - originalRef := ref // Keep original ref for clearer error messages down the line. - - // 2) If no SHA is provided, we try to resolve the ref into a fully-qualified format. - var reference *github.Reference - var resp *github.Response - var err error - var fallbackUsed bool - - switch { - case originalRef == "": - // 2a) If ref is empty, determine the default branch. - reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) - if err != nil { - return nil, false, err // Error is already wrapped in resolveDefaultBranch. - } - ref = reference.GetRef() - case strings.HasPrefix(originalRef, "refs/"): - // 2b) Already fully qualified. The reference will be fetched at the end. - case strings.HasPrefix(originalRef, "heads/") || strings.HasPrefix(originalRef, "tags/"): - // 2c) Partially qualified. Make it fully qualified. - ref = "refs/" + originalRef - default: - // 2d) It's a short name, so we try to resolve it to either a branch or a tag. - branchRef := "refs/heads/" + originalRef - reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, branchRef) - - if err == nil { - ref = branchRef // It's a branch. - } else { - // The branch lookup failed. Check if it was a 404 Not Found error. - ghErr, isGhErr := err.(*github.ErrorResponse) - if isGhErr && ghErr.Response.StatusCode == http.StatusNotFound { - tagRef := "refs/tags/" + originalRef - reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, tagRef) - if err == nil { - ref = tagRef // It's a tag. - } else { - // The tag lookup also failed. Check if it was a 404 Not Found error. - ghErr2, isGhErr2 := err.(*github.ErrorResponse) - if isGhErr2 && ghErr2.Response.StatusCode == http.StatusNotFound { - if originalRef == "main" { - reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) - if err != nil { - return nil, false, err // Error is already wrapped in resolveDefaultBranch. - } - // Update ref to the actual default branch ref so the note can be generated - ref = reference.GetRef() - fallbackUsed = true - break - } - return nil, false, fmt.Errorf("could not resolve ref %q as a branch or a tag", originalRef) - } - - // The tag lookup failed for a different reason. - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (tag)", resp, err) - return nil, false, fmt.Errorf("failed to get reference for tag '%s': %w", originalRef, err) - } - } else { - // The branch lookup failed for a different reason. - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (branch)", resp, err) - return nil, false, fmt.Errorf("failed to get reference for branch '%s': %w", originalRef, err) - } - } - } - - if reference == nil { - reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, ref) - if err != nil { - if ref == "refs/heads/main" { - reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) - if err != nil { - return nil, false, err // Error is already wrapped in resolveDefaultBranch. - } - // Update ref to the actual default branch ref so the note can be generated - ref = reference.GetRef() - fallbackUsed = true - } else { - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get final reference", resp, err) - return nil, false, fmt.Errorf("failed to get final reference for %q: %w", ref, err) - } - } - } - - sha = reference.GetObject().GetSHA() - return &raw.ContentOpts{Ref: ref, SHA: sha}, fallbackUsed, nil -} - -func resolveDefaultBranch(ctx context.Context, githubClient *github.Client, owner, repo string) (*github.Reference, error) { - repoInfo, resp, err := githubClient.Repositories.Get(ctx, owner, repo) - if err != nil { - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get repository info", resp, err) - return nil, fmt.Errorf("failed to get repository info: %w", err) - } - - if resp != nil && resp.Body != nil { - _ = resp.Body.Close() - } - - defaultBranch := repoInfo.GetDefaultBranch() - - defaultRef, resp, err := githubClient.Git.GetRef(ctx, owner, repo, "heads/"+defaultBranch) - if err != nil { - _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get default branch reference", resp, err) - return nil, fmt.Errorf("failed to get default branch reference: %w", err) - } - - if resp != nil && resp.Body != nil { - defer func() { _ = resp.Body.Close() }() - } - - return defaultRef, nil -} - // ListStarredRepositories creates a tool to list starred repositories for the authenticated user or a specified user. func ListStarredRepositories(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( diff --git a/pkg/github/repositories_helper.go b/pkg/github/repositories_helper.go new file mode 100644 index 000000000..de5065d48 --- /dev/null +++ b/pkg/github/repositories_helper.go @@ -0,0 +1,329 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strings" + + 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/v79/github" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// initializeRepository creates an initial commit in an empty repository and returns the default branch ref and base commit +func initializeRepository(ctx context.Context, client *github.Client, owner, repo string) (ref *github.Reference, baseCommit *github.Commit, err error) { + // First, we need to check what the default branch in this empty repo should be: + repository, resp, err := client.Repositories.Get(ctx, owner, repo) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get repository", resp, err) + return nil, nil, fmt.Errorf("failed to get repository: %w", err) + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + + defaultBranch := repository.GetDefaultBranch() + + fileOpts := &github.RepositoryContentFileOptions{ + Message: github.Ptr("Initial commit"), + Content: []byte(""), + Branch: github.Ptr(defaultBranch), + } + + // Create an initial empty commit to create the default branch + createResp, resp, err := client.Repositories.CreateFile(ctx, owner, repo, "README.md", fileOpts) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to create initial file", resp, err) + return nil, nil, fmt.Errorf("failed to create initial file: %w", err) + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + + // Get the commit that was just created to use as base for remaining files + baseCommit, resp, err = client.Git.GetCommit(ctx, owner, repo, *createResp.Commit.SHA) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get initial commit", resp, err) + return nil, nil, fmt.Errorf("failed to get initial commit: %w", err) + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + + ref, resp, err = client.Git.GetRef(ctx, owner, repo, "refs/heads/"+defaultBranch) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get final reference", resp, err) + return nil, nil, fmt.Errorf("failed to get branch reference after initial commit: %w", err) + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + + return ref, baseCommit, nil +} + +// createReferenceFromDefaultBranch creates a new branch reference from the repository's default branch +func createReferenceFromDefaultBranch(ctx context.Context, client *github.Client, owner, repo, branch string) (*github.Reference, error) { + defaultRef, err := resolveDefaultBranch(ctx, client, owner, repo) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to resolve default branch", nil, err) + return nil, fmt.Errorf("failed to resolve default branch: %w", err) + } + + // Create the new branch reference + createdRef, resp, err := client.Git.CreateRef(ctx, owner, repo, github.CreateRef{ + Ref: "refs/heads/" + branch, + SHA: *defaultRef.Object.SHA, + }) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to create new branch reference", resp, err) + return nil, fmt.Errorf("failed to create new branch reference: %w", err) + } + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + + return createdRef, nil +} + +// matchFiles searches for files in the Git tree that match the given path. +// It's used when GetContents fails or returns unexpected results. +func matchFiles(ctx context.Context, client *github.Client, owner, repo, ref, path string, rawOpts *raw.ContentOpts, rawAPIResponseCode int) (*mcp.CallToolResult, any, error) { + // Step 1: Get Git Tree recursively + tree, response, err := client.Git.GetTree(ctx, owner, repo, ref, true) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get git tree", + response, + err, + ), nil, nil + } + defer func() { _ = response.Body.Close() }() + + // Step 2: Filter tree for matching paths + const maxMatchingFiles = 3 + matchingFiles := filterPaths(tree.Entries, path, maxMatchingFiles) + if len(matchingFiles) > 0 { + matchingFilesJSON, err := json.Marshal(matchingFiles) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to marshal matching files: %s", err)), nil, nil + } + resolvedRefs, err := json.Marshal(rawOpts) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to marshal resolved refs: %s", err)), nil, nil + } + if rawAPIResponseCode > 0 { + return utils.NewToolResultText(fmt.Sprintf("Resolved potential matches in the repository tree (resolved refs: %s, matching files: %s), but the content API returned an unexpected status code %d.", string(resolvedRefs), string(matchingFilesJSON), rawAPIResponseCode)), nil, nil + } + return utils.NewToolResultText(fmt.Sprintf("Resolved potential matches in the repository tree (resolved refs: %s, matching files: %s).", string(resolvedRefs), string(matchingFilesJSON))), nil, nil + } + return utils.NewToolResultError("Failed to get file contents. The path does not point to a file or directory, or the file does not exist in the repository."), nil, nil +} + +// filterPaths filters the entries in a GitHub tree to find paths that +// match the given suffix. +// maxResults limits the number of results returned to first maxResults entries, +// a maxResults of -1 means no limit. +// It returns a slice of strings containing the matching paths. +// Directories are returned with a trailing slash. +func filterPaths(entries []*github.TreeEntry, path string, maxResults int) []string { + // Remove trailing slash for matching purposes, but flag whether we + // only want directories. + dirOnly := false + if strings.HasSuffix(path, "/") { + dirOnly = true + path = strings.TrimSuffix(path, "/") + } + + matchedPaths := []string{} + for _, entry := range entries { + if len(matchedPaths) == maxResults { + break // Limit the number of results to maxResults + } + if dirOnly && entry.GetType() != "tree" { + continue // Skip non-directory entries if dirOnly is true + } + entryPath := entry.GetPath() + if entryPath == "" { + continue // Skip empty paths + } + if strings.HasSuffix(entryPath, path) { + if entry.GetType() == "tree" { + entryPath += "/" // Return directories with a trailing slash + } + matchedPaths = append(matchedPaths, entryPath) + } + } + return matchedPaths +} + +// looksLikeSHA returns true if the string appears to be a Git commit SHA. +// A SHA is a 40-character hexadecimal string. +func looksLikeSHA(s string) bool { + if len(s) != 40 { + return false + } + for _, c := range s { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') { + return false + } + } + return true +} + +// resolveGitReference takes a user-provided ref and sha and resolves them into a +// definitive commit SHA and its corresponding fully-qualified reference. +// +// The resolution logic follows a clear priority: +// +// 1. If a specific commit `sha` is provided, it takes precedence and is used directly, +// and all reference resolution is skipped. +// +// 1a. If `sha` is empty but `ref` looks like a commit SHA (40 hexadecimal characters), +// it is returned as-is without any API calls or reference resolution. +// +// 2. If no `sha` is provided and `ref` does not look like a SHA, the function resolves +// the `ref` string into a fully-qualified format (e.g., "refs/heads/main") by trying +// the following steps in order: +// a). **Empty Ref:** If `ref` is empty, the repository's default branch is used. +// b). **Fully-Qualified:** If `ref` already starts with "refs/", it's considered fully +// qualified and used as-is. +// c). **Partially-Qualified:** If `ref` starts with "heads/" or "tags/", it is +// prefixed with "refs/" to make it fully-qualified. +// d). **Short Name:** Otherwise, the `ref` is treated as a short name. The function +// first attempts to resolve it as a branch ("refs/heads/"). If that +// returns a 404 Not Found error, it then attempts to resolve it as a tag +// ("refs/tags/"). +// +// 3. **Final Lookup:** Once a fully-qualified ref is determined, a final API call +// is made to fetch that reference's definitive commit SHA. +// +// Any unexpected (non-404) errors during the resolution process are returned +// immediately. All API errors are logged with rich context to aid diagnostics. +func resolveGitReference(ctx context.Context, githubClient *github.Client, owner, repo, ref, sha string) (*raw.ContentOpts, bool, error) { + // 1) If SHA explicitly provided, it's the highest priority. + if sha != "" { + return &raw.ContentOpts{Ref: "", SHA: sha}, false, nil + } + + // 1a) If sha is empty but ref looks like a SHA, return it without changes + if looksLikeSHA(ref) { + return &raw.ContentOpts{Ref: "", SHA: ref}, false, nil + } + + originalRef := ref // Keep original ref for clearer error messages down the line. + + // 2) If no SHA is provided, we try to resolve the ref into a fully-qualified format. + var reference *github.Reference + var resp *github.Response + var err error + var fallbackUsed bool + + switch { + case originalRef == "": + // 2a) If ref is empty, determine the default branch. + reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) + if err != nil { + return nil, false, err // Error is already wrapped in resolveDefaultBranch. + } + ref = reference.GetRef() + case strings.HasPrefix(originalRef, "refs/"): + // 2b) Already fully qualified. The reference will be fetched at the end. + case strings.HasPrefix(originalRef, "heads/") || strings.HasPrefix(originalRef, "tags/"): + // 2c) Partially qualified. Make it fully qualified. + ref = "refs/" + originalRef + default: + // 2d) It's a short name, so we try to resolve it to either a branch or a tag. + branchRef := "refs/heads/" + originalRef + reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, branchRef) + + if err == nil { + ref = branchRef // It's a branch. + } else { + // The branch lookup failed. Check if it was a 404 Not Found error. + ghErr, isGhErr := err.(*github.ErrorResponse) + if isGhErr && ghErr.Response.StatusCode == http.StatusNotFound { + tagRef := "refs/tags/" + originalRef + reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, tagRef) + if err == nil { + ref = tagRef // It's a tag. + } else { + // The tag lookup also failed. Check if it was a 404 Not Found error. + ghErr2, isGhErr2 := err.(*github.ErrorResponse) + if isGhErr2 && ghErr2.Response.StatusCode == http.StatusNotFound { + if originalRef == "main" { + reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) + if err != nil { + return nil, false, err // Error is already wrapped in resolveDefaultBranch. + } + // Update ref to the actual default branch ref so the note can be generated + ref = reference.GetRef() + fallbackUsed = true + break + } + return nil, false, fmt.Errorf("could not resolve ref %q as a branch or a tag", originalRef) + } + + // The tag lookup failed for a different reason. + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (tag)", resp, err) + return nil, false, fmt.Errorf("failed to get reference for tag '%s': %w", originalRef, err) + } + } else { + // The branch lookup failed for a different reason. + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get reference (branch)", resp, err) + return nil, false, fmt.Errorf("failed to get reference for branch '%s': %w", originalRef, err) + } + } + } + + if reference == nil { + reference, resp, err = githubClient.Git.GetRef(ctx, owner, repo, ref) + if err != nil { + if ref == "refs/heads/main" { + reference, err = resolveDefaultBranch(ctx, githubClient, owner, repo) + if err != nil { + return nil, false, err // Error is already wrapped in resolveDefaultBranch. + } + // Update ref to the actual default branch ref so the note can be generated + ref = reference.GetRef() + fallbackUsed = true + } else { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get final reference", resp, err) + return nil, false, fmt.Errorf("failed to get final reference for %q: %w", ref, err) + } + } + } + + sha = reference.GetObject().GetSHA() + return &raw.ContentOpts{Ref: ref, SHA: sha}, fallbackUsed, nil +} + +func resolveDefaultBranch(ctx context.Context, githubClient *github.Client, owner, repo string) (*github.Reference, error) { + repoInfo, resp, err := githubClient.Repositories.Get(ctx, owner, repo) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get repository info", resp, err) + return nil, fmt.Errorf("failed to get repository info: %w", err) + } + + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + + defaultBranch := repoInfo.GetDefaultBranch() + + defaultRef, resp, err := githubClient.Git.GetRef(ctx, owner, repo, "heads/"+defaultBranch) + if err != nil { + _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get default branch reference", resp, err) + return nil, fmt.Errorf("failed to get default branch reference: %w", err) + } + + if resp != nil && resp.Body != nil { + defer func() { _ = resp.Body.Close() }() + } + + return defaultRef, nil +} diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 8b5dab098..1e81d8c53 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -2,6 +2,7 @@ package github import ( "context" + "encoding/base64" "encoding/json" "net/http" "net/url" @@ -1883,6 +1884,11 @@ func Test_PushFiles(t *testing.T) { mock.GetReposGitRefByOwnerByRepoByRef, mockResponse(t, http.StatusNotFound, nil), ), + // Mock Repositories.Get to fail when trying to create branch from default + mock.WithRequestMatchHandler( + mock.GetReposByOwnerByRepo, + mockResponse(t, http.StatusNotFound, nil), + ), ), requestArgs: map[string]interface{}{ "owner": "owner", @@ -1896,8 +1902,8 @@ func Test_PushFiles(t *testing.T) { }, "message": "Update file", }, - expectError: true, - expectedErrMsg: "failed to get branch reference", + expectError: false, + expectedErrMsg: "failed to create branch from default", }, { name: "fails to get base commit", @@ -1962,6 +1968,400 @@ func Test_PushFiles(t *testing.T) { expectError: true, expectedErrMsg: "failed to create tree", }, + { + name: "successful push to empty repository", + mockedClient: mock.NewMockedHTTPClient( + // Get branch reference - first returns 409 for empty repo, second returns success after init + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + func() http.HandlerFunc { + callCount := 0 + return func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + callCount++ + if callCount == 1 { + // First call: empty repo + w.WriteHeader(http.StatusConflict) + response := map[string]interface{}{ + "message": "Git Repository is empty.", + } + _ = json.NewEncoder(w).Encode(response) + } else { + // Second call: return the created reference + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(mockRef) + } + } + }(), + ), + // Mock Repositories.Get to return default branch for initialization + mock.WithRequestMatch( + mock.GetReposByOwnerByRepo, + &github.Repository{ + DefaultBranch: github.Ptr("main"), + }, + ), + // Create initial file using Contents API + mock.WithRequestMatchHandler( + mock.PutReposContentsByOwnerByRepoByPath, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + require.NoError(t, err) + require.Equal(t, "Initial commit", body["message"]) + require.Equal(t, "main", body["branch"]) + w.WriteHeader(http.StatusCreated) + response := &github.RepositoryContentResponse{ + Commit: github.Commit{SHA: github.Ptr("abc123")}, + } + b, _ := json.Marshal(response) + _, _ = w.Write(b) + }), + ), + // Get the commit after initialization + mock.WithRequestMatch( + mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + mockCommit, + ), + // Create tree + mock.WithRequestMatch( + mock.PostReposGitTreesByOwnerByRepo, + mockTree, + ), + // Create commit + mock.WithRequestMatch( + mock.PostReposGitCommitsByOwnerByRepo, + mockNewCommit, + ), + // Update reference + mock.WithRequestMatch( + mock.PatchReposGitRefsByOwnerByRepoByRef, + mockUpdatedRef, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "main", + "files": []interface{}{ + map[string]interface{}{ + "path": "README.md", + "content": "# Initial README\n\nFirst commit to empty repository.", + }, + }, + "message": "Initial commit", + }, + expectError: false, + expectedRef: mockUpdatedRef, + }, + { + name: "successful push multiple files to empty repository", + mockedClient: mock.NewMockedHTTPClient( + // Get branch reference - called twice: first for empty check, second after file creation + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + func() http.HandlerFunc { + callCount := 0 + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + callCount++ + if callCount == 1 { + // First call: returns 409 Conflict for empty repo + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + response := map[string]interface{}{ + "message": "Git Repository is empty.", + } + _ = json.NewEncoder(w).Encode(response) + } else { + // Second call: returns the updated reference after first file creation + w.WriteHeader(http.StatusOK) + b, _ := json.Marshal(&github.Reference{ + Ref: github.Ptr("refs/heads/main"), + Object: &github.GitObject{SHA: github.Ptr("init456")}, + }) + _, _ = w.Write(b) + } + }) + }(), + ), + // Mock Repositories.Get to return default branch for initialization + mock.WithRequestMatch( + mock.GetReposByOwnerByRepo, + &github.Repository{ + DefaultBranch: github.Ptr("main"), + }, + ), + // Create initial empty README.md file using Contents API to initialize repo + mock.WithRequestMatchHandler( + mock.PutReposContentsByOwnerByRepoByPath, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body map[string]interface{} + err := json.NewDecoder(r.Body).Decode(&body) + require.NoError(t, err) + require.Equal(t, "Initial commit", body["message"]) + require.Equal(t, "main", body["branch"]) + // Verify it's an empty file + expectedContent := base64.StdEncoding.EncodeToString([]byte("")) + require.Equal(t, expectedContent, body["content"]) + w.WriteHeader(http.StatusCreated) + response := &github.RepositoryContentResponse{ + Content: &github.RepositoryContent{ + SHA: github.Ptr("readme123"), + }, + Commit: github.Commit{ + SHA: github.Ptr("init456"), + Tree: &github.Tree{ + SHA: github.Ptr("tree456"), + }, + }, + } + b, _ := json.Marshal(response) + _, _ = w.Write(b) + }), + ), + // Get the commit to retrieve parent SHA + mock.WithRequestMatchHandler( + mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + response := &github.Commit{ + SHA: github.Ptr("init456"), + Tree: &github.Tree{ + SHA: github.Ptr("tree456"), + }, + } + b, _ := json.Marshal(response) + _, _ = w.Write(b) + }), + ), + // Create tree with all user files + mock.WithRequestMatchHandler( + mock.PostReposGitTreesByOwnerByRepo, + expectRequestBody(t, map[string]interface{}{ + "base_tree": "tree456", + "tree": []interface{}{ + map[string]interface{}{ + "path": "README.md", + "mode": "100644", + "type": "blob", + "content": "# Project\n\nProject README", + }, + map[string]interface{}{ + "path": ".gitignore", + "mode": "100644", + "type": "blob", + "content": "node_modules/\n*.log\n", + }, + map[string]interface{}{ + "path": "src/main.js", + "mode": "100644", + "type": "blob", + "content": "console.log('Hello World');\n", + }, + }, + }).andThen( + mockResponse(t, http.StatusCreated, mockTree), + ), + ), + // Create commit with all user files + mock.WithRequestMatchHandler( + mock.PostReposGitCommitsByOwnerByRepo, + expectRequestBody(t, map[string]interface{}{ + "message": "Initial project setup", + "tree": "ghi789", + "parents": []interface{}{"init456"}, + }).andThen( + mockResponse(t, http.StatusCreated, mockNewCommit), + ), + ), + // Update reference + mock.WithRequestMatchHandler( + mock.PatchReposGitRefsByOwnerByRepoByRef, + expectRequestBody(t, map[string]interface{}{ + "sha": "jkl012", + "force": false, + }).andThen( + mockResponse(t, http.StatusOK, mockUpdatedRef), + ), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "main", + "files": []interface{}{ + map[string]interface{}{ + "path": "README.md", + "content": "# Project\n\nProject README", + }, + map[string]interface{}{ + "path": ".gitignore", + "content": "node_modules/\n*.log\n", + }, + map[string]interface{}{ + "path": "src/main.js", + "content": "console.log('Hello World');\n", + }, + }, + "message": "Initial project setup", + }, + expectError: false, + expectedRef: mockUpdatedRef, + }, + { + name: "fails to create initial file in empty repository", + mockedClient: mock.NewMockedHTTPClient( + // Get branch reference returns 409 Conflict for empty repo + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + response := map[string]interface{}{ + "message": "Git Repository is empty.", + } + _ = json.NewEncoder(w).Encode(response) + }), + ), + // Mock Repositories.Get to return default branch + mock.WithRequestMatch( + mock.GetReposByOwnerByRepo, + &github.Repository{ + DefaultBranch: github.Ptr("main"), + }, + ), + // Fail to create initial file using Contents API + mock.WithRequestMatchHandler( + mock.PutReposContentsByOwnerByRepoByPath, + mockResponse(t, http.StatusInternalServerError, nil), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "main", + "files": []interface{}{ + map[string]interface{}{ + "path": "README.md", + "content": "# README", + }, + }, + "message": "Initial commit", + }, + expectError: false, + expectedErrMsg: "failed to initialize repository", + }, + { + name: "fails to get reference after creating initial file in empty repository", + mockedClient: mock.NewMockedHTTPClient( + // Get branch reference - called twice + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + func() http.HandlerFunc { + callCount := 0 + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + callCount++ + if callCount == 1 { + // First call: returns 409 Conflict for empty repo + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + response := map[string]interface{}{ + "message": "Git Repository is empty.", + } + _ = json.NewEncoder(w).Encode(response) + } else { + // Second call: fails + w.WriteHeader(http.StatusInternalServerError) + } + }) + }(), + ), + // Mock Repositories.Get to return default branch + mock.WithRequestMatch( + mock.GetReposByOwnerByRepo, + &github.Repository{ + DefaultBranch: github.Ptr("main"), + }, + ), + // Create initial file using Contents API + mock.WithRequestMatch( + mock.PutReposContentsByOwnerByRepoByPath, + &github.RepositoryContentResponse{ + Content: &github.RepositoryContent{SHA: github.Ptr("readme123")}, + Commit: github.Commit{SHA: github.Ptr("init456")}, + }, + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "main", + "files": []interface{}{ + map[string]interface{}{ + "path": "README.md", + "content": "# README", + }, + }, + "message": "Initial commit", + }, + expectError: false, + expectedErrMsg: "failed to initialize repository", + }, + { + name: "fails to get commit in empty repository with multiple files", + mockedClient: mock.NewMockedHTTPClient( + // Get branch reference returns 409 Conflict for empty repo + mock.WithRequestMatchHandler( + mock.GetReposGitRefByOwnerByRepoByRef, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + response := map[string]interface{}{ + "message": "Git Repository is empty.", + } + _ = json.NewEncoder(w).Encode(response) + }), + ), + // Mock Repositories.Get to return default branch + mock.WithRequestMatch( + mock.GetReposByOwnerByRepo, + &github.Repository{ + DefaultBranch: github.Ptr("main"), + }, + ), + // Create initial file using Contents API + mock.WithRequestMatch( + mock.PutReposContentsByOwnerByRepoByPath, + &github.RepositoryContentResponse{ + Content: &github.RepositoryContent{SHA: github.Ptr("readme123")}, + Commit: github.Commit{SHA: github.Ptr("init456")}, + }, + ), + // Fail to get commit + mock.WithRequestMatchHandler( + mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + mockResponse(t, http.StatusInternalServerError, nil), + ), + ), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "branch": "main", + "files": []interface{}{ + map[string]interface{}{ + "path": "README.md", + "content": "# README", + }, + map[string]interface{}{ + "path": "LICENSE", + "content": "MIT", + }, + }, + "message": "Initial commit", + }, + expectError: false, + expectedErrMsg: "failed to initialize repository", + }, } for _, tc := range tests { From eb6db0fb5da6a9a2c962d8eae85d0716d4c84b58 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Dec 2025 14:58:33 +0000 Subject: [PATCH 17/70] Add scopes package and update ServerTool struct with scope fields - Created pkg/scopes package with OAuth scope constants - Added RequiredScopes and AcceptedScopes fields to ServerTool - Added NewToolWithScopes helpers in dependencies.go - Updated context tools (get_me, get_teams, get_team_members) with scopes Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com> --- pkg/github/context_tools.go | 13 ++++-- pkg/github/dependencies.go | 36 +++++++++++++++++ pkg/inventory/server_tool.go | 9 +++++ pkg/scopes/scopes.go | 77 ++++++++++++++++++++++++++++++++++++ 4 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 pkg/scopes/scopes.go diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index e0df82c88..77c9ce10a 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -7,6 +7,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "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/jsonschema-go/jsonschema" @@ -38,7 +39,7 @@ type UserDetails struct { // GetMe creates a tool to get details of the authenticated user. func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataContext, mcp.Tool{ Name: "get_me", @@ -51,6 +52,8 @@ func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool { // OpenAI strict mode requires the properties field to be present. InputSchema: json.RawMessage(`{"type":"object","properties":{}}`), }, + nil, // no required scopes + nil, // no accepted scopes func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, _ map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -110,7 +113,7 @@ type OrganizationTeams struct { } func GetTeams(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataContext, mcp.Tool{ Name: "get_teams", @@ -129,6 +132,8 @@ func GetTeams(t translations.TranslationHelperFunc) inventory.ServerTool { }, }, }, + scopes.ToStringSlice(scopes.ReadOrg), + scopes.ToStringSlice(scopes.ReadOrg, scopes.WriteOrg, scopes.AdminOrg), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { user, err := OptionalParam[string](args, "user") if err != nil { @@ -207,7 +212,7 @@ func GetTeams(t translations.TranslationHelperFunc) inventory.ServerTool { } func GetTeamMembers(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataContext, mcp.Tool{ Name: "get_team_members", @@ -231,6 +236,8 @@ func GetTeamMembers(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"org", "team_slug"}, }, }, + scopes.ToStringSlice(scopes.ReadOrg), + scopes.ToStringSlice(scopes.ReadOrg, scopes.WriteOrg, scopes.AdminOrg), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { org, err := RequiredParam[string](args, "org") if err != nil { diff --git a/pkg/github/dependencies.go b/pkg/github/dependencies.go index d23e993c3..e15efb1d5 100644 --- a/pkg/github/dependencies.go +++ b/pkg/github/dependencies.go @@ -155,6 +155,24 @@ func NewTool[In, Out any](toolset inventory.ToolsetMetadata, tool mcp.Tool, hand }) } +// NewToolWithScopes creates a ServerTool with OAuth scope requirements. +// This is like NewTool but also accepts required and accepted scopes. +func NewToolWithScopes[In, Out any]( + toolset inventory.ToolsetMetadata, + tool mcp.Tool, + requiredScopes []string, + acceptedScopes []string, + handler func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error), +) inventory.ServerTool { + st := inventory.NewServerToolWithContextHandler(tool, toolset, func(ctx context.Context, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error) { + deps := MustDepsFromContext(ctx) + return handler(ctx, deps, req, args) + }) + st.RequiredScopes = requiredScopes + st.AcceptedScopes = acceptedScopes + return st +} + // NewToolFromHandler creates a ServerTool that retrieves ToolDependencies from context at call time. // Use this when you have a handler that conforms to mcp.ToolHandler directly. // @@ -166,3 +184,21 @@ func NewToolFromHandler(toolset inventory.ToolsetMetadata, tool mcp.Tool, handle return handler(ctx, deps, req) }) } + +// NewToolFromHandlerWithScopes creates a ServerTool with OAuth scope requirements. +// This is like NewToolFromHandler but also accepts required and accepted scopes. +func NewToolFromHandlerWithScopes( + toolset inventory.ToolsetMetadata, + tool mcp.Tool, + requiredScopes []string, + acceptedScopes []string, + handler func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest) (*mcp.CallToolResult, error), +) inventory.ServerTool { + st := inventory.NewServerToolWithRawContextHandler(tool, toolset, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + deps := MustDepsFromContext(ctx) + return handler(ctx, deps, req) + }) + st.RequiredScopes = requiredScopes + st.AcceptedScopes = acceptedScopes + return st +} diff --git a/pkg/inventory/server_tool.go b/pkg/inventory/server_tool.go index 362ee2643..095bedf2b 100644 --- a/pkg/inventory/server_tool.go +++ b/pkg/inventory/server_tool.go @@ -70,6 +70,15 @@ type ServerTool struct { // The context carries request-scoped information for the consumer to use. // Returns (enabled, error). On error, the tool should be treated as disabled. Enabled func(ctx context.Context) (bool, error) + + // RequiredScopes specifies the minimum OAuth scopes required for this tool. + // These are the scopes that must be present for the tool to function. + RequiredScopes []string + + // AcceptedScopes specifies all OAuth scopes that can be used with this tool. + // This includes the required scopes plus any higher-level scopes that provide + // the necessary permissions due to scope hierarchy. + AcceptedScopes []string } // IsReadOnly returns true if this tool is marked as read-only via annotations. diff --git a/pkg/scopes/scopes.go b/pkg/scopes/scopes.go new file mode 100644 index 000000000..d6272a0a0 --- /dev/null +++ b/pkg/scopes/scopes.go @@ -0,0 +1,77 @@ +package scopes + +// Scope represents a GitHub OAuth scope. +// These constants define all OAuth scopes used by the GitHub MCP server tools. +// See https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps +type Scope string + +const ( +// Repo grants full control of private repositories +Repo Scope = "repo" + +// PublicRepo grants access to public repositories +PublicRepo Scope = "public_repo" + +// ReadOrg grants read-only access to organization membership, teams, and projects +ReadOrg Scope = "read:org" + +// WriteOrg grants write access to organization membership and teams +WriteOrg Scope = "write:org" + +// AdminOrg grants full control of organizations and teams +AdminOrg Scope = "admin:org" + +// Gist grants write access to gists +Gist Scope = "gist" + +// Notifications grants access to notifications +Notifications Scope = "notifications" + +// ReadProject grants read-only access to projects +ReadProject Scope = "read:project" + +// Project grants full control of projects +Project Scope = "project" + +// SecurityEvents grants read and write access to security events +SecurityEvents Scope = "security_events" +) + +// ScopeSet represents a set of OAuth scopes. +type ScopeSet map[Scope]bool + +// NewScopeSet creates a new ScopeSet from the given scopes. +func NewScopeSet(scopes ...Scope) ScopeSet { +set := make(ScopeSet) +for _, scope := range scopes { +set[scope] = true +} +return set +} + +// ToSlice converts a ScopeSet to a slice of Scope values. +func (s ScopeSet) ToSlice() []Scope { +scopes := make([]Scope, 0, len(s)) +for scope := range s { +scopes = append(scopes, scope) +} +return scopes +} + +// ToStringSlice converts a ScopeSet to a slice of string values. +func (s ScopeSet) ToStringSlice() []string { +scopes := make([]string, 0, len(s)) +for scope := range s { +scopes = append(scopes, string(scope)) +} +return scopes +} + +// ToStringSlice converts a slice of Scopes to a slice of strings. +func ToStringSlice(scopes ...Scope) []string { +result := make([]string, len(scopes)) +for i, scope := range scopes { +result[i] = string(scope) +} +return result +} From 1e5931a2ab61077c6c6a96b3b3df984eda101147 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Dec 2025 15:07:10 +0000 Subject: [PATCH 18/70] Update most tools with OAuth scope information - Updated 60+ tools with required and accepted OAuth scopes - Added scopes to: gists, git, notifications, projects, code scanning, dependabot, secret scanning, security advisories, actions, discussions, issues (partial), labels, pull requests (partial), repositories (partial), search (partial) - Remaining: ~20 tools in issues, pullrequests, repositories, and search files Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com> --- pkg/github/actions.go | 1 + pkg/github/code_scanning.go | 9 +++- pkg/github/dependabot.go | 9 +++- pkg/github/discussions.go | 17 ++++++-- pkg/github/gists.go | 17 ++++++-- pkg/github/git.go | 5 ++- pkg/github/issues.go | 17 ++++++-- pkg/github/labels.go | 13 ++++-- pkg/github/notifications.go | 25 ++++++++--- pkg/github/projects.go | 37 +++++++++++++---- pkg/github/pullrequests.go | 1 + pkg/github/repositories.go | 69 +++++++++++++++++++++++-------- pkg/github/search.go | 1 + pkg/github/secret_scanning.go | 9 +++- pkg/github/security_advisories.go | 17 ++++++-- 15 files changed, 189 insertions(+), 58 deletions(-) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 1547c3251..cffab4b50 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -13,6 +13,7 @@ import ( buffer "github.com/github/github-mcp-server/pkg/buffer" ghErrors "github.com/github/github-mcp-server/pkg/errors" "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/v79/github" diff --git a/pkg/github/code_scanning.go b/pkg/github/code_scanning.go index 5e25d0501..af232937c 100644 --- a/pkg/github/code_scanning.go +++ b/pkg/github/code_scanning.go @@ -8,6 +8,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "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/v79/github" @@ -16,7 +17,7 @@ import ( ) func GetCodeScanningAlert(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataCodeSecurity, mcp.Tool{ Name: "get_code_scanning_alert", @@ -44,6 +45,8 @@ func GetCodeScanningAlert(t translations.TranslationHelperFunc) inventory.Server Required: []string{"owner", "repo", "alertNumber"}, }, }, + scopes.ToStringSlice(scopes.SecurityEvents), + scopes.ToStringSlice(scopes.SecurityEvents, 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 { @@ -92,7 +95,7 @@ func GetCodeScanningAlert(t translations.TranslationHelperFunc) inventory.Server } func ListCodeScanningAlerts(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataCodeSecurity, mcp.Tool{ Name: "list_code_scanning_alerts", @@ -135,6 +138,8 @@ func ListCodeScanningAlerts(t translations.TranslationHelperFunc) inventory.Serv Required: []string{"owner", "repo"}, }, }, + scopes.ToStringSlice(scopes.SecurityEvents), + scopes.ToStringSlice(scopes.SecurityEvents, 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 { diff --git a/pkg/github/dependabot.go b/pkg/github/dependabot.go index db6352dab..9a0c09a8f 100644 --- a/pkg/github/dependabot.go +++ b/pkg/github/dependabot.go @@ -9,6 +9,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "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/v79/github" @@ -17,7 +18,7 @@ import ( ) func GetDependabotAlert(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataDependabot, mcp.Tool{ Name: "get_dependabot_alert", @@ -45,6 +46,8 @@ func GetDependabotAlert(t translations.TranslationHelperFunc) inventory.ServerTo Required: []string{"owner", "repo", "alertNumber"}, }, }, + scopes.ToStringSlice(scopes.SecurityEvents), + scopes.ToStringSlice(scopes.SecurityEvents, 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 { @@ -93,7 +96,7 @@ func GetDependabotAlert(t translations.TranslationHelperFunc) inventory.ServerTo } func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataDependabot, mcp.Tool{ Name: "list_dependabot_alerts", @@ -128,6 +131,8 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server Required: []string{"owner", "repo"}, }, }, + scopes.ToStringSlice(scopes.SecurityEvents), + scopes.ToStringSlice(scopes.SecurityEvents, 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 { diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index c891ba294..d91722e6e 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -6,6 +6,7 @@ import ( "fmt" "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" @@ -123,7 +124,7 @@ func getQueryType(useOrdering bool, categoryID *githubv4.ID) any { } func ListDiscussions(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataDiscussions, mcp.Tool{ Name: "list_discussions", @@ -161,6 +162,8 @@ func ListDiscussions(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner"}, }), }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { @@ -275,7 +278,7 @@ func ListDiscussions(t translations.TranslationHelperFunc) inventory.ServerTool } func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataDiscussions, mcp.Tool{ Name: "get_discussion", @@ -303,6 +306,8 @@ func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "discussionNumber"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { // Decode params var params struct { @@ -378,7 +383,7 @@ func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool { } func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataDiscussions, mcp.Tool{ Name: "get_discussion_comments", @@ -406,6 +411,8 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve Required: []string{"owner", "repo", "discussionNumber"}, }), }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { // Decode params var params struct { @@ -504,7 +511,7 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve } func ListDiscussionCategories(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataDiscussions, mcp.Tool{ Name: "list_discussion_categories", @@ -528,6 +535,8 @@ func ListDiscussionCategories(t translations.TranslationHelperFunc) inventory.Se Required: []string{"owner"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { diff --git a/pkg/github/gists.go b/pkg/github/gists.go index 4d741b88d..cd268c6a1 100644 --- a/pkg/github/gists.go +++ b/pkg/github/gists.go @@ -9,6 +9,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "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/v79/github" @@ -18,7 +19,7 @@ import ( // ListGists creates a tool to list gists for a user func ListGists(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataGists, mcp.Tool{ Name: "list_gists", @@ -41,6 +42,8 @@ func ListGists(t translations.TranslationHelperFunc) inventory.ServerTool { }, }), }, + nil, // no required scopes + nil, // no accepted scopes func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { username, err := OptionalParam[string](args, "username") if err != nil { @@ -104,7 +107,7 @@ func ListGists(t translations.TranslationHelperFunc) inventory.ServerTool { // GetGist creates a tool to get the content of a gist func GetGist(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataGists, mcp.Tool{ Name: "get_gist", @@ -124,6 +127,8 @@ func GetGist(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"gist_id"}, }, }, + nil, // no required scopes + nil, // no accepted scopes func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { gistID, err := RequiredParam[string](args, "gist_id") if err != nil { @@ -161,7 +166,7 @@ func GetGist(t translations.TranslationHelperFunc) inventory.ServerTool { // CreateGist creates a tool to create a new gist func CreateGist(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataGists, mcp.Tool{ Name: "create_gist", @@ -194,6 +199,8 @@ func CreateGist(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"filename", "content"}, }, }, + scopes.ToStringSlice(scopes.Gist), + scopes.ToStringSlice(scopes.Gist), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { description, err := OptionalParam[string](args, "description") if err != nil { @@ -263,7 +270,7 @@ func CreateGist(t translations.TranslationHelperFunc) inventory.ServerTool { // UpdateGist creates a tool to edit an existing gist func UpdateGist(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataGists, mcp.Tool{ Name: "update_gist", @@ -295,6 +302,8 @@ func UpdateGist(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"gist_id", "filename", "content"}, }, }, + scopes.ToStringSlice(scopes.Gist), + scopes.ToStringSlice(scopes.Gist), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { gistID, err := RequiredParam[string](args, "gist_id") if err != nil { diff --git a/pkg/github/git.go b/pkg/github/git.go index 7b93c3675..06c766163 100644 --- a/pkg/github/git.go +++ b/pkg/github/git.go @@ -8,6 +8,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "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/v79/github" @@ -39,7 +40,7 @@ type TreeResponse struct { // GetRepositoryTree creates a tool to get the tree structure of a GitHub repository. func GetRepositoryTree(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataGit, mcp.Tool{ Name: "get_repository_tree", @@ -76,6 +77,8 @@ func GetRepositoryTree(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner", "repo"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 23d16b172..406e980fe 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -11,6 +11,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/lockdown" "github.com/github/github-mcp-server/pkg/octicons" "github.com/github/github-mcp-server/pkg/sanitize" @@ -545,7 +546,7 @@ func GetIssueLabels(ctx context.Context, client *githubv4.Client, owner string, // 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. func ListIssueTypes(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataIssues, mcp.Tool{ Name: "list_issue_types", @@ -565,6 +566,8 @@ func ListIssueTypes(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner"}, }, }, + scopes.ToStringSlice(scopes.ReadOrg), + scopes.ToStringSlice(scopes.ReadOrg, scopes.WriteOrg, scopes.AdminOrg), 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 { @@ -600,7 +603,7 @@ func ListIssueTypes(t translations.TranslationHelperFunc) inventory.ServerTool { // AddIssueComment creates a tool to add a comment to an issue. func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataIssues, mcp.Tool{ Name: "add_issue_comment", @@ -632,6 +635,8 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo", "issue_number", "body"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { @@ -683,7 +688,7 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool // SubIssueWrite creates a tool to add a sub-issue to a parent issue. func SubIssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataIssues, mcp.Tool{ Name: "sub_issue_write", @@ -736,6 +741,8 @@ Options are: Required: []string{"method", "owner", "repo", "issue_number", "sub_issue_id"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { @@ -971,7 +978,7 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool { // IssueWrite creates a tool to create a new or update an existing issue in a GitHub repository. func IssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataIssues, mcp.Tool{ Name: "issue_write", @@ -1052,6 +1059,8 @@ Options are: Required: []string{"method", "owner", "repo"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { diff --git a/pkg/github/labels.go b/pkg/github/labels.go index 2811cf66e..d55b605fe 100644 --- a/pkg/github/labels.go +++ b/pkg/github/labels.go @@ -8,6 +8,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "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/jsonschema-go/jsonschema" @@ -17,7 +18,7 @@ import ( // GetLabel retrieves a specific label by name from a GitHub repository func GetLabel(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataIssues, mcp.Tool{ Name: "get_label", @@ -45,6 +46,8 @@ func GetLabel(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "name"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { @@ -118,7 +121,7 @@ func GetLabelForLabelsToolset(t translations.TranslationHelperFunc) inventory.Se // ListLabels lists labels from a repository func ListLabels(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetLabels, mcp.Tool{ Name: "list_label", @@ -142,6 +145,8 @@ func ListLabels(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { @@ -208,7 +213,7 @@ func ListLabels(t translations.TranslationHelperFunc) inventory.ServerTool { // LabelWrite handles create, update, and delete operations for GitHub labels func LabelWrite(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetLabels, mcp.Tool{ Name: "label_write", @@ -253,6 +258,8 @@ func LabelWrite(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"method", "owner", "repo", "name"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { // Get and validate required parameters method, err := RequiredParam[string](args, "method") diff --git a/pkg/github/notifications.go b/pkg/github/notifications.go index 1e2011fa3..b3008362b 100644 --- a/pkg/github/notifications.go +++ b/pkg/github/notifications.go @@ -11,6 +11,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "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/v79/github" @@ -26,7 +27,7 @@ const ( // ListNotifications creates a tool to list notifications for the current user. func ListNotifications(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataNotifications, mcp.Tool{ Name: "list_notifications", @@ -62,6 +63,8 @@ func ListNotifications(t translations.TranslationHelperFunc) inventory.ServerToo }, }), }, + scopes.ToStringSlice(scopes.Notifications), + scopes.ToStringSlice(scopes.Notifications), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -162,7 +165,7 @@ func ListNotifications(t translations.TranslationHelperFunc) inventory.ServerToo // DismissNotification creates a tool to mark a notification as read/done. func DismissNotification(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataNotifications, mcp.Tool{ Name: "dismiss_notification", @@ -187,6 +190,8 @@ func DismissNotification(t translations.TranslationHelperFunc) inventory.ServerT Required: []string{"threadID", "state"}, }, }, + scopes.ToStringSlice(scopes.Notifications), + scopes.ToStringSlice(scopes.Notifications), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -243,7 +248,7 @@ func DismissNotification(t translations.TranslationHelperFunc) inventory.ServerT // MarkAllNotificationsRead creates a tool to mark all notifications as read. func MarkAllNotificationsRead(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataNotifications, mcp.Tool{ Name: "mark_all_notifications_read", @@ -270,6 +275,8 @@ func MarkAllNotificationsRead(t translations.TranslationHelperFunc) inventory.Se }, }, }, + scopes.ToStringSlice(scopes.Notifications), + scopes.ToStringSlice(scopes.Notifications), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -334,7 +341,7 @@ func MarkAllNotificationsRead(t translations.TranslationHelperFunc) inventory.Se // GetNotificationDetails creates a tool to get details for a specific notification. func GetNotificationDetails(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataNotifications, mcp.Tool{ Name: "get_notification_details", @@ -354,6 +361,8 @@ func GetNotificationDetails(t translations.TranslationHelperFunc) inventory.Serv Required: []string{"notificationID"}, }, }, + scopes.ToStringSlice(scopes.Notifications), + scopes.ToStringSlice(scopes.Notifications), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -402,7 +411,7 @@ const ( // ManageNotificationSubscription creates a tool to manage a notification subscription (ignore, watch, delete) func ManageNotificationSubscription(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataNotifications, mcp.Tool{ Name: "manage_notification_subscription", @@ -427,6 +436,8 @@ func ManageNotificationSubscription(t translations.TranslationHelperFunc) invent Required: []string{"notificationID", "action"}, }, }, + scopes.ToStringSlice(scopes.Notifications), + scopes.ToStringSlice(scopes.Notifications), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -497,7 +508,7 @@ const ( // ManageRepositoryNotificationSubscription creates a tool to manage a repository notification subscription (ignore, watch, delete) func ManageRepositoryNotificationSubscription(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataNotifications, mcp.Tool{ Name: "manage_repository_notification_subscription", @@ -526,6 +537,8 @@ func ManageRepositoryNotificationSubscription(t translations.TranslationHelperFu Required: []string{"owner", "repo", "action"}, }, }, + scopes.ToStringSlice(scopes.Notifications), + scopes.ToStringSlice(scopes.Notifications), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 0536bed99..4edef178e 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -10,6 +10,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "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/v79/github" @@ -26,7 +27,7 @@ const ( ) func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataProjects, mcp.Tool{ Name: "list_projects", @@ -67,6 +68,8 @@ func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner_type", "owner"}, }, }, + scopes.ToStringSlice(scopes.ReadProject), + scopes.ToStringSlice(scopes.ReadProject, scopes.Project), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -143,7 +146,7 @@ func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool { } func GetProject(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataProjects, mcp.Tool{ Name: "get_project", @@ -172,6 +175,8 @@ func GetProject(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"project_number", "owner_type", "owner"}, }, }, + scopes.ToStringSlice(scopes.ReadProject), + scopes.ToStringSlice(scopes.ReadProject, scopes.Project), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { projectNumber, err := RequiredInt(args, "project_number") @@ -231,7 +236,7 @@ func GetProject(t translations.TranslationHelperFunc) inventory.ServerTool { } func ListProjectFields(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataProjects, mcp.Tool{ Name: "list_project_fields", @@ -272,6 +277,8 @@ func ListProjectFields(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner_type", "owner", "project_number"}, }, }, + scopes.ToStringSlice(scopes.ReadProject), + scopes.ToStringSlice(scopes.ReadProject, scopes.Project), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -337,7 +344,7 @@ func ListProjectFields(t translations.TranslationHelperFunc) inventory.ServerToo } func GetProjectField(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataProjects, mcp.Tool{ Name: "get_project_field", @@ -370,6 +377,8 @@ func GetProjectField(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner_type", "owner", "project_number", "field_id"}, }, }, + scopes.ToStringSlice(scopes.ReadProject), + scopes.ToStringSlice(scopes.ReadProject, scopes.Project), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -429,7 +438,7 @@ func GetProjectField(t translations.TranslationHelperFunc) inventory.ServerTool } func ListProjectItems(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataProjects, mcp.Tool{ Name: "list_project_items", @@ -481,6 +490,8 @@ func ListProjectItems(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner_type", "owner", "project_number"}, }, }, + scopes.ToStringSlice(scopes.ReadProject), + scopes.ToStringSlice(scopes.ReadProject, scopes.Project), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -565,7 +576,7 @@ func ListProjectItems(t translations.TranslationHelperFunc) inventory.ServerTool } func GetProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataProjects, mcp.Tool{ Name: "get_project_item", @@ -605,6 +616,8 @@ func GetProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner_type", "owner", "project_number", "item_id"}, }, }, + scopes.ToStringSlice(scopes.ReadProject), + scopes.ToStringSlice(scopes.ReadProject, scopes.Project), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -671,7 +684,7 @@ func GetProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { } func AddProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataProjects, mcp.Tool{ Name: "add_project_item", @@ -709,6 +722,8 @@ func AddProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner_type", "owner", "project_number", "item_type", "item_id"}, }, }, + scopes.ToStringSlice(scopes.Project), + scopes.ToStringSlice(scopes.Project), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -782,7 +797,7 @@ func AddProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { } func UpdateProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataProjects, mcp.Tool{ Name: "update_project_item", @@ -819,6 +834,8 @@ func UpdateProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner_type", "owner", "project_number", "item_id", "updated_field"}, }, }, + scopes.ToStringSlice(scopes.Project), + scopes.ToStringSlice(scopes.Project), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -894,7 +911,7 @@ func UpdateProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo } func DeleteProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataProjects, mcp.Tool{ Name: "delete_project_item", @@ -928,6 +945,8 @@ func DeleteProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner_type", "owner", "project_number", "item_id"}, }, }, + scopes.ToStringSlice(scopes.Project), + scopes.ToStringSlice(scopes.Project), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index d51c14fa4..400918858 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -15,6 +15,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/lockdown" "github.com/github/github-mcp-server/pkg/octicons" "github.com/github/github-mcp-server/pkg/sanitize" diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 1ab33a57c..f50babc32 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -11,6 +11,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/octicons" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" @@ -20,7 +21,7 @@ import ( ) func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataRepos, mcp.Tool{ Name: "get_commit", @@ -53,6 +54,8 @@ func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "sha"}, }), }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { @@ -117,7 +120,7 @@ func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { // ListCommits creates a tool to get commits of a branch in a repository. func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataRepos, mcp.Tool{ Name: "list_commits", @@ -149,6 +152,8 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }), }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { @@ -224,7 +229,7 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { // ListBranches creates a tool to list branches in a GitHub repository. func ListBranches(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataRepos, mcp.Tool{ Name: "list_branches", @@ -248,6 +253,8 @@ func ListBranches(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }), }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { @@ -508,7 +515,7 @@ If the SHA is not provided, the tool will attempt to acquire it by fetching the // CreateRepository creates a tool to create a new GitHub repository. func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataRepos, mcp.Tool{ Name: "create_repository", @@ -544,6 +551,8 @@ func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"name"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { name, err := RequiredParam[string](args, "name") if err != nil { @@ -613,7 +622,7 @@ func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool // 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( + return NewToolWithScopes( ToolsetMetadataRepos, mcp.Tool{ Name: "get_file_contents", @@ -650,6 +659,8 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { @@ -804,7 +815,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool // ForkRepository creates a tool to fork a repository. func ForkRepository(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataRepos, mcp.Tool{ Name: "fork_repository", @@ -833,6 +844,8 @@ func ForkRepository(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { @@ -902,7 +915,7 @@ func ForkRepository(t translations.TranslationHelperFunc) inventory.ServerTool { // The approach implemented here gets automatic commit signing when used with either the github-actions user or as an app, // both of which suit an LLM well. func DeleteFile(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataRepos, mcp.Tool{ Name: "delete_file", @@ -939,6 +952,8 @@ func DeleteFile(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "path", "message", "branch"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { @@ -1086,7 +1101,7 @@ func DeleteFile(t translations.TranslationHelperFunc) inventory.ServerTool { // CreateBranch creates a tool to create a new branch. func CreateBranch(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataRepos, mcp.Tool{ Name: "create_branch", @@ -1118,6 +1133,8 @@ func CreateBranch(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "branch"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { @@ -1198,7 +1215,7 @@ func CreateBranch(t translations.TranslationHelperFunc) inventory.ServerTool { // PushFiles creates a tool to push multiple files in a single commit to a GitHub repository. func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataRepos, mcp.Tool{ Name: "push_files", @@ -1248,6 +1265,8 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "branch", "files", "message"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { @@ -1431,7 +1450,7 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { // ListTags creates a tool to list tags in a GitHub repository. func ListTags(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataRepos, mcp.Tool{ Name: "list_tags", @@ -1455,6 +1474,8 @@ func ListTags(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }), }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { @@ -1509,7 +1530,7 @@ func ListTags(t translations.TranslationHelperFunc) inventory.ServerTool { // GetTag creates a tool to get details about a specific tag in a GitHub repository. func GetTag(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataRepos, mcp.Tool{ Name: "get_tag", @@ -1537,6 +1558,8 @@ func GetTag(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "tag"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { @@ -1606,7 +1629,7 @@ func GetTag(t translations.TranslationHelperFunc) inventory.ServerTool { // ListReleases creates a tool to list releases in a GitHub repository. func ListReleases(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataRepos, mcp.Tool{ Name: "list_releases", @@ -1630,6 +1653,8 @@ func ListReleases(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }), }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { @@ -1680,7 +1705,7 @@ func ListReleases(t translations.TranslationHelperFunc) inventory.ServerTool { // GetLatestRelease creates a tool to get the latest release in a GitHub repository. func GetLatestRelease(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataRepos, mcp.Tool{ Name: "get_latest_release", @@ -1704,6 +1729,8 @@ func GetLatestRelease(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { @@ -1744,7 +1771,7 @@ func GetLatestRelease(t translations.TranslationHelperFunc) inventory.ServerTool } func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataRepos, mcp.Tool{ Name: "get_release_by_tag", @@ -1772,6 +1799,8 @@ func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo", "tag"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { @@ -1821,7 +1850,7 @@ func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool // ListStarredRepositories creates a tool to list starred repositories for the authenticated user or a specified user. func ListStarredRepositories(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataStargazers, mcp.Tool{ Name: "list_starred_repositories", @@ -1850,6 +1879,8 @@ func ListStarredRepositories(t translations.TranslationHelperFunc) inventory.Ser }, }), }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { username, err := OptionalParam[string](args, "username") if err != nil { @@ -1952,7 +1983,7 @@ func ListStarredRepositories(t translations.TranslationHelperFunc) inventory.Ser // StarRepository creates a tool to star a repository. func StarRepository(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataStargazers, mcp.Tool{ Name: "star_repository", @@ -1977,6 +2008,8 @@ func StarRepository(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }, }, + scopes.ToStringSlice(scopes.PublicRepo), + scopes.ToStringSlice(scopes.PublicRepo, 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 { @@ -2017,7 +2050,7 @@ func StarRepository(t translations.TranslationHelperFunc) inventory.ServerTool { // UnstarRepository creates a tool to unstar a repository. func UnstarRepository(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataStargazers, mcp.Tool{ Name: "unstar_repository", @@ -2041,6 +2074,8 @@ func UnstarRepository(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo"}, }, }, + scopes.ToStringSlice(scopes.PublicRepo), + scopes.ToStringSlice(scopes.PublicRepo, 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 { diff --git a/pkg/github/search.go b/pkg/github/search.go index 9a8b971e2..e207479d6 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -9,6 +9,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "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/v79/github" diff --git a/pkg/github/secret_scanning.go b/pkg/github/secret_scanning.go index 0de5166ba..fb259758e 100644 --- a/pkg/github/secret_scanning.go +++ b/pkg/github/secret_scanning.go @@ -9,6 +9,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "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/v79/github" @@ -17,7 +18,7 @@ import ( ) func GetSecretScanningAlert(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataSecretProtection, mcp.Tool{ Name: "get_secret_scanning_alert", @@ -45,6 +46,8 @@ func GetSecretScanningAlert(t translations.TranslationHelperFunc) inventory.Serv Required: []string{"owner", "repo", "alertNumber"}, }, }, + scopes.ToStringSlice(scopes.SecurityEvents), + scopes.ToStringSlice(scopes.SecurityEvents, 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 { @@ -93,7 +96,7 @@ func GetSecretScanningAlert(t translations.TranslationHelperFunc) inventory.Serv } func ListSecretScanningAlerts(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataSecretProtection, mcp.Tool{ Name: "list_secret_scanning_alerts", @@ -131,6 +134,8 @@ func ListSecretScanningAlerts(t translations.TranslationHelperFunc) inventory.Se Required: []string{"owner", "repo"}, }, }, + scopes.ToStringSlice(scopes.SecurityEvents), + scopes.ToStringSlice(scopes.SecurityEvents, 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 { diff --git a/pkg/github/security_advisories.go b/pkg/github/security_advisories.go index f898de61d..44858dbd9 100644 --- a/pkg/github/security_advisories.go +++ b/pkg/github/security_advisories.go @@ -9,6 +9,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "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/v79/github" @@ -17,7 +18,7 @@ import ( ) func ListGlobalSecurityAdvisories(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataSecurityAdvisories, mcp.Tool{ Name: "list_global_security_advisories", @@ -83,6 +84,8 @@ func ListGlobalSecurityAdvisories(t translations.TranslationHelperFunc) inventor }, }, }, + scopes.ToStringSlice(scopes.SecurityEvents), + scopes.ToStringSlice(scopes.SecurityEvents, scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -207,7 +210,7 @@ func ListGlobalSecurityAdvisories(t translations.TranslationHelperFunc) inventor } func ListRepositorySecurityAdvisories(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataSecurityAdvisories, mcp.Tool{ Name: "list_repository_security_advisories", @@ -246,6 +249,8 @@ func ListRepositorySecurityAdvisories(t translations.TranslationHelperFunc) inve Required: []string{"owner", "repo"}, }, }, + scopes.ToStringSlice(scopes.SecurityEvents), + scopes.ToStringSlice(scopes.SecurityEvents, 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 { @@ -310,7 +315,7 @@ func ListRepositorySecurityAdvisories(t translations.TranslationHelperFunc) inve } func GetGlobalSecurityAdvisory(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataSecurityAdvisories, mcp.Tool{ Name: "get_global_security_advisory", @@ -330,6 +335,8 @@ func GetGlobalSecurityAdvisory(t translations.TranslationHelperFunc) inventory.S Required: []string{"ghsaId"}, }, }, + scopes.ToStringSlice(scopes.SecurityEvents), + scopes.ToStringSlice(scopes.SecurityEvents, scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -366,7 +373,7 @@ func GetGlobalSecurityAdvisory(t translations.TranslationHelperFunc) inventory.S } func ListOrgRepositorySecurityAdvisories(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataSecurityAdvisories, mcp.Tool{ Name: "list_org_repository_security_advisories", @@ -401,6 +408,8 @@ func ListOrgRepositorySecurityAdvisories(t translations.TranslationHelperFunc) i Required: []string{"org"}, }, }, + scopes.ToStringSlice(scopes.SecurityEvents), + scopes.ToStringSlice(scopes.SecurityEvents, scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { org, err := RequiredParam[string](args, "org") if err != nil { From db9f4e8ddf808449b7cda93b8721f17d77b288d6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Dec 2025 15:16:11 +0000 Subject: [PATCH 19/70] Complete OAuth scope implementation for all tools - Updated all remaining tools with OAuth scope information - Added scope documentation generation to generate-docs command - Documentation now shows Required and Accepted OAuth scopes for each tool - All 100+ tools now have scope information defined - Tests pass, linter passes, documentation generated successfully Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com> --- README.md | 245 +++++++++++++++++++++++++ cmd/github-mcp-server/generate_docs.go | 21 ++- pkg/github/actions.go | 72 ++++++-- pkg/github/issues.go | 18 +- pkg/github/pullrequests.go | 42 +++-- pkg/github/repositories.go | 7 +- pkg/github/search.go | 16 +- pkg/scopes/scopes.go | 80 ++++---- 8 files changed, 415 insertions(+), 86 deletions(-) diff --git a/README.md b/README.md index af92cfd0b..0111ba6d6 100644 --- a/README.md +++ b/README.md @@ -492,22 +492,73 @@ The following sets of tools are available: workflow Actions +<<<<<<< HEAD +======= +- **actions_get** - Get details of GitHub Actions resources (workflows, workflow runs, jobs, and artifacts) + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` + - `method`: The method to execute (string, required) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `resource_id`: The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID: + - Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' method. + - Provide a workflow run ID for 'get_workflow_run', 'get_workflow_run_usage', and 'get_workflow_run_logs_url' methods. + - Provide an artifact ID for 'download_workflow_run_artifact' method. + - Provide a job ID for 'get_workflow_job' method. + (string, required) + +- **actions_list** - List GitHub Actions workflows in a repository + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` + - `method`: The action to perform (string, required) + - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (default: 1) (number, optional) + - `per_page`: Results per page for pagination (default: 30, max: 100) (number, optional) + - `repo`: Repository name (string, required) + - `resource_id`: The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID: + - Do not provide any resource ID for 'list_workflows' method. + - Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' method. + - Provide a workflow run ID for 'list_workflow_jobs' and 'list_workflow_run_artifacts' methods. + (string, optional) + - `workflow_jobs_filter`: Filters for workflow jobs. **ONLY** used when method is 'list_workflow_jobs' (object, optional) + - `workflow_runs_filter`: Filters for workflow runs. **ONLY** used when method is 'list_workflow_runs' (object, optional) + +- **actions_run_trigger** - Trigger GitHub Actions workflow actions + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` + - `inputs`: Inputs the workflow accepts. Only used for 'run_workflow' method. (object, optional) + - `method`: The method to execute (string, required) + - `owner`: Repository owner (string, required) + - `ref`: The git reference for the workflow. The reference can be a branch or tag name. Required for 'run_workflow' method. (string, optional) + - `repo`: Repository name (string, required) + - `run_id`: The ID of the workflow run. Required for all methods except 'run_workflow'. (number, optional) + - `workflow_id`: The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml). Required for 'run_workflow' method. (string, optional) + +>>>>>>> 4ed0310 (Complete OAuth scope implementation for all tools) - **cancel_workflow_run** - Cancel workflow run + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `run_id`: The unique identifier of the workflow run (number, required) - **delete_workflow_run_logs** - Delete workflow logs + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `run_id`: The unique identifier of the workflow run (number, required) - **download_workflow_run_artifact** - Download workflow artifact + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `artifact_id`: The unique identifier of the artifact (number, required) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - **get_job_logs** - Get job logs + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `failed_only`: When true, gets logs for all failed jobs in run_id (boolean, optional) - `job_id`: The unique identifier of the workflow job (required for single job logs) (number, optional) - `owner`: Repository owner (string, required) @@ -516,22 +567,44 @@ The following sets of tools are available: - `run_id`: Workflow run ID (required when using failed_only) (number, optional) - `tail_lines`: Number of lines to return from the end of the log (number, optional) +<<<<<<< HEAD +======= +- **get_job_logs** - Get GitHub Actions workflow job logs + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` + - `failed_only`: When true, gets logs for all failed jobs in the workflow run specified by run_id. Requires run_id to be provided. (boolean, optional) + - `job_id`: The unique identifier of the workflow job. Required when getting logs for a single job. (number, optional) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `return_content`: Returns actual log content instead of URLs (boolean, optional) + - `run_id`: The unique identifier of the workflow run. Required when failed_only is true to get logs for all failed jobs in the run. (number, optional) + - `tail_lines`: Number of lines to return from the end of the log (number, optional) + +>>>>>>> 4ed0310 (Complete OAuth scope implementation for all tools) - **get_workflow_run** - Get workflow run + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `run_id`: The unique identifier of the workflow run (number, required) - **get_workflow_run_logs** - Get workflow run logs + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `run_id`: The unique identifier of the workflow run (number, required) - **get_workflow_run_usage** - Get workflow usage + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `run_id`: The unique identifier of the workflow run (number, required) - **list_workflow_jobs** - List workflow jobs + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `filter`: Filters jobs by their completed_at timestamp (string, optional) - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) @@ -540,6 +613,8 @@ The following sets of tools are available: - `run_id`: The unique identifier of the workflow run (number, required) - **list_workflow_run_artifacts** - List workflow artifacts + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) @@ -547,6 +622,8 @@ The following sets of tools are available: - `run_id`: The unique identifier of the workflow run (number, required) - **list_workflow_runs** - List workflow runs + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `actor`: Returns someone's workflow runs. Use the login for the user who created the workflow run. (string, optional) - `branch`: Returns workflow runs associated with a branch. Use the name of the branch. (string, optional) - `event`: Returns workflow runs for a specific event type (string, optional) @@ -558,22 +635,30 @@ The following sets of tools are available: - `workflow_id`: The workflow ID or workflow file name (string, required) - **list_workflows** - List workflows + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - **rerun_failed_jobs** - Rerun failed jobs + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `run_id`: The unique identifier of the workflow run (number, required) - **rerun_workflow_run** - Rerun workflow run + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `run_id`: The unique identifier of the workflow run (number, required) - **run_workflow** - Run workflow + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `inputs`: Inputs the workflow accepts (object, optional) - `owner`: Repository owner (string, required) - `ref`: The git reference for the workflow. The reference can be a branch or tag name. (string, required) @@ -587,11 +672,15 @@ The following sets of tools are available: codescan Code Security - **get_code_scanning_alert** - Get code scanning alert + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `security_events`, `repo` - `alertNumber`: The number of the alert. (number, required) - `owner`: The owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) - **list_code_scanning_alerts** - List code scanning alerts + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `security_events`, `repo` - `owner`: The owner of the repository. (string, required) - `ref`: The Git reference for the results you want to list. (string, optional) - `repo`: The name of the repository. (string, required) @@ -609,10 +698,14 @@ The following sets of tools are available: - No parameters required - **get_team_members** - Get team members + - **Required OAuth Scopes**: `read:org` + - **Accepted OAuth Scopes**: `read:org`, `write:org`, `admin:org` - `org`: Organization login (owner) that contains the team. (string, required) - `team_slug`: Team slug (string, required) - **get_teams** - Get teams + - **Required OAuth Scopes**: `read:org` + - **Accepted OAuth Scopes**: `read:org`, `write:org`, `admin:org` - `user`: Username to get teams for. If not provided, uses the authenticated user. (string, optional) @@ -622,11 +715,15 @@ The following sets of tools are available: dependabot Dependabot - **get_dependabot_alert** - Get dependabot alert + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `security_events`, `repo` - `alertNumber`: The number of the alert. (number, required) - `owner`: The owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) - **list_dependabot_alerts** - List dependabot alerts + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `security_events`, `repo` - `owner`: The owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) - `severity`: Filter dependabot alerts by severity (string, optional) @@ -639,11 +736,15 @@ The following sets of tools are available: comment-discussion Discussions - **get_discussion** - Get discussion + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `discussionNumber`: Discussion Number (number, required) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - **get_discussion_comments** - Get discussion comments + - **Required OAuth Scopes**: `repo` + - **Accepted 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) - `owner`: Repository owner (string, required) @@ -651,10 +752,14 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - **list_discussion_categories** - List discussion categories + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name. If not provided, discussion categories will be queried at the organisation level. (string, optional) - **list_discussions** - List discussions + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) - `category`: Optional filter by discussion category ID. If provided, only discussions with this category are listed. (string, optional) - `direction`: Order direction. (string, optional) @@ -670,6 +775,8 @@ The following sets of tools are available: logo-gist Gists - **create_gist** - Create Gist + - **Required OAuth Scopes**: `gist` + - **Accepted OAuth Scopes**: `gist` - `content`: Content for simple single-file gist creation (string, required) - `description`: Description of the gist (string, optional) - `filename`: Filename for simple single-file gist creation (string, required) @@ -685,6 +792,8 @@ The following sets of tools are available: - `username`: GitHub username (omit for authenticated user's gists) (string, optional) - **update_gist** - Update Gist + - **Required OAuth Scopes**: `gist` + - **Accepted OAuth Scopes**: `gist` - `content`: Content for the file (string, required) - `description`: Updated description of the gist (string, optional) - `filename`: Filename to update or create (string, required) @@ -697,6 +806,8 @@ The following sets of tools are available: git-branch Git - **get_repository_tree** - Get repository tree + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (username or organization) (string, required) - `path_filter`: Optional path prefix to filter the tree results (e.g., 'src/' to only show files in the src directory) (string, optional) - `recursive`: Setting this parameter to true returns the objects or subtrees referenced by the tree. Default is false (boolean, optional) @@ -710,22 +821,34 @@ The following sets of tools are available: issue-opened Issues - **add_issue_comment** - Add comment to issue + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `body`: Comment content (string, required) - `issue_number`: Issue number to comment on (number, required) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - **assign_copilot_to_issue** - Assign Copilot to issue +<<<<<<< HEAD - `issue_number`: Issue number (number, required) +======= + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` + - `issueNumber`: Issue number (number, required) +>>>>>>> 4ed0310 (Complete OAuth scope implementation for all tools) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - **get_label** - Get a specific label from a repository. + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `name`: Label name. (string, required) - `owner`: Repository owner (username or organization name) (string, required) - `repo`: Repository name (string, required) - **issue_read** - Get issue details + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `issue_number`: The number of the issue (number, required) - `method`: The read operation to perform on a single issue. Options are: @@ -740,6 +863,8 @@ The following sets of tools are available: - `repo`: The name of the repository (string, required) - **issue_write** - Create or update issue. + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `assignees`: Usernames to assign to this issue (string[], optional) - `body`: Issue body content (string, optional) - `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional) @@ -759,9 +884,13 @@ The following sets of tools are available: - `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional) - **list_issue_types** - List available issue types + - **Required OAuth Scopes**: `read:org` + - **Accepted OAuth Scopes**: `read:org`, `write:org`, `admin:org` - `owner`: The organization owner of the repository (string, required) - **list_issues** - List issues + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) - `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional) - `labels`: Filter by labels (string[], optional) @@ -773,6 +902,8 @@ The following sets of tools are available: - `state`: Filter by state, by default both open and closed issues are returned when not provided (string, optional) - **search_issues** - Search issues + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `order`: Sort order (string, optional) - `owner`: Optional repository owner. If provided with repo, only issues for this repository are listed. (string, optional) - `page`: Page number for pagination (min 1) (number, optional) @@ -782,6 +913,8 @@ The following sets of tools are available: - `sort`: Sort field by number of matches of categories, defaults to best match (string, optional) - **sub_issue_write** - Change sub-issue + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `after_id`: The ID of the sub-issue to be prioritized after (either after_id OR before_id should be specified) (number, optional) - `before_id`: The ID of the sub-issue to be prioritized before (either after_id OR before_id should be specified) (number, optional) - `issue_number`: The number of the parent issue (number, required) @@ -803,11 +936,15 @@ The following sets of tools are available: tag Labels - **get_label** - Get a specific label from a repository. + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `name`: Label name. (string, required) - `owner`: Repository owner (username or organization name) (string, required) - `repo`: Repository name (string, required) - **label_write** - Write operations on repository labels. + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `color`: Label color as 6-character hex code without '#' prefix (e.g., 'f29513'). Required for 'create', optional for 'update'. (string, optional) - `description`: Label description text. Optional for 'create' and 'update'. (string, optional) - `method`: Operation to perform: 'create', 'update', or 'delete' (string, required) @@ -817,6 +954,8 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - **list_label** - List labels from a repository + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (username or organization name) - required for all operations (string, required) - `repo`: Repository name - required for all operations (string, required) @@ -827,13 +966,19 @@ The following sets of tools are available: bell Notifications - **dismiss_notification** - Dismiss notification + - **Required OAuth Scopes**: `notifications` + - **Accepted OAuth Scopes**: `notifications` - `state`: The new state of the notification (read/done) (string, required) - `threadID`: The ID of the notification thread (string, required) - **get_notification_details** - Get notification details + - **Required OAuth Scopes**: `notifications` + - **Accepted OAuth Scopes**: `notifications` - `notificationID`: The ID of the notification (string, required) - **list_notifications** - List notifications + - **Required OAuth Scopes**: `notifications` + - **Accepted OAuth Scopes**: `notifications` - `before`: Only show notifications updated before the given time (ISO 8601 format) (string, optional) - `filter`: Filter notifications to, use default unless specified. Read notifications are ones that have already been acknowledged by the user. Participating notifications are those that the user is directly involved in, such as issues or pull requests they have commented on or created. (string, optional) - `owner`: Optional repository owner. If provided with repo, only notifications for this repository are listed. (string, optional) @@ -843,15 +988,21 @@ The following sets of tools are available: - `since`: Only show notifications updated after the given time (ISO 8601 format) (string, optional) - **manage_notification_subscription** - Manage notification subscription + - **Required OAuth Scopes**: `notifications` + - **Accepted OAuth Scopes**: `notifications` - `action`: Action to perform: ignore, watch, or delete the notification subscription. (string, required) - `notificationID`: The ID of the notification thread. (string, required) - **manage_repository_notification_subscription** - Manage repository notification subscription + - **Required OAuth Scopes**: `notifications` + - **Accepted OAuth Scopes**: `notifications` - `action`: Action to perform: ignore, watch, or delete the repository notification subscription. (string, required) - `owner`: The account owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) - **mark_all_notifications_read** - Mark all notifications as read + - **Required OAuth Scopes**: `notifications` + - **Accepted OAuth Scopes**: `notifications` - `lastReadAt`: Describes the last point that notifications were checked (optional). Default: Now (string, optional) - `owner`: Optional repository owner. If provided with repo, only notifications for this repository are marked as read. (string, optional) - `repo`: Optional repository name. If provided with owner, only notifications for this repository are marked as read. (string, optional) @@ -863,6 +1014,8 @@ The following sets of tools are available: organization Organizations - **search_orgs** - Search organizations + - **Required OAuth Scopes**: `read:org` + - **Accepted OAuth Scopes**: `read:org`, `write:org`, `admin:org` - `order`: Sort order (string, optional) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) @@ -876,6 +1029,8 @@ The following sets of tools are available: project Projects - **add_project_item** - Add project item + - **Required OAuth Scopes**: `project` + - **Accepted OAuth Scopes**: `project` - `item_id`: The numeric ID of the issue or pull request to add to the project. (number, required) - `item_type`: The item's type, either issue or pull_request. (string, required) - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) @@ -883,23 +1038,31 @@ The following sets of tools are available: - `project_number`: The project's number. (number, required) - **delete_project_item** - Delete project item + - **Required OAuth Scopes**: `project` + - **Accepted OAuth Scopes**: `project` - `item_id`: The internal project item ID to delete from the project (not the issue or pull request ID). (number, required) - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - `owner_type`: Owner type (string, required) - `project_number`: The project's number. (number, required) - **get_project** - Get project + - **Required OAuth Scopes**: `read:project` + - **Accepted OAuth Scopes**: `read:project`, `project` - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - `owner_type`: Owner type (string, required) - `project_number`: The project's number (number, required) - **get_project_field** - Get project field + - **Required OAuth Scopes**: `read:project` + - **Accepted OAuth Scopes**: `read:project`, `project` - `field_id`: The field's id. (number, required) - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - `owner_type`: Owner type (string, required) - `project_number`: The project's number. (number, required) - **get_project_item** - Get project item + - **Required OAuth Scopes**: `read:project` + - **Accepted OAuth Scopes**: `read:project`, `project` - `fields`: Specific list of field IDs to include in the response (e.g. ["102589", "985201", "169875"]). If not provided, only the title field is included. (string[], optional) - `item_id`: The item's ID. (number, required) - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) @@ -907,6 +1070,8 @@ The following sets of tools are available: - `project_number`: The project's number. (number, required) - **list_project_fields** - List project fields + - **Required OAuth Scopes**: `read:project` + - **Accepted OAuth Scopes**: `read:project`, `project` - `after`: Forward pagination cursor from previous pageInfo.nextCursor. (string, optional) - `before`: Backward pagination cursor from previous pageInfo.prevCursor (rare). (string, optional) - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) @@ -915,6 +1080,8 @@ The following sets of tools are available: - `project_number`: The project's number. (number, required) - **list_project_items** - List project items + - **Required OAuth Scopes**: `read:project` + - **Accepted OAuth Scopes**: `read:project`, `project` - `after`: Forward pagination cursor from previous pageInfo.nextCursor. (string, optional) - `before`: Backward pagination cursor from previous pageInfo.prevCursor (rare). (string, optional) - `fields`: Field IDs to include (e.g. ["102589", "985201"]). CRITICAL: Always provide to get field values. Without this, only titles returned. (string[], optional) @@ -925,6 +1092,8 @@ The following sets of tools are available: - `query`: Query string for advanced filtering of project items using GitHub's project filtering syntax. (string, optional) - **list_projects** - List projects + - **Required OAuth Scopes**: `read:project` + - **Accepted OAuth Scopes**: `read:project`, `project` - `after`: Forward pagination cursor from previous pageInfo.nextCursor. (string, optional) - `before`: Backward pagination cursor from previous pageInfo.prevCursor (rare). (string, optional) - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) @@ -933,6 +1102,8 @@ The following sets of tools are available: - `query`: Filter projects by title text and open/closed state; permitted qualifiers: is:open, is:closed; examples: "roadmap is:open", "is:open feature planning". (string, optional) - **update_project_item** - Update project item + - **Required OAuth Scopes**: `project` + - **Accepted OAuth Scopes**: `project` - `item_id`: The unique identifier of the project item. This is not the issue or pull request ID. (number, required) - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - `owner_type`: Owner type (string, required) @@ -946,6 +1117,8 @@ The following sets of tools are available: git-pull-request Pull Requests - **add_comment_to_pending_review** - Add review comment to the requester's latest pending pull request review + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `body`: The text of the review comment (string, required) - `line`: The line of the blob in the pull request diff that the comment applies to. For multi-line comments, the last line of the range (number, optional) - `owner`: Repository owner (string, required) @@ -958,6 +1131,8 @@ The following sets of tools are available: - `subjectType`: The level at which the comment is targeted (string, required) - **create_pull_request** - Open new pull request + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `base`: Branch to merge into (string, required) - `body`: PR description (string, optional) - `draft`: Create as draft PR (boolean, optional) @@ -968,6 +1143,8 @@ The following sets of tools are available: - `title`: PR title (string, required) - **list_pull_requests** - List pull requests + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `base`: Filter by base branch (string, optional) - `direction`: Sort direction (string, optional) - `head`: Filter by head user/org and branch (string, optional) @@ -979,6 +1156,8 @@ The following sets of tools are available: - `state`: Filter by state (string, optional) - **merge_pull_request** - Merge pull request + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `commit_message`: Extra detail for merge commit (string, optional) - `commit_title`: Title for merge commit (string, optional) - `merge_method`: Merge method (string, optional) @@ -987,6 +1166,8 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - **pull_request_read** - Get details for a single pull request + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `method`: Action to specify what pull request data needs to be retrieved from GitHub. Possible options: 1. get - Get details of a specific pull request. @@ -1004,6 +1185,8 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - **pull_request_review_write** - Write operations (create, submit, delete) on pull request reviews. + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `body`: Review comment text (string, optional) - `commitID`: SHA of commit to review (string, optional) - `event`: Review action to perform. (string, optional) @@ -1013,11 +1196,15 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - **request_copilot_review** - Request Copilot review + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `pullNumber`: Pull request number (number, required) - `repo`: Repository name (string, required) - **search_pull_requests** - Search pull requests + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `order`: Sort order (string, optional) - `owner`: Optional repository owner. If provided with repo, only pull requests for this repository are listed. (string, optional) - `page`: Page number for pagination (min 1) (number, optional) @@ -1027,6 +1214,8 @@ The following sets of tools are available: - `sort`: Sort field by number of matches of categories, defaults to best match (string, optional) - **update_pull_request** - Edit pull request + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `base`: New base branch name (string, optional) - `body`: New description (string, optional) - `draft`: Mark pull request as draft (true) or ready for review (false) (boolean, optional) @@ -1039,6 +1228,8 @@ The following sets of tools are available: - `title`: New title (string, optional) - **update_pull_request_branch** - Update pull request branch + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `expectedHeadSha`: The expected SHA of the pull request's HEAD ref (string, optional) - `owner`: Repository owner (string, required) - `pullNumber`: Pull request number (number, required) @@ -1051,12 +1242,16 @@ The following sets of tools are available: repo Repositories - **create_branch** - Create branch + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `branch`: Name for new branch (string, required) - `from_branch`: Source branch (defaults to repo default) (string, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - **create_or_update_file** - Create or update file + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `branch`: Branch to create/update the file in (string, required) - `content`: Content of the file (string, required) - `message`: Commit message (string, required) @@ -1066,6 +1261,8 @@ The following sets of tools are available: - `sha`: The blob SHA of the file being replaced. (string, optional) - **create_repository** - Create repository + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `autoInit`: Initialize with README (boolean, optional) - `description`: Repository description (string, optional) - `name`: Repository name (string, required) @@ -1073,6 +1270,8 @@ The following sets of tools are available: - `private`: Whether repo should be private (boolean, optional) - **delete_file** - Delete file + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `branch`: Branch to delete the file from (string, required) - `message`: Commit message (string, required) - `owner`: Repository owner (username or organization) (string, required) @@ -1080,11 +1279,15 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - **fork_repository** - Fork repository + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `organization`: Organization to fork to (string, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - **get_commit** - Get commit details + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `include_diff`: Whether to include file diffs and stats in the response. Default is true. (boolean, optional) - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) @@ -1093,6 +1296,8 @@ The following sets of tools are available: - `sha`: Commit SHA, branch name, or tag name (string, required) - **get_file_contents** - Get file or directory contents + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (username or organization) (string, required) - `path`: Path to file/directory (string, optional) - `ref`: Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head` (string, optional) @@ -1100,26 +1305,36 @@ The following sets of tools are available: - `sha`: Accepts optional commit SHA. If specified, it will be used instead of ref (string, optional) - **get_latest_release** - Get latest release + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - **get_release_by_tag** - Get a release by tag name + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `tag`: Tag name (e.g., 'v1.0.0') (string, required) - **get_tag** - Get tag details + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `tag`: Tag name (string, required) - **list_branches** - List branches + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - **list_commits** - List commits + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `author`: Author username or email address to filter commits by (string, optional) - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) @@ -1128,18 +1343,24 @@ The following sets of tools are available: - `sha`: Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA. (string, optional) - **list_releases** - List releases + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - **list_tags** - List tags + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: Repository name (string, required) - **push_files** - Push files to repository + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `branch`: Branch to push to (string, required) - `files`: Array of file objects to push, each object with path (string) and content (string) (object[], required) - `message`: Commit message (string, required) @@ -1147,6 +1368,8 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - **search_code** - Search code + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `order`: Sort order for results (string, optional) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) @@ -1154,6 +1377,8 @@ The following sets of tools are available: - `sort`: Sort field ('indexed' only) (string, optional) - **search_repositories** - Search repositories + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `minimal_output`: Return minimal repository information (default: true). When false, returns full GitHub API repository objects. (boolean, optional) - `order`: Sort order (string, optional) - `page`: Page number for pagination (min 1) (number, optional) @@ -1168,11 +1393,15 @@ The following sets of tools are available: shield-lock Secret Protection - **get_secret_scanning_alert** - Get secret scanning alert + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `security_events`, `repo` - `alertNumber`: The number of the alert. (number, required) - `owner`: The owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) - **list_secret_scanning_alerts** - List secret scanning alerts + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `security_events`, `repo` - `owner`: The owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) - `resolution`: Filter by resolution (string, optional) @@ -1186,9 +1415,13 @@ The following sets of tools are available: shield Security Advisories - **get_global_security_advisory** - Get a global security advisory + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `security_events`, `repo` - `ghsaId`: GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx). (string, required) - **list_global_security_advisories** - List global security advisories + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `security_events`, `repo` - `affects`: Filter advisories by affected package or version (e.g. "package1,package2@1.0.0"). (string, optional) - `cveId`: Filter by CVE ID. (string, optional) - `cwes`: Filter by Common Weakness Enumeration IDs (e.g. ["79", "284", "22"]). (string[], optional) @@ -1202,12 +1435,16 @@ The following sets of tools are available: - `updated`: Filter by update date or date range (ISO 8601 date or range). (string, optional) - **list_org_repository_security_advisories** - List org repository security advisories + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `security_events`, `repo` - `direction`: Sort direction. (string, optional) - `org`: The organization login. (string, required) - `sort`: Sort field. (string, optional) - `state`: Filter by advisory state. (string, optional) - **list_repository_security_advisories** - List repository security advisories + - **Required OAuth Scopes**: `security_events` + - **Accepted OAuth Scopes**: `security_events`, `repo` - `direction`: Sort direction. (string, optional) - `owner`: The owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) @@ -1221,6 +1458,8 @@ The following sets of tools are available: star Stargazers - **list_starred_repositories** - List starred repositories + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `direction`: The direction to sort the results by. (string, optional) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) @@ -1228,10 +1467,14 @@ The following sets of tools are available: - `username`: Username to list starred repositories for. Defaults to the authenticated user. (string, optional) - **star_repository** - Star repository + - **Required OAuth Scopes**: `public_repo` + - **Accepted OAuth Scopes**: `public_repo`, `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - **unstar_repository** - Unstar repository + - **Required OAuth Scopes**: `public_repo` + - **Accepted OAuth Scopes**: `public_repo`, `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) @@ -1242,6 +1485,8 @@ The following sets of tools are available: people Users - **search_users** - Search users + - **Required OAuth Scopes**: `repo` + - **Accepted OAuth Scopes**: `repo` - `order`: Sort order (string, optional) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index 65c01c8fa..f28aa1bc2 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -12,7 +12,6 @@ import ( "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/jsonschema-go/jsonschema" - "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/spf13/cobra" ) @@ -189,7 +188,7 @@ func generateToolsDoc(r *inventory.Inventory) string { currentToolsetID = tool.Toolset.ID currentToolsetIcon = tool.Toolset.Icon } - writeToolDoc(&toolBuf, tool.Tool) + writeToolDoc(&toolBuf, tool) toolBuf.WriteString("\n\n") } @@ -223,16 +222,26 @@ func formatToolsetName(name string) string { } } -func writeToolDoc(buf *strings.Builder, tool mcp.Tool) { +func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) { // Tool name (no icon - section header already has the toolset icon) - fmt.Fprintf(buf, "- **%s** - %s\n", tool.Name, tool.Annotations.Title) + fmt.Fprintf(buf, "- **%s** - %s\n", tool.Tool.Name, tool.Tool.Annotations.Title) + + // OAuth scopes if present + if len(tool.RequiredScopes) > 0 || len(tool.AcceptedScopes) > 0 { + if len(tool.RequiredScopes) > 0 { + fmt.Fprintf(buf, " - **Required OAuth Scopes**: `%s`\n", strings.Join(tool.RequiredScopes, "`, `")) + } + if len(tool.AcceptedScopes) > 0 { + fmt.Fprintf(buf, " - **Accepted OAuth Scopes**: `%s`\n", strings.Join(tool.AcceptedScopes, "`, `")) + } + } // Parameters - if tool.InputSchema == nil { + if tool.Tool.InputSchema == nil { buf.WriteString(" - No parameters required") return } - schema, ok := tool.InputSchema.(*jsonschema.Schema) + schema, ok := tool.Tool.InputSchema.(*jsonschema.Schema) if !ok || schema == nil { buf.WriteString(" - No parameters required") return diff --git a/pkg/github/actions.go b/pkg/github/actions.go index cffab4b50..c4ba97983 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -51,7 +51,7 @@ const ( // ListWorkflows creates a tool to list workflows in a repository func ListWorkflows(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewToolWithScopes( ToolsetMetadataActions, mcp.Tool{ Name: "list_workflows", @@ -75,6 +75,8 @@ func ListWorkflows(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }), }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -122,7 +124,7 @@ func ListWorkflows(t translations.TranslationHelperFunc) inventory.ServerTool { // ListWorkflowRuns creates a tool to list workflow runs for a specific workflow func ListWorkflowRuns(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewToolWithScopes( ToolsetMetadataActions, mcp.Tool{ Name: "list_workflow_runs", @@ -201,6 +203,8 @@ func ListWorkflowRuns(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo", "workflow_id"}, }), }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -276,7 +280,7 @@ func ListWorkflowRuns(t translations.TranslationHelperFunc) inventory.ServerTool // RunWorkflow creates a tool to run an Actions workflow func RunWorkflow(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewToolWithScopes( ToolsetMetadataActions, mcp.Tool{ Name: "run_workflow", @@ -312,6 +316,8 @@ func RunWorkflow(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "workflow_id", "ref"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -388,7 +394,7 @@ func RunWorkflow(t translations.TranslationHelperFunc) inventory.ServerTool { // GetWorkflowRun creates a tool to get details of a specific workflow run func GetWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewToolWithScopes( ToolsetMetadataActions, mcp.Tool{ Name: "get_workflow_run", @@ -416,6 +422,8 @@ func GetWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "run_id"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -456,7 +464,7 @@ func GetWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool { // GetWorkflowRunLogs creates a tool to download logs for a specific workflow run func GetWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewToolWithScopes( ToolsetMetadataActions, mcp.Tool{ Name: "get_workflow_run_logs", @@ -484,6 +492,8 @@ func GetWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.ServerTo Required: []string{"owner", "repo", "run_id"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -534,7 +544,7 @@ func GetWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.ServerTo // ListWorkflowJobs creates a tool to list jobs for a specific workflow run func ListWorkflowJobs(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewToolWithScopes( ToolsetMetadataActions, mcp.Tool{ Name: "list_workflow_jobs", @@ -567,6 +577,8 @@ func ListWorkflowJobs(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo", "run_id"}, }), }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -634,7 +646,7 @@ func ListWorkflowJobs(t translations.TranslationHelperFunc) inventory.ServerTool // GetJobLogs creates a tool to download logs for a specific workflow job or efficiently get all failed job logs for a workflow run func GetJobLogs(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewToolWithScopes( ToolsetMetadataActions, mcp.Tool{ Name: "get_job_logs", @@ -679,6 +691,8 @@ func GetJobLogs(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -899,7 +913,7 @@ func downloadLogContent(ctx context.Context, logURL string, tailLines int, maxLi // RerunWorkflowRun creates a tool to re-run an entire workflow run func RerunWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewToolWithScopes( ToolsetMetadataActions, mcp.Tool{ Name: "rerun_workflow_run", @@ -927,6 +941,8 @@ func RerunWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo", "run_id"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -974,7 +990,7 @@ func RerunWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool // RerunFailedJobs creates a tool to re-run only the failed jobs in a workflow run func RerunFailedJobs(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewToolWithScopes( ToolsetMetadataActions, mcp.Tool{ Name: "rerun_failed_jobs", @@ -1002,6 +1018,8 @@ func RerunFailedJobs(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo", "run_id"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -1049,7 +1067,7 @@ func RerunFailedJobs(t translations.TranslationHelperFunc) inventory.ServerTool // CancelWorkflowRun creates a tool to cancel a workflow run func CancelWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewToolWithScopes( ToolsetMetadataActions, mcp.Tool{ Name: "cancel_workflow_run", @@ -1077,6 +1095,8 @@ func CancelWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner", "repo", "run_id"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -1126,7 +1146,7 @@ func CancelWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerToo // ListWorkflowRunArtifacts creates a tool to list artifacts for a workflow run func ListWorkflowRunArtifacts(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewToolWithScopes( ToolsetMetadataActions, mcp.Tool{ Name: "list_workflow_run_artifacts", @@ -1154,6 +1174,8 @@ func ListWorkflowRunArtifacts(t translations.TranslationHelperFunc) inventory.Se Required: []string{"owner", "repo", "run_id"}, }), }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -1206,7 +1228,7 @@ func ListWorkflowRunArtifacts(t translations.TranslationHelperFunc) inventory.Se // DownloadWorkflowRunArtifact creates a tool to download a workflow run artifact func DownloadWorkflowRunArtifact(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewToolWithScopes( ToolsetMetadataActions, mcp.Tool{ Name: "download_workflow_run_artifact", @@ -1234,6 +1256,8 @@ func DownloadWorkflowRunArtifact(t translations.TranslationHelperFunc) inventory Required: []string{"owner", "repo", "artifact_id"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -1283,7 +1307,7 @@ func DownloadWorkflowRunArtifact(t translations.TranslationHelperFunc) inventory // DeleteWorkflowRunLogs creates a tool to delete logs for a workflow run func DeleteWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewToolWithScopes( ToolsetMetadataActions, mcp.Tool{ Name: "delete_workflow_run_logs", @@ -1312,6 +1336,8 @@ func DeleteWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.Serve Required: []string{"owner", "repo", "run_id"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -1359,7 +1385,7 @@ func DeleteWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.Serve // GetWorkflowRunUsage creates a tool to get usage metrics for a workflow run func GetWorkflowRunUsage(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewToolWithScopes( ToolsetMetadataActions, mcp.Tool{ Name: "get_workflow_run_usage", @@ -1387,6 +1413,8 @@ func GetWorkflowRunUsage(t translations.TranslationHelperFunc) inventory.ServerT Required: []string{"owner", "repo", "run_id"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -1427,7 +1455,7 @@ func GetWorkflowRunUsage(t translations.TranslationHelperFunc) inventory.ServerT // ActionsList returns the tool and handler for listing GitHub Actions resources. func ActionsList(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewToolWithScopes( ToolsetMetadataActions, mcp.Tool{ Name: "actions_list", @@ -1551,6 +1579,8 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an Required: []string{"method", "owner", "repo"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { @@ -1622,7 +1652,7 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an // ActionsGet returns the tool and handler for getting GitHub Actions resources. func ActionsGet(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewToolWithScopes( ToolsetMetadataActions, mcp.Tool{ Name: "actions_get", @@ -1669,6 +1699,8 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an Required: []string{"method", "owner", "repo", "resource_id"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { @@ -1730,7 +1762,7 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an // ActionsRunTrigger returns the tool and handler for triggering GitHub Actions workflows. func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewToolWithScopes( ToolsetMetadataActions, mcp.Tool{ Name: "actions_run_trigger", @@ -1782,6 +1814,8 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"method", "owner", "repo"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { @@ -1848,7 +1882,7 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo // ActionsGetJobLogs returns the tool and handler for getting workflow job logs. func ActionsGetJobLogs(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewToolWithScopes( ToolsetMetadataActions, mcp.Tool{ Name: "get_job_logs", @@ -1896,6 +1930,8 @@ For single job logs, provide job_id. For all failed jobs in a run, provide run_i Required: []string{"owner", "repo"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 406e980fe..9ecf4d264 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -11,10 +11,10 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/lockdown" "github.com/github/github-mcp-server/pkg/octicons" "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/go-viper/mapstructure/v2" @@ -264,7 +264,7 @@ Options are: } WithPagination(schema) - return NewTool( + return NewToolWithScopes( ToolsetMetadataIssues, mcp.Tool{ Name: "issue_read", @@ -275,6 +275,8 @@ Options are: }, InputSchema: schema, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { @@ -959,7 +961,7 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool { } WithPagination(schema) - return NewTool( + return NewToolWithScopes( ToolsetMetadataIssues, mcp.Tool{ Name: "search_issues", @@ -970,6 +972,8 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool { }, InputSchema: schema, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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") return result, nil, err @@ -1383,7 +1387,7 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { } WithCursorPagination(schema) - return NewTool( + return NewToolWithScopes( ToolsetMetadataIssues, mcp.Tool{ Name: "list_issues", @@ -1394,6 +1398,8 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { }, InputSchema: schema, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { @@ -1621,7 +1627,7 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server }, } - return NewTool( + return NewToolWithScopes( ToolsetMetadataIssues, mcp.Tool{ Name: "assign_copilot_to_issue", @@ -1651,6 +1657,8 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server Required: []string{"owner", "repo", "issue_number"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { var params struct { Owner string `mapstructure:"owner"` diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 400918858..e81f87ca0 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -15,10 +15,10 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/lockdown" "github.com/github/github-mcp-server/pkg/octicons" "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" ) @@ -59,7 +59,7 @@ Possible options: } WithPagination(schema) - return NewTool( + return NewToolWithScopes( ToolsetMetadataPullRequests, mcp.Tool{ Name: "pull_request_read", @@ -70,6 +70,8 @@ Possible options: }, InputSchema: schema, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { @@ -508,7 +510,7 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner", "repo", "title", "head", "base"}, } - return NewTool( + return NewToolWithScopes( ToolsetMetadataPullRequests, mcp.Tool{ Name: "create_pull_request", @@ -519,6 +521,8 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo }, InputSchema: schema, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { @@ -659,7 +663,7 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner", "repo", "pullNumber"}, } - return NewTool( + return NewToolWithScopes( ToolsetMetadataPullRequests, mcp.Tool{ Name: "update_pull_request", @@ -670,6 +674,8 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo }, InputSchema: schema, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { @@ -940,7 +946,7 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool } WithPagination(schema) - return NewTool( + return NewToolWithScopes( ToolsetMetadataPullRequests, mcp.Tool{ Name: "list_pull_requests", @@ -951,6 +957,8 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool }, InputSchema: schema, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { @@ -1075,7 +1083,7 @@ func MergePullRequest(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo", "pullNumber"}, } - return NewTool( + return NewToolWithScopes( ToolsetMetadataPullRequests, mcp.Tool{ Name: "merge_pull_request", @@ -1087,6 +1095,8 @@ func MergePullRequest(t translations.TranslationHelperFunc) inventory.ServerTool }, InputSchema: schema, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { @@ -1193,7 +1203,7 @@ func SearchPullRequests(t translations.TranslationHelperFunc) inventory.ServerTo } WithPagination(schema) - return NewTool( + return NewToolWithScopes( ToolsetMetadataPullRequests, mcp.Tool{ Name: "search_pull_requests", @@ -1204,6 +1214,8 @@ func SearchPullRequests(t translations.TranslationHelperFunc) inventory.ServerTo }, InputSchema: schema, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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, "pr", "failed to search pull requests") return result, nil, err @@ -1235,7 +1247,7 @@ func UpdatePullRequestBranch(t translations.TranslationHelperFunc) inventory.Ser Required: []string{"owner", "repo", "pullNumber"}, } - return NewTool( + return NewToolWithScopes( ToolsetMetadataPullRequests, mcp.Tool{ Name: "update_pull_request_branch", @@ -1246,6 +1258,8 @@ func UpdatePullRequestBranch(t translations.TranslationHelperFunc) inventory.Ser }, InputSchema: schema, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { @@ -1355,7 +1369,7 @@ func PullRequestReviewWrite(t translations.TranslationHelperFunc) inventory.Serv Required: []string{"method", "owner", "repo", "pullNumber"}, } - return NewTool( + return NewToolWithScopes( ToolsetMetadataPullRequests, mcp.Tool{ Name: "pull_request_review_write", @@ -1372,6 +1386,8 @@ Available methods: }, InputSchema: schema, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { var params PullRequestReviewWriteParams if err := mapstructure.Decode(args, ¶ms); err != nil { @@ -1684,7 +1700,7 @@ func AddCommentToPendingReview(t translations.TranslationHelperFunc) inventory.S Required: []string{"owner", "repo", "pullNumber", "path", "body", "subjectType"}, } - return NewTool( + return NewToolWithScopes( ToolsetMetadataPullRequests, mcp.Tool{ Name: "add_comment_to_pending_review", @@ -1695,6 +1711,8 @@ func AddCommentToPendingReview(t translations.TranslationHelperFunc) inventory.S }, InputSchema: schema, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { var params struct { Owner string @@ -1835,7 +1853,7 @@ func RequestCopilotReview(t translations.TranslationHelperFunc) inventory.Server Required: []string{"owner", "repo", "pullNumber"}, } - return NewTool( + return NewToolWithScopes( ToolsetMetadataPullRequests, mcp.Tool{ Name: "request_copilot_review", @@ -1847,6 +1865,8 @@ func RequestCopilotReview(t translations.TranslationHelperFunc) inventory.Server }, InputSchema: schema, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index f50babc32..b0c59e417 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -11,8 +11,9 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/octicons" + "github.com/github/github-mcp-server/pkg/raw" + "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/v79/github" @@ -317,7 +318,7 @@ func ListBranches(t translations.TranslationHelperFunc) inventory.ServerTool { // CreateOrUpdateFile creates a tool to create or update a file in a GitHub repository. func CreateOrUpdateFile(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithScopes( ToolsetMetadataRepos, mcp.Tool{ Name: "create_or_update_file", @@ -368,6 +369,8 @@ If the SHA is not provided, the tool will attempt to acquire it by fetching the Required: []string{"owner", "repo", "path", "content", "message", "branch"}, }, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(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 { diff --git a/pkg/github/search.go b/pkg/github/search.go index e207479d6..d95214e55 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -46,7 +46,7 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo } WithPagination(schema) - return NewTool( + return NewToolWithScopes( ToolsetMetadataRepos, mcp.Tool{ Name: "search_repositories", @@ -57,6 +57,8 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo }, InputSchema: schema, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { query, err := RequiredParam[string](args, "query") if err != nil { @@ -188,7 +190,7 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { } WithPagination(schema) - return NewTool( + return NewToolWithScopes( ToolsetMetadataRepos, mcp.Tool{ Name: "search_code", @@ -199,6 +201,8 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { }, InputSchema: schema, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { query, err := RequiredParam[string](args, "query") if err != nil { @@ -369,7 +373,7 @@ func SearchUsers(t translations.TranslationHelperFunc) inventory.ServerTool { } WithPagination(schema) - return NewTool( + return NewToolWithScopes( ToolsetMetadataUsers, mcp.Tool{ Name: "search_users", @@ -380,6 +384,8 @@ func SearchUsers(t translations.TranslationHelperFunc) inventory.ServerTool { }, InputSchema: schema, }, + scopes.ToStringSlice(scopes.Repo), + scopes.ToStringSlice(scopes.Repo), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { return userOrOrgHandler(ctx, "user", deps, args) }, @@ -410,7 +416,7 @@ func SearchOrgs(t translations.TranslationHelperFunc) inventory.ServerTool { } WithPagination(schema) - return NewTool( + return NewToolWithScopes( ToolsetMetadataOrgs, mcp.Tool{ Name: "search_orgs", @@ -421,6 +427,8 @@ func SearchOrgs(t translations.TranslationHelperFunc) inventory.ServerTool { }, InputSchema: schema, }, + scopes.ToStringSlice(scopes.ReadOrg), + scopes.ToStringSlice(scopes.ReadOrg, scopes.WriteOrg, scopes.AdminOrg), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { return userOrOrgHandler(ctx, "org", deps, args) }, diff --git a/pkg/scopes/scopes.go b/pkg/scopes/scopes.go index d6272a0a0..268a24d99 100644 --- a/pkg/scopes/scopes.go +++ b/pkg/scopes/scopes.go @@ -6,35 +6,35 @@ package scopes type Scope string const ( -// Repo grants full control of private repositories -Repo Scope = "repo" + // Repo grants full control of private repositories + Repo Scope = "repo" -// PublicRepo grants access to public repositories -PublicRepo Scope = "public_repo" + // PublicRepo grants access to public repositories + PublicRepo Scope = "public_repo" -// ReadOrg grants read-only access to organization membership, teams, and projects -ReadOrg Scope = "read:org" + // ReadOrg grants read-only access to organization membership, teams, and projects + ReadOrg Scope = "read:org" -// WriteOrg grants write access to organization membership and teams -WriteOrg Scope = "write:org" + // WriteOrg grants write access to organization membership and teams + WriteOrg Scope = "write:org" -// AdminOrg grants full control of organizations and teams -AdminOrg Scope = "admin:org" + // AdminOrg grants full control of organizations and teams + AdminOrg Scope = "admin:org" -// Gist grants write access to gists -Gist Scope = "gist" + // Gist grants write access to gists + Gist Scope = "gist" -// Notifications grants access to notifications -Notifications Scope = "notifications" + // Notifications grants access to notifications + Notifications Scope = "notifications" -// ReadProject grants read-only access to projects -ReadProject Scope = "read:project" + // ReadProject grants read-only access to projects + ReadProject Scope = "read:project" -// Project grants full control of projects -Project Scope = "project" + // Project grants full control of projects + Project Scope = "project" -// SecurityEvents grants read and write access to security events -SecurityEvents Scope = "security_events" + // SecurityEvents grants read and write access to security events + SecurityEvents Scope = "security_events" ) // ScopeSet represents a set of OAuth scopes. @@ -42,36 +42,36 @@ type ScopeSet map[Scope]bool // NewScopeSet creates a new ScopeSet from the given scopes. func NewScopeSet(scopes ...Scope) ScopeSet { -set := make(ScopeSet) -for _, scope := range scopes { -set[scope] = true -} -return set + set := make(ScopeSet) + for _, scope := range scopes { + set[scope] = true + } + return set } // ToSlice converts a ScopeSet to a slice of Scope values. func (s ScopeSet) ToSlice() []Scope { -scopes := make([]Scope, 0, len(s)) -for scope := range s { -scopes = append(scopes, scope) -} -return scopes + scopes := make([]Scope, 0, len(s)) + for scope := range s { + scopes = append(scopes, scope) + } + return scopes } // ToStringSlice converts a ScopeSet to a slice of string values. func (s ScopeSet) ToStringSlice() []string { -scopes := make([]string, 0, len(s)) -for scope := range s { -scopes = append(scopes, string(scope)) -} -return scopes + scopes := make([]string, 0, len(s)) + for scope := range s { + scopes = append(scopes, string(scope)) + } + return scopes } // ToStringSlice converts a slice of Scopes to a slice of strings. func ToStringSlice(scopes ...Scope) []string { -result := make([]string, len(scopes)) -for i, scope := range scopes { -result[i] = string(scope) -} -return result + result := make([]string, len(scopes)) + for i, scope := range scopes { + result[i] = string(scope) + } + return result } From db179be4c3fc59343b5aa76a57f3448c2c7b9fe8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 23 Dec 2025 15:27:33 +0000 Subject: [PATCH 20/70] Simplify scope implementation per feedback - Replace NewToolWithScopes with updated NewTool that includes scope parameters - All tools now use the same constructor with explicit scope requirements - Documentation only shows accepted scopes when they differ from required - Removed NewToolWithScopes and NewToolFromHandlerWithScopes helper functions Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com> --- README.md | 74 -------------------------- cmd/github-mcp-server/generate_docs.go | 32 +++++++++-- pkg/github/actions.go | 36 ++++++------- pkg/github/code_scanning.go | 4 +- pkg/github/context_tools.go | 6 +-- pkg/github/dependabot.go | 4 +- pkg/github/dependencies.go | 26 +++------ pkg/github/discussions.go | 8 +-- pkg/github/gists.go | 8 +-- pkg/github/git.go | 2 +- pkg/github/issues.go | 16 +++--- pkg/github/labels.go | 6 +-- pkg/github/notifications.go | 12 ++--- pkg/github/projects.go | 18 +++---- pkg/github/pullrequests.go | 20 +++---- pkg/github/repositories.go | 36 ++++++------- pkg/github/search.go | 8 +-- pkg/github/secret_scanning.go | 4 +- pkg/github/security_advisories.go | 8 +-- 19 files changed, 131 insertions(+), 197 deletions(-) diff --git a/README.md b/README.md index 0111ba6d6..a4f803845 100644 --- a/README.md +++ b/README.md @@ -496,7 +496,6 @@ The following sets of tools are available: ======= - **actions_get** - Get details of GitHub Actions resources (workflows, workflow runs, jobs, and artifacts) - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `method`: The method to execute (string, required) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) @@ -509,7 +508,6 @@ The following sets of tools are available: - **actions_list** - List GitHub Actions workflows in a repository - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `method`: The action to perform (string, required) - `owner`: Repository owner (string, required) - `page`: Page number for pagination (default: 1) (number, optional) @@ -525,7 +523,6 @@ The following sets of tools are available: - **actions_run_trigger** - Trigger GitHub Actions workflow actions - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `inputs`: Inputs the workflow accepts. Only used for 'run_workflow' method. (object, optional) - `method`: The method to execute (string, required) - `owner`: Repository owner (string, required) @@ -537,28 +534,24 @@ The following sets of tools are available: >>>>>>> 4ed0310 (Complete OAuth scope implementation for all tools) - **cancel_workflow_run** - Cancel workflow run - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `run_id`: The unique identifier of the workflow run (number, required) - **delete_workflow_run_logs** - Delete workflow logs - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `run_id`: The unique identifier of the workflow run (number, required) - **download_workflow_run_artifact** - Download workflow artifact - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `artifact_id`: The unique identifier of the artifact (number, required) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - **get_job_logs** - Get job logs - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `failed_only`: When true, gets logs for all failed jobs in run_id (boolean, optional) - `job_id`: The unique identifier of the workflow job (required for single job logs) (number, optional) - `owner`: Repository owner (string, required) @@ -571,7 +564,6 @@ The following sets of tools are available: ======= - **get_job_logs** - Get GitHub Actions workflow job logs - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `failed_only`: When true, gets logs for all failed jobs in the workflow run specified by run_id. Requires run_id to be provided. (boolean, optional) - `job_id`: The unique identifier of the workflow job. Required when getting logs for a single job. (number, optional) - `owner`: Repository owner (string, required) @@ -583,28 +575,24 @@ The following sets of tools are available: >>>>>>> 4ed0310 (Complete OAuth scope implementation for all tools) - **get_workflow_run** - Get workflow run - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `run_id`: The unique identifier of the workflow run (number, required) - **get_workflow_run_logs** - Get workflow run logs - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `run_id`: The unique identifier of the workflow run (number, required) - **get_workflow_run_usage** - Get workflow usage - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `run_id`: The unique identifier of the workflow run (number, required) - **list_workflow_jobs** - List workflow jobs - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `filter`: Filters jobs by their completed_at timestamp (string, optional) - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) @@ -614,7 +602,6 @@ The following sets of tools are available: - **list_workflow_run_artifacts** - List workflow artifacts - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) @@ -623,7 +610,6 @@ The following sets of tools are available: - **list_workflow_runs** - List workflow runs - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `actor`: Returns someone's workflow runs. Use the login for the user who created the workflow run. (string, optional) - `branch`: Returns workflow runs associated with a branch. Use the name of the branch. (string, optional) - `event`: Returns workflow runs for a specific event type (string, optional) @@ -636,7 +622,6 @@ The following sets of tools are available: - **list_workflows** - List workflows - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) @@ -644,21 +629,18 @@ The following sets of tools are available: - **rerun_failed_jobs** - Rerun failed jobs - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `run_id`: The unique identifier of the workflow run (number, required) - **rerun_workflow_run** - Rerun workflow run - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `run_id`: The unique identifier of the workflow run (number, required) - **run_workflow** - Run workflow - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `inputs`: Inputs the workflow accepts (object, optional) - `owner`: Repository owner (string, required) - `ref`: The git reference for the workflow. The reference can be a branch or tag name. (string, required) @@ -737,14 +719,12 @@ The following sets of tools are available: - **get_discussion** - Get discussion - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `discussionNumber`: Discussion Number (number, required) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - **get_discussion_comments** - Get discussion comments - **Required OAuth Scopes**: `repo` - - **Accepted 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) - `owner`: Repository owner (string, required) @@ -753,13 +733,11 @@ The following sets of tools are available: - **list_discussion_categories** - List discussion categories - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name. If not provided, discussion categories will be queried at the organisation level. (string, optional) - **list_discussions** - List discussions - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) - `category`: Optional filter by discussion category ID. If provided, only discussions with this category are listed. (string, optional) - `direction`: Order direction. (string, optional) @@ -776,7 +754,6 @@ The following sets of tools are available: - **create_gist** - Create Gist - **Required OAuth Scopes**: `gist` - - **Accepted OAuth Scopes**: `gist` - `content`: Content for simple single-file gist creation (string, required) - `description`: Description of the gist (string, optional) - `filename`: Filename for simple single-file gist creation (string, required) @@ -793,7 +770,6 @@ The following sets of tools are available: - **update_gist** - Update Gist - **Required OAuth Scopes**: `gist` - - **Accepted OAuth Scopes**: `gist` - `content`: Content for the file (string, required) - `description`: Updated description of the gist (string, optional) - `filename`: Filename to update or create (string, required) @@ -807,7 +783,6 @@ The following sets of tools are available: - **get_repository_tree** - Get repository tree - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (username or organization) (string, required) - `path_filter`: Optional path prefix to filter the tree results (e.g., 'src/' to only show files in the src directory) (string, optional) - `recursive`: Setting this parameter to true returns the objects or subtrees referenced by the tree. Default is false (boolean, optional) @@ -822,7 +797,6 @@ The following sets of tools are available: - **add_issue_comment** - Add comment to issue - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `body`: Comment content (string, required) - `issue_number`: Issue number to comment on (number, required) - `owner`: Repository owner (string, required) @@ -833,7 +807,6 @@ The following sets of tools are available: - `issue_number`: Issue number (number, required) ======= - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `issueNumber`: Issue number (number, required) >>>>>>> 4ed0310 (Complete OAuth scope implementation for all tools) - `owner`: Repository owner (string, required) @@ -841,14 +814,12 @@ The following sets of tools are available: - **get_label** - Get a specific label from a repository. - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `name`: Label name. (string, required) - `owner`: Repository owner (username or organization name) (string, required) - `repo`: Repository name (string, required) - **issue_read** - Get issue details - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `issue_number`: The number of the issue (number, required) - `method`: The read operation to perform on a single issue. Options are: @@ -864,7 +835,6 @@ The following sets of tools are available: - **issue_write** - Create or update issue. - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `assignees`: Usernames to assign to this issue (string[], optional) - `body`: Issue body content (string, optional) - `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional) @@ -890,7 +860,6 @@ The following sets of tools are available: - **list_issues** - List issues - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) - `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional) - `labels`: Filter by labels (string[], optional) @@ -903,7 +872,6 @@ The following sets of tools are available: - **search_issues** - Search issues - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `order`: Sort order (string, optional) - `owner`: Optional repository owner. If provided with repo, only issues for this repository are listed. (string, optional) - `page`: Page number for pagination (min 1) (number, optional) @@ -914,7 +882,6 @@ The following sets of tools are available: - **sub_issue_write** - Change sub-issue - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `after_id`: The ID of the sub-issue to be prioritized after (either after_id OR before_id should be specified) (number, optional) - `before_id`: The ID of the sub-issue to be prioritized before (either after_id OR before_id should be specified) (number, optional) - `issue_number`: The number of the parent issue (number, required) @@ -937,14 +904,12 @@ The following sets of tools are available: - **get_label** - Get a specific label from a repository. - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `name`: Label name. (string, required) - `owner`: Repository owner (username or organization name) (string, required) - `repo`: Repository name (string, required) - **label_write** - Write operations on repository labels. - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `color`: Label color as 6-character hex code without '#' prefix (e.g., 'f29513'). Required for 'create', optional for 'update'. (string, optional) - `description`: Label description text. Optional for 'create' and 'update'. (string, optional) - `method`: Operation to perform: 'create', 'update', or 'delete' (string, required) @@ -955,7 +920,6 @@ The following sets of tools are available: - **list_label** - List labels from a repository - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (username or organization name) - required for all operations (string, required) - `repo`: Repository name - required for all operations (string, required) @@ -967,18 +931,15 @@ The following sets of tools are available: - **dismiss_notification** - Dismiss notification - **Required OAuth Scopes**: `notifications` - - **Accepted OAuth Scopes**: `notifications` - `state`: The new state of the notification (read/done) (string, required) - `threadID`: The ID of the notification thread (string, required) - **get_notification_details** - Get notification details - **Required OAuth Scopes**: `notifications` - - **Accepted OAuth Scopes**: `notifications` - `notificationID`: The ID of the notification (string, required) - **list_notifications** - List notifications - **Required OAuth Scopes**: `notifications` - - **Accepted OAuth Scopes**: `notifications` - `before`: Only show notifications updated before the given time (ISO 8601 format) (string, optional) - `filter`: Filter notifications to, use default unless specified. Read notifications are ones that have already been acknowledged by the user. Participating notifications are those that the user is directly involved in, such as issues or pull requests they have commented on or created. (string, optional) - `owner`: Optional repository owner. If provided with repo, only notifications for this repository are listed. (string, optional) @@ -989,20 +950,17 @@ The following sets of tools are available: - **manage_notification_subscription** - Manage notification subscription - **Required OAuth Scopes**: `notifications` - - **Accepted OAuth Scopes**: `notifications` - `action`: Action to perform: ignore, watch, or delete the notification subscription. (string, required) - `notificationID`: The ID of the notification thread. (string, required) - **manage_repository_notification_subscription** - Manage repository notification subscription - **Required OAuth Scopes**: `notifications` - - **Accepted OAuth Scopes**: `notifications` - `action`: Action to perform: ignore, watch, or delete the repository notification subscription. (string, required) - `owner`: The account owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) - **mark_all_notifications_read** - Mark all notifications as read - **Required OAuth Scopes**: `notifications` - - **Accepted OAuth Scopes**: `notifications` - `lastReadAt`: Describes the last point that notifications were checked (optional). Default: Now (string, optional) - `owner`: Optional repository owner. If provided with repo, only notifications for this repository are marked as read. (string, optional) - `repo`: Optional repository name. If provided with owner, only notifications for this repository are marked as read. (string, optional) @@ -1030,7 +988,6 @@ The following sets of tools are available: - **add_project_item** - Add project item - **Required OAuth Scopes**: `project` - - **Accepted OAuth Scopes**: `project` - `item_id`: The numeric ID of the issue or pull request to add to the project. (number, required) - `item_type`: The item's type, either issue or pull_request. (string, required) - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) @@ -1039,7 +996,6 @@ The following sets of tools are available: - **delete_project_item** - Delete project item - **Required OAuth Scopes**: `project` - - **Accepted OAuth Scopes**: `project` - `item_id`: The internal project item ID to delete from the project (not the issue or pull request ID). (number, required) - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - `owner_type`: Owner type (string, required) @@ -1103,7 +1059,6 @@ The following sets of tools are available: - **update_project_item** - Update project item - **Required OAuth Scopes**: `project` - - **Accepted OAuth Scopes**: `project` - `item_id`: The unique identifier of the project item. This is not the issue or pull request ID. (number, required) - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - `owner_type`: Owner type (string, required) @@ -1118,7 +1073,6 @@ The following sets of tools are available: - **add_comment_to_pending_review** - Add review comment to the requester's latest pending pull request review - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `body`: The text of the review comment (string, required) - `line`: The line of the blob in the pull request diff that the comment applies to. For multi-line comments, the last line of the range (number, optional) - `owner`: Repository owner (string, required) @@ -1132,7 +1086,6 @@ The following sets of tools are available: - **create_pull_request** - Open new pull request - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `base`: Branch to merge into (string, required) - `body`: PR description (string, optional) - `draft`: Create as draft PR (boolean, optional) @@ -1144,7 +1097,6 @@ The following sets of tools are available: - **list_pull_requests** - List pull requests - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `base`: Filter by base branch (string, optional) - `direction`: Sort direction (string, optional) - `head`: Filter by head user/org and branch (string, optional) @@ -1157,7 +1109,6 @@ The following sets of tools are available: - **merge_pull_request** - Merge pull request - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `commit_message`: Extra detail for merge commit (string, optional) - `commit_title`: Title for merge commit (string, optional) - `merge_method`: Merge method (string, optional) @@ -1167,7 +1118,6 @@ The following sets of tools are available: - **pull_request_read** - Get details for a single pull request - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `method`: Action to specify what pull request data needs to be retrieved from GitHub. Possible options: 1. get - Get details of a specific pull request. @@ -1186,7 +1136,6 @@ The following sets of tools are available: - **pull_request_review_write** - Write operations (create, submit, delete) on pull request reviews. - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `body`: Review comment text (string, optional) - `commitID`: SHA of commit to review (string, optional) - `event`: Review action to perform. (string, optional) @@ -1197,14 +1146,12 @@ The following sets of tools are available: - **request_copilot_review** - Request Copilot review - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `pullNumber`: Pull request number (number, required) - `repo`: Repository name (string, required) - **search_pull_requests** - Search pull requests - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `order`: Sort order (string, optional) - `owner`: Optional repository owner. If provided with repo, only pull requests for this repository are listed. (string, optional) - `page`: Page number for pagination (min 1) (number, optional) @@ -1215,7 +1162,6 @@ The following sets of tools are available: - **update_pull_request** - Edit pull request - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `base`: New base branch name (string, optional) - `body`: New description (string, optional) - `draft`: Mark pull request as draft (true) or ready for review (false) (boolean, optional) @@ -1229,7 +1175,6 @@ The following sets of tools are available: - **update_pull_request_branch** - Update pull request branch - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `expectedHeadSha`: The expected SHA of the pull request's HEAD ref (string, optional) - `owner`: Repository owner (string, required) - `pullNumber`: Pull request number (number, required) @@ -1243,7 +1188,6 @@ The following sets of tools are available: - **create_branch** - Create branch - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `branch`: Name for new branch (string, required) - `from_branch`: Source branch (defaults to repo default) (string, optional) - `owner`: Repository owner (string, required) @@ -1251,7 +1195,6 @@ The following sets of tools are available: - **create_or_update_file** - Create or update file - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `branch`: Branch to create/update the file in (string, required) - `content`: Content of the file (string, required) - `message`: Commit message (string, required) @@ -1262,7 +1205,6 @@ The following sets of tools are available: - **create_repository** - Create repository - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `autoInit`: Initialize with README (boolean, optional) - `description`: Repository description (string, optional) - `name`: Repository name (string, required) @@ -1271,7 +1213,6 @@ The following sets of tools are available: - **delete_file** - Delete file - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `branch`: Branch to delete the file from (string, required) - `message`: Commit message (string, required) - `owner`: Repository owner (username or organization) (string, required) @@ -1280,14 +1221,12 @@ The following sets of tools are available: - **fork_repository** - Fork repository - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `organization`: Organization to fork to (string, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - **get_commit** - Get commit details - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `include_diff`: Whether to include file diffs and stats in the response. Default is true. (boolean, optional) - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) @@ -1297,7 +1236,6 @@ The following sets of tools are available: - **get_file_contents** - Get file or directory contents - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (username or organization) (string, required) - `path`: Path to file/directory (string, optional) - `ref`: Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head` (string, optional) @@ -1306,27 +1244,23 @@ The following sets of tools are available: - **get_latest_release** - Get latest release - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - **get_release_by_tag** - Get a release by tag name - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `tag`: Tag name (e.g., 'v1.0.0') (string, required) - **get_tag** - Get tag details - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - `tag`: Tag name (string, required) - **list_branches** - List branches - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) @@ -1334,7 +1268,6 @@ The following sets of tools are available: - **list_commits** - List commits - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `author`: Author username or email address to filter commits by (string, optional) - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) @@ -1344,7 +1277,6 @@ The following sets of tools are available: - **list_releases** - List releases - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) @@ -1352,7 +1284,6 @@ The following sets of tools are available: - **list_tags** - List tags - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) @@ -1360,7 +1291,6 @@ The following sets of tools are available: - **push_files** - Push files to repository - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `branch`: Branch to push to (string, required) - `files`: Array of file objects to push, each object with path (string) and content (string) (object[], required) - `message`: Commit message (string, required) @@ -1369,7 +1299,6 @@ The following sets of tools are available: - **search_code** - Search code - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `order`: Sort order for results (string, optional) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) @@ -1378,7 +1307,6 @@ The following sets of tools are available: - **search_repositories** - Search repositories - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `minimal_output`: Return minimal repository information (default: true). When false, returns full GitHub API repository objects. (boolean, optional) - `order`: Sort order (string, optional) - `page`: Page number for pagination (min 1) (number, optional) @@ -1459,7 +1387,6 @@ The following sets of tools are available: - **list_starred_repositories** - List starred repositories - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `direction`: The direction to sort the results by. (string, optional) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) @@ -1486,7 +1413,6 @@ The following sets of tools are available: - **search_users** - Search users - **Required OAuth Scopes**: `repo` - - **Accepted OAuth Scopes**: `repo` - `order`: Sort order (string, optional) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index f28aa1bc2..85d7ecdbf 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -227,11 +227,11 @@ func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) { fmt.Fprintf(buf, "- **%s** - %s\n", tool.Tool.Name, tool.Tool.Annotations.Title) // OAuth scopes if present - if len(tool.RequiredScopes) > 0 || len(tool.AcceptedScopes) > 0 { - if len(tool.RequiredScopes) > 0 { - fmt.Fprintf(buf, " - **Required OAuth Scopes**: `%s`\n", strings.Join(tool.RequiredScopes, "`, `")) - } - if len(tool.AcceptedScopes) > 0 { + if len(tool.RequiredScopes) > 0 { + fmt.Fprintf(buf, " - **Required OAuth Scopes**: `%s`\n", strings.Join(tool.RequiredScopes, "`, `")) + + // Only show accepted scopes if they differ from required scopes + if len(tool.AcceptedScopes) > 0 && !scopesEqual(tool.RequiredScopes, tool.AcceptedScopes) { fmt.Fprintf(buf, " - **Accepted OAuth Scopes**: `%s`\n", strings.Join(tool.AcceptedScopes, "`, `")) } } @@ -290,6 +290,28 @@ func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) { } } +// scopesEqual checks if two scope slices contain the same elements (order-independent) +func scopesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + + // Create a map for quick lookup + aMap := make(map[string]bool, len(a)) + for _, scope := range a { + aMap[scope] = true + } + + // Check if all elements in b are in a + for _, scope := range b { + if !aMap[scope] { + return false + } + } + + return true +} + func contains(slice []string, item string) bool { for _, s := range slice { if s == item { diff --git a/pkg/github/actions.go b/pkg/github/actions.go index c4ba97983..c6cbd2ee7 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -51,7 +51,7 @@ const ( // ListWorkflows creates a tool to list workflows in a repository func ListWorkflows(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewToolWithScopes( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "list_workflows", @@ -124,7 +124,7 @@ func ListWorkflows(t translations.TranslationHelperFunc) inventory.ServerTool { // ListWorkflowRuns creates a tool to list workflow runs for a specific workflow func ListWorkflowRuns(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewToolWithScopes( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "list_workflow_runs", @@ -280,7 +280,7 @@ func ListWorkflowRuns(t translations.TranslationHelperFunc) inventory.ServerTool // RunWorkflow creates a tool to run an Actions workflow func RunWorkflow(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewToolWithScopes( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "run_workflow", @@ -394,7 +394,7 @@ func RunWorkflow(t translations.TranslationHelperFunc) inventory.ServerTool { // GetWorkflowRun creates a tool to get details of a specific workflow run func GetWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewToolWithScopes( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "get_workflow_run", @@ -464,7 +464,7 @@ func GetWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool { // GetWorkflowRunLogs creates a tool to download logs for a specific workflow run func GetWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewToolWithScopes( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "get_workflow_run_logs", @@ -544,7 +544,7 @@ func GetWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.ServerTo // ListWorkflowJobs creates a tool to list jobs for a specific workflow run func ListWorkflowJobs(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewToolWithScopes( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "list_workflow_jobs", @@ -646,7 +646,7 @@ func ListWorkflowJobs(t translations.TranslationHelperFunc) inventory.ServerTool // GetJobLogs creates a tool to download logs for a specific workflow job or efficiently get all failed job logs for a workflow run func GetJobLogs(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewToolWithScopes( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "get_job_logs", @@ -913,7 +913,7 @@ func downloadLogContent(ctx context.Context, logURL string, tailLines int, maxLi // RerunWorkflowRun creates a tool to re-run an entire workflow run func RerunWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewToolWithScopes( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "rerun_workflow_run", @@ -990,7 +990,7 @@ func RerunWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool // RerunFailedJobs creates a tool to re-run only the failed jobs in a workflow run func RerunFailedJobs(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewToolWithScopes( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "rerun_failed_jobs", @@ -1067,7 +1067,7 @@ func RerunFailedJobs(t translations.TranslationHelperFunc) inventory.ServerTool // CancelWorkflowRun creates a tool to cancel a workflow run func CancelWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewToolWithScopes( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "cancel_workflow_run", @@ -1146,7 +1146,7 @@ func CancelWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerToo // ListWorkflowRunArtifacts creates a tool to list artifacts for a workflow run func ListWorkflowRunArtifacts(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewToolWithScopes( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "list_workflow_run_artifacts", @@ -1228,7 +1228,7 @@ func ListWorkflowRunArtifacts(t translations.TranslationHelperFunc) inventory.Se // DownloadWorkflowRunArtifact creates a tool to download a workflow run artifact func DownloadWorkflowRunArtifact(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewToolWithScopes( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "download_workflow_run_artifact", @@ -1307,7 +1307,7 @@ func DownloadWorkflowRunArtifact(t translations.TranslationHelperFunc) inventory // DeleteWorkflowRunLogs creates a tool to delete logs for a workflow run func DeleteWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewToolWithScopes( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "delete_workflow_run_logs", @@ -1385,7 +1385,7 @@ func DeleteWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.Serve // GetWorkflowRunUsage creates a tool to get usage metrics for a workflow run func GetWorkflowRunUsage(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewToolWithScopes( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "get_workflow_run_usage", @@ -1455,7 +1455,7 @@ func GetWorkflowRunUsage(t translations.TranslationHelperFunc) inventory.ServerT // ActionsList returns the tool and handler for listing GitHub Actions resources. func ActionsList(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewToolWithScopes( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "actions_list", @@ -1652,7 +1652,7 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an // ActionsGet returns the tool and handler for getting GitHub Actions resources. func ActionsGet(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewToolWithScopes( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "actions_get", @@ -1762,7 +1762,7 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an // ActionsRunTrigger returns the tool and handler for triggering GitHub Actions workflows. func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewToolWithScopes( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "actions_run_trigger", @@ -1882,7 +1882,7 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo // ActionsGetJobLogs returns the tool and handler for getting workflow job logs. func ActionsGetJobLogs(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewToolWithScopes( + tool := NewTool( ToolsetMetadataActions, mcp.Tool{ Name: "get_job_logs", diff --git a/pkg/github/code_scanning.go b/pkg/github/code_scanning.go index af232937c..99ab8ad4f 100644 --- a/pkg/github/code_scanning.go +++ b/pkg/github/code_scanning.go @@ -17,7 +17,7 @@ import ( ) func GetCodeScanningAlert(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataCodeSecurity, mcp.Tool{ Name: "get_code_scanning_alert", @@ -95,7 +95,7 @@ func GetCodeScanningAlert(t translations.TranslationHelperFunc) inventory.Server } func ListCodeScanningAlerts(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataCodeSecurity, mcp.Tool{ Name: "list_code_scanning_alerts", diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 77c9ce10a..ad3c8daa4 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -39,7 +39,7 @@ type UserDetails struct { // GetMe creates a tool to get details of the authenticated user. func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataContext, mcp.Tool{ Name: "get_me", @@ -113,7 +113,7 @@ type OrganizationTeams struct { } func GetTeams(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataContext, mcp.Tool{ Name: "get_teams", @@ -212,7 +212,7 @@ func GetTeams(t translations.TranslationHelperFunc) inventory.ServerTool { } func GetTeamMembers(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataContext, mcp.Tool{ Name: "get_team_members", diff --git a/pkg/github/dependabot.go b/pkg/github/dependabot.go index 9a0c09a8f..5c86d9709 100644 --- a/pkg/github/dependabot.go +++ b/pkg/github/dependabot.go @@ -18,7 +18,7 @@ import ( ) func GetDependabotAlert(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataDependabot, mcp.Tool{ Name: "get_dependabot_alert", @@ -96,7 +96,7 @@ func GetDependabotAlert(t translations.TranslationHelperFunc) inventory.ServerTo } func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataDependabot, mcp.Tool{ Name: "list_dependabot_alerts", diff --git a/pkg/github/dependencies.go b/pkg/github/dependencies.go index e15efb1d5..d5a5e30aa 100644 --- a/pkg/github/dependencies.go +++ b/pkg/github/dependencies.go @@ -148,16 +148,9 @@ func (d BaseDeps) GetContentWindowSize() int { return d.ContentWindowSize } // // The handler function receives deps extracted from context via MustDepsFromContext. // Ensure ContextWithDeps is called to inject deps before any tool handlers are invoked. -func NewTool[In, Out any](toolset inventory.ToolsetMetadata, tool mcp.Tool, handler func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error)) inventory.ServerTool { - return inventory.NewServerToolWithContextHandler(tool, toolset, func(ctx context.Context, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error) { - deps := MustDepsFromContext(ctx) - return handler(ctx, deps, req, args) - }) -} - -// NewToolWithScopes creates a ServerTool with OAuth scope requirements. -// This is like NewTool but also accepts required and accepted scopes. -func NewToolWithScopes[In, Out any]( +// +// All tools must explicitly specify their OAuth scope requirements, even if empty (nil). +func NewTool[In, Out any]( toolset inventory.ToolsetMetadata, tool mcp.Tool, requiredScopes []string, @@ -178,16 +171,9 @@ func NewToolWithScopes[In, Out any]( // // The handler function receives deps extracted from context via MustDepsFromContext. // Ensure ContextWithDeps is called to inject deps before any tool handlers are invoked. -func NewToolFromHandler(toolset inventory.ToolsetMetadata, tool mcp.Tool, handler func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest) (*mcp.CallToolResult, error)) inventory.ServerTool { - return inventory.NewServerToolWithRawContextHandler(tool, toolset, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - deps := MustDepsFromContext(ctx) - return handler(ctx, deps, req) - }) -} - -// NewToolFromHandlerWithScopes creates a ServerTool with OAuth scope requirements. -// This is like NewToolFromHandler but also accepts required and accepted scopes. -func NewToolFromHandlerWithScopes( +// +// All tools must explicitly specify their OAuth scope requirements, even if empty (nil). +func NewToolFromHandler( toolset inventory.ToolsetMetadata, tool mcp.Tool, requiredScopes []string, diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index d91722e6e..270228c73 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -124,7 +124,7 @@ func getQueryType(useOrdering bool, categoryID *githubv4.ID) any { } func ListDiscussions(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataDiscussions, mcp.Tool{ Name: "list_discussions", @@ -278,7 +278,7 @@ func ListDiscussions(t translations.TranslationHelperFunc) inventory.ServerTool } func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataDiscussions, mcp.Tool{ Name: "get_discussion", @@ -383,7 +383,7 @@ func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool { } func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataDiscussions, mcp.Tool{ Name: "get_discussion_comments", @@ -511,7 +511,7 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve } func ListDiscussionCategories(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataDiscussions, mcp.Tool{ Name: "list_discussion_categories", diff --git a/pkg/github/gists.go b/pkg/github/gists.go index cd268c6a1..0509220e3 100644 --- a/pkg/github/gists.go +++ b/pkg/github/gists.go @@ -19,7 +19,7 @@ import ( // ListGists creates a tool to list gists for a user func ListGists(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataGists, mcp.Tool{ Name: "list_gists", @@ -107,7 +107,7 @@ func ListGists(t translations.TranslationHelperFunc) inventory.ServerTool { // GetGist creates a tool to get the content of a gist func GetGist(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataGists, mcp.Tool{ Name: "get_gist", @@ -166,7 +166,7 @@ func GetGist(t translations.TranslationHelperFunc) inventory.ServerTool { // CreateGist creates a tool to create a new gist func CreateGist(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataGists, mcp.Tool{ Name: "create_gist", @@ -270,7 +270,7 @@ func CreateGist(t translations.TranslationHelperFunc) inventory.ServerTool { // UpdateGist creates a tool to edit an existing gist func UpdateGist(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataGists, mcp.Tool{ Name: "update_gist", diff --git a/pkg/github/git.go b/pkg/github/git.go index 06c766163..8e632c1af 100644 --- a/pkg/github/git.go +++ b/pkg/github/git.go @@ -40,7 +40,7 @@ type TreeResponse struct { // GetRepositoryTree creates a tool to get the tree structure of a GitHub repository. func GetRepositoryTree(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataGit, mcp.Tool{ Name: "get_repository_tree", diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 9ecf4d264..9f2223295 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -264,7 +264,7 @@ Options are: } WithPagination(schema) - return NewToolWithScopes( + return NewTool( ToolsetMetadataIssues, mcp.Tool{ Name: "issue_read", @@ -548,7 +548,7 @@ func GetIssueLabels(ctx context.Context, client *githubv4.Client, owner string, // 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. func ListIssueTypes(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataIssues, mcp.Tool{ Name: "list_issue_types", @@ -605,7 +605,7 @@ func ListIssueTypes(t translations.TranslationHelperFunc) inventory.ServerTool { // AddIssueComment creates a tool to add a comment to an issue. func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataIssues, mcp.Tool{ Name: "add_issue_comment", @@ -690,7 +690,7 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool // SubIssueWrite creates a tool to add a sub-issue to a parent issue. func SubIssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataIssues, mcp.Tool{ Name: "sub_issue_write", @@ -961,7 +961,7 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool { } WithPagination(schema) - return NewToolWithScopes( + return NewTool( ToolsetMetadataIssues, mcp.Tool{ Name: "search_issues", @@ -982,7 +982,7 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool { // IssueWrite creates a tool to create a new or update an existing issue in a GitHub repository. func IssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataIssues, mcp.Tool{ Name: "issue_write", @@ -1387,7 +1387,7 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { } WithCursorPagination(schema) - return NewToolWithScopes( + return NewTool( ToolsetMetadataIssues, mcp.Tool{ Name: "list_issues", @@ -1627,7 +1627,7 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server }, } - return NewToolWithScopes( + return NewTool( ToolsetMetadataIssues, mcp.Tool{ Name: "assign_copilot_to_issue", diff --git a/pkg/github/labels.go b/pkg/github/labels.go index d55b605fe..e85dee450 100644 --- a/pkg/github/labels.go +++ b/pkg/github/labels.go @@ -18,7 +18,7 @@ import ( // GetLabel retrieves a specific label by name from a GitHub repository func GetLabel(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataIssues, mcp.Tool{ Name: "get_label", @@ -121,7 +121,7 @@ func GetLabelForLabelsToolset(t translations.TranslationHelperFunc) inventory.Se // ListLabels lists labels from a repository func ListLabels(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetLabels, mcp.Tool{ Name: "list_label", @@ -213,7 +213,7 @@ func ListLabels(t translations.TranslationHelperFunc) inventory.ServerTool { // LabelWrite handles create, update, and delete operations for GitHub labels func LabelWrite(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetLabels, mcp.Tool{ Name: "label_write", diff --git a/pkg/github/notifications.go b/pkg/github/notifications.go index b3008362b..87026e2cb 100644 --- a/pkg/github/notifications.go +++ b/pkg/github/notifications.go @@ -27,7 +27,7 @@ const ( // ListNotifications creates a tool to list notifications for the current user. func ListNotifications(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataNotifications, mcp.Tool{ Name: "list_notifications", @@ -165,7 +165,7 @@ func ListNotifications(t translations.TranslationHelperFunc) inventory.ServerToo // DismissNotification creates a tool to mark a notification as read/done. func DismissNotification(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataNotifications, mcp.Tool{ Name: "dismiss_notification", @@ -248,7 +248,7 @@ func DismissNotification(t translations.TranslationHelperFunc) inventory.ServerT // MarkAllNotificationsRead creates a tool to mark all notifications as read. func MarkAllNotificationsRead(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataNotifications, mcp.Tool{ Name: "mark_all_notifications_read", @@ -341,7 +341,7 @@ func MarkAllNotificationsRead(t translations.TranslationHelperFunc) inventory.Se // GetNotificationDetails creates a tool to get details for a specific notification. func GetNotificationDetails(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataNotifications, mcp.Tool{ Name: "get_notification_details", @@ -411,7 +411,7 @@ const ( // ManageNotificationSubscription creates a tool to manage a notification subscription (ignore, watch, delete) func ManageNotificationSubscription(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataNotifications, mcp.Tool{ Name: "manage_notification_subscription", @@ -508,7 +508,7 @@ const ( // ManageRepositoryNotificationSubscription creates a tool to manage a repository notification subscription (ignore, watch, delete) func ManageRepositoryNotificationSubscription(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataNotifications, mcp.Tool{ Name: "manage_repository_notification_subscription", diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 4edef178e..3204243f7 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -27,7 +27,7 @@ const ( ) func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "list_projects", @@ -146,7 +146,7 @@ func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool { } func GetProject(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "get_project", @@ -236,7 +236,7 @@ func GetProject(t translations.TranslationHelperFunc) inventory.ServerTool { } func ListProjectFields(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "list_project_fields", @@ -344,7 +344,7 @@ func ListProjectFields(t translations.TranslationHelperFunc) inventory.ServerToo } func GetProjectField(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "get_project_field", @@ -438,7 +438,7 @@ func GetProjectField(t translations.TranslationHelperFunc) inventory.ServerTool } func ListProjectItems(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "list_project_items", @@ -576,7 +576,7 @@ func ListProjectItems(t translations.TranslationHelperFunc) inventory.ServerTool } func GetProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "get_project_item", @@ -684,7 +684,7 @@ func GetProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { } func AddProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "add_project_item", @@ -797,7 +797,7 @@ func AddProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { } func UpdateProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "update_project_item", @@ -911,7 +911,7 @@ func UpdateProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo } func DeleteProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "delete_project_item", diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index e81f87ca0..91145f61d 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -59,7 +59,7 @@ Possible options: } WithPagination(schema) - return NewToolWithScopes( + return NewTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "pull_request_read", @@ -510,7 +510,7 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner", "repo", "title", "head", "base"}, } - return NewToolWithScopes( + return NewTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "create_pull_request", @@ -663,7 +663,7 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner", "repo", "pullNumber"}, } - return NewToolWithScopes( + return NewTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "update_pull_request", @@ -946,7 +946,7 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool } WithPagination(schema) - return NewToolWithScopes( + return NewTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "list_pull_requests", @@ -1083,7 +1083,7 @@ func MergePullRequest(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo", "pullNumber"}, } - return NewToolWithScopes( + return NewTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "merge_pull_request", @@ -1203,7 +1203,7 @@ func SearchPullRequests(t translations.TranslationHelperFunc) inventory.ServerTo } WithPagination(schema) - return NewToolWithScopes( + return NewTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "search_pull_requests", @@ -1247,7 +1247,7 @@ func UpdatePullRequestBranch(t translations.TranslationHelperFunc) inventory.Ser Required: []string{"owner", "repo", "pullNumber"}, } - return NewToolWithScopes( + return NewTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "update_pull_request_branch", @@ -1369,7 +1369,7 @@ func PullRequestReviewWrite(t translations.TranslationHelperFunc) inventory.Serv Required: []string{"method", "owner", "repo", "pullNumber"}, } - return NewToolWithScopes( + return NewTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "pull_request_review_write", @@ -1700,7 +1700,7 @@ func AddCommentToPendingReview(t translations.TranslationHelperFunc) inventory.S Required: []string{"owner", "repo", "pullNumber", "path", "body", "subjectType"}, } - return NewToolWithScopes( + return NewTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "add_comment_to_pending_review", @@ -1853,7 +1853,7 @@ func RequestCopilotReview(t translations.TranslationHelperFunc) inventory.Server Required: []string{"owner", "repo", "pullNumber"}, } - return NewToolWithScopes( + return NewTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "request_copilot_review", diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index b0c59e417..c2f976e81 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -22,7 +22,7 @@ import ( ) func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "get_commit", @@ -121,7 +121,7 @@ func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { // ListCommits creates a tool to get commits of a branch in a repository. func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "list_commits", @@ -230,7 +230,7 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { // ListBranches creates a tool to list branches in a GitHub repository. func ListBranches(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "list_branches", @@ -318,7 +318,7 @@ func ListBranches(t translations.TranslationHelperFunc) inventory.ServerTool { // CreateOrUpdateFile creates a tool to create or update a file in a GitHub repository. func CreateOrUpdateFile(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "create_or_update_file", @@ -518,7 +518,7 @@ If the SHA is not provided, the tool will attempt to acquire it by fetching the // CreateRepository creates a tool to create a new GitHub repository. func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "create_repository", @@ -625,7 +625,7 @@ func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool // 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 NewToolWithScopes( + return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "get_file_contents", @@ -818,7 +818,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool // ForkRepository creates a tool to fork a repository. func ForkRepository(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "fork_repository", @@ -918,7 +918,7 @@ func ForkRepository(t translations.TranslationHelperFunc) inventory.ServerTool { // The approach implemented here gets automatic commit signing when used with either the github-actions user or as an app, // both of which suit an LLM well. func DeleteFile(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "delete_file", @@ -1104,7 +1104,7 @@ func DeleteFile(t translations.TranslationHelperFunc) inventory.ServerTool { // CreateBranch creates a tool to create a new branch. func CreateBranch(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "create_branch", @@ -1218,7 +1218,7 @@ func CreateBranch(t translations.TranslationHelperFunc) inventory.ServerTool { // PushFiles creates a tool to push multiple files in a single commit to a GitHub repository. func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "push_files", @@ -1453,7 +1453,7 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { // ListTags creates a tool to list tags in a GitHub repository. func ListTags(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "list_tags", @@ -1533,7 +1533,7 @@ func ListTags(t translations.TranslationHelperFunc) inventory.ServerTool { // GetTag creates a tool to get details about a specific tag in a GitHub repository. func GetTag(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "get_tag", @@ -1632,7 +1632,7 @@ func GetTag(t translations.TranslationHelperFunc) inventory.ServerTool { // ListReleases creates a tool to list releases in a GitHub repository. func ListReleases(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "list_releases", @@ -1708,7 +1708,7 @@ func ListReleases(t translations.TranslationHelperFunc) inventory.ServerTool { // GetLatestRelease creates a tool to get the latest release in a GitHub repository. func GetLatestRelease(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "get_latest_release", @@ -1774,7 +1774,7 @@ func GetLatestRelease(t translations.TranslationHelperFunc) inventory.ServerTool } func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "get_release_by_tag", @@ -1853,7 +1853,7 @@ func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool // ListStarredRepositories creates a tool to list starred repositories for the authenticated user or a specified user. func ListStarredRepositories(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataStargazers, mcp.Tool{ Name: "list_starred_repositories", @@ -1986,7 +1986,7 @@ func ListStarredRepositories(t translations.TranslationHelperFunc) inventory.Ser // StarRepository creates a tool to star a repository. func StarRepository(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataStargazers, mcp.Tool{ Name: "star_repository", @@ -2053,7 +2053,7 @@ func StarRepository(t translations.TranslationHelperFunc) inventory.ServerTool { // UnstarRepository creates a tool to unstar a repository. func UnstarRepository(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataStargazers, mcp.Tool{ Name: "unstar_repository", diff --git a/pkg/github/search.go b/pkg/github/search.go index d95214e55..b662cecdd 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -46,7 +46,7 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo } WithPagination(schema) - return NewToolWithScopes( + return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "search_repositories", @@ -190,7 +190,7 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { } WithPagination(schema) - return NewToolWithScopes( + return NewTool( ToolsetMetadataRepos, mcp.Tool{ Name: "search_code", @@ -373,7 +373,7 @@ func SearchUsers(t translations.TranslationHelperFunc) inventory.ServerTool { } WithPagination(schema) - return NewToolWithScopes( + return NewTool( ToolsetMetadataUsers, mcp.Tool{ Name: "search_users", @@ -416,7 +416,7 @@ func SearchOrgs(t translations.TranslationHelperFunc) inventory.ServerTool { } WithPagination(schema) - return NewToolWithScopes( + return NewTool( ToolsetMetadataOrgs, mcp.Tool{ Name: "search_orgs", diff --git a/pkg/github/secret_scanning.go b/pkg/github/secret_scanning.go index fb259758e..2f3c00877 100644 --- a/pkg/github/secret_scanning.go +++ b/pkg/github/secret_scanning.go @@ -18,7 +18,7 @@ import ( ) func GetSecretScanningAlert(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataSecretProtection, mcp.Tool{ Name: "get_secret_scanning_alert", @@ -96,7 +96,7 @@ func GetSecretScanningAlert(t translations.TranslationHelperFunc) inventory.Serv } func ListSecretScanningAlerts(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataSecretProtection, mcp.Tool{ Name: "list_secret_scanning_alerts", diff --git a/pkg/github/security_advisories.go b/pkg/github/security_advisories.go index 44858dbd9..3bbb0d1f7 100644 --- a/pkg/github/security_advisories.go +++ b/pkg/github/security_advisories.go @@ -18,7 +18,7 @@ import ( ) func ListGlobalSecurityAdvisories(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataSecurityAdvisories, mcp.Tool{ Name: "list_global_security_advisories", @@ -210,7 +210,7 @@ func ListGlobalSecurityAdvisories(t translations.TranslationHelperFunc) inventor } func ListRepositorySecurityAdvisories(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataSecurityAdvisories, mcp.Tool{ Name: "list_repository_security_advisories", @@ -315,7 +315,7 @@ func ListRepositorySecurityAdvisories(t translations.TranslationHelperFunc) inve } func GetGlobalSecurityAdvisory(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataSecurityAdvisories, mcp.Tool{ Name: "get_global_security_advisory", @@ -373,7 +373,7 @@ func GetGlobalSecurityAdvisory(t translations.TranslationHelperFunc) inventory.S } func ListOrgRepositorySecurityAdvisories(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithScopes( + return NewTool( ToolsetMetadataSecurityAdvisories, mcp.Tool{ Name: "list_org_repository_security_advisories", From c765101aebcc2b81d4a54d600e0fa91f692099e8 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 11:20:38 +0100 Subject: [PATCH 21/70] refresh readme after rebase --- README.md | 59 +------------------------------------------------------ 1 file changed, 1 insertion(+), 58 deletions(-) diff --git a/README.md b/README.md index a4f803845..80345056a 100644 --- a/README.md +++ b/README.md @@ -492,46 +492,6 @@ The following sets of tools are available: workflow Actions -<<<<<<< HEAD -======= -- **actions_get** - Get details of GitHub Actions resources (workflows, workflow runs, jobs, and artifacts) - - **Required OAuth Scopes**: `repo` - - `method`: The method to execute (string, required) - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `resource_id`: The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID: - - Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'get_workflow' method. - - Provide a workflow run ID for 'get_workflow_run', 'get_workflow_run_usage', and 'get_workflow_run_logs_url' methods. - - Provide an artifact ID for 'download_workflow_run_artifact' method. - - Provide a job ID for 'get_workflow_job' method. - (string, required) - -- **actions_list** - List GitHub Actions workflows in a repository - - **Required OAuth Scopes**: `repo` - - `method`: The action to perform (string, required) - - `owner`: Repository owner (string, required) - - `page`: Page number for pagination (default: 1) (number, optional) - - `per_page`: Results per page for pagination (default: 30, max: 100) (number, optional) - - `repo`: Repository name (string, required) - - `resource_id`: The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID: - - Do not provide any resource ID for 'list_workflows' method. - - Provide a workflow ID or workflow file name (e.g. ci.yaml) for 'list_workflow_runs' method. - - Provide a workflow run ID for 'list_workflow_jobs' and 'list_workflow_run_artifacts' methods. - (string, optional) - - `workflow_jobs_filter`: Filters for workflow jobs. **ONLY** used when method is 'list_workflow_jobs' (object, optional) - - `workflow_runs_filter`: Filters for workflow runs. **ONLY** used when method is 'list_workflow_runs' (object, optional) - -- **actions_run_trigger** - Trigger GitHub Actions workflow actions - - **Required OAuth Scopes**: `repo` - - `inputs`: Inputs the workflow accepts. Only used for 'run_workflow' method. (object, optional) - - `method`: The method to execute (string, required) - - `owner`: Repository owner (string, required) - - `ref`: The git reference for the workflow. The reference can be a branch or tag name. Required for 'run_workflow' method. (string, optional) - - `repo`: Repository name (string, required) - - `run_id`: The ID of the workflow run. Required for all methods except 'run_workflow'. (number, optional) - - `workflow_id`: The workflow ID (numeric) or workflow file name (e.g., main.yml, ci.yaml). Required for 'run_workflow' method. (string, optional) - ->>>>>>> 4ed0310 (Complete OAuth scope implementation for all tools) - **cancel_workflow_run** - Cancel workflow run - **Required OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) @@ -560,19 +520,6 @@ The following sets of tools are available: - `run_id`: Workflow run ID (required when using failed_only) (number, optional) - `tail_lines`: Number of lines to return from the end of the log (number, optional) -<<<<<<< HEAD -======= -- **get_job_logs** - Get GitHub Actions workflow job logs - - **Required OAuth Scopes**: `repo` - - `failed_only`: When true, gets logs for all failed jobs in the workflow run specified by run_id. Requires run_id to be provided. (boolean, optional) - - `job_id`: The unique identifier of the workflow job. Required when getting logs for a single job. (number, optional) - - `owner`: Repository owner (string, required) - - `repo`: Repository name (string, required) - - `return_content`: Returns actual log content instead of URLs (boolean, optional) - - `run_id`: The unique identifier of the workflow run. Required when failed_only is true to get logs for all failed jobs in the run. (number, optional) - - `tail_lines`: Number of lines to return from the end of the log (number, optional) - ->>>>>>> 4ed0310 (Complete OAuth scope implementation for all tools) - **get_workflow_run** - Get workflow run - **Required OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) @@ -803,12 +750,8 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - **assign_copilot_to_issue** - Assign Copilot to issue -<<<<<<< HEAD - - `issue_number`: Issue number (number, required) -======= - **Required OAuth Scopes**: `repo` - - `issueNumber`: Issue number (number, required) ->>>>>>> 4ed0310 (Complete OAuth scope implementation for all tools) + - `issue_number`: Issue number (number, required) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) From 7796c08681eab7cf3fd66aa599e68164ec614d7b Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 12:02:26 +0100 Subject: [PATCH 22/70] Add scope hierarchy and auto-derive accepted scopes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add ScopeHierarchy map defining parent-child scope relationships - Add ExpandScopes() function to derive accepted scopes from required scopes - Update NewTool/NewToolFromHandler to take []scopes.Scope and auto-derive AcceptedScopes - Add new scope constants: NoScope, User, ReadUser, UserEmail, ReadPackages, WritePackages - Update all tool files to use new signature with typed scopes - Add comprehensive tests for ExpandScopes The scope hierarchy allows automatic derivation of accepted scopes: - repo → public_repo, security_events - admin:org → write:org → read:org - project → read:project - write:packages → read:packages - user → read:user, user:email This enables the remote server to consume scope info directly from OSS tools. --- pkg/github/actions.go | 54 ++++------- pkg/github/code_scanning.go | 6 +- pkg/github/context_tools.go | 9 +- pkg/github/dependabot.go | 6 +- pkg/github/dependencies.go | 22 +++-- pkg/github/discussions.go | 12 +-- pkg/github/gists.go | 12 +-- pkg/github/git.go | 3 +- pkg/github/issues.go | 24 ++--- pkg/github/labels.go | 9 +- pkg/github/notifications.go | 18 ++-- pkg/github/projects.go | 27 ++---- pkg/github/pullrequests.go | 30 ++---- pkg/github/repositories.go | 54 ++++------- pkg/github/search.go | 12 +-- pkg/github/secret_scanning.go | 6 +- pkg/github/security_advisories.go | 12 +-- pkg/scopes/scopes.go | 63 +++++++++++++ pkg/scopes/scopes_test.go | 152 ++++++++++++++++++++++++++++++ 19 files changed, 325 insertions(+), 206 deletions(-) create mode 100644 pkg/scopes/scopes_test.go diff --git a/pkg/github/actions.go b/pkg/github/actions.go index c6cbd2ee7..14cb8028c 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -75,8 +75,7 @@ func ListWorkflows(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }), }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -203,8 +202,7 @@ func ListWorkflowRuns(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo", "workflow_id"}, }), }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -316,8 +314,7 @@ func RunWorkflow(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "workflow_id", "ref"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -422,8 +419,7 @@ func GetWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "run_id"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -492,8 +488,7 @@ func GetWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.ServerTo Required: []string{"owner", "repo", "run_id"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -577,8 +572,7 @@ func ListWorkflowJobs(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo", "run_id"}, }), }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -691,8 +685,7 @@ func GetJobLogs(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -941,8 +934,7 @@ func RerunWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo", "run_id"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -1018,8 +1010,7 @@ func RerunFailedJobs(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo", "run_id"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -1095,8 +1086,7 @@ func CancelWorkflowRun(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner", "repo", "run_id"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -1174,8 +1164,7 @@ func ListWorkflowRunArtifacts(t translations.TranslationHelperFunc) inventory.Se Required: []string{"owner", "repo", "run_id"}, }), }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -1256,8 +1245,7 @@ func DownloadWorkflowRunArtifact(t translations.TranslationHelperFunc) inventory Required: []string{"owner", "repo", "artifact_id"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -1336,8 +1324,7 @@ func DeleteWorkflowRunLogs(t translations.TranslationHelperFunc) inventory.Serve Required: []string{"owner", "repo", "run_id"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -1413,8 +1400,7 @@ func GetWorkflowRunUsage(t translations.TranslationHelperFunc) inventory.ServerT Required: []string{"owner", "repo", "run_id"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -1579,8 +1565,7 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an Required: []string{"method", "owner", "repo"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -1699,8 +1684,7 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an Required: []string{"method", "owner", "repo", "resource_id"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -1814,8 +1798,7 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"method", "owner", "repo"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -1930,8 +1913,7 @@ For single job logs, provide job_id. For all failed jobs in a run, provide run_i Required: []string{"owner", "repo"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { diff --git a/pkg/github/code_scanning.go b/pkg/github/code_scanning.go index 99ab8ad4f..ccc00661a 100644 --- a/pkg/github/code_scanning.go +++ b/pkg/github/code_scanning.go @@ -45,8 +45,7 @@ func GetCodeScanningAlert(t translations.TranslationHelperFunc) inventory.Server Required: []string{"owner", "repo", "alertNumber"}, }, }, - scopes.ToStringSlice(scopes.SecurityEvents), - scopes.ToStringSlice(scopes.SecurityEvents, scopes.Repo), + []scopes.Scope{scopes.SecurityEvents}, 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 { @@ -138,8 +137,7 @@ func ListCodeScanningAlerts(t translations.TranslationHelperFunc) inventory.Serv Required: []string{"owner", "repo"}, }, }, - scopes.ToStringSlice(scopes.SecurityEvents), - scopes.ToStringSlice(scopes.SecurityEvents, scopes.Repo), + []scopes.Scope{scopes.SecurityEvents}, 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 { diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index ad3c8daa4..29fa2925d 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -52,8 +52,7 @@ func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool { // OpenAI strict mode requires the properties field to be present. InputSchema: json.RawMessage(`{"type":"object","properties":{}}`), }, - nil, // no required scopes - nil, // no accepted scopes + nil, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, _ map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -132,8 +131,7 @@ func GetTeams(t translations.TranslationHelperFunc) inventory.ServerTool { }, }, }, - scopes.ToStringSlice(scopes.ReadOrg), - scopes.ToStringSlice(scopes.ReadOrg, scopes.WriteOrg, scopes.AdminOrg), + []scopes.Scope{scopes.ReadOrg}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { user, err := OptionalParam[string](args, "user") if err != nil { @@ -236,8 +234,7 @@ func GetTeamMembers(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"org", "team_slug"}, }, }, - scopes.ToStringSlice(scopes.ReadOrg), - scopes.ToStringSlice(scopes.ReadOrg, scopes.WriteOrg, scopes.AdminOrg), + []scopes.Scope{scopes.ReadOrg}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { org, err := RequiredParam[string](args, "org") if err != nil { diff --git a/pkg/github/dependabot.go b/pkg/github/dependabot.go index 5c86d9709..b6b2eeaba 100644 --- a/pkg/github/dependabot.go +++ b/pkg/github/dependabot.go @@ -46,8 +46,7 @@ func GetDependabotAlert(t translations.TranslationHelperFunc) inventory.ServerTo Required: []string{"owner", "repo", "alertNumber"}, }, }, - scopes.ToStringSlice(scopes.SecurityEvents), - scopes.ToStringSlice(scopes.SecurityEvents, scopes.Repo), + []scopes.Scope{scopes.SecurityEvents}, 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 { @@ -131,8 +130,7 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server Required: []string{"owner", "repo"}, }, }, - scopes.ToStringSlice(scopes.SecurityEvents), - scopes.ToStringSlice(scopes.SecurityEvents, scopes.Repo), + []scopes.Scope{scopes.SecurityEvents}, 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 { diff --git a/pkg/github/dependencies.go b/pkg/github/dependencies.go index d5a5e30aa..b41bf0b87 100644 --- a/pkg/github/dependencies.go +++ b/pkg/github/dependencies.go @@ -7,6 +7,7 @@ import ( "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/lockdown" "github.com/github/github-mcp-server/pkg/raw" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" gogithub "github.com/google/go-github/v79/github" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -149,20 +150,21 @@ func (d BaseDeps) GetContentWindowSize() int { return d.ContentWindowSize } // The handler function receives deps extracted from context via MustDepsFromContext. // Ensure ContextWithDeps is called to inject deps before any tool handlers are invoked. // -// All tools must explicitly specify their OAuth scope requirements, even if empty (nil). +// requiredScopes specifies the minimum OAuth scopes needed for this tool. +// AcceptedScopes are automatically derived using the scope hierarchy (e.g., if +// public_repo is required, repo is also accepted since repo grants public_repo). func NewTool[In, Out any]( toolset inventory.ToolsetMetadata, tool mcp.Tool, - requiredScopes []string, - acceptedScopes []string, + requiredScopes []scopes.Scope, handler func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error), ) inventory.ServerTool { st := inventory.NewServerToolWithContextHandler(tool, toolset, func(ctx context.Context, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error) { deps := MustDepsFromContext(ctx) return handler(ctx, deps, req, args) }) - st.RequiredScopes = requiredScopes - st.AcceptedScopes = acceptedScopes + st.RequiredScopes = scopes.ToStringSlice(requiredScopes...) + st.AcceptedScopes = scopes.ExpandScopes(requiredScopes...) return st } @@ -172,19 +174,19 @@ func NewTool[In, Out any]( // The handler function receives deps extracted from context via MustDepsFromContext. // Ensure ContextWithDeps is called to inject deps before any tool handlers are invoked. // -// All tools must explicitly specify their OAuth scope requirements, even if empty (nil). +// requiredScopes specifies the minimum OAuth scopes needed for this tool. +// AcceptedScopes are automatically derived using the scope hierarchy. func NewToolFromHandler( toolset inventory.ToolsetMetadata, tool mcp.Tool, - requiredScopes []string, - acceptedScopes []string, + requiredScopes []scopes.Scope, handler func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest) (*mcp.CallToolResult, error), ) inventory.ServerTool { st := inventory.NewServerToolWithRawContextHandler(tool, toolset, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { deps := MustDepsFromContext(ctx) return handler(ctx, deps, req) }) - st.RequiredScopes = requiredScopes - st.AcceptedScopes = acceptedScopes + st.RequiredScopes = scopes.ToStringSlice(requiredScopes...) + st.AcceptedScopes = scopes.ExpandScopes(requiredScopes...) return st } diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index 270228c73..c03670818 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -162,8 +162,7 @@ func ListDiscussions(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner"}, }), }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -306,8 +305,7 @@ func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "discussionNumber"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { // Decode params var params struct { @@ -411,8 +409,7 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve Required: []string{"owner", "repo", "discussionNumber"}, }), }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { // Decode params var params struct { @@ -535,8 +532,7 @@ func ListDiscussionCategories(t translations.TranslationHelperFunc) inventory.Se Required: []string{"owner"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { diff --git a/pkg/github/gists.go b/pkg/github/gists.go index 0509220e3..0f43ebdf9 100644 --- a/pkg/github/gists.go +++ b/pkg/github/gists.go @@ -42,8 +42,7 @@ func ListGists(t translations.TranslationHelperFunc) inventory.ServerTool { }, }), }, - nil, // no required scopes - nil, // no accepted scopes + nil, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { username, err := OptionalParam[string](args, "username") if err != nil { @@ -127,8 +126,7 @@ func GetGist(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"gist_id"}, }, }, - nil, // no required scopes - nil, // no accepted scopes + nil, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { gistID, err := RequiredParam[string](args, "gist_id") if err != nil { @@ -199,8 +197,7 @@ func CreateGist(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"filename", "content"}, }, }, - scopes.ToStringSlice(scopes.Gist), - scopes.ToStringSlice(scopes.Gist), + []scopes.Scope{scopes.Gist}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { description, err := OptionalParam[string](args, "description") if err != nil { @@ -302,8 +299,7 @@ func UpdateGist(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"gist_id", "filename", "content"}, }, }, - scopes.ToStringSlice(scopes.Gist), - scopes.ToStringSlice(scopes.Gist), + []scopes.Scope{scopes.Gist}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { gistID, err := RequiredParam[string](args, "gist_id") if err != nil { diff --git a/pkg/github/git.go b/pkg/github/git.go index 8e632c1af..ec7159b9b 100644 --- a/pkg/github/git.go +++ b/pkg/github/git.go @@ -77,8 +77,7 @@ func GetRepositoryTree(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner", "repo"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 9f2223295..1e29a0eef 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -275,8 +275,7 @@ Options are: }, InputSchema: schema, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -568,8 +567,7 @@ func ListIssueTypes(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner"}, }, }, - scopes.ToStringSlice(scopes.ReadOrg), - scopes.ToStringSlice(scopes.ReadOrg, scopes.WriteOrg, scopes.AdminOrg), + []scopes.Scope{scopes.ReadOrg}, 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 { @@ -637,8 +635,7 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo", "issue_number", "body"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -743,8 +740,7 @@ Options are: Required: []string{"method", "owner", "repo", "issue_number", "sub_issue_id"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -972,8 +968,7 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool { }, InputSchema: schema, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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") return result, nil, err @@ -1063,8 +1058,7 @@ Options are: Required: []string{"method", "owner", "repo"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -1398,8 +1392,7 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { }, InputSchema: schema, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -1657,8 +1650,7 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server Required: []string{"owner", "repo", "issue_number"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { var params struct { Owner string `mapstructure:"owner"` diff --git a/pkg/github/labels.go b/pkg/github/labels.go index e85dee450..0dbb622d9 100644 --- a/pkg/github/labels.go +++ b/pkg/github/labels.go @@ -46,8 +46,7 @@ func GetLabel(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "name"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -145,8 +144,7 @@ func ListLabels(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -258,8 +256,7 @@ func LabelWrite(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"method", "owner", "repo", "name"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { // Get and validate required parameters method, err := RequiredParam[string](args, "method") diff --git a/pkg/github/notifications.go b/pkg/github/notifications.go index 87026e2cb..1de24fb0d 100644 --- a/pkg/github/notifications.go +++ b/pkg/github/notifications.go @@ -63,8 +63,7 @@ func ListNotifications(t translations.TranslationHelperFunc) inventory.ServerToo }, }), }, - scopes.ToStringSlice(scopes.Notifications), - scopes.ToStringSlice(scopes.Notifications), + []scopes.Scope{scopes.Notifications}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -190,8 +189,7 @@ func DismissNotification(t translations.TranslationHelperFunc) inventory.ServerT Required: []string{"threadID", "state"}, }, }, - scopes.ToStringSlice(scopes.Notifications), - scopes.ToStringSlice(scopes.Notifications), + []scopes.Scope{scopes.Notifications}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -275,8 +273,7 @@ func MarkAllNotificationsRead(t translations.TranslationHelperFunc) inventory.Se }, }, }, - scopes.ToStringSlice(scopes.Notifications), - scopes.ToStringSlice(scopes.Notifications), + []scopes.Scope{scopes.Notifications}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -361,8 +358,7 @@ func GetNotificationDetails(t translations.TranslationHelperFunc) inventory.Serv Required: []string{"notificationID"}, }, }, - scopes.ToStringSlice(scopes.Notifications), - scopes.ToStringSlice(scopes.Notifications), + []scopes.Scope{scopes.Notifications}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -436,8 +432,7 @@ func ManageNotificationSubscription(t translations.TranslationHelperFunc) invent Required: []string{"notificationID", "action"}, }, }, - scopes.ToStringSlice(scopes.Notifications), - scopes.ToStringSlice(scopes.Notifications), + []scopes.Scope{scopes.Notifications}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -537,8 +532,7 @@ func ManageRepositoryNotificationSubscription(t translations.TranslationHelperFu Required: []string{"owner", "repo", "action"}, }, }, - scopes.ToStringSlice(scopes.Notifications), - scopes.ToStringSlice(scopes.Notifications), + []scopes.Scope{scopes.Notifications}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 3204243f7..79cbbe680 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -68,8 +68,7 @@ func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner_type", "owner"}, }, }, - scopes.ToStringSlice(scopes.ReadProject), - scopes.ToStringSlice(scopes.ReadProject, scopes.Project), + []scopes.Scope{scopes.ReadProject}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -175,8 +174,7 @@ func GetProject(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"project_number", "owner_type", "owner"}, }, }, - scopes.ToStringSlice(scopes.ReadProject), - scopes.ToStringSlice(scopes.ReadProject, scopes.Project), + []scopes.Scope{scopes.ReadProject}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { projectNumber, err := RequiredInt(args, "project_number") @@ -277,8 +275,7 @@ func ListProjectFields(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner_type", "owner", "project_number"}, }, }, - scopes.ToStringSlice(scopes.ReadProject), - scopes.ToStringSlice(scopes.ReadProject, scopes.Project), + []scopes.Scope{scopes.ReadProject}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -377,8 +374,7 @@ func GetProjectField(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner_type", "owner", "project_number", "field_id"}, }, }, - scopes.ToStringSlice(scopes.ReadProject), - scopes.ToStringSlice(scopes.ReadProject, scopes.Project), + []scopes.Scope{scopes.ReadProject}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -490,8 +486,7 @@ func ListProjectItems(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner_type", "owner", "project_number"}, }, }, - scopes.ToStringSlice(scopes.ReadProject), - scopes.ToStringSlice(scopes.ReadProject, scopes.Project), + []scopes.Scope{scopes.ReadProject}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -616,8 +611,7 @@ func GetProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner_type", "owner", "project_number", "item_id"}, }, }, - scopes.ToStringSlice(scopes.ReadProject), - scopes.ToStringSlice(scopes.ReadProject, scopes.Project), + []scopes.Scope{scopes.ReadProject}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -722,8 +716,7 @@ func AddProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner_type", "owner", "project_number", "item_type", "item_id"}, }, }, - scopes.ToStringSlice(scopes.Project), - scopes.ToStringSlice(scopes.Project), + []scopes.Scope{scopes.Project}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -834,8 +827,7 @@ func UpdateProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner_type", "owner", "project_number", "item_id", "updated_field"}, }, }, - scopes.ToStringSlice(scopes.Project), - scopes.ToStringSlice(scopes.Project), + []scopes.Scope{scopes.Project}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") @@ -945,8 +937,7 @@ func DeleteProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner_type", "owner", "project_number", "item_id"}, }, }, - scopes.ToStringSlice(scopes.Project), - scopes.ToStringSlice(scopes.Project), + []scopes.Scope{scopes.Project}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 91145f61d..62952783e 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -70,8 +70,7 @@ Possible options: }, InputSchema: schema, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -521,8 +520,7 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo }, InputSchema: schema, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -674,8 +672,7 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo }, InputSchema: schema, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -957,8 +954,7 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool }, InputSchema: schema, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -1095,8 +1091,7 @@ func MergePullRequest(t translations.TranslationHelperFunc) inventory.ServerTool }, InputSchema: schema, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -1214,8 +1209,7 @@ func SearchPullRequests(t translations.TranslationHelperFunc) inventory.ServerTo }, InputSchema: schema, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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, "pr", "failed to search pull requests") return result, nil, err @@ -1258,8 +1252,7 @@ func UpdatePullRequestBranch(t translations.TranslationHelperFunc) inventory.Ser }, InputSchema: schema, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -1386,8 +1379,7 @@ Available methods: }, InputSchema: schema, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { var params PullRequestReviewWriteParams if err := mapstructure.Decode(args, ¶ms); err != nil { @@ -1711,8 +1703,7 @@ func AddCommentToPendingReview(t translations.TranslationHelperFunc) inventory.S }, InputSchema: schema, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { var params struct { Owner string @@ -1865,8 +1856,7 @@ func RequestCopilotReview(t translations.TranslationHelperFunc) inventory.Server }, InputSchema: schema, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index c2f976e81..633648891 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -55,8 +55,7 @@ func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "sha"}, }), }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -153,8 +152,7 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }), }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -254,8 +252,7 @@ func ListBranches(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }), }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -369,8 +366,7 @@ If the SHA is not provided, the tool will attempt to acquire it by fetching the Required: []string{"owner", "repo", "path", "content", "message", "branch"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -554,8 +550,7 @@ func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"name"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { name, err := RequiredParam[string](args, "name") if err != nil { @@ -662,8 +657,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -847,8 +841,7 @@ func ForkRepository(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -955,8 +948,7 @@ func DeleteFile(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "path", "message", "branch"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -1136,8 +1128,7 @@ func CreateBranch(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "branch"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -1268,8 +1259,7 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "branch", "files", "message"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -1477,8 +1467,7 @@ func ListTags(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }), }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -1561,8 +1550,7 @@ func GetTag(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "tag"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -1656,8 +1644,7 @@ func ListReleases(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }), }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -1732,8 +1719,7 @@ func GetLatestRelease(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -1802,8 +1788,7 @@ func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo", "tag"}, }, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []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 { @@ -1882,8 +1867,7 @@ func ListStarredRepositories(t translations.TranslationHelperFunc) inventory.Ser }, }), }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { username, err := OptionalParam[string](args, "username") if err != nil { @@ -2011,8 +1995,7 @@ func StarRepository(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }, }, - scopes.ToStringSlice(scopes.PublicRepo), - scopes.ToStringSlice(scopes.PublicRepo, scopes.Repo), + []scopes.Scope{scopes.PublicRepo}, 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 { @@ -2077,8 +2060,7 @@ func UnstarRepository(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo"}, }, }, - scopes.ToStringSlice(scopes.PublicRepo), - scopes.ToStringSlice(scopes.PublicRepo, scopes.Repo), + []scopes.Scope{scopes.PublicRepo}, 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 { diff --git a/pkg/github/search.go b/pkg/github/search.go index b662cecdd..552fbfe78 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -57,8 +57,7 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo }, InputSchema: schema, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { query, err := RequiredParam[string](args, "query") if err != nil { @@ -201,8 +200,7 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { }, InputSchema: schema, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { query, err := RequiredParam[string](args, "query") if err != nil { @@ -384,8 +382,7 @@ func SearchUsers(t translations.TranslationHelperFunc) inventory.ServerTool { }, InputSchema: schema, }, - scopes.ToStringSlice(scopes.Repo), - scopes.ToStringSlice(scopes.Repo), + []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { return userOrOrgHandler(ctx, "user", deps, args) }, @@ -427,8 +424,7 @@ func SearchOrgs(t translations.TranslationHelperFunc) inventory.ServerTool { }, InputSchema: schema, }, - scopes.ToStringSlice(scopes.ReadOrg), - scopes.ToStringSlice(scopes.ReadOrg, scopes.WriteOrg, scopes.AdminOrg), + []scopes.Scope{scopes.ReadOrg}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { return userOrOrgHandler(ctx, "org", deps, args) }, diff --git a/pkg/github/secret_scanning.go b/pkg/github/secret_scanning.go index 2f3c00877..fa60021e5 100644 --- a/pkg/github/secret_scanning.go +++ b/pkg/github/secret_scanning.go @@ -46,8 +46,7 @@ func GetSecretScanningAlert(t translations.TranslationHelperFunc) inventory.Serv Required: []string{"owner", "repo", "alertNumber"}, }, }, - scopes.ToStringSlice(scopes.SecurityEvents), - scopes.ToStringSlice(scopes.SecurityEvents, scopes.Repo), + []scopes.Scope{scopes.SecurityEvents}, 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 { @@ -134,8 +133,7 @@ func ListSecretScanningAlerts(t translations.TranslationHelperFunc) inventory.Se Required: []string{"owner", "repo"}, }, }, - scopes.ToStringSlice(scopes.SecurityEvents), - scopes.ToStringSlice(scopes.SecurityEvents, scopes.Repo), + []scopes.Scope{scopes.SecurityEvents}, 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 { diff --git a/pkg/github/security_advisories.go b/pkg/github/security_advisories.go index 3bbb0d1f7..7bdb978cd 100644 --- a/pkg/github/security_advisories.go +++ b/pkg/github/security_advisories.go @@ -84,8 +84,7 @@ func ListGlobalSecurityAdvisories(t translations.TranslationHelperFunc) inventor }, }, }, - scopes.ToStringSlice(scopes.SecurityEvents), - scopes.ToStringSlice(scopes.SecurityEvents, scopes.Repo), + []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -249,8 +248,7 @@ func ListRepositorySecurityAdvisories(t translations.TranslationHelperFunc) inve Required: []string{"owner", "repo"}, }, }, - scopes.ToStringSlice(scopes.SecurityEvents), - scopes.ToStringSlice(scopes.SecurityEvents, scopes.Repo), + []scopes.Scope{scopes.SecurityEvents}, 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 { @@ -335,8 +333,7 @@ func GetGlobalSecurityAdvisory(t translations.TranslationHelperFunc) inventory.S Required: []string{"ghsaId"}, }, }, - scopes.ToStringSlice(scopes.SecurityEvents), - scopes.ToStringSlice(scopes.SecurityEvents, scopes.Repo), + []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { client, err := deps.GetClient(ctx) if err != nil { @@ -408,8 +405,7 @@ func ListOrgRepositorySecurityAdvisories(t translations.TranslationHelperFunc) i Required: []string{"org"}, }, }, - scopes.ToStringSlice(scopes.SecurityEvents), - scopes.ToStringSlice(scopes.SecurityEvents, scopes.Repo), + []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { org, err := RequiredParam[string](args, "org") if err != nil { diff --git a/pkg/scopes/scopes.go b/pkg/scopes/scopes.go index 268a24d99..961cf0e1c 100644 --- a/pkg/scopes/scopes.go +++ b/pkg/scopes/scopes.go @@ -6,6 +6,9 @@ package scopes type Scope string const ( + // NoScope indicates no scope is required (public access). + NoScope Scope = "" + // Repo grants full control of private repositories Repo Scope = "repo" @@ -35,8 +38,35 @@ const ( // SecurityEvents grants read and write access to security events SecurityEvents Scope = "security_events" + + // User grants read/write access to profile info + User Scope = "user" + + // ReadUser grants read-only access to profile info + ReadUser Scope = "read:user" + + // UserEmail grants read access to user email addresses + UserEmail Scope = "user:email" + + // ReadPackages grants read access to packages + ReadPackages Scope = "read:packages" + + // WritePackages grants write access to packages + WritePackages Scope = "write:packages" ) +// ScopeHierarchy defines parent-child relationships between scopes. +// A parent scope implicitly grants access to all child scopes. +// For example, "repo" grants access to "public_repo" and "security_events". +var ScopeHierarchy = map[Scope][]Scope{ + Repo: {PublicRepo, SecurityEvents}, + AdminOrg: {WriteOrg, ReadOrg}, + WriteOrg: {ReadOrg}, + Project: {ReadProject}, + WritePackages: {ReadPackages}, + User: {ReadUser, UserEmail}, +} + // ScopeSet represents a set of OAuth scopes. type ScopeSet map[Scope]bool @@ -75,3 +105,36 @@ func ToStringSlice(scopes ...Scope) []string { } return result } + +// ExpandScopes takes a list of required scopes and returns all accepted scopes +// including parent scopes from the hierarchy. +// For example, if "public_repo" is required, "repo" is also accepted since +// having the "repo" scope grants access to "public_repo". +func ExpandScopes(required ...Scope) []string { + if len(required) == 0 { + return nil + } + + accepted := make(map[string]bool) + + // Add required scopes + for _, scope := range required { + accepted[string(scope)] = true + } + + // Add parent scopes that grant access to required scopes + for parent, children := range ScopeHierarchy { + for _, child := range children { + if accepted[string(child)] { + accepted[string(parent)] = true + } + } + } + + // Convert to slice + result := make([]string, 0, len(accepted)) + for scope := range accepted { + result = append(result, scope) + } + return result +} diff --git a/pkg/scopes/scopes_test.go b/pkg/scopes/scopes_test.go new file mode 100644 index 000000000..8ef29a115 --- /dev/null +++ b/pkg/scopes/scopes_test.go @@ -0,0 +1,152 @@ +package scopes + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestExpandScopes(t *testing.T) { + tests := []struct { + name string + required []Scope + expected []string + }{ + { + name: "nil returns nil", + required: nil, + expected: nil, + }, + { + name: "empty returns nil", + required: []Scope{}, + expected: nil, + }, + { + name: "repo scope returns just repo", + required: []Scope{Repo}, + expected: []string{"repo"}, + }, + { + name: "public_repo also accepts repo (parent)", + required: []Scope{PublicRepo}, + expected: []string{"public_repo", "repo"}, + }, + { + name: "security_events also accepts repo (parent)", + required: []Scope{SecurityEvents}, + expected: []string{"repo", "security_events"}, + }, + { + name: "read:org also accepts write:org and admin:org (parents)", + required: []Scope{ReadOrg}, + expected: []string{"admin:org", "read:org", "write:org"}, + }, + { + name: "write:org also accepts admin:org (parent)", + required: []Scope{WriteOrg}, + expected: []string{"admin:org", "write:org"}, + }, + { + name: "admin:org returns just admin:org (no parent)", + required: []Scope{AdminOrg}, + expected: []string{"admin:org"}, + }, + { + name: "read:project also accepts project (parent)", + required: []Scope{ReadProject}, + expected: []string{"project", "read:project"}, + }, + { + name: "project returns just project (no parent)", + required: []Scope{Project}, + expected: []string{"project"}, + }, + { + name: "gist returns just gist (no parent)", + required: []Scope{Gist}, + expected: []string{"gist"}, + }, + { + name: "notifications returns just notifications (no parent)", + required: []Scope{Notifications}, + expected: []string{"notifications"}, + }, + { + name: "read:packages also accepts write:packages (parent)", + required: []Scope{ReadPackages}, + expected: []string{"read:packages", "write:packages"}, + }, + { + name: "read:user also accepts user (parent)", + required: []Scope{ReadUser}, + expected: []string{"read:user", "user"}, + }, + { + name: "multiple scopes combine correctly", + required: []Scope{PublicRepo, ReadOrg}, + expected: []string{"admin:org", "public_repo", "read:org", "repo", "write:org"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ExpandScopes(tt.required...) + + // Sort both for consistent comparison + if result != nil { + sort.Strings(result) + } + if tt.expected != nil { + sort.Strings(tt.expected) + } + + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestToStringSlice(t *testing.T) { + tests := []struct { + name string + scopes []Scope + expected []string + }{ + { + name: "empty returns empty", + scopes: []Scope{}, + expected: []string{}, + }, + { + name: "single scope", + scopes: []Scope{Repo}, + expected: []string{"repo"}, + }, + { + name: "multiple scopes", + scopes: []Scope{Repo, Gist, ReadOrg}, + expected: []string{"repo", "gist", "read:org"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ToStringSlice(tt.scopes...) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestScopeHierarchy(t *testing.T) { + // Verify the hierarchy is correctly defined + assert.Contains(t, ScopeHierarchy[Repo], PublicRepo) + assert.Contains(t, ScopeHierarchy[Repo], SecurityEvents) + assert.Contains(t, ScopeHierarchy[AdminOrg], WriteOrg) + assert.Contains(t, ScopeHierarchy[AdminOrg], ReadOrg) + assert.Contains(t, ScopeHierarchy[WriteOrg], ReadOrg) + assert.Contains(t, ScopeHierarchy[Project], ReadProject) + assert.Contains(t, ScopeHierarchy[WritePackages], ReadPackages) + assert.Contains(t, ScopeHierarchy[User], ReadUser) + assert.Contains(t, ScopeHierarchy[User], UserEmail) +} From 3d1ae306f8021c6f47e349cd161db2c78d9202d8 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 12:51:50 +0100 Subject: [PATCH 23/70] refres readme after update --- README.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 80345056a..4aec47e6b 100644 --- a/README.md +++ b/README.md @@ -628,13 +628,13 @@ The following sets of tools are available: - **get_team_members** - Get team members - **Required OAuth Scopes**: `read:org` - - **Accepted OAuth Scopes**: `read:org`, `write:org`, `admin:org` + - **Accepted OAuth Scopes**: `read:org`, `admin:org`, `write:org` - `org`: Organization login (owner) that contains the team. (string, required) - `team_slug`: Team slug (string, required) - **get_teams** - Get teams - **Required OAuth Scopes**: `read:org` - - **Accepted OAuth Scopes**: `read:org`, `write:org`, `admin:org` + - **Accepted OAuth Scopes**: `read:org`, `admin:org`, `write:org` - `user`: Username to get teams for. If not provided, uses the authenticated user. (string, optional) @@ -798,7 +798,7 @@ The following sets of tools are available: - **list_issue_types** - List available issue types - **Required OAuth Scopes**: `read:org` - - **Accepted OAuth Scopes**: `read:org`, `write:org`, `admin:org` + - **Accepted OAuth Scopes**: `read:org`, `admin:org`, `write:org` - `owner`: The organization owner of the repository (string, required) - **list_issues** - List issues @@ -916,7 +916,7 @@ The following sets of tools are available: - **search_orgs** - Search organizations - **Required OAuth Scopes**: `read:org` - - **Accepted OAuth Scopes**: `read:org`, `write:org`, `admin:org` + - **Accepted OAuth Scopes**: `read:org`, `admin:org`, `write:org` - `order`: Sort order (string, optional) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) @@ -946,7 +946,7 @@ The following sets of tools are available: - **get_project** - Get project - **Required OAuth Scopes**: `read:project` - - **Accepted OAuth Scopes**: `read:project`, `project` + - **Accepted OAuth Scopes**: `project`, `read:project` - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - `owner_type`: Owner type (string, required) - `project_number`: The project's number (number, required) @@ -961,7 +961,7 @@ The following sets of tools are available: - **get_project_item** - Get project item - **Required OAuth Scopes**: `read:project` - - **Accepted OAuth Scopes**: `read:project`, `project` + - **Accepted OAuth Scopes**: `project`, `read:project` - `fields`: Specific list of field IDs to include in the response (e.g. ["102589", "985201", "169875"]). If not provided, only the title field is included. (string[], optional) - `item_id`: The item's ID. (number, required) - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) From df9fc6a3b3726f6bb8f6d1153c9009bb876a865e Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 13:05:19 +0100 Subject: [PATCH 24/70] Use repo scope for star/unstar tools instead of public_repo public_repo is implicit - the GitHub API handles the distinction between public and private repos. Using repo as the required scope is more consistent with our enforcement model: - PATs: tools visible if token has repo scope - OAuth: scope challenge requests repo scope --- README.md | 12 +++++------- pkg/github/repositories.go | 4 ++-- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4aec47e6b..efb9a2294 100644 --- a/README.md +++ b/README.md @@ -628,7 +628,7 @@ The following sets of tools are available: - **get_team_members** - Get team members - **Required OAuth Scopes**: `read:org` - - **Accepted OAuth Scopes**: `read:org`, `admin:org`, `write:org` + - **Accepted OAuth Scopes**: `write:org`, `read:org`, `admin:org` - `org`: Organization login (owner) that contains the team. (string, required) - `team_slug`: Team slug (string, required) @@ -946,7 +946,7 @@ The following sets of tools are available: - **get_project** - Get project - **Required OAuth Scopes**: `read:project` - - **Accepted OAuth Scopes**: `project`, `read:project` + - **Accepted OAuth Scopes**: `read:project`, `project` - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - `owner_type`: Owner type (string, required) - `project_number`: The project's number (number, required) @@ -1265,7 +1265,7 @@ The following sets of tools are available: - **get_secret_scanning_alert** - Get secret scanning alert - **Required OAuth Scopes**: `security_events` - - **Accepted OAuth Scopes**: `security_events`, `repo` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `alertNumber`: The number of the alert. (number, required) - `owner`: The owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) @@ -1337,14 +1337,12 @@ The following sets of tools are available: - `username`: Username to list starred repositories for. Defaults to the authenticated user. (string, optional) - **star_repository** - Star repository - - **Required OAuth Scopes**: `public_repo` - - **Accepted OAuth Scopes**: `public_repo`, `repo` + - **Required OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - **unstar_repository** - Unstar repository - - **Required OAuth Scopes**: `public_repo` - - **Accepted OAuth Scopes**: `public_repo`, `repo` + - **Required OAuth Scopes**: `repo` - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 633648891..388f784b5 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -1995,7 +1995,7 @@ func StarRepository(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }, }, - []scopes.Scope{scopes.PublicRepo}, + []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 { @@ -2060,7 +2060,7 @@ func UnstarRepository(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo"}, }, }, - []scopes.Scope{scopes.PublicRepo}, + []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 { From cec5a1ae0f1d5f33607d33dbaaccdb15872d2962 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 14:31:21 +0100 Subject: [PATCH 25/70] Fix conflict and regenerate docs after rebase --- README.md | 14 +++++++------- pkg/github/repositories.go | 1 - 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index efb9a2294..380729c47 100644 --- a/README.md +++ b/README.md @@ -628,7 +628,7 @@ The following sets of tools are available: - **get_team_members** - Get team members - **Required OAuth Scopes**: `read:org` - - **Accepted OAuth Scopes**: `write:org`, `read:org`, `admin:org` + - **Accepted OAuth Scopes**: `read:org`, `admin:org`, `write:org` - `org`: Organization login (owner) that contains the team. (string, required) - `team_slug`: Team slug (string, required) @@ -652,7 +652,7 @@ The following sets of tools are available: - **list_dependabot_alerts** - List dependabot alerts - **Required OAuth Scopes**: `security_events` - - **Accepted OAuth Scopes**: `security_events`, `repo` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `owner`: The owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) - `severity`: Filter dependabot alerts by severity (string, optional) @@ -961,7 +961,7 @@ The following sets of tools are available: - **get_project_item** - Get project item - **Required OAuth Scopes**: `read:project` - - **Accepted OAuth Scopes**: `project`, `read:project` + - **Accepted OAuth Scopes**: `read:project`, `project` - `fields`: Specific list of field IDs to include in the response (e.g. ["102589", "985201", "169875"]). If not provided, only the title field is included. (string[], optional) - `item_id`: The item's ID. (number, required) - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) @@ -970,7 +970,7 @@ The following sets of tools are available: - **list_project_fields** - List project fields - **Required OAuth Scopes**: `read:project` - - **Accepted OAuth Scopes**: `read:project`, `project` + - **Accepted OAuth Scopes**: `project`, `read:project` - `after`: Forward pagination cursor from previous pageInfo.nextCursor. (string, optional) - `before`: Backward pagination cursor from previous pageInfo.prevCursor (rare). (string, optional) - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) @@ -992,7 +992,7 @@ The following sets of tools are available: - **list_projects** - List projects - **Required OAuth Scopes**: `read:project` - - **Accepted OAuth Scopes**: `read:project`, `project` + - **Accepted OAuth Scopes**: `project`, `read:project` - `after`: Forward pagination cursor from previous pageInfo.nextCursor. (string, optional) - `before`: Backward pagination cursor from previous pageInfo.prevCursor (rare). (string, optional) - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) @@ -1265,7 +1265,7 @@ The following sets of tools are available: - **get_secret_scanning_alert** - Get secret scanning alert - **Required OAuth Scopes**: `security_events` - - **Accepted OAuth Scopes**: `repo`, `security_events` + - **Accepted OAuth Scopes**: `security_events`, `repo` - `alertNumber`: The number of the alert. (number, required) - `owner`: The owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) @@ -1307,7 +1307,7 @@ The following sets of tools are available: - **list_org_repository_security_advisories** - List org repository security advisories - **Required OAuth Scopes**: `security_events` - - **Accepted OAuth Scopes**: `security_events`, `repo` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `direction`: Sort direction. (string, optional) - `org`: The organization login. (string, required) - `sort`: Sort field. (string, optional) diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 388f784b5..f6203f39f 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -12,7 +12,6 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/octicons" - "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" From 48744ca5565fc3f59c648fcb23a73ca6ed59dabc Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 14:37:42 +0100 Subject: [PATCH 26/70] Sort scope slices for deterministic output Map iteration in Go is non-deterministic, which causes doc generation to produce different output on each run. Sort the scope slices in: - ScopeSet.ToSlice() - ScopeSet.ToStringSlice() - ExpandScopes() --- README.md | 32 ++++++++++++++++---------------- pkg/scopes/scopes.go | 12 +++++++++++- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 380729c47..1a0f6b1c4 100644 --- a/README.md +++ b/README.md @@ -602,14 +602,14 @@ The following sets of tools are available: - **get_code_scanning_alert** - Get code scanning alert - **Required OAuth Scopes**: `security_events` - - **Accepted OAuth Scopes**: `security_events`, `repo` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `alertNumber`: The number of the alert. (number, required) - `owner`: The owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) - **list_code_scanning_alerts** - List code scanning alerts - **Required OAuth Scopes**: `security_events` - - **Accepted OAuth Scopes**: `security_events`, `repo` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `owner`: The owner of the repository. (string, required) - `ref`: The Git reference for the results you want to list. (string, optional) - `repo`: The name of the repository. (string, required) @@ -628,13 +628,13 @@ The following sets of tools are available: - **get_team_members** - Get team members - **Required OAuth Scopes**: `read:org` - - **Accepted OAuth Scopes**: `read:org`, `admin:org`, `write:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org` - `org`: Organization login (owner) that contains the team. (string, required) - `team_slug`: Team slug (string, required) - **get_teams** - Get teams - **Required OAuth Scopes**: `read:org` - - **Accepted OAuth Scopes**: `read:org`, `admin:org`, `write:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org` - `user`: Username to get teams for. If not provided, uses the authenticated user. (string, optional) @@ -645,7 +645,7 @@ The following sets of tools are available: - **get_dependabot_alert** - Get dependabot alert - **Required OAuth Scopes**: `security_events` - - **Accepted OAuth Scopes**: `security_events`, `repo` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `alertNumber`: The number of the alert. (number, required) - `owner`: The owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) @@ -798,7 +798,7 @@ The following sets of tools are available: - **list_issue_types** - List available issue types - **Required OAuth Scopes**: `read:org` - - **Accepted OAuth Scopes**: `read:org`, `admin:org`, `write:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org` - `owner`: The organization owner of the repository (string, required) - **list_issues** - List issues @@ -916,7 +916,7 @@ The following sets of tools are available: - **search_orgs** - Search organizations - **Required OAuth Scopes**: `read:org` - - **Accepted OAuth Scopes**: `read:org`, `admin:org`, `write:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org` - `order`: Sort order (string, optional) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) @@ -946,14 +946,14 @@ The following sets of tools are available: - **get_project** - Get project - **Required OAuth Scopes**: `read:project` - - **Accepted OAuth Scopes**: `read:project`, `project` + - **Accepted OAuth Scopes**: `project`, `read:project` - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - `owner_type`: Owner type (string, required) - `project_number`: The project's number (number, required) - **get_project_field** - Get project field - **Required OAuth Scopes**: `read:project` - - **Accepted OAuth Scopes**: `read:project`, `project` + - **Accepted OAuth Scopes**: `project`, `read:project` - `field_id`: The field's id. (number, required) - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - `owner_type`: Owner type (string, required) @@ -961,7 +961,7 @@ The following sets of tools are available: - **get_project_item** - Get project item - **Required OAuth Scopes**: `read:project` - - **Accepted OAuth Scopes**: `read:project`, `project` + - **Accepted OAuth Scopes**: `project`, `read:project` - `fields`: Specific list of field IDs to include in the response (e.g. ["102589", "985201", "169875"]). If not provided, only the title field is included. (string[], optional) - `item_id`: The item's ID. (number, required) - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) @@ -980,7 +980,7 @@ The following sets of tools are available: - **list_project_items** - List project items - **Required OAuth Scopes**: `read:project` - - **Accepted OAuth Scopes**: `read:project`, `project` + - **Accepted OAuth Scopes**: `project`, `read:project` - `after`: Forward pagination cursor from previous pageInfo.nextCursor. (string, optional) - `before`: Backward pagination cursor from previous pageInfo.prevCursor (rare). (string, optional) - `fields`: Field IDs to include (e.g. ["102589", "985201"]). CRITICAL: Always provide to get field values. Without this, only titles returned. (string[], optional) @@ -1265,14 +1265,14 @@ The following sets of tools are available: - **get_secret_scanning_alert** - Get secret scanning alert - **Required OAuth Scopes**: `security_events` - - **Accepted OAuth Scopes**: `security_events`, `repo` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `alertNumber`: The number of the alert. (number, required) - `owner`: The owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) - **list_secret_scanning_alerts** - List secret scanning alerts - **Required OAuth Scopes**: `security_events` - - **Accepted OAuth Scopes**: `security_events`, `repo` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `owner`: The owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) - `resolution`: Filter by resolution (string, optional) @@ -1287,12 +1287,12 @@ The following sets of tools are available: - **get_global_security_advisory** - Get a global security advisory - **Required OAuth Scopes**: `security_events` - - **Accepted OAuth Scopes**: `security_events`, `repo` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `ghsaId`: GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx). (string, required) - **list_global_security_advisories** - List global security advisories - **Required OAuth Scopes**: `security_events` - - **Accepted OAuth Scopes**: `security_events`, `repo` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `affects`: Filter advisories by affected package or version (e.g. "package1,package2@1.0.0"). (string, optional) - `cveId`: Filter by CVE ID. (string, optional) - `cwes`: Filter by Common Weakness Enumeration IDs (e.g. ["79", "284", "22"]). (string[], optional) @@ -1315,7 +1315,7 @@ The following sets of tools are available: - **list_repository_security_advisories** - List repository security advisories - **Required OAuth Scopes**: `security_events` - - **Accepted OAuth Scopes**: `security_events`, `repo` + - **Accepted OAuth Scopes**: `repo`, `security_events` - `direction`: Sort direction. (string, optional) - `owner`: The owner of the repository. (string, required) - `repo`: The name of the repository. (string, required) diff --git a/pkg/scopes/scopes.go b/pkg/scopes/scopes.go index 961cf0e1c..0be6ca32b 100644 --- a/pkg/scopes/scopes.go +++ b/pkg/scopes/scopes.go @@ -1,5 +1,7 @@ package scopes +import "sort" + // Scope represents a GitHub OAuth scope. // These constants define all OAuth scopes used by the GitHub MCP server tools. // See https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps @@ -85,15 +87,21 @@ func (s ScopeSet) ToSlice() []Scope { for scope := range s { scopes = append(scopes, scope) } + // Sort for deterministic output + sort.Slice(scopes, func(i, j int) bool { + return scopes[i] < scopes[j] + }) return scopes } // ToStringSlice converts a ScopeSet to a slice of string values. +// The returned slice is sorted for deterministic output. func (s ScopeSet) ToStringSlice() []string { scopes := make([]string, 0, len(s)) for scope := range s { scopes = append(scopes, string(scope)) } + sort.Strings(scopes) return scopes } @@ -110,6 +118,7 @@ func ToStringSlice(scopes ...Scope) []string { // including parent scopes from the hierarchy. // For example, if "public_repo" is required, "repo" is also accepted since // having the "repo" scope grants access to "public_repo". +// The returned slice is sorted for deterministic output. func ExpandScopes(required ...Scope) []string { if len(required) == 0 { return nil @@ -131,10 +140,11 @@ func ExpandScopes(required ...Scope) []string { } } - // Convert to slice + // Convert to slice and sort for deterministic output result := make([]string, 0, len(accepted)) for scope := range accepted { result = append(result, scope) } + sort.Strings(result) return result } From 46b8cb63accb763c1cff7e264a1e6d1cadf34b70 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 13:55:28 +0100 Subject: [PATCH 27/70] Add PAT scope filtering for stdio server Add the ability to filter tools based on token scopes for PAT users. This uses an HTTP HEAD request to GitHub's API to discover token scopes. New components: - pkg/scopes/filter.go: HasRequiredScopes checks if scopes satisfy tool requirements - pkg/scopes/fetcher.go: FetchTokenScopes gets scopes via HTTP HEAD to GitHub API - pkg/github/scope_filter.go: CreateScopeFilter creates inventory.ToolFilter Integration: - Add --filter-by-scope flag to stdio command (disabled by default) - When enabled, fetches token scopes on startup - Tools requiring unavailable scopes are hidden from tool list - Gracefully continues without filtering if scope fetch fails (logs warning) This allows the OSS server to have similar scope-based tool visibility as the remote server, and the filter logic can be reused by remote server. --- cmd/github-mcp-server/main.go | 3 + internal/ghmcp/server.go | 53 +++++++- pkg/github/scope_filter.go | 36 ++++++ pkg/github/scope_filter_test.go | 162 ++++++++++++++++++++++++ pkg/scopes/fetcher.go | 125 +++++++++++++++++++ pkg/scopes/fetcher_test.go | 214 ++++++++++++++++++++++++++++++++ pkg/scopes/filter.go | 9 ++ pkg/scopes/scopes.go | 44 +++++++ pkg/scopes/scopes_test.go | 180 +++++++++++++++++++++++++++ 9 files changed, 822 insertions(+), 4 deletions(-) create mode 100644 pkg/github/scope_filter.go create mode 100644 pkg/github/scope_filter_test.go create mode 100644 pkg/scopes/fetcher.go create mode 100644 pkg/scopes/fetcher_test.go create mode 100644 pkg/scopes/filter.go diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index cfb68be4e..f5bf16a1a 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -84,6 +84,7 @@ var ( ContentWindowSize: viper.GetInt("content-window-size"), LockdownMode: viper.GetBool("lockdown-mode"), RepoAccessCacheTTL: &ttl, + EnableScopeFiltering: viper.GetBool("enable-scope-filtering"), } return ghmcp.RunStdioServer(stdioServerConfig) }, @@ -109,6 +110,7 @@ func init() { rootCmd.PersistentFlags().Int("content-window-size", 5000, "Specify the content window size") rootCmd.PersistentFlags().Bool("lockdown-mode", false, "Enable lockdown mode") rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)") + rootCmd.PersistentFlags().Bool("enable-scope-filtering", false, "Filter tools based on the token's OAuth scopes") // Bind flag to viper _ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets")) @@ -123,6 +125,7 @@ func init() { _ = viper.BindPFlag("content-window-size", rootCmd.PersistentFlags().Lookup("content-window-size")) _ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode")) _ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl")) + _ = viper.BindPFlag("enable-scope-filtering", rootCmd.PersistentFlags().Lookup("enable-scope-filtering")) // Add subcommands rootCmd.AddCommand(stdioCmd) diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 9859e2e9b..7da2f825f 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -19,6 +19,7 @@ import ( "github.com/github/github-mcp-server/pkg/lockdown" mcplog "github.com/github/github-mcp-server/pkg/log" "github.com/github/github-mcp-server/pkg/raw" + "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" gogithub "github.com/google/go-github/v79/github" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -67,6 +68,11 @@ type MCPServerConfig struct { Logger *slog.Logger // RepoAccessTTL overrides the default TTL for repository access cache entries. RepoAccessTTL *time.Duration + + // TokenScopes contains the OAuth scopes available to the token. + // When non-nil, tools requiring scopes not in this list will be hidden. + // This is used for PAT scope filtering where we can't issue scope challenges. + TokenScopes []string } // githubClients holds all the GitHub API clients created for a server instance. @@ -211,13 +217,19 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { }) // Build and register the tool/resource/prompt inventory - inventory := github.NewInventory(cfg.Translator). + inventoryBuilder := github.NewInventory(cfg.Translator). WithDeprecatedAliases(github.DeprecatedToolAliases). WithReadOnly(cfg.ReadOnly). WithToolsets(enabledToolsets). WithTools(github.CleanTools(cfg.EnabledTools)). - WithFeatureChecker(createFeatureChecker(cfg.EnabledFeatures)). - Build() + WithFeatureChecker(createFeatureChecker(cfg.EnabledFeatures)) + + // Apply token scope filtering if scopes are known (for PAT filtering) + if cfg.TokenScopes != nil { + inventoryBuilder = inventoryBuilder.WithFilter(github.CreateToolScopeFilter(cfg.TokenScopes)) + } + + inventory := inventoryBuilder.Build() if unrecognized := inventory.UnrecognizedToolsets(); len(unrecognized) > 0 { fmt.Fprintf(os.Stderr, "Warning: unrecognized toolsets ignored: %s\n", strings.Join(unrecognized, ", ")) @@ -312,6 +324,11 @@ type StdioServerConfig struct { // RepoAccessCacheTTL overrides the default TTL for repository access cache entries. RepoAccessCacheTTL *time.Duration + + // EnableScopeFiltering enables PAT scope-based tool filtering. + // When true, the server will fetch the token's OAuth scopes at startup + // and hide tools that require scopes the token doesn't have. + EnableScopeFiltering bool } // RunStdioServer is not concurrent safe. @@ -336,7 +353,19 @@ func RunStdioServer(cfg StdioServerConfig) error { slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelInfo}) } logger := slog.New(slogHandler) - logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode) + logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode, "scopeFiltering", cfg.EnableScopeFiltering) + + // Fetch token scopes if scope filtering is enabled + var tokenScopes []string + if cfg.EnableScopeFiltering { + fetchedScopes, err := fetchTokenScopesForHost(ctx, cfg.Token, cfg.Host) + if err != nil { + logger.Warn("failed to fetch token scopes, continuing without scope filtering", "error", err) + } else { + tokenScopes = fetchedScopes + logger.Info("token scopes fetched for filtering", "scopes", tokenScopes) + } + } ghServer, err := NewMCPServer(MCPServerConfig{ Version: cfg.Version, @@ -352,6 +381,7 @@ func RunStdioServer(cfg StdioServerConfig) error { LockdownMode: cfg.LockdownMode, Logger: logger, RepoAccessTTL: cfg.RepoAccessCacheTTL, + TokenScopes: tokenScopes, }) if err != nil { return fmt.Errorf("failed to create MCP server: %w", err) @@ -636,3 +666,18 @@ func addUserAgentsMiddleware(cfg MCPServerConfig, restClient *gogithub.Client, g } } } + +// fetchTokenScopesForHost fetches the OAuth scopes for a token from the GitHub API. +// It constructs the appropriate API host URL based on the configured host. +func fetchTokenScopesForHost(ctx context.Context, token, host string) ([]string, error) { + apiHost, err := parseAPIHost(host) + if err != nil { + return nil, fmt.Errorf("failed to parse API host: %w", err) + } + + fetcher := scopes.NewFetcher(scopes.FetcherOptions{ + APIHost: apiHost.baseRESTURL.String(), + }) + + return fetcher.FetchTokenScopes(ctx, token) +} diff --git a/pkg/github/scope_filter.go b/pkg/github/scope_filter.go new file mode 100644 index 000000000..b1aa77c85 --- /dev/null +++ b/pkg/github/scope_filter.go @@ -0,0 +1,36 @@ +package github + +import ( + "context" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" +) + +// CreateToolScopeFilter creates an inventory.ToolFilter that filters tools +// based on the token's OAuth scopes. +// +// For PATs (Personal Access Tokens), we cannot issue OAuth scope challenges +// like we can with OAuth apps. Instead, we hide tools that require scopes +// the token doesn't have. +// +// This is the recommended way to filter tools for stdio servers where the +// token is known at startup and won't change during the session. +// +// The filter returns true (include tool) if: +// - The tool has no scope requirements (AcceptedScopes is empty) +// - The token has at least one of the tool's accepted scopes +// +// Example usage: +// +// tokenScopes, err := scopes.FetchTokenScopes(ctx, token) +// if err != nil { +// // Handle error - maybe skip filtering +// } +// filter := github.CreateToolScopeFilter(tokenScopes) +// inventory := github.NewInventory(t).WithFilter(filter).Build() +func CreateToolScopeFilter(tokenScopes []string) inventory.ToolFilter { + return func(_ context.Context, tool *inventory.ServerTool) (bool, error) { + return scopes.HasRequiredScopes(tokenScopes, tool.AcceptedScopes), nil + } +} diff --git a/pkg/github/scope_filter_test.go b/pkg/github/scope_filter_test.go new file mode 100644 index 000000000..48eb52aa0 --- /dev/null +++ b/pkg/github/scope_filter_test.go @@ -0,0 +1,162 @@ +package github + +import ( + "context" + "testing" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateToolScopeFilter(t *testing.T) { + // Create test tools with various scope requirements + toolNoScopes := &inventory.ServerTool{ + Tool: mcp.Tool{Name: "no_scopes_tool"}, + AcceptedScopes: nil, + } + + toolEmptyScopes := &inventory.ServerTool{ + Tool: mcp.Tool{Name: "empty_scopes_tool"}, + AcceptedScopes: []string{}, + } + + toolRepoScope := &inventory.ServerTool{ + Tool: mcp.Tool{Name: "repo_tool"}, + AcceptedScopes: []string{"repo"}, + } + + toolPublicRepoScope := &inventory.ServerTool{ + Tool: mcp.Tool{Name: "public_repo_tool"}, + AcceptedScopes: []string{"public_repo", "repo"}, // repo is parent, also accepted + } + + toolGistScope := &inventory.ServerTool{ + Tool: mcp.Tool{Name: "gist_tool"}, + AcceptedScopes: []string{"gist"}, + } + + toolMultiScope := &inventory.ServerTool{ + Tool: mcp.Tool{Name: "multi_scope_tool"}, + AcceptedScopes: []string{"repo", "admin:org"}, + } + + tests := []struct { + name string + tokenScopes []string + tool *inventory.ServerTool + expected bool + }{ + { + name: "tool with no scopes is always visible", + tokenScopes: []string{}, + tool: toolNoScopes, + expected: true, + }, + { + name: "tool with empty scopes is always visible", + tokenScopes: []string{"repo"}, + tool: toolEmptyScopes, + expected: true, + }, + { + name: "token with exact scope can see tool", + tokenScopes: []string{"repo"}, + tool: toolRepoScope, + expected: true, + }, + { + name: "token with parent scope can see child-scoped tool", + tokenScopes: []string{"repo"}, + tool: toolPublicRepoScope, + expected: true, + }, + { + name: "token missing required scope cannot see tool", + tokenScopes: []string{"gist"}, + tool: toolRepoScope, + expected: false, + }, + { + name: "token with unrelated scope cannot see tool", + tokenScopes: []string{"repo"}, + tool: toolGistScope, + expected: false, + }, + { + name: "token with one of multiple accepted scopes can see tool", + tokenScopes: []string{"admin:org"}, + tool: toolMultiScope, + expected: true, + }, + { + name: "empty token scopes cannot see scoped tools", + tokenScopes: []string{}, + tool: toolRepoScope, + expected: false, + }, + { + name: "token with multiple scopes where one matches", + tokenScopes: []string{"gist", "repo"}, + tool: toolPublicRepoScope, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filter := CreateToolScopeFilter(tt.tokenScopes) + result, err := filter(context.Background(), tt.tool) + + require.NoError(t, err) + assert.Equal(t, tt.expected, result, "filter result should match expected") + }) + } +} + +func TestCreateToolScopeFilter_Integration(t *testing.T) { + // Test integration with inventory builder + tools := []inventory.ServerTool{ + { + Tool: mcp.Tool{Name: "public_tool"}, + Toolset: inventory.ToolsetMetadata{ID: "test"}, + AcceptedScopes: nil, // No scopes required + }, + { + Tool: mcp.Tool{Name: "repo_tool"}, + Toolset: inventory.ToolsetMetadata{ID: "test"}, + AcceptedScopes: []string{"repo"}, + }, + { + Tool: mcp.Tool{Name: "gist_tool"}, + Toolset: inventory.ToolsetMetadata{ID: "test"}, + AcceptedScopes: []string{"gist"}, + }, + } + + // Create filter for token with only "repo" scope + filter := CreateToolScopeFilter([]string{"repo"}) + + // Build inventory with the filter + inv := inventory.NewBuilder(). + SetTools(tools). + WithToolsets([]string{"test"}). + WithFilter(filter). + Build() + + // Get available tools + availableTools := inv.AvailableTools(context.Background()) + + // Should see public_tool and repo_tool, but not gist_tool + assert.Len(t, availableTools, 2) + + toolNames := make([]string, len(availableTools)) + for i, tool := range availableTools { + toolNames[i] = tool.Tool.Name + } + + assert.Contains(t, toolNames, "public_tool") + assert.Contains(t, toolNames, "repo_tool") + assert.NotContains(t, toolNames, "gist_tool") +} diff --git a/pkg/scopes/fetcher.go b/pkg/scopes/fetcher.go new file mode 100644 index 000000000..48e000179 --- /dev/null +++ b/pkg/scopes/fetcher.go @@ -0,0 +1,125 @@ +package scopes + +import ( + "context" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +// OAuthScopesHeader is the HTTP response header containing the token's OAuth scopes. +const OAuthScopesHeader = "X-OAuth-Scopes" + +// DefaultFetchTimeout is the default timeout for scope fetching requests. +const DefaultFetchTimeout = 10 * time.Second + +// FetcherOptions configures the scope fetcher. +type FetcherOptions struct { + // HTTPClient is the HTTP client to use for requests. + // If nil, a default client with DefaultFetchTimeout is used. + HTTPClient *http.Client + + // APIHost is the GitHub API host (e.g., "https://api.github.com"). + // Defaults to "https://api.github.com" if empty. + APIHost string +} + +// Fetcher retrieves token scopes from GitHub's API. +// It uses an HTTP HEAD request to minimize bandwidth since we only need headers. +type Fetcher struct { + client *http.Client + apiHost string +} + +// NewFetcher creates a new scope fetcher with the given options. +func NewFetcher(opts FetcherOptions) *Fetcher { + client := opts.HTTPClient + if client == nil { + client = &http.Client{Timeout: DefaultFetchTimeout} + } + + apiHost := opts.APIHost + if apiHost == "" { + apiHost = "https://api.github.com" + } + + return &Fetcher{ + client: client, + apiHost: apiHost, + } +} + +// FetchTokenScopes retrieves the OAuth scopes for a token by making an HTTP HEAD +// request to the GitHub API and parsing the X-OAuth-Scopes header. +// +// Returns: +// - []string: List of scopes (empty if no scopes or fine-grained PAT) +// - error: Any HTTP or parsing error +// +// Note: Fine-grained PATs don't return the X-OAuth-Scopes header, so an empty +// slice is returned for those tokens. +func (f *Fetcher) FetchTokenScopes(ctx context.Context, token string) ([]string, error) { + // Use a lightweight endpoint that requires authentication + endpoint, err := url.JoinPath(f.apiHost, "/") + if err != nil { + return nil, fmt.Errorf("failed to construct API URL: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodHead, endpoint, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + resp, err := f.client.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to fetch scopes: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + return nil, fmt.Errorf("invalid or expired token") + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + return ParseScopeHeader(resp.Header.Get(OAuthScopesHeader)), nil +} + +// ParseScopeHeader parses the X-OAuth-Scopes header value into a list of scopes. +// The header contains comma-separated scope names. +// Returns an empty slice for empty or missing header. +func ParseScopeHeader(header string) []string { + if header == "" { + return []string{} + } + + parts := strings.Split(header, ",") + scopes := make([]string, 0, len(parts)) + for _, part := range parts { + scope := strings.TrimSpace(part) + if scope != "" { + scopes = append(scopes, scope) + } + } + return scopes +} + +// FetchTokenScopes is a convenience function that creates a default fetcher +// and fetches the token scopes. +func FetchTokenScopes(ctx context.Context, token string) ([]string, error) { + return NewFetcher(FetcherOptions{}).FetchTokenScopes(ctx, token) +} + +// FetchTokenScopesWithHost is a convenience function that creates a fetcher +// for a specific API host and fetches the token scopes. +func FetchTokenScopesWithHost(ctx context.Context, token, apiHost string) ([]string, error) { + return NewFetcher(FetcherOptions{APIHost: apiHost}).FetchTokenScopes(ctx, token) +} diff --git a/pkg/scopes/fetcher_test.go b/pkg/scopes/fetcher_test.go new file mode 100644 index 000000000..13feab5b0 --- /dev/null +++ b/pkg/scopes/fetcher_test.go @@ -0,0 +1,214 @@ +package scopes + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseScopeHeader(t *testing.T) { + tests := []struct { + name string + header string + expected []string + }{ + { + name: "empty header", + header: "", + expected: []string{}, + }, + { + name: "single scope", + header: "repo", + expected: []string{"repo"}, + }, + { + name: "multiple scopes", + header: "repo, user, gist", + expected: []string{"repo", "user", "gist"}, + }, + { + name: "scopes with extra whitespace", + header: " repo , user , gist ", + expected: []string{"repo", "user", "gist"}, + }, + { + name: "scopes without spaces", + header: "repo,user,gist", + expected: []string{"repo", "user", "gist"}, + }, + { + name: "scopes with colons", + header: "read:org, write:org, admin:org", + expected: []string{"read:org", "write:org", "admin:org"}, + }, + { + name: "empty parts are filtered", + header: "repo,,gist", + expected: []string{"repo", "gist"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ParseScopeHeader(tt.header) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestFetcher_FetchTokenScopes(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + expectedScopes []string + expectError bool + errorContains string + }{ + { + name: "successful fetch with multiple scopes", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-OAuth-Scopes", "repo, user, gist") + w.WriteHeader(http.StatusOK) + }, + expectedScopes: []string{"repo", "user", "gist"}, + expectError: false, + }, + { + name: "successful fetch with single scope", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("X-OAuth-Scopes", "repo") + w.WriteHeader(http.StatusOK) + }, + expectedScopes: []string{"repo"}, + expectError: false, + }, + { + name: "fine-grained PAT returns empty scopes", + handler: func(w http.ResponseWriter, _ *http.Request) { + // Fine-grained PATs don't return X-OAuth-Scopes + w.WriteHeader(http.StatusOK) + }, + expectedScopes: []string{}, + expectError: false, + }, + { + name: "unauthorized token", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnauthorized) + }, + expectError: true, + errorContains: "invalid or expired token", + }, + { + name: "server error", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }, + expectError: true, + errorContains: "unexpected status code: 500", + }, + { + name: "verifies authorization header is set", + handler: func(w http.ResponseWriter, r *http.Request) { + authHeader := r.Header.Get("Authorization") + if authHeader != "Bearer test-token" { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("X-OAuth-Scopes", "repo") + w.WriteHeader(http.StatusOK) + }, + expectedScopes: []string{"repo"}, + expectError: false, + }, + { + name: "verifies request method is HEAD", + handler: func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodHead { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + w.Header().Set("X-OAuth-Scopes", "repo") + w.WriteHeader(http.StatusOK) + }, + expectedScopes: []string{"repo"}, + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(tt.handler) + defer server.Close() + + fetcher := NewFetcher(FetcherOptions{ + APIHost: server.URL, + }) + + scopes, err := fetcher.FetchTokenScopes(context.Background(), "test-token") + + if tt.expectError { + require.Error(t, err) + if tt.errorContains != "" { + assert.Contains(t, err.Error(), tt.errorContains) + } + } else { + require.NoError(t, err) + assert.Equal(t, tt.expectedScopes, scopes) + } + }) + } +} + +func TestFetcher_DefaultOptions(t *testing.T) { + fetcher := NewFetcher(FetcherOptions{}) + + // Verify default API host is set + assert.Equal(t, "https://api.github.com", fetcher.apiHost) + + // Verify default HTTP client is set with timeout + assert.NotNil(t, fetcher.client) + assert.Equal(t, DefaultFetchTimeout, fetcher.client.Timeout) +} + +func TestFetcher_CustomHTTPClient(t *testing.T) { + customClient := &http.Client{Timeout: 5 * time.Second} + + fetcher := NewFetcher(FetcherOptions{ + HTTPClient: customClient, + }) + + assert.Equal(t, customClient, fetcher.client) +} + +func TestFetcher_CustomAPIHost(t *testing.T) { + fetcher := NewFetcher(FetcherOptions{ + APIHost: "https://api.github.enterprise.com", + }) + + assert.Equal(t, "https://api.github.enterprise.com", fetcher.apiHost) +} + +func TestFetcher_ContextCancellation(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + time.Sleep(100 * time.Millisecond) + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + fetcher := NewFetcher(FetcherOptions{ + APIHost: server.URL, + }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + _, err := fetcher.FetchTokenScopes(ctx, "test-token") + require.Error(t, err) +} diff --git a/pkg/scopes/filter.go b/pkg/scopes/filter.go new file mode 100644 index 000000000..143b736e2 --- /dev/null +++ b/pkg/scopes/filter.go @@ -0,0 +1,9 @@ +// Package scopes provides OAuth scope checking utilities for GitHub MCP Server. +// +// This file contains utilities for filtering tools based on token scopes. +// For PATs, we cannot issue OAuth scope challenges, so we hide tools that +// require scopes the token doesn't have. +// +// The CreateToolScopeFilter function should be called from the github package +// or other packages that can import inventory to create the actual filter. +package scopes diff --git a/pkg/scopes/scopes.go b/pkg/scopes/scopes.go index 0be6ca32b..a9b06e988 100644 --- a/pkg/scopes/scopes.go +++ b/pkg/scopes/scopes.go @@ -148,3 +148,47 @@ func ExpandScopes(required ...Scope) []string { sort.Strings(result) return result } + +// expandScopeSet returns a set of all scopes granted by the given scopes, +// including child scopes from the hierarchy. +// For example, if "repo" is provided, the result includes "repo", "public_repo", +// and "security_events" since "repo" grants access to those child scopes. +func expandScopeSet(scopes []string) map[string]bool { + expanded := make(map[string]bool, len(scopes)) + for _, scope := range scopes { + expanded[scope] = true + // Add child scopes granted by this scope + if children, ok := ScopeHierarchy[Scope(scope)]; ok { + for _, child := range children { + expanded[string(child)] = true + } + } + } + return expanded +} + +// HasRequiredScopes checks if tokenScopes satisfy the acceptedScopes requirement. +// A tool's acceptedScopes includes both the required scopes AND parent scopes +// that implicitly grant the required permissions (via ExpandScopes). +// +// For PAT filtering: if ANY of the acceptedScopes are granted by the token +// (directly or via scope hierarchy), the tool should be visible. +// +// Returns true if the tool should be visible to the token holder. +func HasRequiredScopes(tokenScopes []string, acceptedScopes []string) bool { + // No scopes required = always allowed + if len(acceptedScopes) == 0 { + return true + } + + // Expand token scopes to include child scopes they grant + grantedScopes := expandScopeSet(tokenScopes) + + // Check if any accepted scope is granted by the token + for _, accepted := range acceptedScopes { + if grantedScopes[accepted] { + return true + } + } + return false +} diff --git a/pkg/scopes/scopes_test.go b/pkg/scopes/scopes_test.go index 8ef29a115..b8e0d8e42 100644 --- a/pkg/scopes/scopes_test.go +++ b/pkg/scopes/scopes_test.go @@ -150,3 +150,183 @@ func TestScopeHierarchy(t *testing.T) { assert.Contains(t, ScopeHierarchy[User], ReadUser) assert.Contains(t, ScopeHierarchy[User], UserEmail) } + +func TestExpandScopeSet(t *testing.T) { + tests := []struct { + name string + scopes []string + expected map[string]bool + }{ + { + name: "empty scopes", + scopes: []string{}, + expected: map[string]bool{}, + }, + { + name: "repo expands to include public_repo and security_events", + scopes: []string{"repo"}, + expected: map[string]bool{ + "repo": true, + "public_repo": true, + "security_events": true, + }, + }, + { + name: "admin:org expands to include write:org and read:org", + scopes: []string{"admin:org"}, + expected: map[string]bool{ + "admin:org": true, + "write:org": true, + "read:org": true, + }, + }, + { + name: "write:org expands to include read:org", + scopes: []string{"write:org"}, + expected: map[string]bool{ + "write:org": true, + "read:org": true, + }, + }, + { + name: "user expands to include read:user and user:email", + scopes: []string{"user"}, + expected: map[string]bool{ + "user": true, + "read:user": true, + "user:email": true, + }, + }, + { + name: "scope without children stays as-is", + scopes: []string{"gist"}, + expected: map[string]bool{ + "gist": true, + }, + }, + { + name: "multiple scopes combine correctly", + scopes: []string{"repo", "gist"}, + expected: map[string]bool{ + "repo": true, + "public_repo": true, + "security_events": true, + "gist": true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := expandScopeSet(tt.scopes) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestHasRequiredScopes(t *testing.T) { + tests := []struct { + name string + tokenScopes []string + acceptedScopes []string + expected bool + }{ + { + name: "no accepted scopes - always allowed", + tokenScopes: []string{}, + acceptedScopes: []string{}, + expected: true, + }, + { + name: "nil accepted scopes - always allowed", + tokenScopes: []string{"repo"}, + acceptedScopes: nil, + expected: true, + }, + { + name: "token has exact required scope", + tokenScopes: []string{"repo"}, + acceptedScopes: []string{"repo"}, + expected: true, + }, + { + name: "token has parent scope that grants access", + tokenScopes: []string{"repo"}, + acceptedScopes: []string{"public_repo"}, + expected: true, + }, + { + name: "token has parent scope for security_events", + tokenScopes: []string{"repo"}, + acceptedScopes: []string{"security_events"}, + expected: true, + }, + { + name: "token has admin:org which grants read:org", + tokenScopes: []string{"admin:org"}, + acceptedScopes: []string{"read:org"}, + expected: true, + }, + { + name: "token has write:org which grants read:org", + tokenScopes: []string{"write:org"}, + acceptedScopes: []string{"read:org"}, + expected: true, + }, + { + name: "token missing required scope", + tokenScopes: []string{"gist"}, + acceptedScopes: []string{"repo"}, + expected: false, + }, + { + name: "token has child but not parent - fails", + tokenScopes: []string{"public_repo"}, + acceptedScopes: []string{"repo"}, + expected: false, + }, + { + name: "multiple token scopes - one matches", + tokenScopes: []string{"gist", "repo"}, + acceptedScopes: []string{"public_repo"}, + expected: true, + }, + { + name: "multiple accepted scopes - token has one", + tokenScopes: []string{"repo"}, + acceptedScopes: []string{"repo", "admin:org"}, + expected: true, + }, + { + name: "empty token scopes - fails when scopes required", + tokenScopes: []string{}, + acceptedScopes: []string{"repo"}, + expected: false, + }, + { + name: "user scope grants read:user", + tokenScopes: []string{"user"}, + acceptedScopes: []string{"read:user"}, + expected: true, + }, + { + name: "user scope grants user:email", + tokenScopes: []string{"user"}, + acceptedScopes: []string{"user:email"}, + expected: true, + }, + { + name: "write:packages grants read:packages", + tokenScopes: []string{"write:packages"}, + acceptedScopes: []string{"read:packages"}, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := HasRequiredScopes(tt.tokenScopes, tt.acceptedScopes) + assert.Equal(t, tt.expected, result) + }) + } +} From a19a159d0a872098b3b985fd3a9df096d2d818fe Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 14:02:31 +0100 Subject: [PATCH 28/70] Enable scope filtering by default --- cmd/github-mcp-server/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index f5bf16a1a..528dd976a 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -110,7 +110,7 @@ func init() { rootCmd.PersistentFlags().Int("content-window-size", 5000, "Specify the content window size") rootCmd.PersistentFlags().Bool("lockdown-mode", false, "Enable lockdown mode") rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)") - rootCmd.PersistentFlags().Bool("enable-scope-filtering", false, "Filter tools based on the token's OAuth scopes") + rootCmd.PersistentFlags().Bool("enable-scope-filtering", true, "Filter tools based on the token's OAuth scopes") // Bind flag to viper _ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets")) From f45b94a8f5bd5dcd6b9414f3caf5cf2b54f9c36d Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 14:10:35 +0100 Subject: [PATCH 29/70] Make scope filtering always enabled (remove flag) Scope filtering is now a built-in feature rather than a configurable option. The server automatically fetches token scopes at startup and filters tools accordingly. If scope detection fails, it logs a warning and continues with all tools available. --- cmd/github-mcp-server/main.go | 3 -- docs/scope-filtering.md | 89 +++++++++++++++++++++++++++++++++++ docs/server-configuration.md | 11 +++++ internal/ghmcp/server.go | 23 ++++----- 4 files changed, 108 insertions(+), 18 deletions(-) create mode 100644 docs/scope-filtering.md diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 528dd976a..cfb68be4e 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -84,7 +84,6 @@ var ( ContentWindowSize: viper.GetInt("content-window-size"), LockdownMode: viper.GetBool("lockdown-mode"), RepoAccessCacheTTL: &ttl, - EnableScopeFiltering: viper.GetBool("enable-scope-filtering"), } return ghmcp.RunStdioServer(stdioServerConfig) }, @@ -110,7 +109,6 @@ func init() { rootCmd.PersistentFlags().Int("content-window-size", 5000, "Specify the content window size") rootCmd.PersistentFlags().Bool("lockdown-mode", false, "Enable lockdown mode") rootCmd.PersistentFlags().Duration("repo-access-cache-ttl", 5*time.Minute, "Override the repo access cache TTL (e.g. 1m, 0s to disable)") - rootCmd.PersistentFlags().Bool("enable-scope-filtering", true, "Filter tools based on the token's OAuth scopes") // Bind flag to viper _ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets")) @@ -125,7 +123,6 @@ func init() { _ = viper.BindPFlag("content-window-size", rootCmd.PersistentFlags().Lookup("content-window-size")) _ = viper.BindPFlag("lockdown-mode", rootCmd.PersistentFlags().Lookup("lockdown-mode")) _ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl")) - _ = viper.BindPFlag("enable-scope-filtering", rootCmd.PersistentFlags().Lookup("enable-scope-filtering")) // Add subcommands rootCmd.AddCommand(stdioCmd) diff --git a/docs/scope-filtering.md b/docs/scope-filtering.md new file mode 100644 index 000000000..888a0e1f8 --- /dev/null +++ b/docs/scope-filtering.md @@ -0,0 +1,89 @@ +# OAuth Scope Filtering + +The GitHub MCP Server automatically filters available tools based on your Personal Access Token's (PAT) OAuth scopes. This ensures you only see tools that your token has permission to use, reducing clutter and preventing errors from attempting operations your token can't perform. + +## How It Works + +When the server starts, it makes a lightweight HTTP HEAD request to the GitHub API to discover your token's scopes from the `X-OAuth-Scopes` header. Tools that require scopes your token doesn't have are automatically hidden. + +**Example:** If your token only has `repo` and `gist` scopes, you won't see tools that require `admin:org`, `project`, or `notifications` scopes. + +## Checking Your Token's Scopes + +To see what scopes your token has, you can run: + +```bash +curl -sI -H "Authorization: Bearer $GITHUB_PERSONAL_ACCESS_TOKEN" \ + https://api.github.com/user | grep -i x-oauth-scopes +``` + +Example output: +``` +x-oauth-scopes: delete_repo, gist, read:org, repo +``` + +## Scopes and Tools + +The following table shows which OAuth scopes are required for each category of tools: + +| Scope | Tools Enabled | +|-------|---------------| +| `repo` | Repository operations, issues, PRs, commits, branches, code search, workflows | +| `public_repo` | Star/unstar public repositories (implicit with `repo`) | +| `read:org` | Read organization info, list teams, team members | +| `write:org` | Organization management (includes `read:org`) | +| `admin:org` | Full organization administration (includes `write:org`, `read:org`) | +| `gist` | Create, update, and manage gists | +| `notifications` | List, manage, and dismiss notifications | +| `read:project` | Read GitHub Projects | +| `project` | Create and manage GitHub Projects (includes `read:project`) | +| `security_events` | Code scanning, Dependabot, secret scanning alerts (implicit with `repo`) | +| `user` | Update user profile | +| `read:user` | Read user profile information | + +### Scope Hierarchy + +Some scopes implicitly include others: + +- `repo` → includes `public_repo`, `security_events` +- `admin:org` → includes `write:org` → includes `read:org` +- `project` → includes `read:project` + +This means if your token has `repo`, tools requiring `security_events` will also be available. + +## Recommended Token Scopes + +For full functionality, we recommend these scopes: + +| Use Case | Recommended Scopes | +|----------|-------------------| +| Basic development | `repo`, `read:org` | +| Full development | `repo`, `admin:org`, `gist`, `notifications`, `project` | +| Read-only access | `repo` (with `--read-only` flag) | +| Security analysis | `repo` (includes `security_events`) | + +## Graceful Degradation + +If the server cannot fetch your token's scopes (e.g., network issues, rate limiting), it logs a warning and continues **without filtering**. This ensures the server remains usable even when scope detection fails. + +``` +WARN: failed to fetch token scopes, continuing without scope filtering +``` + +## Fine-Grained Personal Access Tokens + +Fine-grained PATs use a different permission model and don't return OAuth scopes in the `X-OAuth-Scopes` header. When using fine-grained PATs, scope filtering will be skipped and all tools will be available. The GitHub API will still enforce permissions at the API level. + +## Troubleshooting + +| Problem | Cause | Solution | +|---------|-------|----------| +| Missing expected tools | Token lacks required scope | Add the scope to your PAT | +| All tools visible despite limited PAT | Scope detection failed | Check logs for warnings about scope fetching | +| "Insufficient permissions" errors | Tool visible but scope insufficient | This shouldn't happen with scope filtering; report as bug | + +## Related Documentation + +- [Server Configuration Guide](./server-configuration.md) +- [GitHub PAT Documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens) +- [OAuth Scopes Reference](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps) diff --git a/docs/server-configuration.md b/docs/server-configuration.md index e8b7637bd..3338a9275 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -12,6 +12,7 @@ We currently support the following ways in which the GitHub MCP Server can be co | Read-Only Mode | `X-MCP-Readonly` header or `/readonly` URL | `--read-only` flag or `GITHUB_READ_ONLY` env var | | Dynamic Mode | Not available | `--dynamic-toolsets` flag or `GITHUB_DYNAMIC_TOOLSETS` env var | | Lockdown Mode | `X-MCP-Lockdown` header | `--lockdown-mode` flag or `GITHUB_LOCKDOWN_MODE` env var | +| Scope Filtering | Always enabled | Always enabled | > **Default behavior:** If you don't specify any configuration, the server uses the **default toolsets**: `context`, `issues`, `pull_requests`, `repos`, `users`. @@ -330,6 +331,16 @@ Lockdown mode ensures the server only surfaces content in public repositories fr --- +### Scope Filtering + +**Automatic feature:** The server automatically detects your PAT's OAuth scopes and only shows tools you have permission to use. + +This happens transparently at startup - no configuration needed. If scope detection fails (e.g., network issues), the server logs a warning and continues with all tools available. + +See [OAuth Scope Filtering](./scope-filtering.md) for details on which scopes enable which tools. + +--- + ## Troubleshooting | Problem | Cause | Solution | diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 7da2f825f..73aaa8518 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -324,11 +324,6 @@ type StdioServerConfig struct { // RepoAccessCacheTTL overrides the default TTL for repository access cache entries. RepoAccessCacheTTL *time.Duration - - // EnableScopeFiltering enables PAT scope-based tool filtering. - // When true, the server will fetch the token's OAuth scopes at startup - // and hide tools that require scopes the token doesn't have. - EnableScopeFiltering bool } // RunStdioServer is not concurrent safe. @@ -353,18 +348,16 @@ func RunStdioServer(cfg StdioServerConfig) error { slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelInfo}) } logger := slog.New(slogHandler) - logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode, "scopeFiltering", cfg.EnableScopeFiltering) + logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode) - // Fetch token scopes if scope filtering is enabled + // Fetch token scopes for scope-based tool filtering var tokenScopes []string - if cfg.EnableScopeFiltering { - fetchedScopes, err := fetchTokenScopesForHost(ctx, cfg.Token, cfg.Host) - if err != nil { - logger.Warn("failed to fetch token scopes, continuing without scope filtering", "error", err) - } else { - tokenScopes = fetchedScopes - logger.Info("token scopes fetched for filtering", "scopes", tokenScopes) - } + fetchedScopes, err := fetchTokenScopesForHost(ctx, cfg.Token, cfg.Host) + if err != nil { + logger.Warn("failed to fetch token scopes, continuing without scope filtering", "error", err) + } else { + tokenScopes = fetchedScopes + logger.Info("token scopes fetched for filtering", "scopes", tokenScopes) } ghServer, err := NewMCPServer(MCPServerConfig{ From 8afb4fb16e447c3f76c95611f2fad7ae98d4dee0 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 14:14:39 +0100 Subject: [PATCH 30/70] Only check scopes for classic PATs (ghp_ prefix) - Scope filtering only applies to classic PATs which return X-OAuth-Scopes - Fine-grained PATs and other token types skip filtering (all tools shown) - Updated docs to clarify PAT filtering vs OAuth scope challenges --- docs/scope-filtering.md | 24 +++++++++++++++++++----- internal/ghmcp/server.go | 18 ++++++++++++------ 2 files changed, 31 insertions(+), 11 deletions(-) diff --git a/docs/scope-filtering.md b/docs/scope-filtering.md index 888a0e1f8..8dabd0d79 100644 --- a/docs/scope-filtering.md +++ b/docs/scope-filtering.md @@ -1,13 +1,25 @@ -# OAuth Scope Filtering +# PAT Scope Filtering -The GitHub MCP Server automatically filters available tools based on your Personal Access Token's (PAT) OAuth scopes. This ensures you only see tools that your token has permission to use, reducing clutter and preventing errors from attempting operations your token can't perform. +The GitHub MCP Server automatically filters available tools based on your classic Personal Access Token's (PAT) OAuth scopes. This ensures you only see tools that your token has permission to use, reducing clutter and preventing errors from attempting operations your token can't perform. + +> **Note:** This feature applies to **classic PATs** (tokens starting with `ghp_`). Fine-grained PATs and other token types don't support scope detection. ## How It Works -When the server starts, it makes a lightweight HTTP HEAD request to the GitHub API to discover your token's scopes from the `X-OAuth-Scopes` header. Tools that require scopes your token doesn't have are automatically hidden. +When the server starts with a classic PAT, it makes a lightweight HTTP HEAD request to the GitHub API to discover your token's scopes from the `X-OAuth-Scopes` header. Tools that require scopes your token doesn't have are automatically hidden. **Example:** If your token only has `repo` and `gist` scopes, you won't see tools that require `admin:org`, `project`, or `notifications` scopes. +## PAT vs OAuth Authentication + +| Authentication | Scope Handling | +|---------------|----------------| +| **Classic PAT** (`ghp_`) | Filters tools at startup based on token scopes—tools requiring unavailable scopes are hidden | +| **OAuth** (remote server only) | Uses OAuth scope challenges—when a tool needs a scope you haven't granted, you're prompted to authorize it | +| **Fine-grained PAT** (`github_pat_`) | No filtering—all tools shown, API enforces permissions | + +With OAuth, the remote server can dynamically request additional scopes as needed. With PATs, scopes are fixed at token creation, so the server proactively hides tools you can't use. + ## Checking Your Token's Scopes To see what scopes your token has, you can run: @@ -70,9 +82,11 @@ If the server cannot fetch your token's scopes (e.g., network issues, rate limit WARN: failed to fetch token scopes, continuing without scope filtering ``` -## Fine-Grained Personal Access Tokens +## Classic vs Fine-Grained Personal Access Tokens + +**Classic PATs** (`ghp_` prefix) support OAuth scopes and return them in the `X-OAuth-Scopes` header. Scope filtering works fully with these tokens. -Fine-grained PATs use a different permission model and don't return OAuth scopes in the `X-OAuth-Scopes` header. When using fine-grained PATs, scope filtering will be skipped and all tools will be available. The GitHub API will still enforce permissions at the API level. +**Fine-grained PATs** (`github_pat_` prefix) use a different permission model based on repository access and specific permissions rather than OAuth scopes. They don't return the `X-OAuth-Scopes` header, so scope filtering is skipped. All tools will be available, but the GitHub API will still enforce permissions at the API level—you'll get errors if you try to use tools your token doesn't have permission for. ## Troubleshooting diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 73aaa8518..165886606 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -350,14 +350,20 @@ func RunStdioServer(cfg StdioServerConfig) error { logger := slog.New(slogHandler) logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode) - // Fetch token scopes for scope-based tool filtering + // Fetch token scopes for scope-based tool filtering (PAT tokens only) + // Only classic PATs (ghp_ prefix) return OAuth scopes via X-OAuth-Scopes header. + // Fine-grained PATs and other token types don't support this, so we skip filtering. var tokenScopes []string - fetchedScopes, err := fetchTokenScopesForHost(ctx, cfg.Token, cfg.Host) - if err != nil { - logger.Warn("failed to fetch token scopes, continuing without scope filtering", "error", err) + if strings.HasPrefix(cfg.Token, "ghp_") { + fetchedScopes, err := fetchTokenScopesForHost(ctx, cfg.Token, cfg.Host) + if err != nil { + logger.Warn("failed to fetch token scopes, continuing without scope filtering", "error", err) + } else { + tokenScopes = fetchedScopes + logger.Info("token scopes fetched for filtering", "scopes", tokenScopes) + } } else { - tokenScopes = fetchedScopes - logger.Info("token scopes fetched for filtering", "scopes", tokenScopes) + logger.Debug("skipping scope filtering for non-PAT token") } ghServer, err := NewMCPServer(MCPServerConfig{ From 39fed357f452ff70d142dcef7582b719c64a62ad Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 14:19:12 +0100 Subject: [PATCH 31/70] Remove manual scope-to-tools table from docs The README already has auto-generated tool documentation with scopes. Keep only the scope hierarchy explanation which is structural. --- docs/scope-filtering.md | 32 ++------------------------------ 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/docs/scope-filtering.md b/docs/scope-filtering.md index 8dabd0d79..6e251fdb3 100644 --- a/docs/scope-filtering.md +++ b/docs/scope-filtering.md @@ -34,26 +34,7 @@ Example output: x-oauth-scopes: delete_repo, gist, read:org, repo ``` -## Scopes and Tools - -The following table shows which OAuth scopes are required for each category of tools: - -| Scope | Tools Enabled | -|-------|---------------| -| `repo` | Repository operations, issues, PRs, commits, branches, code search, workflows | -| `public_repo` | Star/unstar public repositories (implicit with `repo`) | -| `read:org` | Read organization info, list teams, team members | -| `write:org` | Organization management (includes `read:org`) | -| `admin:org` | Full organization administration (includes `write:org`, `read:org`) | -| `gist` | Create, update, and manage gists | -| `notifications` | List, manage, and dismiss notifications | -| `read:project` | Read GitHub Projects | -| `project` | Create and manage GitHub Projects (includes `read:project`) | -| `security_events` | Code scanning, Dependabot, secret scanning alerts (implicit with `repo`) | -| `user` | Update user profile | -| `read:user` | Read user profile information | - -### Scope Hierarchy +## Scope Hierarchy Some scopes implicitly include others: @@ -63,16 +44,7 @@ Some scopes implicitly include others: This means if your token has `repo`, tools requiring `security_events` will also be available. -## Recommended Token Scopes - -For full functionality, we recommend these scopes: - -| Use Case | Recommended Scopes | -|----------|-------------------| -| Basic development | `repo`, `read:org` | -| Full development | `repo`, `admin:org`, `gist`, `notifications`, `project` | -| Read-only access | `repo` (with `--read-only` flag) | -| Security analysis | `repo` (includes `security_events`) | +Each tool in the [README](../README.md#tools) lists its required and accepted OAuth scopes. ## Graceful Degradation From c2450ce96ac8871a7d905599561976ef141b40cf Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 14:25:47 +0100 Subject: [PATCH 32/70] Update pkg/scopes/filter.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/scopes/filter.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/pkg/scopes/filter.go b/pkg/scopes/filter.go index 143b736e2..3eb3457a5 100644 --- a/pkg/scopes/filter.go +++ b/pkg/scopes/filter.go @@ -4,6 +4,4 @@ // For PATs, we cannot issue OAuth scope challenges, so we hide tools that // require scopes the token doesn't have. // -// The CreateToolScopeFilter function should be called from the github package -// or other packages that can import inventory to create the actual filter. package scopes From acd792928170dd902da0ff1d6160ccbb194fba99 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 14:46:46 +0100 Subject: [PATCH 33/70] Document that GitHub App and server-to-server tokens are not filtered --- docs/scope-filtering.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/scope-filtering.md b/docs/scope-filtering.md index 6e251fdb3..5e3e353d5 100644 --- a/docs/scope-filtering.md +++ b/docs/scope-filtering.md @@ -2,7 +2,7 @@ The GitHub MCP Server automatically filters available tools based on your classic Personal Access Token's (PAT) OAuth scopes. This ensures you only see tools that your token has permission to use, reducing clutter and preventing errors from attempting operations your token can't perform. -> **Note:** This feature applies to **classic PATs** (tokens starting with `ghp_`). Fine-grained PATs and other token types don't support scope detection. +> **Note:** This feature applies to **classic PATs** (tokens starting with `ghp_`). Fine-grained PATs, GitHub App installation tokens, and server-to-server tokens don't support scope detection and show all tools. ## How It Works @@ -17,6 +17,8 @@ When the server starts with a classic PAT, it makes a lightweight HTTP HEAD requ | **Classic PAT** (`ghp_`) | Filters tools at startup based on token scopes—tools requiring unavailable scopes are hidden | | **OAuth** (remote server only) | Uses OAuth scope challenges—when a tool needs a scope you haven't granted, you're prompted to authorize it | | **Fine-grained PAT** (`github_pat_`) | No filtering—all tools shown, API enforces permissions | +| **GitHub App** (`ghs_`) | No filtering—all tools shown, permissions based on app installation | +| **Server-to-server** | No filtering—all tools shown, permissions based on app/token configuration | With OAuth, the remote server can dynamically request additional scopes as needed. With PATs, scopes are fixed at token creation, so the server proactively hides tools you can't use. @@ -60,6 +62,10 @@ WARN: failed to fetch token scopes, continuing without scope filtering **Fine-grained PATs** (`github_pat_` prefix) use a different permission model based on repository access and specific permissions rather than OAuth scopes. They don't return the `X-OAuth-Scopes` header, so scope filtering is skipped. All tools will be available, but the GitHub API will still enforce permissions at the API level—you'll get errors if you try to use tools your token doesn't have permission for. +## GitHub App and Server-to-Server Tokens + +**GitHub App installation tokens** (`ghs_` prefix) and other server-to-server tokens use a permission model based on the app's installation permissions rather than OAuth scopes. These tokens don't return the `X-OAuth-Scopes` header, so scope filtering is skipped. The GitHub API enforces permissions based on the app's configuration. + ## Troubleshooting | Problem | Cause | Solution | From f14f507a39c565ffc7734773ee98cecfcb5da51a Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 14:47:50 +0100 Subject: [PATCH 34/70] Add tip about editing PAT scopes in GitHub UI --- docs/scope-filtering.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/scope-filtering.md b/docs/scope-filtering.md index 5e3e353d5..979a4d59e 100644 --- a/docs/scope-filtering.md +++ b/docs/scope-filtering.md @@ -70,10 +70,12 @@ WARN: failed to fetch token scopes, continuing without scope filtering | Problem | Cause | Solution | |---------|-------|----------| -| Missing expected tools | Token lacks required scope | Add the scope to your PAT | +| Missing expected tools | Token lacks required scope | [Edit your PAT's scopes](https://github.com/settings/tokens) in GitHub settings | | All tools visible despite limited PAT | Scope detection failed | Check logs for warnings about scope fetching | | "Insufficient permissions" errors | Tool visible but scope insufficient | This shouldn't happen with scope filtering; report as bug | +> **Tip:** You can adjust the scopes of an existing classic PAT at any time via [GitHub's token settings](https://github.com/settings/tokens). After updating scopes, restart the MCP server to pick up the changes. + ## Related Documentation - [Server Configuration Guide](./server-configuration.md) From 4deaa8321ecaa2a6843e08a1fc49eb9f7092a149 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 14:54:12 +0100 Subject: [PATCH 35/70] Remove empty filter.go and document OAuth scope challenges --- docs/scope-filtering.md | 12 ++++++++++++ pkg/scopes/filter.go | 7 ------- 2 files changed, 12 insertions(+), 7 deletions(-) delete mode 100644 pkg/scopes/filter.go diff --git a/docs/scope-filtering.md b/docs/scope-filtering.md index 979a4d59e..c40bf729f 100644 --- a/docs/scope-filtering.md +++ b/docs/scope-filtering.md @@ -22,6 +22,18 @@ When the server starts with a classic PAT, it makes a lightweight HTTP HEAD requ With OAuth, the remote server can dynamically request additional scopes as needed. With PATs, scopes are fixed at token creation, so the server proactively hides tools you can't use. +## OAuth Scope Challenges (Remote Server) + +When using the [remote MCP server](./remote-server.md) with OAuth authentication, the server uses a different approach called **scope challenges**. Instead of hiding tools upfront, all tools are available, and the server requests additional scopes on-demand when you try to use a tool that requires them. + +**How it works:** +1. You attempt to use a tool (e.g., creating an issue) +2. If your current OAuth token lacks the required scope, the server returns an OAuth scope challenge +3. Your MCP client prompts you to authorize the additional scope +4. After authorization, the operation completes successfully + +This provides a smoother user experience for OAuth users since you only grant permissions as needed, rather than requesting all scopes upfront. + ## Checking Your Token's Scopes To see what scopes your token has, you can run: diff --git a/pkg/scopes/filter.go b/pkg/scopes/filter.go deleted file mode 100644 index 3eb3457a5..000000000 --- a/pkg/scopes/filter.go +++ /dev/null @@ -1,7 +0,0 @@ -// Package scopes provides OAuth scope checking utilities for GitHub MCP Server. -// -// This file contains utilities for filtering tools based on token scopes. -// For PATs, we cannot issue OAuth scope challenges, so we hide tools that -// require scopes the token doesn't have. -// -package scopes From 9aef43596e1648f1b980f6dc8731f37938482e3e Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 14:56:10 +0100 Subject: [PATCH 36/70] Fix server-configuration.md scope filtering description --- docs/server-configuration.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/server-configuration.md b/docs/server-configuration.md index 3338a9275..b9f8b5fa7 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -333,11 +333,11 @@ Lockdown mode ensures the server only surfaces content in public repositories fr ### Scope Filtering -**Automatic feature:** The server automatically detects your PAT's OAuth scopes and only shows tools you have permission to use. +**Automatic feature:** The server automatically detects your classic PAT's OAuth scopes and only shows tools you have permission to use. -This happens transparently at startup - no configuration needed. If scope detection fails (e.g., network issues), the server logs a warning and continues with all tools available. +This happens transparently at startup for classic PATs (`ghp_` prefix)—no configuration needed. If scope detection fails (e.g., network issues), the server logs a warning and continues with all tools available. -See [OAuth Scope Filtering](./scope-filtering.md) for details on which scopes enable which tools. +Each tool in the [README](../README.md#tools) lists its required and accepted OAuth scopes. See [Scope Filtering](./scope-filtering.md) for details on how filtering works with different token types. --- From c80976661e7ef60be5101dab4c1656240873117d Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 14:58:50 +0100 Subject: [PATCH 37/70] Mention OAuth scope challenges in server-configuration.md --- docs/server-configuration.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/server-configuration.md b/docs/server-configuration.md index b9f8b5fa7..46ec3bc64 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -333,11 +333,15 @@ Lockdown mode ensures the server only surfaces content in public repositories fr ### Scope Filtering -**Automatic feature:** The server automatically detects your classic PAT's OAuth scopes and only shows tools you have permission to use. +**Automatic feature:** The server handles OAuth scopes differently depending on authentication type: -This happens transparently at startup for classic PATs (`ghp_` prefix)—no configuration needed. If scope detection fails (e.g., network issues), the server logs a warning and continues with all tools available. +- **Classic PATs** (`ghp_` prefix): Tools are filtered at startup based on token scopes—you only see tools you have permission to use +- **OAuth** (remote server): Uses scope challenges—when a tool needs a scope you haven't granted, you're prompted to authorize it +- **Other tokens**: No filtering—all tools shown, API enforces permissions -Each tool in the [README](../README.md#tools) lists its required and accepted OAuth scopes. See [Scope Filtering](./scope-filtering.md) for details on how filtering works with different token types. +This happens transparently—no configuration needed. If scope detection fails for a classic PAT (e.g., network issues), the server logs a warning and continues with all tools available. + +See [Scope Filtering](./scope-filtering.md) for details on how filtering works with different token types. --- From c428f72863fbfc12ad114c223cfea462520c13dc Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 16:52:00 +0100 Subject: [PATCH 38/70] Don't filter read-only repo tools (work on public repos without scope) --- pkg/github/scope_filter.go | 28 ++++++++++++++++++++++++++++ pkg/github/scope_filter_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/pkg/github/scope_filter.go b/pkg/github/scope_filter.go index b1aa77c85..42f8e98b0 100644 --- a/pkg/github/scope_filter.go +++ b/pkg/github/scope_filter.go @@ -7,6 +7,29 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" ) +// repoScopesSet contains scopes that grant access to repository content. +// Tools requiring only these scopes work on public repos without any token scope, +// so we don't filter them out even if the token lacks repo/public_repo. +var repoScopesSet = map[string]bool{ + string(scopes.Repo): true, + string(scopes.PublicRepo): true, +} + +// onlyRequiresRepoScopes returns true if all of the tool's accepted scopes +// are repo-related scopes (repo, public_repo). Such tools work on public +// repositories without needing any scope. +func onlyRequiresRepoScopes(acceptedScopes []string) bool { + if len(acceptedScopes) == 0 { + return false + } + for _, scope := range acceptedScopes { + if !repoScopesSet[scope] { + return false + } + } + return true +} + // CreateToolScopeFilter creates an inventory.ToolFilter that filters tools // based on the token's OAuth scopes. // @@ -19,6 +42,7 @@ import ( // // The filter returns true (include tool) if: // - The tool has no scope requirements (AcceptedScopes is empty) +// - The tool is read-only and only requires repo/public_repo scopes (works on public repos) // - The token has at least one of the tool's accepted scopes // // Example usage: @@ -31,6 +55,10 @@ import ( // inventory := github.NewInventory(t).WithFilter(filter).Build() func CreateToolScopeFilter(tokenScopes []string) inventory.ToolFilter { return func(_ context.Context, tool *inventory.ServerTool) (bool, error) { + // Read-only tools requiring only repo/public_repo work on public repos without any scope + if tool.Tool.Annotations != nil && tool.Tool.Annotations.ReadOnlyHint && onlyRequiresRepoScopes(tool.AcceptedScopes) { + return true, nil + } return scopes.HasRequiredScopes(tokenScopes, tool.AcceptedScopes), nil } } diff --git a/pkg/github/scope_filter_test.go b/pkg/github/scope_filter_test.go index 48eb52aa0..451d1a64e 100644 --- a/pkg/github/scope_filter_test.go +++ b/pkg/github/scope_filter_test.go @@ -27,11 +27,27 @@ func TestCreateToolScopeFilter(t *testing.T) { AcceptedScopes: []string{"repo"}, } + toolRepoScopeReadOnly := &inventory.ServerTool{ + Tool: mcp.Tool{ + Name: "repo_tool_readonly", + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true}, + }, + AcceptedScopes: []string{"repo"}, + } + toolPublicRepoScope := &inventory.ServerTool{ Tool: mcp.Tool{Name: "public_repo_tool"}, AcceptedScopes: []string{"public_repo", "repo"}, // repo is parent, also accepted } + toolPublicRepoScopeReadOnly := &inventory.ServerTool{ + Tool: mcp.Tool{ + Name: "public_repo_tool_readonly", + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true}, + }, + AcceptedScopes: []string{"public_repo", "repo"}, + } + toolGistScope := &inventory.ServerTool{ Tool: mcp.Tool{Name: "gist_tool"}, AcceptedScopes: []string{"gist"}, @@ -96,6 +112,18 @@ func TestCreateToolScopeFilter(t *testing.T) { tool: toolRepoScope, expected: false, }, + { + name: "empty token scopes CAN see read-only repo tools (public repos)", + tokenScopes: []string{}, + tool: toolRepoScopeReadOnly, + expected: true, + }, + { + name: "empty token scopes CAN see read-only public_repo tools", + tokenScopes: []string{}, + tool: toolPublicRepoScopeReadOnly, + expected: true, + }, { name: "token with multiple scopes where one matches", tokenScopes: []string{"gist", "repo"}, From 7d4a4a68c320797bee5b6bd14354d5a14e91b798 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 5 Jan 2026 16:55:27 +0100 Subject: [PATCH 39/70] Document public repo access quirk for read-only tools --- docs/scope-filtering.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/scope-filtering.md b/docs/scope-filtering.md index c40bf729f..f29d631ca 100644 --- a/docs/scope-filtering.md +++ b/docs/scope-filtering.md @@ -60,6 +60,14 @@ This means if your token has `repo`, tools requiring `security_events` will also Each tool in the [README](../README.md#tools) lists its required and accepted OAuth scopes. +## Public Repository Access + +Read-only tools that only require `repo` or `public_repo` scopes are **always visible**, even if your token doesn't have these scopes. This is because these tools work on public repositories without authentication. + +For example, `get_file_contents` is always available—you can read files from any public repository regardless of your token's scopes. However, write operations like `create_or_update_file` will be hidden if your token lacks `repo` scope. + +> **Note:** The GitHub API doesn't return `public_repo` in the `X-OAuth-Scopes` header—it's implicit. The server handles this by not filtering read-only repository tools. + ## Graceful Degradation If the server cannot fetch your token's scopes (e.g., network issues, rate limiting), it logs a warning and continues **without filtering**. This ensures the server remains usable even when scope detection fails. From 80b030655769b8816b726f3ce41e2285f154cf0e Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Jan 2026 10:45:29 +0100 Subject: [PATCH 40/70] Replace go-github-mock with stretchr/testify for actions/issues/projects tests (#1737) * Initial plan * migrate tests from go-github-mock to internal testify-based mock Co-authored-by: JoannaaKL <67866556+JoannaaKL@users.noreply.github.com> * address feedback in testmock helper Co-authored-by: JoannaaKL <67866556+JoannaaKL@users.noreply.github.com> * tweak testmock path matching edge case Co-authored-by: JoannaaKL <67866556+JoannaaKL@users.noreply.github.com> * refine testmock options and path matching Co-authored-by: JoannaaKL <67866556+JoannaaKL@users.noreply.github.com> * simplify matchPath and document delete endpoint Co-authored-by: JoannaaKL <67866556+JoannaaKL@users.noreply.github.com> * Replace go-github-mock usage in tests with shared HTTP mock helper Co-authored-by: JoannaaKL <67866556+JoannaaKL@users.noreply.github.com> * Replace go-github-mock usage in tests with shared HTTP mock helper Co-authored-by: JoannaaKL <67866556+JoannaaKL@users.noreply.github.com> * fix tests and lint after mock cleanup Co-authored-by: JoannaaKL <67866556+JoannaaKL@users.noreply.github.com> * Remove import completely * Partial removal in repositories_test.go * Final removal --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: JoannaaKL <67866556+JoannaaKL@users.noreply.github.com> Co-authored-by: JoannaaKL --- docs/testing.md | 2 +- go.mod | 4 - go.sum | 8 - pkg/github/actions_test.go | 539 +++---- pkg/github/helper_test.go | 96 +- pkg/github/issues_test.go | 851 ++++------- pkg/github/projects_test.go | 492 ++---- pkg/github/pullrequests_test.go | 716 ++++----- pkg/github/repositories_test.go | 1341 ++++++++--------- pkg/github/search_test.go | 301 ++-- pkg/raw/raw_mock.go | 20 - third-party-licenses.darwin.md | 4 - third-party-licenses.linux.md | 4 - third-party-licenses.windows.md | 4 - .../google/go-github/v71/github/LICENSE | 27 - third-party/github.com/gorilla/mux/LICENSE | 27 - .../go-github-mock/src/mock/LICENSE | 21 - third-party/golang.org/x/time/rate/LICENSE | 27 - 18 files changed, 1852 insertions(+), 2632 deletions(-) delete mode 100644 pkg/raw/raw_mock.go delete mode 100644 third-party/github.com/google/go-github/v71/github/LICENSE delete mode 100644 third-party/github.com/gorilla/mux/LICENSE delete mode 100644 third-party/github.com/migueleliasweb/go-github-mock/src/mock/LICENSE delete mode 100644 third-party/golang.org/x/time/rate/LICENSE diff --git a/docs/testing.md b/docs/testing.md index 226660e9d..2186b564b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -7,7 +7,7 @@ This project uses a combination of unit tests and end-to-end (e2e) tests to ensu - Unit tests are located alongside implementation, with filenames ending in `_test.go`. - Currently the preference is to use internal tests i.e. test files do not have `_test` package suffix. - Tests use [testify](https://github.com/stretchr/testify) for assertions and require statements. Use `require` when continuing the test is not meaningful, for example it is almost never correct to continue after an error expectation. -- Mocking is performed using [go-github-mock](https://github.com/migueleliasweb/go-github-mock) or `githubv4mock` for simulating GitHub rest and GQL API responses. +- REST mocking is performed with the in-repo `MockHTTPClientWithHandlers` helpers; GraphQL mocking uses `githubv4mock`. - Each tool's schema is snapshotted and checked for changes using the `toolsnaps` utility (see below). - Tests are designed to be explicit and verbose to aid maintainability and clarity. - Handler unit tests should take the form of: diff --git a/go.mod b/go.mod index 691a949bd..5322b47ec 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,6 @@ require ( github.com/google/jsonschema-go v0.4.2 github.com/josephburnett/jd v1.9.2 github.com/microcosm-cc/bluemonday v1.0.27 - github.com/migueleliasweb/go-github-mock v1.3.0 github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021 github.com/spf13/cobra v1.10.2 github.com/spf13/viper v1.21.0 @@ -18,9 +17,7 @@ require ( github.com/aymerick/douceur v0.2.0 // indirect github.com/go-openapi/jsonpointer v0.19.5 // indirect github.com/go-openapi/swag v0.21.1 // indirect - github.com/google/go-github/v71 v71.0.0 // indirect github.com/gorilla/css v1.0.1 // indirect - github.com/gorilla/mux v1.8.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/stretchr/objx v0.5.2 // indirect @@ -53,7 +50,6 @@ require ( golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.28.0 // indirect - golang.org/x/time v0.5.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 6f38bea2f..25cbf7fa9 100644 --- a/go.sum +++ b/go.sum @@ -22,8 +22,6 @@ github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVI github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 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/v71 v71.0.0 h1:Zi16OymGKZZMm8ZliffVVJ/Q9YZreDKONCr+WUd0Z30= -github.com/google/go-github/v71 v71.0.0/go.mod h1:URZXObp2BLlMjwu0O8g4y6VBneUj2bCHgnI8FfgZ51M= github.com/google/go-github/v79 v79.0.0 h1:MdodQojuFPBhmtwHiBcIGLw/e/wei2PvFX9ndxK0X4Y= github.com/google/go-github/v79 v79.0.0/go.mod h1:OAFbNhq7fQwohojb06iIIQAB9CBGYLq999myfUFnrS4= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= @@ -32,8 +30,6 @@ github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbc github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= -github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= -github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josephburnett/jd v1.9.2 h1:ECJRRFXCCqbtidkAHckHGSZm/JIaAxS1gygHLF8MI5Y= @@ -55,8 +51,6 @@ github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= -github.com/migueleliasweb/go-github-mock v1.3.0 h1:2sVP9JEMB2ubQw1IKto3/fzF51oFC6eVWOOFDgQoq88= -github.com/migueleliasweb/go-github-mock v1.3.0/go.mod h1:ipQhV8fTcj/G6m7BKzin08GaJ/3B5/SonRAkgrk0zCY= github.com/modelcontextprotocol/go-sdk v1.2.0 h1:Y23co09300CEk8iZ/tMxIX1dVmKZkzoSBZOpJwUnc/s= github.com/modelcontextprotocol/go-sdk v1.2.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021 h1:31Y+Yu373ymebRdJN1cWLLooHH8xAr0MhKTEJGV/87g= @@ -114,8 +108,6 @@ golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= -golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= -golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 7319feddf..0d47236f6 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -18,7 +18,6 @@ import ( "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" - "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -1394,17 +1393,11 @@ func Test_RerunFailedJobs(t *testing.T) { }{ { name: "successful rerun of failed jobs", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/actions/runs/12345/rerun-failed-jobs", - Method: "POST", - }, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusCreated) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposActionsRunsRerunFailedJobsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + }), + }), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -1414,7 +1407,7 @@ func Test_RerunFailedJobs(t *testing.T) { }, { name: "missing required parameter run_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -1466,17 +1459,11 @@ func Test_RerunWorkflowRun_Behavioral(t *testing.T) { }{ { name: "successful rerun of workflow run", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/actions/runs/12345/rerun", - Method: "POST", - }, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusCreated) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposActionsRunsRerunByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusCreated) + }), + }), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -1486,7 +1473,7 @@ func Test_RerunWorkflowRun_Behavioral(t *testing.T) { }, { name: "missing required parameter run_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -1538,32 +1525,29 @@ func Test_ListWorkflowRuns_Behavioral(t *testing.T) { }{ { name: "successful workflow runs listing", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsWorkflowsRunsByOwnerByRepoByWorkflowId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - runs := &github.WorkflowRuns{ - TotalCount: github.Ptr(2), - WorkflowRuns: []*github.WorkflowRun{ - { - ID: github.Ptr(int64(123)), - Name: github.Ptr("CI"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("success"), - }, - { - ID: github.Ptr(int64(456)), - Name: github.Ptr("CI"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("failure"), - }, + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsWorkflowsRunsByOwnerByRepoByWorkflowID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + runs := &github.WorkflowRuns{ + TotalCount: github.Ptr(2), + WorkflowRuns: []*github.WorkflowRun{ + { + ID: github.Ptr(int64(123)), + Name: github.Ptr("CI"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + }, + { + ID: github.Ptr(int64(456)), + Name: github.Ptr("CI"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("failure"), }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(runs) - }), - ), - ), + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(runs) + }), + }), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -1573,7 +1557,7 @@ func Test_ListWorkflowRuns_Behavioral(t *testing.T) { }, { name: "missing required parameter workflow_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -1625,21 +1609,18 @@ func Test_GetWorkflowRun_Behavioral(t *testing.T) { }{ { name: "successful get workflow run", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsRunsByOwnerByRepoByRunId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - run := &github.WorkflowRun{ - ID: github.Ptr(int64(12345)), - Name: github.Ptr("CI"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("success"), - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(run) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsRunsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + run := &github.WorkflowRun{ + ID: github.Ptr(int64(12345)), + Name: github.Ptr("CI"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(run) + }), + }), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -1649,7 +1630,7 @@ func Test_GetWorkflowRun_Behavioral(t *testing.T) { }, { name: "missing required parameter run_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -1701,15 +1682,12 @@ func Test_GetWorkflowRunLogs_Behavioral(t *testing.T) { }{ { name: "successful get workflow run logs", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsRunsLogsByOwnerByRepoByRunId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Location", "https://github.com/logs/run/12345") - w.WriteHeader(http.StatusFound) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsRunsLogsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", "https://github.com/logs/run/12345") + w.WriteHeader(http.StatusFound) + }), + }), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -1719,7 +1697,7 @@ func Test_GetWorkflowRunLogs_Behavioral(t *testing.T) { }, { name: "missing required parameter run_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -1771,32 +1749,29 @@ func Test_ListWorkflowJobs_Behavioral(t *testing.T) { }{ { name: "successful list workflow jobs", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - jobs := &github.Jobs{ - TotalCount: github.Ptr(2), - Jobs: []*github.WorkflowJob{ - { - ID: github.Ptr(int64(1)), - Name: github.Ptr("build"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("success"), - }, - { - ID: github.Ptr(int64(2)), - Name: github.Ptr("test"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("failure"), - }, + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsRunsJobsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + jobs := &github.Jobs{ + TotalCount: github.Ptr(2), + Jobs: []*github.WorkflowJob{ + { + ID: github.Ptr(int64(1)), + Name: github.Ptr("build"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(jobs) - }), - ), - ), + { + ID: github.Ptr(int64(2)), + Name: github.Ptr("test"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("failure"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(jobs) + }), + }), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -1806,7 +1781,7 @@ func Test_ListWorkflowJobs_Behavioral(t *testing.T) { }, { name: "missing required parameter run_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -1873,32 +1848,29 @@ func Test_ActionsList_ListWorkflows(t *testing.T) { }{ { name: "successful workflow list", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsWorkflowsByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - workflows := &github.Workflows{ - TotalCount: github.Ptr(2), - Workflows: []*github.Workflow{ - { - ID: github.Ptr(int64(1)), - Name: github.Ptr("CI"), - Path: github.Ptr(".github/workflows/ci.yml"), - State: github.Ptr("active"), - }, - { - ID: github.Ptr(int64(2)), - Name: github.Ptr("Deploy"), - Path: github.Ptr(".github/workflows/deploy.yml"), - State: github.Ptr("active"), - }, + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsWorkflowsByOwnerByRepo: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + workflows := &github.Workflows{ + TotalCount: github.Ptr(2), + Workflows: []*github.Workflow{ + { + ID: github.Ptr(int64(1)), + Name: github.Ptr("CI"), + Path: github.Ptr(".github/workflows/ci.yml"), + State: github.Ptr("active"), }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(workflows) - }), - ), - ), + { + ID: github.Ptr(int64(2)), + Name: github.Ptr("Deploy"), + Path: github.Ptr(".github/workflows/deploy.yml"), + State: github.Ptr("active"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(workflows) + }), + }), requestArgs: map[string]any{ "method": "list_workflows", "owner": "owner", @@ -1908,7 +1880,7 @@ func Test_ActionsList_ListWorkflows(t *testing.T) { }, { name: "missing required parameter method", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -1952,26 +1924,23 @@ func Test_ActionsList_ListWorkflowRuns(t *testing.T) { toolDef := ActionsList(translations.NullTranslationHelper) t.Run("successful workflow runs list", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsWorkflowsRunsByOwnerByRepoByWorkflowId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - runs := &github.WorkflowRuns{ - TotalCount: github.Ptr(1), - WorkflowRuns: []*github.WorkflowRun{ - { - ID: github.Ptr(int64(123)), - Name: github.Ptr("CI"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("success"), - }, + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsWorkflowsRunsByOwnerByRepoByWorkflowID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + runs := &github.WorkflowRuns{ + TotalCount: github.Ptr(1), + WorkflowRuns: []*github.WorkflowRun{ + { + ID: github.Ptr(int64(123)), + Name: github.Ptr("CI"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(runs) - }), - ), - ) + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(runs) + }), + }) client := github.NewClient(mockedClient) deps := BaseDeps{ @@ -1998,32 +1967,29 @@ func Test_ActionsList_ListWorkflowRuns(t *testing.T) { }) t.Run("list all workflow runs without resource_id", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsRunsByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - runs := &github.WorkflowRuns{ - TotalCount: github.Ptr(2), - WorkflowRuns: []*github.WorkflowRun{ - { - ID: github.Ptr(int64(123)), - Name: github.Ptr("CI"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("success"), - }, - { - ID: github.Ptr(int64(456)), - Name: github.Ptr("Deploy"), - Status: github.Ptr("in_progress"), - Conclusion: nil, - }, + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsRunsByOwnerByRepo: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + runs := &github.WorkflowRuns{ + TotalCount: github.Ptr(2), + WorkflowRuns: []*github.WorkflowRun{ + { + ID: github.Ptr(int64(123)), + Name: github.Ptr("CI"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(runs) - }), - ), - ) + { + ID: github.Ptr(int64(456)), + Name: github.Ptr("Deploy"), + Status: github.Ptr("in_progress"), + Conclusion: nil, + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(runs) + }), + }) client := github.NewClient(mockedClient) deps := BaseDeps{ @@ -2068,21 +2034,18 @@ func Test_ActionsGet_GetWorkflow(t *testing.T) { toolDef := ActionsGet(translations.NullTranslationHelper) t.Run("successful workflow get", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsWorkflowsByOwnerByRepoByWorkflowId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - workflow := &github.Workflow{ - ID: github.Ptr(int64(1)), - Name: github.Ptr("CI"), - Path: github.Ptr(".github/workflows/ci.yml"), - State: github.Ptr("active"), - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(workflow) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsWorkflowsByOwnerByRepoByWorkflowID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + workflow := &github.Workflow{ + ID: github.Ptr(int64(1)), + Name: github.Ptr("CI"), + Path: github.Ptr(".github/workflows/ci.yml"), + State: github.Ptr("active"), + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(workflow) + }), + }) client := github.NewClient(mockedClient) deps := BaseDeps{ @@ -2114,21 +2077,18 @@ func Test_ActionsGet_GetWorkflowRun(t *testing.T) { toolDef := ActionsGet(translations.NullTranslationHelper) t.Run("successful workflow run get", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsRunsByOwnerByRepoByRunId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - run := &github.WorkflowRun{ - ID: github.Ptr(int64(12345)), - Name: github.Ptr("CI"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("success"), - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(run) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsRunsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + run := &github.WorkflowRun{ + ID: github.Ptr(int64(12345)), + Name: github.Ptr("CI"), + Status: github.Ptr("completed"), + Conclusion: github.Ptr("success"), + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(run) + }), + }) client := github.NewClient(mockedClient) deps := BaseDeps{ @@ -2185,14 +2145,11 @@ func Test_ActionsRunTrigger_RunWorkflow(t *testing.T) { }{ { name: "successful workflow run", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + }), requestArgs: map[string]any{ "method": "run_workflow", "owner": "owner", @@ -2204,7 +2161,7 @@ func Test_ActionsRunTrigger_RunWorkflow(t *testing.T) { }, { name: "missing required parameter workflow_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "method": "run_workflow", "owner": "owner", @@ -2216,7 +2173,7 @@ func Test_ActionsRunTrigger_RunWorkflow(t *testing.T) { }, { name: "missing required parameter ref", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "method": "run_workflow", "owner": "owner", @@ -2261,17 +2218,11 @@ func Test_ActionsRunTrigger_CancelWorkflowRun(t *testing.T) { toolDef := ActionsRunTrigger(translations.NullTranslationHelper) t.Run("successful workflow run cancellation", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/actions/runs/12345/cancel", - Method: "POST", - }, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusAccepted) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposActionsRunsCancelByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusAccepted) + }), + }) client := github.NewClient(mockedClient) deps := BaseDeps{ @@ -2298,17 +2249,11 @@ func Test_ActionsRunTrigger_CancelWorkflowRun(t *testing.T) { }) t.Run("conflict when cancelling a workflow run", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/actions/runs/12345/cancel", - Method: "POST", - }, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusConflict) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposActionsRunsCancelByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + }), + }) client := github.NewClient(mockedClient) deps := BaseDeps{ @@ -2332,7 +2277,7 @@ func Test_ActionsRunTrigger_CancelWorkflowRun(t *testing.T) { }) t.Run("missing run_id for non-run_workflow methods", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient() + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) client := github.NewClient(mockedClient) deps := BaseDeps{ @@ -2379,15 +2324,12 @@ func Test_ActionsGetJobLogs_SingleJob(t *testing.T) { toolDef := ActionsGetJobLogs(translations.NullTranslationHelper) t.Run("successful single job logs with URL", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Location", "https://github.com/logs/job/123") - w.WriteHeader(http.StatusFound) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsJobsLogsByOwnerByRepoByJobID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Location", "https://github.com/logs/job/123") + w.WriteHeader(http.StatusFound) + }), + }) client := github.NewClient(mockedClient) deps := BaseDeps{ @@ -2420,42 +2362,36 @@ func Test_ActionsGetJobLogs_FailedJobs(t *testing.T) { toolDef := ActionsGetJobLogs(translations.NullTranslationHelper) t.Run("successful failed jobs logs", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - jobs := &github.Jobs{ - TotalCount: github.Ptr(3), - Jobs: []*github.WorkflowJob{ - { - ID: github.Ptr(int64(1)), - Name: github.Ptr("test-job-1"), - Conclusion: github.Ptr("success"), - }, - { - ID: github.Ptr(int64(2)), - Name: github.Ptr("test-job-2"), - Conclusion: github.Ptr("failure"), - }, - { - ID: github.Ptr(int64(3)), - Name: github.Ptr("test-job-3"), - Conclusion: github.Ptr("failure"), - }, + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsRunsJobsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + jobs := &github.Jobs{ + TotalCount: github.Ptr(3), + Jobs: []*github.WorkflowJob{ + { + ID: github.Ptr(int64(1)), + Name: github.Ptr("test-job-1"), + Conclusion: github.Ptr("success"), }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(jobs) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposActionsJobsLogsByOwnerByRepoByJobId, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Location", "https://github.com/logs/job/"+r.URL.Path[len(r.URL.Path)-1:]) - w.WriteHeader(http.StatusFound) - }), - ), - ) + { + ID: github.Ptr(int64(2)), + Name: github.Ptr("test-job-2"), + Conclusion: github.Ptr("failure"), + }, + { + ID: github.Ptr(int64(3)), + Name: github.Ptr("test-job-3"), + Conclusion: github.Ptr("failure"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(jobs) + }), + GetReposActionsJobsLogsByOwnerByRepoByJobID: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Location", "https://github.com/logs/job/"+r.URL.Path[len(r.URL.Path)-1:]) + w.WriteHeader(http.StatusFound) + }), + }) client := github.NewClient(mockedClient) deps := BaseDeps{ @@ -2485,30 +2421,27 @@ func Test_ActionsGetJobLogs_FailedJobs(t *testing.T) { }) t.Run("no failed jobs found", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposActionsRunsJobsByOwnerByRepoByRunId, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - jobs := &github.Jobs{ - TotalCount: github.Ptr(2), - Jobs: []*github.WorkflowJob{ - { - ID: github.Ptr(int64(1)), - Name: github.Ptr("test-job-1"), - Conclusion: github.Ptr("success"), - }, - { - ID: github.Ptr(int64(2)), - Name: github.Ptr("test-job-2"), - Conclusion: github.Ptr("success"), - }, + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposActionsRunsJobsByOwnerByRepoByRunID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + jobs := &github.Jobs{ + TotalCount: github.Ptr(2), + Jobs: []*github.WorkflowJob{ + { + ID: github.Ptr(int64(1)), + Name: github.Ptr("test-job-1"), + Conclusion: github.Ptr("success"), }, - } - w.WriteHeader(http.StatusOK) - _ = json.NewEncoder(w).Encode(jobs) - }), - ), - ) + { + ID: github.Ptr(int64(2)), + Name: github.Ptr("test-job-2"), + Conclusion: github.Ptr("success"), + }, + }, + } + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(jobs) + }), + }) client := github.NewClient(mockedClient) deps := BaseDeps{ diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index 56a236660..0bb73008e 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -11,7 +11,7 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" + testifymock "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) @@ -41,9 +41,9 @@ const ( // Git endpoints GetReposGitTreesByOwnerByRepoByTree = "GET /repos/{owner}/{repo}/git/trees/{tree}" - GetReposGitRefByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/git/ref/{ref}" + GetReposGitRefByOwnerByRepoByRef = "GET /repos/{owner}/{repo}/git/ref/{ref:.*}" PostReposGitRefsByOwnerByRepo = "POST /repos/{owner}/{repo}/git/refs" - PatchReposGitRefsByOwnerByRepoByRef = "PATCH /repos/{owner}/{repo}/git/refs/{ref}" + PatchReposGitRefsByOwnerByRepoByRef = "PATCH /repos/{owner}/{repo}/git/refs/{ref:.*}" GetReposGitCommitsByOwnerByRepoByCommitSHA = "GET /repos/{owner}/{repo}/git/commits/{commit_sha}" PostReposGitCommitsByOwnerByRepo = "POST /repos/{owner}/{repo}/git/commits" GetReposGitTagsByOwnerByRepoByTagSHA = "GET /repos/{owner}/{repo}/git/tags/{tag_sha}" @@ -59,7 +59,7 @@ const ( PatchReposIssuesByOwnerByRepoByIssueNumber = "PATCH /repos/{owner}/{repo}/issues/{issue_number}" GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber = "GET /repos/{owner}/{repo}/issues/{issue_number}/sub_issues" PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber = "POST /repos/{owner}/{repo}/issues/{issue_number}/sub_issues" - DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber = "DELETE /repos/{owner}/{repo}/issues/{issue_number}/sub_issues" + DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber = "DELETE /repos/{owner}/{repo}/issues/{issue_number}/sub_issue" PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber = "PATCH /repos/{owner}/{repo}/issues/{issue_number}/sub_issues/priority" // Pull request endpoints @@ -118,6 +118,7 @@ const ( GetReposActionsWorkflowsByOwnerByRepoByWorkflowID = "GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}" PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowID = "POST /repos/{owner}/{repo}/actions/workflows/{workflow_id}/dispatches" GetReposActionsWorkflowsRunsByOwnerByRepoByWorkflowID = "GET /repos/{owner}/{repo}/actions/workflows/{workflow_id}/runs" + GetReposActionsRunsByOwnerByRepo = "GET /repos/{owner}/{repo}/actions/runs" GetReposActionsRunsByOwnerByRepoByRunID = "GET /repos/{owner}/{repo}/actions/runs/{run_id}" GetReposActionsRunsLogsByOwnerByRepoByRunID = "GET /repos/{owner}/{repo}/actions/runs/{run_id}/logs" GetReposActionsRunsJobsByOwnerByRepoByRunID = "GET /repos/{owner}/{repo}/actions/runs/{run_id}/jobs" @@ -132,8 +133,8 @@ const ( // Search endpoints GetSearchCode = "GET /search/code" GetSearchIssues = "GET /search/issues" - GetSearchRepositories = "GET /search/repositories" GetSearchUsers = "GET /search/users" + GetSearchRepositories = "GET /search/repositories" // Raw content endpoints (used for GitHub raw content API, not standard API) // These are used with the raw content client that interacts with raw.githubusercontent.com @@ -141,6 +142,31 @@ const ( GetRawReposContentsByOwnerByRepoByBranchByPath = "GET /{owner}/{repo}/refs/heads/{branch}/{path:.*}" GetRawReposContentsByOwnerByRepoByTagByPath = "GET /{owner}/{repo}/refs/tags/{tag}/{path:.*}" GetRawReposContentsByOwnerByRepoBySHAByPath = "GET /{owner}/{repo}/{sha}/{path:.*}" + + // Projects (ProjectsV2) endpoints + // Organization-scoped + GetOrgsProjectsV2 = "GET /orgs/{org}/projectsV2" + GetOrgsProjectsV2ByProject = "GET /orgs/{org}/projectsV2/{project}" + GetOrgsProjectsV2FieldsByProject = "GET /orgs/{org}/projectsV2/{project}/fields" + GetOrgsProjectsV2FieldsByProjectByFieldID = "GET /orgs/{org}/projectsV2/{project}/fields/{field_id}" + GetOrgsProjectsV2ItemsByProject = "GET /orgs/{org}/projectsV2/{project}/items" + GetOrgsProjectsV2ItemsByProjectByItemID = "GET /orgs/{org}/projectsV2/{project}/items/{item_id}" + PostOrgsProjectsV2ItemsByProject = "POST /orgs/{org}/projectsV2/{project}/items" + PatchOrgsProjectsV2ItemsByProjectByItemID = "PATCH /orgs/{org}/projectsV2/{project}/items/{item_id}" + DeleteOrgsProjectsV2ItemsByProjectByItemID = "DELETE /orgs/{org}/projectsV2/{project}/items/{item_id}" + // User-scoped + GetUsersProjectsV2ByUsername = "GET /users/{username}/projectsV2" + GetUsersProjectsV2ByUsernameByProject = "GET /users/{username}/projectsV2/{project}" + GetUsersProjectsV2FieldsByUsernameByProject = "GET /users/{username}/projectsV2/{project}/fields" + GetUsersProjectsV2FieldsByUsernameByProjectByFieldID = "GET /users/{username}/projectsV2/{project}/fields/{field_id}" + GetUsersProjectsV2ItemsByUsernameByProject = "GET /users/{username}/projectsV2/{project}/items" + GetUsersProjectsV2ItemsByUsernameByProjectByItemID = "GET /users/{username}/projectsV2/{project}/items/{item_id}" + PostUsersProjectsV2ItemsByUsernameByProject = "POST /users/{username}/projectsV2/{project}/items" + PatchUsersProjectsV2ItemsByUsernameByProjectByItemID = "PATCH /users/{username}/projectsV2/{project}/items/{item_id}" + DeleteUsersProjectsV2ItemsByUsernameByProjectByItemID = "DELETE /users/{username}/projectsV2/{project}/items/{item_id}" + + // Organization issue types endpoints + GetOrgsIssueTypesByOrg = "GET /orgs/{org}/issue-types" ) type expectations struct { @@ -408,7 +434,7 @@ func getResourceResult(t *testing.T, result *mcp.CallToolResult) *mcp.ResourceCo // MockRoundTripper is a mock HTTP transport using testify/mock type MockRoundTripper struct { - mock.Mock + testifymock.Mock handlers map[string]http.HandlerFunc } @@ -564,6 +590,64 @@ func MockHTTPClientWithHandlers(handlers map[string]http.HandlerFunc) *http.Clie return &http.Client{Transport: transport} } +// Compatibility helpers to replace github.com/migueleliasweb/go-github-mock in tests +type EndpointPattern string + +type MockBackendOption func(map[string]http.HandlerFunc) + +func parseEndpointPattern(p EndpointPattern) (string, string) { + parts := strings.SplitN(string(p), " ", 2) + if len(parts) != 2 { + return http.MethodGet, string(p) + } + return parts[0], parts[1] +} + +func WithRequestMatch(pattern EndpointPattern, response any) MockBackendOption { + return func(handlers map[string]http.HandlerFunc) { + method, path := parseEndpointPattern(pattern) + handlers[method+" "+path] = func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + switch v := response.(type) { + case string: + _, _ = w.Write([]byte(v)) + case []byte: + _, _ = w.Write(v) + default: + data, err := json.Marshal(v) + if err == nil { + _, _ = w.Write(data) + } + } + } + } +} + +func WithRequestMatchHandler(pattern EndpointPattern, handler http.HandlerFunc) MockBackendOption { + return func(handlers map[string]http.HandlerFunc) { + method, path := parseEndpointPattern(pattern) + handlers[method+" "+path] = handler + } +} + +func NewMockedHTTPClient(options ...MockBackendOption) *http.Client { + handlers := map[string]http.HandlerFunc{} + for _, opt := range options { + if opt != nil { + opt(handlers) + } + } + return MockHTTPClientWithHandlers(handlers) +} + +func MustMarshal(v any) []byte { + data, err := json.Marshal(v) + if err != nil { + panic(err) + } + return data +} + type multiHandlerTransport struct { handlers map[string]http.HandlerFunc } diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 694b991dc..2ccd4918f 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -17,7 +17,6 @@ import ( "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" - "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -181,12 +180,9 @@ func Test_GetIssue(t *testing.T) { }{ { name: "successful issue retrieval", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposIssuesByOwnerByRepoByIssueNumber, - mockIssue, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue), + }), requestArgs: map[string]interface{}{ "method": "get", "owner": "owner2", @@ -197,12 +193,9 @@ func Test_GetIssue(t *testing.T) { }, { name: "issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), + }), requestArgs: map[string]interface{}{ "method": "get", "owner": "owner", @@ -214,12 +207,9 @@ func Test_GetIssue(t *testing.T) { }, { name: "lockdown enabled - private repository", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposIssuesByOwnerByRepoByIssueNumber, - mockIssue2, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue2), + }), gqlHTTPClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( struct { @@ -261,12 +251,9 @@ func Test_GetIssue(t *testing.T) { }, { name: "lockdown enabled - user lacks push access", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposIssuesByOwnerByRepoByIssueNumber, - mockIssue, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue), + }), gqlHTTPClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( struct { @@ -406,12 +393,9 @@ func Test_AddIssueComment(t *testing.T) { }{ { name: "successful comment creation", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesCommentsByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusCreated, mockComment), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockComment), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -423,15 +407,12 @@ func Test_AddIssueComment(t *testing.T) { }, { name: "comment creation fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesCommentsByOwnerByRepoByIssueNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesCommentsByOwnerByRepoByIssueNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) + }), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -546,23 +527,20 @@ func Test_SearchIssues(t *testing.T) { }{ { name: "successful issues search with all parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:issue repo:owner/repo is:open", - "sort": "created", - "order": "desc", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:issue repo:owner/repo is:open", + "sort": "created", + "order": "desc", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "repo:owner/repo is:open", "sort": "created", @@ -575,23 +553,20 @@ func Test_SearchIssues(t *testing.T) { }, { name: "issues search with owner and repo parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "repo:test-owner/test-repo is:issue is:open", - "sort": "created", - "order": "asc", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "repo:test-owner/test-repo is:issue is:open", + "sort": "created", + "order": "asc", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "is:open", "owner": "test-owner", @@ -604,21 +579,18 @@ func Test_SearchIssues(t *testing.T) { }, { name: "issues search with only owner parameter (should ignore it)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:issue bug", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:issue bug", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "bug", "owner": "test-owner", @@ -628,21 +600,18 @@ func Test_SearchIssues(t *testing.T) { }, { name: "issues search with only repo parameter (should ignore it)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:issue feature", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:issue feature", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "feature", "repo": "test-repo", @@ -652,12 +621,9 @@ func Test_SearchIssues(t *testing.T) { }, { name: "issues search with minimal parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetSearchIssues, - mockSearchResult, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: mockResponse(t, http.StatusOK, mockSearchResult), + }), requestArgs: map[string]interface{}{ "query": "is:issue repo:owner/repo is:open", }, @@ -666,21 +632,18 @@ func Test_SearchIssues(t *testing.T) { }, { name: "query with existing is:issue filter - no duplication", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "repo:github/github-mcp-server is:issue is:open (label:critical OR label:urgent)", }, @@ -689,21 +652,18 @@ func Test_SearchIssues(t *testing.T) { }, { name: "query with existing repo: filter and conflicting owner/repo params - uses query filter", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:issue repo:github/github-mcp-server critical", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:issue repo:github/github-mcp-server critical", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "repo:github/github-mcp-server critical", "owner": "different-owner", @@ -714,21 +674,18 @@ func Test_SearchIssues(t *testing.T) { }, { name: "query with both is: and repo: filters already present", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:issue repo:octocat/Hello-World bug", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:issue repo:octocat/Hello-World bug", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "is:issue repo:octocat/Hello-World bug", }, @@ -737,21 +694,18 @@ func Test_SearchIssues(t *testing.T) { }, { name: "complex query with multiple OR operators and existing filters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "repo:github/github-mcp-server is:issue (label:critical OR label:urgent OR label:high-priority OR label:blocker)", }, @@ -760,15 +714,12 @@ func Test_SearchIssues(t *testing.T) { }, { name: "search issues fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }), + }), requestArgs: map[string]interface{}{ "query": "invalid:query", }, @@ -868,21 +819,18 @@ func Test_CreateIssue(t *testing.T) { }{ { name: "successful issue creation with all fields", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesByOwnerByRepo, - expectRequestBody(t, map[string]any{ - "title": "Test Issue", - "body": "This is a test issue", - "labels": []any{"bug", "help wanted"}, - "assignees": []any{"user1", "user2"}, - "milestone": float64(5), - "type": "Bug", - }).andThen( - mockResponse(t, http.StatusCreated, mockIssue), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesByOwnerByRepo: expectRequestBody(t, map[string]any{ + "title": "Test Issue", + "body": "This is a test issue", + "labels": []any{"bug", "help wanted"}, + "assignees": []any{"user1", "user2"}, + "milestone": float64(5), + "type": "Bug", + }).andThen( + mockResponse(t, http.StatusCreated, mockIssue), ), - ), + }), requestArgs: map[string]interface{}{ "method": "create", "owner": "owner", @@ -899,17 +847,14 @@ func Test_CreateIssue(t *testing.T) { }, { name: "successful issue creation with minimal fields", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesByOwnerByRepo, - mockResponse(t, http.StatusCreated, &github.Issue{ - Number: github.Ptr(124), - Title: github.Ptr("Minimal Issue"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"), - State: github.Ptr("open"), - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesByOwnerByRepo: mockResponse(t, http.StatusCreated, &github.Issue{ + Number: github.Ptr(124), + Title: github.Ptr("Minimal Issue"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"), + State: github.Ptr("open"), + }), + }), requestArgs: map[string]interface{}{ "method": "create", "owner": "owner", @@ -927,15 +872,12 @@ func Test_CreateIssue(t *testing.T) { }, { name: "issue creation fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message": "Validation failed"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesByOwnerByRepo: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Validation failed"}`)) + }), + }), requestArgs: map[string]interface{}{ "method": "create", "owner": "owner", @@ -1427,17 +1369,14 @@ func Test_UpdateIssue(t *testing.T) { }{ { name: "partial update of non-state fields only", - mockedRESTClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesByOwnerByRepoByIssueNumber, - expectRequestBody(t, map[string]interface{}{ - "title": "Updated Title", - "body": "Updated Description", - }).andThen( - mockResponse(t, http.StatusOK, mockUpdatedIssue), - ), - ), - ), + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]interface{}{ + "title": "Updated Title", + "body": "Updated Description", + }).andThen( + mockResponse(t, http.StatusOK, mockUpdatedIssue), + ), + }), mockedGQLClient: githubv4mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "method": "update", @@ -1452,15 +1391,12 @@ func Test_UpdateIssue(t *testing.T) { }, { name: "issue not found when updating non-state fields only", - mockedRESTClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesByOwnerByRepoByIssueNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + }), mockedGQLClient: githubv4mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "method": "update", @@ -1474,12 +1410,9 @@ func Test_UpdateIssue(t *testing.T) { }, { name: "close issue as duplicate", - mockedRESTClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.PatchReposIssuesByOwnerByRepoByIssueNumber, - mockBaseIssue, - ), - ), + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue), + }), mockedGQLClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( struct { @@ -1534,12 +1467,9 @@ func Test_UpdateIssue(t *testing.T) { }, { name: "reopen issue", - mockedRESTClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.PatchReposIssuesByOwnerByRepoByIssueNumber, - mockBaseIssue, - ), - ), + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue), + }), mockedGQLClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( struct { @@ -1586,12 +1516,9 @@ func Test_UpdateIssue(t *testing.T) { }, { name: "main issue not found when trying to close it", - mockedRESTClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.PatchReposIssuesByOwnerByRepoByIssueNumber, - mockBaseIssue, - ), - ), + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue), + }), mockedGQLClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( struct { @@ -1622,12 +1549,9 @@ func Test_UpdateIssue(t *testing.T) { }, { name: "duplicate issue not found when closing as duplicate", - mockedRESTClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.PatchReposIssuesByOwnerByRepoByIssueNumber, - mockBaseIssue, - ), - ), + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue), + }), mockedGQLClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( struct { @@ -1663,31 +1587,28 @@ func Test_UpdateIssue(t *testing.T) { }, { name: "close as duplicate with combined non-state updates", - mockedRESTClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesByOwnerByRepoByIssueNumber, - expectRequestBody(t, map[string]interface{}{ - "title": "Updated Title", - "body": "Updated Description", - "labels": []any{"bug", "priority"}, - "assignees": []any{"assignee1", "assignee2"}, - "milestone": float64(5), - "type": "Bug", - }).andThen( - mockResponse(t, http.StatusOK, &github.Issue{ - Number: github.Ptr(123), - Title: github.Ptr("Updated Title"), - Body: github.Ptr("Updated Description"), - Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}}, - Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}}, - Milestone: &github.Milestone{Number: github.Ptr(5)}, - Type: &github.IssueType{Name: github.Ptr("Bug")}, - State: github.Ptr("open"), // Still open after REST update - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), - }), - ), + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]interface{}{ + "title": "Updated Title", + "body": "Updated Description", + "labels": []any{"bug", "priority"}, + "assignees": []any{"assignee1", "assignee2"}, + "milestone": float64(5), + "type": "Bug", + }).andThen( + mockResponse(t, http.StatusOK, &github.Issue{ + Number: github.Ptr(123), + Title: github.Ptr("Updated Title"), + Body: github.Ptr("Updated Description"), + Labels: []*github.Label{{Name: github.Ptr("bug")}, {Name: github.Ptr("priority")}}, + Assignees: []*github.User{{Login: github.Ptr("assignee1")}, {Login: github.Ptr("assignee2")}}, + Milestone: &github.Milestone{Number: github.Ptr(5)}, + Type: &github.IssueType{Name: github.Ptr("Bug")}, + State: github.Ptr("open"), // Still open after REST update + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), + }), ), - ), + }), mockedGQLClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( struct { @@ -1748,7 +1669,7 @@ func Test_UpdateIssue(t *testing.T) { }, { name: "duplicate_of without duplicate state_reason should fail", - mockedRESTClient: mock.NewMockedHTTPClient(), + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), mockedGQLClient: githubv4mock.NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "method": "update", @@ -1910,12 +1831,9 @@ func Test_GetIssueComments(t *testing.T) { }{ { name: "successful comments retrieval", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, - mockComments, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockComments), + }), requestArgs: map[string]interface{}{ "method": "get_comments", "owner": "owner", @@ -1927,17 +1845,14 @@ func Test_GetIssueComments(t *testing.T) { }, { name: "successful comments retrieval with pagination", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, - expectQueryParams(t, map[string]string{ - "page": "2", - "per_page": "10", - }).andThen( - mockResponse(t, http.StatusOK, mockComments), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesCommentsByOwnerByRepoByIssueNumber: expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockComments), ), - ), + }), requestArgs: map[string]interface{}{ "method": "get_comments", "owner": "owner", @@ -1951,12 +1866,9 @@ func Test_GetIssueComments(t *testing.T) { }, { name: "issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Issue not found"}`), + }), requestArgs: map[string]interface{}{ "method": "get_comments", "owner": "owner", @@ -1968,23 +1880,20 @@ func Test_GetIssueComments(t *testing.T) { }, { name: "lockdown enabled filters comments without push access", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, - []*github.IssueComment{ - { - ID: github.Ptr(int64(789)), - Body: github.Ptr("Maintainer comment"), - User: &github.User{Login: github.Ptr("maintainer")}, - }, - { - ID: github.Ptr(int64(790)), - Body: github.Ptr("External user comment"), - User: &github.User{Login: github.Ptr("testuser")}, - }, + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesCommentsByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, []*github.IssueComment{ + { + ID: github.Ptr(int64(789)), + Body: github.Ptr("Maintainer comment"), + User: &github.User{Login: github.Ptr("maintainer")}, }, - ), - ), + { + ID: github.Ptr(int64(790)), + Body: github.Ptr("External user comment"), + User: &github.User{Login: github.Ptr("testuser")}, + }, + }), + }), gqlHTTPClient: newRepoAccessHTTPClient(), requestArgs: map[string]interface{}{ "method": "get_comments", @@ -2631,12 +2540,9 @@ func Test_AddSubIssue(t *testing.T) { }{ { name: "successful sub-issue addition with all parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusCreated, mockIssue), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockIssue), + }), requestArgs: map[string]interface{}{ "method": "add", "owner": "owner", @@ -2650,12 +2556,9 @@ func Test_AddSubIssue(t *testing.T) { }, { name: "successful sub-issue addition with minimal parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusCreated, mockIssue), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockIssue), + }), requestArgs: map[string]interface{}{ "method": "add", "owner": "owner", @@ -2668,12 +2571,9 @@ func Test_AddSubIssue(t *testing.T) { }, { name: "successful sub-issue addition with replace_parent false", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusCreated, mockIssue), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusCreated, mockIssue), + }), requestArgs: map[string]interface{}{ "method": "add", "owner": "owner", @@ -2687,12 +2587,9 @@ func Test_AddSubIssue(t *testing.T) { }, { name: "parent issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Parent issue not found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Parent issue not found"}`), + }), requestArgs: map[string]interface{}{ "method": "add", "owner": "owner", @@ -2705,12 +2602,9 @@ func Test_AddSubIssue(t *testing.T) { }, { name: "sub-issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), + }), requestArgs: map[string]interface{}{ "method": "add", "owner": "owner", @@ -2723,12 +2617,9 @@ func Test_AddSubIssue(t *testing.T) { }, { name: "validation failed - sub-issue cannot be parent of itself", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Sub-issue cannot be a parent of itself"}]}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Sub-issue cannot be a parent of itself"}]}`), + }), requestArgs: map[string]interface{}{ "method": "add", "owner": "owner", @@ -2741,12 +2632,9 @@ func Test_AddSubIssue(t *testing.T) { }, { name: "insufficient permissions", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), + }), requestArgs: map[string]interface{}{ "method": "add", "owner": "owner", @@ -2759,9 +2647,7 @@ func Test_AddSubIssue(t *testing.T) { }, { name: "missing required parameter owner", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "add", "repo": "repo", @@ -2773,9 +2659,7 @@ func Test_AddSubIssue(t *testing.T) { }, { name: "missing required parameter sub_issue_id", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "add", "owner": "owner", @@ -2895,12 +2779,9 @@ func Test_GetSubIssues(t *testing.T) { }{ { name: "successful sub-issues listing with minimal parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockSubIssues, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockSubIssues), + }), requestArgs: map[string]interface{}{ "method": "get_sub_issues", "owner": "owner", @@ -2912,17 +2793,14 @@ func Test_GetSubIssues(t *testing.T) { }, { name: "successful sub-issues listing with pagination", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - expectQueryParams(t, map[string]string{ - "page": "2", - "per_page": "10", - }).andThen( - mockResponse(t, http.StatusOK, mockSubIssues), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockSubIssues), ), - ), + }), requestArgs: map[string]interface{}{ "method": "get_sub_issues", "owner": "owner", @@ -2936,12 +2814,9 @@ func Test_GetSubIssues(t *testing.T) { }, { name: "successful sub-issues listing with empty result", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - []*github.Issue{}, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, []*github.Issue{}), + }), requestArgs: map[string]interface{}{ "method": "get_sub_issues", "owner": "owner", @@ -2953,12 +2828,9 @@ func Test_GetSubIssues(t *testing.T) { }, { name: "parent issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + }), requestArgs: map[string]interface{}{ "method": "get_sub_issues", "owner": "owner", @@ -2970,12 +2842,9 @@ func Test_GetSubIssues(t *testing.T) { }, { name: "repository not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + }), requestArgs: map[string]interface{}{ "method": "get_sub_issues", "owner": "nonexistent", @@ -2987,12 +2856,9 @@ func Test_GetSubIssues(t *testing.T) { }, { name: "sub-issues feature gone/deprecated", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusGone, `{"message": "This feature has been deprecated"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesSubIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusGone, `{"message": "This feature has been deprecated"}`), + }), requestArgs: map[string]interface{}{ "method": "get_sub_issues", "owner": "owner", @@ -3004,9 +2870,7 @@ func Test_GetSubIssues(t *testing.T) { }, { name: "missing required parameter owner", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "get_sub_issues", "repo": "repo", @@ -3017,9 +2881,7 @@ func Test_GetSubIssues(t *testing.T) { }, { name: "missing required parameter issue_number", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "get_sub_issues", "owner": "owner", @@ -3135,12 +2997,9 @@ func Test_RemoveSubIssue(t *testing.T) { }{ { name: "successful sub-issue removal", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusOK, mockIssue), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue), + }), requestArgs: map[string]interface{}{ "method": "remove", "owner": "owner", @@ -3153,12 +3012,9 @@ func Test_RemoveSubIssue(t *testing.T) { }, { name: "parent issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + }), requestArgs: map[string]interface{}{ "method": "remove", "owner": "owner", @@ -3171,12 +3027,9 @@ func Test_RemoveSubIssue(t *testing.T) { }, { name: "sub-issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), + }), requestArgs: map[string]interface{}{ "method": "remove", "owner": "owner", @@ -3189,12 +3042,9 @@ func Test_RemoveSubIssue(t *testing.T) { }, { name: "bad request - invalid sub_issue_id", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusBadRequest, `{"message": "Invalid sub_issue_id"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusBadRequest, `{"message": "Invalid sub_issue_id"}`), + }), requestArgs: map[string]interface{}{ "method": "remove", "owner": "owner", @@ -3207,12 +3057,9 @@ func Test_RemoveSubIssue(t *testing.T) { }, { name: "repository not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + }), requestArgs: map[string]interface{}{ "method": "remove", "owner": "nonexistent", @@ -3225,12 +3072,9 @@ func Test_RemoveSubIssue(t *testing.T) { }, { name: "insufficient permissions", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteReposIssuesSubIssueByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), + }), requestArgs: map[string]interface{}{ "method": "remove", "owner": "owner", @@ -3243,9 +3087,7 @@ func Test_RemoveSubIssue(t *testing.T) { }, { name: "missing required parameter owner", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "remove", "repo": "repo", @@ -3257,9 +3099,7 @@ func Test_RemoveSubIssue(t *testing.T) { }, { name: "missing required parameter sub_issue_id", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "remove", "owner": "owner", @@ -3365,12 +3205,9 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }{ { name: "successful reprioritization with after_id", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusOK, mockIssue), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue), + }), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3384,12 +3221,9 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "successful reprioritization with before_id", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusOK, mockIssue), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue), + }), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3403,9 +3237,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "validation error - neither after_id nor before_id specified", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3418,9 +3250,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "validation error - both after_id and before_id specified", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3435,12 +3265,9 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "parent issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`), + }), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3454,12 +3281,9 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "sub-issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusNotFound, `{"message": "Sub-issue not found"}`), + }), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3473,12 +3297,9 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "validation failed - positioning sub-issue not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Positioning sub-issue not found"}]}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusUnprocessableEntity, `{"message": "Validation failed", "errors": [{"message": "Positioning sub-issue not found"}]}`), + }), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3492,12 +3313,9 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "insufficient permissions", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusForbidden, `{"message": "Must have write access to repository"}`), + }), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3511,12 +3329,9 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "service unavailable", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber, - mockResponse(t, http.StatusServiceUnavailable, `{"message": "Service Unavailable"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesSubIssuesPriorityByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusServiceUnavailable, `{"message": "Service Unavailable"}`), + }), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3530,9 +3345,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "missing required parameter owner", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "reprioritize", "repo": "repo", @@ -3545,9 +3358,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { }, { name: "missing required parameter sub_issue_id", - mockedClient: mock.NewMockedHTTPClient( - // No mocked requests needed since validation fails before HTTP call - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "method": "reprioritize", "owner": "owner", @@ -3645,15 +3456,9 @@ func Test_ListIssueTypes(t *testing.T) { }{ { name: "successful issue types retrieval", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/orgs/testorg/issue-types", - Method: "GET", - }, - mockResponse(t, http.StatusOK, mockIssueTypes), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/testorg/issue-types": mockResponse(t, http.StatusOK, mockIssueTypes), + }), requestArgs: map[string]interface{}{ "owner": "testorg", }, @@ -3662,15 +3467,9 @@ func Test_ListIssueTypes(t *testing.T) { }, { name: "organization not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/orgs/nonexistent/issue-types", - Method: "GET", - }, - mockResponse(t, http.StatusNotFound, `{"message": "Organization not found"}`), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/nonexistent/issue-types": mockResponse(t, http.StatusNotFound, `{"message": "Organization not found"}`), + }), requestArgs: map[string]interface{}{ "owner": "nonexistent", }, @@ -3679,15 +3478,9 @@ func Test_ListIssueTypes(t *testing.T) { }, { name: "missing owner parameter", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/orgs/testorg/issue-types", - Method: "GET", - }, - mockResponse(t, http.StatusOK, mockIssueTypes), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/testorg/issue-types": mockResponse(t, http.StatusOK, mockIssueTypes), + }), requestArgs: map[string]interface{}{}, expectError: false, // This should be handled by parameter validation, error returned in result expectedErrMsg: "missing required parameter: owner", diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index e443b9ecd..e3a50af29 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -3,7 +3,6 @@ package github import ( "context" "encoding/json" - "io" "net/http" "testing" @@ -11,7 +10,6 @@ import ( "github.com/github/github-mcp-server/pkg/translations" gh "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" - "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -45,15 +43,9 @@ func Test_ListProjects(t *testing.T) { }{ { name: "success organization", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgProjects)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2: mockResponse(t, http.StatusOK, orgProjects), + }), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -63,15 +55,9 @@ func Test_ListProjects(t *testing.T) { }, { name: "success user", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{username}/projectsV2", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(userProjects)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2ByUsername: mockResponse(t, http.StatusOK, userProjects), + }), requestArgs: map[string]interface{}{ "owner": "octocat", "owner_type": "user", @@ -81,21 +67,12 @@ func Test_ListProjects(t *testing.T) { }, { name: "success organization with pagination & query", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if q.Get("per_page") == "50" && q.Get("q") == "roadmap" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgProjects)) - return - } - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2: expectQueryParams(t, map[string]string{ + "per_page": "50", + "q": "roadmap", + }).andThen(mockResponse(t, http.StatusOK, orgProjects)), + }), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -107,12 +84,9 @@ func Test_ListProjects(t *testing.T) { }, { name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + }), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -122,7 +96,7 @@ func Test_ListProjects(t *testing.T) { }, { name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner_type": "org", }, @@ -130,7 +104,7 @@ func Test_ListProjects(t *testing.T) { }, { name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner": "octo-org", }, @@ -204,12 +178,9 @@ func Test_GetProject(t *testing.T) { }{ { name: "success organization project fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/123", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, project), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project), + }), requestArgs: map[string]interface{}{ "project_number": float64(123), "owner": "octo-org", @@ -219,12 +190,9 @@ func Test_GetProject(t *testing.T) { }, { name: "success user project fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{username}/projectsV2/456", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, project), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2ByUsernameByProject: mockResponse(t, http.StatusOK, project), + }), requestArgs: map[string]interface{}{ "project_number": float64(456), "owner": "octocat", @@ -234,12 +202,9 @@ func Test_GetProject(t *testing.T) { }, { name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/999", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + }), requestArgs: map[string]interface{}{ "project_number": float64(999), "owner": "octo-org", @@ -250,7 +215,7 @@ func Test_GetProject(t *testing.T) { }, { name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -259,7 +224,7 @@ func Test_GetProject(t *testing.T) { }, { name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "project_number": float64(123), "owner_type": "org", @@ -268,7 +233,7 @@ func Test_GetProject(t *testing.T) { }, { name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "project_number": float64(123), "owner": "octo-org", @@ -343,15 +308,9 @@ func Test_ListProjectFields(t *testing.T) { }{ { name: "success organization fields", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgFields)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2FieldsByProject: mockResponse(t, http.StatusOK, orgFields), + }), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -361,21 +320,11 @@ func Test_ListProjectFields(t *testing.T) { }, { name: "success user fields with per_page override", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/fields", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if q.Get("per_page") == "50" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(userFields)) - return - } - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2FieldsByUsernameByProject: expectQueryParams(t, map[string]string{ + "per_page": "50", + }).andThen(mockResponse(t, http.StatusOK, userFields)), + }), requestArgs: map[string]interface{}{ "owner": "octocat", "owner_type": "user", @@ -386,12 +335,9 @@ func Test_ListProjectFields(t *testing.T) { }, { name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2FieldsByProject: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + }), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -402,7 +348,7 @@ func Test_ListProjectFields(t *testing.T) { }, { name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner_type": "org", "project_number": 10, @@ -411,7 +357,7 @@ func Test_ListProjectFields(t *testing.T) { }, { name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner": "octo-org", "project_number": 10, @@ -420,7 +366,7 @@ func Test_ListProjectFields(t *testing.T) { }, { name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -500,12 +446,9 @@ func Test_GetProjectField(t *testing.T) { }{ { name: "success organization field", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, orgField), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2FieldsByProjectByFieldID: mockResponse(t, http.StatusOK, orgField), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -516,12 +459,9 @@ func Test_GetProjectField(t *testing.T) { }, { name: "success user field", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, userField), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2FieldsByUsernameByProjectByFieldID: mockResponse(t, http.StatusOK, userField), + }), requestArgs: map[string]any{ "owner": "octocat", "owner_type": "user", @@ -532,12 +472,9 @@ func Test_GetProjectField(t *testing.T) { }, { name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/fields/{field_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2FieldsByProjectByFieldID: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -549,7 +486,7 @@ func Test_GetProjectField(t *testing.T) { }, { name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner_type": "org", "project_number": float64(10), @@ -559,7 +496,7 @@ func Test_GetProjectField(t *testing.T) { }, { name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "project_number": float64(10), @@ -569,7 +506,7 @@ func Test_GetProjectField(t *testing.T) { }, { name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -579,7 +516,7 @@ func Test_GetProjectField(t *testing.T) { }, { name: "missing field_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -671,12 +608,9 @@ func Test_ListProjectItems(t *testing.T) { }{ { name: "success organization items", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, orgItems), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProject: mockResponse(t, http.StatusOK, orgItems), + }), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -686,21 +620,12 @@ func Test_ListProjectItems(t *testing.T) { }, { name: "success organization items with fields", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if q.Get("fields") == "123,456,789" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgItems)) - return - } - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProject: expectQueryParams(t, map[string]string{ + "fields": "123,456,789", + "per_page": "50", + }).andThen(mockResponse(t, http.StatusOK, orgItems)), + }), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -711,12 +636,9 @@ func Test_ListProjectItems(t *testing.T) { }, { name: "success user items", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, userItems), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2ItemsByUsernameByProject: mockResponse(t, http.StatusOK, userItems), + }), requestArgs: map[string]interface{}{ "owner": "octocat", "owner_type": "user", @@ -726,21 +648,12 @@ func Test_ListProjectItems(t *testing.T) { }, { name: "success with pagination and query", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if q.Get("per_page") == "50" && q.Get("q") == "bug" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgItems)) - return - } - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProject: expectQueryParams(t, map[string]string{ + "per_page": "50", + "q": "bug", + }).andThen(mockResponse(t, http.StatusOK, orgItems)), + }), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -752,12 +665,9 @@ func Test_ListProjectItems(t *testing.T) { }, { name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProject: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + }), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -768,7 +678,7 @@ func Test_ListProjectItems(t *testing.T) { }, { name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner_type": "org", "project_number": float64(10), @@ -777,7 +687,7 @@ func Test_ListProjectItems(t *testing.T) { }, { name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner": "octo-org", "project_number": float64(10), @@ -786,7 +696,7 @@ func Test_ListProjectItems(t *testing.T) { }, { name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner": "octo-org", "owner_type": "org", @@ -877,12 +787,9 @@ func Test_GetProjectItem(t *testing.T) { }{ { name: "success organization item", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, orgItem), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, orgItem), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -893,21 +800,11 @@ func Test_GetProjectItem(t *testing.T) { }, { name: "success organization item with fields", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - q := r.URL.Query() - if q.Get("fields") == "123,456" { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgItem)) - return - } - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message":"unexpected query params"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProjectByItemID: expectQueryParams(t, map[string]string{ + "fields": "123,456", + }).andThen(mockResponse(t, http.StatusOK, orgItem)), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -919,12 +816,9 @@ func Test_GetProjectItem(t *testing.T) { }, { name: "success user item", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusOK, userItem), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2ItemsByUsernameByProjectByItemID: mockResponse(t, http.StatusOK, userItem), + }), requestArgs: map[string]any{ "owner": "octocat", "owner_type": "user", @@ -935,12 +829,9 @@ func Test_GetProjectItem(t *testing.T) { }, { name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodGet}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -952,7 +843,7 @@ func Test_GetProjectItem(t *testing.T) { }, { name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner_type": "org", "project_number": float64(10), @@ -962,7 +853,7 @@ func Test_GetProjectItem(t *testing.T) { }, { name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "project_number": float64(10), @@ -972,7 +863,7 @@ func Test_GetProjectItem(t *testing.T) { }, { name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -982,7 +873,7 @@ func Test_GetProjectItem(t *testing.T) { }, { name: "missing item_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1086,24 +977,12 @@ func Test_AddProjectItem(t *testing.T) { }{ { name: "success organization issue", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodPost}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - var payload struct { - Type string `json:"type"` - ID int `json:"id"` - } - assert.NoError(t, json.Unmarshal(body, &payload)) - assert.Equal(t, "Issue", payload.Type) - assert.Equal(t, 9876, payload.ID) - w.WriteHeader(http.StatusCreated) - _, _ = w.Write(mock.MustMarshal(orgItem)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostOrgsProjectsV2ItemsByProject: expectRequestBody(t, map[string]any{ + "type": "Issue", + "id": float64(9876), + }).andThen(mockResponse(t, http.StatusCreated, orgItem)), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1117,24 +996,12 @@ func Test_AddProjectItem(t *testing.T) { }, { name: "success user pull request", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items", Method: http.MethodPost}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - var payload struct { - Type string `json:"type"` - ID int `json:"id"` - } - assert.NoError(t, json.Unmarshal(body, &payload)) - assert.Equal(t, "PullRequest", payload.Type) - assert.Equal(t, 7654, payload.ID) - w.WriteHeader(http.StatusCreated) - _, _ = w.Write(mock.MustMarshal(userItem)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostUsersProjectsV2ItemsByUsernameByProject: expectRequestBody(t, map[string]any{ + "type": "PullRequest", + "id": float64(7654), + }).andThen(mockResponse(t, http.StatusCreated, userItem)), + }), requestArgs: map[string]any{ "owner": "octocat", "owner_type": "user", @@ -1148,12 +1015,9 @@ func Test_AddProjectItem(t *testing.T) { }, { name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items", Method: http.MethodPost}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostOrgsProjectsV2ItemsByProject: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1166,7 +1030,7 @@ func Test_AddProjectItem(t *testing.T) { }, { name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner_type": "org", "project_number": float64(1), @@ -1177,7 +1041,7 @@ func Test_AddProjectItem(t *testing.T) { }, { name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "project_number": float64(1), @@ -1188,7 +1052,7 @@ func Test_AddProjectItem(t *testing.T) { }, { name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1199,7 +1063,7 @@ func Test_AddProjectItem(t *testing.T) { }, { name: "missing item_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1210,7 +1074,7 @@ func Test_AddProjectItem(t *testing.T) { }, { name: "missing item_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1310,27 +1174,11 @@ func Test_UpdateProjectItem(t *testing.T) { }{ { name: "success organization update", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - var payload struct { - Fields []struct { - ID int `json:"id"` - Value interface{} `json:"value"` - } `json:"fields"` - } - assert.NoError(t, json.Unmarshal(body, &payload)) - require.Len(t, payload.Fields, 1) - assert.Equal(t, 101, payload.Fields[0].ID) - assert.Equal(t, "Done", payload.Fields[0].Value) - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgUpdatedItem)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchOrgsProjectsV2ItemsByProjectByItemID: expectRequestBody(t, map[string]any{ + "fields": []any{map[string]any{"id": float64(101), "value": "Done"}}, + }).andThen(mockResponse(t, http.StatusOK, orgUpdatedItem)), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1345,27 +1193,11 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "success user update", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, err := io.ReadAll(r.Body) - assert.NoError(t, err) - var payload struct { - Fields []struct { - ID int `json:"id"` - Value interface{} `json:"value"` - } `json:"fields"` - } - assert.NoError(t, json.Unmarshal(body, &payload)) - require.Len(t, payload.Fields, 1) - assert.Equal(t, 202, payload.Fields[0].ID) - assert.Equal(t, 42.0, payload.Fields[0].Value) - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(userUpdatedItem)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchUsersProjectsV2ItemsByUsernameByProjectByItemID: expectRequestBody(t, map[string]any{ + "fields": []any{map[string]any{"id": float64(202), "value": float64(42)}}, + }).andThen(mockResponse(t, http.StatusOK, userUpdatedItem)), + }), requestArgs: map[string]any{ "owner": "octocat", "owner_type": "user", @@ -1380,12 +1212,9 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodPatch}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1401,7 +1230,7 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner_type": "org", "project_number": float64(1), @@ -1415,7 +1244,7 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "project_number": float64(1), @@ -1429,7 +1258,7 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1443,7 +1272,7 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "missing item_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1457,7 +1286,7 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "missing updated_field", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1468,7 +1297,7 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "updated_field not object", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1480,7 +1309,7 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "updated_field missing id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1492,7 +1321,7 @@ func Test_UpdateProjectItem(t *testing.T) { }, { name: "updated_field missing value", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1580,14 +1409,11 @@ func Test_DeleteProjectItem(t *testing.T) { }{ { name: "success organization delete", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteOrgsProjectsV2ItemsByProjectByItemID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1598,14 +1424,11 @@ func Test_DeleteProjectItem(t *testing.T) { }, { name: "success user delete", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{user}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteUsersProjectsV2ItemsByUsernameByProjectByItemID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + }), requestArgs: map[string]any{ "owner": "octocat", "owner_type": "user", @@ -1616,12 +1439,9 @@ func Test_DeleteProjectItem(t *testing.T) { }, { name: "api error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/{project}/items/{item_id}", Method: http.MethodDelete}, - mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "boom"}), + }), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1633,7 +1453,7 @@ func Test_DeleteProjectItem(t *testing.T) { }, { name: "missing owner", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner_type": "org", "project_number": float64(1), @@ -1643,7 +1463,7 @@ func Test_DeleteProjectItem(t *testing.T) { }, { name: "missing owner_type", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "project_number": float64(1), @@ -1653,7 +1473,7 @@ func Test_DeleteProjectItem(t *testing.T) { }, { name: "missing project_number", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1663,7 +1483,7 @@ func Test_DeleteProjectItem(t *testing.T) { }, { name: "missing item_id", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 3cb41515d..d2664479d 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -14,8 +14,6 @@ import ( "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" - - "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -64,12 +62,9 @@ func Test_GetPullRequest(t *testing.T) { }{ { name: "successful PR fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockPR, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + }), requestArgs: map[string]interface{}{ "method": "get", "owner": "owner", @@ -81,15 +76,12 @@ func Test_GetPullRequest(t *testing.T) { }, { name: "PR fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }, + }), requestArgs: map[string]interface{}{ "method": "get", "owner": "owner", @@ -209,24 +201,17 @@ func Test_UpdatePullRequest(t *testing.T) { }{ { name: "successful PR update (title, body, base, maintainer_can_modify)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposPullsByOwnerByRepoByPullNumber, - // Expect the flat string based on previous test failure output and API docs - expectRequestBody(t, map[string]interface{}{ - "title": "Updated Test PR Title", - "body": "Updated test PR body.", - "base": "develop", - "maintainer_can_modify": false, - }).andThen( - mockResponse(t, http.StatusOK, mockUpdatedPR), - ), - ), - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockUpdatedPR, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]interface{}{ + "title": "Updated Test PR Title", + "body": "Updated test PR body.", + "base": "develop", + "maintainer_can_modify": false, + }).andThen( + mockResponse(t, http.StatusOK, mockUpdatedPR), + ), + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockUpdatedPR), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -241,20 +226,14 @@ func Test_UpdatePullRequest(t *testing.T) { }, { name: "successful PR update (state)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposPullsByOwnerByRepoByPullNumber, - expectRequestBody(t, map[string]interface{}{ - "state": "closed", - }).andThen( - mockResponse(t, http.StatusOK, mockClosedPR), - ), - ), - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockClosedPR, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]interface{}{ + "state": "closed", + }).andThen( + mockResponse(t, http.StatusOK, mockClosedPR), + ), + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockClosedPR), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -266,17 +245,10 @@ func Test_UpdatePullRequest(t *testing.T) { }, { name: "successful PR update with reviewers", - mockedClient: mock.NewMockedHTTPClient( - // Mock for RequestReviewers call, returning the PR with reviewers - mock.WithRequestMatch( - mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, - mockPRWithReviewers, - ), - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockPRWithReviewers, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPRWithReviewers), + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPRWithReviewers), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -288,20 +260,14 @@ func Test_UpdatePullRequest(t *testing.T) { }, { name: "successful PR update (title only)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposPullsByOwnerByRepoByPullNumber, - expectRequestBody(t, map[string]interface{}{ - "title": "Updated Test PR Title", - }).andThen( - mockResponse(t, http.StatusOK, mockUpdatedPR), - ), - ), - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockUpdatedPR, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]interface{}{ + "title": "Updated Test PR Title", + }).andThen( + mockResponse(t, http.StatusOK, mockUpdatedPR), + ), + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockUpdatedPR), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -313,7 +279,7 @@ func Test_UpdatePullRequest(t *testing.T) { }, { name: "no update parameters provided", - mockedClient: mock.NewMockedHTTPClient(), // No API call expected + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), // No API call expected requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -325,15 +291,12 @@ func Test_UpdatePullRequest(t *testing.T) { }, { name: "PR update fails (API error)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PatchReposPullsByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposPullsByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -345,16 +308,12 @@ func Test_UpdatePullRequest(t *testing.T) { }, { name: "request reviewers fails", - mockedClient: mock.NewMockedHTTPClient( - // Then reviewer request fails - mock.WithRequestMatchHandler( - mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message": "Invalid reviewers"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Invalid reviewers"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -553,12 +512,9 @@ 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(mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockUpdatedPR, - ), - )) + restClient := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockUpdatedPR), + })) gqlClient := githubv4.NewClient(tc.mockedClient) serverTool := UpdatePullRequest(translations.NullTranslationHelper) @@ -642,20 +598,17 @@ func Test_ListPullRequests(t *testing.T) { }{ { name: "successful PRs listing", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsByOwnerByRepo, - expectQueryParams(t, map[string]string{ - "state": "all", - "sort": "created", - "direction": "desc", - "per_page": "30", - "page": "1", - }).andThen( - mockResponse(t, http.StatusOK, mockPRs), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "state": "all", + "sort": "created", + "direction": "desc", + "per_page": "30", + "page": "1", + }).andThen( + mockResponse(t, http.StatusOK, mockPRs), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -670,15 +623,12 @@ func Test_ListPullRequests(t *testing.T) { }, { name: "PRs listing fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepo: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -769,18 +719,15 @@ func Test_MergePullRequest(t *testing.T) { }{ { name: "successful merge", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposPullsMergeByOwnerByRepoByPullNumber, - expectRequestBody(t, map[string]interface{}{ - "commit_title": "Merge PR #42", - "commit_message": "Merging awesome feature", - "merge_method": "squash", - }).andThen( - mockResponse(t, http.StatusOK, mockMergeResult), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposPullsMergeByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]interface{}{ + "commit_title": "Merge PR #42", + "commit_message": "Merging awesome feature", + "merge_method": "squash", + }).andThen( + mockResponse(t, http.StatusOK, mockMergeResult), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -794,15 +741,12 @@ func Test_MergePullRequest(t *testing.T) { }, { name: "merge fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposPullsMergeByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusMethodNotAllowed) - _, _ = w.Write([]byte(`{"message": "Pull request cannot be merged"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposPullsMergeByOwnerByRepoByPullNumber: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusMethodNotAllowed) + _, _ = w.Write([]byte(`{"message": "Pull request cannot be merged"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -911,23 +855,20 @@ func Test_SearchPullRequests(t *testing.T) { }{ { name: "successful pull request search with all parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:pr repo:owner/repo is:open", - "sort": "created", - "order": "desc", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:pr repo:owner/repo is:open", + "sort": "created", + "order": "desc", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "repo:owner/repo is:open", "sort": "created", @@ -940,23 +881,20 @@ func Test_SearchPullRequests(t *testing.T) { }, { name: "pull request search with owner and repo parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "repo:test-owner/test-repo is:pr draft:false", - "sort": "updated", - "order": "asc", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "repo:test-owner/test-repo is:pr draft:false", + "sort": "updated", + "order": "asc", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "draft:false", "owner": "test-owner", @@ -969,21 +907,18 @@ func Test_SearchPullRequests(t *testing.T) { }, { name: "pull request search with only owner parameter (should ignore it)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:pr feature", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:pr feature", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "feature", "owner": "test-owner", @@ -993,21 +928,18 @@ func Test_SearchPullRequests(t *testing.T) { }, { name: "pull request search with only repo parameter (should ignore it)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:pr review-required", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:pr review-required", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "review-required", "repo": "test-repo", @@ -1017,12 +949,9 @@ func Test_SearchPullRequests(t *testing.T) { }, { name: "pull request search with minimal parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetSearchIssues, - mockSearchResult, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: mockResponse(t, http.StatusOK, mockSearchResult), + }), requestArgs: map[string]interface{}{ "query": "is:pr repo:owner/repo is:open", }, @@ -1031,21 +960,18 @@ func Test_SearchPullRequests(t *testing.T) { }, { name: "query with existing is:pr filter - no duplication", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:pr repo:github/github-mcp-server is:open draft:false", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:pr repo:github/github-mcp-server is:open draft:false", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "is:pr repo:github/github-mcp-server is:open draft:false", }, @@ -1054,21 +980,18 @@ func Test_SearchPullRequests(t *testing.T) { }, { name: "query with existing repo: filter and conflicting owner/repo params - uses query filter", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:pr repo:github/github-mcp-server author:octocat", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:pr repo:github/github-mcp-server author:octocat", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "repo:github/github-mcp-server author:octocat", "owner": "different-owner", @@ -1079,21 +1002,18 @@ func Test_SearchPullRequests(t *testing.T) { }, { name: "complex query with existing is:pr filter and OR operators", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - expectQueryParams( - t, - map[string]string{ - "q": "is:pr repo:github/github-mcp-server (label:bug OR label:enhancement OR label:feature)", - "page": "1", - "per_page": "30", - }, - ).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:pr repo:github/github-mcp-server (label:bug OR label:enhancement OR label:feature)", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "is:pr repo:github/github-mcp-server (label:bug OR label:enhancement OR label:feature)", }, @@ -1102,15 +1022,12 @@ func Test_SearchPullRequests(t *testing.T) { }, { name: "search pull requests fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchIssues, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }, + }), requestArgs: map[string]interface{}{ "query": "invalid:query", }, @@ -1216,12 +1133,14 @@ func Test_GetPullRequestFiles(t *testing.T) { }{ { name: "successful files fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsFilesByOwnerByRepoByPullNumber, - mockFiles, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsFilesByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{ + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockFiles), + ), + }), requestArgs: map[string]interface{}{ "method": "get_files", "owner": "owner", @@ -1233,12 +1152,14 @@ func Test_GetPullRequestFiles(t *testing.T) { }, { name: "successful files fetch with pagination", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsFilesByOwnerByRepoByPullNumber, - mockFiles, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsFilesByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockFiles), + ), + }), requestArgs: map[string]interface{}{ "method": "get_files", "owner": "owner", @@ -1252,15 +1173,17 @@ func Test_GetPullRequestFiles(t *testing.T) { }, { name: "files fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsFilesByOwnerByRepoByPullNumber, + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsFilesByOwnerByRepoByPullNumber: 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]interface{}{ "method": "get_files", "owner": "owner", @@ -1382,16 +1305,10 @@ func Test_GetPullRequestStatus(t *testing.T) { }{ { name: "successful status fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockPR, - ), - mock.WithRequestMatch( - mock.GetReposCommitsStatusByOwnerByRepoByRef, - mockStatus, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + GetReposCommitsStatusByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockStatus), + }), requestArgs: map[string]interface{}{ "method": "get_status", "owner": "owner", @@ -1403,15 +1320,12 @@ func Test_GetPullRequestStatus(t *testing.T) { }, { name: "PR fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + }), requestArgs: map[string]interface{}{ "method": "get_status", "owner": "owner", @@ -1423,19 +1337,13 @@ func Test_GetPullRequestStatus(t *testing.T) { }, { name: "status fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsByOwnerByRepoByPullNumber, - mockPR, - ), - mock.WithRequestMatchHandler( - mock.GetReposCommitsStatusesByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + GetReposCommitsStatusesByOwnerByRepoByRef: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + }), requestArgs: map[string]interface{}{ "method": "get_status", "owner": "owner", @@ -1527,16 +1435,13 @@ func Test_UpdatePullRequestBranch(t *testing.T) { }{ { name: "successful branch update", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposPullsUpdateBranchByOwnerByRepoByPullNumber, - expectRequestBody(t, map[string]interface{}{ - "expected_head_sha": "abcd1234", - }).andThen( - mockResponse(t, http.StatusAccepted, mockUpdateResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposPullsUpdateBranchByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]interface{}{ + "expected_head_sha": "abcd1234", + }).andThen( + mockResponse(t, http.StatusAccepted, mockUpdateResult), ), - ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1548,14 +1453,11 @@ func Test_UpdatePullRequestBranch(t *testing.T) { }, { name: "branch update without expected SHA", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposPullsUpdateBranchByOwnerByRepoByPullNumber, - expectRequestBody(t, map[string]interface{}{}).andThen( - mockResponse(t, http.StatusAccepted, mockUpdateResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposPullsUpdateBranchByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]interface{}{}).andThen( + mockResponse(t, http.StatusAccepted, mockUpdateResult), ), - ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1566,15 +1468,12 @@ func Test_UpdatePullRequestBranch(t *testing.T) { }, { name: "branch update fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposPullsUpdateBranchByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusConflict) - _, _ = w.Write([]byte(`{"message": "Merge conflict"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposPullsUpdateBranchByOwnerByRepoByPullNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"message": "Merge conflict"}`)) + }), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1997,12 +1896,9 @@ func Test_GetPullRequestReviews(t *testing.T) { }{ { name: "successful reviews fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsReviewsByOwnerByRepoByPullNumber, - mockReviews, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsReviewsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockReviews), + }), requestArgs: map[string]interface{}{ "method": "get_reviews", "owner": "owner", @@ -2014,15 +1910,12 @@ func Test_GetPullRequestReviews(t *testing.T) { }, { name: "reviews fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsReviewsByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), + 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"}`)) + }), + }), requestArgs: map[string]interface{}{ "method": "get_reviews", "owner": "owner", @@ -2034,25 +1927,22 @@ func Test_GetPullRequestReviews(t *testing.T) { }, { name: "lockdown enabled filters reviews without push access", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposPullsReviewsByOwnerByRepoByPullNumber, - []*github.PullRequestReview{ - { - ID: github.Ptr(int64(2030)), - State: github.Ptr("APPROVED"), - Body: github.Ptr("Maintainer review"), - User: &github.User{Login: github.Ptr("maintainer")}, - }, - { - ID: github.Ptr(int64(2031)), - State: github.Ptr("COMMENTED"), - Body: github.Ptr("External reviewer"), - User: &github.User{Login: github.Ptr("testuser")}, - }, + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsReviewsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, []*github.PullRequestReview{ + { + ID: github.Ptr(int64(2030)), + State: github.Ptr("APPROVED"), + Body: github.Ptr("Maintainer review"), + User: &github.User{Login: github.Ptr("maintainer")}, }, - ), - ), + { + ID: github.Ptr(int64(2031)), + State: github.Ptr("COMMENTED"), + Body: github.Ptr("External reviewer"), + User: &github.User{Login: github.Ptr("testuser")}, + }, + }), + }), gqlHTTPClient: newRepoAccessHTTPClient(), requestArgs: map[string]interface{}{ "method": "get_reviews", @@ -2183,21 +2073,18 @@ func Test_CreatePullRequest(t *testing.T) { }{ { name: "successful PR creation", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposPullsByOwnerByRepo, - expectRequestBody(t, map[string]interface{}{ - "title": "Test PR", - "body": "This is a test PR", - "head": "feature-branch", - "base": "main", - "draft": false, - "maintainer_can_modify": true, - }).andThen( - mockResponse(t, http.StatusCreated, mockPR), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsByOwnerByRepo: expectRequestBody(t, map[string]interface{}{ + "title": "Test PR", + "body": "This is a test PR", + "head": "feature-branch", + "base": "main", + "draft": false, + "maintainer_can_modify": true, + }).andThen( + mockResponse(t, http.StatusCreated, mockPR), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -2213,7 +2100,7 @@ func Test_CreatePullRequest(t *testing.T) { }, { name: "missing required parameter", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -2224,15 +2111,12 @@ func Test_CreatePullRequest(t *testing.T) { }, { name: "PR creation fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposPullsByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message":"Validation failed","errors":[{"resource":"PullRequest","code":"invalid"}]}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsByOwnerByRepo: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message":"Validation failed","errors":[{"resource":"PullRequest","code":"invalid"}]}`)) + }), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -2535,19 +2419,16 @@ func Test_RequestCopilotReview(t *testing.T) { }{ { name: "successful request", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, - expect(t, expectations{ - path: "/repos/owner/repo/pulls/1/requested_reviewers", - requestBody: map[string]any{ - "reviewers": []any{"copilot-pull-request-reviewer[bot]"}, - }, - }).andThen( - mockResponse(t, http.StatusCreated, mockPR), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: expect(t, expectations{ + path: "/repos/owner/repo/pulls/1/requested_reviewers", + requestBody: map[string]any{ + "reviewers": []any{"copilot-pull-request-reviewer[bot]"}, + }, + }).andThen( + mockResponse(t, http.StatusCreated, mockPR), ), - ), + }), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -2557,15 +2438,12 @@ func Test_RequestCopilotReview(t *testing.T) { }, { name: "request fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + }), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", @@ -3234,15 +3112,11 @@ index 5d6e7b2..8a4f5c3 100644 "repo": "repo", "pullNumber": float64(42), }, - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposPullsByOwnerByRepoByPullNumber, - // Should also expect Accept header to be application/vnd.github.v3.diff - expectPath(t, "/repos/owner/repo/pulls/42").andThen( - mockResponse(t, http.StatusOK, stubbedDiff), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepoByPullNumber: expectPath(t, "/repos/owner/repo/pulls/42").andThen( + mockResponse(t, http.StatusOK, stubbedDiff), ), - ), + }), expectToolError: false, }, } diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 1e81d8c53..d91af8851 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -16,7 +16,6 @@ import ( "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" - "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -74,36 +73,25 @@ func Test_GetFileContents(t *testing.T) { }{ { name: "successful text content fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - fileContent := &github.RepositoryContent{ - Name: github.Ptr("README.md"), - Path: github.Ptr("README.md"), - SHA: github.Ptr("abc123"), - Type: github.Ptr("file"), - } - contentBytes, _ := json.Marshal(fileContent) - _, _ = w.Write(contentBytes) - }), - ), - mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoByBranchByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/markdown") - _, _ = w.Write(mockRawContent) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("README.md"), + Path: github.Ptr("README.md"), + SHA: github.Ptr("abc123"), + Type: github.Ptr("file"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }, + GetRawReposContentsByOwnerByRepoByBranchByPath: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/markdown") + _, _ = w.Write(mockRawContent) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -119,36 +107,25 @@ func Test_GetFileContents(t *testing.T) { }, { name: "successful file blob content fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - fileContent := &github.RepositoryContent{ - Name: github.Ptr("test.png"), - Path: github.Ptr("test.png"), - SHA: github.Ptr("def456"), - Type: github.Ptr("file"), - } - contentBytes, _ := json.Marshal(fileContent) - _, _ = w.Write(contentBytes) - }), - ), - mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoByBranchByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "image/png") - _, _ = w.Write(mockRawContent) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("test.png"), + Path: github.Ptr("test.png"), + SHA: github.Ptr("def456"), + Type: github.Ptr("file"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }, + GetRawReposContentsByOwnerByRepoByBranchByPath: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "image/png") + _, _ = w.Write(mockRawContent) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -164,36 +141,25 @@ func Test_GetFileContents(t *testing.T) { }, { name: "successful PDF file content fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - fileContent := &github.RepositoryContent{ - Name: github.Ptr("document.pdf"), - Path: github.Ptr("document.pdf"), - SHA: github.Ptr("pdf123"), - Type: github.Ptr("file"), - } - contentBytes, _ := json.Marshal(fileContent) - _, _ = w.Write(contentBytes) - }), - ), - mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoByBranchByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/pdf") - _, _ = w.Write(mockRawContent) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("document.pdf"), + Path: github.Ptr("document.pdf"), + SHA: github.Ptr("pdf123"), + Type: github.Ptr("file"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }, + GetRawReposContentsByOwnerByRepoByBranchByPath: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/pdf") + _, _ = w.Write(mockRawContent) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -209,36 +175,16 @@ func Test_GetFileContents(t *testing.T) { }, { name: "successful directory content fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"name": "repo", "default_branch": "main"}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposContentsByOwnerByRepoByPath, - expectQueryParams(t, map[string]string{}).andThen( - mockResponse(t, http.StatusOK, mockDirContent), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposContentsByOwnerByRepoByPath: expectQueryParams(t, map[string]string{}).andThen( + mockResponse(t, http.StatusOK, mockDirContent), ), - mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoByPath, - expectQueryParams(t, map[string]string{ - "branch": "main", - }).andThen( - mockResponse(t, http.StatusNotFound, nil), - ), + GetRawReposContentsByOwnerByRepoByPath: expectQueryParams(t, map[string]string{"branch": "main"}).andThen( + mockResponse(t, http.StatusNotFound, nil), ), - ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -249,36 +195,25 @@ func Test_GetFileContents(t *testing.T) { }, { name: "successful text content fetch with leading slash in path", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - fileContent := &github.RepositoryContent{ - Name: github.Ptr("README.md"), - Path: github.Ptr("README.md"), - SHA: github.Ptr("abc123"), - Type: github.Ptr("file"), - } - contentBytes, _ := json.Marshal(fileContent) - _, _ = w.Write(contentBytes) - }), - ), - mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoByBranchByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/markdown") - _, _ = w.Write(mockRawContent) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("README.md"), + Path: github.Ptr("README.md"), + SHA: github.Ptr("abc123"), + Type: github.Ptr("file"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }, + GetRawReposContentsByOwnerByRepoByBranchByPath: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/markdown") + _, _ = w.Write(mockRawContent) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -294,54 +229,82 @@ func Test_GetFileContents(t *testing.T) { }, { name: "successful text content fetch with note when ref falls back to default branch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"develop\"}"), + GetReposGitRefByOwnerByRepoByRef: func(w http.ResponseWriter, r *http.Request) { + path := strings.ReplaceAll(r.URL.Path, "%2F", "/") + switch { + case strings.Contains(path, "heads/main"): + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + case strings.Contains(path, "heads/develop"): w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"name": "repo", "default_branch": "develop"}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - // Request for "refs/heads/main" -> 404 (doesn't exist) - // Request for "refs/heads/develop" (default branch) -> 200 - switch { - case strings.Contains(r.URL.Path, "heads/main"): - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - case strings.Contains(r.URL.Path, "heads/develop"): - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456"}}`)) - default: - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - } - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456"}}`)) + default: + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + } + }, + "GET /repos/{owner}/{repo}/git/refs/{ref}": func(w http.ResponseWriter, r *http.Request) { + path := strings.ReplaceAll(r.URL.Path, "%2F", "/") + switch { + case strings.Contains(path, "heads/main"): + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + case strings.Contains(path, "heads/develop"): w.WriteHeader(http.StatusOK) - fileContent := &github.RepositoryContent{ - Name: github.Ptr("README.md"), - Path: github.Ptr("README.md"), - SHA: github.Ptr("abc123"), - Type: github.Ptr("file"), - } - contentBytes, _ := json.Marshal(fileContent) - _, _ = w.Write(contentBytes) - }), - ), - mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoBySHAByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "text/markdown") - _, _ = w.Write(mockRawContent) - }), - ), - ), + _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456"}}`)) + default: + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + } + }, + "GET /repos/{owner}/{repo}/git/refs/{ref:.*}": func(w http.ResponseWriter, r *http.Request) { + path := strings.ReplaceAll(r.URL.Path, "%2F", "/") + switch { + case strings.Contains(path, "heads/main"): + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + case strings.Contains(path, "heads/develop"): + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456"}}`)) + default: + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + } + }, + "GET /repos/owner/repo/git/ref/heads/main": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }, + "GET /repos/owner/repo/git/ref/heads/develop": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"ref": "refs/heads/develop", "object": {"sha": "abc123def456", "type": "commit", "url": "https://api.github.com/repos/owner/repo/git/commits/abc123def456"}}`)) + }, + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("README.md"), + Path: github.Ptr("README.md"), + SHA: github.Ptr("abc123"), + Type: github.Ptr("file"), + } + contentBytes, _ := json.Marshal(fileContent) + _, _ = w.Write(contentBytes) + }, + "GET /owner/repo/refs/heads/develop/README.md": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/markdown") + _, _ = w.Write(mockRawContent) + }, + "GET /owner/repo/refs%2Fheads%2Fdevelop/README.md": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/markdown") + _, _ = w.Write(mockRawContent) + }, + "GET /owner/repo/abc123def456/README.md": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/markdown") + _, _ = w.Write(mockRawContent) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -358,29 +321,17 @@ func Test_GetFileContents(t *testing.T) { }, { name: "content fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte(`{"ref": "refs/heads/main", "object": {"sha": ""}}`)) - }), - ), - mock.WithRequestMatchHandler( - mock.GetReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - mock.WithRequestMatchHandler( - raw.GetRawReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), + GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }, + GetRawReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -492,12 +443,9 @@ func Test_ForkRepository(t *testing.T) { }{ { name: "successful repository fork", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposForksByOwnerByRepo, - mockResponse(t, http.StatusAccepted, mockForkedRepo), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposForksByOwnerByRepo: mockResponse(t, http.StatusAccepted, mockForkedRepo), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -507,15 +455,12 @@ func Test_ForkRepository(t *testing.T) { }, { name: "repository fork fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PostReposForksByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusForbidden) - _, _ = w.Write([]byte(`{"message": "Forbidden"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposForksByOwnerByRepo: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message": "Forbidden"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -608,16 +553,11 @@ func Test_CreateBranch(t *testing.T) { }{ { name: "successful branch creation with from_branch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, - mockSourceRef, - ), - mock.WithRequestMatch( - mock.PostReposGitRefsByOwnerByRepo, - mockCreatedRef, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockSourceRef), + "GET /repos/owner/repo/git/ref/heads/main": mockResponse(t, http.StatusOK, mockSourceRef), + PostReposGitRefsByOwnerByRepo: mockResponse(t, http.StatusCreated, mockCreatedRef), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -629,25 +569,17 @@ func Test_CreateBranch(t *testing.T) { }, { name: "successful branch creation with default branch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposByOwnerByRepo, - mockRepo, - ), - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, - mockSourceRef, - ), - mock.WithRequestMatchHandler( - mock.PostReposGitRefsByOwnerByRepo, - expectRequestBody(t, map[string]interface{}{ - "ref": "refs/heads/new-feature", - "sha": "abc123def456", - }).andThen( - mockResponse(t, http.StatusCreated, mockCreatedRef), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, mockRepo), + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockSourceRef), + "GET /repos/owner/repo/git/ref/heads/main": mockResponse(t, http.StatusOK, mockSourceRef), + PostReposGitRefsByOwnerByRepo: expectRequestBody(t, map[string]interface{}{ + "ref": "refs/heads/new-feature", + "sha": "abc123def456", + }).andThen( + mockResponse(t, http.StatusCreated, mockCreatedRef), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -658,15 +590,12 @@ func Test_CreateBranch(t *testing.T) { }, { name: "fail to get repository", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Repository not found"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposByOwnerByRepo: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Repository not found"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "nonexistent-repo", @@ -677,15 +606,12 @@ func Test_CreateBranch(t *testing.T) { }, { name: "fail to get reference", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Reference not found"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Reference not found"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -697,19 +623,14 @@ func Test_CreateBranch(t *testing.T) { }, { name: "fail to create branch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, - mockSourceRef, - ), - mock.WithRequestMatchHandler( - mock.PostReposGitRefsByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message": "Reference already exists"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockSourceRef), + "GET /repos/owner/repo/git/ref/heads/main": mockResponse(t, http.StatusOK, mockSourceRef), + PostReposGitRefsByOwnerByRepo: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Reference already exists"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -818,12 +739,9 @@ func Test_GetCommit(t *testing.T) { }{ { name: "successful commit fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposCommitsByOwnerByRepoByRef, - mockResponse(t, http.StatusOK, mockCommit), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockCommit), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -834,15 +752,12 @@ func Test_GetCommit(t *testing.T) { }, { name: "commit fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposCommitsByOwnerByRepoByRef, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepoByRef: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1000,12 +915,9 @@ func Test_ListCommits(t *testing.T) { }{ { name: "successful commits fetch with default params", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposCommitsByOwnerByRepo, - mockCommits, - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepo: mockResponse(t, http.StatusOK, mockCommits), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1015,19 +927,16 @@ func Test_ListCommits(t *testing.T) { }, { name: "successful commits fetch with branch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposCommitsByOwnerByRepo, - expectQueryParams(t, map[string]string{ - "author": "username", - "sha": "main", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockCommits), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "author": "username", + "sha": "main", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockCommits), ), - ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1039,17 +948,14 @@ func Test_ListCommits(t *testing.T) { }, { name: "successful commits fetch with pagination", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposCommitsByOwnerByRepo, - expectQueryParams(t, map[string]string{ - "page": "2", - "per_page": "10", - }).andThen( - mockResponse(t, http.StatusOK, mockCommits), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockCommits), ), - ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1061,15 +967,12 @@ func Test_ListCommits(t *testing.T) { }, { name: "commits fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposCommitsByOwnerByRepo, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - _, _ = w.Write([]byte(`{"message": "Not Found"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepo: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "nonexistent-repo", @@ -1184,18 +1087,22 @@ func Test_CreateOrUpdateFile(t *testing.T) { }{ { name: "successful file creation", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposContentsByOwnerByRepoByPath, - expectRequestBody(t, map[string]interface{}{ - "message": "Add example file", - "content": "IyBFeGFtcGxlCgpUaGlzIGlzIGFuIGV4YW1wbGUgZmlsZS4=", // Base64 encoded content - "branch": "main", - }).andThen( - mockResponse(t, http.StatusOK, mockFileResponse), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]interface{}{ + "message": "Add example file", + "content": "IyBFeGFtcGxlCgpUaGlzIGlzIGFuIGV4YW1wbGUgZmlsZS4=", // Base64 encoded content + "branch": "main", + }).andThen( + mockResponse(t, http.StatusOK, mockFileResponse), + ), + "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]interface{}{ + "message": "Add example file", + "content": "IyBFeGFtcGxlCgpUaGlzIGlzIGFuIGV4YW1wbGUgZmlsZS4=", // Base64 encoded content + "branch": "main", + }).andThen( + mockResponse(t, http.StatusOK, mockFileResponse), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1209,19 +1116,24 @@ func Test_CreateOrUpdateFile(t *testing.T) { }, { name: "successful file update with SHA", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposContentsByOwnerByRepoByPath, - expectRequestBody(t, map[string]interface{}{ - "message": "Update example file", - "content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", // Base64 encoded content - "branch": "main", - "sha": "abc123def456", - }).andThen( - mockResponse(t, http.StatusOK, mockFileResponse), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]interface{}{ + "message": "Update example file", + "content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", // Base64 encoded content + "branch": "main", + "sha": "abc123def456", + }).andThen( + mockResponse(t, http.StatusOK, mockFileResponse), + ), + "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]interface{}{ + "message": "Update example file", + "content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", // Base64 encoded content + "branch": "main", + "sha": "abc123def456", + }).andThen( + mockResponse(t, http.StatusOK, mockFileResponse), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1236,15 +1148,16 @@ func Test_CreateOrUpdateFile(t *testing.T) { }, { name: "file creation fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutReposContentsByOwnerByRepoByPath, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusUnprocessableEntity) - _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) + }, + "PUT /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Invalid request"}`)) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1258,35 +1171,42 @@ func Test_CreateOrUpdateFile(t *testing.T) { }, { name: "sha validation - current sha matches (304 Not Modified)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/contents/docs/example.md", - Method: "HEAD", - }, - http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - // Verify If-None-Match header is set correctly - ifNoneMatch := req.Header.Get("If-None-Match") - if ifNoneMatch == `"abc123def456"` { - w.WriteHeader(http.StatusNotModified) - } else { - w.WriteHeader(http.StatusOK) - w.Header().Set("ETag", `"abc123def456"`) - } - }), - ), - mock.WithRequestMatchHandler( - mock.PutReposContentsByOwnerByRepoByPath, - expectRequestBody(t, map[string]interface{}{ - "message": "Update example file", - "content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", - "branch": "main", - "sha": "abc123def456", - }).andThen( - mockResponse(t, http.StatusOK, mockFileResponse), - ), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "HEAD /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, req *http.Request) { + ifNoneMatch := req.Header.Get("If-None-Match") + if ifNoneMatch == `"abc123def456"` { + w.WriteHeader(http.StatusNotModified) + } else { + w.WriteHeader(http.StatusOK) + w.Header().Set("ETag", `"abc123def456"`) + } + }, + "HEAD /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, req *http.Request) { + ifNoneMatch := req.Header.Get("If-None-Match") + if ifNoneMatch == `"abc123def456"` { + w.WriteHeader(http.StatusNotModified) + } else { + w.WriteHeader(http.StatusOK) + w.Header().Set("ETag", `"abc123def456"`) + } + }, + PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]interface{}{ + "message": "Update example file", + "content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", + "branch": "main", + "sha": "abc123def456", + }).andThen( + mockResponse(t, http.StatusOK, mockFileResponse), + ), + "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]interface{}{ + "message": "Update example file", + "content": "IyBVcGRhdGVkIEV4YW1wbGUKClRoaXMgZmlsZSBoYXMgYmVlbiB1cGRhdGVkLg==", + "branch": "main", + "sha": "abc123def456", + }).andThen( + mockResponse(t, http.StatusOK, mockFileResponse), + ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1301,19 +1221,16 @@ func Test_CreateOrUpdateFile(t *testing.T) { }, { name: "sha validation - stale sha detected (200 OK with different ETag)", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/contents/docs/example.md", - Method: "HEAD", - }, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - // SHA doesn't match - return 200 with current ETag - w.Header().Set("ETag", `"newsha999888"`) - w.WriteHeader(http.StatusOK) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "HEAD /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("ETag", `"newsha999888"`) + w.WriteHeader(http.StatusOK) + }, + "HEAD /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("ETag", `"newsha999888"`) + w.WriteHeader(http.StatusOK) + }, + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1328,28 +1245,30 @@ func Test_CreateOrUpdateFile(t *testing.T) { }, { name: "sha validation - file doesn't exist (404), proceed with create", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/contents/docs/example.md", - Method: "HEAD", - }, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - }), - ), - mock.WithRequestMatchHandler( - mock.PutReposContentsByOwnerByRepoByPath, - expectRequestBody(t, map[string]interface{}{ - "message": "Create new file", - "content": "IyBOZXcgRmlsZQoKVGhpcyBpcyBhIG5ldyBmaWxlLg==", - "branch": "main", - "sha": "ignoredsha", // SHA is sent but GitHub API ignores it for new files - }).andThen( - mockResponse(t, http.StatusCreated, mockFileResponse), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "HEAD /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, + PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]interface{}{ + "message": "Create new file", + "content": "IyBOZXcgRmlsZQoKVGhpcyBpcyBhIG5ldyBmaWxlLg==", + "branch": "main", + "sha": "ignoredsha", // SHA is sent but GitHub API ignores it for new files + }).andThen( + mockResponse(t, http.StatusCreated, mockFileResponse), + ), + "HEAD /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, + "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]interface{}{ + "message": "Create new file", + "content": "IyBOZXcgRmlsZQoKVGhpcyBpcyBhIG5ldyBmaWxlLg==", + "branch": "main", + "sha": "ignoredsha", // SHA is sent but GitHub API ignores it for new files + }).andThen( + mockResponse(t, http.StatusCreated, mockFileResponse), ), - ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1364,29 +1283,32 @@ func Test_CreateOrUpdateFile(t *testing.T) { }, { name: "no sha provided - file exists, returns warning", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/contents/docs/example.md", - Method: "HEAD", - }, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("ETag", `"existing123"`) - w.WriteHeader(http.StatusOK) - }), - ), - mock.WithRequestMatchHandler( - mock.PutReposContentsByOwnerByRepoByPath, - expectRequestBody(t, map[string]interface{}{ - "message": "Update without SHA", - "content": "IyBVcGRhdGVkCgpVcGRhdGVkIHdpdGhvdXQgU0hBLg==", - "branch": "main", - "sha": "existing123", // SHA is automatically added from ETag - }).andThen( - mockResponse(t, http.StatusOK, mockFileResponse), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "HEAD /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("ETag", `"existing123"`) + w.WriteHeader(http.StatusOK) + }, + PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]interface{}{ + "message": "Update without SHA", + "content": "IyBVcGRhdGVkCgpVcGRhdGVkIHdpdGhvdXQgU0hBLg==", + "branch": "main", + "sha": "existing123", // SHA is automatically added from ETag + }).andThen( + mockResponse(t, http.StatusOK, mockFileResponse), + ), + "HEAD /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("ETag", `"existing123"`) + w.WriteHeader(http.StatusOK) + }, + "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]interface{}{ + "message": "Update without SHA", + "content": "IyBVcGRhdGVkCgpVcGRhdGVkIHdpdGhvdXQgU0hBLg==", + "branch": "main", + "sha": "existing123", // SHA is automatically added from ETag + }).andThen( + mockResponse(t, http.StatusOK, mockFileResponse), ), - ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1400,27 +1322,28 @@ func Test_CreateOrUpdateFile(t *testing.T) { }, { name: "no sha provided - file doesn't exist, no warning", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/repos/owner/repo/contents/docs/example.md", - Method: "HEAD", - }, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNotFound) - }), - ), - mock.WithRequestMatchHandler( - mock.PutReposContentsByOwnerByRepoByPath, - expectRequestBody(t, map[string]interface{}{ - "message": "Create new file", - "content": "IyBOZXcgRmlsZQoKQ3JlYXRlZCB3aXRob3V0IFNIQQ==", - "branch": "main", - }).andThen( - mockResponse(t, http.StatusCreated, mockFileResponse), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "HEAD /repos/owner/repo/contents/docs/example.md": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, + PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]interface{}{ + "message": "Create new file", + "content": "IyBOZXcgRmlsZQoKQ3JlYXRlZCB3aXRob3V0IFNIQQ==", + "branch": "main", + }).andThen( + mockResponse(t, http.StatusCreated, mockFileResponse), + ), + "HEAD /repos/{owner}/{repo}/contents/{path:.*}": func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + }, + "PUT /repos/{owner}/{repo}/contents/{path:.*}": expectRequestBody(t, map[string]interface{}{ + "message": "Create new file", + "content": "IyBOZXcgRmlsZQoKQ3JlYXRlZCB3aXRob3V0IFNIQQ==", + "branch": "main", + }).andThen( + mockResponse(t, http.StatusCreated, mockFileResponse), ), - ), + }), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -1527,12 +1450,9 @@ func Test_CreateRepository(t *testing.T) { }{ { name: "successful repository creation with all parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/user/repos", - Method: "POST", - }, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + EndpointPattern("POST /user/repos"), expectRequestBody(t, map[string]interface{}{ "name": "test-repo", "description": "Test repository", @@ -1554,12 +1474,9 @@ func Test_CreateRepository(t *testing.T) { }, { name: "successful repository creation in organization", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/orgs/testorg/repos", - Method: "POST", - }, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + EndpointPattern("POST /orgs/testorg/repos"), expectRequestBody(t, map[string]interface{}{ "name": "test-repo", "description": "Test repository", @@ -1582,12 +1499,9 @@ func Test_CreateRepository(t *testing.T) { }, { name: "successful repository creation with minimal parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/user/repos", - Method: "POST", - }, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + EndpointPattern("POST /user/repos"), expectRequestBody(t, map[string]interface{}{ "name": "test-repo", "auto_init": false, @@ -1606,12 +1520,9 @@ func Test_CreateRepository(t *testing.T) { }, { name: "repository creation fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{ - Pattern: "/user/repos", - Method: "POST", - }, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + EndpointPattern("POST /user/repos"), http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusUnprocessableEntity) _, _ = w.Write([]byte(`{"message": "Repository creation failed"}`)) @@ -1730,20 +1641,20 @@ func Test_PushFiles(t *testing.T) { }{ { name: "successful push of multiple files", - mockedClient: mock.NewMockedHTTPClient( + mockedClient: NewMockedHTTPClient( // Get branch reference - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, + WithRequestMatch( + GetReposGitRefByOwnerByRepoByRef, mockRef, ), // Get commit - mock.WithRequestMatch( - mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + WithRequestMatch( + GetReposGitCommitsByOwnerByRepoByCommitSHA, mockCommit, ), // Create tree - mock.WithRequestMatchHandler( - mock.PostReposGitTreesByOwnerByRepo, + WithRequestMatchHandler( + PostReposGitTreesByOwnerByRepo, expectRequestBody(t, map[string]interface{}{ "base_tree": "def456", "tree": []interface{}{ @@ -1765,8 +1676,8 @@ func Test_PushFiles(t *testing.T) { ), ), // Create commit - mock.WithRequestMatchHandler( - mock.PostReposGitCommitsByOwnerByRepo, + WithRequestMatchHandler( + PostReposGitCommitsByOwnerByRepo, expectRequestBody(t, map[string]interface{}{ "message": "Update multiple files", "tree": "ghi789", @@ -1776,8 +1687,8 @@ func Test_PushFiles(t *testing.T) { ), ), // Update reference - mock.WithRequestMatchHandler( - mock.PatchReposGitRefsByOwnerByRepoByRef, + WithRequestMatchHandler( + PatchReposGitRefsByOwnerByRepoByRef, expectRequestBody(t, map[string]interface{}{ "sha": "jkl012", "force": false, @@ -1807,7 +1718,7 @@ func Test_PushFiles(t *testing.T) { }, { name: "fails when files parameter is invalid", - mockedClient: mock.NewMockedHTTPClient( + mockedClient: NewMockedHTTPClient( // No requests expected ), requestArgs: map[string]interface{}{ @@ -1822,15 +1733,15 @@ func Test_PushFiles(t *testing.T) { }, { name: "fails when files contains object without path", - mockedClient: mock.NewMockedHTTPClient( + mockedClient: NewMockedHTTPClient( // Get branch reference - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, + WithRequestMatch( + GetReposGitRefByOwnerByRepoByRef, mockRef, ), // Get commit - mock.WithRequestMatch( - mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + WithRequestMatch( + GetReposGitCommitsByOwnerByRepoByCommitSHA, mockCommit, ), ), @@ -1850,15 +1761,15 @@ func Test_PushFiles(t *testing.T) { }, { name: "fails when files contains object without content", - mockedClient: mock.NewMockedHTTPClient( + mockedClient: NewMockedHTTPClient( // Get branch reference - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, + WithRequestMatch( + GetReposGitRefByOwnerByRepoByRef, mockRef, ), // Get commit - mock.WithRequestMatch( - mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + WithRequestMatch( + GetReposGitCommitsByOwnerByRepoByCommitSHA, mockCommit, ), ), @@ -1879,14 +1790,14 @@ func Test_PushFiles(t *testing.T) { }, { name: "fails to get branch reference", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, mockResponse(t, http.StatusNotFound, nil), ), // Mock Repositories.Get to fail when trying to create branch from default - mock.WithRequestMatchHandler( - mock.GetReposByOwnerByRepo, + WithRequestMatchHandler( + GetReposByOwnerByRepo, mockResponse(t, http.StatusNotFound, nil), ), ), @@ -1907,15 +1818,15 @@ func Test_PushFiles(t *testing.T) { }, { name: "fails to get base commit", - mockedClient: mock.NewMockedHTTPClient( + mockedClient: NewMockedHTTPClient( // Get branch reference - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, + WithRequestMatch( + GetReposGitRefByOwnerByRepoByRef, mockRef, ), // Fail to get commit - mock.WithRequestMatchHandler( - mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + WithRequestMatchHandler( + GetReposGitCommitsByOwnerByRepoByCommitSHA, mockResponse(t, http.StatusNotFound, nil), ), ), @@ -1936,20 +1847,20 @@ func Test_PushFiles(t *testing.T) { }, { name: "fails to create tree", - mockedClient: mock.NewMockedHTTPClient( + mockedClient: NewMockedHTTPClient( // Get branch reference - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, + WithRequestMatch( + GetReposGitRefByOwnerByRepoByRef, mockRef, ), // Get commit - mock.WithRequestMatch( - mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + WithRequestMatch( + GetReposGitCommitsByOwnerByRepoByCommitSHA, mockCommit, ), // Fail to create tree - mock.WithRequestMatchHandler( - mock.PostReposGitTreesByOwnerByRepo, + WithRequestMatchHandler( + PostReposGitTreesByOwnerByRepo, mockResponse(t, http.StatusInternalServerError, nil), ), ), @@ -1970,10 +1881,10 @@ func Test_PushFiles(t *testing.T) { }, { name: "successful push to empty repository", - mockedClient: mock.NewMockedHTTPClient( + mockedClient: NewMockedHTTPClient( // Get branch reference - first returns 409 for empty repo, second returns success after init - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, func() http.HandlerFunc { callCount := 0 return func(w http.ResponseWriter, _ *http.Request) { @@ -1995,15 +1906,15 @@ func Test_PushFiles(t *testing.T) { }(), ), // Mock Repositories.Get to return default branch for initialization - mock.WithRequestMatch( - mock.GetReposByOwnerByRepo, + WithRequestMatch( + GetReposByOwnerByRepo, &github.Repository{ DefaultBranch: github.Ptr("main"), }, ), // Create initial file using Contents API - mock.WithRequestMatchHandler( - mock.PutReposContentsByOwnerByRepoByPath, + WithRequestMatchHandler( + PutReposContentsByOwnerByRepoByPath, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body map[string]interface{} err := json.NewDecoder(r.Body).Decode(&body) @@ -2019,23 +1930,23 @@ func Test_PushFiles(t *testing.T) { }), ), // Get the commit after initialization - mock.WithRequestMatch( - mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + WithRequestMatch( + GetReposGitCommitsByOwnerByRepoByCommitSHA, mockCommit, ), // Create tree - mock.WithRequestMatch( - mock.PostReposGitTreesByOwnerByRepo, + WithRequestMatch( + PostReposGitTreesByOwnerByRepo, mockTree, ), // Create commit - mock.WithRequestMatch( - mock.PostReposGitCommitsByOwnerByRepo, + WithRequestMatch( + PostReposGitCommitsByOwnerByRepo, mockNewCommit, ), // Update reference - mock.WithRequestMatch( - mock.PatchReposGitRefsByOwnerByRepoByRef, + WithRequestMatch( + PatchReposGitRefsByOwnerByRepoByRef, mockUpdatedRef, ), ), @@ -2056,10 +1967,10 @@ func Test_PushFiles(t *testing.T) { }, { name: "successful push multiple files to empty repository", - mockedClient: mock.NewMockedHTTPClient( + mockedClient: NewMockedHTTPClient( // Get branch reference - called twice: first for empty check, second after file creation - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, func() http.HandlerFunc { callCount := 0 return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -2085,15 +1996,15 @@ func Test_PushFiles(t *testing.T) { }(), ), // Mock Repositories.Get to return default branch for initialization - mock.WithRequestMatch( - mock.GetReposByOwnerByRepo, + WithRequestMatch( + GetReposByOwnerByRepo, &github.Repository{ DefaultBranch: github.Ptr("main"), }, ), // Create initial empty README.md file using Contents API to initialize repo - mock.WithRequestMatchHandler( - mock.PutReposContentsByOwnerByRepoByPath, + WithRequestMatchHandler( + PutReposContentsByOwnerByRepoByPath, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body map[string]interface{} err := json.NewDecoder(r.Body).Decode(&body) @@ -2120,8 +2031,8 @@ func Test_PushFiles(t *testing.T) { }), ), // Get the commit to retrieve parent SHA - mock.WithRequestMatchHandler( - mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + WithRequestMatchHandler( + GetReposGitCommitsByOwnerByRepoByCommitSHA, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) response := &github.Commit{ @@ -2135,8 +2046,8 @@ func Test_PushFiles(t *testing.T) { }), ), // Create tree with all user files - mock.WithRequestMatchHandler( - mock.PostReposGitTreesByOwnerByRepo, + WithRequestMatchHandler( + PostReposGitTreesByOwnerByRepo, expectRequestBody(t, map[string]interface{}{ "base_tree": "tree456", "tree": []interface{}{ @@ -2164,8 +2075,8 @@ func Test_PushFiles(t *testing.T) { ), ), // Create commit with all user files - mock.WithRequestMatchHandler( - mock.PostReposGitCommitsByOwnerByRepo, + WithRequestMatchHandler( + PostReposGitCommitsByOwnerByRepo, expectRequestBody(t, map[string]interface{}{ "message": "Initial project setup", "tree": "ghi789", @@ -2175,8 +2086,8 @@ func Test_PushFiles(t *testing.T) { ), ), // Update reference - mock.WithRequestMatchHandler( - mock.PatchReposGitRefsByOwnerByRepoByRef, + WithRequestMatchHandler( + PatchReposGitRefsByOwnerByRepoByRef, expectRequestBody(t, map[string]interface{}{ "sha": "jkl012", "force": false, @@ -2210,10 +2121,10 @@ func Test_PushFiles(t *testing.T) { }, { name: "fails to create initial file in empty repository", - mockedClient: mock.NewMockedHTTPClient( + mockedClient: NewMockedHTTPClient( // Get branch reference returns 409 Conflict for empty repo - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusConflict) @@ -2224,15 +2135,15 @@ func Test_PushFiles(t *testing.T) { }), ), // Mock Repositories.Get to return default branch - mock.WithRequestMatch( - mock.GetReposByOwnerByRepo, + WithRequestMatch( + GetReposByOwnerByRepo, &github.Repository{ DefaultBranch: github.Ptr("main"), }, ), // Fail to create initial file using Contents API - mock.WithRequestMatchHandler( - mock.PutReposContentsByOwnerByRepoByPath, + WithRequestMatchHandler( + PutReposContentsByOwnerByRepoByPath, mockResponse(t, http.StatusInternalServerError, nil), ), ), @@ -2253,10 +2164,10 @@ func Test_PushFiles(t *testing.T) { }, { name: "fails to get reference after creating initial file in empty repository", - mockedClient: mock.NewMockedHTTPClient( + mockedClient: NewMockedHTTPClient( // Get branch reference - called twice - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, func() http.HandlerFunc { callCount := 0 return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { @@ -2277,15 +2188,15 @@ func Test_PushFiles(t *testing.T) { }(), ), // Mock Repositories.Get to return default branch - mock.WithRequestMatch( - mock.GetReposByOwnerByRepo, + WithRequestMatch( + GetReposByOwnerByRepo, &github.Repository{ DefaultBranch: github.Ptr("main"), }, ), // Create initial file using Contents API - mock.WithRequestMatch( - mock.PutReposContentsByOwnerByRepoByPath, + WithRequestMatch( + PutReposContentsByOwnerByRepoByPath, &github.RepositoryContentResponse{ Content: &github.RepositoryContent{SHA: github.Ptr("readme123")}, Commit: github.Commit{SHA: github.Ptr("init456")}, @@ -2309,10 +2220,10 @@ func Test_PushFiles(t *testing.T) { }, { name: "fails to get commit in empty repository with multiple files", - mockedClient: mock.NewMockedHTTPClient( + mockedClient: NewMockedHTTPClient( // Get branch reference returns 409 Conflict for empty repo - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusConflict) @@ -2323,23 +2234,23 @@ func Test_PushFiles(t *testing.T) { }), ), // Mock Repositories.Get to return default branch - mock.WithRequestMatch( - mock.GetReposByOwnerByRepo, + WithRequestMatch( + GetReposByOwnerByRepo, &github.Repository{ DefaultBranch: github.Ptr("main"), }, ), // Create initial file using Contents API - mock.WithRequestMatch( - mock.PutReposContentsByOwnerByRepoByPath, + WithRequestMatch( + PutReposContentsByOwnerByRepoByPath, &github.RepositoryContentResponse{ Content: &github.RepositoryContent{SHA: github.Ptr("readme123")}, Commit: github.Commit{SHA: github.Ptr("init456")}, }, ), // Fail to get commit - mock.WithRequestMatchHandler( - mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + WithRequestMatchHandler( + GetReposGitCommitsByOwnerByRepoByCommitSHA, mockResponse(t, http.StatusInternalServerError, nil), ), ), @@ -2446,7 +2357,7 @@ func Test_ListBranches(t *testing.T) { tests := []struct { name string args map[string]interface{} - mockResponses []mock.MockBackendOption + mockResponses []MockBackendOption wantErr bool errContains string }{ @@ -2457,9 +2368,9 @@ func Test_ListBranches(t *testing.T) { "repo": "repo", "page": float64(2), }, - mockResponses: []mock.MockBackendOption{ - mock.WithRequestMatch( - mock.GetReposBranchesByOwnerByRepo, + mockResponses: []MockBackendOption{ + WithRequestMatch( + GetReposBranchesByOwnerByRepo, mockBranches, ), }, @@ -2470,7 +2381,7 @@ func Test_ListBranches(t *testing.T) { args: map[string]interface{}{ "repo": "repo", }, - mockResponses: []mock.MockBackendOption{}, + mockResponses: []MockBackendOption{}, wantErr: false, errContains: "missing required parameter: owner", }, @@ -2479,7 +2390,7 @@ func Test_ListBranches(t *testing.T) { args: map[string]interface{}{ "owner": "owner", }, - mockResponses: []mock.MockBackendOption{}, + mockResponses: []MockBackendOption{}, wantErr: false, errContains: "missing required parameter: repo", }, @@ -2488,7 +2399,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(mock.NewMockedHTTPClient(tt.mockResponses...)) + mockClient := github.NewClient(NewMockedHTTPClient(tt.mockResponses...)) deps := BaseDeps{ Client: mockClient, } @@ -2585,20 +2496,20 @@ func Test_DeleteFile(t *testing.T) { }{ { name: "successful file deletion using Git Data API", - mockedClient: mock.NewMockedHTTPClient( + mockedClient: NewMockedHTTPClient( // Get branch reference - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, + WithRequestMatch( + GetReposGitRefByOwnerByRepoByRef, mockRef, ), // Get commit - mock.WithRequestMatch( - mock.GetReposGitCommitsByOwnerByRepoByCommitSha, + WithRequestMatch( + GetReposGitCommitsByOwnerByRepoByCommitSHA, mockCommit, ), // Create tree - mock.WithRequestMatchHandler( - mock.PostReposGitTreesByOwnerByRepo, + WithRequestMatchHandler( + PostReposGitTreesByOwnerByRepo, expectRequestBody(t, map[string]interface{}{ "base_tree": "def456", "tree": []interface{}{ @@ -2614,8 +2525,8 @@ func Test_DeleteFile(t *testing.T) { ), ), // Create commit - mock.WithRequestMatchHandler( - mock.PostReposGitCommitsByOwnerByRepo, + WithRequestMatchHandler( + PostReposGitCommitsByOwnerByRepo, expectRequestBody(t, map[string]interface{}{ "message": "Delete example file", "tree": "ghi789", @@ -2625,8 +2536,8 @@ func Test_DeleteFile(t *testing.T) { ), ), // Update reference - mock.WithRequestMatchHandler( - mock.PatchReposGitRefsByOwnerByRepoByRef, + WithRequestMatchHandler( + PatchReposGitRefsByOwnerByRepoByRef, expectRequestBody(t, map[string]interface{}{ "sha": "jkl012", "force": false, @@ -2652,9 +2563,9 @@ func Test_DeleteFile(t *testing.T) { }, { name: "file deletion fails - branch not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Reference not found"}`)) @@ -2762,9 +2673,9 @@ func Test_ListTags(t *testing.T) { }{ { name: "successful tags list", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposTagsByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposTagsByOwnerByRepo, expectPath( t, "/repos/owner/repo/tags", @@ -2782,9 +2693,9 @@ func Test_ListTags(t *testing.T) { }, { name: "list tags fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposTagsByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposTagsByOwnerByRepo, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"message": "Internal Server Error"}`)) @@ -2888,9 +2799,9 @@ func Test_GetTag(t *testing.T) { }{ { name: "successful tag retrieval", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, expectPath( t, "/repos/owner/repo/git/ref/tags/v1.0.0", @@ -2898,8 +2809,8 @@ func Test_GetTag(t *testing.T) { mockResponse(t, http.StatusOK, mockTagRef), ), ), - mock.WithRequestMatchHandler( - mock.GetReposGitTagsByOwnerByRepoByTagSha, + WithRequestMatchHandler( + GetReposGitTagsByOwnerByRepoByTagSHA, expectPath( t, "/repos/owner/repo/git/tags/v1.0.0-tag-sha", @@ -2918,9 +2829,9 @@ func Test_GetTag(t *testing.T) { }, { name: "tag reference not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Reference does not exist"}`)) @@ -2937,13 +2848,13 @@ func Test_GetTag(t *testing.T) { }, { name: "tag object not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposGitRefByOwnerByRepoByRef, + mockedClient: NewMockedHTTPClient( + WithRequestMatch( + GetReposGitRefByOwnerByRepoByRef, mockTagRef, ), - mock.WithRequestMatchHandler( - mock.GetReposGitTagsByOwnerByRepoByTagSha, + WithRequestMatchHandler( + GetReposGitTagsByOwnerByRepoByTagSHA, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Tag object does not exist"}`)) @@ -3041,9 +2952,9 @@ func Test_ListReleases(t *testing.T) { }{ { name: "successful releases list", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposReleasesByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatch( + GetReposReleasesByOwnerByRepo, mockReleases, ), ), @@ -3056,9 +2967,9 @@ func Test_ListReleases(t *testing.T) { }, { name: "releases list fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposReleasesByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposReleasesByOwnerByRepo, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Not Found"}`)) @@ -3132,9 +3043,9 @@ func Test_GetLatestRelease(t *testing.T) { }{ { name: "successful latest release fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposReleasesLatestByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatch( + GetReposReleasesLatestByOwnerByRepo, mockRelease, ), ), @@ -3147,9 +3058,9 @@ func Test_GetLatestRelease(t *testing.T) { }, { name: "latest release fetch fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposReleasesLatestByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposReleasesLatestByOwnerByRepo, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Not Found"}`)) @@ -3229,9 +3140,9 @@ func Test_GetReleaseByTag(t *testing.T) { }{ { name: "successful release by tag fetch", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatch( - mock.GetReposReleasesTagsByOwnerByRepoByTag, + mockedClient: NewMockedHTTPClient( + WithRequestMatch( + GetReposReleasesTagsByOwnerByRepoByTag, mockRelease, ), ), @@ -3245,7 +3156,7 @@ func Test_GetReleaseByTag(t *testing.T) { }, { name: "missing owner parameter", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "repo": "repo", "tag": "v1.0.0", @@ -3255,7 +3166,7 @@ func Test_GetReleaseByTag(t *testing.T) { }, { name: "missing repo parameter", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "owner": "owner", "tag": "v1.0.0", @@ -3265,7 +3176,7 @@ func Test_GetReleaseByTag(t *testing.T) { }, { name: "missing tag parameter", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: NewMockedHTTPClient(), requestArgs: map[string]interface{}{ "owner": "owner", "repo": "repo", @@ -3275,9 +3186,9 @@ func Test_GetReleaseByTag(t *testing.T) { }, { name: "release by tag not found", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposReleasesTagsByOwnerByRepoByTag, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposReleasesTagsByOwnerByRepoByTag, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Not Found"}`)) @@ -3294,9 +3205,9 @@ func Test_GetReleaseByTag(t *testing.T) { }, { name: "server error", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposReleasesTagsByOwnerByRepoByTag, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposReleasesTagsByOwnerByRepoByTag, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusInternalServerError) _, _ = w.Write([]byte(`{"message": "Internal Server Error"}`)) @@ -3543,7 +3454,7 @@ func Test_resolveGitReference(t *testing.T) { sha: "123sha456", mockSetup: func() *http.Client { // No API calls should be made when SHA is provided - return mock.NewMockedHTTPClient() + return NewMockedHTTPClient() }, expectedOutput: &raw.ContentOpts{ SHA: "123sha456", @@ -3555,16 +3466,16 @@ func Test_resolveGitReference(t *testing.T) { ref: "", sha: "", mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposByOwnerByRepo, + return NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposByOwnerByRepo, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"name": "repo", "default_branch": "main"}`)) }), ), - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Contains(t, r.URL.Path, "/git/ref/heads/main") w.WriteHeader(http.StatusOK) @@ -3584,9 +3495,9 @@ func Test_resolveGitReference(t *testing.T) { ref: "refs/heads/feature-branch", sha: "", mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + return NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Contains(t, r.URL.Path, "/git/ref/heads/feature-branch") w.WriteHeader(http.StatusOK) @@ -3606,9 +3517,9 @@ func Test_resolveGitReference(t *testing.T) { ref: "main", sha: "", mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + return NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.URL.Path, "/git/ref/heads/main") { w.WriteHeader(http.StatusOK) @@ -3632,9 +3543,9 @@ func Test_resolveGitReference(t *testing.T) { ref: "v1.0.0", sha: "", mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + return NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch { case strings.Contains(r.URL.Path, "/git/ref/heads/v1.0.0"): @@ -3662,9 +3573,9 @@ func Test_resolveGitReference(t *testing.T) { ref: "heads/feature-branch", sha: "", mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + return NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Contains(t, r.URL.Path, "/git/ref/heads/feature-branch") w.WriteHeader(http.StatusOK) @@ -3684,9 +3595,9 @@ func Test_resolveGitReference(t *testing.T) { ref: "tags/v1.0.0", sha: "", mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + return NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Contains(t, r.URL.Path, "/git/ref/tags/v1.0.0") w.WriteHeader(http.StatusOK) @@ -3706,9 +3617,9 @@ func Test_resolveGitReference(t *testing.T) { ref: "nonexistent", sha: "", mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + return NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { // Both branch and tag attempts should return 404 w.WriteHeader(http.StatusNotFound) @@ -3725,9 +3636,9 @@ func Test_resolveGitReference(t *testing.T) { ref: "refs/pull/123/head", sha: "", mockSetup: func() *http.Client { - return mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetReposGitRefByOwnerByRepoByRef, + return NewMockedHTTPClient( + WithRequestMatchHandler( + GetReposGitRefByOwnerByRepoByRef, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Contains(t, r.URL.Path, "/git/ref/pull/123/head") w.WriteHeader(http.StatusOK) @@ -3748,7 +3659,7 @@ func Test_resolveGitReference(t *testing.T) { sha: "", mockSetup: func() *http.Client { // No API calls should be made when ref looks like SHA - return mock.NewMockedHTTPClient() + return NewMockedHTTPClient() }, expectedOutput: &raw.ContentOpts{ SHA: "abc123def456abc123def456abc123def456abc1", @@ -3856,12 +3767,12 @@ func Test_ListStarredRepositories(t *testing.T) { }{ { name: "successful list for authenticated user", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetUserStarred, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetUserStarred, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(mockStarredRepos)) + _, _ = w.Write(MustMarshal(mockStarredRepos)) }), ), ), @@ -3871,12 +3782,12 @@ func Test_ListStarredRepositories(t *testing.T) { }, { name: "successful list for specific user", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetUsersStarredByUsername, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetUsersStarredByUsername, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(mockStarredRepos)) + _, _ = w.Write(MustMarshal(mockStarredRepos)) }), ), ), @@ -3888,9 +3799,9 @@ func Test_ListStarredRepositories(t *testing.T) { }, { name: "list fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetUserStarred, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + GetUserStarred, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Not Found"}`)) @@ -3970,9 +3881,9 @@ func Test_StarRepository(t *testing.T) { }{ { name: "successful star", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutUserStarredByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + PutUserStarredByOwnerByRepo, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }), @@ -3986,9 +3897,9 @@ func Test_StarRepository(t *testing.T) { }, { name: "star fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.PutUserStarredByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + PutUserStarredByOwnerByRepo, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Not Found"}`)) @@ -4061,9 +3972,9 @@ func Test_UnstarRepository(t *testing.T) { }{ { name: "successful unstar", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteUserStarredByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + DeleteUserStarredByOwnerByRepo, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) }), @@ -4077,9 +3988,9 @@ func Test_UnstarRepository(t *testing.T) { }, { name: "unstar fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.DeleteUserStarredByOwnerByRepo, + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + DeleteUserStarredByOwnerByRepo, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNotFound) _, _ = w.Write([]byte(`{"message": "Not Found"}`)) diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index be1b26714..e15758c3e 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -10,7 +10,6 @@ import ( "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" - "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -67,20 +66,17 @@ func Test_SearchRepositories(t *testing.T) { }{ { name: "successful repository search", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchRepositories, - expectQueryParams(t, map[string]string{ - "q": "golang test", - "sort": "stars", - "order": "desc", - "page": "2", - "per_page": "10", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchRepositories: expectQueryParams(t, map[string]string{ + "q": "golang test", + "sort": "stars", + "order": "desc", + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "golang test", "sort": "stars", @@ -93,18 +89,15 @@ func Test_SearchRepositories(t *testing.T) { }, { name: "repository search with default pagination", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchRepositories, - expectQueryParams(t, map[string]string{ - "q": "golang test", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchRepositories: expectQueryParams(t, map[string]string{ + "q": "golang test", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "golang test", }, @@ -113,15 +106,12 @@ func Test_SearchRepositories(t *testing.T) { }, { name: "search fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchRepositories, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Invalid query"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchRepositories: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Invalid query"}`)) + }), + }), requestArgs: map[string]interface{}{ "query": "invalid:query", }, @@ -194,18 +184,15 @@ func Test_SearchRepositories_FullOutput(t *testing.T) { }, } - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchRepositories, - expectQueryParams(t, map[string]string{ - "q": "golang test", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchRepositories: expectQueryParams(t, map[string]string{ + "q": "golang test", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ) + }) client := github.NewClient(mockedClient) serverTool := SearchRepositories(translations.NullTranslationHelper) @@ -291,20 +278,17 @@ func Test_SearchCode(t *testing.T) { }{ { name: "successful code search with all parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchCode, - expectQueryParams(t, map[string]string{ - "q": "fmt.Println language:go", - "sort": "indexed", - "order": "desc", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchCode: expectQueryParams(t, map[string]string{ + "q": "fmt.Println language:go", + "sort": "indexed", + "order": "desc", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "fmt.Println language:go", "sort": "indexed", @@ -317,18 +301,15 @@ func Test_SearchCode(t *testing.T) { }, { name: "code search with minimal parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchCode, - expectQueryParams(t, map[string]string{ - "q": "fmt.Println language:go", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchCode: expectQueryParams(t, map[string]string{ + "q": "fmt.Println language:go", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "fmt.Println language:go", }, @@ -337,15 +318,12 @@ func Test_SearchCode(t *testing.T) { }, { name: "search code fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchCode, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchCode: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }), + }), requestArgs: map[string]interface{}{ "query": "invalid:query", }, @@ -451,20 +429,17 @@ func Test_SearchUsers(t *testing.T) { }{ { name: "successful users search with all parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - expectQueryParams(t, map[string]string{ - "q": "type:user location:finland language:go", - "sort": "followers", - "order": "desc", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchUsers: expectQueryParams(t, map[string]string{ + "q": "type:user location:finland language:go", + "sort": "followers", + "order": "desc", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "location:finland language:go", "sort": "followers", @@ -477,18 +452,15 @@ func Test_SearchUsers(t *testing.T) { }, { name: "users search with minimal parameters", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - expectQueryParams(t, map[string]string{ - "q": "type:user location:finland language:go", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchUsers: expectQueryParams(t, map[string]string{ + "q": "type:user location:finland language:go", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "location:finland language:go", }, @@ -497,18 +469,15 @@ func Test_SearchUsers(t *testing.T) { }, { name: "query with existing type:user filter - no duplication", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - expectQueryParams(t, map[string]string{ - "q": "type:user location:seattle followers:>100", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchUsers: expectQueryParams(t, map[string]string{ + "q": "type:user location:seattle followers:>100", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "type:user location:seattle followers:>100", }, @@ -517,18 +486,15 @@ func Test_SearchUsers(t *testing.T) { }, { name: "complex query with existing type:user filter and OR operators", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - expectQueryParams(t, map[string]string{ - "q": "type:user (location:seattle OR location:california) followers:>50", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchUsers: expectQueryParams(t, map[string]string{ + "q": "type:user (location:seattle OR location:california) followers:>50", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "type:user (location:seattle OR location:california) followers:>50", }, @@ -537,15 +503,12 @@ func Test_SearchUsers(t *testing.T) { }, { name: "search users fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchUsers: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }), + }), requestArgs: map[string]interface{}{ "query": "invalid:query", }, @@ -652,18 +615,15 @@ func Test_SearchOrgs(t *testing.T) { }{ { name: "successful org search", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - expectQueryParams(t, map[string]string{ - "q": "type:org github", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchUsers: expectQueryParams(t, map[string]string{ + "q": "type:org github", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "github", }, @@ -672,18 +632,15 @@ func Test_SearchOrgs(t *testing.T) { }, { name: "query with existing type:org filter - no duplication", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - expectQueryParams(t, map[string]string{ - "q": "type:org location:california followers:>1000", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchUsers: expectQueryParams(t, map[string]string{ + "q": "type:org location:california followers:>1000", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "type:org location:california followers:>1000", }, @@ -692,18 +649,15 @@ func Test_SearchOrgs(t *testing.T) { }, { name: "complex query with existing type:org filter and OR operators", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - expectQueryParams(t, map[string]string{ - "q": "type:org (location:seattle OR location:california OR location:newyork) repos:>10", - "page": "1", - "per_page": "30", - }).andThen( - mockResponse(t, http.StatusOK, mockSearchResult), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchUsers: expectQueryParams(t, map[string]string{ + "q": "type:org (location:seattle OR location:california OR location:newyork) repos:>10", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), ), - ), + }), requestArgs: map[string]interface{}{ "query": "type:org (location:seattle OR location:california OR location:newyork) repos:>10", }, @@ -712,15 +666,12 @@ func Test_SearchOrgs(t *testing.T) { }, { name: "org search fails", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.GetSearchUsers, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchUsers: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }), + }), requestArgs: map[string]interface{}{ "query": "invalid:query", }, diff --git a/pkg/raw/raw_mock.go b/pkg/raw/raw_mock.go deleted file mode 100644 index 30c7759d3..000000000 --- a/pkg/raw/raw_mock.go +++ /dev/null @@ -1,20 +0,0 @@ -package raw - -import "github.com/migueleliasweb/go-github-mock/src/mock" - -var GetRawReposContentsByOwnerByRepoByPath mock.EndpointPattern = mock.EndpointPattern{ - Pattern: "/{owner}/{repo}/HEAD/{path:.*}", - Method: "GET", -} -var GetRawReposContentsByOwnerByRepoByBranchByPath mock.EndpointPattern = mock.EndpointPattern{ - Pattern: "/{owner}/{repo}/refs/heads/{branch}/{path:.*}", - Method: "GET", -} -var GetRawReposContentsByOwnerByRepoByTagByPath mock.EndpointPattern = mock.EndpointPattern{ - Pattern: "/{owner}/{repo}/refs/tags/{tag}/{path:.*}", - Method: "GET", -} -var GetRawReposContentsByOwnerByRepoBySHAByPath mock.EndpointPattern = mock.EndpointPattern{ - Pattern: "/{owner}/{repo}/{sha}/{path:.*}", - Method: "GET", -} diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index 5cb31cac4..fb4392fb9 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -18,17 +18,14 @@ The following packages are included for the amd64, arm64 architectures. - [github.com/go-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.19.5/LICENSE)) - [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.21.1/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.4.0/LICENSE)) - - [github.com/google/go-github/v71/github](https://pkg.go.dev/github.com/google/go-github/v71/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v71.0.0/LICENSE)) - [github.com/google/go-github/v79/github](https://pkg.go.dev/github.com/google/go-github/v79/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v79.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.1.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)) - - [github.com/gorilla/mux](https://pkg.go.dev/github.com/gorilla/mux) ([BSD-3-Clause](https://github.com/gorilla/mux/blob/v1.8.0/LICENSE)) - [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v1.9.2/LICENSE)) - [github.com/josharian/intern](https://pkg.go.dev/github.com/josharian/intern) ([MIT](https://github.com/josharian/intern/blob/v1.0.0/license.md)) - [github.com/mailru/easyjson](https://pkg.go.dev/github.com/mailru/easyjson) ([MIT](https://github.com/mailru/easyjson/blob/v0.7.7/LICENSE)) - [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) - - [github.com/migueleliasweb/go-github-mock/src/mock](https://pkg.go.dev/github.com/migueleliasweb/go-github-mock/src/mock) ([MIT](https://github.com/migueleliasweb/go-github-mock/blob/v1.3.0/LICENSE)) - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.2.0/LICENSE)) - [github.com/muesli/cache2go](https://pkg.go.dev/github.com/muesli/cache2go) ([BSD-3-Clause](https://github.com/muesli/cache2go/blob/518229cd8021/LICENSE.txt)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE)) @@ -49,7 +46,6 @@ The following packages are included for the amd64, arm64 architectures. - [golang.org/x/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.38.0:LICENSE)) - [golang.org/x/sys/unix](https://pkg.go.dev/golang.org/x/sys/unix) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE)) - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.28.0:LICENSE)) - - [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.5.0:LICENSE)) - [gopkg.in/yaml.v2](https://pkg.go.dev/gopkg.in/yaml.v2) ([Apache-2.0](https://github.com/go-yaml/yaml/blob/v2.4.0/LICENSE)) [github/github-mcp-server]: https://github.com/github/github-mcp-server diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index 8d0829a63..564f20dcb 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -18,17 +18,14 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/go-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.19.5/LICENSE)) - [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.21.1/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.4.0/LICENSE)) - - [github.com/google/go-github/v71/github](https://pkg.go.dev/github.com/google/go-github/v71/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v71.0.0/LICENSE)) - [github.com/google/go-github/v79/github](https://pkg.go.dev/github.com/google/go-github/v79/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v79.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.1.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)) - - [github.com/gorilla/mux](https://pkg.go.dev/github.com/gorilla/mux) ([BSD-3-Clause](https://github.com/gorilla/mux/blob/v1.8.0/LICENSE)) - [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v1.9.2/LICENSE)) - [github.com/josharian/intern](https://pkg.go.dev/github.com/josharian/intern) ([MIT](https://github.com/josharian/intern/blob/v1.0.0/license.md)) - [github.com/mailru/easyjson](https://pkg.go.dev/github.com/mailru/easyjson) ([MIT](https://github.com/mailru/easyjson/blob/v0.7.7/LICENSE)) - [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) - - [github.com/migueleliasweb/go-github-mock/src/mock](https://pkg.go.dev/github.com/migueleliasweb/go-github-mock/src/mock) ([MIT](https://github.com/migueleliasweb/go-github-mock/blob/v1.3.0/LICENSE)) - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.2.0/LICENSE)) - [github.com/muesli/cache2go](https://pkg.go.dev/github.com/muesli/cache2go) ([BSD-3-Clause](https://github.com/muesli/cache2go/blob/518229cd8021/LICENSE.txt)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE)) @@ -49,7 +46,6 @@ The following packages are included for the 386, amd64, arm64 architectures. - [golang.org/x/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.38.0:LICENSE)) - [golang.org/x/sys/unix](https://pkg.go.dev/golang.org/x/sys/unix) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE)) - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.28.0:LICENSE)) - - [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.5.0:LICENSE)) - [gopkg.in/yaml.v2](https://pkg.go.dev/gopkg.in/yaml.v2) ([Apache-2.0](https://github.com/go-yaml/yaml/blob/v2.4.0/LICENSE)) [github/github-mcp-server]: https://github.com/github/github-mcp-server diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index 79b925138..6b4dcfb97 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -18,18 +18,15 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/go-openapi/jsonpointer](https://pkg.go.dev/github.com/go-openapi/jsonpointer) ([Apache-2.0](https://github.com/go-openapi/jsonpointer/blob/v0.19.5/LICENSE)) - [github.com/go-openapi/swag](https://pkg.go.dev/github.com/go-openapi/swag) ([Apache-2.0](https://github.com/go-openapi/swag/blob/v0.21.1/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.4.0/LICENSE)) - - [github.com/google/go-github/v71/github](https://pkg.go.dev/github.com/google/go-github/v71/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v71.0.0/LICENSE)) - [github.com/google/go-github/v79/github](https://pkg.go.dev/github.com/google/go-github/v79/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v79.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.1.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)) - - [github.com/gorilla/mux](https://pkg.go.dev/github.com/gorilla/mux) ([BSD-3-Clause](https://github.com/gorilla/mux/blob/v1.8.0/LICENSE)) - [github.com/inconshreveable/mousetrap](https://pkg.go.dev/github.com/inconshreveable/mousetrap) ([Apache-2.0](https://github.com/inconshreveable/mousetrap/blob/v1.1.0/LICENSE)) - [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v1.9.2/LICENSE)) - [github.com/josharian/intern](https://pkg.go.dev/github.com/josharian/intern) ([MIT](https://github.com/josharian/intern/blob/v1.0.0/license.md)) - [github.com/mailru/easyjson](https://pkg.go.dev/github.com/mailru/easyjson) ([MIT](https://github.com/mailru/easyjson/blob/v0.7.7/LICENSE)) - [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) - - [github.com/migueleliasweb/go-github-mock/src/mock](https://pkg.go.dev/github.com/migueleliasweb/go-github-mock/src/mock) ([MIT](https://github.com/migueleliasweb/go-github-mock/blob/v1.3.0/LICENSE)) - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.2.0/LICENSE)) - [github.com/muesli/cache2go](https://pkg.go.dev/github.com/muesli/cache2go) ([BSD-3-Clause](https://github.com/muesli/cache2go/blob/518229cd8021/LICENSE.txt)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE)) @@ -50,7 +47,6 @@ The following packages are included for the 386, amd64, arm64 architectures. - [golang.org/x/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.38.0:LICENSE)) - [golang.org/x/sys/windows](https://pkg.go.dev/golang.org/x/sys/windows) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE)) - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.28.0:LICENSE)) - - [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.5.0:LICENSE)) - [gopkg.in/yaml.v2](https://pkg.go.dev/gopkg.in/yaml.v2) ([Apache-2.0](https://github.com/go-yaml/yaml/blob/v2.4.0/LICENSE)) [github/github-mcp-server]: https://github.com/github/github-mcp-server diff --git a/third-party/github.com/google/go-github/v71/github/LICENSE b/third-party/github.com/google/go-github/v71/github/LICENSE deleted file mode 100644 index 28b6486f0..000000000 --- a/third-party/github.com/google/go-github/v71/github/LICENSE +++ /dev/null @@ -1,27 +0,0 @@ -Copyright (c) 2013 The go-github AUTHORS. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/github.com/gorilla/mux/LICENSE b/third-party/github.com/gorilla/mux/LICENSE deleted file mode 100644 index 6903df638..000000000 --- a/third-party/github.com/gorilla/mux/LICENSE +++ /dev/null @@ -1,27 +0,0 @@ -Copyright (c) 2012-2018 The Gorilla Authors. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/third-party/github.com/migueleliasweb/go-github-mock/src/mock/LICENSE b/third-party/github.com/migueleliasweb/go-github-mock/src/mock/LICENSE deleted file mode 100644 index 86d42717d..000000000 --- a/third-party/github.com/migueleliasweb/go-github-mock/src/mock/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2021 Miguel Elias dos Santos - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/third-party/golang.org/x/time/rate/LICENSE b/third-party/golang.org/x/time/rate/LICENSE deleted file mode 100644 index 6a66aea5e..000000000 --- a/third-party/golang.org/x/time/rate/LICENSE +++ /dev/null @@ -1,27 +0,0 @@ -Copyright (c) 2009 The Go Authors. All rights reserved. - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are -met: - - * Redistributions of source code must retain the above copyright -notice, this list of conditions and the following disclaimer. - * Redistributions in binary form must reproduce the above -copyright notice, this list of conditions and the following disclaimer -in the documentation and/or other materials provided with the -distribution. - * Neither the name of Google Inc. nor the names of its -contributors may be used to endorse or promote products derived from -this software without specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS -"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT -LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR -A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT -OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, -SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT -LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, -DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY -THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT -(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. From 66a01645f82e00c56a141e08903402f9d4e1ead7 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Wed, 31 Dec 2025 10:26:30 +0000 Subject: [PATCH 41/70] initial projects consolidation --- README.md | 29 + pkg/github/__toolsnaps__/projects_get.snap | 59 ++ pkg/github/__toolsnaps__/projects_list.snap | 66 ++ pkg/github/__toolsnaps__/projects_write.snap | 60 ++ pkg/github/projects.go | 775 ++++++++++++++++++- pkg/github/projects_test.go | 694 +++++++++++++++++ pkg/github/tools.go | 5 + 7 files changed, 1679 insertions(+), 9 deletions(-) create mode 100644 pkg/github/__toolsnaps__/projects_get.snap create mode 100644 pkg/github/__toolsnaps__/projects_list.snap create mode 100644 pkg/github/__toolsnaps__/projects_write.snap diff --git a/README.md b/README.md index 1a0f6b1c4..dcf4756d2 100644 --- a/README.md +++ b/README.md @@ -1000,6 +1000,35 @@ The following sets of tools are available: - `per_page`: Results per page (max 50) (number, optional) - `query`: Filter projects by title text and open/closed state; permitted qualifiers: is:open, is:closed; examples: "roadmap is:open", "is:open feature planning". (string, optional) +- **projects_get** - Get details of GitHub Projects resources + - `field_id`: The field's ID. Required for 'get_project_field' method. (number, optional) + - `fields`: Specific list of field IDs to include in the response when getting a project item (e.g. ["102589", "985201", "169875"]). If not provided, only the title field is included. Only used for 'get_project_item' method. (string[], optional) + - `item_id`: The item's ID. Required for 'get_project_item' method. (number, optional) + - `method`: The method to execute (string, required) + - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) + - `owner_type`: Owner type (string, required) + - `project_number`: The project's number. (number, required) + +- **projects_list** - List GitHub Projects resources + - `after`: Forward pagination cursor from previous pageInfo.nextCursor. (string, optional) + - `before`: Backward pagination cursor from previous pageInfo.prevCursor (rare). (string, optional) + - `fields`: Field IDs to include when listing project items (e.g. ["102589", "985201"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Only used for 'list_project_items' method. (string[], optional) + - `method`: The action to perform (string, required) + - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) + - `owner_type`: Owner type (string, required) + - `per_page`: Results per page (max 50) (number, optional) + - `project_number`: The project's number. Required for 'list_project_fields' and 'list_project_items' methods. (number, optional) + - `query`: Filter/query string. For list_projects: filter by title text and state (e.g. "roadmap is:open"). For list_project_items: advanced filtering using GitHub's project filtering syntax. (string, optional) + +- **projects_write** - Modify GitHub Project items + - `item_id`: The project item ID. Required for 'update_project_item' and 'delete_project_item' methods. For add_project_item, this is the numeric ID of the issue or pull request to add. (number, optional) + - `item_type`: The item's type, either issue or pull_request. Required for 'add_project_item' method. (string, optional) + - `method`: The method to execute (string, required) + - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) + - `owner_type`: Owner type (string, required) + - `project_number`: The project's number. (number, required) + - `updated_field`: Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {"id": 123456, "value": "New Value"}. Required for 'update_project_item' method. (object, optional) + - **update_project_item** - Update project item - **Required OAuth Scopes**: `project` - `item_id`: The unique identifier of the project item. This is not the issue or pull request ID. (number, required) diff --git a/pkg/github/__toolsnaps__/projects_get.snap b/pkg/github/__toolsnaps__/projects_get.snap new file mode 100644 index 000000000..9758de0f2 --- /dev/null +++ b/pkg/github/__toolsnaps__/projects_get.snap @@ -0,0 +1,59 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get details of GitHub Projects resources" + }, + "description": "Get details about specific GitHub Projects resources.\nUse this tool to get details about individual projects, project fields, and project items by their unique IDs.\n", + "inputSchema": { + "type": "object", + "required": [ + "method", + "owner_type", + "owner", + "project_number" + ], + "properties": { + "field_id": { + "type": "number", + "description": "The field's ID. Required for 'get_project_field' method." + }, + "fields": { + "type": "array", + "description": "Specific list of field IDs to include in the response when getting a project item (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included. Only used for 'get_project_item' method.", + "items": { + "type": "string" + } + }, + "item_id": { + "type": "number", + "description": "The item's ID. Required for 'get_project_item' method." + }, + "method": { + "type": "string", + "description": "The method to execute", + "enum": [ + "get_project", + "get_project_field", + "get_project_item" + ] + }, + "owner": { + "type": "string", + "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive." + }, + "owner_type": { + "type": "string", + "description": "Owner type", + "enum": [ + "user", + "org" + ] + }, + "project_number": { + "type": "number", + "description": "The project's number." + } + } + }, + "name": "projects_get" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/projects_list.snap b/pkg/github/__toolsnaps__/projects_list.snap new file mode 100644 index 000000000..7cc2e2df7 --- /dev/null +++ b/pkg/github/__toolsnaps__/projects_list.snap @@ -0,0 +1,66 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "List GitHub Projects resources" + }, + "description": "Tools for listing GitHub Projects resources.\nUse this tool to list projects for a user or organization, or list project fields and items for a specific project.\n", + "inputSchema": { + "type": "object", + "required": [ + "method", + "owner_type", + "owner" + ], + "properties": { + "after": { + "type": "string", + "description": "Forward pagination cursor from previous pageInfo.nextCursor." + }, + "before": { + "type": "string", + "description": "Backward pagination cursor from previous pageInfo.prevCursor (rare)." + }, + "fields": { + "type": "array", + "description": "Field IDs to include when listing project items (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Only used for 'list_project_items' method.", + "items": { + "type": "string" + } + }, + "method": { + "type": "string", + "description": "The action to perform", + "enum": [ + "list_projects", + "list_project_fields", + "list_project_items" + ] + }, + "owner": { + "type": "string", + "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive." + }, + "owner_type": { + "type": "string", + "description": "Owner type", + "enum": [ + "user", + "org" + ] + }, + "per_page": { + "type": "number", + "description": "Results per page (max 50)" + }, + "project_number": { + "type": "number", + "description": "The project's number. Required for 'list_project_fields' and 'list_project_items' methods." + }, + "query": { + "type": "string", + "description": "Filter/query string. For list_projects: filter by title text and state (e.g. \"roadmap is:open\"). For list_project_items: advanced filtering using GitHub's project filtering syntax." + } + } + }, + "name": "projects_list" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap new file mode 100644 index 000000000..2224590c5 --- /dev/null +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -0,0 +1,60 @@ +{ + "annotations": { + "destructiveHint": true, + "title": "Modify GitHub Project items" + }, + "description": "Add, update, or delete project items in a GitHub Project.", + "inputSchema": { + "type": "object", + "required": [ + "method", + "owner_type", + "owner", + "project_number" + ], + "properties": { + "item_id": { + "type": "number", + "description": "The project item ID. Required for 'update_project_item' and 'delete_project_item' methods. For add_project_item, this is the numeric ID of the issue or pull request to add." + }, + "item_type": { + "type": "string", + "description": "The item's type, either issue or pull_request. Required for 'add_project_item' method.", + "enum": [ + "issue", + "pull_request" + ] + }, + "method": { + "type": "string", + "description": "The method to execute", + "enum": [ + "add_project_item", + "update_project_item", + "delete_project_item" + ] + }, + "owner": { + "type": "string", + "description": "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive." + }, + "owner_type": { + "type": "string", + "description": "Owner type", + "enum": [ + "user", + "org" + ] + }, + "project_number": { + "type": "number", + "description": "The project's number." + }, + "updated_field": { + "type": "object", + "description": "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}. Required for 'update_project_item' method." + } + } + }, + "name": "projects_write" +} \ No newline at end of file diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 79cbbe680..9a5d25839 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -26,8 +26,25 @@ const ( MaxProjectsPerPage = 50 ) +// FeatureFlagConsolidatedProjects is the feature flag that disables individual project tools +// in favor of the consolidated project tools. +const FeatureFlagConsolidatedProjects = "remote_mcp_consolidated_projects" + +// Method constants for consolidated project tools +const ( + projectsMethodListProjects = "list_projects" + projectsMethodListProjectFields = "list_project_fields" + projectsMethodListProjectItems = "list_project_items" + projectsMethodGetProject = "get_project" + projectsMethodGetProjectField = "get_project_field" + projectsMethodGetProjectItem = "get_project_item" + projectsMethodAddProjectItem = "add_project_item" + projectsMethodUpdateProjectItem = "update_project_item" + projectsMethodDeleteProjectItem = "delete_project_item" +) + func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "list_projects", @@ -142,10 +159,12 @@ func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + return tool } func GetProject(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "get_project", @@ -231,10 +250,12 @@ func GetProject(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + return tool } func ListProjectFields(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "list_project_fields", @@ -338,10 +359,12 @@ func ListProjectFields(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + return tool } func GetProjectField(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "get_project_field", @@ -431,10 +454,12 @@ func GetProjectField(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + return tool } func ListProjectItems(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "list_project_items", @@ -568,10 +593,12 @@ func ListProjectItems(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + return tool } func GetProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "get_project_item", @@ -675,10 +702,12 @@ func GetProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + return tool } func AddProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "add_project_item", @@ -787,10 +816,12 @@ func AddProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + return tool } func UpdateProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "update_project_item", @@ -900,10 +931,12 @@ func UpdateProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultText(string(r)), nil, nil }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + return tool } func DeleteProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + tool := NewTool( ToolsetMetadataProjects, mcp.Tool{ Name: "delete_project_item", @@ -987,6 +1020,730 @@ func DeleteProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultText("project item successfully deleted"), nil, nil }, ) + tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + return tool +} + +// ProjectsList returns the tool and handler for listing GitHub Projects resources. +func ProjectsList(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataProjects, + mcp.Tool{ + Name: "projects_list", + Description: t("TOOL_PROJECTS_LIST_DESCRIPTION", + `Tools for listing GitHub Projects resources. +Use this tool to list projects for a user or organization, or list project fields and items for a specific project. +`), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_PROJECTS_LIST_USER_TITLE", "List GitHub Projects resources"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: "The action to perform", + Enum: []any{ + projectsMethodListProjects, + projectsMethodListProjectFields, + projectsMethodListProjectItems, + }, + }, + "owner_type": { + Type: "string", + Description: "Owner type", + Enum: []any{"user", "org"}, + }, + "owner": { + Type: "string", + Description: "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", + }, + "project_number": { + Type: "number", + Description: "The project's number. Required for 'list_project_fields' and 'list_project_items' methods.", + }, + "query": { + Type: "string", + Description: `Filter/query string. For list_projects: filter by title text and state (e.g. "roadmap is:open"). For list_project_items: advanced filtering using GitHub's project filtering syntax.`, + }, + "fields": { + Type: "array", + Description: "Field IDs to include when listing project items (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Only used for 'list_project_items' method.", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + "per_page": { + Type: "number", + Description: fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage), + }, + "after": { + Type: "string", + Description: "Forward pagination cursor from previous pageInfo.nextCursor.", + }, + "before": { + Type: "string", + Description: "Backward pagination cursor from previous pageInfo.prevCursor (rare).", + }, + }, + Required: []string{"method", "owner_type", "owner"}, + }, + }, + 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 + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + ownerType, err := RequiredParam[string](args, "owner_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + switch method { + case projectsMethodListProjects: + return listProjects(ctx, client, args, owner, ownerType) + case projectsMethodListProjectFields: + return listProjectFields(ctx, client, args, owner, ownerType) + case projectsMethodListProjectItems: + return listProjectItems(ctx, client, args, owner, ownerType) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + }, + ) + tool.FeatureFlagEnable = FeatureFlagConsolidatedProjects + return tool +} + +// ProjectsGet returns the tool and handler for getting GitHub Projects resources. +func ProjectsGet(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataProjects, + mcp.Tool{ + Name: "projects_get", + Description: t("TOOL_PROJECTS_GET_DESCRIPTION", `Get details about specific GitHub Projects resources. +Use this tool to get details about individual projects, project fields, and project items by their unique IDs. +`), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_PROJECTS_GET_USER_TITLE", "Get details of GitHub Projects resources"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: "The method to execute", + Enum: []any{ + projectsMethodGetProject, + projectsMethodGetProjectField, + projectsMethodGetProjectItem, + }, + }, + "owner_type": { + Type: "string", + Description: "Owner type", + Enum: []any{"user", "org"}, + }, + "owner": { + Type: "string", + Description: "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", + }, + "project_number": { + Type: "number", + Description: "The project's number.", + }, + "field_id": { + Type: "number", + Description: "The field's ID. Required for 'get_project_field' method.", + }, + "item_id": { + Type: "number", + Description: "The item's ID. Required for 'get_project_item' method.", + }, + "fields": { + Type: "array", + Description: "Specific list of field IDs to include in the response when getting a project item (e.g. [\"102589\", \"985201\", \"169875\"]). If not provided, only the title field is included. Only used for 'get_project_item' method.", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + }, + Required: []string{"method", "owner_type", "owner", "project_number"}, + }, + }, + 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 + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + ownerType, err := RequiredParam[string](args, "owner_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + switch method { + case projectsMethodGetProject: + return getProject(ctx, client, owner, ownerType, projectNumber) + case projectsMethodGetProjectField: + fieldID, err := RequiredBigInt(args, "field_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + return getProjectField(ctx, client, owner, ownerType, projectNumber, fieldID) + case projectsMethodGetProjectItem: + itemID, err := RequiredBigInt(args, "item_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + fields, err := OptionalBigIntArrayParam(args, "fields") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + return getProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fields) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + }, + ) + tool.FeatureFlagEnable = FeatureFlagConsolidatedProjects + return tool +} + +// ProjectsWrite returns the tool and handler for modifying GitHub Projects resources. +func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataProjects, + mcp.Tool{ + Name: "projects_write", + Description: t("TOOL_PROJECTS_WRITE_DESCRIPTION", "Add, update, or delete project items in a GitHub Project."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_PROJECTS_WRITE_USER_TITLE", "Modify GitHub Project items"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Description: "The method to execute", + Enum: []any{ + projectsMethodAddProjectItem, + projectsMethodUpdateProjectItem, + projectsMethodDeleteProjectItem, + }, + }, + "owner_type": { + Type: "string", + Description: "Owner type", + Enum: []any{"user", "org"}, + }, + "owner": { + Type: "string", + Description: "If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive.", + }, + "project_number": { + Type: "number", + Description: "The project's number.", + }, + "item_id": { + Type: "number", + Description: "The project item ID. Required for 'update_project_item' and 'delete_project_item' methods. For add_project_item, this is the numeric ID of the issue or pull request to add.", + }, + "item_type": { + Type: "string", + Description: "The item's type, either issue or pull_request. Required for 'add_project_item' method.", + Enum: []any{"issue", "pull_request"}, + }, + "updated_field": { + Type: "object", + Description: "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}. Required for 'update_project_item' method.", + }, + }, + Required: []string{"method", "owner_type", "owner", "project_number"}, + }, + }, + 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 + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + ownerType, err := RequiredParam[string](args, "owner_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + switch method { + case projectsMethodAddProjectItem: + itemID, err := RequiredBigInt(args, "item_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + itemType, err := RequiredParam[string](args, "item_type") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + return addProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, itemType) + case projectsMethodUpdateProjectItem: + itemID, err := RequiredBigInt(args, "item_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + rawUpdatedField, exists := args["updated_field"] + if !exists { + return utils.NewToolResultError("missing required parameter: updated_field"), nil, nil + } + fieldValue, ok := rawUpdatedField.(map[string]any) + if !ok || fieldValue == nil { + return utils.NewToolResultError("updated_field must be an object"), nil, nil + } + return updateProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fieldValue) + case projectsMethodDeleteProjectItem: + itemID, err := RequiredBigInt(args, "item_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + return deleteProjectItem(ctx, client, owner, ownerType, projectNumber, itemID) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + }, + ) + tool.FeatureFlagEnable = FeatureFlagConsolidatedProjects + return tool +} + +// Helper functions for consolidated projects tools + +func listProjects(ctx context.Context, client *github.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) { + queryStr, err := OptionalParam[string](args, "query") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + pagination, err := extractPaginationOptionsFromArgs(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var resp *github.Response + var projects []*github.ProjectV2 + var queryPtr *string + + if queryStr != "" { + queryPtr = &queryStr + } + + minimalProjects := []MinimalProject{} + opts := &github.ListProjectsOptions{ + ListProjectsPaginationOptions: pagination, + Query: queryPtr, + } + + if ownerType == "org" { + projects, resp, err = client.Projects.ListOrganizationProjects(ctx, owner, opts) + } else { + projects, resp, err = client.Projects.ListUserProjects(ctx, owner, opts) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list projects", + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + for _, project := range projects { + minimalProjects = append(minimalProjects, *convertToMinimalProject(project)) + } + + response := map[string]any{ + "projects": minimalProjects, + "pageInfo": buildPageInfo(resp), + } + + r, err := json.Marshal(response) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func listProjectFields(ctx context.Context, client *github.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) { + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + pagination, err := extractPaginationOptionsFromArgs(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var resp *github.Response + var projectFields []*github.ProjectV2Field + + opts := &github.ListProjectsOptions{ + ListProjectsPaginationOptions: pagination, + } + + if ownerType == "org" { + projectFields, resp, err = client.Projects.ListOrganizationProjectFields(ctx, owner, projectNumber, opts) + } else { + projectFields, resp, err = client.Projects.ListUserProjectFields(ctx, owner, projectNumber, opts) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to list project fields", + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + response := map[string]any{ + "fields": projectFields, + "pageInfo": buildPageInfo(resp), + } + + r, err := json.Marshal(response) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func listProjectItems(ctx context.Context, client *github.Client, args map[string]any, owner, ownerType string) (*mcp.CallToolResult, any, error) { + projectNumber, err := RequiredInt(args, "project_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + queryStr, err := OptionalParam[string](args, "query") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + fields, err := OptionalBigIntArrayParam(args, "fields") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + pagination, err := extractPaginationOptionsFromArgs(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + 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, + }, + } + + if ownerType == "org" { + projectItems, resp, err = client.Projects.ListOrganizationProjectItems(ctx, owner, projectNumber, opts) + } else { + projectItems, resp, err = client.Projects.ListUserProjectItems(ctx, owner, projectNumber, opts) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + ProjectListFailedError, + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + response := map[string]any{ + "items": projectItems, + "pageInfo": buildPageInfo(resp), + } + + r, err := json.Marshal(response) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func getProject(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int) (*mcp.CallToolResult, any, error) { + var resp *github.Response + var project *github.ProjectV2 + var err error + + if ownerType == "org" { + project, resp, err = client.Projects.GetOrganizationProject(ctx, owner, projectNumber) + } else { + project, resp, err = client.Projects.GetUserProject(ctx, owner, projectNumber) + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get project", + 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 get project", resp, body), nil, nil + } + + minimalProject := convertToMinimalProject(project) + r, err := json.Marshal(minimalProject) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func getProjectField(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, fieldID int64) (*mcp.CallToolResult, any, error) { + var resp *github.Response + var projectField *github.ProjectV2Field + var err error + + if ownerType == "org" { + projectField, resp, err = client.Projects.GetOrganizationProjectField(ctx, owner, projectNumber, fieldID) + } else { + projectField, resp, err = client.Projects.GetUserProjectField(ctx, owner, projectNumber, fieldID) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get project field", + 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 get project field", resp, body), nil, nil + } + r, err := json.Marshal(projectField) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func getProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64, fields []int64) (*mcp.CallToolResult, any, error) { + var resp *github.Response + var projectItem *github.ProjectV2Item + var opts *github.GetProjectItemOptions + var err error + + if len(fields) > 0 { + opts = &github.GetProjectItemOptions{ + Fields: fields, + } + } + + if ownerType == "org" { + projectItem, resp, err = client.Projects.GetOrganizationProjectItem(ctx, owner, projectNumber, itemID, opts) + } else { + projectItem, resp, err = client.Projects.GetUserProjectItem(ctx, owner, projectNumber, itemID, opts) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get project item", + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(projectItem) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func addProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64, itemType string) (*mcp.CallToolResult, any, error) { + if itemType != "issue" && itemType != "pull_request" { + return utils.NewToolResultError("item_type must be either 'issue' or 'pull_request'"), nil, nil + } + + newItem := &github.AddProjectItemOptions{ + ID: itemID, + Type: toNewProjectType(itemType), + } + + var resp *github.Response + var addedItem *github.ProjectV2Item + var err error + + if ownerType == "org" { + addedItem, resp, err = client.Projects.AddOrganizationProjectItem(ctx, owner, projectNumber, newItem) + } else { + addedItem, resp, err = client.Projects.AddUserProjectItem(ctx, owner, projectNumber, newItem) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + ProjectAddFailedError, + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusCreated { + 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, ProjectAddFailedError, resp, body), nil, nil + } + r, err := json.Marshal(addedItem) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func updateProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64, fieldValue map[string]any) (*mcp.CallToolResult, any, error) { + updatePayload, err := buildUpdateProjectItem(fieldValue) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + var resp *github.Response + var updatedItem *github.ProjectV2Item + + if ownerType == "org" { + updatedItem, resp, err = client.Projects.UpdateOrganizationProjectItem(ctx, owner, projectNumber, itemID, updatePayload) + } else { + updatedItem, resp, err = client.Projects.UpdateUserProjectItem(ctx, owner, projectNumber, itemID, updatePayload) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + ProjectUpdateFailedError, + 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, ProjectUpdateFailedError, resp, body), nil, nil + } + r, err := json.Marshal(updatedItem) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func deleteProjectItem(ctx context.Context, client *github.Client, owner, ownerType string, projectNumber int, itemID int64) (*mcp.CallToolResult, any, error) { + var resp *github.Response + var err error + + if ownerType == "org" { + resp, err = client.Projects.DeleteOrganizationProjectItem(ctx, owner, projectNumber, itemID) + } else { + resp, err = client.Projects.DeleteUserProjectItem(ctx, owner, projectNumber, itemID) + } + + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + ProjectDeleteFailedError, + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusNoContent { + 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, ProjectDeleteFailedError, resp, body), nil, nil + } + return utils.NewToolResultText("project item successfully deleted"), nil, nil } type pageInfo struct { diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index e3a50af29..96bd4d128 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -1529,3 +1529,697 @@ func Test_DeleteProjectItem(t *testing.T) { }) } } + +// Tests for consolidated project tools + +func Test_ProjectsList(t *testing.T) { + // Verify tool definition once + toolDef := ProjectsList(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "projects_list", toolDef.Tool.Name) + assert.NotEmpty(t, toolDef.Tool.Description) + inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, inputSchema.Properties, "method") + assert.Contains(t, inputSchema.Properties, "owner") + assert.Contains(t, inputSchema.Properties, "owner_type") + assert.Contains(t, inputSchema.Properties, "project_number") + assert.Contains(t, inputSchema.Properties, "query") + assert.Contains(t, inputSchema.Properties, "fields") + assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner_type", "owner"}) +} + +func Test_ProjectsList_ListProjects(t *testing.T) { + toolDef := ProjectsList(translations.NullTranslationHelper) + + orgProjects := []map[string]any{{"id": 1, "node_id": "NODE1", "title": "Org Project"}} + userProjects := []map[string]any{{"id": 2, "node_id": "NODE2", "title": "User Project"}} + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + expectedLength int + }{ + { + name: "success organization", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(orgProjects)) + }), + ), + ), + requestArgs: map[string]any{ + "method": "list_projects", + "owner": "octo-org", + "owner_type": "org", + }, + expectError: false, + expectedLength: 1, + }, + { + name: "success user", + mockedClient: mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/users/{username}/projectsV2", Method: http.MethodGet}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(userProjects)) + }), + ), + ), + requestArgs: map[string]any{ + "method": "list_projects", + "owner": "octocat", + "owner_type": "user", + }, + expectError: false, + expectedLength: 1, + }, + { + name: "missing required parameter method", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "owner": "octo-org", + "owner_type": "org", + }, + expectError: true, + expectedErrMsg: "missing required parameter: method", + }, + { + name: "unknown method", + mockedClient: mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "method": "unknown_method", + "owner": "octo-org", + "owner_type": "org", + }, + expectError: true, + expectedErrMsg: "unknown method: unknown_method", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := gh.NewClient(tc.mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.Equal(t, tc.expectError, result.IsError) + + textContent := getTextResult(t, result) + + if tc.expectError { + if tc.expectedErrMsg != "" { + assert.Contains(t, textContent.Text, tc.expectedErrMsg) + } + return + } + + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + projects, ok := response["projects"].([]interface{}) + require.True(t, ok) + assert.Equal(t, tc.expectedLength, len(projects)) + }) + } +} + +func Test_ProjectsList_ListProjectFields(t *testing.T) { + toolDef := ProjectsList(translations.NullTranslationHelper) + + fields := []map[string]any{{"id": 101, "name": "Status", "data_type": "single_select"}} + + t.Run("success organization", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1/fields", Method: http.MethodGet}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(fields)) + }), + ), + ) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "list_project_fields", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + fieldsList, ok := response["fields"].([]interface{}) + require.True(t, ok) + assert.Equal(t, 1, len(fieldsList)) + }) + + t.Run("missing project_number", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient() + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "list_project_fields", + "owner": "octo-org", + "owner_type": "org", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "missing required parameter: project_number") + }) +} + +func Test_ProjectsList_ListProjectItems(t *testing.T) { + toolDef := ProjectsList(translations.NullTranslationHelper) + + items := []map[string]any{{"id": 1001, "archived_at": nil, "content": map[string]any{"title": "Issue 1"}}} + + t.Run("success organization", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1/items", Method: http.MethodGet}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(items)) + }), + ), + ) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "list_project_items", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + itemsList, ok := response["items"].([]interface{}) + require.True(t, ok) + assert.Equal(t, 1, len(itemsList)) + }) +} + +func Test_ProjectsGet(t *testing.T) { + // Verify tool definition once + toolDef := ProjectsGet(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "projects_get", toolDef.Tool.Name) + assert.NotEmpty(t, toolDef.Tool.Description) + inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, inputSchema.Properties, "method") + assert.Contains(t, inputSchema.Properties, "owner") + assert.Contains(t, inputSchema.Properties, "owner_type") + assert.Contains(t, inputSchema.Properties, "project_number") + assert.Contains(t, inputSchema.Properties, "field_id") + assert.Contains(t, inputSchema.Properties, "item_id") + assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner_type", "owner", "project_number"}) +} + +func Test_ProjectsGet_GetProject(t *testing.T) { + toolDef := ProjectsGet(translations.NullTranslationHelper) + + project := map[string]any{"id": 123, "title": "Project Title"} + + t.Run("success organization", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1", Method: http.MethodGet}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(project)) + }), + ), + ) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "get_project", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response["id"]) + }) + + t.Run("unknown method", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient() + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "unknown_method", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "unknown method: unknown_method") + }) +} + +func Test_ProjectsGet_GetProjectField(t *testing.T) { + toolDef := ProjectsGet(translations.NullTranslationHelper) + + field := map[string]any{"id": 101, "name": "Status", "data_type": "single_select"} + + t.Run("success organization", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1/fields/101", Method: http.MethodGet}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(field)) + }), + ), + ) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "get_project_field", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "field_id": float64(101), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response["id"]) + }) + + t.Run("missing field_id", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient() + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "get_project_field", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "missing required parameter: field_id") + }) +} + +func Test_ProjectsGet_GetProjectItem(t *testing.T) { + toolDef := ProjectsGet(translations.NullTranslationHelper) + + item := map[string]any{"id": 1001, "archived_at": nil, "content": map[string]any{"title": "Issue 1"}} + + t.Run("success organization", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1/items/1001", Method: http.MethodGet}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(item)) + }), + ), + ) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "get_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(1001), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response["id"]) + }) + + t.Run("missing item_id", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient() + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "get_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "missing required parameter: item_id") + }) +} + +func Test_ProjectsWrite(t *testing.T) { + // Verify tool definition once + toolDef := ProjectsWrite(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "projects_write", toolDef.Tool.Name) + assert.NotEmpty(t, toolDef.Tool.Description) + inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, inputSchema.Properties, "method") + assert.Contains(t, inputSchema.Properties, "owner") + assert.Contains(t, inputSchema.Properties, "owner_type") + assert.Contains(t, inputSchema.Properties, "project_number") + assert.Contains(t, inputSchema.Properties, "item_id") + assert.Contains(t, inputSchema.Properties, "item_type") + assert.Contains(t, inputSchema.Properties, "updated_field") + assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner_type", "owner", "project_number"}) + + // Verify DestructiveHint is set + assert.NotNil(t, toolDef.Tool.Annotations) + assert.NotNil(t, toolDef.Tool.Annotations.DestructiveHint) + assert.True(t, *toolDef.Tool.Annotations.DestructiveHint) +} + +func Test_ProjectsWrite_AddProjectItem(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + addedItem := map[string]any{"id": 2001, "archived_at": nil} + + t.Run("success organization", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1/items", Method: http.MethodPost}, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var payload map[string]any + _ = json.Unmarshal(body, &payload) + if payload["id"] == nil || payload["type"] == nil { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"message":"bad request"}`)) + return + } + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(mock.MustMarshal(addedItem)) + }), + ), + ) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "add_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(123), + "item_type": "issue", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response["id"]) + }) + + t.Run("missing item_type", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient() + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "add_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(123), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "missing required parameter: item_type") + }) + + t.Run("invalid item_type", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient() + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "add_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(123), + "item_type": "invalid_type", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "item_type must be either 'issue' or 'pull_request'") + }) + + t.Run("unknown method", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient() + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "unknown_method", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "unknown method: unknown_method") + }) +} + +func Test_ProjectsWrite_UpdateProjectItem(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + updatedItem := map[string]any{"id": 1001, "archived_at": nil} + + t.Run("success organization", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1/items/1001", Method: http.MethodPatch}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write(mock.MustMarshal(updatedItem)) + }), + ), + ) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "update_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(1001), + "updated_field": map[string]any{ + "id": float64(101), + "value": "In Progress", + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.NotNil(t, response["id"]) + }) + + t.Run("missing updated_field", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient() + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "update_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(1001), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "missing required parameter: updated_field") + }) +} + +func Test_ProjectsWrite_DeleteProjectItem(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + t.Run("success organization", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient( + mock.WithRequestMatchHandler( + mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1/items/1001", Method: http.MethodDelete}, + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + ), + ) + + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "delete_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_id": float64(1001), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "project item successfully deleted") + }) + + t.Run("missing item_id", func(t *testing.T) { + mockedClient := mock.NewMockedHTTPClient() + client := gh.NewClient(mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "delete_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "missing required parameter: item_id") + }) +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index f6d4afa80..b15c4fc9a 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -279,6 +279,11 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { DeleteProjectItem(t), UpdateProjectItem(t), + // Consolidated project tools (enabled via feature flag) + ProjectsList(t), + ProjectsGet(t), + ProjectsWrite(t), + // Label tools GetLabel(t), GetLabelForLabelsToolset(t), From 3cd0be2af2a9d71ed95ff4dfd4d85081321b5d29 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Wed, 31 Dec 2025 11:59:16 +0000 Subject: [PATCH 42/70] update tool aliases --- pkg/github/deprecated_tool_aliases.go | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/pkg/github/deprecated_tool_aliases.go b/pkg/github/deprecated_tool_aliases.go index 63394770e..4415731fb 100644 --- a/pkg/github/deprecated_tool_aliases.go +++ b/pkg/github/deprecated_tool_aliases.go @@ -28,4 +28,15 @@ var DeprecatedToolAliases = map[string]string{ "rerun_failed_jobs": "actions_run_trigger", "cancel_workflow_run": "actions_run_trigger", "delete_workflow_run_logs": "actions_run_trigger", + + // Projects tools consolidated + "list_projects": "projects_list", + "list_project_fields": "projects_list", + "list_project_items": "projects_list", + "get_project": "projects_get", + "get_project_field": "projects_get", + "get_project_item": "projects_get", + "add_project_item": "projects_write", + "update_project_item": "projects_write", + "delete_project_item": "projects_write", } From 304f07401e6b88591c5e659f6d86d0065f55480d Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Mon, 5 Jan 2026 15:27:57 +0000 Subject: [PATCH 43/70] hold-bac feature flag --- pkg/github/projects.go | 17 ++++++++++++ pkg/inventory/filters.go | 13 ++++++--- pkg/inventory/prompts.go | 3 ++ pkg/inventory/registry_test.go | 50 ++++++++++++++++++++++++++++++++++ pkg/inventory/resources.go | 3 ++ pkg/inventory/server_tool.go | 6 ++++ 6 files changed, 88 insertions(+), 4 deletions(-) diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 9a5d25839..bb53fac45 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -30,6 +30,14 @@ const ( // in favor of the consolidated project tools. const FeatureFlagConsolidatedProjects = "remote_mcp_consolidated_projects" +// FeatureFlagHoldBackLegacyProjects allows users to keep the old individual project tools +// even after FeatureFlagConsolidatedProjects is enabled. This provides a transition period +// for users who need more time to migrate to the consolidated tools. +// +// Deprecated: This flag will be removed in a future release. Users should migrate to +// the consolidated project tools (projects_list, projects_get, projects_write). +const FeatureFlagHoldBackLegacyProjects = "remote_mcp_holdback_legacy_projects" + // Method constants for consolidated project tools const ( projectsMethodListProjects = "list_projects" @@ -160,6 +168,7 @@ func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool { }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -251,6 +260,7 @@ func GetProject(t translations.TranslationHelperFunc) inventory.ServerTool { }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -360,6 +370,7 @@ func ListProjectFields(t translations.TranslationHelperFunc) inventory.ServerToo }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -455,6 +466,7 @@ func GetProjectField(t translations.TranslationHelperFunc) inventory.ServerTool }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -594,6 +606,7 @@ func ListProjectItems(t translations.TranslationHelperFunc) inventory.ServerTool }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -703,6 +716,7 @@ func GetProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -817,6 +831,7 @@ func AddProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -932,6 +947,7 @@ func UpdateProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -1021,6 +1037,7 @@ func DeleteProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects + tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } diff --git a/pkg/inventory/filters.go b/pkg/inventory/filters.go index c5156e61a..a852760ce 100644 --- a/pkg/inventory/filters.go +++ b/pkg/inventory/filters.go @@ -38,13 +38,18 @@ func (r *Inventory) checkFeatureFlag(ctx context.Context, flagName string) bool // isFeatureFlagAllowed checks if an item passes feature flag filtering. // - If FeatureFlagEnable is set, the item is only allowed if the flag is enabled // - If FeatureFlagDisable is set, the item is excluded if the flag is enabled -func (r *Inventory) isFeatureFlagAllowed(ctx context.Context, enableFlag, disableFlag string) bool { +// - If FeatureFlagHoldBack is set and enabled, it overrides FeatureFlagDisable (keeps tool available) +func (r *Inventory) isFeatureFlagAllowed(ctx context.Context, enableFlag, disableFlag, holdBackFlag string) bool { // Check enable flag - item requires this flag to be on if enableFlag != "" && !r.checkFeatureFlag(ctx, enableFlag) { return false } // Check disable flag - item is excluded if this flag is on if disableFlag != "" && r.checkFeatureFlag(ctx, disableFlag) { + // Check if hold-back flag overrides the disable + if holdBackFlag != "" && r.checkFeatureFlag(ctx, holdBackFlag) { + return true // Hold-back keeps tool enabled during transition + } return false } return true @@ -70,7 +75,7 @@ func (r *Inventory) isToolEnabled(ctx context.Context, tool *ServerTool) bool { } } // 2. Check feature flags - if !r.isFeatureFlagAllowed(ctx, tool.FeatureFlagEnable, tool.FeatureFlagDisable) { + if !r.isFeatureFlagAllowed(ctx, tool.FeatureFlagEnable, tool.FeatureFlagDisable, tool.FeatureFlagHoldBack) { return false } // 3. Check read-only filter (applies to all tools) @@ -130,7 +135,7 @@ func (r *Inventory) AvailableResourceTemplates(ctx context.Context) []ServerReso for i := range r.resourceTemplates { res := &r.resourceTemplates[i] // Check feature flags - if !r.isFeatureFlagAllowed(ctx, res.FeatureFlagEnable, res.FeatureFlagDisable) { + if !r.isFeatureFlagAllowed(ctx, res.FeatureFlagEnable, res.FeatureFlagDisable, res.FeatureFlagHoldBack) { continue } if r.isToolsetEnabled(res.Toolset.ID) { @@ -157,7 +162,7 @@ func (r *Inventory) AvailablePrompts(ctx context.Context) []ServerPrompt { for i := range r.prompts { prompt := &r.prompts[i] // Check feature flags - if !r.isFeatureFlagAllowed(ctx, prompt.FeatureFlagEnable, prompt.FeatureFlagDisable) { + if !r.isFeatureFlagAllowed(ctx, prompt.FeatureFlagEnable, prompt.FeatureFlagDisable, prompt.FeatureFlagHoldBack) { continue } if r.isToolsetEnabled(prompt.Toolset.ID) { diff --git a/pkg/inventory/prompts.go b/pkg/inventory/prompts.go index 648f20f9c..2ef57deb8 100644 --- a/pkg/inventory/prompts.go +++ b/pkg/inventory/prompts.go @@ -14,6 +14,9 @@ type ServerPrompt struct { // FeatureFlagDisable specifies a feature flag that, when enabled, causes this prompt // to be omitted. Used to disable prompts when a feature flag is on. FeatureFlagDisable string + // FeatureFlagHoldBack specifies a feature flag that, when enabled, overrides + // FeatureFlagDisable and keeps the prompt available during a transition period. + FeatureFlagHoldBack string } // NewServerPrompt creates a new ServerPrompt with toolset metadata. diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go index 742ad3646..305f0fe86 100644 --- a/pkg/inventory/registry_test.go +++ b/pkg/inventory/registry_test.go @@ -1077,6 +1077,56 @@ func TestFeatureFlagBoth(t *testing.T) { } } +func TestFeatureFlagHoldBack(t *testing.T) { + // Tool with disable flag and hold-back flag (simulates legacy tool during consolidation) + legacyTool := mockToolWithFlags("legacy_tool", "toolset1", true, "", "consolidation_flag") + legacyTool.FeatureFlagHoldBack = "holdback_flag" + + tools := []ServerTool{ + mockTool("always_available", "toolset1", true), + legacyTool, + } + + // Consolidation OFF, hold-back OFF -> legacy tool available (normal operation) + checkerAllOff := func(_ context.Context, _ string) (bool, error) { return false, nil } + regAllOff := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerAllOff).Build() + availableAllOff := regAllOff.AvailableTools(context.Background()) + if len(availableAllOff) != 2 { + t.Fatalf("Expected 2 tools when both flags off, got %d", len(availableAllOff)) + } + + // Consolidation ON, hold-back OFF -> legacy tool excluded (migrated to new tools) + checkerConsolidationOnly := func(_ context.Context, flag string) (bool, error) { + return flag == "consolidation_flag", nil + } + regConsolidationOnly := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerConsolidationOnly).Build() + availableConsolidationOnly := regConsolidationOnly.AvailableTools(context.Background()) + if len(availableConsolidationOnly) != 1 { + t.Fatalf("Expected 1 tool when consolidation on but holdback off, got %d", len(availableConsolidationOnly)) + } + if availableConsolidationOnly[0].Tool.Name != "always_available" { + t.Errorf("Expected always_available, got %s", availableConsolidationOnly[0].Tool.Name) + } + + // Consolidation ON, hold-back ON -> legacy tool available (user opted to hold back) + checkerBothOn := func(_ context.Context, _ string) (bool, error) { return true, nil } + regBothOn := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerBothOn).Build() + availableBothOn := regBothOn.AvailableTools(context.Background()) + if len(availableBothOn) != 2 { + t.Fatalf("Expected 2 tools when both consolidation and holdback on, got %d", len(availableBothOn)) + } + + // Consolidation OFF, hold-back ON -> legacy tool available (hold-back has no effect when consolidation off) + checkerHoldbackOnly := func(_ context.Context, flag string) (bool, error) { + return flag == "holdback_flag", nil + } + regHoldbackOnly := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerHoldbackOnly).Build() + availableHoldbackOnly := regHoldbackOnly.AvailableTools(context.Background()) + if len(availableHoldbackOnly) != 2 { + t.Fatalf("Expected 2 tools when only holdback on, got %d", len(availableHoldbackOnly)) + } +} + func TestFeatureFlagError(t *testing.T) { tools := []ServerTool{ mockToolWithFlags("needs_flag", "toolset1", true, "my_feature", ""), diff --git a/pkg/inventory/resources.go b/pkg/inventory/resources.go index 6de037d58..83904355c 100644 --- a/pkg/inventory/resources.go +++ b/pkg/inventory/resources.go @@ -22,6 +22,9 @@ type ServerResourceTemplate struct { // FeatureFlagDisable specifies a feature flag that, when enabled, causes this resource // to be omitted. Used to disable resources when a feature flag is on. FeatureFlagDisable string + // FeatureFlagHoldBack specifies a feature flag that, when enabled, overrides + // FeatureFlagDisable and keeps the resource available during a transition period. + FeatureFlagHoldBack string } // HasHandler returns true if this resource has a handler function. diff --git a/pkg/inventory/server_tool.go b/pkg/inventory/server_tool.go index 095bedf2b..728b44697 100644 --- a/pkg/inventory/server_tool.go +++ b/pkg/inventory/server_tool.go @@ -64,6 +64,12 @@ type ServerTool struct { // to be omitted. Used to disable tools when a feature flag is on. FeatureFlagDisable string + // FeatureFlagHoldBack specifies a feature flag that, when enabled, overrides + // FeatureFlagDisable and keeps the tool available. This allows users to "hold back" + // on a deprecation by opting to keep the old tools during a transition period. + // Used during tool consolidation to give users time to migrate. + FeatureFlagHoldBack string + // Enabled is an optional function called at build/filter time to determine // if this tool should be available. If nil, the tool is considered enabled // (subject to FeatureFlagEnable/FeatureFlagDisable checks). From 7b30c930f8505e29fdf4f44efdcccdac9b4cd052 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Mon, 5 Jan 2026 15:28:42 +0000 Subject: [PATCH 44/70] update docs --- README.md | 29 ----------------------------- docs/tool-renaming.md | 9 +++++++++ 2 files changed, 9 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index dcf4756d2..1a0f6b1c4 100644 --- a/README.md +++ b/README.md @@ -1000,35 +1000,6 @@ The following sets of tools are available: - `per_page`: Results per page (max 50) (number, optional) - `query`: Filter projects by title text and open/closed state; permitted qualifiers: is:open, is:closed; examples: "roadmap is:open", "is:open feature planning". (string, optional) -- **projects_get** - Get details of GitHub Projects resources - - `field_id`: The field's ID. Required for 'get_project_field' method. (number, optional) - - `fields`: Specific list of field IDs to include in the response when getting a project item (e.g. ["102589", "985201", "169875"]). If not provided, only the title field is included. Only used for 'get_project_item' method. (string[], optional) - - `item_id`: The item's ID. Required for 'get_project_item' method. (number, optional) - - `method`: The method to execute (string, required) - - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - - `owner_type`: Owner type (string, required) - - `project_number`: The project's number. (number, required) - -- **projects_list** - List GitHub Projects resources - - `after`: Forward pagination cursor from previous pageInfo.nextCursor. (string, optional) - - `before`: Backward pagination cursor from previous pageInfo.prevCursor (rare). (string, optional) - - `fields`: Field IDs to include when listing project items (e.g. ["102589", "985201"]). CRITICAL: Always provide to get field values. Without this, only titles returned. Only used for 'list_project_items' method. (string[], optional) - - `method`: The action to perform (string, required) - - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - - `owner_type`: Owner type (string, required) - - `per_page`: Results per page (max 50) (number, optional) - - `project_number`: The project's number. Required for 'list_project_fields' and 'list_project_items' methods. (number, optional) - - `query`: Filter/query string. For list_projects: filter by title text and state (e.g. "roadmap is:open"). For list_project_items: advanced filtering using GitHub's project filtering syntax. (string, optional) - -- **projects_write** - Modify GitHub Project items - - `item_id`: The project item ID. Required for 'update_project_item' and 'delete_project_item' methods. For add_project_item, this is the numeric ID of the issue or pull request to add. (number, optional) - - `item_type`: The item's type, either issue or pull_request. Required for 'add_project_item' method. (string, optional) - - `method`: The method to execute (string, required) - - `owner`: If owner_type == user it is the handle for the GitHub user account. If owner_type == org it is the name of the organization. The name is not case sensitive. (string, required) - - `owner_type`: Owner type (string, required) - - `project_number`: The project's number. (number, required) - - `updated_field`: Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {"id": 123456, "value": "New Value"}. Required for 'update_project_item' method. (object, optional) - - **update_project_item** - Update project item - **Required OAuth Scopes**: `project` - `item_id`: The unique identifier of the project item. This is not the issue or pull request ID. (number, required) diff --git a/docs/tool-renaming.md b/docs/tool-renaming.md index cf342f6dc..050ac9b77 100644 --- a/docs/tool-renaming.md +++ b/docs/tool-renaming.md @@ -46,15 +46,23 @@ Will get `issue_read` and `get_file_contents` tools registered, with no errors. | Old Name | New Name | |----------|----------| +| `add_project_item` | `projects_write` | | `cancel_workflow_run` | `actions_run_trigger` | +| `delete_project_item` | `projects_write` | | `delete_workflow_run_logs` | `actions_run_trigger` | | `download_workflow_run_artifact` | `actions_get` | +| `get_project` | `projects_get` | +| `get_project_field` | `projects_get` | +| `get_project_item` | `projects_get` | | `get_workflow` | `actions_get` | | `get_workflow_job` | `actions_get` | | `get_workflow_job_logs` | `actions_get` | | `get_workflow_run` | `actions_get` | | `get_workflow_run_logs` | `actions_get` | | `get_workflow_run_usage` | `actions_get` | +| `list_project_fields` | `projects_list` | +| `list_project_items` | `projects_list` | +| `list_projects` | `projects_list` | | `list_workflow_jobs` | `actions_list` | | `list_workflow_run_artifacts` | `actions_list` | | `list_workflow_runs` | `actions_list` | @@ -62,4 +70,5 @@ Will get `issue_read` and `get_file_contents` tools registered, with no errors. | `rerun_failed_jobs` | `actions_run_trigger` | | `rerun_workflow_run` | `actions_run_trigger` | | `run_workflow` | `actions_run_trigger` | +| `update_project_item` | `projects_write` | From 099f995615c94c31f4ccd3a42efdb13889d4cde4 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Mon, 5 Jan 2026 16:18:40 +0000 Subject: [PATCH 45/70] revert "hold-bac feature flag" --- pkg/github/projects.go | 17 ------------ pkg/inventory/filters.go | 13 +++------ pkg/inventory/prompts.go | 3 -- pkg/inventory/registry_test.go | 50 ---------------------------------- pkg/inventory/resources.go | 3 -- pkg/inventory/server_tool.go | 6 ---- 6 files changed, 4 insertions(+), 88 deletions(-) diff --git a/pkg/github/projects.go b/pkg/github/projects.go index bb53fac45..9a5d25839 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -30,14 +30,6 @@ const ( // in favor of the consolidated project tools. const FeatureFlagConsolidatedProjects = "remote_mcp_consolidated_projects" -// FeatureFlagHoldBackLegacyProjects allows users to keep the old individual project tools -// even after FeatureFlagConsolidatedProjects is enabled. This provides a transition period -// for users who need more time to migrate to the consolidated tools. -// -// Deprecated: This flag will be removed in a future release. Users should migrate to -// the consolidated project tools (projects_list, projects_get, projects_write). -const FeatureFlagHoldBackLegacyProjects = "remote_mcp_holdback_legacy_projects" - // Method constants for consolidated project tools const ( projectsMethodListProjects = "list_projects" @@ -168,7 +160,6 @@ func ListProjects(t translations.TranslationHelperFunc) inventory.ServerTool { }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects - tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -260,7 +251,6 @@ func GetProject(t translations.TranslationHelperFunc) inventory.ServerTool { }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects - tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -370,7 +360,6 @@ func ListProjectFields(t translations.TranslationHelperFunc) inventory.ServerToo }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects - tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -466,7 +455,6 @@ func GetProjectField(t translations.TranslationHelperFunc) inventory.ServerTool }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects - tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -606,7 +594,6 @@ func ListProjectItems(t translations.TranslationHelperFunc) inventory.ServerTool }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects - tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -716,7 +703,6 @@ func GetProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects - tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -831,7 +817,6 @@ func AddProjectItem(t translations.TranslationHelperFunc) inventory.ServerTool { }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects - tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -947,7 +932,6 @@ func UpdateProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects - tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } @@ -1037,7 +1021,6 @@ func DeleteProjectItem(t translations.TranslationHelperFunc) inventory.ServerToo }, ) tool.FeatureFlagDisable = FeatureFlagConsolidatedProjects - tool.FeatureFlagHoldBack = FeatureFlagHoldBackLegacyProjects return tool } diff --git a/pkg/inventory/filters.go b/pkg/inventory/filters.go index a852760ce..c5156e61a 100644 --- a/pkg/inventory/filters.go +++ b/pkg/inventory/filters.go @@ -38,18 +38,13 @@ func (r *Inventory) checkFeatureFlag(ctx context.Context, flagName string) bool // isFeatureFlagAllowed checks if an item passes feature flag filtering. // - If FeatureFlagEnable is set, the item is only allowed if the flag is enabled // - If FeatureFlagDisable is set, the item is excluded if the flag is enabled -// - If FeatureFlagHoldBack is set and enabled, it overrides FeatureFlagDisable (keeps tool available) -func (r *Inventory) isFeatureFlagAllowed(ctx context.Context, enableFlag, disableFlag, holdBackFlag string) bool { +func (r *Inventory) isFeatureFlagAllowed(ctx context.Context, enableFlag, disableFlag string) bool { // Check enable flag - item requires this flag to be on if enableFlag != "" && !r.checkFeatureFlag(ctx, enableFlag) { return false } // Check disable flag - item is excluded if this flag is on if disableFlag != "" && r.checkFeatureFlag(ctx, disableFlag) { - // Check if hold-back flag overrides the disable - if holdBackFlag != "" && r.checkFeatureFlag(ctx, holdBackFlag) { - return true // Hold-back keeps tool enabled during transition - } return false } return true @@ -75,7 +70,7 @@ func (r *Inventory) isToolEnabled(ctx context.Context, tool *ServerTool) bool { } } // 2. Check feature flags - if !r.isFeatureFlagAllowed(ctx, tool.FeatureFlagEnable, tool.FeatureFlagDisable, tool.FeatureFlagHoldBack) { + if !r.isFeatureFlagAllowed(ctx, tool.FeatureFlagEnable, tool.FeatureFlagDisable) { return false } // 3. Check read-only filter (applies to all tools) @@ -135,7 +130,7 @@ func (r *Inventory) AvailableResourceTemplates(ctx context.Context) []ServerReso for i := range r.resourceTemplates { res := &r.resourceTemplates[i] // Check feature flags - if !r.isFeatureFlagAllowed(ctx, res.FeatureFlagEnable, res.FeatureFlagDisable, res.FeatureFlagHoldBack) { + if !r.isFeatureFlagAllowed(ctx, res.FeatureFlagEnable, res.FeatureFlagDisable) { continue } if r.isToolsetEnabled(res.Toolset.ID) { @@ -162,7 +157,7 @@ func (r *Inventory) AvailablePrompts(ctx context.Context) []ServerPrompt { for i := range r.prompts { prompt := &r.prompts[i] // Check feature flags - if !r.isFeatureFlagAllowed(ctx, prompt.FeatureFlagEnable, prompt.FeatureFlagDisable, prompt.FeatureFlagHoldBack) { + if !r.isFeatureFlagAllowed(ctx, prompt.FeatureFlagEnable, prompt.FeatureFlagDisable) { continue } if r.isToolsetEnabled(prompt.Toolset.ID) { diff --git a/pkg/inventory/prompts.go b/pkg/inventory/prompts.go index 2ef57deb8..648f20f9c 100644 --- a/pkg/inventory/prompts.go +++ b/pkg/inventory/prompts.go @@ -14,9 +14,6 @@ type ServerPrompt struct { // FeatureFlagDisable specifies a feature flag that, when enabled, causes this prompt // to be omitted. Used to disable prompts when a feature flag is on. FeatureFlagDisable string - // FeatureFlagHoldBack specifies a feature flag that, when enabled, overrides - // FeatureFlagDisable and keeps the prompt available during a transition period. - FeatureFlagHoldBack string } // NewServerPrompt creates a new ServerPrompt with toolset metadata. diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go index 305f0fe86..742ad3646 100644 --- a/pkg/inventory/registry_test.go +++ b/pkg/inventory/registry_test.go @@ -1077,56 +1077,6 @@ func TestFeatureFlagBoth(t *testing.T) { } } -func TestFeatureFlagHoldBack(t *testing.T) { - // Tool with disable flag and hold-back flag (simulates legacy tool during consolidation) - legacyTool := mockToolWithFlags("legacy_tool", "toolset1", true, "", "consolidation_flag") - legacyTool.FeatureFlagHoldBack = "holdback_flag" - - tools := []ServerTool{ - mockTool("always_available", "toolset1", true), - legacyTool, - } - - // Consolidation OFF, hold-back OFF -> legacy tool available (normal operation) - checkerAllOff := func(_ context.Context, _ string) (bool, error) { return false, nil } - regAllOff := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerAllOff).Build() - availableAllOff := regAllOff.AvailableTools(context.Background()) - if len(availableAllOff) != 2 { - t.Fatalf("Expected 2 tools when both flags off, got %d", len(availableAllOff)) - } - - // Consolidation ON, hold-back OFF -> legacy tool excluded (migrated to new tools) - checkerConsolidationOnly := func(_ context.Context, flag string) (bool, error) { - return flag == "consolidation_flag", nil - } - regConsolidationOnly := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerConsolidationOnly).Build() - availableConsolidationOnly := regConsolidationOnly.AvailableTools(context.Background()) - if len(availableConsolidationOnly) != 1 { - t.Fatalf("Expected 1 tool when consolidation on but holdback off, got %d", len(availableConsolidationOnly)) - } - if availableConsolidationOnly[0].Tool.Name != "always_available" { - t.Errorf("Expected always_available, got %s", availableConsolidationOnly[0].Tool.Name) - } - - // Consolidation ON, hold-back ON -> legacy tool available (user opted to hold back) - checkerBothOn := func(_ context.Context, _ string) (bool, error) { return true, nil } - regBothOn := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerBothOn).Build() - availableBothOn := regBothOn.AvailableTools(context.Background()) - if len(availableBothOn) != 2 { - t.Fatalf("Expected 2 tools when both consolidation and holdback on, got %d", len(availableBothOn)) - } - - // Consolidation OFF, hold-back ON -> legacy tool available (hold-back has no effect when consolidation off) - checkerHoldbackOnly := func(_ context.Context, flag string) (bool, error) { - return flag == "holdback_flag", nil - } - regHoldbackOnly := NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerHoldbackOnly).Build() - availableHoldbackOnly := regHoldbackOnly.AvailableTools(context.Background()) - if len(availableHoldbackOnly) != 2 { - t.Fatalf("Expected 2 tools when only holdback on, got %d", len(availableHoldbackOnly)) - } -} - func TestFeatureFlagError(t *testing.T) { tools := []ServerTool{ mockToolWithFlags("needs_flag", "toolset1", true, "my_feature", ""), diff --git a/pkg/inventory/resources.go b/pkg/inventory/resources.go index 83904355c..6de037d58 100644 --- a/pkg/inventory/resources.go +++ b/pkg/inventory/resources.go @@ -22,9 +22,6 @@ type ServerResourceTemplate struct { // FeatureFlagDisable specifies a feature flag that, when enabled, causes this resource // to be omitted. Used to disable resources when a feature flag is on. FeatureFlagDisable string - // FeatureFlagHoldBack specifies a feature flag that, when enabled, overrides - // FeatureFlagDisable and keeps the resource available during a transition period. - FeatureFlagHoldBack string } // HasHandler returns true if this resource has a handler function. diff --git a/pkg/inventory/server_tool.go b/pkg/inventory/server_tool.go index 728b44697..095bedf2b 100644 --- a/pkg/inventory/server_tool.go +++ b/pkg/inventory/server_tool.go @@ -64,12 +64,6 @@ type ServerTool struct { // to be omitted. Used to disable tools when a feature flag is on. FeatureFlagDisable string - // FeatureFlagHoldBack specifies a feature flag that, when enabled, overrides - // FeatureFlagDisable and keeps the tool available. This allows users to "hold back" - // on a deprecation by opting to keep the old tools during a transition period. - // Used during tool consolidation to give users time to migrate. - FeatureFlagHoldBack string - // Enabled is an optional function called at build/filter time to determine // if this tool should be available. If nil, the tool is considered enabled // (subject to FeatureFlagEnable/FeatureFlagDisable checks). From be5a449e4898069f47a5eca3b16e4b3fc4813a32 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Mon, 5 Jan 2026 16:23:00 +0000 Subject: [PATCH 46/70] fix project tools to add scope to newtool init --- pkg/github/projects.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 9a5d25839..6e43a3e9b 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -1090,6 +1090,7 @@ Use this tool to list projects for a user or organization, or list project field Required: []string{"method", "owner_type", "owner"}, }, }, + []scopes.Scope{scopes.ReadProject}, 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 { @@ -1184,6 +1185,7 @@ Use this tool to get details about individual projects, project fields, and proj Required: []string{"method", "owner_type", "owner", "project_number"}, }, }, + []scopes.Scope{scopes.ReadProject}, 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 { @@ -1292,6 +1294,7 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"method", "owner_type", "owner", "project_number"}, }, }, + []scopes.Scope{scopes.Project}, 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 { From a57b4726e5994a20cc5395dfa050f2dd8594fb93 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Tue, 6 Jan 2026 13:24:24 +0000 Subject: [PATCH 47/70] add http resp code checking for getProjectItem --- pkg/github/projects.go | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 6e43a3e9b..8af181a72 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -1629,6 +1629,14 @@ func getProjectItem(ctx context.Context, client *github.Client, owner, ownerType } 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 get project item", resp, body), nil, nil + } + r, err := json.Marshal(projectItem) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) From 71862a93e45ddcc178e672ea4dad61226e80c72d Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Tue, 6 Jan 2026 13:27:43 +0000 Subject: [PATCH 48/70] update tests to use new mock pattern --- pkg/github/projects_test.go | 154 +++++++++++------------------------- 1 file changed, 46 insertions(+), 108 deletions(-) diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 96bd4d128..9819e7d7e 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -1565,15 +1565,9 @@ func Test_ProjectsList_ListProjects(t *testing.T) { }{ { name: "success organization", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(orgProjects)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2: mockResponse(t, http.StatusOK, orgProjects), + }), requestArgs: map[string]any{ "method": "list_projects", "owner": "octo-org", @@ -1584,15 +1578,9 @@ func Test_ProjectsList_ListProjects(t *testing.T) { }, { name: "success user", - mockedClient: mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/users/{username}/projectsV2", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(userProjects)) - }), - ), - ), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2ByUsername: mockResponse(t, http.StatusOK, userProjects), + }), requestArgs: map[string]any{ "method": "list_projects", "owner": "octocat", @@ -1603,7 +1591,7 @@ func Test_ProjectsList_ListProjects(t *testing.T) { }, { name: "missing required parameter method", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "owner": "octo-org", "owner_type": "org", @@ -1613,7 +1601,7 @@ func Test_ProjectsList_ListProjects(t *testing.T) { }, { name: "unknown method", - mockedClient: mock.NewMockedHTTPClient(), + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), requestArgs: map[string]any{ "method": "unknown_method", "owner": "octo-org", @@ -1662,15 +1650,9 @@ func Test_ProjectsList_ListProjectFields(t *testing.T) { fields := []map[string]any{{"id": 101, "name": "Status", "data_type": "single_select"}} t.Run("success organization", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1/fields", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(fields)) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2FieldsByProject: mockResponse(t, http.StatusOK, fields), + }) client := gh.NewClient(mockedClient) deps := BaseDeps{ @@ -1698,7 +1680,7 @@ func Test_ProjectsList_ListProjectFields(t *testing.T) { }) t.Run("missing project_number", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient() + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) client := gh.NewClient(mockedClient) deps := BaseDeps{ Client: client, @@ -1724,15 +1706,9 @@ func Test_ProjectsList_ListProjectItems(t *testing.T) { items := []map[string]any{{"id": 1001, "archived_at": nil, "content": map[string]any{"title": "Issue 1"}}} t.Run("success organization", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1/items", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(items)) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProject: mockResponse(t, http.StatusOK, items), + }) client := gh.NewClient(mockedClient) deps := BaseDeps{ @@ -1783,15 +1759,9 @@ func Test_ProjectsGet_GetProject(t *testing.T) { project := map[string]any{"id": 123, "title": "Project Title"} t.Run("success organization", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(project)) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project), + }) client := gh.NewClient(mockedClient) deps := BaseDeps{ @@ -1817,7 +1787,7 @@ func Test_ProjectsGet_GetProject(t *testing.T) { }) t.Run("unknown method", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient() + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) client := gh.NewClient(mockedClient) deps := BaseDeps{ Client: client, @@ -1844,15 +1814,9 @@ func Test_ProjectsGet_GetProjectField(t *testing.T) { field := map[string]any{"id": 101, "name": "Status", "data_type": "single_select"} t.Run("success organization", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1/fields/101", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(field)) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2FieldsByProjectByFieldID: mockResponse(t, http.StatusOK, field), + }) client := gh.NewClient(mockedClient) deps := BaseDeps{ @@ -1879,7 +1843,7 @@ func Test_ProjectsGet_GetProjectField(t *testing.T) { }) t.Run("missing field_id", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient() + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) client := gh.NewClient(mockedClient) deps := BaseDeps{ Client: client, @@ -1906,15 +1870,9 @@ func Test_ProjectsGet_GetProjectItem(t *testing.T) { item := map[string]any{"id": 1001, "archived_at": nil, "content": map[string]any{"title": "Issue 1"}} t.Run("success organization", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1/items/1001", Method: http.MethodGet}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(item)) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, item), + }) client := gh.NewClient(mockedClient) deps := BaseDeps{ @@ -1941,7 +1899,7 @@ func Test_ProjectsGet_GetProjectItem(t *testing.T) { }) t.Run("missing item_id", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient() + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) client := gh.NewClient(mockedClient) deps := BaseDeps{ Client: client, @@ -1991,23 +1949,12 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) { addedItem := map[string]any{"id": 2001, "archived_at": nil} t.Run("success organization", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1/items", Method: http.MethodPost}, - http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - body, _ := io.ReadAll(r.Body) - var payload map[string]any - _ = json.Unmarshal(body, &payload) - if payload["id"] == nil || payload["type"] == nil { - w.WriteHeader(http.StatusBadRequest) - _, _ = w.Write([]byte(`{"message":"bad request"}`)) - return - } - w.WriteHeader(http.StatusCreated) - _, _ = w.Write(mock.MustMarshal(addedItem)) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostOrgsProjectsV2ItemsByProject: expectRequestBody(t, map[string]any{ + "type": "Issue", + "id": float64(123), + }).andThen(mockResponse(t, http.StatusCreated, addedItem)), + }) client := gh.NewClient(mockedClient) deps := BaseDeps{ @@ -2035,7 +1982,7 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) { }) t.Run("missing item_type", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient() + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) client := gh.NewClient(mockedClient) deps := BaseDeps{ Client: client, @@ -2057,7 +2004,7 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) { }) t.Run("invalid item_type", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient() + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) client := gh.NewClient(mockedClient) deps := BaseDeps{ Client: client, @@ -2080,7 +2027,7 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) { }) t.Run("unknown method", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient() + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) client := gh.NewClient(mockedClient) deps := BaseDeps{ Client: client, @@ -2107,15 +2054,9 @@ func Test_ProjectsWrite_UpdateProjectItem(t *testing.T) { updatedItem := map[string]any{"id": 1001, "archived_at": nil} t.Run("success organization", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1/items/1001", Method: http.MethodPatch}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - _, _ = w.Write(mock.MustMarshal(updatedItem)) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, updatedItem), + }) client := gh.NewClient(mockedClient) deps := BaseDeps{ @@ -2146,7 +2087,7 @@ func Test_ProjectsWrite_UpdateProjectItem(t *testing.T) { }) t.Run("missing updated_field", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient() + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) client := gh.NewClient(mockedClient) deps := BaseDeps{ Client: client, @@ -2172,14 +2113,11 @@ func Test_ProjectsWrite_DeleteProjectItem(t *testing.T) { toolDef := ProjectsWrite(translations.NullTranslationHelper) t.Run("success organization", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient( - mock.WithRequestMatchHandler( - mock.EndpointPattern{Pattern: "/orgs/{org}/projectsV2/1/items/1001", Method: http.MethodDelete}, - http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - }), - ), - ) + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + DeleteOrgsProjectsV2ItemsByProjectByItemID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), + }) client := gh.NewClient(mockedClient) deps := BaseDeps{ @@ -2203,7 +2141,7 @@ func Test_ProjectsWrite_DeleteProjectItem(t *testing.T) { }) t.Run("missing item_id", func(t *testing.T) { - mockedClient := mock.NewMockedHTTPClient() + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) client := gh.NewClient(mockedClient) deps := BaseDeps{ Client: client, From ab23070b43a70fd0d4d7d516132e33bc8b79841b Mon Sep 17 00:00:00 2001 From: Florian Grousset Date: Sat, 25 Oct 2025 11:50:20 -0500 Subject: [PATCH 49/70] Update command instructions for terminal usage Clarified instructions to run commands in the terminal instead of Claude Code CLI. --- docs/installation-guides/install-claude.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/installation-guides/install-claude.md b/docs/installation-guides/install-claude.md index 1a5b789f4..ca8491aff 100644 --- a/docs/installation-guides/install-claude.md +++ b/docs/installation-guides/install-claude.md @@ -28,7 +28,7 @@ echo -e ".env\n.mcp.json" >> .gitignore ### Remote Server Setup (Streamable HTTP) -1. Run the following command in the Claude Code CLI +1. Run the following command in the terminal (not in Claude Code CLI): ```bash claude mcp add --transport http github https://api.githubcopilot.com/mcp -H "Authorization: Bearer YOUR_GITHUB_PAT" ``` @@ -43,7 +43,7 @@ claude mcp add --transport http github https://api.githubcopilot.com/mcp -H "Aut ### Local Server Setup (Docker required) ### With Docker -1. Run the following command in the Claude Code CLI: +1. Run the following command in the terminal (not in Claude Code CLI): ```bash claude mcp add github -e GITHUB_PERSONAL_ACCESS_TOKEN=YOUR_GITHUB_PAT -- docker run -i --rm -e GITHUB_PERSONAL_ACCESS_TOKEN ghcr.io/github/github-mcp-server ``` From cc9e8645c1427a2988e96e81dd34e1dc6ec0d31e Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 7 Jan 2026 16:11:03 +0100 Subject: [PATCH 50/70] Fix nil pointer dereference in completion handler The CompleteParams.Context field is optional (marked omitempty) and can be nil when clients don't send it. The code was accessing Context.Arguments directly without checking if Context was nil first, causing a panic. This fix adds a nil check for Context before accessing Arguments. --- pkg/github/repository_resource_completions.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/github/repository_resource_completions.go b/pkg/github/repository_resource_completions.go index aeb2d88a6..c70cfe948 100644 --- a/pkg/github/repository_resource_completions.go +++ b/pkg/github/repository_resource_completions.go @@ -33,8 +33,10 @@ func RepositoryResourceCompletionHandler(getClient GetClientFn) func(ctx context argName := req.Params.Argument.Name argValue := req.Params.Argument.Value - resolved := req.Params.Context.Arguments - if resolved == nil { + var resolved map[string]string + if req.Params.Context != nil && req.Params.Context.Arguments != nil { + resolved = req.Params.Context.Arguments + } else { resolved = map[string]string{} } From f2ff9d22fe392468fdd5454881eaeace683bc4f9 Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Thu, 8 Jan 2026 09:53:48 +0000 Subject: [PATCH 51/70] updated (#1756) --- docs/remote-server.md | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/remote-server.md b/docs/remote-server.md index d7d0f72b1..039d094fe 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -19,24 +19,24 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) | | ---- | ----------- | ------- | ------------------------- | -------------- | ----------------------------------- | -| apps
all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | -| workflow
Actions | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | -| codescan
Code Security | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | -| dependabot
Dependabot | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | -| comment-discussion
Discussions | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) | -| logo-gist
Gists | GitHub Gist related tools | https://api.githubcopilot.com/mcp/x/gists | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/gists/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%2Freadonly%22%7D) | -| git-branch
Git | GitHub Git API related tools for low-level Git operations | https://api.githubcopilot.com/mcp/x/git | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/git/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%2Freadonly%22%7D) | -| issue-opened
Issues | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) | -| tag
Labels | GitHub Labels related tools | https://api.githubcopilot.com/mcp/x/labels | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/labels/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%2Freadonly%22%7D) | -| bell
Notifications | GitHub Notifications related tools | https://api.githubcopilot.com/mcp/x/notifications | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/notifications/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%2Freadonly%22%7D) | -| organization
Organizations | GitHub Organization related tools | https://api.githubcopilot.com/mcp/x/orgs | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/orgs/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%2Freadonly%22%7D) | -| project
Projects | GitHub Projects related tools | https://api.githubcopilot.com/mcp/x/projects | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/projects/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%2Freadonly%22%7D) | -| git-pull-request
Pull Requests | GitHub Pull Request related tools | https://api.githubcopilot.com/mcp/x/pull_requests | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/pull_requests/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%2Freadonly%22%7D) | -| repo
Repositories | GitHub Repository related tools | https://api.githubcopilot.com/mcp/x/repos | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/repos/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%2Freadonly%22%7D) | -| shield-lock
Secret Protection | Secret protection related tools, such as GitHub Secret Scanning | https://api.githubcopilot.com/mcp/x/secret_protection | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/secret_protection/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%2Freadonly%22%7D) | -| shield
Security Advisories | Security advisories related tools | https://api.githubcopilot.com/mcp/x/security_advisories | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/security_advisories/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%2Freadonly%22%7D) | -| star
Stargazers | GitHub Stargazers related tools | https://api.githubcopilot.com/mcp/x/stargazers | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-stargazers&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fstargazers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/stargazers/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-stargazers&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fstargazers%2Freadonly%22%7D) | -| people
Users | GitHub User related tools | https://api.githubcopilot.com/mcp/x/users | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/users/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%2Freadonly%22%7D) | +| apps
`all` | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | +| workflow
`actions` | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | +| codescan
`code_security` | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | +| dependabot
`dependabot` | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | +| comment-discussion
`discussions` | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) | +| logo-gist
`gists` | GitHub Gist related tools | https://api.githubcopilot.com/mcp/x/gists | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/gists/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%2Freadonly%22%7D) | +| git-branch
`git` | GitHub Git API related tools for low-level Git operations | https://api.githubcopilot.com/mcp/x/git | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/git/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%2Freadonly%22%7D) | +| issue-opened
`issues` | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) | +| tag
`labels` | GitHub Labels related tools | https://api.githubcopilot.com/mcp/x/labels | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/labels/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%2Freadonly%22%7D) | +| bell
`notifications` | GitHub Notifications related tools | https://api.githubcopilot.com/mcp/x/notifications | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/notifications/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%2Freadonly%22%7D) | +| organization
`orgs` | GitHub Organization related tools | https://api.githubcopilot.com/mcp/x/orgs | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/orgs/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%2Freadonly%22%7D) | +| project
`projects` | GitHub Projects related tools | https://api.githubcopilot.com/mcp/x/projects | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/projects/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%2Freadonly%22%7D) | +| git-pull-request
`pull_requests` | GitHub Pull Request related tools | https://api.githubcopilot.com/mcp/x/pull_requests | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/pull_requests/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%2Freadonly%22%7D) | +| repo
`repos` | GitHub Repository related tools | https://api.githubcopilot.com/mcp/x/repos | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/repos/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%2Freadonly%22%7D) | +| shield-lock
`secret_protection` | Secret protection related tools, such as GitHub Secret Scanning | https://api.githubcopilot.com/mcp/x/secret_protection | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/secret_protection/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%2Freadonly%22%7D) | +| shield
`security_advisories` | Security advisories related tools | https://api.githubcopilot.com/mcp/x/security_advisories | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/security_advisories/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%2Freadonly%22%7D) | +| star
`stargazers` | GitHub Stargazers related tools | https://api.githubcopilot.com/mcp/x/stargazers | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-stargazers&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fstargazers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/stargazers/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-stargazers&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fstargazers%2Freadonly%22%7D) | +| people
`users` | GitHub User related tools | https://api.githubcopilot.com/mcp/x/users | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/users/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%2Freadonly%22%7D) | ### Additional _Remote_ Server Toolsets @@ -46,9 +46,9 @@ These toolsets are only available in the remote GitHub MCP Server and are not in | Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) | | ---- | ----------- | ------- | ------------------------- | -------------- | ----------------------------------- | -| copilot
Copilot | Copilot related tools | https://api.githubcopilot.com/mcp/x/copilot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%2Freadonly%22%7D) | -| copilot
Copilot Spaces | Copilot Spaces tools | https://api.githubcopilot.com/mcp/x/copilot_spaces | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot_spaces/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%2Freadonly%22%7D) | -| book
Github Support Docs Search | Retrieve documentation to answer GitHub product and support questions. Topics include: GitHub Actions Workflows, Authentication, ... | https://api.githubcopilot.com/mcp/x/github_support_docs_search | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-github_support_docs_search&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/github_support_docs_search/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-github_support_docs_search&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%2Freadonly%22%7D) | +| copilot
`copilot` | Copilot related tools | https://api.githubcopilot.com/mcp/x/copilot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%2Freadonly%22%7D) | +| copilot
`copilot_spaces` | Copilot Spaces tools | https://api.githubcopilot.com/mcp/x/copilot_spaces | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot_spaces/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%2Freadonly%22%7D) | +| book
`github_support_docs_search` | Retrieve documentation to answer GitHub product and support questions. Topics include: GitHub Actions Workflows, Authentication, ... | https://api.githubcopilot.com/mcp/x/github_support_docs_search | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-github_support_docs_search&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/github_support_docs_search/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-github_support_docs_search&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%2Freadonly%22%7D) | ### Optional Headers From ee8f4e6bc594bcdaee496c566cb43995025135a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Jan 2026 17:53:24 +0000 Subject: [PATCH 52/70] Add list-scopes command using inventory architecture Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com> --- script/list-scopes | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100755 script/list-scopes diff --git a/script/list-scopes b/script/list-scopes new file mode 100755 index 000000000..2f7502823 --- /dev/null +++ b/script/list-scopes @@ -0,0 +1,24 @@ +#!/bin/bash +# +# List required OAuth scopes for enabled tools. +# +# Usage: +# script/list-scopes [--toolsets=...] [--output=text|json|summary] +# +# Examples: +# script/list-scopes +# script/list-scopes --toolsets=all --output=json +# script/list-scopes --toolsets=repos,issues --output=summary +# + +set -e + +cd "$(dirname "$0")/.." + +# Build the server if it doesn't exist or is outdated +if [ ! -f github-mcp-server ] || [ cmd/github-mcp-server/list_scopes.go -nt github-mcp-server ]; then + echo "Building github-mcp-server..." >&2 + go build -o github-mcp-server ./cmd/github-mcp-server +fi + +exec ./github-mcp-server list-scopes "$@" From d2df189e980423a0b7afaf8e33c71a4ce345cb4e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Jan 2026 17:55:59 +0000 Subject: [PATCH 53/70] Add list_scopes.go implementation file Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com> --- cmd/github-mcp-server/list_scopes.go | 315 +++++++++++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 cmd/github-mcp-server/list_scopes.go diff --git a/cmd/github-mcp-server/list_scopes.go b/cmd/github-mcp-server/list_scopes.go new file mode 100644 index 000000000..a63bd44f5 --- /dev/null +++ b/cmd/github-mcp-server/list_scopes.go @@ -0,0 +1,315 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "sort" + "strings" + + "github.com/github/github-mcp-server/pkg/github" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// ToolScopeInfo contains scope information for a single tool. +type ToolScopeInfo struct { + Name string `json:"name"` + Toolset string `json:"toolset"` + ReadOnly bool `json:"read_only"` + RequiredScopes []string `json:"required_scopes"` + AcceptedScopes []string `json:"accepted_scopes,omitempty"` +} + +// ScopesOutput is the full output structure for the list-scopes command. +type ScopesOutput struct { + Tools []ToolScopeInfo `json:"tools"` + UniqueScopes []string `json:"unique_scopes"` + ScopesByTool map[string][]string `json:"scopes_by_tool"` + ToolsByScope map[string][]string `json:"tools_by_scope"` + EnabledToolsets []string `json:"enabled_toolsets"` + ReadOnly bool `json:"read_only"` +} + +var listScopesCmd = &cobra.Command{ + Use: "list-scopes", + Short: "List required OAuth scopes for enabled tools", + Long: `List the required OAuth scopes for all enabled tools. + +This command creates an inventory based on the same flags as the stdio command +and outputs the required OAuth scopes for each enabled tool. This is useful for +determining what scopes a token needs to use specific tools. + +The output format can be controlled with the --output flag: + - text (default): Human-readable text output + - json: JSON output for programmatic use + - summary: Just the unique scopes needed + +Examples: + # List scopes for default toolsets + github-mcp-server list-scopes + + # List scopes for specific toolsets + github-mcp-server list-scopes --toolsets=repos,issues,pull_requests + + # List scopes for all toolsets + github-mcp-server list-scopes --toolsets=all + + # Output as JSON + github-mcp-server list-scopes --output=json + + # Just show unique scopes needed + github-mcp-server list-scopes --output=summary`, + RunE: func(_ *cobra.Command, _ []string) error { + return runListScopes() + }, +} + +func init() { + listScopesCmd.Flags().StringP("output", "o", "text", "Output format: text, json, or summary") + _ = viper.BindPFlag("list-scopes-output", listScopesCmd.Flags().Lookup("output")) + + rootCmd.AddCommand(listScopesCmd) +} + +func runListScopes() error { + // Get toolsets configuration (same logic as stdio command) + var enabledToolsets []string + if viper.IsSet("toolsets") { + if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil { + return fmt.Errorf("failed to unmarshal toolsets: %w", err) + } + } + // else: enabledToolsets stays nil, meaning "use defaults" + + // Get specific tools (similar to toolsets) + var enabledTools []string + if viper.IsSet("tools") { + if err := viper.UnmarshalKey("tools", &enabledTools); err != nil { + return fmt.Errorf("failed to unmarshal tools: %w", err) + } + } + + readOnly := viper.GetBool("read-only") + outputFormat := viper.GetString("list-scopes-output") + + // Create translation helper + t, _ := translations.TranslationHelper() + + // Build inventory using the same logic as the stdio server + inventoryBuilder := github.NewInventory(t). + WithReadOnly(readOnly) + + // Configure toolsets (same as stdio) + if enabledToolsets != nil { + inventoryBuilder = inventoryBuilder.WithToolsets(enabledToolsets) + } + + // Configure specific tools + if len(enabledTools) > 0 { + inventoryBuilder = inventoryBuilder.WithTools(enabledTools) + } + + inv := inventoryBuilder.Build() + + // Collect all tools and their scopes + output := collectToolScopes(inv, readOnly) + + // Output based on format + switch outputFormat { + case "json": + return outputJSON(output) + case "summary": + return outputSummary(output) + default: + return outputText(output) + } +} + +func collectToolScopes(inv *inventory.Inventory, readOnly bool) ScopesOutput { + var tools []ToolScopeInfo + scopeSet := make(map[string]bool) + scopesByTool := make(map[string][]string) + toolsByScope := make(map[string][]string) + + // Get all available tools from the inventory + // Use context.Background() for feature flag evaluation + availableTools := inv.AvailableTools(context.Background()) + + for _, serverTool := range availableTools { + tool := serverTool.Tool + + // Get scope information directly from ServerTool + requiredScopes := serverTool.RequiredScopes + acceptedScopes := serverTool.AcceptedScopes + + // Determine if tool is read-only + isReadOnly := serverTool.IsReadOnly() + + toolInfo := ToolScopeInfo{ + Name: tool.Name, + Toolset: string(serverTool.Toolset.ID), + ReadOnly: isReadOnly, + RequiredScopes: requiredScopes, + AcceptedScopes: acceptedScopes, + } + tools = append(tools, toolInfo) + + // Track unique scopes + for _, s := range requiredScopes { + scopeSet[s] = true + toolsByScope[s] = append(toolsByScope[s], tool.Name) + } + + // Track scopes by tool + scopesByTool[tool.Name] = requiredScopes + } + + // Sort tools by name + sort.Slice(tools, func(i, j int) bool { + return tools[i].Name < tools[j].Name + }) + + // Get unique scopes as sorted slice + var uniqueScopes []string + for s := range scopeSet { + uniqueScopes = append(uniqueScopes, s) + } + sort.Strings(uniqueScopes) + + // Sort tools within each scope + for scope := range toolsByScope { + sort.Strings(toolsByScope[scope]) + } + + // Get enabled toolsets as string slice + toolsetIDs := inv.ToolsetIDs() + toolsetIDStrs := make([]string, len(toolsetIDs)) + for i, id := range toolsetIDs { + toolsetIDStrs[i] = string(id) + } + + return ScopesOutput{ + Tools: tools, + UniqueScopes: uniqueScopes, + ScopesByTool: scopesByTool, + ToolsByScope: toolsByScope, + EnabledToolsets: toolsetIDStrs, + ReadOnly: readOnly, + } +} + +func outputJSON(output ScopesOutput) error { + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + return encoder.Encode(output) +} + +func outputSummary(output ScopesOutput) error { + if len(output.UniqueScopes) == 0 { + fmt.Println("No OAuth scopes required for enabled tools.") + return nil + } + + fmt.Println("Required OAuth scopes for enabled tools:") + fmt.Println() + for _, scope := range output.UniqueScopes { + if scope == "" { + fmt.Println(" (no scope required for public read access)") + } else { + fmt.Printf(" %s\n", scope) + } + } + fmt.Printf("\nTotal: %d unique scope(s)\n", len(output.UniqueScopes)) + return nil +} + +func outputText(output ScopesOutput) error { + fmt.Printf("OAuth Scopes for Enabled Tools\n") + fmt.Printf("==============================\n\n") + + fmt.Printf("Enabled Toolsets: %s\n", strings.Join(output.EnabledToolsets, ", ")) + fmt.Printf("Read-Only Mode: %v\n\n", output.ReadOnly) + + // Group tools by toolset + toolsByToolset := make(map[string][]ToolScopeInfo) + for _, tool := range output.Tools { + toolsByToolset[tool.Toolset] = append(toolsByToolset[tool.Toolset], tool) + } + + // Get sorted toolset names + var toolsetNames []string + for name := range toolsByToolset { + toolsetNames = append(toolsetNames, name) + } + sort.Strings(toolsetNames) + + for _, toolsetName := range toolsetNames { + tools := toolsByToolset[toolsetName] + fmt.Printf("## %s\n\n", formatToolsetNameForOutput(toolsetName)) + + for _, tool := range tools { + rwIndicator := "📝" + if tool.ReadOnly { + rwIndicator = "👁" + } + + scopeStr := "(no scope required)" + if len(tool.RequiredScopes) > 0 { + scopeStr = strings.Join(tool.RequiredScopes, ", ") + } + + fmt.Printf(" %s %s: %s\n", rwIndicator, tool.Name, scopeStr) + } + fmt.Println() + } + + // Summary + fmt.Println("## Summary") + fmt.Println() + if len(output.UniqueScopes) == 0 { + fmt.Println("No OAuth scopes required for enabled tools.") + } else { + fmt.Println("Unique scopes required:") + for _, scope := range output.UniqueScopes { + if scope == "" { + fmt.Println(" • (no scope - public read access)") + } else { + fmt.Printf(" • %s\n", scope) + } + } + } + fmt.Printf("\nTotal: %d tools, %d unique scopes\n", len(output.Tools), len(output.UniqueScopes)) + + // Legend + fmt.Println("\nLegend: 👁 = read-only, 📝 = read-write") + + return nil +} + +func formatToolsetNameForOutput(name string) string { + switch name { + case "pull_requests": + return "Pull Requests" + case "repos": + return "Repositories" + case "code_security": + return "Code Security" + case "secret_protection": + return "Secret Protection" + case "orgs": + return "Organizations" + default: + // Capitalize first letter and replace underscores with spaces + parts := strings.Split(name, "_") + for i, part := range parts { + if len(part) > 0 { + parts[i] = strings.ToUpper(string(part[0])) + part[1:] + } + } + return strings.Join(parts, " ") + } +} From cd75b9b96b928641cc3a6bf26433569994d52fa9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Jan 2026 18:00:00 +0000 Subject: [PATCH 54/70] Refactor formatToolsetName to shared helper function Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com> --- cmd/github-mcp-server/generate_docs.go | 24 ------------------------ cmd/github-mcp-server/list_scopes.go | 26 +------------------------- 2 files changed, 1 insertion(+), 49 deletions(-) diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index 85d7ecdbf..14d771330 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -198,30 +198,6 @@ func generateToolsDoc(r *inventory.Inventory) string { return buf.String() } -func formatToolsetName(name string) string { - switch name { - case "pull_requests": - return "Pull Requests" - case "repos": - return "Repositories" - case "code_security": - return "Code Security" - case "secret_protection": - return "Secret Protection" - case "orgs": - return "Organizations" - default: - // Fallback: capitalize first letter and replace underscores with spaces - parts := strings.Split(name, "_") - for i, part := range parts { - if len(part) > 0 { - parts[i] = strings.ToUpper(string(part[0])) + part[1:] - } - } - return strings.Join(parts, " ") - } -} - func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) { // Tool name (no icon - section header already has the toolset icon) fmt.Fprintf(buf, "- **%s** - %s\n", tool.Tool.Name, tool.Tool.Annotations.Title) diff --git a/cmd/github-mcp-server/list_scopes.go b/cmd/github-mcp-server/list_scopes.go index a63bd44f5..9f7dcabce 100644 --- a/cmd/github-mcp-server/list_scopes.go +++ b/cmd/github-mcp-server/list_scopes.go @@ -249,7 +249,7 @@ func outputText(output ScopesOutput) error { for _, toolsetName := range toolsetNames { tools := toolsByToolset[toolsetName] - fmt.Printf("## %s\n\n", formatToolsetNameForOutput(toolsetName)) + fmt.Printf("## %s\n\n", formatToolsetName(toolsetName)) for _, tool := range tools { rwIndicator := "📝" @@ -289,27 +289,3 @@ func outputText(output ScopesOutput) error { return nil } - -func formatToolsetNameForOutput(name string) string { - switch name { - case "pull_requests": - return "Pull Requests" - case "repos": - return "Repositories" - case "code_security": - return "Code Security" - case "secret_protection": - return "Secret Protection" - case "orgs": - return "Organizations" - default: - // Capitalize first letter and replace underscores with spaces - parts := strings.Split(name, "_") - for i, part := range parts { - if len(part) > 0 { - parts[i] = strings.ToUpper(string(part[0])) + part[1:] - } - } - return strings.Join(parts, " ") - } -} From 33014a66ef908cf1cad3b132bd61bc17ca149046 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Jan 2026 18:00:47 +0000 Subject: [PATCH 55/70] Add helpers.go with shared formatToolsetName function Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com> --- cmd/github-mcp-server/helpers.go | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) create mode 100644 cmd/github-mcp-server/helpers.go diff --git a/cmd/github-mcp-server/helpers.go b/cmd/github-mcp-server/helpers.go new file mode 100644 index 000000000..c5f498813 --- /dev/null +++ b/cmd/github-mcp-server/helpers.go @@ -0,0 +1,29 @@ +package main + +import "strings" + +// formatToolsetName converts a toolset ID to a human-readable name. +// Used by both generate_docs.go and list_scopes.go for consistent formatting. +func formatToolsetName(name string) string { + switch name { + case "pull_requests": + return "Pull Requests" + case "repos": + return "Repositories" + case "code_security": + return "Code Security" + case "secret_protection": + return "Secret Protection" + case "orgs": + return "Organizations" + default: + // Fallback: capitalize first letter and replace underscores with spaces + parts := strings.Split(name, "_") + for i, part := range parts { + if len(part) > 0 { + parts[i] = strings.ToUpper(string(part[0])) + part[1:] + } + } + return strings.Join(parts, " ") + } +} From ff0e67e00899df9a185fbbaf423c9d15f2093ffa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 6 Jan 2026 18:03:29 +0000 Subject: [PATCH 56/70] Add formatScopeDisplay helper and improve empty scope handling Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com> --- cmd/github-mcp-server/list_scopes.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/cmd/github-mcp-server/list_scopes.go b/cmd/github-mcp-server/list_scopes.go index 9f7dcabce..2d1817500 100644 --- a/cmd/github-mcp-server/list_scopes.go +++ b/cmd/github-mcp-server/list_scopes.go @@ -75,6 +75,14 @@ func init() { rootCmd.AddCommand(listScopesCmd) } +// formatScopeDisplay formats a scope string for display, handling empty scopes. +func formatScopeDisplay(scope string) string { + if scope == "" { + return "(no scope required for public read access)" + } + return scope +} + func runListScopes() error { // Get toolsets configuration (same logic as stdio command) var enabledToolsets []string @@ -217,11 +225,7 @@ func outputSummary(output ScopesOutput) error { fmt.Println("Required OAuth scopes for enabled tools:") fmt.Println() for _, scope := range output.UniqueScopes { - if scope == "" { - fmt.Println(" (no scope required for public read access)") - } else { - fmt.Printf(" %s\n", scope) - } + fmt.Printf(" %s\n", formatScopeDisplay(scope)) } fmt.Printf("\nTotal: %d unique scope(s)\n", len(output.UniqueScopes)) return nil @@ -275,11 +279,7 @@ func outputText(output ScopesOutput) error { } else { fmt.Println("Unique scopes required:") for _, scope := range output.UniqueScopes { - if scope == "" { - fmt.Println(" • (no scope - public read access)") - } else { - fmt.Printf(" • %s\n", scope) - } + fmt.Printf(" • %s\n", formatScopeDisplay(scope)) } } fmt.Printf("\nTotal: %d tools, %d unique scopes\n", len(output.Tools), len(output.UniqueScopes)) From e33550311a5ddd66f1a72b14111554457ddd59a4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 14:29:49 +0000 Subject: [PATCH 57/70] Update Claude Code installation command to use add-json format for v2.1.1+ Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com> --- docs/installation-guides/install-claude.md | 30 +++++++++++++++++----- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/docs/installation-guides/install-claude.md b/docs/installation-guides/install-claude.md index ca8491aff..8f01a59e4 100644 --- a/docs/installation-guides/install-claude.md +++ b/docs/installation-guides/install-claude.md @@ -28,15 +28,23 @@ echo -e ".env\n.mcp.json" >> .gitignore ### Remote Server Setup (Streamable HTTP) +> **Note**: For Claude Code versions **2.1.1 and newer**, use the `add-json` command format below. For older versions, see the [legacy command format](#for-older-versions-of-claude-code). + 1. Run the following command in the terminal (not in Claude Code CLI): ```bash -claude mcp add --transport http github https://api.githubcopilot.com/mcp -H "Authorization: Bearer YOUR_GITHUB_PAT" +claude mcp add-json github '{"type":"http","url":"https://api.githubcopilot.com/mcp","headers":{"Authorization":"Bearer YOUR_GITHUB_PAT"}}' --scope user ``` With an environment variable: ```bash -claude mcp add --transport http github https://api.githubcopilot.com/mcp -H "Authorization: Bearer $(grep GITHUB_PAT .env | cut -d '=' -f2)" +claude mcp add-json github '{"type":"http","url":"https://api.githubcopilot.com/mcp","headers":{"Authorization":"Bearer '"$(grep GITHUB_PAT .env | cut -d '=' -f2)"'"}}' --scope user ``` + +> **About the `--scope` flag**: This specifies where the configuration is stored. Options: +> - `local` (default): Available only to you in the current project (was called `project` in older versions) +> - `project`: Shared with everyone in the project via `.mcp.json` file +> - `user`: Available to you across all projects (was called `global` in older versions) + 2. Restart Claude Code 3. Run `claude mcp list` to see if the GitHub server is configured @@ -72,6 +80,19 @@ claude mcp list claude mcp get github ``` +### For Older Versions of Claude Code + +If you're using Claude Code version **2.1.0 or earlier**, use this legacy command format: + +```bash +claude mcp add --transport http github https://api.githubcopilot.com/mcp -H "Authorization: Bearer YOUR_GITHUB_PAT" +``` + +With an environment variable: +```bash +claude mcp add --transport http github https://api.githubcopilot.com/mcp -H "Authorization: Bearer $(grep GITHUB_PAT .env | cut -d '=' -f2)" +``` + --- ## Claude Desktop @@ -161,7 +182,4 @@ Add this codeblock to your `claude_desktop_config.json`: - The npm package `@modelcontextprotocol/server-github` is deprecated as of April 2025 - Remote server requires Streamable HTTP support (check your Claude version) -- Configuration scopes for Claude Code: - - `-s user`: Available across all projects - - `-s project`: Shared via `.mcp.json` file - - Default: `local` (current project only) +- For Claude Code configuration scopes, see the `--scope` flag documentation in the [Remote Server Setup](#remote-server-setup-streamable-http) section From c061804405bd4ac96ea3c81d110195a2194e415a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 8 Jan 2026 21:28:57 +0000 Subject: [PATCH 58/70] Use default scope in examples and clarify --scope flag is optional Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com> --- docs/installation-guides/install-claude.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/docs/installation-guides/install-claude.md b/docs/installation-guides/install-claude.md index 8f01a59e4..ff1b26d70 100644 --- a/docs/installation-guides/install-claude.md +++ b/docs/installation-guides/install-claude.md @@ -32,18 +32,20 @@ echo -e ".env\n.mcp.json" >> .gitignore 1. Run the following command in the terminal (not in Claude Code CLI): ```bash -claude mcp add-json github '{"type":"http","url":"https://api.githubcopilot.com/mcp","headers":{"Authorization":"Bearer YOUR_GITHUB_PAT"}}' --scope user +claude mcp add-json github '{"type":"http","url":"https://api.githubcopilot.com/mcp","headers":{"Authorization":"Bearer YOUR_GITHUB_PAT"}}' ``` With an environment variable: ```bash -claude mcp add-json github '{"type":"http","url":"https://api.githubcopilot.com/mcp","headers":{"Authorization":"Bearer '"$(grep GITHUB_PAT .env | cut -d '=' -f2)"'"}}' --scope user +claude mcp add-json github '{"type":"http","url":"https://api.githubcopilot.com/mcp","headers":{"Authorization":"Bearer '"$(grep GITHUB_PAT .env | cut -d '=' -f2)"'"}}' ``` -> **About the `--scope` flag**: This specifies where the configuration is stored. Options: +> **About the `--scope` flag** (optional): Use this to specify where the configuration is stored: > - `local` (default): Available only to you in the current project (was called `project` in older versions) > - `project`: Shared with everyone in the project via `.mcp.json` file > - `user`: Available to you across all projects (was called `global` in older versions) +> +> Example: Add `--scope user` to the end of the command to make it available across all projects. 2. Restart Claude Code 3. Run `claude mcp list` to see if the GitHub server is configured From b1ab893af3dbbd20fa0ee18d029eef644a3e671e Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Fri, 9 Jan 2026 12:10:42 +0000 Subject: [PATCH 59/70] bug fix (#1775) --- pkg/inventory/builder.go | 9 ++++-- pkg/inventory/registry_test.go | 54 ++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/pkg/inventory/builder.go b/pkg/inventory/builder.go index a0ed2baee..0400c2a24 100644 --- a/pkg/inventory/builder.go +++ b/pkg/inventory/builder.go @@ -149,11 +149,14 @@ func (b *Builder) Build() *Inventory { if len(b.additionalTools) > 0 { r.additionalTools = make(map[string]bool, len(b.additionalTools)) for _, name := range b.additionalTools { - // Resolve deprecated aliases to canonical names + // Always include the original name - this handles the case where + // the tool exists but is controlled by a feature flag that's OFF. + r.additionalTools[name] = true + // Also include the canonical name if this is a deprecated alias. + // This handles the case where the feature flag is ON and only + // the new consolidated tool is available. if canonical, isAlias := b.deprecatedAliases[name]; isAlias { r.additionalTools[canonical] = true - } else { - r.additionalTools[name] = true } } } diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go index 742ad3646..2c3262873 100644 --- a/pkg/inventory/registry_test.go +++ b/pkg/inventory/registry_test.go @@ -1688,3 +1688,57 @@ func TestForMCPRequest_ToolsCall_FeatureFlaggedVariants(t *testing.T) { availableOn[0].FeatureFlagEnable, availableOn[0].FeatureFlagDisable) } } + +// TestWithTools_DeprecatedAliasAndFeatureFlag tests that deprecated aliases work correctly +// when the old tool is controlled by a feature flag. This covers the scenario where: +// - Old tool "old_tool" has FeatureFlagDisable="my_flag" (available when flag is OFF) +// - New tool "new_tool" has FeatureFlagEnable="my_flag" (available when flag is ON) +// - Deprecated alias maps "old_tool" -> "new_tool" +// - User specifies --tools=old_tool +// Expected behavior: +// - Flag OFF: old_tool should be available (not the new_tool via alias) +// - Flag ON: new_tool should be available (via alias resolution) +func TestWithTools_DeprecatedAliasAndFeatureFlag(t *testing.T) { + oldTool := mockToolWithFlags("old_tool", "actions", true, "", "my_flag") + newTool := mockToolWithFlags("new_tool", "actions", true, "my_flag", "") + tools := []ServerTool{oldTool, newTool} + + deprecatedAliases := map[string]string{ + "old_tool": "new_tool", + } + + // Test 1: Flag OFF - old_tool should be available via direct name match + // (not via alias resolution to new_tool, since old_tool still exists) + regFlagOff := NewBuilder(). + SetTools(tools). + WithDeprecatedAliases(deprecatedAliases). + WithToolsets([]string{}). // No toolsets enabled + WithTools([]string{"old_tool"}). // Explicitly request old tool + Build() + availableOff := regFlagOff.AvailableTools(context.Background()) + if len(availableOff) != 1 { + t.Fatalf("Flag OFF: Expected 1 tool, got %d", len(availableOff)) + } + if availableOff[0].Tool.Name != "old_tool" { + t.Errorf("Flag OFF: Expected old_tool, got %s", availableOff[0].Tool.Name) + } + + // Test 2: Flag ON - new_tool should be available via alias resolution + checker := func(_ context.Context, flag string) (bool, error) { + return flag == "my_flag", nil + } + regFlagOn := NewBuilder(). + SetTools(tools). + WithDeprecatedAliases(deprecatedAliases). + WithToolsets([]string{}). // No toolsets enabled + WithTools([]string{"old_tool"}). // Request old tool name + WithFeatureChecker(checker). + Build() + availableOn := regFlagOn.AvailableTools(context.Background()) + if len(availableOn) != 1 { + t.Fatalf("Flag ON: Expected 1 tool, got %d", len(availableOn)) + } + if availableOn[0].Tool.Name != "new_tool" { + t.Errorf("Flag ON: Expected new_tool (via alias), got %s", availableOn[0].Tool.Name) + } +} From 44d9e1329e416082619a78a3ff8c4ef29dc3d09b Mon Sep 17 00:00:00 2001 From: Ksenia Bobrova Date: Fri, 9 Jan 2026 13:41:11 +0100 Subject: [PATCH 60/70] Bringing back local mcp server registry config (#1767) * Bringing back local mcp server registry config * Making auth header optional --- server.json | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/server.json b/server.json index 83b4e06be..15fdf47bd 100644 --- a/server.json +++ b/server.json @@ -8,6 +8,31 @@ "source": "github" }, "version": "${VERSION}", + "packages": [ + { + "registryType": "oci", + "identifier": "ghcr.io/github/github-mcp-server:${VERSION}", + "transport": { + "type": "stdio" + }, + "runtimeArguments": [ + { + "type": "named", + "name": "-e", + "description": "Set an environment variable in the runtime", + "value": "GITHUB_PERSONAL_ACCESS_TOKEN={token}", + "isRequired": true, + "variables": { + "token": { + "isRequired": true, + "isSecret": true, + "format": "string" + } + } + } + ] + } + ], "remotes": [ { "type": "streamable-http", @@ -15,8 +40,7 @@ "headers": [ { "name": "Authorization", - "description": "Authentication token (PAT or App token)", - "isRequired": true, + "description": "Authorization header with authentication token (PAT or App token)", "isSecret": true } ] From 8058d3070974a490e7e6bdf707da4d6b418dfff1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 13:54:48 +0000 Subject: [PATCH 61/70] Update automation to use toolset IDs instead of display names The generate-docs command now outputs toolset IDs (e.g., `actions`, `code_security`) wrapped in backticks instead of display names (e.g., "Actions", "Code Security"). This ensures the manual changes from PR #1756 persist when the docs are regenerated, fixing the issue where users need to configure the actual toolset ID, not the display name. Changes: - Modified generateRemoteToolsetsDoc() to use `idStr` instead of `formattedName()` - Modified generateRemoteOnlyToolsetsDoc() to use `idStr` instead of `formattedName()` - Both functions now wrap the toolset ID in backticks for clarity Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com> --- cmd/github-mcp-server/generate_docs.go | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index 14d771330..a458c04b6 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -349,14 +349,13 @@ func generateRemoteToolsetsDoc() string { // Add "all" toolset first (special case) allIcon := octiconImg("apps", "../") - fmt.Fprintf(&buf, "| %s
all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2F%%22%%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2Freadonly%%22%%7D) |\n", allIcon) + fmt.Fprintf(&buf, "| %s
`all` | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2F%%22%%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2Freadonly%%22%%7D) |\n", allIcon) // AvailableToolsets() returns toolsets that have tools, sorted by ID // Exclude context (handled separately) and dynamic (internal only) for _, ts := range r.AvailableToolsets("context", "dynamic") { idStr := string(ts.ID) - formattedName := formatToolsetName(idStr) apiURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s", idStr) readonlyURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s/readonly", idStr) @@ -372,9 +371,9 @@ func generateRemoteToolsetsDoc() string { readonlyInstallLink := fmt.Sprintf("[Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", idStr, readonlyConfig) icon := octiconImg(ts.Icon, "../") - fmt.Fprintf(&buf, "| %s
%s | %s | %s | %s | [read-only](%s) | %s |\n", + fmt.Fprintf(&buf, "| %s
`%s` | %s | %s | %s | [read-only](%s) | %s |\n", icon, - formattedName, + idStr, ts.Description, apiURL, installLink, @@ -397,7 +396,6 @@ func generateRemoteOnlyToolsetsDoc() string { for _, ts := range github.RemoteOnlyToolsets() { idStr := string(ts.ID) - formattedName := formatToolsetName(idStr) apiURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s", idStr) readonlyURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s/readonly", idStr) @@ -413,9 +411,9 @@ func generateRemoteOnlyToolsetsDoc() string { readonlyInstallLink := fmt.Sprintf("[Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", idStr, readonlyConfig) icon := octiconImg(ts.Icon, "../") - fmt.Fprintf(&buf, "| %s
%s | %s | %s | %s | [read-only](%s) | %s |\n", + fmt.Fprintf(&buf, "| %s
`%s` | %s | %s | %s | [read-only](%s) | %s |\n", icon, - formattedName, + idStr, ts.Description, apiURL, installLink, From 676956faf30ffd8f71275be6d2a34aed632624ba Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Fri, 9 Jan 2026 18:26:59 +0100 Subject: [PATCH 62/70] Fix resource handler to use deps from context The RepositoryResourceContentsHandler was using closure-captured deps instead of retrieving them from context at call time. This causes issues on the remote server which injects per-request deps via context. Changed to use MustDepsFromContext(ctx) pattern consistent with tool handlers in NewTool and NewToolFromHandler. --- pkg/github/repository_resource.go | 9 ++--- pkg/github/repository_resource_test.go | 47 +++++++++++++------------- 2 files changed, 29 insertions(+), 27 deletions(-) diff --git a/pkg/github/repository_resource.go b/pkg/github/repository_resource.go index ee43e9d04..28ce63b46 100644 --- a/pkg/github/repository_resource.go +++ b/pkg/github/repository_resource.go @@ -102,15 +102,16 @@ func GetRepositoryResourcePrContent(t translations.TranslationHelperFunc) invent // repositoryResourceContentsHandlerFunc returns a ResourceHandlerFunc that creates handlers on-demand. func repositoryResourceContentsHandlerFunc(resourceURITemplate *uritemplate.Template) inventory.ResourceHandlerFunc { - return func(deps any) mcp.ResourceHandler { - d := deps.(ToolDependencies) - return RepositoryResourceContentsHandler(d, resourceURITemplate) + return func(_ any) mcp.ResourceHandler { + return RepositoryResourceContentsHandler(resourceURITemplate) } } // RepositoryResourceContentsHandler returns a handler function for repository content requests. -func RepositoryResourceContentsHandler(deps ToolDependencies, resourceURITemplate *uritemplate.Template) mcp.ResourceHandler { +// It retrieves ToolDependencies from the context at call time via MustDepsFromContext. +func RepositoryResourceContentsHandler(resourceURITemplate *uritemplate.Template) mcp.ResourceHandler { return func(ctx context.Context, request *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + deps := MustDepsFromContext(ctx) // Match the URI to extract parameters uriValues := resourceURITemplate.Match(request.Params.URI) if uriValues == nil { diff --git a/pkg/github/repository_resource_test.go b/pkg/github/repository_resource_test.go index b55b821af..a3b3ca754 100644 --- a/pkg/github/repository_resource_test.go +++ b/pkg/github/repository_resource_test.go @@ -26,7 +26,7 @@ func Test_repositoryResourceContents(t *testing.T) { name string mockedClient *http.Client uri string - handlerFn func(deps ToolDependencies) mcp.ResourceHandler + handlerFn func() mcp.ResourceHandler expectedResponseType resourceResponseType expectError string expectedResult *mcp.ReadResourceResult @@ -41,8 +41,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo:///repo/contents/README.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceContentURITemplate) }, expectedResponseType: resourceResponseTypeText, // Ignored as error is expected expectError: "owner is required", @@ -57,8 +57,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner//refs/heads/main/contents/README.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceBranchContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceBranchContentURITemplate) }, expectedResponseType: resourceResponseTypeText, // Ignored as error is expected expectError: "repo is required", @@ -73,8 +73,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/contents/data.png", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceContentURITemplate) }, expectedResponseType: resourceResponseTypeBlob, expectedResult: &mcp.ReadResourceResult{ @@ -94,8 +94,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/contents/README.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -117,8 +117,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/contents/pkg/github/actions.go", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -138,8 +138,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/refs/heads/main/contents/README.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceBranchContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceBranchContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -159,8 +159,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/refs/tags/v1.0.0/contents/README.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceTagContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceTagContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -180,8 +180,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/sha/abc123/contents/README.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceCommitContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceCommitContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -206,8 +206,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/refs/pull/42/head/contents/README.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourcePrContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourcePrContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -226,8 +226,8 @@ func Test_repositoryResourceContents(t *testing.T) { }), }), uri: "repo://owner/repo/contents/nonexistent.md", - handlerFn: func(deps ToolDependencies) mcp.ResourceHandler { - return RepositoryResourceContentsHandler(deps, repositoryResourceContentURITemplate) + handlerFn: func() mcp.ResourceHandler { + return RepositoryResourceContentsHandler(repositoryResourceContentURITemplate) }, expectedResponseType: resourceResponseTypeText, // Ignored as error is expected expectError: "404 Not Found", @@ -242,7 +242,8 @@ func Test_repositoryResourceContents(t *testing.T) { Client: client, RawClient: mockRawClient, } - handler := tc.handlerFn(deps) + ctx := ContextWithDeps(context.Background(), deps) + handler := tc.handlerFn() request := &mcp.ReadResourceRequest{ Params: &mcp.ReadResourceParams{ @@ -250,7 +251,7 @@ func Test_repositoryResourceContents(t *testing.T) { }, } - resp, err := handler(context.TODO(), request) + resp, err := handler(ctx, request) if tc.expectError != "" { require.ErrorContains(t, err, tc.expectError) From 53a672040f05205a52e2a3e3c3536d1ec8b2fa13 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 12 Jan 2026 11:46:59 +0100 Subject: [PATCH 63/70] fix: keep all resources registered for resources/read requests The ForMCPRequest optimization was incorrectly filtering resources by doing an exact string match between the URI template pattern and the concrete URI. This would never match because templates like 'repo://{owner}/{repo}/contents{/path*}' don't match concrete URIs like 'repo://owner/repo/contents/file.py'. Instead of implementing template matching in the inventory, we simply keep all resources registered for resources/read requests and let the SDK handle URI template matching internally (which it already does correctly via uritemplate.Regexp().MatchString()). This fixes resources/read returning 'Resource not found' for valid URIs. --- pkg/inventory/registry.go | 6 ++---- pkg/inventory/registry_test.go | 12 +++++------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/pkg/inventory/registry.go b/pkg/inventory/registry.go index f3691e38a..885617b43 100644 --- a/pkg/inventory/registry.go +++ b/pkg/inventory/registry.go @@ -91,7 +91,7 @@ const ( // - MCPMethodToolsList: All available tools (no resources/prompts) // - MCPMethodToolsCall: Only the named tool // - MCPMethodResourcesList, MCPMethodResourcesTemplatesList: All available resources (no tools/prompts) -// - MCPMethodResourcesRead: Only the named resource template +// - MCPMethodResourcesRead: All resources (SDK handles URI template matching) // - MCPMethodPromptsList: All available prompts (no tools/resources) // - MCPMethodPromptsGet: Only the named prompt // - Unknown methods: Empty (no items registered) @@ -134,10 +134,8 @@ func (r *Inventory) ForMCPRequest(method string, itemName string) *Inventory { case MCPMethodResourcesList, MCPMethodResourcesTemplatesList: result.tools, result.prompts = nil, nil case MCPMethodResourcesRead: + // Keep all resources registered - SDK handles URI template matching internally result.tools, result.prompts = nil, nil - if itemName != "" { - result.resourceTemplates = r.filterResourcesByURI(itemName) - } case MCPMethodPromptsList: result.tools, result.resourceTemplates = nil, nil case MCPMethodPromptsGet: diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go index 2c3262873..136f8e523 100644 --- a/pkg/inventory/registry_test.go +++ b/pkg/inventory/registry_test.go @@ -775,17 +775,15 @@ func TestForMCPRequest_ResourcesRead(t *testing.T) { } reg := NewBuilder().SetResources(resources).WithToolsets([]string{"all"}).Build() - filtered := reg.ForMCPRequest(MCPMethodResourcesRead, "repo://{owner}/{repo}") + // Pass a concrete URI - all resources remain registered, SDK handles matching + filtered := reg.ForMCPRequest(MCPMethodResourcesRead, "repo://owner/repo") + // All resources should be available - SDK handles URI template matching internally available := filtered.AvailableResourceTemplates(context.Background()) - if len(available) != 1 { - t.Fatalf("Expected 1 resource for resources/read, got %d", len(available)) - } - if available[0].Template.URITemplate != "repo://{owner}/{repo}" { - t.Errorf("Expected URI template 'repo://{owner}/{repo}', got %q", available[0].Template.URITemplate) + if len(available) != 2 { + t.Fatalf("Expected 2 resources for resources/read (SDK handles matching), got %d", len(available)) } } - func TestForMCPRequest_PromptsList(t *testing.T) { tools := []ServerTool{ mockTool("tool1", "repos", true), From c44ce2e2b881a7cadc37ca38941cc608e6c5d085 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 12 Jan 2026 12:49:57 +0100 Subject: [PATCH 64/70] chore: remove unused filterResourcesByURI function --- pkg/inventory/filters.go | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/pkg/inventory/filters.go b/pkg/inventory/filters.go index c5156e61a..533bba552 100644 --- a/pkg/inventory/filters.go +++ b/pkg/inventory/filters.go @@ -203,17 +203,6 @@ func (r *Inventory) filterToolsByName(name string) []ServerTool { return result } -// filterResourcesByURI returns resource templates matching the given URI pattern. -// Uses linear scan - optimized for single-lookup per-request scenarios (ForMCPRequest). -func (r *Inventory) filterResourcesByURI(uri string) []ServerResourceTemplate { - for i := range r.resourceTemplates { - if r.resourceTemplates[i].Template.URITemplate == uri { - return []ServerResourceTemplate{r.resourceTemplates[i]} - } - } - return []ServerResourceTemplate{} -} - // filterPromptsByName returns prompts matching the given name. // Uses linear scan - optimized for single-lookup per-request scenarios (ForMCPRequest). func (r *Inventory) filterPromptsByName(name string) []ServerPrompt { From 88d117bd82f7da044fc5f1619037477d5fc691fb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 10 Jan 2026 13:44:40 +0000 Subject: [PATCH 65/70] Add base_ref support to assign_copilot_to_issue tool - Add optional base_ref parameter to tool schema - Change from replaceActorsForAssignable to updateIssue mutation with agentAssignment - Add AgentAssignmentInput and UpdateIssueInput structs for new GraphQL mutation - Update all tests to use new mutation structure - Add test case for base_ref functionality - Update toolsnaps and documentation Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com> --- README.md | 1 + docs/remote-server.md | 42 ++-- .../assign_copilot_to_issue.snap | 4 + pkg/github/issues.go | 69 ++++-- pkg/github/issues_test.go | 219 ++++++++++++++++-- 5 files changed, 278 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 1a0f6b1c4..6da46c00e 100644 --- a/README.md +++ b/README.md @@ -751,6 +751,7 @@ The following sets of tools are available: - **assign_copilot_to_issue** - Assign Copilot to issue - **Required OAuth Scopes**: `repo` + - `base_ref`: Git reference (e.g., branch) that the agent will start its work from. If not specified, defaults to the repository's default branch (string, optional) - `issue_number`: Issue number (number, required) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) diff --git a/docs/remote-server.md b/docs/remote-server.md index 039d094fe..d7d0f72b1 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -19,24 +19,24 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) | | ---- | ----------- | ------- | ------------------------- | -------------- | ----------------------------------- | -| apps
`all` | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | -| workflow
`actions` | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | -| codescan
`code_security` | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | -| dependabot
`dependabot` | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | -| comment-discussion
`discussions` | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) | -| logo-gist
`gists` | GitHub Gist related tools | https://api.githubcopilot.com/mcp/x/gists | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/gists/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%2Freadonly%22%7D) | -| git-branch
`git` | GitHub Git API related tools for low-level Git operations | https://api.githubcopilot.com/mcp/x/git | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/git/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%2Freadonly%22%7D) | -| issue-opened
`issues` | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) | -| tag
`labels` | GitHub Labels related tools | https://api.githubcopilot.com/mcp/x/labels | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/labels/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%2Freadonly%22%7D) | -| bell
`notifications` | GitHub Notifications related tools | https://api.githubcopilot.com/mcp/x/notifications | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/notifications/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%2Freadonly%22%7D) | -| organization
`orgs` | GitHub Organization related tools | https://api.githubcopilot.com/mcp/x/orgs | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/orgs/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%2Freadonly%22%7D) | -| project
`projects` | GitHub Projects related tools | https://api.githubcopilot.com/mcp/x/projects | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/projects/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%2Freadonly%22%7D) | -| git-pull-request
`pull_requests` | GitHub Pull Request related tools | https://api.githubcopilot.com/mcp/x/pull_requests | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/pull_requests/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%2Freadonly%22%7D) | -| repo
`repos` | GitHub Repository related tools | https://api.githubcopilot.com/mcp/x/repos | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/repos/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%2Freadonly%22%7D) | -| shield-lock
`secret_protection` | Secret protection related tools, such as GitHub Secret Scanning | https://api.githubcopilot.com/mcp/x/secret_protection | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/secret_protection/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%2Freadonly%22%7D) | -| shield
`security_advisories` | Security advisories related tools | https://api.githubcopilot.com/mcp/x/security_advisories | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/security_advisories/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%2Freadonly%22%7D) | -| star
`stargazers` | GitHub Stargazers related tools | https://api.githubcopilot.com/mcp/x/stargazers | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-stargazers&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fstargazers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/stargazers/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-stargazers&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fstargazers%2Freadonly%22%7D) | -| people
`users` | GitHub User related tools | https://api.githubcopilot.com/mcp/x/users | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/users/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%2Freadonly%22%7D) | +| apps
all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | +| workflow
Actions | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | +| codescan
Code Security | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | +| dependabot
Dependabot | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | +| comment-discussion
Discussions | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) | +| logo-gist
Gists | GitHub Gist related tools | https://api.githubcopilot.com/mcp/x/gists | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/gists/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%2Freadonly%22%7D) | +| git-branch
Git | GitHub Git API related tools for low-level Git operations | https://api.githubcopilot.com/mcp/x/git | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/git/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%2Freadonly%22%7D) | +| issue-opened
Issues | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) | +| tag
Labels | GitHub Labels related tools | https://api.githubcopilot.com/mcp/x/labels | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/labels/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%2Freadonly%22%7D) | +| bell
Notifications | GitHub Notifications related tools | https://api.githubcopilot.com/mcp/x/notifications | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/notifications/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%2Freadonly%22%7D) | +| organization
Organizations | GitHub Organization related tools | https://api.githubcopilot.com/mcp/x/orgs | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/orgs/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%2Freadonly%22%7D) | +| project
Projects | GitHub Projects related tools | https://api.githubcopilot.com/mcp/x/projects | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/projects/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%2Freadonly%22%7D) | +| git-pull-request
Pull Requests | GitHub Pull Request related tools | https://api.githubcopilot.com/mcp/x/pull_requests | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/pull_requests/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%2Freadonly%22%7D) | +| repo
Repositories | GitHub Repository related tools | https://api.githubcopilot.com/mcp/x/repos | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/repos/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%2Freadonly%22%7D) | +| shield-lock
Secret Protection | Secret protection related tools, such as GitHub Secret Scanning | https://api.githubcopilot.com/mcp/x/secret_protection | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/secret_protection/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%2Freadonly%22%7D) | +| shield
Security Advisories | Security advisories related tools | https://api.githubcopilot.com/mcp/x/security_advisories | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/security_advisories/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%2Freadonly%22%7D) | +| star
Stargazers | GitHub Stargazers related tools | https://api.githubcopilot.com/mcp/x/stargazers | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-stargazers&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fstargazers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/stargazers/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-stargazers&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fstargazers%2Freadonly%22%7D) | +| people
Users | GitHub User related tools | https://api.githubcopilot.com/mcp/x/users | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/users/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%2Freadonly%22%7D) | ### Additional _Remote_ Server Toolsets @@ -46,9 +46,9 @@ These toolsets are only available in the remote GitHub MCP Server and are not in | Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) | | ---- | ----------- | ------- | ------------------------- | -------------- | ----------------------------------- | -| copilot
`copilot` | Copilot related tools | https://api.githubcopilot.com/mcp/x/copilot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%2Freadonly%22%7D) | -| copilot
`copilot_spaces` | Copilot Spaces tools | https://api.githubcopilot.com/mcp/x/copilot_spaces | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot_spaces/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%2Freadonly%22%7D) | -| book
`github_support_docs_search` | Retrieve documentation to answer GitHub product and support questions. Topics include: GitHub Actions Workflows, Authentication, ... | https://api.githubcopilot.com/mcp/x/github_support_docs_search | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-github_support_docs_search&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/github_support_docs_search/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-github_support_docs_search&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%2Freadonly%22%7D) | +| copilot
Copilot | Copilot related tools | https://api.githubcopilot.com/mcp/x/copilot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%2Freadonly%22%7D) | +| copilot
Copilot Spaces | Copilot Spaces tools | https://api.githubcopilot.com/mcp/x/copilot_spaces | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot_spaces/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%2Freadonly%22%7D) | +| book
Github Support Docs Search | Retrieve documentation to answer GitHub product and support questions. Topics include: GitHub Actions Workflows, Authentication, ... | https://api.githubcopilot.com/mcp/x/github_support_docs_search | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-github_support_docs_search&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/github_support_docs_search/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-github_support_docs_search&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%2Freadonly%22%7D) | ### Optional Headers diff --git a/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap b/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap index 354600147..7ad1922a0 100644 --- a/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap +++ b/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap @@ -7,6 +7,10 @@ "inputSchema": { "type": "object", "properties": { + "base_ref": { + "type": "string", + "description": "Git reference (e.g., branch) that the agent will start its work from. If not specified, defaults to the repository's default branch" + }, "issue_number": { "type": "number", "description": "Issue number" diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 1e29a0eef..b4580a89d 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1646,6 +1646,10 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server Type: "number", Description: "Issue number", }, + "base_ref": { + Type: "string", + Description: "Git reference (e.g., branch) that the agent will start its work from. If not specified, defaults to the repository's default branch", + }, }, Required: []string{"owner", "repo", "issue_number"}, }, @@ -1656,6 +1660,7 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server Owner string `mapstructure:"owner"` Repo string `mapstructure:"repo"` IssueNumber int32 `mapstructure:"issue_number"` + BaseRef string `mapstructure:"base_ref"` } if err := mapstructure.Decode(args, ¶ms); err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -1724,10 +1729,10 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server return utils.NewToolResultError("copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information."), nil, nil } - // Next let's get the GQL Node ID and current assignees for this issue because the only way to - // assign copilot is to use replaceActorsForAssignable which requires the full list. + // Next, get the issue ID and repository ID var getIssueQuery struct { Repository struct { + ID githubv4.ID Issue struct { ID githubv4.ID Assignees struct { @@ -1749,30 +1754,49 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get issue ID", err), nil, nil } - // Finally, do the assignment. Just for reference, assigning copilot to an issue that it is already - // assigned to seems to have no impact (which is a good thing). - var assignCopilotMutation struct { - ReplaceActorsForAssignable struct { - Typename string `graphql:"__typename"` // Not required but we need a selector or GQL errors - } `graphql:"replaceActorsForAssignable(input: $input)"` - } - + // Build the assignee IDs list including copilot actorIDs := make([]githubv4.ID, len(getIssueQuery.Repository.Issue.Assignees.Nodes)+1) for i, node := range getIssueQuery.Repository.Issue.Assignees.Nodes { actorIDs[i] = node.ID } actorIDs[len(getIssueQuery.Repository.Issue.Assignees.Nodes)] = copilotAssignee.ID + // Prepare agent assignment input + emptyString := githubv4.String("") + agentAssignment := &AgentAssignmentInput{ + CustomAgent: &emptyString, + CustomInstructions: &emptyString, + TargetRepositoryID: getIssueQuery.Repository.ID, + } + + // Add base ref if provided + if params.BaseRef != "" { + baseRef := githubv4.String(params.BaseRef) + agentAssignment.BaseRef = &baseRef + } + + // Execute the updateIssue mutation + var updateIssueMutation struct { + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` + } + if err := client.Mutate( ctx, - &assignCopilotMutation, - ReplaceActorsForAssignableInput{ - AssignableID: getIssueQuery.Repository.Issue.ID, - ActorIDs: actorIDs, + &updateIssueMutation, + UpdateIssueInput{ + ID: getIssueQuery.Repository.Issue.ID, + AssigneeIDs: actorIDs, + AgentAssignment: agentAssignment, }, nil, ); err != nil { - return nil, nil, fmt.Errorf("failed to replace actors for assignable: %w", err) + return nil, nil, fmt.Errorf("failed to update issue with agent assignment: %w", err) } return utils.NewToolResultText("successfully assigned copilot to issue"), nil, nil @@ -1784,6 +1808,21 @@ type ReplaceActorsForAssignableInput struct { ActorIDs []githubv4.ID `json:"actorIds"` } +// AgentAssignmentInput represents the input for assigning an agent to an issue. +type AgentAssignmentInput struct { + BaseRef *githubv4.String `json:"baseRef,omitempty"` + CustomAgent *githubv4.String `json:"customAgent,omitempty"` + CustomInstructions *githubv4.String `json:"customInstructions,omitempty"` + TargetRepositoryID githubv4.ID `json:"targetRepositoryId"` +} + +// UpdateIssueInput represents the input for updating an issue with agent assignment. +type UpdateIssueInput struct { + ID githubv4.ID `json:"id"` + AssigneeIDs []githubv4.ID `json:"assigneeIds"` + AgentAssignment *AgentAssignmentInput `json:"agentAssignment,omitempty"` +} + // parseISOTimestamp parses an ISO 8601 timestamp string into a time.Time object. // Returns the parsed time or an error if parsing fails. // Example formats supported: "2023-01-15T14:30:00Z", "2023-01-15" diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 2ccd4918f..21e78874a 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -2085,8 +2085,15 @@ func TestAssignCopilotToIssue(t *testing.T) { assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_number") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "base_ref") assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo", "issue_number"}) + // Helper function to create pointer to githubv4.String + ptrGitHubv4String := func(s string) *githubv4.String { + v := githubv4.String(s) + return &v + } + var pageOfFakeBots = func(n int) []struct{} { // We don't _really_ need real bots here, just objects that count as entries for the page bots := make([]struct{}, n) @@ -2151,6 +2158,7 @@ func TestAssignCopilotToIssue(t *testing.T) { githubv4mock.NewQueryMatcher( struct { Repository struct { + ID githubv4.ID Issue struct { ID githubv4.ID Assignees struct { @@ -2168,6 +2176,7 @@ func TestAssignCopilotToIssue(t *testing.T) { }, githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ + "id": githubv4.ID("test-repo-id"), "issue": map[string]any{ "id": githubv4.ID("test-issue-id"), "assignees": map[string]any{ @@ -2179,16 +2188,34 @@ func TestAssignCopilotToIssue(t *testing.T) { ), githubv4mock.NewMutationMatcher( struct { - ReplaceActorsForAssignable struct { - Typename string `graphql:"__typename"` - } `graphql:"replaceActorsForAssignable(input: $input)"` + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` }{}, - ReplaceActorsForAssignableInput{ - AssignableID: githubv4.ID("test-issue-id"), - ActorIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + UpdateIssueInput{ + ID: githubv4.ID("test-issue-id"), + AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + AgentAssignment: &AgentAssignmentInput{ + BaseRef: nil, + CustomAgent: ptrGitHubv4String(""), + CustomInstructions: ptrGitHubv4String(""), + TargetRepositoryID: githubv4.ID("test-repo-id"), + }, }, nil, - githubv4mock.DataResponse(map[string]any{}), + githubv4mock.DataResponse(map[string]any{ + "updateIssue": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "number": githubv4.Int(123), + "url": githubv4.String("https://github.com/owner/repo/issues/123"), + }, + }, + }), ), ), }, @@ -2240,6 +2267,7 @@ func TestAssignCopilotToIssue(t *testing.T) { githubv4mock.NewQueryMatcher( struct { Repository struct { + ID githubv4.ID Issue struct { ID githubv4.ID Assignees struct { @@ -2257,6 +2285,7 @@ func TestAssignCopilotToIssue(t *testing.T) { }, githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ + "id": githubv4.ID("test-repo-id"), "issue": map[string]any{ "id": githubv4.ID("test-issue-id"), "assignees": map[string]any{ @@ -2275,20 +2304,38 @@ func TestAssignCopilotToIssue(t *testing.T) { ), githubv4mock.NewMutationMatcher( struct { - ReplaceActorsForAssignable struct { - Typename string `graphql:"__typename"` - } `graphql:"replaceActorsForAssignable(input: $input)"` + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` }{}, - ReplaceActorsForAssignableInput{ - AssignableID: githubv4.ID("test-issue-id"), - ActorIDs: []githubv4.ID{ + UpdateIssueInput{ + ID: githubv4.ID("test-issue-id"), + AssigneeIDs: []githubv4.ID{ githubv4.ID("existing-assignee-id"), githubv4.ID("existing-assignee-id-2"), githubv4.ID("copilot-swe-agent-id"), }, + AgentAssignment: &AgentAssignmentInput{ + BaseRef: nil, + CustomAgent: ptrGitHubv4String(""), + CustomInstructions: ptrGitHubv4String(""), + TargetRepositoryID: githubv4.ID("test-repo-id"), + }, }, nil, - githubv4mock.DataResponse(map[string]any{}), + githubv4mock.DataResponse(map[string]any{ + "updateIssue": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "number": githubv4.Int(123), + "url": githubv4.String("https://github.com/owner/repo/issues/123"), + }, + }, + }), ), ), }, @@ -2377,6 +2424,7 @@ func TestAssignCopilotToIssue(t *testing.T) { githubv4mock.NewQueryMatcher( struct { Repository struct { + ID githubv4.ID Issue struct { ID githubv4.ID Assignees struct { @@ -2394,6 +2442,7 @@ func TestAssignCopilotToIssue(t *testing.T) { }, githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ + "id": githubv4.ID("test-repo-id"), "issue": map[string]any{ "id": githubv4.ID("test-issue-id"), "assignees": map[string]any{ @@ -2405,16 +2454,34 @@ func TestAssignCopilotToIssue(t *testing.T) { ), githubv4mock.NewMutationMatcher( struct { - ReplaceActorsForAssignable struct { - Typename string `graphql:"__typename"` - } `graphql:"replaceActorsForAssignable(input: $input)"` + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` }{}, - ReplaceActorsForAssignableInput{ - AssignableID: githubv4.ID("test-issue-id"), - ActorIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + UpdateIssueInput{ + ID: githubv4.ID("test-issue-id"), + AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + AgentAssignment: &AgentAssignmentInput{ + BaseRef: nil, + CustomAgent: ptrGitHubv4String(""), + CustomInstructions: ptrGitHubv4String(""), + TargetRepositoryID: githubv4.ID("test-repo-id"), + }, }, nil, - githubv4mock.DataResponse(map[string]any{}), + githubv4mock.DataResponse(map[string]any{ + "updateIssue": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "number": githubv4.Int(123), + "url": githubv4.String("https://github.com/owner/repo/issues/123"), + }, + }, + }), ), ), }, @@ -2461,6 +2528,116 @@ func TestAssignCopilotToIssue(t *testing.T) { expectToolError: true, expectedToolErrMsg: "copilot isn't available as an assignee for this issue. Please inform the user to visit https://docs.github.com/en/copilot/using-github-copilot/using-copilot-coding-agent-to-work-on-tasks/about-assigning-tasks-to-copilot for more information.", }, + { + name: "successful assignment with base_ref specified", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "base_ref": "feature-branch", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + SuggestedActors struct { + Nodes []struct { + Bot struct { + ID githubv4.ID + Login githubv4.String + TypeName string `graphql:"__typename"` + } `graphql:"... on Bot"` + } + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"suggestedActors(first: 100, after: $endCursor, capabilities: CAN_BE_ASSIGNED)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "endCursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "suggestedActors": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("copilot-swe-agent-id"), + "login": githubv4.String("copilot-swe-agent"), + "__typename": "Bot", + }, + }, + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + ID githubv4.ID + Issue struct { + ID githubv4.ID + Assignees struct { + Nodes []struct { + ID githubv4.ID + } + } `graphql:"assignees(first: 100)"` + } `graphql:"issue(number: $number)"` + } `graphql:"repository(owner: $owner, name: $name)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + "number": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "id": githubv4.ID("test-repo-id"), + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "assignees": map[string]any{ + "nodes": []any{}, + }, + }, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + UpdateIssue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + } `graphql:"updateIssue(input: $input)"` + }{}, + UpdateIssueInput{ + ID: githubv4.ID("test-issue-id"), + AssigneeIDs: []githubv4.ID{githubv4.ID("copilot-swe-agent-id")}, + AgentAssignment: &AgentAssignmentInput{ + BaseRef: ptrGitHubv4String("feature-branch"), + CustomAgent: ptrGitHubv4String(""), + CustomInstructions: ptrGitHubv4String(""), + TargetRepositoryID: githubv4.ID("test-repo-id"), + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateIssue": map[string]any{ + "issue": map[string]any{ + "id": githubv4.ID("test-issue-id"), + "number": githubv4.Int(123), + "url": githubv4.String("https://github.com/owner/repo/issues/123"), + }, + }, + }), + ), + ), + }, } for _, tc := range tests { From d18f26e5595d51ed21fcc10c0aa6726f27b2fdf1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 12 Jan 2026 13:31:06 +0000 Subject: [PATCH 66/70] Add GraphQL-Features header support for agent assignment API - Add context-based GraphQL feature flag support - Modify bearerAuthTransport to read features from context and add GraphQL-Features header - Use issues_copilot_assignment_api_support feature flag for updateIssue mutation - Export GetGraphQLFeatures function for use in HTTP transport layer This allows the assign_copilot_to_issue tool to work with the non-GA agent assignment API by sending the required GraphQL-Features header. Co-authored-by: SamMorrowDrums <4811358+SamMorrowDrums@users.noreply.github.com> --- internal/ghmcp/server.go | 6 ++++++ pkg/github/issues.go | 25 +++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 165886606..250f6b4cc 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -622,6 +622,12 @@ type bearerAuthTransport struct { func (t *bearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) { req = req.Clone(req.Context()) req.Header.Set("Authorization", "Bearer "+t.token) + + // Check for GraphQL-Features in context and add header if present + if features := github.GetGraphQLFeatures(req.Context()); len(features) > 0 { + req.Header.Set("GraphQL-Features", strings.Join(features, ", ")) + } + return t.transport.RoundTrip(req) } diff --git a/pkg/github/issues.go b/pkg/github/issues.go index b4580a89d..63174c9e9 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1775,7 +1775,8 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server agentAssignment.BaseRef = &baseRef } - // Execute the updateIssue mutation + // Execute the updateIssue mutation with the GraphQL-Features header + // This header is required for the agent assignment API which is not GA yet var updateIssueMutation struct { UpdateIssue struct { Issue struct { @@ -1786,8 +1787,12 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server } `graphql:"updateIssue(input: $input)"` } + // Add the GraphQL-Features header for the agent assignment API + // The header will be read by the HTTP transport if it's configured to do so + ctxWithFeatures := withGraphQLFeatures(ctx, "issues_copilot_assignment_api_support") + if err := client.Mutate( - ctx, + ctxWithFeatures, &updateIssueMutation, UpdateIssueInput{ ID: getIssueQuery.Repository.Issue.ID, @@ -1908,3 +1913,19 @@ func AssignCodingAgentPrompt(t translations.TranslationHelperFunc) inventory.Ser }, ) } + +// graphQLFeaturesKey is a context key for GraphQL feature flags +type graphQLFeaturesKey struct{} + +// withGraphQLFeatures adds GraphQL feature flags to the context +func withGraphQLFeatures(ctx context.Context, features ...string) context.Context { + return context.WithValue(ctx, graphQLFeaturesKey{}, features) +} + +// GetGraphQLFeatures retrieves GraphQL feature flags from the context +func GetGraphQLFeatures(ctx context.Context) []string { + if features, ok := ctx.Value(graphQLFeaturesKey{}).([]string); ok { + return features + } + return nil +} From f62ff634c53b94e9cf38a80ee1fc93fef8d532fb Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 12 Jan 2026 14:54:38 +0100 Subject: [PATCH 67/70] Regenerate documentation TOCs --- docs/remote-server.md | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/docs/remote-server.md b/docs/remote-server.md index d7d0f72b1..039d094fe 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -19,24 +19,24 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) | | ---- | ----------- | ------- | ------------------------- | -------------- | ----------------------------------- | -| apps
all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | -| workflow
Actions | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | -| codescan
Code Security | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | -| dependabot
Dependabot | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | -| comment-discussion
Discussions | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) | -| logo-gist
Gists | GitHub Gist related tools | https://api.githubcopilot.com/mcp/x/gists | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/gists/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%2Freadonly%22%7D) | -| git-branch
Git | GitHub Git API related tools for low-level Git operations | https://api.githubcopilot.com/mcp/x/git | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/git/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%2Freadonly%22%7D) | -| issue-opened
Issues | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) | -| tag
Labels | GitHub Labels related tools | https://api.githubcopilot.com/mcp/x/labels | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/labels/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%2Freadonly%22%7D) | -| bell
Notifications | GitHub Notifications related tools | https://api.githubcopilot.com/mcp/x/notifications | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/notifications/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%2Freadonly%22%7D) | -| organization
Organizations | GitHub Organization related tools | https://api.githubcopilot.com/mcp/x/orgs | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/orgs/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%2Freadonly%22%7D) | -| project
Projects | GitHub Projects related tools | https://api.githubcopilot.com/mcp/x/projects | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/projects/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%2Freadonly%22%7D) | -| git-pull-request
Pull Requests | GitHub Pull Request related tools | https://api.githubcopilot.com/mcp/x/pull_requests | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/pull_requests/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%2Freadonly%22%7D) | -| repo
Repositories | GitHub Repository related tools | https://api.githubcopilot.com/mcp/x/repos | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/repos/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%2Freadonly%22%7D) | -| shield-lock
Secret Protection | Secret protection related tools, such as GitHub Secret Scanning | https://api.githubcopilot.com/mcp/x/secret_protection | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/secret_protection/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%2Freadonly%22%7D) | -| shield
Security Advisories | Security advisories related tools | https://api.githubcopilot.com/mcp/x/security_advisories | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/security_advisories/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%2Freadonly%22%7D) | -| star
Stargazers | GitHub Stargazers related tools | https://api.githubcopilot.com/mcp/x/stargazers | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-stargazers&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fstargazers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/stargazers/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-stargazers&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fstargazers%2Freadonly%22%7D) | -| people
Users | GitHub User related tools | https://api.githubcopilot.com/mcp/x/users | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/users/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%2Freadonly%22%7D) | +| apps
`all` | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | +| workflow
`actions` | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | +| codescan
`code_security` | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | +| dependabot
`dependabot` | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | +| comment-discussion
`discussions` | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) | +| logo-gist
`gists` | GitHub Gist related tools | https://api.githubcopilot.com/mcp/x/gists | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/gists/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%2Freadonly%22%7D) | +| git-branch
`git` | GitHub Git API related tools for low-level Git operations | https://api.githubcopilot.com/mcp/x/git | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/git/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%2Freadonly%22%7D) | +| issue-opened
`issues` | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) | +| tag
`labels` | GitHub Labels related tools | https://api.githubcopilot.com/mcp/x/labels | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/labels/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%2Freadonly%22%7D) | +| bell
`notifications` | GitHub Notifications related tools | https://api.githubcopilot.com/mcp/x/notifications | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/notifications/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%2Freadonly%22%7D) | +| organization
`orgs` | GitHub Organization related tools | https://api.githubcopilot.com/mcp/x/orgs | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/orgs/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-orgs&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Forgs%2Freadonly%22%7D) | +| project
`projects` | GitHub Projects related tools | https://api.githubcopilot.com/mcp/x/projects | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/projects/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-projects&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fprojects%2Freadonly%22%7D) | +| git-pull-request
`pull_requests` | GitHub Pull Request related tools | https://api.githubcopilot.com/mcp/x/pull_requests | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/pull_requests/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-pull_requests&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fpull_requests%2Freadonly%22%7D) | +| repo
`repos` | GitHub Repository related tools | https://api.githubcopilot.com/mcp/x/repos | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/repos/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-repos&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Frepos%2Freadonly%22%7D) | +| shield-lock
`secret_protection` | Secret protection related tools, such as GitHub Secret Scanning | https://api.githubcopilot.com/mcp/x/secret_protection | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/secret_protection/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-secret_protection&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecret_protection%2Freadonly%22%7D) | +| shield
`security_advisories` | Security advisories related tools | https://api.githubcopilot.com/mcp/x/security_advisories | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/security_advisories/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%2Freadonly%22%7D) | +| star
`stargazers` | GitHub Stargazers related tools | https://api.githubcopilot.com/mcp/x/stargazers | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-stargazers&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fstargazers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/stargazers/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-stargazers&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fstargazers%2Freadonly%22%7D) | +| people
`users` | GitHub User related tools | https://api.githubcopilot.com/mcp/x/users | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/users/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%2Freadonly%22%7D) | ### Additional _Remote_ Server Toolsets @@ -46,9 +46,9 @@ These toolsets are only available in the remote GitHub MCP Server and are not in | Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) | | ---- | ----------- | ------- | ------------------------- | -------------- | ----------------------------------- | -| copilot
Copilot | Copilot related tools | https://api.githubcopilot.com/mcp/x/copilot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%2Freadonly%22%7D) | -| copilot
Copilot Spaces | Copilot Spaces tools | https://api.githubcopilot.com/mcp/x/copilot_spaces | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot_spaces/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%2Freadonly%22%7D) | -| book
Github Support Docs Search | Retrieve documentation to answer GitHub product and support questions. Topics include: GitHub Actions Workflows, Authentication, ... | https://api.githubcopilot.com/mcp/x/github_support_docs_search | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-github_support_docs_search&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/github_support_docs_search/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-github_support_docs_search&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%2Freadonly%22%7D) | +| copilot
`copilot` | Copilot related tools | https://api.githubcopilot.com/mcp/x/copilot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%2Freadonly%22%7D) | +| copilot
`copilot_spaces` | Copilot Spaces tools | https://api.githubcopilot.com/mcp/x/copilot_spaces | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot_spaces/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot_spaces&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot_spaces%2Freadonly%22%7D) | +| book
`github_support_docs_search` | Retrieve documentation to answer GitHub product and support questions. Topics include: GitHub Actions Workflows, Authentication, ... | https://api.githubcopilot.com/mcp/x/github_support_docs_search | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-github_support_docs_search&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/github_support_docs_search/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-github_support_docs_search&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgithub_support_docs_search%2Freadonly%22%7D) | ### Optional Headers From 27950fe0689cb5d039bf9d5cb3ca1c13ec902cd1 Mon Sep 17 00:00:00 2001 From: haith17 Date: Mon, 12 Jan 2026 21:22:17 +0300 Subject: [PATCH 68/70] README.md to Y3tik Khairha MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit نموذج أولي --- README.md => Y3tik Khairha | 465 +++++++++++++++++++++++-------------- 1 file changed, 297 insertions(+), 168 deletions(-) rename README.md => Y3tik Khairha (85%) diff --git a/README.md b/Y3tik Khairha similarity index 85% rename from README.md rename to Y3tik Khairha index 6da46c00e..c5fd498ea 100644 --- a/README.md +++ b/Y3tik Khairha @@ -1,184 +1,313 @@ -[![Go Report Card](https://goreportcard.com/badge/github.com/github/github-mcp-server)](https://goreportcard.com/report/github.com/github/github-mcp-server) - -# GitHub MCP Server - -The GitHub MCP Server connects AI tools directly to GitHub's platform. This gives AI agents, assistants, and chatbots the ability to read repositories and code files, manage issues and PRs, analyze code, and automate workflows. All through natural language interactions. - -### Use Cases - -- Repository Management: Browse and query code, search files, analyze commits, and understand project structure across any repository you have access to. -- Issue & PR Automation: Create, update, and manage issues and pull requests. Let AI help triage bugs, review code changes, and maintain project boards. -- CI/CD & Workflow Intelligence: Monitor GitHub Actions workflow runs, analyze build failures, manage releases, and get insights into your development pipeline. -- Code Analysis: Examine security findings, review Dependabot alerts, understand code patterns, and get comprehensive insights into your codebase. -- Team Collaboration: Access discussions, manage notifications, analyze team activity, and streamline processes for your team. - -Built for developers who want to connect their AI tools to GitHub context and capabilities, from simple natural language queries to complex multi-step agent workflows. - ---- - -## Remote GitHub MCP Server - -[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) [![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D&quality=insiders) - -The remote GitHub MCP Server is hosted by GitHub and provides the easiest method for getting up and running. If your MCP host does not support remote MCP servers, don't worry! You can use the [local version of the GitHub MCP Server](https://github.com/github/github-mcp-server?tab=readme-ov-file#local-github-mcp-server) instead. - -### Prerequisites - -1. A compatible MCP host with remote server support (VS Code 1.101+, Claude Desktop, Cursor, Windsurf, etc.) -2. Any applicable [policies enabled](https://github.com/github/github-mcp-server/blob/main/docs/policies-and-governance.md) - -### Install in VS Code - -For quick installation, use one of the one-click install buttons above. Once you complete that flow, toggle Agent mode (located by the Copilot Chat text input) and the server will start. Make sure you're using [VS Code 1.101](https://code.visualstudio.com/updates/v1_101) or [later](https://code.visualstudio.com/updates) for remote MCP and OAuth support. +"use client" + +import type React from "react" + +import { useState } from "react" +import { useRouter, useSearchParams } from "next/navigation" +import { createClient } from "@/lib/supabase" +import Link from "next/link" + +export default function OwnerInfoContent() { + const router = useRouter() + const searchParams = useSearchParams() + + const [formData, setFormData] = useState({ + full_name: "", + date_of_birth: "", + id_number: "", + phone: "", + email: "", + nationality: "", + gender: "male", + has_partners: false, + partners: [] as Array<{ name: string; id_number: string; ownership_percentage: string }>, + agree_terms: false, + agree_privacy: false, + }) + + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState("") + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value, type } = e.target + const val = type === "checkbox" ? (e.target as HTMLInputElement).checked : value + + setFormData((prev) => ({ + ...prev, + [name]: val, + })) + } -Alternatively, to manually configure VS Code, choose the appropriate JSON block from the examples below and add it to your host configuration: + const handleAddPartner = () => { + setFormData((prev) => ({ + ...prev, + partners: [ + ...prev.partners, + { + name: "", + id_number: "", + ownership_percentage: "", + }, + ], + })) + } - - - - - - - -
Using OAuthUsing a GitHub PAT
VS Code (version 1.101 or greater)
+ const handlePartnerChange = (index: number, field: string, value: string) => { + setFormData((prev) => { + const newPartners = [...prev.partners] + newPartners[index] = { ...newPartners[index], [field]: value } + return { ...prev, partners: newPartners } + }) + } -```json -{ - "servers": { - "github": { - "type": "http", - "url": "https://api.githubcopilot.com/mcp/" - } + const handleRemovePartner = (index: number) => { + setFormData((prev) => ({ + ...prev, + partners: prev.partners.filter((_, i) => i !== index), + })) } -} -``` - + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError("") -```json -{ - "servers": { - "github": { - "type": "http", - "url": "https://api.githubcopilot.com/mcp/", - "headers": { - "Authorization": "Bearer ${input:github_mcp_pat}" - } + if (!formData.agree_terms || !formData.agree_privacy) { + setError("ظٹط¬ط¨ ط§ظ„ظ…ظˆط§ظپظ‚ط© ط¹ظ„ظ‰ ط§ظ„ط´ط±ظˆط· ظˆط§ظ„ط®طµظˆطµظٹط©") + return } - }, - "inputs": [ - { - "type": "promptString", - "id": "github_mcp_pat", - "description": "GitHub Personal Access Token", - "password": true - } - ] -} -``` -
+ setIsLoading(true) -### Install in other MCP hosts -- **[GitHub Copilot in other IDEs](/docs/installation-guides/install-other-copilot-ides.md)** - Installation for JetBrains, Visual Studio, Eclipse, and Xcode with GitHub Copilot -- **[Claude Applications](/docs/installation-guides/install-claude.md)** - Installation guide for Claude Desktop and Claude Code CLI -- **[Codex](/docs/installation-guides/install-codex.md)** - Installation guide for Open AI Codex -- **[Cursor](/docs/installation-guides/install-cursor.md)** - Installation guide for Cursor IDE -- **[Windsurf](/docs/installation-guides/install-windsurf.md)** - Installation guide for Windsurf IDE -- **[Rovo Dev CLI](/docs/installation-guides/install-rovo-dev-cli.md)** - Installation guide for Rovo Dev CLI + try { + const supabase = createClient() + const { + data: { user }, + } = await supabase.auth.getUser() -> **Note:** Each MCP host application needs to configure a GitHub App or OAuth App to support remote access via OAuth. Any host application that supports remote MCP servers should support the remote GitHub server with PAT authentication. Configuration details and support levels vary by host. Make sure to refer to the host application's documentation for more info. - -### Configuration - -#### Toolset configuration - -See [Remote Server Documentation](docs/remote-server.md) for full details on remote server configuration, toolsets, headers, and advanced usage. This file provides comprehensive instructions and examples for connecting, customizing, and installing the remote GitHub MCP Server in VS Code and other MCP hosts. - -When no toolsets are specified, [default toolsets](#default-toolset) are used. - -#### GitHub Enterprise - -##### GitHub Enterprise Cloud with data residency (ghe.com) - -GitHub Enterprise Cloud can also make use of the remote server. - -Example for `https://octocorp.ghe.com` with GitHub PAT token: -``` -{ - ... - "proxima-github": { - "type": "http", - "url": "https://copilot-api.octocorp.ghe.com/mcp", - "headers": { - "Authorization": "Bearer ${input:github_mcp_pat}" + if (!user) { + throw new Error("User not authenticated") } - }, - ... -} -``` - -> **Note:** When using OAuth with GitHub Enterprise with VS Code and GitHub Copilot, you also need to configure your VS Code settings to point to your GitHub Enterprise instance - see [Authenticate from VS Code](https://docs.github.com/en/enterprise-cloud@latest/copilot/how-tos/configure-personal-settings/authenticate-to-ghecom) - -##### GitHub Enterprise Server - -GitHub Enterprise Server does not support remote server hosting. Please refer to [GitHub Enterprise Server and Enterprise Cloud with data residency (ghe.com)](#github-enterprise-server-and-enterprise-cloud-with-data-residency-ghecom) from the local server configuration. - ---- - -## Local GitHub MCP Server - -[![Install with Docker in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&inputs=%5B%7B%22id%22%3A%22github_token%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22GitHub%20Personal%20Access%20Token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_PERSONAL_ACCESS_TOKEN%22%3A%22%24%7Binput%3Agithub_token%7D%22%7D%7D) [![Install with Docker in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&inputs=%5B%7B%22id%22%3A%22github_token%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22GitHub%20Personal%20Access%20Token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_PERSONAL_ACCESS_TOKEN%22%3A%22%24%7Binput%3Agithub_token%7D%22%7D%7D&quality=insiders) -### Prerequisites - -1. To run the server in a container, you will need to have [Docker](https://www.docker.com/) installed. -2. Once Docker is installed, you will also need to ensure Docker is running. The Docker image is available at `ghcr.io/github/github-mcp-server`. The image is public; if you get errors on pull, you may have an expired token and need to `docker logout ghcr.io`. -3. Lastly you will need to [Create a GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new). -The MCP server can use many of the GitHub APIs, so enable the permissions that you feel comfortable granting your AI tools (to learn more about access tokens, please check out the [documentation](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens)). - -
Handling PATs Securely - -### Environment Variables (Recommended) -To keep your GitHub PAT secure and reusable across different MCP hosts: - -1. **Store your PAT in environment variables** - ```bash - export GITHUB_PAT=your_token_here - ``` - Or create a `.env` file: - ```env - GITHUB_PAT=your_token_here - ``` - -2. **Protect your `.env` file** - ```bash - # Add to .gitignore to prevent accidental commits - echo ".env" >> .gitignore - ``` - -3. **Reference the token in configurations** - ```bash - # CLI usage - claude mcp update github -e GITHUB_PERSONAL_ACCESS_TOKEN=$GITHUB_PAT - - # In config files (where supported) - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "$GITHUB_PAT" - } - ``` - -> **Note**: Environment variable support varies by host app and IDE. Some applications (like Windsurf) require hardcoded tokens in config files. - -### Token Security Best Practices + router.push("/dashboard/owner/setup/payment") + } catch (err) { + setError("ط­ط¯ط« ط®ط·ط£. ظٹط±ط¬ظ‰ ط§ظ„ظ…ط­ط§ظˆظ„ط© ظ…ط±ط© ط£ط®ط±ظ‰.") + } finally { + setIsLoading(false) + } + } -- **Minimum scopes**: Only grant necessary permissions - - `repo` - Repository operations - - `read:packages` - Docker image access - - `read:org` - Organization team access -- **Separate tokens**: Use different PATs for different projects/environments -- **Regular rotation**: Update tokens periodically -- **Never commit**: Keep tokens out of version control + return ( +
+
+
+

ظ…ط¹ظ„ظˆظ…ط§طھ ط§ظ„ظ…ط§ظ„ظƒ

+

ط§ظ„ط®ط·ظˆط© 4 ظ…ظ† 4

+
+ +
+ {error &&
{error}
} + + {/* ط§ظ„ط¨ظٹط§ظ†ط§طھ ط§ظ„ط£ط³ط§ط³ظٹط© */} +
+
+ + +
+
+ + +
+
+ + {/* ط§ظ„ظ‡ظˆظٹط© ظˆط§ظ„ط§طھطµط§ظ„ */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + {/* ط§ظ„ط¬ظ†ط³ظٹط© ظˆط§ظ„ظ†ظˆط¹ */} +
+
+ + +
+
+ + +
+
+ + {/* ظ‡ظ„ ظ„ط¯ظٹظƒ ط´ط±ظƒط§ط، */} +
+ +
+ + {/* ط¨ظٹط§ظ†ط§طھ ط§ظ„ط´ط±ظƒط§ط، */} + {formData.has_partners && ( +
+ {formData.partners.map((partner, index) => ( +
+
+ handlePartnerChange(index, "name", e.target.value)} + className="px-3 py-2 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary" + /> + handlePartnerChange(index, "id_number", e.target.value)} + className="px-3 py-2 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary" + /> + handlePartnerChange(index, "ownership_percentage", e.target.value)} + className="px-3 py-2 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary" + min="0" + max="100" + /> +
+ +
+ ))} + +
+ )} + + {/* ط§ظ„ظ…ظˆط§ظپظ‚ط§طھ */} +
+ + +
+ + {/* ط£ط²ط±ط§ط± ط§ظ„طھظ†ظ‚ظ„ */} +
+ + ط§ظ„ط³ط§ط¨ظ‚ + + +
+
+
+
+ ) +}f version control - **File permissions**: Restrict access to config files containing tokens ```bash chmod 600 ~/.your-app/config.json From dc55261c7d54020d98be3eb131c570641499d072 Mon Sep 17 00:00:00 2001 From: haith17 Date: Mon, 12 Jan 2026 21:35:07 +0300 Subject: [PATCH 69/70] README.md to Y3tik Khairha --- .devcontainer/devcontainer.json | 313 ++++++++++++++++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 .devcontainer/devcontainer.json diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 000000000..7b114b004 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,313 @@ +"use client" + +import type React from "react" + +import { useState } from "react" +import { useRouter, useSearchParams } from "next/navigation" +import { createClient } from "@/lib/supabase" +import Link from "next/link" + +export default function OwnerInfoContent() { + const router = useRouter() + const searchParams = useSearchParams() + + const [formData, setFormData] = useState({ + full_name: "", + date_of_birth: "", + id_number: "", + phone: "", + email: "", + nationality: "", + gender: "male", + has_partners: false, + partners: [] as Array<{ name: string; id_number: string; ownership_percentage: string }>, + agree_terms: false, + agree_privacy: false, + }) + + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState("") + + const handleInputChange = (e: React.ChangeEvent) => { + const { name, value, type } = e.target + const val = type === "checkbox" ? (e.target as HTMLInputElement).checked : value + + setFormData((prev) => ({ + ...prev, + [name]: val, + })) + } + + const handleAddPartner = () => { + setFormData((prev) => ({ + ...prev, + partners: [ + ...prev.partners, + { + name: "", + id_number: "", + ownership_percentage: "", + }, + ], + })) + } + + const handlePartnerChange = (index: number, field: string, value: string) => { + setFormData((prev) => { + const newPartners = [...prev.partners] + newPartners[index] = { ...newPartners[index], [field]: value } + return { ...prev, partners: newPartners } + }) + } + + const handleRemovePartner = (index: number) => { + setFormData((prev) => ({ + ...prev, + partners: prev.partners.filter((_, i) => i !== index), + })) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + setError("") + + if (!formData.agree_terms || !formData.agree_privacy) { + setError("ظٹط¬ط¨ ط§ظ„ظ…ظˆط§ظپظ‚ط© ط¹ظ„ظ‰ ط§ظ„ط´ط±ظˆط· ظˆط§ظ„ط®طµظˆطµظٹط©") + return + } + + setIsLoading(true) + + try { + const supabase = createClient() + const { + data: { user }, + } = await supabase.auth.getUser() + + if (!user) { + throw new Error("User not authenticated") + } + + router.push("/dashboard/owner/setup/payment") + } catch (err) { + setError("ط­ط¯ط« ط®ط·ط£. ظٹط±ط¬ظ‰ ط§ظ„ظ…ط­ط§ظˆظ„ط© ظ…ط±ط© ط£ط®ط±ظ‰.") + } finally { + setIsLoading(false) + } + } + + return ( +
+
+
+

ظ…ط¹ظ„ظˆظ…ط§طھ ط§ظ„ظ…ط§ظ„ظƒ

+

ط§ظ„ط®ط·ظˆط© 4 ظ…ظ† 4

+
+ +
+ {error &&
{error}
} + + {/* ط§ظ„ط¨ظٹط§ظ†ط§طھ ط§ظ„ط£ط³ط§ط³ظٹط© */} +
+
+ + +
+
+ + +
+
+ + {/* ط§ظ„ظ‡ظˆظٹط© ظˆط§ظ„ط§طھطµط§ظ„ */} +
+
+ + +
+
+ + +
+
+ + +
+
+ + {/* ط§ظ„ط¬ظ†ط³ظٹط© ظˆط§ظ„ظ†ظˆط¹ */} +
+
+ + +
+
+ + +
+
+ + {/* ظ‡ظ„ ظ„ط¯ظٹظƒ ط´ط±ظƒط§ط، */} +
+ +
+ + {/* ط¨ظٹط§ظ†ط§طھ ط§ظ„ط´ط±ظƒط§ط، */} + {formData.has_partners && ( +
+ {formData.partners.map((partner, index) => ( +
+
+ handlePartnerChange(index, "name", e.target.value)} + className="px-3 py-2 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary" + /> + handlePartnerChange(index, "id_number", e.target.value)} + className="px-3 py-2 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary" + /> + handlePartnerChange(index, "ownership_percentage", e.target.value)} + className="px-3 py-2 border border-border rounded-lg bg-background text-foreground focus:outline-none focus:border-primary" + min="0" + max="100" + /> +
+ +
+ ))} + +
+ )} + + {/* ط§ظ„ظ…ظˆط§ظپظ‚ط§طھ */} +
+ + +
+ + {/* ط£ط²ط±ط§ط± ط§ظ„طھظ†ظ‚ظ„ */} +
+ + ط§ظ„ط³ط§ط¨ظ‚ + + +
+
+
+
+ ) +}{ + "image": "mcr.microsoft.com/devcontainers/universal:2", + "features": {} +} From 457a1cbbd57483a6df721a15da6c75923e34143a Mon Sep 17 00:00:00 2001 From: haith17 Date: Wed, 4 Feb 2026 08:33:55 +0300 Subject: [PATCH 70/70] Create jons --- jons | 1 + 1 file changed, 1 insertion(+) create mode 100644 jons diff --git a/jons b/jons new file mode 100644 index 000000000..7ee3a4beb --- /dev/null +++ b/jons @@ -0,0 +1 @@ +ghkl \ No newline at end of file