From 225c47cda1d5f7fba3a90292933ade8d9a801c47 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 1 Jul 2026 18:12:12 +0200 Subject: [PATCH 01/16] chore(deps): upgrade go-sdk to v1.7.0-pre.1 (new MCP spec) (#2787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(deps): upgrade go-sdk to v1.7.0-pre.1 (new MCP spec) Bumps github.com/modelcontextprotocol/go-sdk v1.6.1 -> v1.7.0-pre.1, the pre-release that implements the new stateless MCP spec (SEP-2575 server/discover, SEP-2567 sessionless, MRTR per SEP-2322). The only source-visible change is tool annotation serialization: the new SDK drops `omitempty` on ToolAnnotations.ReadOnlyHint and IdempotentHint, so false values are now emitted explicitly. Regenerated the 113 affected toolsnaps to match. No behavioural changes; build, vet, test and lint all pass. Refs: github/copilot-mcp-core#1709 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * ci(mcp-diff): pin to cross-spec-aware mcp-server-diff (3c2d5ea) Pins both the stdio and streamable-http MCP Server Diff jobs to the 3.0 branch of SamMorrowDrums/mcp-server-diff (commit 3c2d5ea), which normalizes cross-spec-version churn: _meta protocol plumbing, CacheableResult cache hints, the initialize envelope, and tool-annotation default hints. Without it the go-sdk v1.6.1 -> v1.7.0-pre.1 bump would surface ~113 spurious idempotentHint/readOnlyHint:false diffs from the SDK dropping omitempty. Temporary commit pin; move to the v3.0.0 tag once it ships. Refs: github/copilot-mcp-core#1709 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci(mcp-diff): bump to a2ba618 (stateless server/discover probe) a2ba618 adds the SEP-2575 server/discover stateless probe path, so each server is probed at its own newest supported spec (base v1.6.1 via initialize/2025-11-25, this branch via server/discover/2026-07-28) rather than negotiating both down to the legacy handshake. Produces an honest, signal-only cross-spec diff. Still a temporary commit pin; moves to v3.0.0 once tagged. Refs: github/copilot-mcp-core#1709 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci(mcp-diff): use full commit SHA for action pin Actions rejects shortened SHAs ('not supported'); use the full a2ba618c42293fb36e67be88e59c60d5608a302a so the action resolves. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci(mcp-diff): track 3.0 HEAD (8fc26d8, becomes v3.0.0) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat(inventory): project owner/repo to Mcp-Param-* headers (SEP-2243) Annotates owner/repo tool params with x-mcp-header so the SDK projects them to Mcp-Param-owner/Mcp-Param-repo request headers. A remote proxy can route and filter on owner/repo from headers instead of re-parsing the JSON-RPC body (headers are SDK-validated against the body). No-op for tools without these params; old-protocol traffic unaffected. Refs: github/copilot-mcp-core#1709, #1828 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(inventory): preserve instructions in ForMCPRequest incl. server/discover ForMCPRequest dropped the generated instructions when narrowing the per-request inventory, so HTTP server/discover (and initialize) returned empty instructions even though the full inventory had them. Preserve instructions on the copy and treat server/discover like initialize. Fixes discover<->initialize parity flagged on go-sdk#1034 (root cause was here, not the SDK). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci(mcp-diff): pin to mcp-server-diff v3.0.0 Release is out; move both jobs from the 3.0-branch SHA to v3.0.0 (3521651, full SHA since Actions rejects short SHAs). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * test(github): enforce owner/repo header projection across all tools Export HeaderParams + AnnotateHeaderParams and add a coverage test over the full all-toolsets inventory asserting every owner/repo param projects to its Mcp-Param-* header. Guards the remote proxy's per-request header read so a new tool can never silently ship without it (would fall back to body re-parsing). Adding a future routing param is one entry in inventory.HeaderParams. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(inventory): clone schema before header annotation to avoid shared-map race AnnotateHeaderParams mutated the *jsonschema.Schema (and per-property Extra maps) shared with the original tool definition via the caller's shallow copy. Under per-request registration (remote server), concurrent requests could race on — and fatally panic from — the same Extra map. Now clone only what we touch (schema value, Properties map, annotated property schemas + their Extra maps); the original is never written. Adds a no-mutation test and a 64-goroutine race regression (go test -race clean). Addresses Copilot review on #2787. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] --- .github/workflows/mcp-diff.yml | 10 +++- go.mod | 4 +- go.sum | 8 ++- pkg/github/__toolsnaps__/actions_get.snap | 1 + pkg/github/__toolsnaps__/actions_list.snap | 1 + .../__toolsnaps__/actions_run_trigger.snap | 2 + .../add_comment_to_pending_review.snap | 2 + .../__toolsnaps__/add_issue_comment.snap | 2 + .../add_issue_comment_reaction.snap | 2 + .../__toolsnaps__/add_issue_reaction.snap | 2 + .../add_pull_request_review_comment.snap | 2 + ..._pull_request_review_comment_reaction.snap | 2 + .../add_reply_to_pull_request_comment.snap | 2 + pkg/github/__toolsnaps__/add_sub_issue.snap | 2 + .../assign_copilot_to_issue.snap | 1 + pkg/github/__toolsnaps__/create_branch.snap | 2 + pkg/github/__toolsnaps__/create_gist.snap | 2 + pkg/github/__toolsnaps__/create_issue.snap | 2 + .../__toolsnaps__/create_or_update_file.snap | 2 + .../__toolsnaps__/create_pull_request.snap | 2 + .../create_pull_request_review.snap | 2 + .../__toolsnaps__/create_repository.snap | 2 + pkg/github/__toolsnaps__/delete_file.snap | 2 + .../delete_pending_pull_request_review.snap | 2 + .../discussion_comment_write.snap | 2 + .../__toolsnaps__/dismiss_notification.snap | 2 + pkg/github/__toolsnaps__/fork_repository.snap | 2 + .../get_code_quality_finding.snap | 1 + .../get_code_scanning_alert.snap | 1 + pkg/github/__toolsnaps__/get_commit.snap | 1 + .../__toolsnaps__/get_dependabot_alert.snap | 1 + pkg/github/__toolsnaps__/get_discussion.snap | 1 + .../get_discussion_comments.snap | 1 + pkg/github/__toolsnaps__/get_file_blame.snap | 1 + .../__toolsnaps__/get_file_contents.snap | 1 + pkg/github/__toolsnaps__/get_gist.snap | 1 + .../get_global_security_advisory.snap | 1 + pkg/github/__toolsnaps__/get_label.snap | 1 + .../__toolsnaps__/get_latest_release.snap | 1 + pkg/github/__toolsnaps__/get_me.snap | 1 + .../get_notification_details.snap | 1 + .../__toolsnaps__/get_release_by_tag.snap | 1 + .../__toolsnaps__/get_repository_tree.snap | 1 + .../get_secret_scanning_alert.snap | 1 + pkg/github/__toolsnaps__/get_tag.snap | 1 + .../__toolsnaps__/get_team_members.snap | 1 + pkg/github/__toolsnaps__/get_teams.snap | 1 + ...dependency_read_ff_issue_dependencies.snap | 1 + ...ependency_write_ff_issue_dependencies.snap | 2 + pkg/github/__toolsnaps__/issue_read.snap | 1 + pkg/github/__toolsnaps__/issue_write.snap | 2 + pkg/github/__toolsnaps__/label_write.snap | 2 + pkg/github/__toolsnaps__/list_branches.snap | 1 + .../list_code_scanning_alerts.snap | 1 + pkg/github/__toolsnaps__/list_commits.snap | 1 + .../__toolsnaps__/list_dependabot_alerts.snap | 1 + .../list_discussion_categories.snap | 1 + .../__toolsnaps__/list_discussions.snap | 1 + pkg/github/__toolsnaps__/list_gists.snap | 1 + .../list_global_security_advisories.snap | 1 + .../__toolsnaps__/list_issue_fields.snap | 1 + .../__toolsnaps__/list_issue_types.snap | 1 + pkg/github/__toolsnaps__/list_issues.snap | 1 + pkg/github/__toolsnaps__/list_label.snap | 1 + .../__toolsnaps__/list_notifications.snap | 1 + ...st_org_repository_security_advisories.snap | 1 + .../__toolsnaps__/list_pull_requests.snap | 1 + pkg/github/__toolsnaps__/list_releases.snap | 1 + .../list_repository_collaborators.snap | 1 + .../list_repository_security_advisories.snap | 1 + .../list_secret_scanning_alerts.snap | 1 + .../list_starred_repositories.snap | 1 + pkg/github/__toolsnaps__/list_tags.snap | 1 + .../manage_notification_subscription.snap | 2 + ..._repository_notification_subscription.snap | 2 + .../mark_all_notifications_read.snap | 2 + .../__toolsnaps__/merge_pull_request.snap | 2 + pkg/github/__toolsnaps__/projects_get.snap | 1 + pkg/github/__toolsnaps__/projects_list.snap | 1 + pkg/github/__toolsnaps__/projects_write.snap | 2 + .../__toolsnaps__/pull_request_read.snap | 1 + .../pull_request_review_write.snap | 2 + pkg/github/__toolsnaps__/push_files.snap | 2 + .../__toolsnaps__/remove_sub_issue.snap | 2 + .../__toolsnaps__/reprioritize_sub_issue.snap | 2 + .../__toolsnaps__/request_copilot_review.snap | 2 + .../request_pull_request_reviewers.snap | 2 + .../__toolsnaps__/resolve_review_thread.snap | 2 + pkg/github/__toolsnaps__/search_code.snap | 1 + pkg/github/__toolsnaps__/search_commits.snap | 1 + pkg/github/__toolsnaps__/search_issues.snap | 1 + pkg/github/__toolsnaps__/search_orgs.snap | 1 + .../__toolsnaps__/search_pull_requests.snap | 1 + .../__toolsnaps__/search_repositories.snap | 1 + pkg/github/__toolsnaps__/search_users.snap | 1 + .../__toolsnaps__/set_issue_fields.snap | 2 + pkg/github/__toolsnaps__/star_repository.snap | 2 + pkg/github/__toolsnaps__/sub_issue_write.snap | 2 + .../submit_pending_pull_request_review.snap | 2 + pkg/github/__toolsnaps__/ui_get.snap | 1 + .../unresolve_review_thread.snap | 2 + .../__toolsnaps__/unstar_repository.snap | 2 + pkg/github/__toolsnaps__/update_gist.snap | 2 + .../__toolsnaps__/update_issue_assignees.snap | 2 + .../__toolsnaps__/update_issue_body.snap | 2 + .../__toolsnaps__/update_issue_labels.snap | 2 + .../__toolsnaps__/update_issue_milestone.snap | 2 + .../__toolsnaps__/update_issue_state.snap | 2 + .../__toolsnaps__/update_issue_title.snap | 2 + .../__toolsnaps__/update_issue_type.snap | 2 + .../__toolsnaps__/update_pull_request.snap | 2 + .../update_pull_request_body.snap | 2 + .../update_pull_request_branch.snap | 2 + .../update_pull_request_draft_state.snap | 2 + .../update_pull_request_state.snap | 2 + .../update_pull_request_title.snap | 2 + pkg/github/header_params_test.go | 44 ++++++++++++++ pkg/inventory/registry.go | 9 ++- pkg/inventory/registry_test.go | 13 +++++ pkg/inventory/server_tool.go | 57 ++++++++++++++++++ pkg/inventory/server_tool_test.go | 58 +++++++++++++++++++ third-party-licenses.darwin.md | 6 +- third-party-licenses.linux.md | 6 +- third-party-licenses.windows.md | 6 +- .../golang.org/x/sync/errgroup/LICENSE | 27 +++++++++ third-party/golang.org/x/time/rate/LICENSE | 27 +++++++++ 126 files changed, 431 insertions(+), 13 deletions(-) create mode 100644 pkg/github/header_params_test.go create mode 100644 third-party/golang.org/x/sync/errgroup/LICENSE create mode 100644 third-party/golang.org/x/time/rate/LICENSE diff --git a/.github/workflows/mcp-diff.yml b/.github/workflows/mcp-diff.yml index c0813bc3dc..eeb24eaea1 100644 --- a/.github/workflows/mcp-diff.yml +++ b/.github/workflows/mcp-diff.yml @@ -50,7 +50,12 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Run MCP Server Diff - uses: SamMorrowDrums/mcp-server-diff@v2.3.5 + # Pinned to the cross-spec-aware mcp-server-diff v3.0.0 (full SHA required — + # Actions rejects shortened SHAs): normalizes _meta plumbing, cache hints, + # initialize envelope, tool-annotation default hints; probes each server at its + # own newest spec via the stateless SEP-2575 server/discover path. Keeps the + # go-sdk v1.6.1 -> v1.7.0-pre.1 bump an honest, signal-only diff. github/copilot-mcp-core#1709. + uses: SamMorrowDrums/mcp-server-diff@3521651bb0d3cc267a23df8d94a2a645556980a4 # v3.0.0 with: setup_go: "false" install_command: | @@ -114,7 +119,8 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Run MCP Server Diff (streamable-http) - uses: SamMorrowDrums/mcp-server-diff@v2.3.5 + # Pinned to mcp-server-diff v3.0.0 — see rationale on the stdio job above. + uses: SamMorrowDrums/mcp-server-diff@3521651bb0d3cc267a23df8d94a2a645556980a4 # v3.0.0 with: setup_go: "false" install_command: | diff --git a/go.mod b/go.mod index 66c7a974ad..c2990f718a 100644 --- a/go.mod +++ b/go.mod @@ -10,7 +10,7 @@ require ( github.com/josephburnett/jd/v2 v2.5.0 github.com/lithammer/fuzzysearch v1.1.8 github.com/microcosm-cc/bluemonday v1.0.27 - github.com/modelcontextprotocol/go-sdk v1.6.1 + github.com/modelcontextprotocol/go-sdk v1.7.0-pre.1 github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021 github.com/shurcooL/githubv4 v0.0.0-20240727222349-48295856cce7 github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 @@ -41,8 +41,10 @@ require ( github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.38.0 // indirect + golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.28.0 // indirect + golang.org/x/time v0.15.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 fbf06018f7..10b57f6f89 100644 --- a/go.sum +++ b/go.sum @@ -39,8 +39,8 @@ github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8 github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= 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/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= -github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= +github.com/modelcontextprotocol/go-sdk v1.7.0-pre.1 h1:GlMIJyMHFX76bBSQuBCLXZ7pB9cGh4VBS6O5wGd0tgI= +github.com/modelcontextprotocol/go-sdk v1.7.0-pre.1/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021 h1:31Y+Yu373ymebRdJN1cWLLooHH8xAr0MhKTEJGV/87g= github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021/go.mod h1:WERUkUryfUWlrHnFSO/BEUZ+7Ns8aZy7iVOGewxKzcc= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= @@ -99,6 +99,8 @@ golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwE golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -117,6 +119,8 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 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.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= diff --git a/pkg/github/__toolsnaps__/actions_get.snap b/pkg/github/__toolsnaps__/actions_get.snap index ba128875ee..661f379f5f 100644 --- a/pkg/github/__toolsnaps__/actions_get.snap +++ b/pkg/github/__toolsnaps__/actions_get.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Get details of GitHub Actions resources (workflows, workflow runs, jobs, and artifacts)" }, diff --git a/pkg/github/__toolsnaps__/actions_list.snap b/pkg/github/__toolsnaps__/actions_list.snap index a7e9ec56bd..be97affbdb 100644 --- a/pkg/github/__toolsnaps__/actions_list.snap +++ b/pkg/github/__toolsnaps__/actions_list.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "List GitHub Actions workflows in a repository" }, diff --git a/pkg/github/__toolsnaps__/actions_run_trigger.snap b/pkg/github/__toolsnaps__/actions_run_trigger.snap index 41a6439929..0c859b86ec 100644 --- a/pkg/github/__toolsnaps__/actions_run_trigger.snap +++ b/pkg/github/__toolsnaps__/actions_run_trigger.snap @@ -1,6 +1,8 @@ { "annotations": { "destructiveHint": true, + "idempotentHint": false, + "readOnlyHint": false, "title": "Trigger GitHub Actions workflow actions" }, "description": "Trigger GitHub Actions workflow operations, including running, re-running, cancelling workflow runs, and deleting workflow run logs.", diff --git a/pkg/github/__toolsnaps__/add_comment_to_pending_review.snap b/pkg/github/__toolsnaps__/add_comment_to_pending_review.snap index af4c41f52f..4a6e6ca9ba 100644 --- a/pkg/github/__toolsnaps__/add_comment_to_pending_review.snap +++ b/pkg/github/__toolsnaps__/add_comment_to_pending_review.snap @@ -1,5 +1,7 @@ { "annotations": { + "idempotentHint": false, + "readOnlyHint": false, "title": "Add review comment to the requester's latest pending pull request review" }, "description": "Add review comment to the requester's latest pending pull request review. A pending review needs to already exist to call this (check with the user if not sure).", diff --git a/pkg/github/__toolsnaps__/add_issue_comment.snap b/pkg/github/__toolsnaps__/add_issue_comment.snap index 356edac2f1..f59431816e 100644 --- a/pkg/github/__toolsnaps__/add_issue_comment.snap +++ b/pkg/github/__toolsnaps__/add_issue_comment.snap @@ -1,5 +1,7 @@ { "annotations": { + "idempotentHint": false, + "readOnlyHint": false, "title": "Add comment to issue or pull request" }, "description": "Add a comment and/or reaction to a specific issue or issue comment in a GitHub repository. Use this tool with pull requests as well (in this case pass pull request number as issue_number), but only if user is not asking specifically to add or react to review comments. At least one of body or reaction is required.", diff --git a/pkg/github/__toolsnaps__/add_issue_comment_reaction.snap b/pkg/github/__toolsnaps__/add_issue_comment_reaction.snap index 5e2953a46c..fea8434a62 100644 --- a/pkg/github/__toolsnaps__/add_issue_comment_reaction.snap +++ b/pkg/github/__toolsnaps__/add_issue_comment_reaction.snap @@ -1,7 +1,9 @@ { "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": true, + "readOnlyHint": false, "title": "Add Reaction to Issue or Pull Request Comment" }, "description": "Add a reaction to an issue or pull request comment.", diff --git a/pkg/github/__toolsnaps__/add_issue_reaction.snap b/pkg/github/__toolsnaps__/add_issue_reaction.snap index db1148dee8..49175d1412 100644 --- a/pkg/github/__toolsnaps__/add_issue_reaction.snap +++ b/pkg/github/__toolsnaps__/add_issue_reaction.snap @@ -1,7 +1,9 @@ { "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": true, + "readOnlyHint": false, "title": "Add Reaction to Issue or Pull Request" }, "description": "Add a reaction to an issue or pull request.", diff --git a/pkg/github/__toolsnaps__/add_pull_request_review_comment.snap b/pkg/github/__toolsnaps__/add_pull_request_review_comment.snap index 1e27c5645e..ae7450539d 100644 --- a/pkg/github/__toolsnaps__/add_pull_request_review_comment.snap +++ b/pkg/github/__toolsnaps__/add_pull_request_review_comment.snap @@ -1,7 +1,9 @@ { "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": true, + "readOnlyHint": false, "title": "Add Pull Request Review Comment" }, "description": "Add a review comment to the current user's pending pull request review.", diff --git a/pkg/github/__toolsnaps__/add_pull_request_review_comment_reaction.snap b/pkg/github/__toolsnaps__/add_pull_request_review_comment_reaction.snap index e5fed3b013..aec4b38465 100644 --- a/pkg/github/__toolsnaps__/add_pull_request_review_comment_reaction.snap +++ b/pkg/github/__toolsnaps__/add_pull_request_review_comment_reaction.snap @@ -1,7 +1,9 @@ { "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": true, + "readOnlyHint": false, "title": "Add Pull Request Review Comment Reaction" }, "description": "Add a reaction to a pull request review comment.", diff --git a/pkg/github/__toolsnaps__/add_reply_to_pull_request_comment.snap b/pkg/github/__toolsnaps__/add_reply_to_pull_request_comment.snap index a22720dcb6..a86ea3d4b5 100644 --- a/pkg/github/__toolsnaps__/add_reply_to_pull_request_comment.snap +++ b/pkg/github/__toolsnaps__/add_reply_to_pull_request_comment.snap @@ -1,5 +1,7 @@ { "annotations": { + "idempotentHint": false, + "readOnlyHint": false, "title": "Add reply to pull request comment" }, "description": "Add a reply and/or reaction to an existing pull request comment. This can create a new comment linked as a reply to the specified comment, add an emoji reaction to the specified comment, or do both. At least one of body or reaction is required.", diff --git a/pkg/github/__toolsnaps__/add_sub_issue.snap b/pkg/github/__toolsnaps__/add_sub_issue.snap index ef9df400c6..a232507d4f 100644 --- a/pkg/github/__toolsnaps__/add_sub_issue.snap +++ b/pkg/github/__toolsnaps__/add_sub_issue.snap @@ -1,7 +1,9 @@ { "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": true, + "readOnlyHint": false, "title": "Add Sub-Issue" }, "description": "Add a sub-issue to a parent issue.", diff --git a/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap b/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap index 9c105267bc..994d9f5709 100644 --- a/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap +++ b/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap @@ -1,6 +1,7 @@ { "annotations": { "idempotentHint": true, + "readOnlyHint": false, "title": "Assign Copilot to issue" }, "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", diff --git a/pkg/github/__toolsnaps__/create_branch.snap b/pkg/github/__toolsnaps__/create_branch.snap index a561247ef1..e796405140 100644 --- a/pkg/github/__toolsnaps__/create_branch.snap +++ b/pkg/github/__toolsnaps__/create_branch.snap @@ -1,5 +1,7 @@ { "annotations": { + "idempotentHint": false, + "readOnlyHint": false, "title": "Create branch" }, "description": "Create a new branch in a GitHub repository", diff --git a/pkg/github/__toolsnaps__/create_gist.snap b/pkg/github/__toolsnaps__/create_gist.snap index 0ef05aa4a7..f91810bd00 100644 --- a/pkg/github/__toolsnaps__/create_gist.snap +++ b/pkg/github/__toolsnaps__/create_gist.snap @@ -1,5 +1,7 @@ { "annotations": { + "idempotentHint": false, + "readOnlyHint": false, "title": "Create Gist" }, "description": "Create a new gist", diff --git a/pkg/github/__toolsnaps__/create_issue.snap b/pkg/github/__toolsnaps__/create_issue.snap index 51923c47cc..e0963741ac 100644 --- a/pkg/github/__toolsnaps__/create_issue.snap +++ b/pkg/github/__toolsnaps__/create_issue.snap @@ -1,7 +1,9 @@ { "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": true, + "readOnlyHint": false, "title": "Create Issue" }, "description": "Create a new issue in a GitHub repository with a title and optional body.", diff --git a/pkg/github/__toolsnaps__/create_or_update_file.snap b/pkg/github/__toolsnaps__/create_or_update_file.snap index e6900c9053..8feae6f934 100644 --- a/pkg/github/__toolsnaps__/create_or_update_file.snap +++ b/pkg/github/__toolsnaps__/create_or_update_file.snap @@ -1,5 +1,7 @@ { "annotations": { + "idempotentHint": false, + "readOnlyHint": false, "title": "Create or update file" }, "description": "Create or update a single file in a GitHub repository. \nIf updating, you should provide the SHA of the file you want to update. Use this tool to create or update a file in a GitHub repository remotely; do not use it for local file operations.\n\nIn order to obtain the SHA of original file version before updating, use the following git command:\ngit rev-parse \u003cbranch\u003e:\u003cpath to file\u003e\n\nSHA MUST be provided for existing file updates.\n", diff --git a/pkg/github/__toolsnaps__/create_pull_request.snap b/pkg/github/__toolsnaps__/create_pull_request.snap index 3bd3404d49..acb74bcd7a 100644 --- a/pkg/github/__toolsnaps__/create_pull_request.snap +++ b/pkg/github/__toolsnaps__/create_pull_request.snap @@ -9,6 +9,8 @@ } }, "annotations": { + "idempotentHint": false, + "readOnlyHint": false, "title": "Open new pull request" }, "description": "Create a new pull request in a GitHub repository.", diff --git a/pkg/github/__toolsnaps__/create_pull_request_review.snap b/pkg/github/__toolsnaps__/create_pull_request_review.snap index 1986b2cfff..f8bf3f12a8 100644 --- a/pkg/github/__toolsnaps__/create_pull_request_review.snap +++ b/pkg/github/__toolsnaps__/create_pull_request_review.snap @@ -1,7 +1,9 @@ { "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": true, + "readOnlyHint": false, "title": "Create Pull Request Review" }, "description": "Create a review on a pull request. If event is provided, the review is submitted immediately; otherwise a pending review is created.", diff --git a/pkg/github/__toolsnaps__/create_repository.snap b/pkg/github/__toolsnaps__/create_repository.snap index 0aa2123673..d13cc69aa1 100644 --- a/pkg/github/__toolsnaps__/create_repository.snap +++ b/pkg/github/__toolsnaps__/create_repository.snap @@ -1,5 +1,7 @@ { "annotations": { + "idempotentHint": false, + "readOnlyHint": false, "title": "Create repository" }, "description": "Create a new GitHub repository in your account or specified organization", diff --git a/pkg/github/__toolsnaps__/delete_file.snap b/pkg/github/__toolsnaps__/delete_file.snap index ff110ff786..4885c891d1 100644 --- a/pkg/github/__toolsnaps__/delete_file.snap +++ b/pkg/github/__toolsnaps__/delete_file.snap @@ -1,6 +1,8 @@ { "annotations": { "destructiveHint": true, + "idempotentHint": false, + "readOnlyHint": false, "title": "Delete file" }, "description": "Delete a file from a GitHub repository", diff --git a/pkg/github/__toolsnaps__/delete_pending_pull_request_review.snap b/pkg/github/__toolsnaps__/delete_pending_pull_request_review.snap index b457e415a8..f99f5d5632 100644 --- a/pkg/github/__toolsnaps__/delete_pending_pull_request_review.snap +++ b/pkg/github/__toolsnaps__/delete_pending_pull_request_review.snap @@ -1,7 +1,9 @@ { "annotations": { "destructiveHint": true, + "idempotentHint": false, "openWorldHint": true, + "readOnlyHint": false, "title": "Delete Pending Pull Request Review" }, "description": "Delete a pending pull request review.", diff --git a/pkg/github/__toolsnaps__/discussion_comment_write.snap b/pkg/github/__toolsnaps__/discussion_comment_write.snap index 5edadfaeaa..eb730b48e7 100644 --- a/pkg/github/__toolsnaps__/discussion_comment_write.snap +++ b/pkg/github/__toolsnaps__/discussion_comment_write.snap @@ -1,6 +1,8 @@ { "annotations": { "destructiveHint": true, + "idempotentHint": false, + "readOnlyHint": false, "title": "Manage discussion comments" }, "description": "Write operations for discussion comments.\nSupports adding top-level comments, replying to existing comments, updating comment content, deleting comments, and marking or unmarking comments as the answer.", diff --git a/pkg/github/__toolsnaps__/dismiss_notification.snap b/pkg/github/__toolsnaps__/dismiss_notification.snap index 6c65d9ce05..6b2e38afd7 100644 --- a/pkg/github/__toolsnaps__/dismiss_notification.snap +++ b/pkg/github/__toolsnaps__/dismiss_notification.snap @@ -1,5 +1,7 @@ { "annotations": { + "idempotentHint": false, + "readOnlyHint": false, "title": "Dismiss notification" }, "description": "Dismiss a notification by marking it as read or done", diff --git a/pkg/github/__toolsnaps__/fork_repository.snap b/pkg/github/__toolsnaps__/fork_repository.snap index d635734a95..1373ef0519 100644 --- a/pkg/github/__toolsnaps__/fork_repository.snap +++ b/pkg/github/__toolsnaps__/fork_repository.snap @@ -1,5 +1,7 @@ { "annotations": { + "idempotentHint": false, + "readOnlyHint": false, "title": "Fork repository" }, "description": "Fork a GitHub repository to your account or specified organization", diff --git a/pkg/github/__toolsnaps__/get_code_quality_finding.snap b/pkg/github/__toolsnaps__/get_code_quality_finding.snap index 378efe835d..84ccb948e4 100644 --- a/pkg/github/__toolsnaps__/get_code_quality_finding.snap +++ b/pkg/github/__toolsnaps__/get_code_quality_finding.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Get code quality finding" }, diff --git a/pkg/github/__toolsnaps__/get_code_scanning_alert.snap b/pkg/github/__toolsnaps__/get_code_scanning_alert.snap index 2a65aefa64..907870b03d 100644 --- a/pkg/github/__toolsnaps__/get_code_scanning_alert.snap +++ b/pkg/github/__toolsnaps__/get_code_scanning_alert.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Get code scanning alert" }, diff --git a/pkg/github/__toolsnaps__/get_commit.snap b/pkg/github/__toolsnaps__/get_commit.snap index 122e6210b3..ad6a805515 100644 --- a/pkg/github/__toolsnaps__/get_commit.snap +++ b/pkg/github/__toolsnaps__/get_commit.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Get commit details" }, diff --git a/pkg/github/__toolsnaps__/get_dependabot_alert.snap b/pkg/github/__toolsnaps__/get_dependabot_alert.snap index 78ff827d25..383e86a29e 100644 --- a/pkg/github/__toolsnaps__/get_dependabot_alert.snap +++ b/pkg/github/__toolsnaps__/get_dependabot_alert.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Get dependabot alert" }, diff --git a/pkg/github/__toolsnaps__/get_discussion.snap b/pkg/github/__toolsnaps__/get_discussion.snap index b931afe79a..3f538d4e97 100644 --- a/pkg/github/__toolsnaps__/get_discussion.snap +++ b/pkg/github/__toolsnaps__/get_discussion.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Get discussion" }, diff --git a/pkg/github/__toolsnaps__/get_discussion_comments.snap b/pkg/github/__toolsnaps__/get_discussion_comments.snap index 0dcd7343e7..a2705c9bf1 100644 --- a/pkg/github/__toolsnaps__/get_discussion_comments.snap +++ b/pkg/github/__toolsnaps__/get_discussion_comments.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Get discussion comments" }, diff --git a/pkg/github/__toolsnaps__/get_file_blame.snap b/pkg/github/__toolsnaps__/get_file_blame.snap index 83d09f265c..33a09fea88 100644 --- a/pkg/github/__toolsnaps__/get_file_blame.snap +++ b/pkg/github/__toolsnaps__/get_file_blame.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Get file blame information" }, diff --git a/pkg/github/__toolsnaps__/get_file_contents.snap b/pkg/github/__toolsnaps__/get_file_contents.snap index 94b7aeedac..ea317f6f14 100644 --- a/pkg/github/__toolsnaps__/get_file_contents.snap +++ b/pkg/github/__toolsnaps__/get_file_contents.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Get file or directory contents" }, diff --git a/pkg/github/__toolsnaps__/get_gist.snap b/pkg/github/__toolsnaps__/get_gist.snap index ef316937fb..b4c053b549 100644 --- a/pkg/github/__toolsnaps__/get_gist.snap +++ b/pkg/github/__toolsnaps__/get_gist.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Get Gist Content" }, diff --git a/pkg/github/__toolsnaps__/get_global_security_advisory.snap b/pkg/github/__toolsnaps__/get_global_security_advisory.snap index 97b81d17dd..74ec794740 100644 --- a/pkg/github/__toolsnaps__/get_global_security_advisory.snap +++ b/pkg/github/__toolsnaps__/get_global_security_advisory.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Get a global security advisory" }, diff --git a/pkg/github/__toolsnaps__/get_label.snap b/pkg/github/__toolsnaps__/get_label.snap index 379ca7d8df..b258038506 100644 --- a/pkg/github/__toolsnaps__/get_label.snap +++ b/pkg/github/__toolsnaps__/get_label.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Get a specific label from a repository" }, diff --git a/pkg/github/__toolsnaps__/get_latest_release.snap b/pkg/github/__toolsnaps__/get_latest_release.snap index 760d8f812b..4d4f0d103e 100644 --- a/pkg/github/__toolsnaps__/get_latest_release.snap +++ b/pkg/github/__toolsnaps__/get_latest_release.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Get latest release" }, diff --git a/pkg/github/__toolsnaps__/get_me.snap b/pkg/github/__toolsnaps__/get_me.snap index 6f287df092..4196c6734d 100644 --- a/pkg/github/__toolsnaps__/get_me.snap +++ b/pkg/github/__toolsnaps__/get_me.snap @@ -9,6 +9,7 @@ } }, "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Get my user profile" }, diff --git a/pkg/github/__toolsnaps__/get_notification_details.snap b/pkg/github/__toolsnaps__/get_notification_details.snap index 48842229ff..2929ec2fc9 100644 --- a/pkg/github/__toolsnaps__/get_notification_details.snap +++ b/pkg/github/__toolsnaps__/get_notification_details.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Get notification details" }, diff --git a/pkg/github/__toolsnaps__/get_release_by_tag.snap b/pkg/github/__toolsnaps__/get_release_by_tag.snap index 6e6d30e980..60e0a3a30b 100644 --- a/pkg/github/__toolsnaps__/get_release_by_tag.snap +++ b/pkg/github/__toolsnaps__/get_release_by_tag.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Get a release by tag name" }, diff --git a/pkg/github/__toolsnaps__/get_repository_tree.snap b/pkg/github/__toolsnaps__/get_repository_tree.snap index c810d1e209..6ba368f2f2 100644 --- a/pkg/github/__toolsnaps__/get_repository_tree.snap +++ b/pkg/github/__toolsnaps__/get_repository_tree.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Get repository tree" }, diff --git a/pkg/github/__toolsnaps__/get_secret_scanning_alert.snap b/pkg/github/__toolsnaps__/get_secret_scanning_alert.snap index 2789cfbabc..7d04cc5ca9 100644 --- a/pkg/github/__toolsnaps__/get_secret_scanning_alert.snap +++ b/pkg/github/__toolsnaps__/get_secret_scanning_alert.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Get secret scanning alert" }, diff --git a/pkg/github/__toolsnaps__/get_tag.snap b/pkg/github/__toolsnaps__/get_tag.snap index 126e8a9996..bb2751c970 100644 --- a/pkg/github/__toolsnaps__/get_tag.snap +++ b/pkg/github/__toolsnaps__/get_tag.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Get tag details" }, diff --git a/pkg/github/__toolsnaps__/get_team_members.snap b/pkg/github/__toolsnaps__/get_team_members.snap index 4cde7237c1..a95be1ceda 100644 --- a/pkg/github/__toolsnaps__/get_team_members.snap +++ b/pkg/github/__toolsnaps__/get_team_members.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Get team members" }, diff --git a/pkg/github/__toolsnaps__/get_teams.snap b/pkg/github/__toolsnaps__/get_teams.snap index 946364bad8..49bc62ed9f 100644 --- a/pkg/github/__toolsnaps__/get_teams.snap +++ b/pkg/github/__toolsnaps__/get_teams.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Get teams" }, diff --git a/pkg/github/__toolsnaps__/issue_dependency_read_ff_issue_dependencies.snap b/pkg/github/__toolsnaps__/issue_dependency_read_ff_issue_dependencies.snap index 4a09d78713..37b4e8f113 100644 --- a/pkg/github/__toolsnaps__/issue_dependency_read_ff_issue_dependencies.snap +++ b/pkg/github/__toolsnaps__/issue_dependency_read_ff_issue_dependencies.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Read issue dependencies" }, diff --git a/pkg/github/__toolsnaps__/issue_dependency_write_ff_issue_dependencies.snap b/pkg/github/__toolsnaps__/issue_dependency_write_ff_issue_dependencies.snap index 0d1723b2f8..b907dff6cf 100644 --- a/pkg/github/__toolsnaps__/issue_dependency_write_ff_issue_dependencies.snap +++ b/pkg/github/__toolsnaps__/issue_dependency_write_ff_issue_dependencies.snap @@ -1,5 +1,7 @@ { "annotations": { + "idempotentHint": false, + "readOnlyHint": false, "title": "Change issue dependency" }, "description": "Add or remove an issue dependency relationship in a GitHub repository. Use type 'blocked_by' to record that the subject issue is blocked by a related issue, or type 'blocking' to record that the subject issue blocks a related issue. The related issue defaults to the same repository as the subject unless related_owner/related_repo are provided.", diff --git a/pkg/github/__toolsnaps__/issue_read.snap b/pkg/github/__toolsnaps__/issue_read.snap index 9b882c79b6..b5932fb107 100644 --- a/pkg/github/__toolsnaps__/issue_read.snap +++ b/pkg/github/__toolsnaps__/issue_read.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Get issue details" }, diff --git a/pkg/github/__toolsnaps__/issue_write.snap b/pkg/github/__toolsnaps__/issue_write.snap index da6a286887..55fd2dbcc2 100644 --- a/pkg/github/__toolsnaps__/issue_write.snap +++ b/pkg/github/__toolsnaps__/issue_write.snap @@ -9,6 +9,8 @@ } }, "annotations": { + "idempotentHint": false, + "readOnlyHint": false, "title": "Create or update issue/pull request" }, "description": "Create a new or update an existing issue in a GitHub repository.", diff --git a/pkg/github/__toolsnaps__/label_write.snap b/pkg/github/__toolsnaps__/label_write.snap index de4b98bef7..6eeb9fd730 100644 --- a/pkg/github/__toolsnaps__/label_write.snap +++ b/pkg/github/__toolsnaps__/label_write.snap @@ -1,5 +1,7 @@ { "annotations": { + "idempotentHint": false, + "readOnlyHint": false, "title": "Write operations on repository labels" }, "description": "Perform write operations on repository labels. To set labels on issues, use the 'update_issue' tool.", diff --git a/pkg/github/__toolsnaps__/list_branches.snap b/pkg/github/__toolsnaps__/list_branches.snap index 883a6fffcb..912b49d1ba 100644 --- a/pkg/github/__toolsnaps__/list_branches.snap +++ b/pkg/github/__toolsnaps__/list_branches.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "List branches" }, diff --git a/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap b/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap index 9eddf045d8..df67380a35 100644 --- a/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap +++ b/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "List code scanning alerts" }, diff --git a/pkg/github/__toolsnaps__/list_commits.snap b/pkg/github/__toolsnaps__/list_commits.snap index 1a773f217e..00cce882f1 100644 --- a/pkg/github/__toolsnaps__/list_commits.snap +++ b/pkg/github/__toolsnaps__/list_commits.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "List commits" }, diff --git a/pkg/github/__toolsnaps__/list_dependabot_alerts.snap b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap index 5fdbcd2e6f..ff602b13e1 100644 --- a/pkg/github/__toolsnaps__/list_dependabot_alerts.snap +++ b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "List dependabot alerts" }, diff --git a/pkg/github/__toolsnaps__/list_discussion_categories.snap b/pkg/github/__toolsnaps__/list_discussion_categories.snap index c46b75f84c..095973dc8b 100644 --- a/pkg/github/__toolsnaps__/list_discussion_categories.snap +++ b/pkg/github/__toolsnaps__/list_discussion_categories.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "List discussion categories" }, diff --git a/pkg/github/__toolsnaps__/list_discussions.snap b/pkg/github/__toolsnaps__/list_discussions.snap index fdf5f6d7a3..6a2afd7dc4 100644 --- a/pkg/github/__toolsnaps__/list_discussions.snap +++ b/pkg/github/__toolsnaps__/list_discussions.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "List discussions" }, diff --git a/pkg/github/__toolsnaps__/list_gists.snap b/pkg/github/__toolsnaps__/list_gists.snap index 3974173033..8f09ff3880 100644 --- a/pkg/github/__toolsnaps__/list_gists.snap +++ b/pkg/github/__toolsnaps__/list_gists.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "List Gists" }, diff --git a/pkg/github/__toolsnaps__/list_global_security_advisories.snap b/pkg/github/__toolsnaps__/list_global_security_advisories.snap index f714f47829..687248f6e7 100644 --- a/pkg/github/__toolsnaps__/list_global_security_advisories.snap +++ b/pkg/github/__toolsnaps__/list_global_security_advisories.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "List global security advisories" }, diff --git a/pkg/github/__toolsnaps__/list_issue_fields.snap b/pkg/github/__toolsnaps__/list_issue_fields.snap index 0eec8bc9e1..df9ae47ba6 100644 --- a/pkg/github/__toolsnaps__/list_issue_fields.snap +++ b/pkg/github/__toolsnaps__/list_issue_fields.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "List issue fields" }, diff --git a/pkg/github/__toolsnaps__/list_issue_types.snap b/pkg/github/__toolsnaps__/list_issue_types.snap index 283cb5a8de..c1ea3d515a 100644 --- a/pkg/github/__toolsnaps__/list_issue_types.snap +++ b/pkg/github/__toolsnaps__/list_issue_types.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "List available issue types" }, diff --git a/pkg/github/__toolsnaps__/list_issues.snap b/pkg/github/__toolsnaps__/list_issues.snap index 53a951846a..5c68c01497 100644 --- a/pkg/github/__toolsnaps__/list_issues.snap +++ b/pkg/github/__toolsnaps__/list_issues.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "List issues" }, diff --git a/pkg/github/__toolsnaps__/list_label.snap b/pkg/github/__toolsnaps__/list_label.snap index 9bf8a9f3e0..9aaf90f3b2 100644 --- a/pkg/github/__toolsnaps__/list_label.snap +++ b/pkg/github/__toolsnaps__/list_label.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "List labels from a repository" }, diff --git a/pkg/github/__toolsnaps__/list_notifications.snap b/pkg/github/__toolsnaps__/list_notifications.snap index bf25c4fe07..bb75d60427 100644 --- a/pkg/github/__toolsnaps__/list_notifications.snap +++ b/pkg/github/__toolsnaps__/list_notifications.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "List notifications" }, diff --git a/pkg/github/__toolsnaps__/list_org_repository_security_advisories.snap b/pkg/github/__toolsnaps__/list_org_repository_security_advisories.snap index 563da98c3c..3646d86c34 100644 --- a/pkg/github/__toolsnaps__/list_org_repository_security_advisories.snap +++ b/pkg/github/__toolsnaps__/list_org_repository_security_advisories.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "List org repository security advisories" }, diff --git a/pkg/github/__toolsnaps__/list_pull_requests.snap b/pkg/github/__toolsnaps__/list_pull_requests.snap index 25f1268c64..a94b6eaee1 100644 --- a/pkg/github/__toolsnaps__/list_pull_requests.snap +++ b/pkg/github/__toolsnaps__/list_pull_requests.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "List pull requests" }, diff --git a/pkg/github/__toolsnaps__/list_releases.snap b/pkg/github/__toolsnaps__/list_releases.snap index 57502c3c86..d905f32087 100644 --- a/pkg/github/__toolsnaps__/list_releases.snap +++ b/pkg/github/__toolsnaps__/list_releases.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "List releases" }, diff --git a/pkg/github/__toolsnaps__/list_repository_collaborators.snap b/pkg/github/__toolsnaps__/list_repository_collaborators.snap index 629e4bdf1c..5af768fe05 100644 --- a/pkg/github/__toolsnaps__/list_repository_collaborators.snap +++ b/pkg/github/__toolsnaps__/list_repository_collaborators.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "List repository collaborators" }, diff --git a/pkg/github/__toolsnaps__/list_repository_security_advisories.snap b/pkg/github/__toolsnaps__/list_repository_security_advisories.snap index c86508f927..50ec8db699 100644 --- a/pkg/github/__toolsnaps__/list_repository_security_advisories.snap +++ b/pkg/github/__toolsnaps__/list_repository_security_advisories.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "List repository security advisories" }, diff --git a/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap b/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap index 5c6a21a0ab..66aced9eac 100644 --- a/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap +++ b/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "List secret scanning alerts" }, diff --git a/pkg/github/__toolsnaps__/list_starred_repositories.snap b/pkg/github/__toolsnaps__/list_starred_repositories.snap index e631719fde..f4c37373e5 100644 --- a/pkg/github/__toolsnaps__/list_starred_repositories.snap +++ b/pkg/github/__toolsnaps__/list_starred_repositories.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "List starred repositories" }, diff --git a/pkg/github/__toolsnaps__/list_tags.snap b/pkg/github/__toolsnaps__/list_tags.snap index 1e66d2c1f6..6473040b1c 100644 --- a/pkg/github/__toolsnaps__/list_tags.snap +++ b/pkg/github/__toolsnaps__/list_tags.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "List tags" }, diff --git a/pkg/github/__toolsnaps__/manage_notification_subscription.snap b/pkg/github/__toolsnaps__/manage_notification_subscription.snap index e04acd11e8..603099200a 100644 --- a/pkg/github/__toolsnaps__/manage_notification_subscription.snap +++ b/pkg/github/__toolsnaps__/manage_notification_subscription.snap @@ -1,5 +1,7 @@ { "annotations": { + "idempotentHint": false, + "readOnlyHint": false, "title": "Manage notification subscription" }, "description": "Manage a notification subscription: ignore, watch, or delete a notification thread subscription.", diff --git a/pkg/github/__toolsnaps__/manage_repository_notification_subscription.snap b/pkg/github/__toolsnaps__/manage_repository_notification_subscription.snap index 0a4567b71c..1bd09afd14 100644 --- a/pkg/github/__toolsnaps__/manage_repository_notification_subscription.snap +++ b/pkg/github/__toolsnaps__/manage_repository_notification_subscription.snap @@ -1,5 +1,7 @@ { "annotations": { + "idempotentHint": false, + "readOnlyHint": false, "title": "Manage repository notification subscription" }, "description": "Manage a repository notification subscription: ignore, watch, or delete repository notifications subscription for the provided repository.", diff --git a/pkg/github/__toolsnaps__/mark_all_notifications_read.snap b/pkg/github/__toolsnaps__/mark_all_notifications_read.snap index 1f5a32284e..42de313e4a 100644 --- a/pkg/github/__toolsnaps__/mark_all_notifications_read.snap +++ b/pkg/github/__toolsnaps__/mark_all_notifications_read.snap @@ -1,5 +1,7 @@ { "annotations": { + "idempotentHint": false, + "readOnlyHint": false, "title": "Mark all notifications as read" }, "description": "Mark all notifications as read", diff --git a/pkg/github/__toolsnaps__/merge_pull_request.snap b/pkg/github/__toolsnaps__/merge_pull_request.snap index d0cdb2b1ad..4a5bdfd8b3 100644 --- a/pkg/github/__toolsnaps__/merge_pull_request.snap +++ b/pkg/github/__toolsnaps__/merge_pull_request.snap @@ -1,5 +1,7 @@ { "annotations": { + "idempotentHint": false, + "readOnlyHint": false, "title": "Merge pull request" }, "description": "Merge a pull request in a GitHub repository.", diff --git a/pkg/github/__toolsnaps__/projects_get.snap b/pkg/github/__toolsnaps__/projects_get.snap index 864f61d83f..c1b93a9291 100644 --- a/pkg/github/__toolsnaps__/projects_get.snap +++ b/pkg/github/__toolsnaps__/projects_get.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Get details of GitHub Projects resources" }, diff --git a/pkg/github/__toolsnaps__/projects_list.snap b/pkg/github/__toolsnaps__/projects_list.snap index c2bb0d3f49..bef9ec41d5 100644 --- a/pkg/github/__toolsnaps__/projects_list.snap +++ b/pkg/github/__toolsnaps__/projects_list.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "List GitHub Projects resources" }, diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap index 6c9d349f63..f01cd1368a 100644 --- a/pkg/github/__toolsnaps__/projects_write.snap +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -1,6 +1,8 @@ { "annotations": { "destructiveHint": true, + "idempotentHint": false, + "readOnlyHint": false, "title": "Manage GitHub Projects" }, "description": "Create and manage GitHub Projects: create projects, add/update/delete items, create status updates, and add iteration fields.", diff --git a/pkg/github/__toolsnaps__/pull_request_read.snap b/pkg/github/__toolsnaps__/pull_request_read.snap index f1bb855d51..41bc90b597 100644 --- a/pkg/github/__toolsnaps__/pull_request_read.snap +++ b/pkg/github/__toolsnaps__/pull_request_read.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Get details for a single pull request" }, diff --git a/pkg/github/__toolsnaps__/pull_request_review_write.snap b/pkg/github/__toolsnaps__/pull_request_review_write.snap index d4a7c30d32..74ef808559 100644 --- a/pkg/github/__toolsnaps__/pull_request_review_write.snap +++ b/pkg/github/__toolsnaps__/pull_request_review_write.snap @@ -1,5 +1,7 @@ { "annotations": { + "idempotentHint": false, + "readOnlyHint": false, "title": "Write operations (create, submit, delete) on pull request reviews" }, "description": "Create and/or submit, delete review of a pull request.\n\nAvailable methods:\n- create: Create a new review of a pull request. If \"event\" parameter is provided, the review is submitted. If \"event\" is omitted, a pending review is created.\n- submit_pending: Submit an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request. The \"body\" and \"event\" parameters are used when submitting the review.\n- delete_pending: Delete an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request.\n- resolve_thread: Resolve a review thread. Requires only \"threadId\" parameter with the thread's node ID (e.g., PRRT_kwDOxxx). The owner, repo, and pullNumber parameters are not used for this method. Resolving an already-resolved thread is a no-op.\n- unresolve_thread: Unresolve a previously resolved review thread. Requires only \"threadId\" parameter. The owner, repo, and pullNumber parameters are not used for this method. Unresolving an already-unresolved thread is a no-op.\n", diff --git a/pkg/github/__toolsnaps__/push_files.snap b/pkg/github/__toolsnaps__/push_files.snap index df6c4d1e79..798ad18451 100644 --- a/pkg/github/__toolsnaps__/push_files.snap +++ b/pkg/github/__toolsnaps__/push_files.snap @@ -1,5 +1,7 @@ { "annotations": { + "idempotentHint": false, + "readOnlyHint": false, "title": "Push files to repository" }, "description": "Push multiple files to a GitHub repository in a single commit", diff --git a/pkg/github/__toolsnaps__/remove_sub_issue.snap b/pkg/github/__toolsnaps__/remove_sub_issue.snap index 31fdcbb3e2..4f07c85f41 100644 --- a/pkg/github/__toolsnaps__/remove_sub_issue.snap +++ b/pkg/github/__toolsnaps__/remove_sub_issue.snap @@ -1,7 +1,9 @@ { "annotations": { "destructiveHint": true, + "idempotentHint": false, "openWorldHint": true, + "readOnlyHint": false, "title": "Remove Sub-Issue" }, "description": "Remove a sub-issue from a parent issue.", diff --git a/pkg/github/__toolsnaps__/reprioritize_sub_issue.snap b/pkg/github/__toolsnaps__/reprioritize_sub_issue.snap index d4e1ea4be4..bb981b74ea 100644 --- a/pkg/github/__toolsnaps__/reprioritize_sub_issue.snap +++ b/pkg/github/__toolsnaps__/reprioritize_sub_issue.snap @@ -1,7 +1,9 @@ { "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": true, + "readOnlyHint": false, "title": "Reprioritize Sub-Issue" }, "description": "Reprioritize (reorder) a sub-issue relative to other sub-issues.", diff --git a/pkg/github/__toolsnaps__/request_copilot_review.snap b/pkg/github/__toolsnaps__/request_copilot_review.snap index cd00f73fd4..5e8ecdca19 100644 --- a/pkg/github/__toolsnaps__/request_copilot_review.snap +++ b/pkg/github/__toolsnaps__/request_copilot_review.snap @@ -1,5 +1,7 @@ { "annotations": { + "idempotentHint": false, + "readOnlyHint": false, "title": "Request Copilot review" }, "description": "Request a GitHub Copilot code review for a pull request. Use this for automated feedback on pull requests, usually before requesting a human reviewer.", diff --git a/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap b/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap index 20f1ab62b6..607f540d1b 100644 --- a/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap +++ b/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap @@ -1,7 +1,9 @@ { "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": true, + "readOnlyHint": false, "title": "Request Pull Request Reviewers" }, "description": "Request reviewers for a pull request.", diff --git a/pkg/github/__toolsnaps__/resolve_review_thread.snap b/pkg/github/__toolsnaps__/resolve_review_thread.snap index afcd407841..5f2b21b36f 100644 --- a/pkg/github/__toolsnaps__/resolve_review_thread.snap +++ b/pkg/github/__toolsnaps__/resolve_review_thread.snap @@ -1,7 +1,9 @@ { "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": true, + "readOnlyHint": false, "title": "Resolve Review Thread" }, "description": "Resolve a review thread on a pull request. Resolving an already-resolved thread is a no-op.", diff --git a/pkg/github/__toolsnaps__/search_code.snap b/pkg/github/__toolsnaps__/search_code.snap index 79cbbf04e9..313c2f4c5f 100644 --- a/pkg/github/__toolsnaps__/search_code.snap +++ b/pkg/github/__toolsnaps__/search_code.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Search code" }, diff --git a/pkg/github/__toolsnaps__/search_commits.snap b/pkg/github/__toolsnaps__/search_commits.snap index 394bce9a1c..8221dee480 100644 --- a/pkg/github/__toolsnaps__/search_commits.snap +++ b/pkg/github/__toolsnaps__/search_commits.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Search commits" }, diff --git a/pkg/github/__toolsnaps__/search_issues.snap b/pkg/github/__toolsnaps__/search_issues.snap index beaa5b7376..a2ec55b911 100644 --- a/pkg/github/__toolsnaps__/search_issues.snap +++ b/pkg/github/__toolsnaps__/search_issues.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Search issues" }, diff --git a/pkg/github/__toolsnaps__/search_orgs.snap b/pkg/github/__toolsnaps__/search_orgs.snap index 9670a4be8f..9796c6105d 100644 --- a/pkg/github/__toolsnaps__/search_orgs.snap +++ b/pkg/github/__toolsnaps__/search_orgs.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Search organizations" }, diff --git a/pkg/github/__toolsnaps__/search_pull_requests.snap b/pkg/github/__toolsnaps__/search_pull_requests.snap index 05376c0065..2e33af03b3 100644 --- a/pkg/github/__toolsnaps__/search_pull_requests.snap +++ b/pkg/github/__toolsnaps__/search_pull_requests.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Search pull requests" }, diff --git a/pkg/github/__toolsnaps__/search_repositories.snap b/pkg/github/__toolsnaps__/search_repositories.snap index 8e1cb31714..23b1d5e839 100644 --- a/pkg/github/__toolsnaps__/search_repositories.snap +++ b/pkg/github/__toolsnaps__/search_repositories.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Search repositories" }, diff --git a/pkg/github/__toolsnaps__/search_users.snap b/pkg/github/__toolsnaps__/search_users.snap index bed86e8c6c..27bd2eb081 100644 --- a/pkg/github/__toolsnaps__/search_users.snap +++ b/pkg/github/__toolsnaps__/search_users.snap @@ -1,5 +1,6 @@ { "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Search users" }, diff --git a/pkg/github/__toolsnaps__/set_issue_fields.snap b/pkg/github/__toolsnaps__/set_issue_fields.snap index 7a98fde2aa..c5b89077ac 100644 --- a/pkg/github/__toolsnaps__/set_issue_fields.snap +++ b/pkg/github/__toolsnaps__/set_issue_fields.snap @@ -1,7 +1,9 @@ { "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": true, + "readOnlyHint": false, "title": "Set Issue Fields" }, "description": "Set issue field values for an issue. Fields are organization-level custom fields (text, number, date, or single select). Use this to create or update field values on an issue.", diff --git a/pkg/github/__toolsnaps__/star_repository.snap b/pkg/github/__toolsnaps__/star_repository.snap index 3d7088939b..4344d0ee06 100644 --- a/pkg/github/__toolsnaps__/star_repository.snap +++ b/pkg/github/__toolsnaps__/star_repository.snap @@ -1,5 +1,7 @@ { "annotations": { + "idempotentHint": false, + "readOnlyHint": false, "title": "Star repository" }, "description": "Star a GitHub repository", diff --git a/pkg/github/__toolsnaps__/sub_issue_write.snap b/pkg/github/__toolsnaps__/sub_issue_write.snap index 1e4fcceabf..3ebb012def 100644 --- a/pkg/github/__toolsnaps__/sub_issue_write.snap +++ b/pkg/github/__toolsnaps__/sub_issue_write.snap @@ -1,5 +1,7 @@ { "annotations": { + "idempotentHint": false, + "readOnlyHint": false, "title": "Change sub-issue" }, "description": "Add a sub-issue to a parent issue in a GitHub repository.", diff --git a/pkg/github/__toolsnaps__/submit_pending_pull_request_review.snap b/pkg/github/__toolsnaps__/submit_pending_pull_request_review.snap index 81223e2a9d..12438ffe71 100644 --- a/pkg/github/__toolsnaps__/submit_pending_pull_request_review.snap +++ b/pkg/github/__toolsnaps__/submit_pending_pull_request_review.snap @@ -1,7 +1,9 @@ { "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": true, + "readOnlyHint": false, "title": "Submit Pending Pull Request Review" }, "description": "Submit a pending pull request review.", diff --git a/pkg/github/__toolsnaps__/ui_get.snap b/pkg/github/__toolsnaps__/ui_get.snap index 7f13d97c1c..85b562156d 100644 --- a/pkg/github/__toolsnaps__/ui_get.snap +++ b/pkg/github/__toolsnaps__/ui_get.snap @@ -7,6 +7,7 @@ } }, "annotations": { + "idempotentHint": false, "readOnlyHint": true, "title": "Get UI data" }, diff --git a/pkg/github/__toolsnaps__/unresolve_review_thread.snap b/pkg/github/__toolsnaps__/unresolve_review_thread.snap index d58ba31a6f..fe40bab3e9 100644 --- a/pkg/github/__toolsnaps__/unresolve_review_thread.snap +++ b/pkg/github/__toolsnaps__/unresolve_review_thread.snap @@ -1,7 +1,9 @@ { "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": true, + "readOnlyHint": false, "title": "Unresolve Review Thread" }, "description": "Unresolve a previously resolved review thread on a pull request. Unresolving an already-unresolved thread is a no-op.", diff --git a/pkg/github/__toolsnaps__/unstar_repository.snap b/pkg/github/__toolsnaps__/unstar_repository.snap index 2bb5d68259..ac8262e22b 100644 --- a/pkg/github/__toolsnaps__/unstar_repository.snap +++ b/pkg/github/__toolsnaps__/unstar_repository.snap @@ -1,5 +1,7 @@ { "annotations": { + "idempotentHint": false, + "readOnlyHint": false, "title": "Unstar repository" }, "description": "Unstar a GitHub repository", diff --git a/pkg/github/__toolsnaps__/update_gist.snap b/pkg/github/__toolsnaps__/update_gist.snap index 6d5ed100e4..c004a8bf14 100644 --- a/pkg/github/__toolsnaps__/update_gist.snap +++ b/pkg/github/__toolsnaps__/update_gist.snap @@ -1,5 +1,7 @@ { "annotations": { + "idempotentHint": false, + "readOnlyHint": false, "title": "Update Gist" }, "description": "Update an existing gist", diff --git a/pkg/github/__toolsnaps__/update_issue_assignees.snap b/pkg/github/__toolsnaps__/update_issue_assignees.snap index 9c7261c9aa..ab23f72a95 100644 --- a/pkg/github/__toolsnaps__/update_issue_assignees.snap +++ b/pkg/github/__toolsnaps__/update_issue_assignees.snap @@ -1,7 +1,9 @@ { "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": true, + "readOnlyHint": false, "title": "Update Issue Assignees" }, "description": "Update the assignees of an existing issue. This replaces the current assignees with the provided list.", diff --git a/pkg/github/__toolsnaps__/update_issue_body.snap b/pkg/github/__toolsnaps__/update_issue_body.snap index c54d69172a..e17043331d 100644 --- a/pkg/github/__toolsnaps__/update_issue_body.snap +++ b/pkg/github/__toolsnaps__/update_issue_body.snap @@ -1,7 +1,9 @@ { "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": true, + "readOnlyHint": false, "title": "Update Issue Body" }, "description": "Update the body content of an existing issue.", diff --git a/pkg/github/__toolsnaps__/update_issue_labels.snap b/pkg/github/__toolsnaps__/update_issue_labels.snap index 2b31d756b0..2ad3877586 100644 --- a/pkg/github/__toolsnaps__/update_issue_labels.snap +++ b/pkg/github/__toolsnaps__/update_issue_labels.snap @@ -1,7 +1,9 @@ { "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": true, + "readOnlyHint": false, "title": "Update Issue Labels" }, "description": "Update the labels of an existing issue. This replaces the current labels with the provided list. When setting values, include a confidence level (LOW, MEDIUM, or HIGH) reflecting how certain you are about the choice.", diff --git a/pkg/github/__toolsnaps__/update_issue_milestone.snap b/pkg/github/__toolsnaps__/update_issue_milestone.snap index 9188779f0a..cae51246b4 100644 --- a/pkg/github/__toolsnaps__/update_issue_milestone.snap +++ b/pkg/github/__toolsnaps__/update_issue_milestone.snap @@ -1,7 +1,9 @@ { "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": true, + "readOnlyHint": false, "title": "Update Issue Milestone" }, "description": "Update the milestone of an existing issue.", diff --git a/pkg/github/__toolsnaps__/update_issue_state.snap b/pkg/github/__toolsnaps__/update_issue_state.snap index b14d737b7d..ceb984b1f9 100644 --- a/pkg/github/__toolsnaps__/update_issue_state.snap +++ b/pkg/github/__toolsnaps__/update_issue_state.snap @@ -1,7 +1,9 @@ { "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": true, + "readOnlyHint": false, "title": "Update Issue State" }, "description": "Update the state of an existing issue (open or closed), with an optional state reason.", diff --git a/pkg/github/__toolsnaps__/update_issue_title.snap b/pkg/github/__toolsnaps__/update_issue_title.snap index 825fab0655..48c74d98ef 100644 --- a/pkg/github/__toolsnaps__/update_issue_title.snap +++ b/pkg/github/__toolsnaps__/update_issue_title.snap @@ -1,7 +1,9 @@ { "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": true, + "readOnlyHint": false, "title": "Update Issue Title" }, "description": "Update the title of an existing issue.", diff --git a/pkg/github/__toolsnaps__/update_issue_type.snap b/pkg/github/__toolsnaps__/update_issue_type.snap index d07a9d43d9..21a2f64bd5 100644 --- a/pkg/github/__toolsnaps__/update_issue_type.snap +++ b/pkg/github/__toolsnaps__/update_issue_type.snap @@ -1,7 +1,9 @@ { "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": true, + "readOnlyHint": false, "title": "Update Issue Type" }, "description": "Update the type of an existing issue (e.g. 'bug', 'feature'). When setting values, include a confidence level (LOW, MEDIUM, or HIGH) reflecting how certain you are about the choice.", diff --git a/pkg/github/__toolsnaps__/update_pull_request.snap b/pkg/github/__toolsnaps__/update_pull_request.snap index cadc391ef4..ae037a3e0d 100644 --- a/pkg/github/__toolsnaps__/update_pull_request.snap +++ b/pkg/github/__toolsnaps__/update_pull_request.snap @@ -9,6 +9,8 @@ } }, "annotations": { + "idempotentHint": false, + "readOnlyHint": false, "title": "Edit pull request" }, "description": "Update an existing pull request in a GitHub repository.", diff --git a/pkg/github/__toolsnaps__/update_pull_request_body.snap b/pkg/github/__toolsnaps__/update_pull_request_body.snap index 1e6040bd4d..00b4fb978a 100644 --- a/pkg/github/__toolsnaps__/update_pull_request_body.snap +++ b/pkg/github/__toolsnaps__/update_pull_request_body.snap @@ -1,7 +1,9 @@ { "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": true, + "readOnlyHint": false, "title": "Update Pull Request Body" }, "description": "Update the body description of an existing pull request.", diff --git a/pkg/github/__toolsnaps__/update_pull_request_branch.snap b/pkg/github/__toolsnaps__/update_pull_request_branch.snap index a84ac414dd..633ce6fb88 100644 --- a/pkg/github/__toolsnaps__/update_pull_request_branch.snap +++ b/pkg/github/__toolsnaps__/update_pull_request_branch.snap @@ -1,5 +1,7 @@ { "annotations": { + "idempotentHint": false, + "readOnlyHint": false, "title": "Update pull request branch" }, "description": "Update the branch of a pull request with the latest changes from the base branch.", diff --git a/pkg/github/__toolsnaps__/update_pull_request_draft_state.snap b/pkg/github/__toolsnaps__/update_pull_request_draft_state.snap index 2a397951ab..653fa3398f 100644 --- a/pkg/github/__toolsnaps__/update_pull_request_draft_state.snap +++ b/pkg/github/__toolsnaps__/update_pull_request_draft_state.snap @@ -1,7 +1,9 @@ { "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": true, + "readOnlyHint": false, "title": "Update Pull Request Draft State" }, "description": "Mark a pull request as draft or ready for review.", diff --git a/pkg/github/__toolsnaps__/update_pull_request_state.snap b/pkg/github/__toolsnaps__/update_pull_request_state.snap index 9cbdb81124..440533e9b7 100644 --- a/pkg/github/__toolsnaps__/update_pull_request_state.snap +++ b/pkg/github/__toolsnaps__/update_pull_request_state.snap @@ -1,7 +1,9 @@ { "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": true, + "readOnlyHint": false, "title": "Update Pull Request State" }, "description": "Update the state of an existing pull request (open or closed).", diff --git a/pkg/github/__toolsnaps__/update_pull_request_title.snap b/pkg/github/__toolsnaps__/update_pull_request_title.snap index e6398ed40a..1ac5455f77 100644 --- a/pkg/github/__toolsnaps__/update_pull_request_title.snap +++ b/pkg/github/__toolsnaps__/update_pull_request_title.snap @@ -1,7 +1,9 @@ { "annotations": { "destructiveHint": false, + "idempotentHint": false, "openWorldHint": true, + "readOnlyHint": false, "title": "Update Pull Request Title" }, "description": "Update the title of an existing pull request.", diff --git a/pkg/github/header_params_test.go b/pkg/github/header_params_test.go new file mode 100644 index 0000000000..ed08f27a79 --- /dev/null +++ b/pkg/github/header_params_test.go @@ -0,0 +1,44 @@ +package github + +import ( + "context" + "testing" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/google/jsonschema-go/jsonschema" + "github.com/stretchr/testify/require" +) + +// TestAllToolsRoutingParamsGetHeaders enforces that every tool exposing a +// routing-relevant param (owner/repo, per inventory.HeaderParams) has it +// projected to an Mcp-Param-* header. This guards the per-request header +// optimization used by the remote proxy: a future tool must not silently ship +// without its owner/repo header, so it can never fall back to body re-parsing. +func TestAllToolsRoutingParamsGetHeaders(t *testing.T) { + inv, err := NewInventory(stubTranslator).WithToolsets([]string{"all"}).Build() + require.NoError(t, err) + + tools := inv.AvailableTools(context.Background()) + require.NotEmpty(t, tools) + + checked := 0 + for _, st := range tools { + tool := st.Tool + inventory.AnnotateHeaderParams(&tool) + schema, ok := tool.InputSchema.(*jsonschema.Schema) + if !ok || schema == nil { + continue + } + for prop, header := range inventory.HeaderParams { + ps, ok := schema.Properties[prop] + if !ok || ps == nil { + continue + } + require.NotNilf(t, ps.Extra, "tool %q param %q missing x-mcp-header annotation", tool.Name, prop) + require.Equalf(t, header, ps.Extra["x-mcp-header"], + "tool %q param %q must project to Mcp-Param-%s", tool.Name, prop, header) + checked++ + } + } + require.Positive(t, checked, "expected at least one owner/repo param across all toolsets") +} diff --git a/pkg/inventory/registry.go b/pkg/inventory/registry.go index 5f4b37ef28..6505e6b5ef 100644 --- a/pkg/inventory/registry.go +++ b/pkg/inventory/registry.go @@ -71,6 +71,7 @@ func (r *Inventory) UnrecognizedToolsets() []string { // MCP method constants for use with ForMCPRequest. const ( MCPMethodInitialize = "initialize" + MCPMethodDiscover = "server/discover" MCPMethodToolsList = "tools/list" MCPMethodToolsCall = "tools/call" MCPMethodResourcesList = "resources/list" @@ -89,7 +90,7 @@ const ( // - itemName: Name of specific item for call/get methods (tool name, resource URI, or prompt name) // // Returns a new Registry containing only the items relevant to the request: -// - MCPMethodInitialize: Empty (capabilities are set via ServerOptions, not registration) +// - MCPMethodInitialize / MCPMethodDiscover: Empty items (capabilities from ServerOptions; instructions preserved) // - MCPMethodToolsList: All available tools (no resources/prompts) // - MCPMethodToolsCall: Only the named tool // - MCPMethodResourcesList, MCPMethodResourcesTemplatesList: All available resources (no tools/prompts) @@ -114,6 +115,7 @@ func (r *Inventory) ForMCPRequest(method string, itemName string) *Inventory { featureChecker: r.featureChecker, filters: r.filters, // shared, not modified unrecognizedToolsets: r.unrecognizedToolsets, + instructions: r.instructions, // server identity; preserved for all methods } // Helper to clear all item types @@ -124,7 +126,10 @@ func (r *Inventory) ForMCPRequest(method string, itemName string) *Inventory { } switch method { - case MCPMethodInitialize: + case MCPMethodInitialize, MCPMethodDiscover: + // Both handshakes register no items; capabilities come from ServerOptions + // and instructions are preserved via the copy above (SEP-2575 discover + // must surface the same server identity as initialize). clearAll() case MCPMethodToolsList: result.resourceTemplates, result.prompts = nil, nil diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go index 3200bedde6..0a966b289b 100644 --- a/pkg/inventory/registry_test.go +++ b/pkg/inventory/registry_test.go @@ -2268,3 +2268,16 @@ func TestShouldStripMCPAppsMetadata(t *testing.T) { }) } } + +func TestForMCPRequest_PreservesInstructions(t *testing.T) { + reg := mustBuild(t, NewBuilder(). + SetTools([]ServerTool{mockTool("tool1", "repos", true)}). + WithToolsets([]string{"all"}). + WithServerInstructions()) + want := reg.Instructions() + require.NotEmpty(t, want, "expected base inventory to generate instructions") + for _, m := range []string{MCPMethodDiscover, MCPMethodInitialize, MCPMethodToolsList} { + require.Equal(t, want, reg.ForMCPRequest(m, "").Instructions(), + "instructions must be preserved for %s (server identity)", m) + } +} diff --git a/pkg/inventory/server_tool.go b/pkg/inventory/server_tool.go index 326009b59f..beb70138eb 100644 --- a/pkg/inventory/server_tool.go +++ b/pkg/inventory/server_tool.go @@ -4,8 +4,10 @@ import ( "context" "encoding/json" "fmt" + "maps" "github.com/github/github-mcp-server/pkg/octicons" + "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -116,9 +118,64 @@ func (st *ServerTool) RegisterFunc(s *mcp.Server, deps any) { if len(toolCopy.Icons) == 0 { toolCopy.Icons = st.Toolset.Icons() } + // Project routing-relevant params to standard MCP-Param-* headers (SEP-2243) + // so a remote proxy can read owner/repo from headers instead of re-parsing the + // JSON-RPC body. No-op for tools without these params. + AnnotateHeaderParams(&toolCopy) s.AddTool(&toolCopy, handler) } +// HeaderParams maps tool input properties to the MCP-Param-* header name a +// header-aware proxy reads, avoiding a second parse of the request body. New +// routing-relevant params should be added here so projection stays automatic +// for every tool; the enforcement test in pkg/github guards full coverage. +var HeaderParams = map[string]string{"owner": "owner", "repo": "repo"} + +// AnnotateHeaderParams returns a copy of tool whose routing-relevant input +// properties (per HeaderParams) carry an "x-mcp-header" annotation, which the +// SDK projects onto Mcp-Param-{name} request headers. It never mutates the +// input tool's schema or any map shared with the original tool definition: +// callers shallow-copy ServerTool.Tool, so the *jsonschema.Schema (and its +// per-property Extra maps) are shared, and per-request registration must not +// race on them. Only the schema, its Properties map, and the specific property +// schemas/Extra maps that gain an annotation are cloned. +func AnnotateHeaderParams(tool *mcp.Tool) { + schema, ok := tool.InputSchema.(*jsonschema.Schema) + if !ok || schema == nil { + return + } + + // Collect params that actually need an annotation, so a tool without + // owner/repo (or already annotated) is left untouched and unCloned. + var toAnnotate []string + for prop := range HeaderParams { + if ps := schema.Properties[prop]; ps != nil { + if _, exists := ps.Extra["x-mcp-header"]; !exists { + toAnnotate = append(toAnnotate, prop) + } + } + } + if len(toAnnotate) == 0 { + return + } + + // Clone only what we mutate: a fresh schema value, a fresh Properties map, + // and fresh property schemas with fresh Extra maps. The original schema and + // its maps are never written to, so concurrent per-request registration is + // race-free and deterministic. + schemaCopy := *schema + schemaCopy.Properties = maps.Clone(schema.Properties) + for _, prop := range toAnnotate { + propCopy := *schemaCopy.Properties[prop] + extra := make(map[string]any, len(propCopy.Extra)+1) + maps.Copy(extra, propCopy.Extra) + extra["x-mcp-header"] = HeaderParams[prop] + propCopy.Extra = extra + schemaCopy.Properties[prop] = &propCopy + } + tool.InputSchema = &schemaCopy +} + // NewServerToolWithContextHandler creates a ServerTool with a handler that receives deps via context. // This is the preferred approach for tools because it doesn't create closures at registration time, // which is critical for performance in servers that create a new instance per request. diff --git a/pkg/inventory/server_tool_test.go b/pkg/inventory/server_tool_test.go index 69cee94af0..adf012b1f5 100644 --- a/pkg/inventory/server_tool_test.go +++ b/pkg/inventory/server_tool_test.go @@ -3,8 +3,10 @@ package inventory import ( "context" "encoding/json" + "sync" "testing" + "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -78,3 +80,59 @@ func TestNewServerToolWithContextHandler_ValidArguments_Succeeds(t *testing.T) { require.True(t, ok) assert.Equal(t, "success: octocat/hello-world", textContent.Text) } + +func TestAnnotateHeaderParams(t *testing.T) { + tool := &mcp.Tool{InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string"}, + "repo": {Type: "string"}, + "detail": {Type: "string"}, + }, + }} + AnnotateHeaderParams(tool) + schema := tool.InputSchema.(*jsonschema.Schema) + assert.Equal(t, "owner", schema.Properties["owner"].Extra["x-mcp-header"]) + assert.Equal(t, "repo", schema.Properties["repo"].Extra["x-mcp-header"]) + assert.Nil(t, schema.Properties["detail"].Extra) + + // No-op for tools without owner/repo and when InputSchema is not a *jsonschema.Schema + AnnotateHeaderParams(&mcp.Tool{InputSchema: &jsonschema.Schema{Properties: map[string]*jsonschema.Schema{"x": {}}}}) + AnnotateHeaderParams(&mcp.Tool{InputSchema: json.RawMessage(`{}`)}) +} + +func TestAnnotateHeaderParams_DoesNotMutateOriginal(t *testing.T) { + orig := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{"owner": {Type: "string"}, "repo": {Type: "string"}}, + } + tool := &mcp.Tool{InputSchema: orig} + AnnotateHeaderParams(tool) + + // Original schema and its property Extra maps must be untouched. + require.Nil(t, orig.Properties["owner"].Extra, "must not mutate original owner schema") + require.Nil(t, orig.Properties["repo"].Extra, "must not mutate original repo schema") + // Returned copy carries the annotation. + got := tool.InputSchema.(*jsonschema.Schema) + require.NotSame(t, orig, got, "must replace InputSchema with a copy") + require.Equal(t, "owner", got.Properties["owner"].Extra["x-mcp-header"]) +} + +func TestAnnotateHeaderParams_ConcurrentRegistrationIsRaceFree(t *testing.T) { + // Shared base schema, as ServerTool.Tool is shallow-copied per registration. + base := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{"owner": {Type: "string"}, "repo": {Type: "string"}}, + } + var wg sync.WaitGroup + for range 64 { + wg.Go(func() { + tool := mcp.Tool{InputSchema: base} // shallow copy shares *Schema + AnnotateHeaderParams(&tool) + got := tool.InputSchema.(*jsonschema.Schema) + require.Equal(t, "repo", got.Properties["repo"].Extra["x-mcp-header"]) + }) + } + wg.Wait() + require.Nil(t, base.Properties["owner"].Extra, "shared base must remain unmutated") +} diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index 5f56c1c89b..387cc5ae7f 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -24,8 +24,8 @@ The following packages are included for the amd64, arm64 architectures. - [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v2.5.0/v2/LICENSE)) - [github.com/lithammer/fuzzysearch/fuzzy](https://pkg.go.dev/github.com/lithammer/fuzzysearch/fuzzy) ([MIT](https://github.com/lithammer/fuzzysearch/blob/v1.1.8/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/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.6.1/LICENSE)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.6.1/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.1/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.1/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)) - [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.11.0/LICENSE)) @@ -44,7 +44,9 @@ The following packages are included for the amd64, arm64 architectures. - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE)) - [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/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.35.0:LICENSE)) + - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.20.0:LICENSE)) - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.41.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.15.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 7d8213d2f2..fa0a07af16 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -24,8 +24,8 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v2.5.0/v2/LICENSE)) - [github.com/lithammer/fuzzysearch/fuzzy](https://pkg.go.dev/github.com/lithammer/fuzzysearch/fuzzy) ([MIT](https://github.com/lithammer/fuzzysearch/blob/v1.1.8/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/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.6.1/LICENSE)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.6.1/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.1/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.1/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)) - [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.11.0/LICENSE)) @@ -44,7 +44,9 @@ The following packages are included for the 386, amd64, arm64 architectures. - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE)) - [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/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.35.0:LICENSE)) + - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.20.0:LICENSE)) - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.41.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.15.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 3d0fd8f386..398a1fa469 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -25,8 +25,8 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v2.5.0/v2/LICENSE)) - [github.com/lithammer/fuzzysearch/fuzzy](https://pkg.go.dev/github.com/lithammer/fuzzysearch/fuzzy) ([MIT](https://github.com/lithammer/fuzzysearch/blob/v1.1.8/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/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.6.1/LICENSE)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.6.1/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.1/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.0-pre.1/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)) - [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.11.0/LICENSE)) @@ -45,7 +45,9 @@ The following packages are included for the 386, amd64, arm64 architectures. - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE)) - [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/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.35.0:LICENSE)) + - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.20.0:LICENSE)) - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.41.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.15.0:LICENSE)) [github/github-mcp-server]: https://github.com/github/github-mcp-server diff --git a/third-party/golang.org/x/sync/errgroup/LICENSE b/third-party/golang.org/x/sync/errgroup/LICENSE new file mode 100644 index 0000000000..2a7cf70da6 --- /dev/null +++ b/third-party/golang.org/x/sync/errgroup/LICENSE @@ -0,0 +1,27 @@ +Copyright 2009 The Go Authors. + +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 LLC 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/golang.org/x/time/rate/LICENSE b/third-party/golang.org/x/time/rate/LICENSE new file mode 100644 index 0000000000..2a7cf70da6 --- /dev/null +++ b/third-party/golang.org/x/time/rate/LICENSE @@ -0,0 +1,27 @@ +Copyright 2009 The Go Authors. + +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 LLC 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 9bdbcaa515dce5656cf9b7d7d02b6c6121d9c59c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:14:13 +0000 Subject: [PATCH 02/16] build(deps): bump SamMorrowDrums/mcp-server-diff Bumps [SamMorrowDrums/mcp-server-diff](https://github.com/sammorrowdrums/mcp-server-diff) from 3521651bb0d3cc267a23df8d94a2a645556980a4 to 40d992e0a220e5b63378758f9a40d6a8982898d2. - [Release notes](https://github.com/sammorrowdrums/mcp-server-diff/releases) - [Commits](https://github.com/sammorrowdrums/mcp-server-diff/compare/3521651bb0d3cc267a23df8d94a2a645556980a4...40d992e0a220e5b63378758f9a40d6a8982898d2) --- updated-dependencies: - dependency-name: SamMorrowDrums/mcp-server-diff dependency-version: 3.0.0 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] --- .github/workflows/mcp-diff.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mcp-diff.yml b/.github/workflows/mcp-diff.yml index eeb24eaea1..3c38e49020 100644 --- a/.github/workflows/mcp-diff.yml +++ b/.github/workflows/mcp-diff.yml @@ -55,7 +55,7 @@ jobs: # initialize envelope, tool-annotation default hints; probes each server at its # own newest spec via the stateless SEP-2575 server/discover path. Keeps the # go-sdk v1.6.1 -> v1.7.0-pre.1 bump an honest, signal-only diff. github/copilot-mcp-core#1709. - uses: SamMorrowDrums/mcp-server-diff@3521651bb0d3cc267a23df8d94a2a645556980a4 # v3.0.0 + uses: SamMorrowDrums/mcp-server-diff@40d992e0a220e5b63378758f9a40d6a8982898d2 # v3.0.0 with: setup_go: "false" install_command: | @@ -120,7 +120,7 @@ jobs: - name: Run MCP Server Diff (streamable-http) # Pinned to mcp-server-diff v3.0.0 — see rationale on the stdio job above. - uses: SamMorrowDrums/mcp-server-diff@3521651bb0d3cc267a23df8d94a2a645556980a4 # v3.0.0 + uses: SamMorrowDrums/mcp-server-diff@40d992e0a220e5b63378758f9a40d6a8982898d2 # v3.0.0 with: setup_go: "false" install_command: | From a074b94465af83bfb040b766660f2191dcc595a9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:58:15 +0200 Subject: [PATCH 03/16] build(deps): bump golang.org/x/net from 0.38.0 to 0.55.0 in the go_modules group across 1 directory (#2806) * build(deps): bump golang.org/x/net Bumps the go_modules group with 1 update in the / directory: [golang.org/x/net](https://github.com/golang/net). Updates `golang.org/x/net` from 0.38.0 to 0.55.0 - [Commits](https://github.com/golang/net/compare/v0.38.0...v0.55.0) --- updated-dependencies: - dependency-name: golang.org/x/net dependency-version: 0.55.0 dependency-type: indirect dependency-group: go_modules ... Signed-off-by: dependabot[bot] * chore: regenerate license files Auto-generated by license-check workflow --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] --- go.mod | 6 +++--- go.sum | 16 ++++++++-------- third-party-licenses.darwin.md | 6 +++--- third-party-licenses.linux.md | 6 +++--- third-party-licenses.windows.md | 6 +++--- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/go.mod b/go.mod index c2990f718a..90f88b1c45 100644 --- a/go.mod +++ b/go.mod @@ -40,10 +40,10 @@ require ( github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/net v0.38.0 // indirect + golang.org/x/net v0.55.0 // indirect golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.28.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.15.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 10b57f6f89..360e17fb48 100644 --- a/go.sum +++ b/go.sum @@ -92,8 +92,8 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= -golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -107,8 +107,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -117,16 +117,16 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= -golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= -golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index 387cc5ae7f..c2256493b7 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -42,11 +42,11 @@ The following packages are included for the amd64, arm64 architectures. - [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE)) - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE)) - - [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/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.55.0:LICENSE)) - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.35.0:LICENSE)) - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.20.0:LICENSE)) - - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.41.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/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.45.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.37.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.15.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 fa0a07af16..2e6e111931 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -42,11 +42,11 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE)) - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE)) - - [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/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.55.0:LICENSE)) - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.35.0:LICENSE)) - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.20.0:LICENSE)) - - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.41.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/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.45.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.37.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.15.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 398a1fa469..f03f3fb51c 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -43,11 +43,11 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE)) - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.4/LICENSE)) - - [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/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.55.0:LICENSE)) - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.35.0:LICENSE)) - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.20.0:LICENSE)) - - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.41.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/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.45.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.37.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.15.0:LICENSE)) [github/github-mcp-server]: https://github.com/github/github-mcp-server From e5ad9de8e1d996dc7b638fd80b4382e2595091bf Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:34:06 +0200 Subject: [PATCH 04/16] build(deps): bump goreleaser/goreleaser-action from 7.2.2 to 7.2.3 (#2796) Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 7.2.2 to 7.2.3. - [Release notes](https://github.com/goreleaser/goreleaser-action/releases) - [Commits](https://github.com/goreleaser/goreleaser-action/compare/5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89...f06c13b6b1a9625abc9e6e439d9c05a8f2190e94) --- updated-dependencies: - dependency-name: goreleaser/goreleaser-action dependency-version: 7.2.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Iryna Kulakova <52420926+IrynaKulakova@users.noreply.github.com> --- .github/workflows/goreleaser.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index dadf0c55b9..aa98b8db8f 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -28,7 +28,7 @@ jobs: run: go mod download - name: Run GoReleaser - uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 + uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 with: distribution: goreleaser # GoReleaser version From 49abf1557c0373267adc518f2e2c5fc30db03a42 Mon Sep 17 00:00:00 2001 From: Kelsey Myers <52179263+kelsey-myers@users.noreply.github.com> Date: Mon, 6 Jul 2026 17:52:06 +0100 Subject: [PATCH 05/16] Add is_suggestion + rationale to update_issue_assignees (#2821) * Add is_suggestion + rationale to update_issue_assignees * Align assignee confidence to uppercase LOW/MEDIUM/HIGH; add non-string/object test --- docs/feature-flags.md | 2 +- .../__toolsnaps__/update_issue_assignees.snap | 41 +++- pkg/github/granular_tools_test.go | 161 ++++++++++++++ pkg/github/issues_granular.go | 207 ++++++++++++++++-- 4 files changed, 391 insertions(+), 20 deletions(-) diff --git a/docs/feature-flags.md b/docs/feature-flags.md index ce3c32e35f..5da87310c6 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -150,7 +150,7 @@ runtime behavior (such as output formatting) won't appear here. - **update_issue_assignees** - Update Issue Assignees - **Required OAuth Scopes**: `repo` - - `assignees`: GitHub usernames to assign to this issue (string[], required) + - `assignees`: GitHub usernames to assign to this issue. ([], required) - `issue_number`: The issue number to update (number, required) - `owner`: Repository owner (username or organization) (string, required) - `repo`: Repository name (string, required) diff --git a/pkg/github/__toolsnaps__/update_issue_assignees.snap b/pkg/github/__toolsnaps__/update_issue_assignees.snap index ab23f72a95..51a407c164 100644 --- a/pkg/github/__toolsnaps__/update_issue_assignees.snap +++ b/pkg/github/__toolsnaps__/update_issue_assignees.snap @@ -6,13 +6,48 @@ "readOnlyHint": false, "title": "Update Issue Assignees" }, - "description": "Update the assignees of an existing issue. This replaces the current assignees with the provided list.", + "description": "Update the assignees of an existing issue. This replaces the current assignees with the provided list. When setting values, include a confidence level (LOW, MEDIUM, or HIGH) reflecting how certain you are about the choice.", "inputSchema": { "properties": { "assignees": { - "description": "GitHub usernames to assign to this issue", + "description": "GitHub usernames to assign to this issue.", "items": { - "type": "string" + "oneOf": [ + { + "description": "GitHub username", + "type": "string" + }, + { + "properties": { + "confidence": { + "description": "How confident you are in this choice. Use 'HIGH' for clear signal or explicit user request, 'MEDIUM' for reasonable inference with some ambiguity, 'LOW' for best guess with limited signal.", + "enum": [ + "LOW", + "MEDIUM", + "HIGH" + ], + "type": "string" + }, + "is_suggestion": { + "description": "If true, this assignee is sent to the API as a suggestion (suggest:true) rather than an applied assignee. Whether the assignee is applied or recorded as a proposal is determined by the API.", + "type": "boolean" + }, + "login": { + "description": "GitHub username", + "type": "string" + }, + "rationale": { + "description": "One concise sentence explaining what specifically about the issue led you to choose this assignee. State the concrete signal (e.g. 'Authored the file the crash originates in').", + "maxLength": 280, + "type": "string" + } + }, + "required": [ + "login" + ], + "type": "object" + } + ] }, "type": "array" }, diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go index e302435ce5..6b4ab2346b 100644 --- a/pkg/github/granular_tools_test.go +++ b/pkg/github/granular_tools_test.go @@ -271,6 +271,167 @@ func TestGranularUpdateIssueAssignees(t *testing.T) { assert.False(t, result.IsError) } +func TestGranularUpdateIssueAssigneesObjectForm(t *testing.T) { + tests := []struct { + name string + requestArgs map[string]any + expectedReq map[string]any + }{ + { + name: "assignee objects without intent serialize as strings", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "assignees": []any{ + map[string]any{"login": "octocat"}, + "monalisa", + }, + }, + expectedReq: map[string]any{ + "assignees": []any{"octocat", "monalisa"}, + }, + }, + { + name: "assignee suggested without rationale", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "assignees": []any{ + map[string]any{"login": "octocat", "is_suggestion": true}, + }, + }, + expectedReq: map[string]any{ + "assignees": []any{ + map[string]any{"login": "octocat", "suggest": true}, + }, + }, + }, + { + name: "suggested assignee with rationale and confidence", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "assignees": []any{ + map[string]any{"login": "octocat", "rationale": " Authored the crashing file ", "confidence": "high", "is_suggestion": true}, + }, + }, + expectedReq: map[string]any{ + "assignees": []any{ + map[string]any{"login": "octocat", "rationale": "Authored the crashing file", "confidence": "HIGH", "suggest": true}, + }, + }, + }, + { + name: "mix of plain and suggested assignees", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "assignees": []any{ + "monalisa", + map[string]any{"login": "octocat", "is_suggestion": true}, + }, + }, + expectedReq: map[string]any{ + "assignees": []any{ + "monalisa", + map[string]any{"login": "octocat", "suggest": true}, + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq). + andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), + })) + deps := BaseDeps{Client: client} + serverTool := GranularUpdateIssueAssignees(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + } +} + +func TestGranularUpdateIssueAssigneesInvalidInput(t *testing.T) { + tests := []struct { + name string + requestArgs map[string]any + expectedErrText string + }{ + { + name: "rationale too long", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "assignees": []any{ + map[string]any{"login": "octocat", "rationale": strings.Repeat("a", 281)}, + }, + }, + expectedErrText: "assignee rationale must be 280 characters or less", + }, + { + name: "assignee object missing login", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "assignees": []any{ + map[string]any{"rationale": "no login provided"}, + }, + }, + expectedErrText: "each assignee object must have a 'login' string", + }, + { + name: "invalid confidence value", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "assignees": []any{ + map[string]any{"login": "octocat", "confidence": "maybe"}, + }, + }, + expectedErrText: "confidence must be one of: LOW, MEDIUM, HIGH", + }, + { + name: "assignee entry is neither string nor object", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "assignees": []any{float64(123)}, + }, + expectedErrText: "each assignee must be a string or an object", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(nil))} + serverTool := GranularUpdateIssueAssignees(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrText) + }) + } +} + func TestGranularUpdateIssueLabels(t *testing.T) { tests := []struct { name string diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go index e965ce9458..455122850b 100644 --- a/pkg/github/issues_granular.go +++ b/pkg/github/issues_granular.go @@ -238,29 +238,188 @@ func GranularUpdateIssueBody(t translations.TranslationHelperFunc) inventory.Ser // GranularUpdateIssueAssignees creates a tool to update an issue's assignees. func GranularUpdateIssueAssignees(t translations.TranslationHelperFunc) inventory.ServerTool { - return issueUpdateTool(t, - "update_issue_assignees", - "Update the assignees of an existing issue. This replaces the current assignees with the provided list.", - "Update Issue Assignees", - map[string]*jsonschema.Schema{ - "assignees": { - Type: "array", - Description: "GitHub usernames to assign to this issue", - Items: &jsonschema.Schema{Type: "string"}, + st := NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: "update_issue_assignees", + Description: t("TOOL_UPDATE_ISSUE_ASSIGNEES_DESCRIPTION", "Update the assignees of an existing issue. This replaces the current assignees with the provided list. When setting values, include a confidence level (LOW, MEDIUM, or HIGH) reflecting how certain you are about the choice."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_UPDATE_ISSUE_ASSIGNEES_USER_TITLE", "Update Issue Assignees"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(false), + OpenWorldHint: jsonschema.Ptr(true), + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner (username or organization)", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "issue_number": { + Type: "number", + Description: "The issue number to update", + Minimum: jsonschema.Ptr(1.0), + }, + "assignees": { + Type: "array", + Description: "GitHub usernames to assign to this issue.", + Items: &jsonschema.Schema{ + OneOf: []*jsonschema.Schema{ + {Type: "string", Description: "GitHub username"}, + { + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "login": { + Type: "string", + Description: "GitHub username", + }, + "rationale": { + Type: "string", + Description: "One concise sentence explaining what specifically about the issue led you to choose this assignee. " + + "State the concrete signal (e.g. 'Authored the file the crash originates in').", + MaxLength: jsonschema.Ptr(280), + }, + "confidence": { + Type: "string", + Description: "How confident you are in this choice. Use 'HIGH' for clear signal or explicit user request, 'MEDIUM' for reasonable inference with some ambiguity, 'LOW' for best guess with limited signal.", + Enum: []any{"LOW", "MEDIUM", "HIGH"}, + }, + "is_suggestion": { + Type: "boolean", + Description: "If true, this assignee is sent to the API as a suggestion (suggest:true) rather than an applied assignee. " + + "Whether the assignee is applied or recorded as a proposal is determined by the API.", + }, + }, + Required: []string{"login"}, + }, + }, + }, + }, + }, + Required: []string{"owner", "repo", "issue_number", "assignees"}, }, }, - []string{"assignees"}, - func(args map[string]any) (*github.IssueRequest, error) { - if _, ok := args["assignees"]; !ok { - return nil, fmt.Errorf("missing required parameter: assignees") + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil } - assignees, err := OptionalStringArrayParam(args, "assignees") + repo, err := RequiredParam[string](args, "repo") if err != nil { - return nil, err + return utils.NewToolResultError(err.Error()), nil, nil + } + issueNumber, err := RequiredInt(args, "issue_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + assigneesRaw, ok := args["assignees"] + if !ok { + return utils.NewToolResultError("missing required parameter: assignees"), nil, nil } - return &github.IssueRequest{Assignees: &assignees}, nil + assigneesSlice, ok := assigneesRaw.([]any) + if !ok { + // Also accept []string for callers that pre-typed the array. + if strs, ok := assigneesRaw.([]string); ok { + assigneesSlice = make([]any, len(strs)) + for i, s := range strs { + assigneesSlice[i] = s + } + } else { + return utils.NewToolResultError("parameter assignees must be an array"), nil, nil + } + } + + useObjectForm := false + payload := make([]any, 0, len(assigneesSlice)) + for _, item := range assigneesSlice { + switch v := item.(type) { + case string: + payload = append(payload, v) + case map[string]any: + login, err := RequiredParam[string](v, "login") + if err != nil { + return utils.NewToolResultError("each assignee object must have a 'login' string"), nil, nil + } + rationale, err := OptionalParam[string](v, "rationale") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + rationale = strings.TrimSpace(rationale) + if len([]rune(rationale)) > 280 { + return utils.NewToolResultError("assignee rationale must be 280 characters or less"), nil, nil + } + confidence, err := OptionalParam[string](v, "confidence") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + confidence = normalizeConfidence(confidence) + if confidence != "" && confidence != "LOW" && confidence != "MEDIUM" && confidence != "HIGH" { + return utils.NewToolResultError("confidence must be one of: LOW, MEDIUM, HIGH"), nil, nil + } + isSuggestion, err := OptionalParam[bool](v, "is_suggestion") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if rationale == "" && !isSuggestion && confidence == "" { + payload = append(payload, login) + } else { + useObjectForm = true + payload = append(payload, assigneeWithIntent{Login: login, Rationale: rationale, Confidence: confidence, Suggest: isSuggestion}) + } + default: + return utils.NewToolResultError("each assignee must be a string or an object with 'login' and optional 'rationale', 'confidence', and/or 'is_suggestion'"), nil, nil + } + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + var body any + if useObjectForm { + body = &assigneesUpdateRequest{Assignees: payload} + } else { + // Preserve the standard wire format when no rationale or suggest is supplied. + logins := make([]string, len(payload)) + for i, p := range payload { + logins[i] = p.(string) + } + body = &github.IssueRequest{Assignees: &logins} + } + + apiURL := fmt.Sprintf("repos/%s/%s/issues/%d", owner, repo, issueNumber) + req, err := client.NewRequest(ctx, "PATCH", apiURL, body) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil + } + + issue := &github.Issue{} + resp, err := client.Do(req, issue) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update issue", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%d", issue.GetID()), + URL: issue.GetHTMLURL(), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + return utils.NewToolResultText(string(r)), nil, nil }, ) + st.FeatureFlagEnable = FeatureFlagIssuesGranular + return st } // labelWithIntent represents the object form of a label entry, allowing a @@ -279,6 +438,22 @@ type labelsUpdateRequest struct { Labels []any `json:"labels"` } +// assigneeWithIntent represents the object form of an assignee entry, allowing a +// rationale, confidence level, and/or suggest flag to be sent alongside the login. +type assigneeWithIntent struct { + Login string `json:"login"` + Rationale string `json:"rationale,omitempty"` + Confidence string `json:"confidence,omitempty"` + Suggest bool `json:"suggest,omitempty"` +} + +// assigneesUpdateRequest is a custom request body for updating an issue's +// assignees where individual assignees may optionally include a rationale. Each +// element of Assignees is either a string (login) or an assigneeWithIntent object. +type assigneesUpdateRequest struct { + Assignees []any `json:"assignees"` +} + // GranularUpdateIssueLabels creates a tool to update an issue's labels. func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.ServerTool { st := NewTool( From 74c34cd71b363fa960683c1f4eafd3abbef5b40a Mon Sep 17 00:00:00 2001 From: Iulia Bejan <64602043+iulia-b@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:24:27 +0300 Subject: [PATCH 06/16] Add rationale and confidence to closing an issue (#2802) * Add rationale and confidence to closing an issue * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Fix review feedback for update_issue_state validation/docs --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Roberto Nacu --- docs/feature-flags.md | 4 + .../__toolsnaps__/update_issue_state.snap | 25 +- pkg/github/granular_tools_test.go | 238 ++++++++++++++++++ pkg/github/issues_granular.go | 205 +++++++++++++-- 4 files changed, 450 insertions(+), 22 deletions(-) diff --git a/docs/feature-flags.md b/docs/feature-flags.md index 5da87310c6..7b29698a93 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -178,8 +178,12 @@ runtime behavior (such as output formatting) won't appear here. - **update_issue_state** - Update Issue State - **Required OAuth Scopes**: `repo` + - `confidence`: How confident you are in this choice. Use 'HIGH' for clear signal or explicit user request, 'MEDIUM' for reasonable inference with some ambiguity, 'LOW' for best guess with limited signal. (string, optional) + - `duplicate_of`: The issue number of the canonical issue this issue duplicates. Only valid when state_reason is 'duplicate'. Required when is_suggestion is true and state_reason is 'duplicate'. The issue number is resolved to a database ID before being sent to the API. (number, optional) + - `is_suggestion`: If true, this state change is sent to the API as a suggestion (suggest:true) rather than an applied change. Whether the change is applied or recorded as a proposal is determined by the API. (boolean, optional) - `issue_number`: The issue number to update (number, required) - `owner`: Repository owner (username or organization) (string, required) + - `rationale`: One concise sentence explaining what specifically about the issue led you to choose this state. State the concrete signal (e.g. 'The reported crash is fixed in v2.1' → completed). (string, optional) - `repo`: Repository name (string, required) - `state`: The new state for the issue (string, required) - `state_reason`: The reason for the state change (only for closed state) (string, optional) diff --git a/pkg/github/__toolsnaps__/update_issue_state.snap b/pkg/github/__toolsnaps__/update_issue_state.snap index ceb984b1f9..2a26e8be64 100644 --- a/pkg/github/__toolsnaps__/update_issue_state.snap +++ b/pkg/github/__toolsnaps__/update_issue_state.snap @@ -6,9 +6,27 @@ "readOnlyHint": false, "title": "Update Issue State" }, - "description": "Update the state of an existing issue (open or closed), with an optional state reason.", + "description": "Update the state of an existing issue (open or closed), with an optional state reason. When closing, include a confidence level (LOW, MEDIUM, or HIGH) reflecting how certain you are about the decision. Use is_suggestion to propose the change without applying it directly.", "inputSchema": { "properties": { + "confidence": { + "description": "How confident you are in this choice. Use 'HIGH' for clear signal or explicit user request, 'MEDIUM' for reasonable inference with some ambiguity, 'LOW' for best guess with limited signal.", + "enum": [ + "LOW", + "MEDIUM", + "HIGH" + ], + "type": "string" + }, + "duplicate_of": { + "description": "The issue number of the canonical issue this issue duplicates. Only valid when state_reason is 'duplicate'. Required when is_suggestion is true and state_reason is 'duplicate'. The issue number is resolved to a database ID before being sent to the API.", + "minimum": 1, + "type": "number" + }, + "is_suggestion": { + "description": "If true, this state change is sent to the API as a suggestion (suggest:true) rather than an applied change. Whether the change is applied or recorded as a proposal is determined by the API.", + "type": "boolean" + }, "issue_number": { "description": "The issue number to update", "minimum": 1, @@ -18,6 +36,11 @@ "description": "Repository owner (username or organization)", "type": "string" }, + "rationale": { + "description": "One concise sentence explaining what specifically about the issue led you to choose this state. State the concrete signal (e.g. 'The reported crash is fixed in v2.1' → completed).", + "maxLength": 280, + "type": "string" + }, "repo": { "description": "Repository name", "type": "string" diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go index 6b4ab2346b..8cd311c022 100644 --- a/pkg/github/granular_tools_test.go +++ b/pkg/github/granular_tools_test.go @@ -1106,6 +1106,244 @@ func TestGranularUpdateIssueState(t *testing.T) { } } +func TestGranularUpdateIssueStateSuggest(t *testing.T) { + tests := []struct { + name string + requestArgs map[string]any + expectedReq map[string]any + }{ + { + name: "suggest without rationale", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "state": "closed", + "is_suggestion": true, + }, + expectedReq: map[string]any{ + "state": map[string]any{ + "value": "closed", + "suggest": true, + }, + }, + }, + { + name: "suggest with rationale and state_reason", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "state": "closed", + "state_reason": "not_planned", + "rationale": " No activity in 6 months ", + "is_suggestion": true, + }, + expectedReq: map[string]any{ + "state": map[string]any{ + "value": "closed", + "rationale": "No activity in 6 months", + "suggest": true, + }, + "state_reason": "not_planned", + }, + }, + { + name: "rationale applied directly (no suggestion)", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "state": "closed", + "rationale": "The reported crash is fixed in v2.1", + "confidence": "HIGH", + }, + expectedReq: map[string]any{ + "state": map[string]any{ + "value": "closed", + "rationale": "The reported crash is fixed in v2.1", + "confidence": "HIGH", + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq). + andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), + })) + deps := BaseDeps{Client: client} + serverTool := GranularUpdateIssueState(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + } +} + +func TestGranularUpdateIssueStateDuplicate(t *testing.T) { + const duplicateIssueID = int64(99999) + + tests := []struct { + name string + requestArgs map[string]any + expectedReq map[string]any + }{ + { + name: "suggestion duplicate close", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "state": "closed", + "state_reason": "duplicate", + "is_suggestion": true, + "duplicate_of": float64(42), + }, + expectedReq: map[string]any{ + "state": map[string]any{ + "value": "closed", + "suggest": true, + }, + "state_reason": "duplicate", + "duplicate_issue_id": float64(duplicateIssueID), + }, + }, + { + name: "direct duplicate close", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "state": "closed", + "state_reason": "duplicate", + "duplicate_of": float64(42), + }, + expectedReq: map[string]any{ + "state": map[string]any{"value": "closed"}, + "state_reason": "duplicate", + "duplicate_issue_id": float64(duplicateIssueID), + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, &gogithub.Issue{ + ID: gogithub.Ptr(duplicateIssueID), + Number: gogithub.Ptr(42), + }), + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq). + andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), + })) + deps := BaseDeps{Client: client} + serverTool := GranularUpdateIssueState(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + } +} + +func TestGranularUpdateIssueStateInvalidRationale(t *testing.T) { + tests := []struct { + name string + requestArgs map[string]any + expectedErrText string + }{ + { + name: "rationale too long", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "state": "closed", + "rationale": strings.Repeat("a", 281), + }, + expectedErrText: "parameter rationale must be 280 characters or less", + }, + { + name: "duplicate_of without state_reason duplicate", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "state": "closed", + "state_reason": "not_planned", + "is_suggestion": true, + "duplicate_of": float64(42), + }, + expectedErrText: "duplicate_of can only be used when state_reason is 'duplicate'", + }, + { + name: "suggestion duplicate without duplicate_of", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "state": "closed", + "state_reason": "duplicate", + "is_suggestion": true, + }, + expectedErrText: "duplicate_of is required when suggesting a close as duplicate", + }, + { + name: "state_reason with open state", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "state": "open", + "state_reason": "completed", + }, + expectedErrText: "state_reason can only be used when state is 'closed'", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(nil))} + serverTool := GranularUpdateIssueState(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrText) + }) + } +} + +func TestGranularUpdateIssueStateInvalidConfidence(t *testing.T) { + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(nil))} + serverTool := GranularUpdateIssueState(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "state": "closed", + "confidence": "VERY_HIGH", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, "confidence must be one of: LOW, MEDIUM, HIGH") +} + // --- Pull request granular tool handler tests --- func TestGranularUpdatePullRequestTitle(t *testing.T) { diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go index 455122850b..c279835e0c 100644 --- a/pkg/github/issues_granular.go +++ b/pkg/github/issues_granular.go @@ -817,39 +817,202 @@ func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.Ser return st } +// stateWithIntent represents the object form of the state field, allowing +// rationale, confidence, and/or suggest flag to be sent alongside the state value. +type stateWithIntent struct { + Value string `json:"value"` + Rationale string `json:"rationale,omitempty"` + Confidence string `json:"confidence,omitempty"` + Suggest bool `json:"suggest,omitempty"` +} + +// stateUpdateRequest is a custom request body for updating an issue's state +// with optional intent metadata, using the object form that the REST API accepts. +type stateUpdateRequest struct { + State stateWithIntent `json:"state"` + StateReason string `json:"state_reason,omitempty"` + DuplicateIssueID *int64 `json:"duplicate_issue_id,omitempty"` +} + // GranularUpdateIssueState creates a tool to update an issue's state. func GranularUpdateIssueState(t translations.TranslationHelperFunc) inventory.ServerTool { - return issueUpdateTool(t, - "update_issue_state", - "Update the state of an existing issue (open or closed), with an optional state reason.", - "Update Issue State", - map[string]*jsonschema.Schema{ - "state": { - Type: "string", - Description: "The new state for the issue", - Enum: []any{"open", "closed"}, + st := NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: "update_issue_state", + Description: t("TOOL_UPDATE_ISSUE_STATE_DESCRIPTION", "Update the state of an existing issue (open or closed), with an optional state reason. When closing, include a confidence level (LOW, MEDIUM, or HIGH) reflecting how certain you are about the decision. Use is_suggestion to propose the change without applying it directly."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_UPDATE_ISSUE_STATE_USER_TITLE", "Update Issue State"), + ReadOnlyHint: false, + DestructiveHint: jsonschema.Ptr(false), + OpenWorldHint: jsonschema.Ptr(true), }, - "state_reason": { - Type: "string", - Description: "The reason for the state change (only for closed state)", - Enum: []any{"completed", "not_planned", "duplicate"}, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner (username or organization)", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "issue_number": { + Type: "number", + Description: "The issue number to update", + Minimum: jsonschema.Ptr(1.0), + }, + "state": { + Type: "string", + Description: "The new state for the issue", + Enum: []any{"open", "closed"}, + }, + "state_reason": { + Type: "string", + Description: "The reason for the state change (only for closed state)", + Enum: []any{"completed", "not_planned", "duplicate"}, + }, + "rationale": { + Type: "string", + Description: "One concise sentence explaining what specifically about the issue led you to choose this state. " + + "State the concrete signal (e.g. 'The reported crash is fixed in v2.1' → completed).", + MaxLength: jsonschema.Ptr(280), + }, + "confidence": { + Type: "string", + Description: "How confident you are in this choice. Use 'HIGH' for clear signal or explicit user request, 'MEDIUM' for reasonable inference with some ambiguity, 'LOW' for best guess with limited signal.", + Enum: []any{"LOW", "MEDIUM", "HIGH"}, + }, + "is_suggestion": { + Type: "boolean", + Description: "If true, this state change is sent to the API as a suggestion (suggest:true) rather than an applied change. " + + "Whether the change is applied or recorded as a proposal is determined by the API.", + }, + "duplicate_of": { + Type: "number", + Description: "The issue number of the canonical issue this issue duplicates. Only valid when state_reason is 'duplicate'. Required when is_suggestion is true and state_reason is 'duplicate'. The issue number is resolved to a database ID before being sent to the API.", + Minimum: jsonschema.Ptr(1.0), + }, + }, + Required: []string{"owner", "repo", "issue_number", "state"}, }, }, - []string{"state"}, - func(args map[string]any) (*github.IssueRequest, error) { + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + issueNumber, err := RequiredInt(args, "issue_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } state, err := RequiredParam[string](args, "state") if err != nil { - return nil, err + return utils.NewToolResultError(err.Error()), nil, nil + } + stateReason, err := OptionalParam[string](args, "state_reason") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + rationale, err := OptionalParam[string](args, "rationale") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + rationale = strings.TrimSpace(rationale) + if len([]rune(rationale)) > 280 { + return utils.NewToolResultError("parameter rationale must be 280 characters or less"), nil, nil + } + confidence, err := OptionalParam[string](args, "confidence") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + confidence = normalizeConfidence(confidence) + if confidence != "" && confidence != "LOW" && confidence != "MEDIUM" && confidence != "HIGH" { + return utils.NewToolResultError("confidence must be one of: LOW, MEDIUM, HIGH"), nil, nil + } + isSuggestion, err := OptionalParam[bool](args, "is_suggestion") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + duplicateOf, err := OptionalIntParam(args, "duplicate_of") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if stateReason != "" && state != "closed" { + return utils.NewToolResultError("state_reason can only be used when state is 'closed'"), nil, nil + } + if duplicateOf != 0 && stateReason != "duplicate" { + return utils.NewToolResultError("duplicate_of can only be used when state_reason is 'duplicate'"), nil, nil + } + if isSuggestion && stateReason == "duplicate" && duplicateOf == 0 { + return utils.NewToolResultError("duplicate_of is required when suggesting a close as duplicate"), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } - req := &github.IssueRequest{State: &state} - stateReason, _ := OptionalParam[string](args, "state_reason") - if stateReason != "" { - req.StateReason = &stateReason + var body any + if rationale != "" || isSuggestion || confidence != "" || duplicateOf != 0 { + req := &stateUpdateRequest{ + State: stateWithIntent{ + Value: state, + Rationale: rationale, + Confidence: confidence, + Suggest: isSuggestion, + }, + StateReason: stateReason, + } + if duplicateOf != 0 { + duplicateIssue, resp, err := client.Issues.Get(ctx, owner, repo, duplicateOf) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get duplicate issue", resp, err), nil, nil + } + _ = resp.Body.Close() + id := duplicateIssue.GetID() + req.DuplicateIssueID = &id + } + body = req + } else { + req := &github.IssueRequest{State: &state} + if stateReason != "" { + req.StateReason = &stateReason + } + body = req } - return req, nil + + apiURL := fmt.Sprintf("repos/%s/%s/issues/%d", owner, repo, issueNumber) + req, err := client.NewRequest(ctx, "PATCH", apiURL, body) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil + } + + issue := &github.Issue{} + resp, err := client.Do(req, issue) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update issue", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + r, err := json.Marshal(MinimalResponse{ + ID: fmt.Sprintf("%d", issue.GetID()), + URL: issue.GetHTMLURL(), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + return utils.NewToolResultText(string(r)), nil, nil }, ) + st.FeatureFlagEnable = FeatureFlagIssuesGranular + return st } // GranularAddSubIssue creates a tool to add a sub-issue. From 659284735780c3a71e1815ea738aada13060917e Mon Sep 17 00:00:00 2001 From: Bryan Zwicker Date: Tue, 7 Jul 2026 07:00:59 -0400 Subject: [PATCH 07/16] Sanitize and lockdown-gate issue_read get_parent title (#2780) * Sanitize and lockdown-gate issue_read get_parent title GetIssueParent returned the parent issue title raw and ungated by lockdown mode, so an agent could read an unverified, possibly cross-repo parent title even with lockdown enabled. Always sanitize the parent title and, under lockdown mode, only return the parent when its author has push access to the parent repository, failing closed otherwise. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Prove sanitization in get_parent test; trim comments Embed a U+202E BiDi control char in the mocked parent title so the happy-path assertion fails if Sanitize is removed, and tighten the GetIssueParent comments. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Roberto Nacu --- pkg/github/issues.go | 50 +++++++++++++++++----- pkg/github/issues_test.go | 87 +++++++++++++++++++++++++++++---------- 2 files changed, 105 insertions(+), 32 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 62db8d48d3..e452b4b49a 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -701,7 +701,7 @@ Options are: result, err := GetSubIssues(ctx, client, deps, owner, repo, issueNumber, pagination) return attachIFC(result), nil, err case "get_parent": - result, err := GetIssueParent(ctx, gqlClient, owner, repo, issueNumber) + result, err := GetIssueParent(ctx, gqlClient, deps, owner, repo, issueNumber) return attachIFC(result), nil, err case "get_labels": result, err := GetIssueLabels(ctx, gqlClient, owner, repo, issueNumber) @@ -900,18 +900,31 @@ func GetSubIssues(ctx context.Context, client *github.Client, deps ToolDependenc return utils.NewToolResultText(string(r)), nil } -// GetIssueParent returns the parent issue of the given issue, or a null parent -// when the issue is not a sub-issue of any other issue. It reads the GraphQL -// Issue.parent field, the upward counterpart to the downward get_sub_issues read. -func GetIssueParent(ctx context.Context, client *githubv4.Client, owner string, repo string, issueNumber int) (*mcp.CallToolResult, error) { +// GetIssueParent returns the parent issue of the given issue, or a null +// parent when the issue is not a sub-issue. It reads the GraphQL +// Issue.parent field, the upward counterpart to get_sub_issues. +// +// The parent title is always sanitized (it may be cross-repo). Under +// lockdown mode the parent is only returned when its author has push +// access to the parent repo (mirroring GetIssue); otherwise it is omitted. +func GetIssueParent(ctx context.Context, client *githubv4.Client, deps ToolDependencies, owner string, repo string, issueNumber int) (*mcp.CallToolResult, error) { + cache, err := deps.GetRepoAccessCache(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get repo access cache: %w", err) + } + flags := deps.GetFlags(ctx) + var query struct { Repository struct { Issue struct { Parent *struct { - Number githubv4.Int - Title githubv4.String - State githubv4.String - URL githubv4.String + Number githubv4.Int + Title githubv4.String + State githubv4.String + URL githubv4.String + Author struct { + Login githubv4.String + } Repository struct { NameWithOwner githubv4.String } @@ -935,10 +948,27 @@ func GetIssueParent(ctx context.Context, client *githubv4.Client, owner string, return MarshalledTextResult(map[string]any{"parent": nil}), nil } + if flags.LockdownMode { + if cache == nil { + return nil, fmt.Errorf("lockdown cache is not configured") + } + // Fail closed: omit the parent if anything needed for the safe-content + // check is missing or unverifiable. + parentAuthorLogin := string(parent.Author.Login) + parentOwner, parentRepo, ok := strings.Cut(string(parent.Repository.NameWithOwner), "/") + if parentAuthorLogin == "" || !ok || parentOwner == "" || parentRepo == "" { + return MarshalledTextResult(map[string]any{"parent": nil}), nil + } + isSafeContent, err := cache.IsSafeContent(ctx, parentAuthorLogin, parentOwner, parentRepo) + if err != nil || !isSafeContent { + return MarshalledTextResult(map[string]any{"parent": nil}), nil + } + } + return MarshalledTextResult(map[string]any{ "parent": map[string]any{ "number": int(parent.Number), - "title": string(parent.Title), + "title": sanitize.Sanitize(string(parent.Title)), "state": string(parent.State), "url": string(parent.URL), "repository": string(parent.Repository.NameWithOwner), diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 3be4fe1b50..2903f4b845 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -3529,10 +3529,13 @@ func Test_GetIssueParent(t *testing.T) { Repository struct { Issue struct { Parent *struct { - Number githubv4.Int - Title githubv4.String - State githubv4.String - URL githubv4.String + Number githubv4.Int + Title githubv4.String + State githubv4.String + URL githubv4.String + Author struct { + Login githubv4.String + } Repository struct { NameWithOwner githubv4.String } @@ -3547,9 +3550,32 @@ func Test_GetIssueParent(t *testing.T) { "issueNumber": githubv4.Int(123), } + parentResponse := func(authorLogin string) githubv4mock.GQLResponse { + return githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "parent": map[string]any{ + "number": githubv4.Int(42), + "title": githubv4.String("Parent\u202e issue"), + "state": githubv4.String("OPEN"), + "url": githubv4.String("https://github.com/owner/repo/issues/42"), + "author": map[string]any{ + "login": githubv4.String(authorLogin), + }, + "repository": map[string]any{ + "nameWithOwner": githubv4.String("owner/repo"), + }, + }, + }, + }, + }) + } + tests := []struct { name string mockedClient *http.Client + lockdownEnabled bool + restOverrides map[string]string expectToolError bool expectedText string }{ @@ -3559,24 +3585,10 @@ func Test_GetIssueParent(t *testing.T) { githubv4mock.NewQueryMatcher( parentMatcherStruct, vars, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "issue": map[string]any{ - "parent": map[string]any{ - "number": githubv4.Int(42), - "title": githubv4.String("Parent issue"), - "state": githubv4.String("OPEN"), - "url": githubv4.String("https://github.com/owner/repo/issues/42"), - "repository": map[string]any{ - "nameWithOwner": githubv4.String("owner/repo"), - }, - }, - }, - }, - }), + parentResponse("author"), ), ), - expectedText: `"number":42`, + expectedText: `"title":"Parent issue"`, }, { name: "issue has no parent", @@ -3606,17 +3618,48 @@ func Test_GetIssueParent(t *testing.T) { ), expectToolError: true, }, + { + name: "lockdown enabled - parent author has push access", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + parentMatcherStruct, + vars, + parentResponse("maintainer"), + ), + ), + lockdownEnabled: true, + restOverrides: map[string]string{"maintainer": "write"}, + expectedText: `"title":"Parent issue"`, + }, + { + name: "lockdown enabled - parent author lacks push access", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + parentMatcherStruct, + vars, + parentResponse("externaluser"), + ), + ), + lockdownEnabled: true, + restOverrides: map[string]string{"externaluser": "read"}, + expectedText: `"parent":null`, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { gqlClient := githubv4.NewClient(tc.mockedClient) client := mustNewGHClient(t, nil) + + var restClient *github.Client + if tc.lockdownEnabled { + restClient = mockRESTPermissionServer(t, "read", tc.restOverrides) + } deps := BaseDeps{ Client: client, GQLClient: gqlClient, - RepoAccessCache: stubRepoAccessCache(nil, 15*time.Minute), - Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}), + RepoAccessCache: stubRepoAccessCache(restClient, 15*time.Minute), + Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled}), } handler := serverTool.Handler(deps) From b7bc3dccee601e7d90e7c455b540cc37090bbd1e Mon Sep 17 00:00:00 2001 From: Bryan Zwicker Date: Tue, 7 Jul 2026 07:18:09 -0400 Subject: [PATCH 08/16] Enrich issue_read get with hierarchy relationship signals (#2764) * Enrich issue_read get with hierarchy relationship signals The default issue_read `get` payload surfaced no hierarchy data, forcing agents to drop to raw REST (parent_issue_url) or scan sibling sub_issues to discover relationships. Enrich `get` with a layered, zero-extra-round-trip relationship signal derived from a single combined GraphQL query: - has_parent / has_children: cheap, always-emitted routing booleans (addresses Sam Morrow's #2726 review note). - parent: compact ref (number/title/state/url/repository) mirroring the existing get_parent payload keys; omitted when there is no parent. - sub_issues_summary: native subIssuesSummary counts (total/completed/ percent_completed); omitted when there are no sub-issues. The single-issue field-values GraphQL call in GetIssue is replaced by one combined query (fetchIssueReadEnrichment) returning field values + parent + subIssuesSummary, so `get` adds no round-trips. Enrichment is best-effort: a query failure still returns the base issue and never fails `get`. Parent titles are sanitized (parent may be cross-repo) and redacted under lockdown mode unless the parent content can be verified as safe; numeric/structural fields and counts stay intact. get_parent / sub_issue_write behavior is unchanged; tool descriptions clarify hierarchy is read here but written via sub_issue_write (no writable parent field). Refs github/planning-tracking#3306 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Tighten hierarchy tool wording Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Omit unverified parent under lockdown instead of redacting title Align issue_read get parent enrichment with the codebase's existing lockdown patterns: rather than introducing a third, redaction-with-sentinel behavior, omit the whole parent reference when its (possibly cross-repo) content cannot be verified safe. This mirrors how unsafe comments, sub-issues, and PR reviews are filtered out. has_parent stays true so an agent can still route to get_parent. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Restore explicit issue read query matcher Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Omit hierarchy flags when enrichment fails Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address hierarchy review nits Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Roberto Nacu --- README.md | 5 +- pkg/github/__toolsnaps__/issue_read.snap | 2 +- pkg/github/__toolsnaps__/sub_issue_write.snap | 2 +- pkg/github/issues.go | 175 ++++++++++-- pkg/github/issues_test.go | 258 +++++++++++++++++- pkg/github/minimal_types.go | 26 ++ 6 files changed, 442 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 973b926d66..48e6c40c82 100644 --- a/README.md +++ b/README.md @@ -891,7 +891,7 @@ The following sets of tools are available: - `issue_number`: The number of the issue (number, required) - `method`: The read operation to perform on a single issue. Options are: - 1. get - Get details of a specific issue. + 1. get - Get issue details. Also returns best-effort hierarchy flags (`has_parent`, `has_children`); `parent` and `sub_issues_summary` are optional relationship summaries. 2. get_comments - Get issue comments. 3. get_sub_issues - Get sub-issues (children) of the issue. 4. get_parent - Get the parent issue, if this issue is a sub-issue of another. @@ -968,7 +968,8 @@ The following sets of tools are available: - 'add' - add a sub-issue to a parent issue in a GitHub repository. - 'remove' - remove a sub-issue from a parent issue in a GitHub repository. - 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position. - (string, required) + Writes issue hierarchy. To move a sub-issue to a new parent, use `add` with `replace_parent=true`; there is no writable parent field. + (string, required) - `owner`: Repository owner (string, required) - `replace_parent`: When true, replaces the sub-issue's current parent issue. Use with 'add' method only. (boolean, optional) - `repo`: Repository name (string, required) diff --git a/pkg/github/__toolsnaps__/issue_read.snap b/pkg/github/__toolsnaps__/issue_read.snap index b5932fb107..ded99579ab 100644 --- a/pkg/github/__toolsnaps__/issue_read.snap +++ b/pkg/github/__toolsnaps__/issue_read.snap @@ -12,7 +12,7 @@ "type": "number" }, "method": { - "description": "The read operation to perform on a single issue.\nOptions are:\n1. get - Get details of a specific issue.\n2. get_comments - Get issue comments.\n3. get_sub_issues - Get sub-issues (children) of the issue.\n4. get_parent - Get the parent issue, if this issue is a sub-issue of another.\n5. get_labels - Get labels assigned to the issue.\n", + "description": "The read operation to perform on a single issue.\nOptions are:\n1. get - Get issue details. Also returns best-effort hierarchy flags (`has_parent`, `has_children`); `parent` and `sub_issues_summary` are optional relationship summaries.\n2. get_comments - Get issue comments.\n3. get_sub_issues - Get sub-issues (children) of the issue.\n4. get_parent - Get the parent issue, if this issue is a sub-issue of another.\n5. get_labels - Get labels assigned to the issue.\n", "enum": [ "get", "get_comments", diff --git a/pkg/github/__toolsnaps__/sub_issue_write.snap b/pkg/github/__toolsnaps__/sub_issue_write.snap index 3ebb012def..9dc2776c32 100644 --- a/pkg/github/__toolsnaps__/sub_issue_write.snap +++ b/pkg/github/__toolsnaps__/sub_issue_write.snap @@ -20,7 +20,7 @@ "type": "number" }, "method": { - "description": "The action to perform on a single sub-issue\nOptions are:\n- 'add' - add a sub-issue to a parent issue in a GitHub repository.\n- 'remove' - remove a sub-issue from a parent issue in a GitHub repository.\n- 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position.\n\t\t\t\t", + "description": "The action to perform on a single sub-issue\nOptions are:\n- 'add' - add a sub-issue to a parent issue in a GitHub repository.\n- 'remove' - remove a sub-issue from a parent issue in a GitHub repository.\n- 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position.\nWrites issue hierarchy. To move a sub-issue to a new parent, use `add` with `replace_parent=true`; there is no writable parent field.\n", "type": "string" }, "owner": { diff --git a/pkg/github/issues.go b/pkg/github/issues.go index e452b4b49a..a40afa172e 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -14,6 +14,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/lockdown" "github.com/github/github-mcp-server/pkg/sanitize" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" @@ -612,14 +613,13 @@ func IssueRead(t translations.TranslationHelperFunc) inventory.ServerTool { Properties: map[string]*jsonschema.Schema{ "method": { Type: "string", - Description: `The read operation to perform on a single issue. -Options are: -1. get - Get details of a specific issue. -2. get_comments - Get issue comments. -3. get_sub_issues - Get sub-issues (children) of the issue. -4. get_parent - Get the parent issue, if this issue is a sub-issue of another. -5. get_labels - Get labels assigned to the issue. -`, + Description: "The read operation to perform on a single issue.\n" + + "Options are:\n" + + "1. get - Get issue details. Also returns best-effort hierarchy flags (`has_parent`, `has_children`); `parent` and `sub_issues_summary` are optional relationship summaries.\n" + + "2. get_comments - Get issue comments.\n" + + "3. get_sub_issues - Get sub-issues (children) of the issue.\n" + + "4. get_parent - Get the parent issue, if this issue is a sub-issue of another.\n" + + "5. get_labels - Get labels assigned to the issue.\n", Enum: []any{"get", "get_comments", "get_sub_issues", "get_parent", "get_labels"}, }, "owner": { @@ -762,13 +762,14 @@ func GetIssue(ctx context.Context, client *github.Client, deps ToolDependencies, minimalIssue := convertToMinimalIssue(issue) // Always drop the verbose REST IssueFieldValues; enrich with the GraphQL - // field_values view instead. + // field_values view and the hierarchy relationship signals instead. The + // enrichment is best-effort: a failure here must never fail `get`. minimalIssue.IssueFieldValues = nil if issue != nil && issue.NodeID != nil && *issue.NodeID != "" { gqlClient, err := deps.GetGQLClient(ctx) if err == nil { - if fieldValuesByID, err := fetchIssueFieldValuesByNodeID(ctx, gqlClient, []*github.Issue{issue}); err == nil { - minimalIssue.FieldValues = fieldValuesByID[*issue.NodeID] + if enrichment, err := fetchIssueReadEnrichment(ctx, gqlClient, *issue.NodeID); err == nil { + applyIssueReadEnrichment(ctx, &minimalIssue, enrichment, cache, flags.LockdownMode) } } } @@ -776,6 +777,54 @@ func GetIssue(ctx context.Context, client *github.Client, deps ToolDependencies, return MarshalledTextResult(minimalIssue), nil } +// applyIssueReadEnrichment populates the hierarchy relationship signals (has_parent/has_children, +// parent, sub_issues_summary) and field_values onto the minimal issue. In lockdown mode the parent +// reference is omitted unless the parent content can be verified as safe; has_parent and the numeric +// counts are structural routing signals and are always safe to surface. +func applyIssueReadEnrichment(ctx context.Context, minimalIssue *MinimalIssue, enrichment *issueReadEnrichment, cache *lockdown.RepoAccessCache, lockdownMode bool) { + if enrichment == nil { + return + } + + minimalIssue.FieldValues = enrichment.FieldValues + minimalIssue.HasParent = ToBoolPtr(enrichment.Parent != nil) + minimalIssue.HasChildren = ToBoolPtr(enrichment.SubIssuesSummary.Total > 0) + + if parent := enrichment.Parent; parent != nil { + // Surface the parent reference only when it is safe to expose. Under lockdown an + // unverified (possibly cross-repo) parent is omitted entirely, mirroring how unsafe + // comments and sub-issues are filtered out. has_parent still routes an agent to + // get_parent if it needs to follow up. + if !lockdownMode || isSafeParentContent(ctx, cache, parent) { + ref := parent.Ref + minimalIssue.Parent = &ref + } + } + + if enrichment.SubIssuesSummary.Total > 0 { + summary := enrichment.SubIssuesSummary + minimalIssue.SubIssuesSummary = &summary + } +} + +// isSafeParentContent reports whether the parent issue reference can be exposed under lockdown mode. +// It fails closed: any inability to positively verify safe content (missing cache, missing author, +// unparseable repository, or a lookup error) results in the parent reference being omitted. +func isSafeParentContent(ctx context.Context, cache *lockdown.RepoAccessCache, parent *issueReadParent) bool { + if cache == nil || parent.AuthorLogin == "" { + return false + } + owner, repo, ok := strings.Cut(parent.Ref.Repository, "/") + if !ok || owner == "" || repo == "" { + return false + } + safe, err := cache.IsSafeContent(ctx, parent.AuthorLogin, owner, repo) + if err != nil { + return false + } + return safe +} + func GetIssueComments(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { cache, err := deps.GetRepoAccessCache(ctx) if err != nil { @@ -1343,12 +1392,12 @@ func SubIssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool { Properties: map[string]*jsonschema.Schema{ "method": { Type: "string", - Description: `The action to perform on a single sub-issue -Options are: -- 'add' - add a sub-issue to a parent issue in a GitHub repository. -- 'remove' - remove a sub-issue from a parent issue in a GitHub repository. -- 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position. - `, + Description: "The action to perform on a single sub-issue\n" + + "Options are:\n" + + "- 'add' - add a sub-issue to a parent issue in a GitHub repository.\n" + + "- 'remove' - remove a sub-issue from a parent issue in a GitHub repository.\n" + + "- 'reprioritize' - change the order of sub-issues within a parent issue in a GitHub repository. Use either 'after_id' or 'before_id' to specify the new position.\n" + + "Writes issue hierarchy. To move a sub-issue to a new parent, use `add` with `replace_parent=true`; there is no writable parent field.\n", }, "owner": { Type: "string", @@ -1786,6 +1835,98 @@ func fetchIssueFieldValuesByNodeID(ctx context.Context, gqlClient *githubv4.Clie return result, nil } +// issueReadEnrichmentQuery fetches, in a single GraphQL round-trip, the custom field values, +// parent reference, and sub-issue summary counts for the issues identified by their node IDs. +// It powers the issue_read `get` relationship signals without adding extra round-trips. +type issueReadEnrichmentQuery struct { + Nodes []struct { + Issue struct { + ID githubv4.ID + IssueFieldValues struct { + Nodes []IssueFieldValueFragment + } `graphql:"issueFieldValues(first: 25)"` + Parent *struct { + Number githubv4.Int + Title githubv4.String + State githubv4.String + URL githubv4.String + Author struct { + Login githubv4.String + } + Repository struct { + NameWithOwner githubv4.String + } + } + SubIssuesSummary struct { + Total githubv4.Int + Completed githubv4.Int + PercentCompleted githubv4.Int + } + } `graphql:"... on Issue"` + } `graphql:"nodes(ids: $ids)"` +} + +// issueReadParent is the parent reference plus the metadata needed to make a lockdown +// safe-content decision about whether the (possibly cross-repo) parent title may be exposed. +type issueReadParent struct { + Ref MinimalIssueRef + AuthorLogin string +} + +// issueReadEnrichment is the flattened result of the issue_read `get` enrichment query. +type issueReadEnrichment struct { + FieldValues []MinimalFieldValue + Parent *issueReadParent + SubIssuesSummary MinimalSubIssuesSummary +} + +// fetchIssueReadEnrichment runs one GraphQL nodes() query for the given issue node ID and returns +// its field values, parent reference, and sub-issue summary counts. The parent title is sanitized +// here because it may originate from a different repository. +func fetchIssueReadEnrichment(ctx context.Context, gqlClient *githubv4.Client, nodeID string) (*issueReadEnrichment, error) { + var q issueReadEnrichmentQuery + if err := gqlClient.Query(ctx, &q, map[string]any{"ids": []githubv4.ID{githubv4.ID(nodeID)}}); err != nil { + return nil, err + } + + enrichment := &issueReadEnrichment{} + for _, n := range q.Nodes { + idStr, ok := n.Issue.ID.(string) + if !ok || idStr != nodeID { + continue + } + + vals := make([]MinimalFieldValue, 0, len(n.Issue.IssueFieldValues.Nodes)) + for _, fv := range n.Issue.IssueFieldValues.Nodes { + if m, ok := fragmentToMinimalFieldValue(fv); ok { + vals = append(vals, m) + } + } + enrichment.FieldValues = vals + + if p := n.Issue.Parent; p != nil { + enrichment.Parent = &issueReadParent{ + Ref: MinimalIssueRef{ + Number: int(p.Number), + Title: sanitize.Sanitize(string(p.Title)), + State: string(p.State), + URL: string(p.URL), + Repository: string(p.Repository.NameWithOwner), + }, + AuthorLogin: string(p.Author.Login), + } + } + + enrichment.SubIssuesSummary = MinimalSubIssuesSummary{ + Total: int(n.Issue.SubIssuesSummary.Total), + Completed: int(n.Issue.SubIssuesSummary.Completed), + PercentCompleted: int(n.Issue.SubIssuesSummary.PercentCompleted), + } + break + } + return enrichment, nil +} + // searchIssuesHandler runs the REST issues search, enriches each hit with custom field values // fetched via a single follow-up GraphQL nodes() query, and applies any post-process options // (e.g. IFC labelling). diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 2903f4b845..191b6aa710 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -49,6 +49,18 @@ func newRepoAccessHTTPClient() *http.Client { return &http.Client{Transport: &repoAccessMockTransport{responses: responses}} } +const issueReadEnrichmentQueryString = "query($ids:[ID!]!){nodes(ids: $ids){... on Issue{id,issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}},parent{number,title,state,url,author{login},repository{nameWithOwner}},subIssuesSummary{total,completed,percentCompleted}}}}" + +// newIssueReadEnrichmentMatcher builds a matcher for the issue_read `get` enrichment query for a +// single issue node ID. +func newIssueReadEnrichmentMatcher(nodeID string, response githubv4mock.GQLResponse) githubv4mock.Matcher { + return githubv4mock.NewQueryMatcher( + issueReadEnrichmentQueryString, + map[string]any{"ids": []any{nodeID}}, + response, + ) +} + func (rt *repoAccessMockTransport) RoundTrip(req *http.Request) (*http.Response, error) { if req.Body == nil { return nil, fmt.Errorf("missing request body") @@ -495,9 +507,6 @@ func Test_GetIssue_FieldValues_Enriched(t *testing.T) { GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssueWithFields), }) - gqlVars := map[string]any{ - "ids": []any{"I_node_99"}, - } gqlResponse := githubv4mock.DataResponse(map[string]any{ "nodes": []map[string]any{ { @@ -516,12 +525,13 @@ func Test_GetIssue_FieldValues_Enriched(t *testing.T) { }, }, }, + "parent": nil, + "subIssuesSummary": map[string]any{"total": 0, "completed": 0, "percentCompleted": 0}, }, }, }) - const nodesQueryString = "query($ids:[ID!]!){nodes(ids: $ids){... on Issue{id,issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}}}}" - matcher := githubv4mock.NewQueryMatcher(nodesQueryString, gqlVars, gqlResponse) + matcher := newIssueReadEnrichmentMatcher("I_node_99", gqlResponse) gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) cache := stubRepoAccessCache(nil, 15*time.Minute) @@ -558,6 +568,244 @@ func Test_GetIssue_FieldValues_Enriched(t *testing.T) { assert.Equal(t, "P1", returnedIssue.FieldValues[0].Value) assert.Equal(t, "estimate", returnedIssue.FieldValues[1].Field) assert.Equal(t, "2.5", returnedIssue.FieldValues[1].Value) + + // With no parent and no sub-issues, the routing booleans are explicit false and the + // optional relationship payloads are omitted. + assert.Equal(t, github.Ptr(false), returnedIssue.HasParent, "has_parent should be false without a parent") + assert.Equal(t, github.Ptr(false), returnedIssue.HasChildren, "has_children should be false without sub-issues") + assert.Nil(t, returnedIssue.Parent, "parent should be omitted when there is no parent") + assert.Nil(t, returnedIssue.SubIssuesSummary, "sub_issues_summary should be omitted with no sub-issues") +} + +func Test_GetIssue_HierarchyEnrichment(t *testing.T) { + mockIssue := &github.Issue{ + Number: github.Ptr(2990), + NodeID: github.Ptr("I_node_2990"), + Title: github.Ptr("Child issue"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/2990"), + User: &github.User{Login: github.Ptr("author")}, + } + + parentNode := map[string]any{ + "number": 2820, + "title": "Parent issue", + "state": "OPEN", + "url": "https://github.com/owner/repo/issues/2820", + "author": map[string]any{"login": "parentauthor"}, + "repository": map[string]any{ + "nameWithOwner": "owner/repo", + }, + } + + tests := []struct { + name string + parent any + summary map[string]any + lockdown bool + assertResponse func(t *testing.T, issue MinimalIssue) + }{ + { + name: "parent and children present", + parent: parentNode, + summary: map[string]any{"total": 4, "completed": 1, "percentCompleted": 25}, + assertResponse: func(t *testing.T, issue MinimalIssue) { + assert.Equal(t, github.Ptr(true), issue.HasParent) + assert.Equal(t, github.Ptr(true), issue.HasChildren) + require.NotNil(t, issue.Parent) + assert.Equal(t, 2820, issue.Parent.Number) + assert.Equal(t, "Parent issue", issue.Parent.Title) + assert.Equal(t, "OPEN", issue.Parent.State) + assert.Equal(t, "owner/repo", issue.Parent.Repository) + require.NotNil(t, issue.SubIssuesSummary) + assert.Equal(t, 4, issue.SubIssuesSummary.Total) + assert.Equal(t, 1, issue.SubIssuesSummary.Completed) + assert.Equal(t, 25, issue.SubIssuesSummary.PercentCompleted) + }, + }, + { + name: "no parent omits parent and sets has_parent false", + parent: nil, + summary: map[string]any{"total": 0, "completed": 0, "percentCompleted": 0}, + assertResponse: func(t *testing.T, issue MinimalIssue) { + assert.Equal(t, github.Ptr(false), issue.HasParent) + assert.Nil(t, issue.Parent) + }, + }, + { + name: "has_children is false when total is zero even with completed nonzero", + parent: nil, + summary: map[string]any{"total": 0, "completed": 1, "percentCompleted": 0}, + assertResponse: func(t *testing.T, issue MinimalIssue) { + assert.Equal(t, github.Ptr(false), issue.HasChildren) + assert.Nil(t, issue.SubIssuesSummary) + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue), + }) + + gqlResponse := githubv4mock.DataResponse(map[string]any{ + "nodes": []map[string]any{ + { + "id": "I_node_2990", + "issueFieldValues": map[string]any{"nodes": []map[string]any{}}, + "parent": tc.parent, + "subIssuesSummary": tc.summary, + }, + }, + }) + matcher := newIssueReadEnrichmentMatcher("I_node_2990", gqlResponse) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) + + deps := BaseDeps{ + Client: mustNewGHClient(t, restClient), + GQLClient: gqlClient, + RepoAccessCache: stubRepoAccessCache(nil, 15*time.Minute), + Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdown}), + } + serverTool := IssueRead(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "method": "get", + "owner": "owner", + "repo": "repo", + "issue_number": float64(2990), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, result.IsError, "expected result to not be an error") + + var returnedIssue MinimalIssue + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returnedIssue)) + tc.assertResponse(t, returnedIssue) + }) + } +} + +func Test_GetIssue_HierarchyEnrichment_Lockdown(t *testing.T) { + mockIssue := &github.Issue{ + Number: github.Ptr(2990), + NodeID: github.Ptr("I_node_2990"), + Title: github.Ptr("Child issue"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/2990"), + User: &github.User{Login: github.Ptr("author")}, + } + + parentNode := map[string]any{ + "number": 2820, + "title": "Sensitive parent title", + "state": "OPEN", + "url": "https://github.com/owner/repo/issues/2820", + "author": map[string]any{"login": "parentauthor"}, + "repository": map[string]any{ + "nameWithOwner": "owner/repo", + }, + } + + // In lockdown mode the issue's own author must be verified as safe (mirrors the existing + // REST lockdown gate). The repo-access cache performs push-access checks against its own + // REST client: the issue author ("author") has write access, while the parent author + // ("parentauthor") only has read access and so cannot be verified as safe. The parent + // reference is therefore omitted entirely, while has_parent stays true so an agent can + // still route to get_parent. + restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue), + }) + permClient := mockRESTPermissionServer(t, "read", map[string]string{"author": "write"}) + + gqlResponse := githubv4mock.DataResponse(map[string]any{ + "nodes": []map[string]any{ + { + "id": "I_node_2990", + "issueFieldValues": map[string]any{"nodes": []map[string]any{}}, + "parent": parentNode, + "subIssuesSummary": map[string]any{"total": 0, "completed": 0, "percentCompleted": 0}, + }, + }, + }) + matcher := newIssueReadEnrichmentMatcher("I_node_2990", gqlResponse) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) + + deps := BaseDeps{ + Client: mustNewGHClient(t, restClient), + GQLClient: gqlClient, + RepoAccessCache: stubRepoAccessCache(permClient, 15*time.Minute), + Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": true}), + } + serverTool := IssueRead(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "method": "get", + "owner": "owner", + "repo": "repo", + "issue_number": float64(2990), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, result.IsError, "expected result to not be an error") + + var returnedIssue MinimalIssue + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returnedIssue)) + + require.Nil(t, returnedIssue.Parent, "parent reference should be omitted under lockdown when it cannot be verified safe") + assert.Equal(t, github.Ptr(true), returnedIssue.HasParent, "has_parent should still be true so agents can route to get_parent") +} + +func Test_GetIssue_HierarchyEnrichment_QueryFailureReturnsBaseIssue(t *testing.T) { + mockIssue := &github.Issue{ + Number: github.Ptr(2990), + NodeID: github.Ptr("I_node_2990"), + Title: github.Ptr("Child issue"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/2990"), + User: &github.User{Login: github.Ptr("author")}, + } + + restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssue), + }) + + matcher := newIssueReadEnrichmentMatcher("I_node_2990", githubv4mock.ErrorResponse("enrichment failed")) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) + + deps := BaseDeps{ + Client: mustNewGHClient(t, restClient), + GQLClient: gqlClient, + RepoAccessCache: stubRepoAccessCache(nil, 15*time.Minute), + Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}), + } + serverTool := IssueRead(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "method": "get", + "owner": "owner", + "repo": "repo", + "issue_number": float64(2990), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.NotNil(t, result) + // Relationship enrichment must never fail `get`: the base issue is still returned. + require.False(t, result.IsError, "enrichment failure should not fail get") + + var returnedIssue MinimalIssue + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returnedIssue)) + assert.Equal(t, 2990, returnedIssue.Number) + assert.Nil(t, returnedIssue.HasParent) + assert.Nil(t, returnedIssue.HasChildren) + assert.Nil(t, returnedIssue.Parent) + assert.Nil(t, returnedIssue.SubIssuesSummary) } func Test_SearchIssues(t *testing.T) { diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 256bdcb911..b9179bebf4 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -342,6 +342,32 @@ type MinimalIssue struct { IssueType string `json:"issue_type,omitempty"` IssueFieldValues []MinimalIssueFieldValue `json:"issue_field_values,omitempty"` FieldValues []MinimalFieldValue `json:"field_values,omitempty"` + + // Hierarchy relationship signals. HasParent and HasChildren are populated when + // hierarchy enrichment succeeds; SubIssuesSummary is populated when children exist, + // and Parent when a parent exists and may be surfaced (under lockdown an unverified + // parent reference is omitted while HasParent stays true). + HasParent *bool `json:"has_parent,omitempty"` + HasChildren *bool `json:"has_children,omitempty"` + Parent *MinimalIssueRef `json:"parent,omitempty"` + SubIssuesSummary *MinimalSubIssuesSummary `json:"sub_issues_summary,omitempty"` +} + +// MinimalIssueRef is a compact reference to a related issue (e.g. a parent issue). +// Its keys mirror the get_parent (GetIssueParent) response shape. +type MinimalIssueRef struct { + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` + URL string `json:"url"` + Repository string `json:"repository,omitempty"` +} + +// MinimalSubIssuesSummary holds the native GraphQL subIssuesSummary counts for an issue. +type MinimalSubIssuesSummary struct { + Total int `json:"total"` + Completed int `json:"completed"` + PercentCompleted int `json:"percent_completed"` } // MinimalIssuesResponse is the trimmed output for a paginated list of issues. From 40db5e37b2cf0a7afc20d97df91c88739639dc58 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 7 Jul 2026 14:00:57 +0200 Subject: [PATCH 09/16] build(deps): bump docker/build-push-action from 7.2.0 to 7.3.0 (#2828) Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 7.2.0 to 7.3.0. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/f9f3042f7e2789586610d6e8b85c8f03e5195baf...53b7df96c91f9c12dcc8a07bcb9ccacbed38856a) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-version: 7.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Iryna Kulakova <52420926+IrynaKulakova@users.noreply.github.com> --- .github/workflows/docker-publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 088ce2bd8a..51c3e8d8b0 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -106,7 +106,7 @@ jobs: # https://github.com/docker/build-push-action - name: Build and push Docker image id: build-and-push - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . push: ${{ github.event_name != 'pull_request' }} From 778f5bb6a3120719bfeaeefb47169ad0739ebee8 Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:08:59 +0100 Subject: [PATCH 10/16] =?UTF-8?q?chore(deps):=20bump=20go-github=20v87=20?= =?UTF-8?q?=E2=86=92=20v89=20and=20resolve=20breaking=20changes=20(#2840)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(deps): bump go-github v87 -> v89 and resolve breaking changes Bumps google/go-github from v87 to v89 across the module and fixes the resulting breaking changes. No tool or behavior changes. - Rewrite all import paths go-github/v87 -> go-github/v89. - gists.go: Gists.Create now takes CreateGistRequest by value and Gists.Edit is renamed to Gists.Update taking UpdateGistRequest. - repositories_test.go: adapt to RepositoryRelease fields that became value types in v89 (ID, TagName, Draft). - Regenerate third-party license files for the new module path. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Don't clear gist description on update when omitted update_gist always sent Description as a pointer to the OptionalParam zero value (""), so omitting description would overwrite an existing gist description with an empty string. Only set UpdateGistRequest.Description when the caller actually provided the argument; an explicit empty string still clears it. Adds a test asserting the description key is absent from the PATCH body when omitted and present when set. This addresses a pre-existing behavior surfaced while migrating to the v89 gist request types. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- e2e/e2e_test.go | 2 +- go.mod | 2 +- go.sum | 4 +- internal/ghmcp/server.go | 2 +- pkg/errors/error.go | 2 +- pkg/errors/error_test.go | 2 +- pkg/github/actions.go | 2 +- pkg/github/actions_test.go | 2 +- pkg/github/code_quality_test.go | 2 +- pkg/github/code_scanning.go | 2 +- pkg/github/code_scanning_test.go | 2 +- pkg/github/context_tools_test.go | 2 +- pkg/github/copilot.go | 2 +- pkg/github/copilot_test.go | 2 +- pkg/github/dependabot.go | 2 +- pkg/github/dependabot_test.go | 2 +- pkg/github/dependencies.go | 2 +- pkg/github/discussions.go | 2 +- pkg/github/discussions_test.go | 2 +- pkg/github/gists.go | 29 +++++---- pkg/github/gists_test.go | 65 ++++++++++++++++++- pkg/github/git.go | 2 +- pkg/github/git_test.go | 2 +- pkg/github/granular_tools_test.go | 2 +- pkg/github/helper_test.go | 2 +- pkg/github/ifc_labels.go | 2 +- pkg/github/issues.go | 2 +- pkg/github/issues_delete_test.go | 2 +- pkg/github/issues_granular.go | 2 +- pkg/github/issues_test.go | 2 +- pkg/github/minimal_types.go | 2 +- pkg/github/notifications.go | 2 +- pkg/github/notifications_test.go | 2 +- pkg/github/params.go | 2 +- pkg/github/params_test.go | 2 +- pkg/github/projects.go | 2 +- pkg/github/pullrequests.go | 2 +- pkg/github/pullrequests_granular.go | 2 +- pkg/github/pullrequests_test.go | 2 +- pkg/github/repositories.go | 2 +- pkg/github/repositories_helper.go | 2 +- pkg/github/repositories_test.go | 32 ++++----- pkg/github/repository_resource.go | 2 +- pkg/github/repository_resource_completions.go | 2 +- .../repository_resource_completions_test.go | 2 +- pkg/github/search.go | 2 +- pkg/github/search_test.go | 2 +- pkg/github/search_utils.go | 2 +- pkg/github/secret_scanning.go | 2 +- pkg/github/secret_scanning_test.go | 2 +- pkg/github/security_advisories.go | 2 +- pkg/github/security_advisories_test.go | 2 +- pkg/github/server_test.go | 2 +- pkg/github/tools.go | 2 +- pkg/github/ui_tools.go | 2 +- pkg/github/ui_tools_test.go | 2 +- pkg/lockdown/lockdown.go | 2 +- pkg/lockdown/lockdown_test.go | 2 +- pkg/raw/raw.go | 2 +- pkg/raw/raw_test.go | 2 +- third-party-licenses.darwin.md | 2 +- third-party-licenses.linux.md | 2 +- third-party-licenses.windows.md | 2 +- .../go-github/{v87 => v89}/github/LICENSE | 0 64 files changed, 159 insertions(+), 89 deletions(-) rename third-party/github.com/google/go-github/{v87 => v89}/github/LICENSE (100%) diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 73d5f271c9..4be9a45aa8 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -18,7 +18,7 @@ import ( "github.com/github/github-mcp-server/internal/ghmcp" "github.com/github/github-mcp-server/pkg/github" "github.com/github/github-mcp-server/pkg/translations" - gogithub "github.com/google/go-github/v87/github" + gogithub "github.com/google/go-github/v89/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" ) diff --git a/go.mod b/go.mod index 90f88b1c45..358a271a7a 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.0 require ( github.com/go-chi/chi/v5 v5.3.0 github.com/go-viper/mapstructure/v2 v2.5.0 - github.com/google/go-github/v87 v87.0.0 + github.com/google/go-github/v89 v89.0.0 github.com/google/jsonschema-go v0.4.3 github.com/josephburnett/jd/v2 v2.5.0 github.com/lithammer/fuzzysearch v1.1.8 diff --git a/go.sum b/go.sum index 360e17fb48..f3f23b549a 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-github/v87 v87.0.0 h1:9Ck3dcOxWJyfsN8tzdah4YvmqB/7ZsstMglv/PkOsl0= -github.com/google/go-github/v87 v87.0.0/go.mod h1:hGUoT5pwm/ck5uLL+wroSVQfg8mpe+buxllCcGV4VaM= +github.com/google/go-github/v89 v89.0.0 h1:35bEK5XoEcF3PZrlVbl9XN63f5BcJRA/UGkxeC9xPg0= +github.com/google/go-github/v89 v89.0.0/go.mod h1:QLcbU0ipeAqQuR5KSg8c2lql4Qk1EwJ2dWz/0rP4Nho= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 1bf84453c8..2267dd5d62 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -25,7 +25,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - gogithub "github.com/google/go-github/v87/github" + gogithub "github.com/google/go-github/v89/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" ) diff --git a/pkg/errors/error.go b/pkg/errors/error.go index a1b35d697d..4a791a7435 100644 --- a/pkg/errors/error.go +++ b/pkg/errors/error.go @@ -8,7 +8,7 @@ import ( "time" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/errors/error_test.go b/pkg/errors/error_test.go index 3c899b6b58..414b7008f7 100644 --- a/pkg/errors/error_test.go +++ b/pkg/errors/error_test.go @@ -3,7 +3,7 @@ package errors import ( "context" "fmt" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 9dac877736..c16efa0f18 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -17,7 +17,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 371bbbe9dc..4ed9c87d69 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/code_quality_test.go b/pkg/github/code_quality_test.go index 3971e5a0d6..e84e1c5e33 100644 --- a/pkg/github/code_quality_test.go +++ b/pkg/github/code_quality_test.go @@ -6,7 +6,7 @@ import ( "net/http" "testing" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/code_scanning.go b/pkg/github/code_scanning.go index fb8b7a79c8..74a1c7e0fa 100644 --- a/pkg/github/code_scanning.go +++ b/pkg/github/code_scanning.go @@ -12,7 +12,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/code_scanning_test.go b/pkg/github/code_scanning_test.go index 3d0f261d2a..c9978d8ac5 100644 --- a/pkg/github/code_scanning_test.go +++ b/pkg/github/code_scanning_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index 082b467135..65c4741a4b 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -10,7 +10,7 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/copilot.go b/pkg/github/copilot.go index 017bb98bc9..62b18350eb 100644 --- a/pkg/github/copilot.go +++ b/pkg/github/copilot.go @@ -17,7 +17,7 @@ import ( "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/go-viper/mapstructure/v2" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/copilot_test.go b/pkg/github/copilot_test.go index b86f26f474..f52c8eecc5 100644 --- a/pkg/github/copilot_test.go +++ b/pkg/github/copilot_test.go @@ -10,7 +10,7 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" diff --git a/pkg/github/dependabot.go b/pkg/github/dependabot.go index 1ac6b1b44c..f56a33134a 100644 --- a/pkg/github/dependabot.go +++ b/pkg/github/dependabot.go @@ -13,7 +13,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/dependabot_test.go b/pkg/github/dependabot_test.go index 5236c6d349..dba0a5603f 100644 --- a/pkg/github/dependabot_test.go +++ b/pkg/github/dependabot_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/github/dependencies.go b/pkg/github/dependencies.go index 1141fbce89..c57fa2f0dc 100644 --- a/pkg/github/dependencies.go +++ b/pkg/github/dependencies.go @@ -18,7 +18,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - gogithub "github.com/google/go-github/v87/github" + gogithub "github.com/google/go-github/v89/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" ) diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index 68ed014b2b..8643acc7ef 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -12,7 +12,7 @@ import ( "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/go-viper/mapstructure/v2" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index 36fdb6c43a..c2c0426218 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -9,7 +9,7 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" diff --git a/pkg/github/gists.go b/pkg/github/gists.go index 9c319176bc..0ea883ec7d 100644 --- a/pkg/github/gists.go +++ b/pkg/github/gists.go @@ -13,7 +13,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -224,13 +224,12 @@ func CreateGist(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultError(err.Error()), nil, nil } - files := make(map[github.GistFilename]github.GistFile) - files[github.GistFilename(filename)] = github.GistFile{ - Filename: github.Ptr(filename), - Content: github.Ptr(content), + files := make(map[github.GistFilename]*github.CreateGistFile) + files[github.GistFilename(filename)] = &github.CreateGistFile{ + Content: content, } - gist := &github.Gist{ + gist := github.CreateGistRequest{ Files: files, Public: github.Ptr(public), Description: github.Ptr(description), @@ -326,15 +325,23 @@ func UpdateGist(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultError(err.Error()), nil, nil } - files := make(map[github.GistFilename]github.GistFile) - files[github.GistFilename(filename)] = github.GistFile{ + files := make(map[github.GistFilename]*github.UpdateGistFile) + files[github.GistFilename(filename)] = &github.UpdateGistFile{ Filename: github.Ptr(filename), Content: github.Ptr(content), } - gist := &github.Gist{ + // Only set Description when the caller actually provided it, so + // omitting it preserves the gist's existing description. Passing an + // explicit empty string still clears it. + var descriptionPtr *string + if _, ok := args["description"]; ok { + descriptionPtr = github.Ptr(description) + } + + gist := github.UpdateGistRequest{ Files: files, - Description: github.Ptr(description), + Description: descriptionPtr, } client, err := deps.GetClient(ctx) @@ -342,7 +349,7 @@ func UpdateGist(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } - updatedGist, resp, err := client.Gists.Edit(ctx, gistID, gist) + updatedGist, resp, err := client.Gists.Update(ctx, gistID, gist) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update gist", resp, err), nil, nil } diff --git a/pkg/github/gists_test.go b/pkg/github/gists_test.go index 342cd0c8f5..a0e153b033 100644 --- a/pkg/github/gists_test.go +++ b/pkg/github/gists_test.go @@ -9,7 +9,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -581,3 +581,66 @@ func Test_UpdateGist(t *testing.T) { }) } } + +func Test_UpdateGist_DescriptionOnlySentWhenProvided(t *testing.T) { + serverTool := UpdateGist(translations.NullTranslationHelper) + + updatedGist := &github.Gist{ + ID: github.Ptr("existing-gist-id"), + HTMLURL: github.Ptr("https://gist.github.com/user/existing-gist-id"), + } + + cases := []struct { + name string + args map[string]any + wantDescPresent bool + wantDescValue string + }{ + { + name: "omitted description is not sent, preserving the existing one", + args: map[string]any{ + "gist_id": "existing-gist-id", + "filename": "updated.go", + "content": "package main", + }, + wantDescPresent: false, + }, + { + name: "explicit empty description is sent to clear it", + args: map[string]any{ + "gist_id": "existing-gist-id", + "filename": "updated.go", + "content": "package main", + "description": "", + }, + wantDescPresent: true, + wantDescValue: "", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var gotBody map[string]any + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchGistsByGistID: func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, json.NewDecoder(r.Body).Decode(&gotBody)) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(MustMarshal(updatedGist)) + }, + })) + deps := BaseDeps{Client: client} + handler := serverTool.Handler(deps) + request := createMCPRequest(tc.args) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + desc, present := gotBody["description"] + assert.Equal(t, tc.wantDescPresent, present, "description key presence in PATCH body") + if tc.wantDescPresent { + assert.Equal(t, tc.wantDescValue, desc) + } + }) + } +} diff --git a/pkg/github/git.go b/pkg/github/git.go index bf88aad770..5b0c9b79b2 100644 --- a/pkg/github/git.go +++ b/pkg/github/git.go @@ -12,7 +12,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/git_test.go b/pkg/github/git_test.go index 1ad7147507..67921d6045 100644 --- a/pkg/github/git_test.go +++ b/pkg/github/git_test.go @@ -9,7 +9,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go index 8cd311c022..58fd904e88 100644 --- a/pkg/github/granular_tools_test.go +++ b/pkg/github/granular_tools_test.go @@ -13,7 +13,7 @@ import ( transportpkg "github.com/github/github-mcp-server/pkg/http/transport" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/translations" - gogithub "github.com/google/go-github/v87/github" + gogithub "github.com/google/go-github/v89/github" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index 80adb19b4a..c5a73d9667 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - gogithub "github.com/google/go-github/v87/github" + gogithub "github.com/google/go-github/v89/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" testifymock "github.com/stretchr/testify/mock" diff --git a/pkg/github/ifc_labels.go b/pkg/github/ifc_labels.go index 9ab46b5136..d91a9b16e2 100644 --- a/pkg/github/ifc_labels.go +++ b/pkg/github/ifc_labels.go @@ -4,7 +4,7 @@ import ( "context" "github.com/github/github-mcp-server/pkg/ifc" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index a40afa172e..3d94ac856b 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -19,7 +19,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/issues_delete_test.go b/pkg/github/issues_delete_test.go index f61b65ece0..54f515ba5c 100644 --- a/pkg/github/issues_delete_test.go +++ b/pkg/github/issues_delete_test.go @@ -10,7 +10,7 @@ import ( "testing" "github.com/github/github-mcp-server/internal/githubv4mock" - gogithub "github.com/google/go-github/v87/github" + gogithub "github.com/google/go-github/v89/github" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go index c279835e0c..c1eb556c9c 100644 --- a/pkg/github/issues_granular.go +++ b/pkg/github/issues_granular.go @@ -13,7 +13,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 191b6aa710..dbd246af94 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -18,7 +18,7 @@ import ( transportpkg "github.com/github/github-mcp-server/pkg/http/transport" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index b9179bebf4..66c39b2917 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -7,7 +7,7 @@ import ( "strings" "time" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/github/github-mcp-server/pkg/sanitize" ) diff --git a/pkg/github/notifications.go b/pkg/github/notifications.go index 1504757a7f..618cbdef59 100644 --- a/pkg/github/notifications.go +++ b/pkg/github/notifications.go @@ -14,7 +14,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/notifications_test.go b/pkg/github/notifications_test.go index bcfc28abc2..0262ab8388 100644 --- a/pkg/github/notifications_test.go +++ b/pkg/github/notifications_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/params.go b/pkg/github/params.go index a6b43375ef..9be51b94b9 100644 --- a/pkg/github/params.go +++ b/pkg/github/params.go @@ -6,7 +6,7 @@ import ( "math" "strconv" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" ) diff --git a/pkg/github/params_test.go b/pkg/github/params_test.go index b00efeb10c..cbac37fee5 100644 --- a/pkg/github/params_test.go +++ b/pkg/github/params_test.go @@ -5,7 +5,7 @@ import ( "math" "testing" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/stretchr/testify/assert" ) diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 8f24cde7e2..0758e3c4e4 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -15,7 +15,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 9fc8455988..9b5b1b0750 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -8,7 +8,7 @@ import ( "net/http" "github.com/go-viper/mapstructure/v2" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/pullrequests_granular.go b/pkg/github/pullrequests_granular.go index d83d648533..c727beb6e5 100644 --- a/pkg/github/pullrequests_granular.go +++ b/pkg/github/pullrequests_granular.go @@ -12,7 +12,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - gogithub "github.com/google/go-github/v87/github" + gogithub "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index bcae87c94c..11f51cece4 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -11,7 +11,7 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 949a180081..dc457c2965 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -18,7 +18,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/repositories_helper.go b/pkg/github/repositories_helper.go index be377f773e..9795bdc109 100644 --- a/pkg/github/repositories_helper.go +++ b/pkg/github/repositories_helper.go @@ -10,7 +10,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index e5531cc55b..2d44e9ed54 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -15,7 +15,7 @@ import ( "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" @@ -3519,13 +3519,13 @@ func Test_ListReleases(t *testing.T) { mockReleases := []*github.RepositoryRelease{ { - ID: github.Ptr(int64(1)), - TagName: github.Ptr("v1.0.0"), + ID: 1, + TagName: "v1.0.0", Name: github.Ptr("First Release"), }, { - ID: github.Ptr(int64(2)), - TagName: github.Ptr("v0.9.0"), + ID: 2, + TagName: "v0.9.0", Name: github.Ptr("Beta Release"), }, } @@ -3596,7 +3596,7 @@ func Test_ListReleases(t *testing.T) { require.NoError(t, err) assert.Len(t, returnedReleases, len(tc.expectedResult)) for i := range returnedReleases { - assert.Equal(t, *tc.expectedResult[i].TagName, returnedReleases[i].TagName) + assert.Equal(t, tc.expectedResult[i].TagName, returnedReleases[i].TagName) } }) } @@ -3617,8 +3617,8 @@ func Test_GetLatestRelease(t *testing.T) { assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"}) mockRelease := &github.RepositoryRelease{ - ID: github.Ptr(int64(1)), - TagName: github.Ptr("v1.0.0"), + ID: 1, + TagName: "v1.0.0", Name: github.Ptr("First Release"), } @@ -3686,7 +3686,7 @@ func Test_GetLatestRelease(t *testing.T) { var returnedRelease github.RepositoryRelease err = json.Unmarshal([]byte(textContent.Text), &returnedRelease) require.NoError(t, err) - assert.Equal(t, *tc.expectedResult.TagName, *returnedRelease.TagName) + assert.Equal(t, tc.expectedResult.TagName, returnedRelease.TagName) }) } } @@ -3707,8 +3707,8 @@ func Test_GetReleaseByTag(t *testing.T) { assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "tag"}) mockRelease := &github.RepositoryRelease{ - ID: github.Ptr(int64(1)), - TagName: github.Ptr("v1.0.0"), + ID: 1, + TagName: "v1.0.0", Name: github.Ptr("Release v1.0.0"), Body: github.Ptr("This is the first stable release."), Assets: []*github.ReleaseAsset{ @@ -3848,8 +3848,8 @@ func Test_GetReleaseByTag(t *testing.T) { err = json.Unmarshal([]byte(textContent.Text), &returnedRelease) require.NoError(t, err) - assert.Equal(t, *tc.expectedResult.ID, *returnedRelease.ID) - assert.Equal(t, *tc.expectedResult.TagName, *returnedRelease.TagName) + assert.Equal(t, tc.expectedResult.ID, returnedRelease.ID) + assert.Equal(t, tc.expectedResult.TagName, returnedRelease.TagName) assert.Equal(t, *tc.expectedResult.Name, *returnedRelease.Name) if tc.expectedResult.Body != nil { assert.Equal(t, *tc.expectedResult.Body, *returnedRelease.Body) @@ -3875,10 +3875,10 @@ func Test_GetReleaseByTag_IFC_FeatureFlag(t *testing.T) { makeRelease := func(draft bool) *github.RepositoryRelease { return &github.RepositoryRelease{ - ID: github.Ptr(int64(1)), - TagName: github.Ptr("v1.0.0"), + ID: 1, + TagName: "v1.0.0", Name: github.Ptr("v1.0.0"), - Draft: github.Ptr(draft), + Draft: draft, } } diff --git a/pkg/github/repository_resource.go b/pkg/github/repository_resource.go index 3ab4cf3906..69ce8d3f20 100644 --- a/pkg/github/repository_resource.go +++ b/pkg/github/repository_resource.go @@ -17,7 +17,7 @@ import ( "github.com/github/github-mcp-server/pkg/octicons" "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/yosida95/uritemplate/v3" ) diff --git a/pkg/github/repository_resource_completions.go b/pkg/github/repository_resource_completions.go index 18e7eb5f01..2c05d0531e 100644 --- a/pkg/github/repository_resource_completions.go +++ b/pkg/github/repository_resource_completions.go @@ -6,7 +6,7 @@ import ( "fmt" "strings" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/repository_resource_completions_test.go b/pkg/github/repository_resource_completions_test.go index 33df2761e6..22c083b466 100644 --- a/pkg/github/repository_resource_completions_test.go +++ b/pkg/github/repository_resource_completions_test.go @@ -6,7 +6,7 @@ import ( "fmt" "testing" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/search.go b/pkg/github/search.go index 23ccbd8387..9adaefab67 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -13,7 +13,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index 5ebf60842a..a9b1c0609e 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -9,7 +9,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go index 54213a2407..56eed37541 100644 --- a/pkg/github/search_utils.go +++ b/pkg/github/search_utils.go @@ -11,7 +11,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/secret_scanning.go b/pkg/github/secret_scanning.go index 18cfe73771..de444faea7 100644 --- a/pkg/github/secret_scanning.go +++ b/pkg/github/secret_scanning.go @@ -13,7 +13,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/secret_scanning_test.go b/pkg/github/secret_scanning_test.go index eb94fa5e9a..603f1e9eb2 100644 --- a/pkg/github/secret_scanning_test.go +++ b/pkg/github/secret_scanning_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/security_advisories.go b/pkg/github/security_advisories.go index 36e114c1dc..4f718de767 100644 --- a/pkg/github/security_advisories.go +++ b/pkg/github/security_advisories.go @@ -13,7 +13,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/security_advisories_test.go b/pkg/github/security_advisories_test.go index d02908610d..8c212d5843 100644 --- a/pkg/github/security_advisories_test.go +++ b/pkg/github/security_advisories_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go index 7f909f431c..bc891fac1f 100644 --- a/pkg/github/server_test.go +++ b/pkg/github/server_test.go @@ -16,7 +16,7 @@ import ( "github.com/github/github-mcp-server/pkg/observability/metrics" "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/translations" - gogithub "github.com/google/go-github/v87/github" + gogithub "github.com/google/go-github/v89/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" diff --git a/pkg/github/tools.go b/pkg/github/tools.go index e361a6cfa4..ec6716d5d4 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -5,7 +5,7 @@ import ( "slices" "strings" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/shurcooL/githubv4" "github.com/github/github-mcp-server/pkg/inventory" diff --git a/pkg/github/ui_tools.go b/pkg/github/ui_tools.go index 9ba157c3be..3fa4afcb15 100644 --- a/pkg/github/ui_tools.go +++ b/pkg/github/ui_tools.go @@ -14,7 +14,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/ui_tools_test.go b/pkg/github/ui_tools_test.go index 4ba6cc9aa8..4a4981875b 100644 --- a/pkg/github/ui_tools_test.go +++ b/pkg/github/ui_tools_test.go @@ -13,7 +13,7 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" diff --git a/pkg/lockdown/lockdown.go b/pkg/lockdown/lockdown.go index 238ccb06ee..e9231414a9 100644 --- a/pkg/lockdown/lockdown.go +++ b/pkg/lockdown/lockdown.go @@ -9,7 +9,7 @@ import ( "sync" "time" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/muesli/cache2go" "github.com/shurcooL/githubv4" ) diff --git a/pkg/lockdown/lockdown_test.go b/pkg/lockdown/lockdown_test.go index f16d6a062c..887fcfde6f 100644 --- a/pkg/lockdown/lockdown_test.go +++ b/pkg/lockdown/lockdown_test.go @@ -10,7 +10,7 @@ import ( "time" "github.com/github/github-mcp-server/internal/githubv4mock" - gogithub "github.com/google/go-github/v87/github" + gogithub "github.com/google/go-github/v89/github" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/require" ) diff --git a/pkg/raw/raw.go b/pkg/raw/raw.go index 4f794ac1f6..7234dc69df 100644 --- a/pkg/raw/raw.go +++ b/pkg/raw/raw.go @@ -6,7 +6,7 @@ import ( "net/http" "net/url" - gogithub "github.com/google/go-github/v87/github" + gogithub "github.com/google/go-github/v89/github" ) // GetRawClientFn is a function type that returns a RawClient instance. diff --git a/pkg/raw/raw_test.go b/pkg/raw/raw_test.go index 60137684d7..c921d15149 100644 --- a/pkg/raw/raw_test.go +++ b/pkg/raw/raw_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "github.com/google/go-github/v87/github" + "github.com/google/go-github/v89/github" "github.com/stretchr/testify/require" ) diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index c2256493b7..88235f3f40 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -17,7 +17,7 @@ The following packages are included for the amd64, arm64 architectures. - [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.3.0/LICENSE)) - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.5.0/LICENSE)) - - [github.com/google/go-github/v87/github](https://pkg.go.dev/github.com/google/go-github/v87/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v87.0.0/LICENSE)) + - [github.com/google/go-github/v89/github](https://pkg.go.dev/github.com/google/go-github/v89/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v89.0.0/LICENSE)) - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.2.0/LICENSE)) - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.3/LICENSE)) - [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index 2e6e111931..e3762f5c04 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -17,7 +17,7 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.3.0/LICENSE)) - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.5.0/LICENSE)) - - [github.com/google/go-github/v87/github](https://pkg.go.dev/github.com/google/go-github/v87/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v87.0.0/LICENSE)) + - [github.com/google/go-github/v89/github](https://pkg.go.dev/github.com/google/go-github/v89/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v89.0.0/LICENSE)) - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.2.0/LICENSE)) - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.3/LICENSE)) - [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index f03f3fb51c..eb0743558a 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -17,7 +17,7 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.3.0/LICENSE)) - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.5.0/LICENSE)) - - [github.com/google/go-github/v87/github](https://pkg.go.dev/github.com/google/go-github/v87/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v87.0.0/LICENSE)) + - [github.com/google/go-github/v89/github](https://pkg.go.dev/github.com/google/go-github/v89/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v89.0.0/LICENSE)) - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.2.0/LICENSE)) - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.3/LICENSE)) - [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) diff --git a/third-party/github.com/google/go-github/v87/github/LICENSE b/third-party/github.com/google/go-github/v89/github/LICENSE similarity index 100% rename from third-party/github.com/google/go-github/v87/github/LICENSE rename to third-party/github.com/google/go-github/v89/github/LICENSE From 6787f085697c09f045acbba6c02c4504a878e8a0 Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Thu, 9 Jul 2026 11:39:20 +0100 Subject: [PATCH 11/16] Reimplement issue dependencies on go-github v89 REST API (#2839) * Reimplement issue dependencies on go-github v89 REST API Issue dependencies (blocked_by / blocking) were implemented on GraphQL via githubv4. Because the pinned githubv4 library predates the dependency mutations, the code hand-declared AddBlockedByInput / RemoveBlockedByInput and resolved issue numbers to node IDs with a custom aliased query. go-github v89 adds first-class REST methods (ListBlockedBy, ListBlocking, AddBlockedBy, RemoveBlockedBy), so switch issue_dependency_read and issue_dependency_write to those. This removes the workaround, aligns the tools with the rest of the REST-based issue tooling, and simplifies tests. - Rewrite issue_dependencies.go on REST: page-based pagination for reads, and a single Issues.Get to resolve the blocking issue's database ID for writes. Preserve the tool surface, self-dependency guard and cross-repo support. - Rewrite the issue dependency tests on the REST mock helpers. - Regenerate the read toolsnap (cursor -> page pagination) and docs. Refs #950 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Normalize issue dependency ref state to match other tools The REST issue-dependency endpoints return lower-case issue states (open/closed), whereas the previous GraphQL implementation and the sibling get_parent tool populate MinimalIssueRef.State from the GraphQL IssueState enum (OPEN/CLOSED). Upper-case the state in issueToDependencyRef so the field stays consistent across every tool that emits a MinimalIssueRef, and guard against a nil issue while here. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/feature-flags.md | 2 +- docs/insiders-features.md | 2 +- ...dependency_read_ff_issue_dependencies.snap | 9 +- pkg/github/issue_dependencies.go | 332 ++++++++--------- pkg/github/issue_dependencies_test.go | 342 ++++++++---------- 5 files changed, 292 insertions(+), 395 deletions(-) diff --git a/docs/feature-flags.md b/docs/feature-flags.md index 7b29698a93..32891af62e 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -310,7 +310,6 @@ runtime behavior (such as output formatting) won't appear here. - **issue_dependency_read** - Read issue dependencies - **Required OAuth Scopes**: `repo` - - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional) - `issue_number`: The number of the issue (number, required) - `method`: The read operation to perform on a single issue's dependencies. Options are: @@ -318,6 +317,7 @@ runtime behavior (such as output formatting) won't appear here. 2. get_blocking - List the issues that this issue blocks. (string, required) - `owner`: The owner of the repository (string, required) + - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: The name of the repository (string, required) diff --git a/docs/insiders-features.md b/docs/insiders-features.md index a7a3ceb5d3..10df187a91 100644 --- a/docs/insiders-features.md +++ b/docs/insiders-features.md @@ -105,7 +105,6 @@ The list below is generated from the Go source. It covers tool **inventory and s - **issue_dependency_read** - Read issue dependencies - **Required OAuth Scopes**: `repo` - - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional) - `issue_number`: The number of the issue (number, required) - `method`: The read operation to perform on a single issue's dependencies. Options are: @@ -113,6 +112,7 @@ The list below is generated from the Go source. It covers tool **inventory and s 2. get_blocking - List the issues that this issue blocks. (string, required) - `owner`: The owner of the repository (string, required) + - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: The name of the repository (string, required) diff --git a/pkg/github/__toolsnaps__/issue_dependency_read_ff_issue_dependencies.snap b/pkg/github/__toolsnaps__/issue_dependency_read_ff_issue_dependencies.snap index 37b4e8f113..cf6c3662f1 100644 --- a/pkg/github/__toolsnaps__/issue_dependency_read_ff_issue_dependencies.snap +++ b/pkg/github/__toolsnaps__/issue_dependency_read_ff_issue_dependencies.snap @@ -7,10 +7,6 @@ "description": "Read an issue's dependency relationships in a GitHub repository: the issues that block it (blocked_by) or the issues it blocks (blocking).", "inputSchema": { "properties": { - "after": { - "description": "Cursor for pagination. Use the cursor from the previous response.", - "type": "string" - }, "issue_number": { "description": "The number of the issue", "type": "number" @@ -27,6 +23,11 @@ "description": "The owner of the repository", "type": "string" }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, "perPage": { "description": "Results per page for pagination (min 1, max 100)", "maximum": 100, diff --git a/pkg/github/issue_dependencies.go b/pkg/github/issue_dependencies.go index 6fbcc9eed7..49533232d5 100644 --- a/pkg/github/issue_dependencies.go +++ b/pkg/github/issue_dependencies.go @@ -3,6 +3,8 @@ package github import ( "context" "fmt" + "io" + "net/http" "strings" ghErrors "github.com/github/github-mcp-server/pkg/errors" @@ -10,59 +12,11 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" + "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/shurcooL/githubv4" ) -// AddBlockedByInput represents the input for the addBlockedBy GraphQL mutation. -// The pinned githubv4 library predates the dependency mutations, so the input -// type is declared here. The Go type name must match the GraphQL input type name. -type AddBlockedByInput struct { - IssueID githubv4.ID `json:"issueId"` - BlockingIssueID githubv4.ID `json:"blockingIssueId"` - ClientMutationID *githubv4.String `json:"clientMutationId,omitempty"` -} - -// RemoveBlockedByInput represents the input for the removeBlockedBy GraphQL mutation. -type RemoveBlockedByInput struct { - IssueID githubv4.ID `json:"issueId"` - BlockingIssueID githubv4.ID `json:"blockingIssueId"` - ClientMutationID *githubv4.String `json:"clientMutationId,omitempty"` -} - -// dependencyIssueNode is the minimal projection returned for each related issue -// in a blocked-by / blocking listing. -type dependencyIssueNode struct { - Number githubv4.Int - Title githubv4.String - State githubv4.String - URL githubv4.String - Repository struct { - NameWithOwner githubv4.String - } -} - -// dependencyConnection mirrors the shape of an IssueConnection returned by the -// blockedBy / blocking fields. -type dependencyConnection struct { - TotalCount githubv4.Int - PageInfo struct { - HasNextPage githubv4.Boolean - EndCursor githubv4.String - } - Nodes []dependencyIssueNode -} - -// minimalDependencyIssue is the JSON-serialised form of a related issue. -type minimalDependencyIssue struct { - Number int `json:"number"` - Title string `json:"title"` - State string `json:"state"` - URL string `json:"url"` - Repository string `json:"repository"` -} - // IssueDependencyRead creates a tool to read an issue's blocked-by and blocking // relationships. It is a separate, feature-flagged tool (rather than a method on // the default issue_read) so the whole dependency capability can be gated as a @@ -95,7 +49,7 @@ Options are: }, Required: []string{"method", "owner", "repo", "issue_number"}, } - WithCursorPagination(schema) + WithPagination(schema) st := NewTool( ToolsetMetadataIssues, @@ -127,29 +81,23 @@ Options are: return utils.NewToolResultError(err.Error()), nil, nil } - if _, pageProvided := args["page"]; pageProvided { - return utils.NewToolResultError("This tool uses cursor-based pagination. Use the 'after' parameter with the 'endCursor' value from the previous response instead of 'page'."), nil, nil - } - pagination, err := OptionalCursorPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - gqlPagination, err := pagination.ToGraphQLParams() + pagination, err := OptionalPaginationParams(args) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + opts := &github.ListOptions{Page: pagination.Page, PerPage: pagination.PerPage} - gqlClient, err := deps.GetGQLClient(ctx) + client, err := deps.GetClient(ctx) if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } switch method { case "get_blocked_by": - result, err := GetIssueBlockedBy(ctx, gqlClient, owner, repo, issueNumber, gqlPagination) + result, err := GetIssueBlockedBy(ctx, client, owner, repo, issueNumber, opts) return result, nil, err case "get_blocking": - result, err := GetIssueBlocking(ctx, gqlClient, owner, repo, issueNumber, gqlPagination) + result, err := GetIssueBlocking(ctx, client, owner, repo, issueNumber, opts) return result, nil, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil @@ -159,79 +107,86 @@ Options are: return st } -func dependencyQueryVars(owner, repo string, issueNumber int, pagination *GraphQLPaginationParams) map[string]any { - vars := map[string]any{ - "owner": githubv4.String(owner), - "repo": githubv4.String(repo), - "issueNumber": githubv4.Int(issueNumber), // #nosec G115 - issue numbers are always small positive integers - "first": githubv4.Int(*pagination.First), +// GetIssueBlockedBy lists the issues that block the given issue. +func GetIssueBlockedBy(ctx context.Context, client *github.Client, owner, repo string, issueNumber int, opts *github.ListOptions) (*mcp.CallToolResult, error) { + issues, resp, err := client.Issues.ListBlockedBy(ctx, owner, repo, int64(issueNumber), opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list blocked-by issues", resp, err), nil } - if pagination.After != nil { - vars["after"] = githubv4.String(*pagination.After) - } else { - vars["after"] = (*githubv4.String)(nil) + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list blocked-by issues", resp, body), nil } - return vars + return dependencyReadResult(issues, resp), nil } -func dependencyResult(conn dependencyConnection) *mcp.CallToolResult { - issues := make([]minimalDependencyIssue, 0, len(conn.Nodes)) - for _, node := range conn.Nodes { - issues = append(issues, minimalDependencyIssue{ - Number: int(node.Number), - Title: string(node.Title), - State: string(node.State), - URL: string(node.URL), - Repository: string(node.Repository.NameWithOwner), - }) +// GetIssueBlocking lists the issues that the given issue blocks. +func GetIssueBlocking(ctx context.Context, client *github.Client, owner, repo string, issueNumber int, opts *github.ListOptions) (*mcp.CallToolResult, error) { + issues, resp, err := client.Issues.ListBlocking(ctx, owner, repo, int64(issueNumber), opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list blocking issues", resp, err), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list blocking issues", resp, body), nil + } + return dependencyReadResult(issues, resp), nil +} + +// dependencyReadResult projects a list of related issues into the minimal +// dependency shape and attaches page-based pagination info. +func dependencyReadResult(issues []*github.Issue, resp *github.Response) *mcp.CallToolResult { + refs := make([]MinimalIssueRef, 0, len(issues)) + for _, issue := range issues { + if issue == nil { + continue + } + refs = append(refs, issueToDependencyRef(issue)) } return MarshalledTextResult(map[string]any{ - "issues": issues, - "totalCount": int(conn.TotalCount), + "issues": refs, "pageInfo": map[string]any{ - "hasNextPage": bool(conn.PageInfo.HasNextPage), - "endCursor": string(conn.PageInfo.EndCursor), + "hasNextPage": resp.NextPage != 0, + "nextPage": resp.NextPage, }, }) } -// GetIssueBlockedBy lists the issues that block the given issue. -func GetIssueBlockedBy(ctx context.Context, client *githubv4.Client, owner, repo string, issueNumber int, pagination *GraphQLPaginationParams) (*mcp.CallToolResult, error) { - var query struct { - Repository struct { - Issue struct { - BlockedBy dependencyConnection `graphql:"blockedBy(first: $first, after: $after)"` - } `graphql:"issue(number: $issueNumber)"` - } `graphql:"repository(owner: $owner, name: $repo)"` +// issueToDependencyRef converts a REST issue into the compact reference used by +// the dependency tools, deriving the "owner/repo" name from the issue's +// repository URL. The state is upper-cased so it matches the GraphQL-sourced +// state (e.g. "OPEN"/"CLOSED") that MinimalIssueRef carries for the other issue +// tools such as get_parent, keeping the field consistent across tools. +func issueToDependencyRef(issue *github.Issue) MinimalIssueRef { + if issue == nil { + return MinimalIssueRef{} } - - if err := client.Query(ctx, &query, dependencyQueryVars(owner, repo, issueNumber, pagination)); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get blocked-by issues", err), nil + ref := MinimalIssueRef{ + Number: issue.GetNumber(), + Title: issue.GetTitle(), + State: strings.ToUpper(issue.GetState()), + URL: issue.GetHTMLURL(), } - return dependencyResult(query.Repository.Issue.BlockedBy), nil -} - -// GetIssueBlocking lists the issues that the given issue blocks. -func GetIssueBlocking(ctx context.Context, client *githubv4.Client, owner, repo string, issueNumber int, pagination *GraphQLPaginationParams) (*mcp.CallToolResult, error) { - var query struct { - Repository struct { - Issue struct { - Blocking dependencyConnection `graphql:"blocking(first: $first, after: $after)"` - } `graphql:"issue(number: $issueNumber)"` - } `graphql:"repository(owner: $owner, name: $repo)"` + if owner, repo, ok := parseRepositoryURL(issue.GetRepositoryURL()); ok { + ref.Repository = owner + "/" + repo } - - if err := client.Query(ctx, &query, dependencyQueryVars(owner, repo, issueNumber, pagination)); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get blocking issues", err), nil - } - return dependencyResult(query.Repository.Issue.Blocking), nil + return ref } // IssueDependencyWrite creates a tool to add or remove an issue dependency -// (blocked-by / blocking) relationship. It accepts issue numbers and resolves -// them to GraphQL node IDs before calling the addBlockedBy / removeBlockedBy -// mutations. "blocking" is the inverse of "blocked_by", so both directions are -// served by the same mutation pair with the issue arguments swapped. +// (blocked-by / blocking) relationship. The REST dependency endpoints are always +// expressed as "the blocked issue is blocked_by the blocking issue", so both +// directions are served by the same endpoint pair with the two issues swapped. func IssueDependencyWrite(t translations.TranslationHelperFunc) inventory.ServerTool { st := NewTool( ToolsetMetadataIssues, @@ -347,102 +302,97 @@ Options are: return utils.NewToolResultError("an issue cannot block or depend on itself"), nil, nil } - gqlClient, err := deps.GetGQLClient(ctx) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil + // Map the subject/related pair onto the blocked/blocking roles the REST + // endpoints expect. For type 'blocked_by' the subject is the blocked + // issue; for 'blocking' the subject blocks the related issue, so the + // roles swap. + blocked := issueCoordinate{owner: owner, repo: repo, number: issueNumber} + blocking := issueCoordinate{owner: relatedOwner, repo: relatedRepo, number: relatedIssueNumber} + if relationshipType == "blocking" { + blocked, blocking = blocking, blocked } - subjectID, relatedID, err := resolveIssueNodeIDs(ctx, gqlClient, owner, repo, issueNumber, relatedOwner, relatedRepo, relatedIssueNumber) + client, err := deps.GetClient(ctx) if err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to resolve issues", err), nil, nil + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } - // "A blocks B" is recorded as addBlockedBy(issueId: B, blockingIssueId: A). - // For type 'blocked_by' the subject is blocked by the related issue. - // For type 'blocking' the subject blocks the related issue, so the roles swap. - blockedID, blockingID := subjectID, relatedID - if relationshipType == "blocking" { - blockedID, blockingID = relatedID, subjectID - } - - result, err := writeBlockedByRelationship(ctx, gqlClient, method, blockedID, blockingID) + result, err := writeIssueDependency(ctx, client, method, blocked, blocking) return result, nil, err }) st.FeatureFlagEnable = FeatureFlagIssueDependencies return st } -// resolveIssueNodeIDs resolves the subject and related issue numbers to their -// GraphQL node IDs in a single aliased query. -func resolveIssueNodeIDs(ctx context.Context, client *githubv4.Client, owner, repo string, issueNumber int, relatedOwner, relatedRepo string, relatedIssueNumber int) (githubv4.ID, githubv4.ID, error) { - var query struct { - Subject struct { - Issue struct { - ID githubv4.ID - } `graphql:"issue(number: $subjectNumber)"` - } `graphql:"subject: repository(owner: $subjectOwner, name: $subjectRepo)"` - Related struct { - Issue struct { - ID githubv4.ID - } `graphql:"issue(number: $relatedNumber)"` - } `graphql:"related: repository(owner: $relatedOwner, name: $relatedRepo)"` - } - vars := map[string]any{ - "subjectOwner": githubv4.String(owner), - "subjectRepo": githubv4.String(repo), - "subjectNumber": githubv4.Int(issueNumber), // #nosec G115 - issue numbers are always small positive integers - "relatedOwner": githubv4.String(relatedOwner), - "relatedRepo": githubv4.String(relatedRepo), - "relatedNumber": githubv4.Int(relatedIssueNumber), // #nosec G115 - issue numbers are always small positive integers - } - if err := client.Query(ctx, &query, vars); err != nil { - return "", "", err - } - return query.Subject.Issue.ID, query.Related.Issue.ID, nil +// issueCoordinate identifies an issue by repository and number. +type issueCoordinate struct { + owner string + repo string + number int } -// writeBlockedByRelationship runs the addBlockedBy / removeBlockedBy mutation and -// returns a minimal description of the affected issues. -func writeBlockedByRelationship(ctx context.Context, client *githubv4.Client, method string, blockedID, blockingID githubv4.ID) (*mcp.CallToolResult, error) { - type mutationIssue struct { - Number githubv4.Int - URL githubv4.String +// writeIssueDependency resolves the blocking issue to its global database ID and +// then adds or removes the blocked-by relationship on the blocked issue. +func writeIssueDependency(ctx context.Context, client *github.Client, method string, blocked, blocking issueCoordinate) (*mcp.CallToolResult, error) { + // The REST API identifies the blocking issue by its global database ID + // (not its number), so resolve the number to an ID first. + blockingIssue, resp, err := client.Issues.Get(ctx, blocking.owner, blocking.repo, blocking.number) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to resolve blocking issue", resp, err), nil } + _ = resp.Body.Close() + blockingID := blockingIssue.GetID() switch method { case "add": - var mutation struct { - AddBlockedBy struct { - Issue mutationIssue - BlockingIssue mutationIssue - } `graphql:"addBlockedBy(input: $input)"` + blockedIssue, opResp, err := client.Issues.AddBlockedBy(ctx, blocked.owner, blocked.repo, int64(blocked.number), github.IssueDependencyRequest{IssueID: blockingID}) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add issue dependency", opResp, err), nil } - input := AddBlockedByInput{IssueID: blockedID, BlockingIssueID: blockingID} - if err := client.Mutate(ctx, &mutation, input, nil); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to add issue dependency", err), nil + defer func() { _ = opResp.Body.Close() }() + if opResp.StatusCode != http.StatusCreated { + body, readErr := io.ReadAll(opResp.Body) + if readErr != nil { + return nil, fmt.Errorf("failed to read response body: %w", readErr) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to add issue dependency", opResp, body), nil } - return MarshalledTextResult(map[string]any{ - "message": "dependency added", - "blocked_issue": map[string]any{"number": int(mutation.AddBlockedBy.Issue.Number), "url": string(mutation.AddBlockedBy.Issue.URL)}, - "blocking_issue": map[string]any{"number": int(mutation.AddBlockedBy.BlockingIssue.Number), "url": string(mutation.AddBlockedBy.BlockingIssue.URL)}, - }), nil + return dependencyWriteResult("dependency added", blockedIssue, blockingIssue, blocked, blocking), nil case "remove": - var mutation struct { - RemoveBlockedBy struct { - Issue mutationIssue - BlockingIssue mutationIssue - } `graphql:"removeBlockedBy(input: $input)"` + blockedIssue, opResp, err := client.Issues.RemoveBlockedBy(ctx, blocked.owner, blocked.repo, int64(blocked.number), blockingID) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to remove issue dependency", opResp, err), nil } - input := RemoveBlockedByInput{IssueID: blockedID, BlockingIssueID: blockingID} - if err := client.Mutate(ctx, &mutation, input, nil); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to remove issue dependency", err), nil + defer func() { _ = opResp.Body.Close() }() + if opResp.StatusCode != http.StatusOK { + body, readErr := io.ReadAll(opResp.Body) + if readErr != nil { + return nil, fmt.Errorf("failed to read response body: %w", readErr) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to remove issue dependency", opResp, body), nil } - return MarshalledTextResult(map[string]any{ - "message": "dependency removed", - "blocked_issue": map[string]any{"number": int(mutation.RemoveBlockedBy.Issue.Number), "url": string(mutation.RemoveBlockedBy.Issue.URL)}, - "blocking_issue": map[string]any{"number": int(mutation.RemoveBlockedBy.BlockingIssue.Number), "url": string(mutation.RemoveBlockedBy.BlockingIssue.URL)}, - }), nil + return dependencyWriteResult("dependency removed", blockedIssue, blockingIssue, blocked, blocking), nil default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil } } + +// dependencyWriteResult builds the minimal description of the affected issues. +// The blocked issue comes from the mutation response and the blocking issue from +// the earlier resolve; each falls back to its known coordinate when the API +// response omits the repository URL. +func dependencyWriteResult(message string, blockedIssue, blockingIssue *github.Issue, blocked, blocking issueCoordinate) *mcp.CallToolResult { + blockedRef := issueToDependencyRef(blockedIssue) + if blockedRef.Repository == "" { + blockedRef.Repository = blocked.owner + "/" + blocked.repo + } + blockingRef := issueToDependencyRef(blockingIssue) + if blockingRef.Repository == "" { + blockingRef.Repository = blocking.owner + "/" + blocking.repo + } + return MarshalledTextResult(map[string]any{ + "message": message, + "blocked_issue": blockedRef, + "blocking_issue": blockingRef, + }) +} diff --git a/pkg/github/issue_dependencies_test.go b/pkg/github/issue_dependencies_test.go index 47859e9378..6af9c504ed 100644 --- a/pkg/github/issue_dependencies_test.go +++ b/pkg/github/issue_dependencies_test.go @@ -3,17 +3,33 @@ package github import ( "context" "encoding/json" + "net/http" + "strconv" "testing" - "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/jsonschema-go/jsonschema" - "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +const ( + endpointBlockedBy = EndpointPattern("GET /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by") + endpointBlocking = EndpointPattern("GET /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocking") + endpointAddBlock = EndpointPattern("POST /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by") + endpointRemoveBlk = EndpointPattern("DELETE /repos/{owner}/{repo}/issues/{issue_number}/dependencies/blocked_by/{issue_id}") + endpointGetIssue = EndpointPattern("GET /repos/{owner}/{repo}/issues/{issue_number}") +) + +// jsonHandler writes the given status code and JSON-encoded body. +func jsonHandler(status int, body any) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(status) + _, _ = w.Write(MustMarshal(body)) + } +} + func Test_IssueDependencyRead(t *testing.T) { // Verify tool definition once (flag-gated variant snap) serverTool := IssueDependencyRead(translations.NullTranslationHelper) @@ -29,124 +45,76 @@ func Test_IssueDependencyRead(t *testing.T) { assert.Contains(t, schema.Properties, "owner") assert.Contains(t, schema.Properties, "repo") assert.Contains(t, schema.Properties, "issue_number") + assert.Contains(t, schema.Properties, "page") + assert.Contains(t, schema.Properties, "perPage") assert.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo", "issue_number"}) - blockedByQuery := githubv4mock.NewQueryMatcher( - struct { - Repository struct { - Issue struct { - BlockedBy dependencyConnection `graphql:"blockedBy(first: $first, after: $after)"` - } `graphql:"issue(number: $issueNumber)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "issueNumber": githubv4.Int(123), - "first": githubv4.Int(30), - "after": (*githubv4.String)(nil), + blockedByIssues := []map[string]any{ + { + "number": 7, + "title": "Blocker", + "state": "open", + "html_url": "https://github.com/owner/repo/issues/7", + "repository_url": "https://api.github.com/repos/owner/repo", }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "issue": map[string]any{ - "blockedBy": map[string]any{ - "totalCount": 1, - "pageInfo": map[string]any{ - "hasNextPage": false, - "endCursor": "", - }, - "nodes": []map[string]any{ - { - "number": 7, - "title": "Blocker", - "state": "OPEN", - "url": "https://github.com/owner/repo/issues/7", - "repository": map[string]any{"nameWithOwner": "owner/repo"}, - }, - }, - }, - }, - }, - }), - ) - - blockingQuery := githubv4mock.NewQueryMatcher( - struct { - Repository struct { - Issue struct { - Blocking dependencyConnection `graphql:"blocking(first: $first, after: $after)"` - } `graphql:"issue(number: $issueNumber)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "issueNumber": githubv4.Int(123), - "first": githubv4.Int(30), - "after": (*githubv4.String)(nil), + } + blockingIssues := []map[string]any{ + { + "number": 8, + "title": "Blocked A", + "state": "open", + "html_url": "https://github.com/owner/repo/issues/8", + "repository_url": "https://api.github.com/repos/owner/repo", }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "issue": map[string]any{ - "blocking": map[string]any{ - "totalCount": 2, - "pageInfo": map[string]any{ - "hasNextPage": true, - "endCursor": "Y3Vyc29y", - }, - "nodes": []map[string]any{ - { - "number": 8, - "title": "Blocked A", - "state": "OPEN", - "url": "https://github.com/owner/repo/issues/8", - "repository": map[string]any{"nameWithOwner": "owner/repo"}, - }, - { - "number": 9, - "title": "Blocked B", - "state": "CLOSED", - "url": "https://github.com/owner/repo/issues/9", - "repository": map[string]any{"nameWithOwner": "owner/repo"}, - }, - }, - }, - }, - }, - }), - ) + { + "number": 9, + "title": "Blocked B", + "state": "closed", + "html_url": "https://github.com/owner/repo/issues/9", + "repository_url": "https://api.github.com/repos/owner/repo", + }, + } + + // A handler that also advertises a next page via the Link header. + blockingHandler := func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Link", `; rel="next"`) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(MustMarshal(blockingIssues)) + } tests := []struct { name string method string - matcher githubv4mock.Matcher - expectError bool + option MockBackendOption expectedCount int expectedFirst int + expectedState string expectedNext bool }{ { name: "get_blocked_by returns blockers", method: "get_blocked_by", - matcher: blockedByQuery, + option: WithRequestMatch(endpointBlockedBy, blockedByIssues), expectedCount: 1, expectedFirst: 7, + expectedState: "OPEN", expectedNext: false, }, { name: "get_blocking returns blocked issues", method: "get_blocking", - matcher: blockingQuery, + option: WithRequestMatchHandler(endpointBlocking, blockingHandler), expectedCount: 2, expectedFirst: 8, + expectedState: "OPEN", expectedNext: true, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(tc.matcher)) - deps := BaseDeps{GQLClient: gqlClient} + client := mustNewGHClient(t, NewMockedHTTPClient(tc.option)) + deps := BaseDeps{Client: client} handler := serverTool.Handler(deps) request := createMCPRequest(map[string]any{ @@ -161,17 +129,20 @@ func Test_IssueDependencyRead(t *testing.T) { text := getTextResult(t, result) var payload struct { - Issues []minimalDependencyIssue `json:"issues"` - Total int `json:"totalCount"` - Page struct { - HasNextPage bool `json:"hasNextPage"` - EndCursor string `json:"endCursor"` + Issues []MinimalIssueRef `json:"issues"` + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + NextPage int `json:"nextPage"` } `json:"pageInfo"` } require.NoError(t, json.Unmarshal([]byte(text.Text), &payload)) require.Len(t, payload.Issues, tc.expectedCount) assert.Equal(t, tc.expectedFirst, payload.Issues[0].Number) - assert.Equal(t, tc.expectedNext, payload.Page.HasNextPage) + assert.Equal(t, "owner/repo", payload.Issues[0].Repository) + // State is normalized to upper case to match the GraphQL-sourced + // state used by other MinimalIssueRef producers (e.g. get_parent). + assert.Equal(t, tc.expectedState, payload.Issues[0].State) + assert.Equal(t, tc.expectedNext, payload.PageInfo.HasNextPage) }) } } @@ -179,31 +150,34 @@ func Test_IssueDependencyRead(t *testing.T) { func Test_IssueDependencyRead_Errors(t *testing.T) { serverTool := IssueDependencyRead(translations.NullTranslationHelper) - t.Run("rejects page-based pagination", func(t *testing.T) { - gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient()) - deps := BaseDeps{GQLClient: gqlClient} + t.Run("missing required param", func(t *testing.T) { + client := mustNewGHClient(t, NewMockedHTTPClient()) + deps := BaseDeps{Client: client} handler := serverTool.Handler(deps) request := createMCPRequest(map[string]any{ - "method": "get_blocked_by", - "owner": "owner", - "repo": "repo", - "issue_number": float64(1), - "page": float64(2), + "method": "get_blocked_by", + "owner": "owner", + "repo": "repo", }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) - errText := getErrorResult(t, result) - assert.Contains(t, errText.Text, "cursor-based pagination") + getErrorResult(t, result) }) - t.Run("missing required param", func(t *testing.T) { - gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient()) - deps := BaseDeps{GQLClient: gqlClient} + t.Run("API error is surfaced", func(t *testing.T) { + client := mustNewGHClient(t, NewMockedHTTPClient( + WithRequestMatchHandler(endpointBlockedBy, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + })), + )) + deps := BaseDeps{Client: client} handler := serverTool.Handler(deps) request := createMCPRequest(map[string]any{ - "method": "get_blocked_by", - "owner": "owner", - "repo": "repo", + "method": "get_blocked_by", + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) @@ -228,114 +202,82 @@ func Test_IssueDependencyWrite(t *testing.T) { assert.Contains(t, schema.Properties, "related_issue_number") assert.ElementsMatch(t, schema.Required, []string{"method", "type", "owner", "repo", "issue_number", "related_issue_number"}) - resolveMatcher := func(subjectID, relatedID string) githubv4mock.Matcher { - return githubv4mock.NewQueryMatcher( - struct { - Subject struct { - Issue struct { - ID githubv4.ID - } `graphql:"issue(number: $subjectNumber)"` - } `graphql:"subject: repository(owner: $subjectOwner, name: $subjectRepo)"` - Related struct { - Issue struct { - ID githubv4.ID - } `graphql:"issue(number: $relatedNumber)"` - } `graphql:"related: repository(owner: $relatedOwner, name: $relatedRepo)"` - }{}, - map[string]any{ - "subjectOwner": githubv4.String("owner"), - "subjectRepo": githubv4.String("repo"), - "subjectNumber": githubv4.Int(1), - "relatedOwner": githubv4.String("owner"), - "relatedRepo": githubv4.String("repo"), - "relatedNumber": githubv4.Int(2), - }, - githubv4mock.DataResponse(map[string]any{ - "subject": map[string]any{"issue": map[string]any{"id": subjectID}}, - "related": map[string]any{"issue": map[string]any{"id": relatedID}}, - }), - ) - } - - type mutationIssue struct { - Number githubv4.Int - URL githubv4.String - } - - addMutation := func(issueID, blockingID string) githubv4mock.Matcher { - return githubv4mock.NewMutationMatcher( - struct { - AddBlockedBy struct { - Issue mutationIssue - BlockingIssue mutationIssue - } `graphql:"addBlockedBy(input: $input)"` - }{}, - AddBlockedByInput{IssueID: githubv4.ID(issueID), BlockingIssueID: githubv4.ID(blockingID)}, - nil, - githubv4mock.DataResponse(map[string]any{ - "addBlockedBy": map[string]any{ - "issue": map[string]any{"number": 1, "url": "https://github.com/owner/repo/issues/1"}, - "blockingIssue": map[string]any{"number": 2, "url": "https://github.com/owner/repo/issues/2"}, - }, - }), - ) + // issue returned by the blocking-issue resolve GET; its id is what the + // dependency endpoints operate on. + resolvedIssue := func(number, id int) map[string]any { + return map[string]any{ + "id": id, + "number": number, + "title": "Resolved", + "state": "open", + "html_url": "https://github.com/owner/repo/issues/" + strconv.Itoa(number), + "repository_url": "https://api.github.com/repos/owner/repo", + } } - - removeMutation := func(issueID, blockingID string) githubv4mock.Matcher { - return githubv4mock.NewMutationMatcher( - struct { - RemoveBlockedBy struct { - Issue mutationIssue - BlockingIssue mutationIssue - } `graphql:"removeBlockedBy(input: $input)"` - }{}, - RemoveBlockedByInput{IssueID: githubv4.ID(issueID), BlockingIssueID: githubv4.ID(blockingID)}, - nil, - githubv4mock.DataResponse(map[string]any{ - "removeBlockedBy": map[string]any{ - "issue": map[string]any{"number": 1, "url": "https://github.com/owner/repo/issues/1"}, - "blockingIssue": map[string]any{"number": 2, "url": "https://github.com/owner/repo/issues/2"}, - }, - }), - ) + // issue returned by the add/remove endpoints (the blocked issue). + blockedIssue := func(number int) map[string]any { + return map[string]any{ + "number": number, + "title": "Blocked", + "state": "open", + "html_url": "https://github.com/owner/repo/issues/" + strconv.Itoa(number), + "repository_url": "https://api.github.com/repos/owner/repo", + } } tests := []struct { name string method string relationship string - matchers []githubv4mock.Matcher + options []MockBackendOption expectedMessage string + expectedBlocked int + expectedBlockng int }{ { name: "add blocked_by uses subject as blocked", method: "add", relationship: "blocked_by", - // subject(1) is blocked by related(2): issueId=subject, blockingIssueId=related - matchers: []githubv4mock.Matcher{resolveMatcher("I_subject", "I_related"), addMutation("I_subject", "I_related")}, + // subject(1) is blocked by related(2): resolve related(2), block issue 1. + options: []MockBackendOption{ + WithRequestMatch(endpointGetIssue, resolvedIssue(2, 1002)), + WithRequestMatchHandler(endpointAddBlock, jsonHandler(http.StatusCreated, blockedIssue(1))), + }, expectedMessage: "dependency added", + expectedBlocked: 1, + expectedBlockng: 2, }, { name: "add blocking swaps roles", method: "add", relationship: "blocking", - // subject(1) blocks related(2): issueId=related, blockingIssueId=subject - matchers: []githubv4mock.Matcher{resolveMatcher("I_subject", "I_related"), addMutation("I_related", "I_subject")}, + // subject(1) blocks related(2): resolve subject(1), block issue 2. + options: []MockBackendOption{ + WithRequestMatch(endpointGetIssue, resolvedIssue(1, 1001)), + WithRequestMatchHandler(endpointAddBlock, jsonHandler(http.StatusCreated, blockedIssue(2))), + }, expectedMessage: "dependency added", + expectedBlocked: 2, + expectedBlockng: 1, }, { - name: "remove blocked_by", - method: "remove", - relationship: "blocked_by", - matchers: []githubv4mock.Matcher{resolveMatcher("I_subject", "I_related"), removeMutation("I_subject", "I_related")}, + name: "remove blocked_by", + method: "remove", + relationship: "blocked_by", + options: []MockBackendOption{ + WithRequestMatch(endpointGetIssue, resolvedIssue(2, 1002)), + WithRequestMatch(endpointRemoveBlk, blockedIssue(1)), + }, expectedMessage: "dependency removed", + expectedBlocked: 1, + expectedBlockng: 2, }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(tc.matchers...)) - deps := BaseDeps{GQLClient: gqlClient} + client := mustNewGHClient(t, NewMockedHTTPClient(tc.options...)) + deps := BaseDeps{Client: client} handler := serverTool.Handler(deps) request := createMCPRequest(map[string]any{ @@ -352,17 +294,21 @@ func Test_IssueDependencyWrite(t *testing.T) { text := getTextResult(t, result) var payload struct { - Message string `json:"message"` + Message string `json:"message"` + BlockedIssue MinimalIssueRef `json:"blocked_issue"` + BlockingIssue MinimalIssueRef `json:"blocking_issue"` } require.NoError(t, json.Unmarshal([]byte(text.Text), &payload)) assert.Equal(t, tc.expectedMessage, payload.Message) + assert.Equal(t, tc.expectedBlocked, payload.BlockedIssue.Number) + assert.Equal(t, tc.expectedBlockng, payload.BlockingIssue.Number) }) } t.Run("self dependency fails before any API call", func(t *testing.T) { - // Register no matchers: the handler must return before resolving node IDs or mutating. - gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient()) - deps := BaseDeps{GQLClient: gqlClient} + // Register no handlers: the handler must return before resolving or mutating. + client := mustNewGHClient(t, NewMockedHTTPClient()) + deps := BaseDeps{Client: client} handler := serverTool.Handler(deps) request := createMCPRequest(map[string]any{ @@ -414,8 +360,8 @@ func Test_IssueDependencyWrite_Validation(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { - gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient()) - deps := BaseDeps{GQLClient: gqlClient} + client := mustNewGHClient(t, NewMockedHTTPClient()) + deps := BaseDeps{Client: client} handler := serverTool.Handler(deps) request := createMCPRequest(tc.args) result, err := handler(ContextWithDeps(context.Background(), deps), &request) From a718d37f6208ac0b18afc4b29a40a89735c92072 Mon Sep 17 00:00:00 2001 From: insonmia0126-lovable Date: Fri, 10 Jul 2026 17:48:03 +0900 Subject: [PATCH 12/16] Add README community resource links (#2848) Co-authored-by: Codex --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index 48e6c40c82..3a660845c1 100644 --- a/README.md +++ b/README.md @@ -1656,6 +1656,18 @@ export GITHUB_MCP_SERVER_TITLE="GHES MCP Server" The exported Go API of this module should currently be considered unstable, and subject to breaking changes. In the future, we may offer stability; please file an issue if there is a use case where this would be valuable. +## Contributing + +Contributions are welcome. Before opening a pull request, please read the [contributing guide](CONTRIBUTING.md) for setup, testing, linting, and documentation generation instructions. + +## Support + +For help using the GitHub MCP Server, see the [support guide](SUPPORT.md). If you have found a bug or want to request a feature, please search existing issues before opening a new one. + +## Security + +Please do not report security vulnerabilities through public issues. Follow the instructions in the [security policy](SECURITY.md) to report vulnerabilities responsibly. + ## License This project is licensed under the terms of the MIT open source license. Please refer to [MIT](./LICENSE) for the full terms. From 3b8ff504c0fcfd4b027ce8f6f55f12f8fb95e86f Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:50:47 +0100 Subject: [PATCH 13/16] Add fields param to search_code and get_file_contents (#2775) * Add fields param to search_code and get_file_contents Add an optional `fields` array parameter to the `search_code` and `get_file_contents` tools so callers can request only the fields they need, reducing tool response size and context usage. - search_code: filters each result item to the selected fields while preserving the total_count / incomplete_results wrapper. - get_file_contents: filters each directory entry when listing a directory; ignored for single-file responses. Adds shared filterFields / filterEachField helpers and per-tool field enums, plus unit tests and regenerated toolsnaps and docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Gate fields param behind fields_param flag and add usage telemetry Register search_code and get_file_contents as two mutually exclusive variants gated by the new `fields_param` feature flag, following the existing dual-variant flag pattern: - The flag-enabled variant advertises the optional `fields` parameter and filters each result to the requested subset. It owns the `_ff_fields_param` toolsnap. - The Legacy* variant exposes the original schema with no `fields` parameter and never filters, acting as a kill switch when the flag is off. It owns the canonical toolsnap. Add best-effort, low-cardinality telemetry at each tool's filter point to measure adoption and realized savings: - `mcp.fields.tool_call` (increment) tagged by tool and whether the response was filtered. - `mcp.fields.bytes_full` / `bytes_sent` / `bytes_saved` (counters) tagged by tool, emitted only when a response was filtered. Tags are limited to `tool` and `filtered` to bound cardinality; repo, owner, user, query, and the requested field list are never tagged. The local server discards these via the noop metrics sink, while hosted deployments inject a real sink. Metrics accessors now fall back to a noop sink when no exporter is configured so emitting telemetry never panics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Drop mcp.fields.bytes_saved metric Remove the mcp.fields.bytes_saved counter. It is derivable on the dashboard from the two remaining byte counters, since sum(bytes_full) - sum(bytes_sent) equals the total saved at any rollup, so emitting it separately is redundant. Keeping only bytes_full and bytes_sent shrinks the emitted telemetry surface. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/feature-flags.md | 20 +++ .../get_file_contents_ff_fields_param.snap | 57 +++++++ .../search_code_ff_fields_param.snap | 58 +++++++ pkg/github/dependencies.go | 6 + pkg/github/feature_flags.go | 9 ++ pkg/github/fields_param_gating_test.go | 75 +++++++++ pkg/github/fields_telemetry.go | 54 +++++++ pkg/github/fields_telemetry_test.go | 123 +++++++++++++++ pkg/github/minimal_types.go | 53 +++++++ pkg/github/repositories.go | 132 ++++++++++++---- pkg/github/repositories_test.go | 135 +++++++++++++++- pkg/github/search.go | 80 +++++++++- pkg/github/search_test.go | 147 +++++++++++++++++- pkg/github/tools.go | 2 + 14 files changed, 918 insertions(+), 33 deletions(-) create mode 100644 pkg/github/__toolsnaps__/get_file_contents_ff_fields_param.snap create mode 100644 pkg/github/__toolsnaps__/search_code_ff_fields_param.snap create mode 100644 pkg/github/fields_param_gating_test.go create mode 100644 pkg/github/fields_telemetry.go create mode 100644 pkg/github/fields_telemetry_test.go diff --git a/docs/feature-flags.md b/docs/feature-flags.md index 32891af62e..66fcb57c1b 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -338,4 +338,24 @@ runtime behavior (such as output formatting) won't appear here. - 'blocked_by' - the subject issue is blocked by the related issue. - 'blocking' - the subject issue blocks the related issue. (string, required) +### `fields_param` + +- **get_file_contents** - Get file or directory contents + - **Required OAuth Scopes**: `repo` + - `fields`: Subset of fields to return for each entry when the path is a directory. If omitted, all fields are returned. Ignored when the path is a single file. Use this to reduce response size when listing directories and you only need specific fields, e.g. just 'name' and 'type'. (string[], optional) + - `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) + - `repo`: Repository name (string, required) + - `sha`: Accepts optional commit SHA. If specified, it will be used instead of ref (string, optional) + +- **search_code** - Search code + - **Required OAuth Scopes**: `repo` + - `fields`: Subset of fields to return for each code search result. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'repository' and 'text_matches' in particular drops the largest per-result data. (string[], optional) + - `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) + - `query`: Search query (GitHub code search REST). Implicit AND between terms; supports `OR`, `NOT`, and `"quoted phrase"` for exact match. Qualifiers: `repo:owner/repo`, `org:`, `user:`, `language:`, `path:dir` (prefix match), `filename:exact.ext`, `extension:`, `in:file`, `in:path`, `size:`, `is:archived`, `is:fork`. Max 256 chars. Examples: `WithContext language:go org:github`; `"package main" repo:o/r`; `func extension:go path:cmd repo:o/r`; `NOT TODO language:go repo:o/r`. (string, required) + - `sort`: Sort field ('indexed' only) (string, optional) + diff --git a/pkg/github/__toolsnaps__/get_file_contents_ff_fields_param.snap b/pkg/github/__toolsnaps__/get_file_contents_ff_fields_param.snap new file mode 100644 index 0000000000..dec933c94d --- /dev/null +++ b/pkg/github/__toolsnaps__/get_file_contents_ff_fields_param.snap @@ -0,0 +1,57 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "Get file or directory contents" + }, + "description": "Get the contents of a file or directory from a GitHub repository", + "inputSchema": { + "properties": { + "fields": { + "description": "Subset of fields to return for each entry when the path is a directory. If omitted, all fields are returned. Ignored when the path is a single file. Use this to reduce response size when listing directories and you only need specific fields, e.g. just 'name' and 'type'.", + "items": { + "enum": [ + "type", + "name", + "path", + "size", + "sha", + "url", + "git_url", + "html_url", + "download_url" + ], + "type": "string" + }, + "type": "array" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "path": { + "default": "/", + "description": "Path to file/directory", + "type": "string" + }, + "ref": { + "description": "Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "sha": { + "description": "Accepts optional commit SHA. If specified, it will be used instead of ref", + "type": "string" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "name": "get_file_contents" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_code_ff_fields_param.snap b/pkg/github/__toolsnaps__/search_code_ff_fields_param.snap new file mode 100644 index 0000000000..00d4686712 --- /dev/null +++ b/pkg/github/__toolsnaps__/search_code_ff_fields_param.snap @@ -0,0 +1,58 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "Search code" + }, + "description": "Fast and precise code search across ALL GitHub repositories using GitHub's native search engine. Best for finding exact symbols, functions, classes, or specific code patterns.", + "inputSchema": { + "properties": { + "fields": { + "description": "Subset of fields to return for each code search result. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'repository' and 'text_matches' in particular drops the largest per-result data.", + "items": { + "enum": [ + "name", + "path", + "sha", + "repository", + "text_matches" + ], + "type": "string" + }, + "type": "array" + }, + "order": { + "description": "Sort order for results", + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "query": { + "description": "Search query (GitHub code search REST). Implicit AND between terms; supports `OR`, `NOT`, and `\"quoted phrase\"` for exact match. Qualifiers: `repo:owner/repo`, `org:`, `user:`, `language:`, `path:dir` (prefix match), `filename:exact.ext`, `extension:`, `in:file`, `in:path`, `size:`, `is:archived`, `is:fork`. Max 256 chars. Examples: `WithContext language:go org:github`; `\"package main\" repo:o/r`; `func extension:go path:cmd repo:o/r`; `NOT TODO language:go repo:o/r`.", + "type": "string" + }, + "sort": { + "description": "Sort field ('indexed' only)", + "type": "string" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "search_code" +} \ No newline at end of file diff --git a/pkg/github/dependencies.go b/pkg/github/dependencies.go index c57fa2f0dc..49b6f6315a 100644 --- a/pkg/github/dependencies.go +++ b/pkg/github/dependencies.go @@ -193,6 +193,9 @@ func (d BaseDeps) Logger(_ context.Context) *slog.Logger { // Metrics implements ToolDependencies. func (d BaseDeps) Metrics(ctx context.Context) metrics.Metrics { + if d.Obsv == nil { + return metrics.NewNoopMetrics() + } return d.Obsv.Metrics(ctx) } @@ -423,6 +426,9 @@ func (d *RequestDeps) Logger(_ context.Context) *slog.Logger { // Metrics implements ToolDependencies. func (d *RequestDeps) Metrics(ctx context.Context) metrics.Metrics { + if d.obsv == nil { + return metrics.NewNoopMetrics() + } return d.obsv.Metrics(ctx) } diff --git a/pkg/github/feature_flags.go b/pkg/github/feature_flags.go index fdb113585a..3b6334a4ea 100644 --- a/pkg/github/feature_flags.go +++ b/pkg/github/feature_flags.go @@ -23,6 +23,14 @@ const FeatureFlagFileBlame = "file_blame" // unless explicitly opted in. const FeatureFlagIssueDependencies = "issue_dependencies" +// FeatureFlagFieldsParam is the feature flag name for the optional `fields` +// parameter on selected read tools (for example search_code and +// get_file_contents). When enabled, those tools advertise `fields` and filter +// each result to the requested subset, reducing response size. It is gated so +// the feature can be rolled out gradually and disabled as a kill switch without +// a redeploy. +const FeatureFlagFieldsParam = "fields_param" + // AllowedFeatureFlags is the allowlist of feature flags that can be enabled // by users via --features CLI flag or X-MCP-Features HTTP header. // Only flags in this list are accepted; unknown flags are silently ignored. @@ -35,6 +43,7 @@ var AllowedFeatureFlags = []string{ FeatureFlagPullRequestsGranular, FeatureFlagFileBlame, FeatureFlagIssueDependencies, + FeatureFlagFieldsParam, } // InsidersFeatureFlags is the list of feature flags that insiders mode enables. diff --git a/pkg/github/fields_param_gating_test.go b/pkg/github/fields_param_gating_test.go new file mode 100644 index 0000000000..09b176914b --- /dev/null +++ b/pkg/github/fields_param_gating_test.go @@ -0,0 +1,75 @@ +package github + +import ( + "context" + "testing" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/jsonschema-go/jsonschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Test_FieldsParamVariants_MutuallyExclusive guards the dual-variant +// registration for the fields_param feature flag. The flag-enabled tools +// (search_code, get_file_contents) and their Legacy* counterparts share a tool +// name, so exactly one of each pair must survive inventory filtering for any +// flag state. If both ever leaked, a client could be offered two tools with the +// same name. This asserts that each gated tool is present exactly once, +// advertising the `fields` parameter only when fields_param is enabled. +func Test_FieldsParamVariants_MutuallyExclusive(t *testing.T) { + gatedTools := []string{"search_code", "get_file_contents"} + + for _, tc := range []struct { + name string + flagEnabled bool + expectFields bool + featureChecks func(context.Context, string) (bool, error) + }{ + { + name: "flag off registers the legacy variant without fields", + flagEnabled: false, + expectFields: false, + featureChecks: featureCheckerFor(), // fields_param disabled + }, + { + name: "flag on registers the fields variant with fields", + flagEnabled: true, + expectFields: true, + featureChecks: featureCheckerFor(FeatureFlagFieldsParam), + }, + } { + t.Run(tc.name, func(t *testing.T) { + inv, err := NewInventory(translations.NullTranslationHelper). + WithToolsets([]string{"all"}). + WithFeatureChecker(tc.featureChecks). + Build() + require.NoError(t, err) + + available := inv.AvailableTools(context.Background()) + + counts := make(map[string]int, len(available)) + for _, tool := range available { + counts[tool.Tool.Name]++ + } + + // Each gated tool must be present exactly once (never both variants) + // and advertise `fields` only when the flag is enabled. + for _, name := range gatedTools { + require.Equalf(t, 1, counts[name], "expected exactly one %q for flagEnabled=%v; dual variants must be mutually exclusive", name, tc.flagEnabled) + + tool := requireToolByName(t, available, name) + schema, ok := tool.Tool.InputSchema.(*jsonschema.Schema) + require.Truef(t, ok, "%q InputSchema should be *jsonschema.Schema", name) + + if tc.expectFields { + assert.Containsf(t, schema.Properties, "fields", "%q should advertise fields when flag is on", name) + assert.Equalf(t, FeatureFlagFieldsParam, tool.FeatureFlagEnable, "%q should be the flag-enabled variant", name) + } else { + assert.NotContainsf(t, schema.Properties, "fields", "%q must not advertise fields when flag is off", name) + assert.Containsf(t, tool.FeatureFlagDisable, FeatureFlagFieldsParam, "%q should be the legacy (flag-disabled) variant", name) + } + } + }) + } +} diff --git a/pkg/github/fields_telemetry.go b/pkg/github/fields_telemetry.go new file mode 100644 index 0000000000..b2c1528c6b --- /dev/null +++ b/pkg/github/fields_telemetry.go @@ -0,0 +1,54 @@ +package github + +import ( + "context" + "strconv" +) + +// Metric names for the optional `fields` response-filtering feature. They let a +// dashboard answer two questions on real traffic: how often the model actually +// filters (adoption) and how many bytes that filtering removes (effectiveness). +// +// Cardinality is kept deliberately low: the only tags ever attached are `tool` +// (a small fixed set of tool names) and `filtered` (a boolean). Unbounded values +// such as repository, owner, user, the query, or the requested field list are +// never used as tags. +// +// The realized savings (bytes_full - bytes_sent) is intentionally not emitted as +// its own metric: it is derivable on the dashboard from the two byte counters, +// since sum(bytes_full) - sum(bytes_sent) equals the total saved at any rollup. +const ( + metricFieldsToolCall = "mcp.fields.tool_call" + metricFieldsBytesFull = "mcp.fields.bytes_full" + metricFieldsBytesSent = "mcp.fields.bytes_sent" +) + +// recordFieldsUsage emits telemetry for a single call to a tool that supports +// the `fields` parameter. It is best-effort: the local server wires a no-op +// metrics sink, while hosted deployments inject a real sink. +// +// Every call increments mcp.fields.tool_call tagged by tool and whether the +// response was filtered, which yields the adoption rate (filtered / total). When +// the response was filtered, it also records the unfiltered (fullBytes) and +// returned (sentBytes) payload sizes. Byte counters are only emitted for +// filtered calls so that "percent saved" (1 - bytes_sent / bytes_full) is +// computed over the population where filtering actually applied. +func recordFieldsUsage(ctx context.Context, deps ToolDependencies, tool string, filtered bool, fullBytes, sentBytes int) { + m := deps.Metrics(ctx) + if m == nil { + return + } + + m.Increment(metricFieldsToolCall, map[string]string{ + "tool": tool, + "filtered": strconv.FormatBool(filtered), + }) + + if !filtered { + return + } + + toolTag := map[string]string{"tool": tool} + m.Counter(metricFieldsBytesFull, toolTag, int64(fullBytes)) + m.Counter(metricFieldsBytesSent, toolTag, int64(sentBytes)) +} diff --git a/pkg/github/fields_telemetry_test.go b/pkg/github/fields_telemetry_test.go new file mode 100644 index 0000000000..14e898bf29 --- /dev/null +++ b/pkg/github/fields_telemetry_test.go @@ -0,0 +1,123 @@ +package github + +import ( + "context" + "log/slog" + "sync" + "testing" + "time" + + "github.com/github/github-mcp-server/pkg/observability" + "github.com/github/github-mcp-server/pkg/observability/metrics" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// recordingMetrics is a metrics.Metrics implementation that captures emitted +// metrics so tests can assert on telemetry. It is safe for concurrent use. +type recordingMetrics struct { + mu sync.Mutex + increments []recordedMetric + counters []recordedMetric +} + +type recordedMetric struct { + key string + tags map[string]string + value int64 +} + +func (m *recordingMetrics) Increment(key string, tags map[string]string) { + m.mu.Lock() + defer m.mu.Unlock() + m.increments = append(m.increments, recordedMetric{key: key, tags: tags, value: 1}) +} + +func (m *recordingMetrics) Counter(key string, tags map[string]string, value int64) { + m.mu.Lock() + defer m.mu.Unlock() + m.counters = append(m.counters, recordedMetric{key: key, tags: tags, value: value}) +} + +func (m *recordingMetrics) Distribution(_ string, _ map[string]string, _ float64) {} +func (m *recordingMetrics) DistributionMs(_ string, _ map[string]string, _ time.Duration) {} +func (m *recordingMetrics) WithTags(_ map[string]string) metrics.Metrics { return m } + +// counter returns the recorded counter for the given key, or false if absent. +func (m *recordingMetrics) counter(key string) (recordedMetric, bool) { + m.mu.Lock() + defer m.mu.Unlock() + for _, c := range m.counters { + if c.key == key { + return c, true + } + } + return recordedMetric{}, false +} + +// increment returns the recorded increment for the given key, or false if absent. +func (m *recordingMetrics) increment(key string) (recordedMetric, bool) { + m.mu.Lock() + defer m.mu.Unlock() + for _, c := range m.increments { + if c.key == key { + return c, true + } + } + return recordedMetric{}, false +} + +// depsWithRecordingMetrics returns BaseDeps wired with a recording metrics sink +// plus the sink for assertions. +func depsWithRecordingMetrics(t *testing.T, base BaseDeps) (BaseDeps, *recordingMetrics) { + t.Helper() + rec := &recordingMetrics{} + exporters, err := observability.NewExporters(slog.New(slog.DiscardHandler), rec) + require.NoError(t, err) + base.Obsv = exporters + return base, rec +} + +func Test_recordFieldsUsage_Filtered(t *testing.T) { + deps, rec := depsWithRecordingMetrics(t, BaseDeps{}) + + recordFieldsUsage(context.Background(), deps, "search_code", true, 100, 30) + + call, ok := rec.increment(metricFieldsToolCall) + require.True(t, ok) + assert.Equal(t, "search_code", call.tags["tool"]) + assert.Equal(t, "true", call.tags["filtered"]) + + full, ok := rec.counter(metricFieldsBytesFull) + require.True(t, ok) + assert.Equal(t, int64(100), full.value) + assert.Equal(t, "search_code", full.tags["tool"]) + assert.NotContains(t, full.tags, "filtered") + + sent, ok := rec.counter(metricFieldsBytesSent) + require.True(t, ok) + assert.Equal(t, int64(30), sent.value) +} + +func Test_recordFieldsUsage_NotFiltered(t *testing.T) { + deps, rec := depsWithRecordingMetrics(t, BaseDeps{}) + + recordFieldsUsage(context.Background(), deps, "search_code", false, 100, 100) + + call, ok := rec.increment(metricFieldsToolCall) + require.True(t, ok) + assert.Equal(t, "false", call.tags["filtered"]) + + // No byte counters are emitted when the response was not filtered. + _, ok = rec.counter(metricFieldsBytesFull) + assert.False(t, ok) + _, ok = rec.counter(metricFieldsBytesSent) + assert.False(t, ok) +} + +func Test_recordFieldsUsage_NilExporterDoesNotPanic(t *testing.T) { + // BaseDeps with no Obsv falls back to a noop sink rather than panicking. + assert.NotPanics(t, func() { + recordFieldsUsage(context.Background(), BaseDeps{}, "search_code", true, 100, 30) + }) +} diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 66c39b2917..16273c7093 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -1,6 +1,8 @@ package github import ( + "bytes" + "encoding/json" "fmt" "net/url" "strconv" @@ -12,6 +14,57 @@ import ( "github.com/github/github-mcp-server/pkg/sanitize" ) +// codeSearchItemFieldEnum lists the selectable fields for search_code result +// items, matching the JSON field names of MinimalCodeResult. The repository and +// text_matches fields are the heaviest, so omitting them is the main lever for +// shrinking large result sets. +var codeSearchItemFieldEnum = []any{"name", "path", "sha", "repository", "text_matches"} + +// fileContentFieldEnum lists the selectable fields for get_file_contents +// directory listings, matching the JSON field names of +// github.RepositoryContent that appear for directory entries. Only applied when +// the requested path is a directory; ignored for single files. +var fileContentFieldEnum = []any{"type", "name", "path", "size", "sha", "url", "git_url", "html_url", "download_url"} + +// filterFields marshals v to a JSON object and returns a map containing only the +// requested fields. Fields that are unknown or absent from the JSON (for example +// empty values dropped via omitempty) are skipped. +func filterFields(v any, fields []string) (map[string]any, error) { + data, err := json.Marshal(v) + if err != nil { + return nil, err + } + + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() // preserve integer precision for fields such as IDs + var object map[string]any + if err := decoder.Decode(&object); err != nil { + return nil, err + } + + picked := make(map[string]any, len(fields)) + for _, field := range fields { + if value, ok := object[field]; ok { + picked[field] = value + } + } + return picked, nil +} + +// filterEachField applies filterFields to every item, returning a slice in which +// each element contains only the requested fields. +func filterEachField[T any](items []T, fields []string) ([]map[string]any, error) { + filtered := make([]map[string]any, 0, len(items)) + for _, item := range items { + picked, err := filterFields(item, fields) + if err != nil { + return nil, err + } + filtered = append(filtered, picked) + } + return filtered, nil +} + // MinimalUser is the output type for user and organization search results. type MinimalUser struct { Login string `json:"login"` diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index dc457c2965..fc49b61178 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -693,8 +693,73 @@ func FetchRepoIsPrivate(ctx context.Context, client *github.Client, owner, repo return r.GetPrivate(), nil } -// GetFileContents creates a tool to get the contents of a file or directory from a GitHub repository. +// GetFileContents creates a tool to get the contents of a file or directory from +// a GitHub repository. It is the FeatureFlagFieldsParam-enabled variant: it +// advertises the optional `fields` parameter and filters directory listings to +// the requested subset. Both this and LegacyGetFileContents register under the +// tool name "get_file_contents"; exactly one is active for any given request +// thanks to mutually exclusive FeatureFlagEnable / FeatureFlagDisable annotations. func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool { + st := getFileContentsTool(t, true) + st.FeatureFlagEnable = FeatureFlagFieldsParam + return st +} + +// LegacyGetFileContents is the FeatureFlagFieldsParam-disabled variant of +// get_file_contents. It exposes the original schema (no `fields` parameter) and +// never filters directory listings, so it acts as the kill switch when the flag +// is off. It owns the canonical get_file_contents.snap; the flag-enabled variant +// owns get_file_contents_ff_.snap. Delete this function when the flag is +// removed. +func LegacyGetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool { + st := getFileContentsTool(t, false) + st.FeatureFlagDisable = []string{FeatureFlagFieldsParam} + return st +} + +// getFileContentsTool builds the get_file_contents tool. When includeFields is +// true the tool advertises the optional `fields` parameter, filters directory +// listings to the requested subset, and emits fields telemetry. When false it is +// the original tool with no fields parameter and no filtering. +func getFileContentsTool(t translations.TranslationHelperFunc, includeFields bool) inventory.ServerTool { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner (username or organization)", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "path": { + Type: "string", + Description: "Path to file/directory", + Default: json.RawMessage(`"/"`), + }, + "ref": { + Type: "string", + Description: "Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`", + }, + "sha": { + Type: "string", + Description: "Accepts optional commit SHA. If specified, it will be used instead of ref", + }, + }, + Required: []string{"owner", "repo"}, + } + if includeFields { + schema.Properties["fields"] = &jsonschema.Schema{ + Type: "array", + Description: "Subset of fields to return for each entry when the path is a directory. If omitted, all fields are returned. Ignored when the path is a single file. Use this to reduce response size when listing directories and you only need specific fields, e.g. just 'name' and 'type'.", + Items: &jsonschema.Schema{ + Type: "string", + Enum: fileContentFieldEnum, + }, + } + } + return NewTool( ToolsetMetadataRepos, mcp.Tool{ @@ -704,33 +769,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool Title: t("TOOL_GET_FILE_CONTENTS_USER_TITLE", "Get file or directory contents"), ReadOnlyHint: true, }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: "Repository owner (username or organization)", - }, - "repo": { - Type: "string", - Description: "Repository name", - }, - "path": { - Type: "string", - Description: "Path to file/directory", - Default: json.RawMessage(`"/"`), - }, - "ref": { - Type: "string", - Description: "Accepts optional git refs such as `refs/tags/{tag}`, `refs/heads/{branch}` or `refs/pull/{pr_number}/head`", - }, - "sha": { - Type: "string", - Description: "Accepts optional commit SHA. If specified, it will be used instead of ref", - }, - }, - Required: []string{"owner", "repo"}, - }, + InputSchema: schema, }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { @@ -760,6 +799,14 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultError(err.Error()), nil, nil } + var fields []string + if includeFields { + fields, err = OptionalStringArrayParam(args, "fields") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + } + client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultError("failed to get GitHub client"), nil, nil @@ -883,10 +930,23 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool return attachIFC(utils.NewToolResultResource(fmt.Sprintf("successfully downloaded binary file (SHA: %s)%s", fileSHA, successNote), result)), nil, nil } else if dirContent != nil { // file content or file SHA is nil which means it's a directory - r, err := json.Marshal(dirContent) + filtered := false + var payload any = dirContent + if includeFields && len(fields) > 0 { + filteredEntries, err := filterEachField(dirContent, fields) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to filter directory contents", err), nil, nil + } + payload = filteredEntries + filtered = true + } + r, err := json.Marshal(payload) if err != nil { return utils.NewToolResultError("failed to marshal response"), nil, nil } + if includeFields { + recordDirContentsFieldsUsage(ctx, deps, dirContent, filtered, len(r)) + } return attachIFC(utils.NewToolResultText(string(r))), nil, nil } @@ -895,6 +955,20 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool ) } +// recordDirContentsFieldsUsage emits fields telemetry for a get_file_contents +// directory listing. sentBytes is the size of the payload actually returned. +// When the listing was filtered, the unfiltered size is computed from the full +// directory content so the realized savings can be measured. +func recordDirContentsFieldsUsage(ctx context.Context, deps ToolDependencies, full []*github.RepositoryContent, filtered bool, sentBytes int) { + fullBytes := sentBytes + if filtered { + if data, err := json.Marshal(full); err == nil { + fullBytes = len(data) + } + } + recordFieldsUsage(ctx, deps, "get_file_contents", filtered, fullBytes, sentBytes) +} + // ForkRepository creates a tool to fork a repository. func ForkRepository(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 2d44e9ed54..4c07430ee6 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -27,7 +27,11 @@ func Test_GetFileContents(t *testing.T) { // Verify tool definition once serverTool := GetFileContents(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + // GetFileContents is the FeatureFlagFieldsParam-enabled variant; it owns the + // _ff_ snapshot. The canonical get_file_contents.snap is owned by + // LegacyGetFileContents (see Test_LegacyGetFileContents_Definition). + require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagFieldsParam, tool)) + require.Equal(t, FeatureFlagFieldsParam, serverTool.FeatureFlagEnable) schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") @@ -39,6 +43,7 @@ func Test_GetFileContents(t *testing.T) { assert.Contains(t, schema.Properties, "path") assert.Contains(t, schema.Properties, "ref") assert.Contains(t, schema.Properties, "sha") + assert.Contains(t, schema.Properties, "fields") assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"}) // Mock response for raw content @@ -480,6 +485,134 @@ func Test_GetFileContents(t *testing.T) { } } +func Test_GetFileContents_DirectoryFieldFiltering(t *testing.T) { + mockDirContent := []*github.RepositoryContent{ + { + Type: github.Ptr("file"), + Name: github.Ptr("README.md"), + Path: github.Ptr("README.md"), + SHA: github.Ptr("abc123"), + Size: github.Ptr(42), + URL: github.Ptr("https://api.github.com/repos/owner/repo/contents/README.md"), + HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/README.md"), + DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/README.md"), + }, + { + Type: github.Ptr("dir"), + Name: github.Ptr("src"), + Path: github.Ptr("src"), + SHA: github.Ptr("def456"), + HTMLURL: github.Ptr("https://github.com/owner/repo/tree/main/src"), + }, + } + + serverTool := GetFileContents(translations.NullTranslationHelper) + client := mustNewGHClient(t, 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), + ), + GetRawReposContentsByOwnerByRepoByPath: expectQueryParams(t, map[string]string{"branch": "main"}).andThen( + mockResponse(t, http.StatusNotFound, nil), + ), + })) + deps := BaseDeps{Client: client} + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "path": "src/", + "fields": []any{"name", "type"}, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + + // Each directory entry is reduced to the requested fields only; heavier + // fields such as html_url and download_url are dropped. + var returned []map[string]any + require.NoError(t, json.Unmarshal([]byte(textContent.Text), &returned)) + require.Len(t, returned, len(mockDirContent)) + for _, entry := range returned { + require.Len(t, entry, 2) + assert.Contains(t, entry, "name") + assert.Contains(t, entry, "type") + } + + assert.NotContains(t, textContent.Text, "html_url") + assert.NotContains(t, textContent.Text, "download_url") +} + +func Test_LegacyGetFileContents_Definition(t *testing.T) { + serverTool := LegacyGetFileContents(translations.NullTranslationHelper) + tool := serverTool.Tool + // LegacyGetFileContents is the FeatureFlagFieldsParam-disabled variant and + // owns the canonical get_file_contents.snap (no `fields`). + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + require.Equal(t, []string{FeatureFlagFieldsParam}, serverTool.FeatureFlagDisable) + + assert.Equal(t, "get_file_contents", tool.Name) + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.NotContains(t, schema.Properties, "fields") +} + +func Test_GetFileContents_DirectoryFieldsTelemetry(t *testing.T) { + mockDirContent := []*github.RepositoryContent{ + { + Type: github.Ptr("file"), + Name: github.Ptr("README.md"), + Path: github.Ptr("README.md"), + SHA: github.Ptr("abc123"), + Size: github.Ptr(42), + URL: github.Ptr("https://api.github.com/repos/owner/repo/contents/README.md"), + HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/README.md"), + DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/README.md"), + }, + } + + serverTool := GetFileContents(translations.NullTranslationHelper) + client := mustNewGHClient(t, 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), + ), + GetRawReposContentsByOwnerByRepoByPath: expectQueryParams(t, map[string]string{"branch": "main"}).andThen( + mockResponse(t, http.StatusNotFound, nil), + ), + })) + deps, rec := depsWithRecordingMetrics(t, BaseDeps{Client: client}) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "path": "src/", + "fields": []any{"name", "type"}, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + call, ok := rec.increment(metricFieldsToolCall) + require.True(t, ok) + assert.Equal(t, "get_file_contents", call.tags["tool"]) + assert.Equal(t, "true", call.tags["filtered"]) + + full, ok := rec.counter(metricFieldsBytesFull) + require.True(t, ok) + sent, ok := rec.counter(metricFieldsBytesSent) + require.True(t, ok) + assert.Greater(t, full.value, sent.value, "filtering should remove bytes") +} + func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { t.Parallel() diff --git a/pkg/github/search.go b/pkg/github/search.go index 9adaefab67..0cfbfb4f3c 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -191,8 +191,34 @@ func attachSearchRepositoriesIFCLabel(ctx context.Context, deps ToolDependencies setIFCLabel(callResult, ifc.LabelSearchIssues(visibilities)) } -// SearchCode creates a tool to search for code across GitHub repositories. +// SearchCode creates a tool to search for code across GitHub repositories. It is +// the FeatureFlagFieldsParam-enabled variant: it advertises the optional +// `fields` parameter and filters each result to the requested subset. Both this +// and LegacySearchCode register under the tool name "search_code"; exactly one +// is active for any given request thanks to mutually exclusive +// FeatureFlagEnable / FeatureFlagDisable annotations. func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { + st := searchCodeTool(t, true) + st.FeatureFlagEnable = FeatureFlagFieldsParam + return st +} + +// LegacySearchCode is the FeatureFlagFieldsParam-disabled variant of +// search_code. It exposes the original schema (no `fields` parameter) and never +// filters results, so it acts as the kill switch when the flag is off. It owns +// the canonical search_code.snap; the flag-enabled variant owns +// search_code_ff_.snap. Delete this function when the flag is removed. +func LegacySearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { + st := searchCodeTool(t, false) + st.FeatureFlagDisable = []string{FeatureFlagFieldsParam} + return st +} + +// searchCodeTool builds the search_code tool. When includeFields is true the +// tool advertises the optional `fields` parameter, filters each result to the +// requested subset, and emits fields telemetry. When false it is the original +// tool with no fields parameter and no filtering. +func searchCodeTool(t translations.TranslationHelperFunc, includeFields bool) inventory.ServerTool { schema := &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ @@ -212,6 +238,16 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { }, Required: []string{"query"}, } + if includeFields { + schema.Properties["fields"] = &jsonschema.Schema{ + Type: "array", + Description: "Subset of fields to return for each code search result. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'repository' and 'text_matches' in particular drops the largest per-result data.", + Items: &jsonschema.Schema{ + Type: "string", + Enum: codeSearchItemFieldEnum, + }, + } + } WithPagination(schema) return NewTool( @@ -239,6 +275,13 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + var fields []string + if includeFields { + fields, err = OptionalStringArrayParam(args, "fields") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + } pagination, err := OptionalPaginationParams(args) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -297,11 +340,30 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { Items: minimalItems, } - r, err := json.Marshal(minimalResult) + filtered := false + var payload any = minimalResult + if includeFields && len(fields) > 0 { + filteredItems, err := filterEachField(minimalItems, fields) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to filter code search results", err), nil, nil + } + payload = map[string]any{ + "total_count": minimalResult.TotalCount, + "incomplete_results": minimalResult.IncompleteResults, + "items": filteredItems, + } + filtered = true + } + + r, err := json.Marshal(payload) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } + if includeFields { + recordSearchCodeFieldsUsage(ctx, deps, minimalResult, filtered, len(r)) + } + callResult := utils.NewToolResultText(string(r)) // Code search spans repositories; the IFC label is the conservative // join across every matched repository's visibility, read directly @@ -318,6 +380,20 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { ) } +// recordSearchCodeFieldsUsage emits fields telemetry for a search_code call. +// sentBytes is the size of the payload actually returned. When the response was +// filtered, the unfiltered size is computed from the full minimal result so the +// realized savings can be measured. +func recordSearchCodeFieldsUsage(ctx context.Context, deps ToolDependencies, full *MinimalCodeSearchResult, filtered bool, sentBytes int) { + fullBytes := sentBytes + if filtered { + if data, err := json.Marshal(full); err == nil { + fullBytes = len(data) + } + } + recordFieldsUsage(ctx, deps, "search_code", filtered, fullBytes, sentBytes) +} + func userOrOrgHandler(ctx context.Context, accountType string, deps ToolDependencies, args map[string]any) (*mcp.CallToolResult, any, error) { query, err := RequiredParam[string](args, "query") if err != nil { diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index a9b1c0609e..e5e673e74f 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -342,7 +342,11 @@ func Test_SearchCode(t *testing.T) { // Verify tool definition once serverTool := SearchCode(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + // SearchCode is the FeatureFlagFieldsParam-enabled variant; it owns the + // _ff_ snapshot. The canonical search_code.snap is owned by + // LegacySearchCode (see Test_LegacySearchCode_Definition). + require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagFieldsParam, tool)) + require.Equal(t, FeatureFlagFieldsParam, serverTool.FeatureFlagEnable) assert.Equal(t, "search_code", tool.Name) assert.NotEmpty(t, tool.Description) @@ -354,6 +358,7 @@ func Test_SearchCode(t *testing.T) { assert.Contains(t, schema.Properties, "order") assert.Contains(t, schema.Properties, "perPage") assert.Contains(t, schema.Properties, "page") + assert.Contains(t, schema.Properties, "fields") assert.ElementsMatch(t, schema.Required, []string{"query"}) // Setup mock search results @@ -509,6 +514,146 @@ func Test_SearchCode(t *testing.T) { } } +func Test_SearchCode_FieldFiltering(t *testing.T) { + mockSearchResult := &github.CodeSearchResult{ + Total: github.Ptr(1), + IncompleteResults: github.Ptr(false), + CodeResults: []*github.CodeResult{ + { + Name: github.Ptr("file1.go"), + Path: github.Ptr("path/to/file1.go"), + SHA: github.Ptr("abc123def456"), + Repository: &github.Repository{ + Name: github.Ptr("repo"), + FullName: github.Ptr("owner/repo"), + }, + TextMatches: []*github.TextMatch{ + {Fragment: github.Ptr("func main() {}")}, + }, + }, + }, + } + + serverTool := SearchCode(translations.NullTranslationHelper) + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchCode: mockResponse(t, http.StatusOK, mockSearchResult), + })) + deps := BaseDeps{Client: client} + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "query": "fmt.Println language:go", + "fields": []any{"name", "path"}, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + + // The wrapper metadata is preserved while each item is reduced to the + // requested fields only; the heavier repository and text_matches data is + // dropped. + var returned struct { + TotalCount int `json:"total_count"` + IncompleteResults bool `json:"incomplete_results"` + Items []map[string]any `json:"items"` + } + require.NoError(t, json.Unmarshal([]byte(textContent.Text), &returned)) + assert.Equal(t, 1, returned.TotalCount) + require.Len(t, returned.Items, 1) + require.Len(t, returned.Items[0], 2) + assert.Contains(t, returned.Items[0], "name") + assert.Contains(t, returned.Items[0], "path") + + assert.NotContains(t, textContent.Text, "repository") + assert.NotContains(t, textContent.Text, "text_matches") +} + +func Test_LegacySearchCode_Definition(t *testing.T) { + serverTool := LegacySearchCode(translations.NullTranslationHelper) + tool := serverTool.Tool + // LegacySearchCode is the FeatureFlagFieldsParam-disabled variant and owns + // the canonical search_code.snap (no `fields`). + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + require.Equal(t, []string{FeatureFlagFieldsParam}, serverTool.FeatureFlagDisable) + + assert.Equal(t, "search_code", tool.Name) + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.NotContains(t, schema.Properties, "fields") +} + +func Test_SearchCode_FieldsTelemetry(t *testing.T) { + mockSearchResult := &github.CodeSearchResult{ + Total: github.Ptr(1), + IncompleteResults: github.Ptr(false), + CodeResults: []*github.CodeResult{ + { + Name: github.Ptr("file1.go"), + Path: github.Ptr("path/to/file1.go"), + SHA: github.Ptr("abc123def456"), + Repository: &github.Repository{ + Name: github.Ptr("repo"), + FullName: github.Ptr("owner/repo"), + }, + TextMatches: []*github.TextMatch{ + {Fragment: github.Ptr("func main() {}")}, + }, + }, + }, + } + + serverTool := SearchCode(translations.NullTranslationHelper) + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchCode: mockResponse(t, http.StatusOK, mockSearchResult), + })) + + t.Run("filtered call records savings", func(t *testing.T) { + deps, rec := depsWithRecordingMetrics(t, BaseDeps{Client: client}) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "query": "fmt.Println language:go", + "fields": []any{"name", "path"}, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + call, ok := rec.increment(metricFieldsToolCall) + require.True(t, ok) + assert.Equal(t, "search_code", call.tags["tool"]) + assert.Equal(t, "true", call.tags["filtered"]) + + full, ok := rec.counter(metricFieldsBytesFull) + require.True(t, ok) + sent, ok := rec.counter(metricFieldsBytesSent) + require.True(t, ok) + assert.Greater(t, full.value, sent.value, "filtering should remove bytes") + }) + + t.Run("unfiltered call records adoption only", func(t *testing.T) { + deps, rec := depsWithRecordingMetrics(t, BaseDeps{Client: client}) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "query": "fmt.Println language:go", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + call, ok := rec.increment(metricFieldsToolCall) + require.True(t, ok) + assert.Equal(t, "false", call.tags["filtered"]) + + _, ok = rec.counter(metricFieldsBytesFull) + assert.False(t, ok, "no byte counters when not filtered") + }) +} + func Test_SearchUsers(t *testing.T) { // Verify tool definition once serverTool := SearchUsers(translations.NullTranslationHelper) diff --git a/pkg/github/tools.go b/pkg/github/tools.go index ec6716d5d4..d352e9b3c8 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -182,8 +182,10 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { // Repository tools SearchRepositories(t), GetFileContents(t), + LegacyGetFileContents(t), ListCommits(t), SearchCode(t), + LegacySearchCode(t), SearchCommits(t), GetCommit(t), GetFileBlame(t), From 439ec711fa106f06969caa9d20738f74014abe69 Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:09:34 +0100 Subject: [PATCH 14/16] Add fields param to six more list/search tools (#2810) * Add fields param to six more list/search tools Extend the optional `fields` response-filtering parameter (gated behind the `fields_param` feature flag, with adoption/savings telemetry) to six more read tools, following the dual-variant pattern already used by search_code and get_file_contents: - list_issues, list_pull_requests, list_commits, list_releases - search_issues, search_pull_requests For each tool, `X` is the flag-enabled variant that advertises `fields` and filters each result item to the requested subset, while `LegacyX` exposes the original schema and never filters, acting as a kill switch when the flag is off. Exactly one variant survives inventory filtering for any flag state via mutually exclusive FeatureFlagEnable / FeatureFlagDisable annotations. Wrapped responses (list_issues, search_issues, search_pull_requests) preserve their count / pagination envelope and only filter the item list; bare-array responses keep their array shape. Filtering reuses the shared filterEachField helper. A new fieldsSchemaProperty helper builds the `fields` schema (search_code and get_file_contents now use it too), and a shared recordFieldsUsageFor helper centralizes the telemetry full-size computation. Each field enum lists only the JSON fields the specific tool actually emits: list_commits omits stats/files (requested without per-file detail) and list_issues lists only the fields its GraphQL fragment populates. Adds per-tool field-filtering, telemetry, and Legacy definition tests, extends the mutual-exclusivity gating test to all eight gated tools, and regenerates the `_ff_fields_param` toolsnaps and feature-flag docs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Remove duplicate tool doc comments Address review: drop the leftover one-line doc comment above each dual-variant tool constructor (list_issues, list_pull_requests, list_commits, list_releases, search_issues, search_pull_requests); the detailed variant comment remains. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/feature-flags.md | 70 +++ .../list_commits_ff_fields_param.snap | 71 +++ .../list_issues_ff_fields_param.snap | 112 ++++ .../list_pull_requests_ff_fields_param.snap | 106 ++++ .../list_releases_ff_fields_param.snap | 55 ++ .../search_issues_ff_fields_param.snap | 98 ++++ .../search_pull_requests_ff_fields_param.snap | 96 ++++ pkg/github/fields_filtering_test.go | 540 ++++++++++++++++++ pkg/github/fields_param_gating_test.go | 23 +- pkg/github/fields_telemetry.go | 17 + pkg/github/issues.go | 143 ++++- pkg/github/issues_test.go | 15 +- pkg/github/minimal_types.go | 80 +++ pkg/github/pullrequests.go | 104 +++- pkg/github/pullrequests_test.go | 14 +- pkg/github/repositories.go | 240 +++++--- pkg/github/repositories_test.go | 14 +- pkg/github/search.go | 24 +- pkg/github/search_utils.go | 40 +- pkg/github/tools.go | 6 + 20 files changed, 1752 insertions(+), 116 deletions(-) create mode 100644 pkg/github/__toolsnaps__/list_commits_ff_fields_param.snap create mode 100644 pkg/github/__toolsnaps__/list_issues_ff_fields_param.snap create mode 100644 pkg/github/__toolsnaps__/list_pull_requests_ff_fields_param.snap create mode 100644 pkg/github/__toolsnaps__/list_releases_ff_fields_param.snap create mode 100644 pkg/github/__toolsnaps__/search_issues_ff_fields_param.snap create mode 100644 pkg/github/__toolsnaps__/search_pull_requests_ff_fields_param.snap create mode 100644 pkg/github/fields_filtering_test.go diff --git a/docs/feature-flags.md b/docs/feature-flags.md index 66fcb57c1b..c83e0c74be 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -349,6 +349,54 @@ runtime behavior (such as output formatting) won't appear here. - `repo`: Repository name (string, required) - `sha`: Accepts optional commit SHA. If specified, it will be used instead of ref (string, optional) +- **list_commits** - List commits + - **Required OAuth Scopes**: `repo` + - `author`: Author username or email address to filter commits by (string, optional) + - `fields`: Subset of fields to return for each commit. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields, e.g. just 'sha' and 'html_url'. (string[], optional) + - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `path`: Only commits containing this file path will be returned (string, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `repo`: Repository name (string, required) + - `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) + - `since`: Only commits after this date will be returned (ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DD) (string, optional) + - `until`: Only commits before this date will be returned (ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DD) (string, optional) + +- **list_issues** - List issues + - **Required OAuth Scopes**: `repo` + - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional) + - `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional) + - `field_filters`: Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date). (object[], optional) + - `fields`: Subset of fields to return for each issue. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body' and 'field_values' in particular drops the largest per-result data. (string[], optional) + - `labels`: Filter by labels (string[], optional) + - `orderBy`: Order issues by field. If provided, the 'direction' also needs to be provided. (string, optional) + - `owner`: Repository owner (string, required) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `repo`: Repository name (string, required) + - `since`: Filter by date (ISO 8601 timestamp) (string, optional) + - `state`: Filter by state, by default both open and closed issues are returned when not provided (string, optional) + +- **list_pull_requests** - List pull requests + - **Required OAuth Scopes**: `repo` + - `base`: Filter by base branch (string, optional) + - `direction`: Sort direction (string, optional) + - `fields`: Subset of fields to return for each pull request. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body' in particular drops the largest per-result data. (string[], optional) + - `head`: Filter by head user/org and branch (string, optional) + - `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) + - `sort`: Sort by (string, optional) + - `state`: Filter by state (string, optional) + +- **list_releases** - List releases + - **Required OAuth Scopes**: `repo` + - `fields`: Subset of fields to return for each release. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body' in particular drops the largest per-release data. (string[], optional) + - `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) + - **search_code** - Search code - **Required OAuth Scopes**: `repo` - `fields`: Subset of fields to return for each code search result. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'repository' and 'text_matches' in particular drops the largest per-result data. (string[], optional) @@ -358,4 +406,26 @@ runtime behavior (such as output formatting) won't appear here. - `query`: Search query (GitHub code search REST). Implicit AND between terms; supports `OR`, `NOT`, and `"quoted phrase"` for exact match. Qualifiers: `repo:owner/repo`, `org:`, `user:`, `language:`, `path:dir` (prefix match), `filename:exact.ext`, `extension:`, `in:file`, `in:path`, `size:`, `is:archived`, `is:fork`. Max 256 chars. Examples: `WithContext language:go org:github`; `"package main" repo:o/r`; `func extension:go path:cmd repo:o/r`; `NOT TODO language:go repo:o/r`. (string, required) - `sort`: Sort field ('indexed' only) (string, optional) +- **search_issues** - Search issues + - **Required OAuth Scopes**: `repo` + - `fields`: Subset of fields to return for each issue result. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body', 'reactions', and 'labels' in particular drops the largest per-result data. (string[], optional) + - `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) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `query`: Search query using GitHub issues search syntax (string, required) + - `repo`: Optional repository name. If provided with owner, only issues for this repository are listed. (string, optional) + - `sort`: Sort field by number of matches of categories, defaults to best match (string, optional) + +- **search_pull_requests** - Search pull requests + - **Required OAuth Scopes**: `repo` + - `fields`: Subset of fields to return for each pull request result. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body', 'reactions', and 'labels' in particular drops the largest per-result data. (string[], optional) + - `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) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `query`: Search query using GitHub pull request search syntax (string, required) + - `repo`: Optional repository name. If provided with owner, only pull requests for this repository are listed. (string, optional) + - `sort`: Sort field by number of matches of categories, defaults to best match (string, optional) + diff --git a/pkg/github/__toolsnaps__/list_commits_ff_fields_param.snap b/pkg/github/__toolsnaps__/list_commits_ff_fields_param.snap new file mode 100644 index 0000000000..bc4ffd1753 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_commits_ff_fields_param.snap @@ -0,0 +1,71 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "List commits" + }, + "description": "Get list of commits of a branch in a GitHub repository. Returns at least 30 results per page by default, but can return more if specified using the perPage parameter (up to 100).", + "inputSchema": { + "properties": { + "author": { + "description": "Author username or email address to filter commits by", + "type": "string" + }, + "fields": { + "description": "Subset of fields to return for each commit. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields, e.g. just 'sha' and 'html_url'.", + "items": { + "enum": [ + "sha", + "html_url", + "commit", + "author", + "committer" + ], + "type": "string" + }, + "type": "array" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "path": { + "description": "Only commits containing this file path will be returned", + "type": "string" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "sha": { + "description": "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.", + "type": "string" + }, + "since": { + "description": "Only commits after this date will be returned (ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DD)", + "type": "string" + }, + "until": { + "description": "Only commits before this date will be returned (ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DD)", + "type": "string" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "name": "list_commits" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_issues_ff_fields_param.snap b/pkg/github/__toolsnaps__/list_issues_ff_fields_param.snap new file mode 100644 index 0000000000..1055fe9947 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_issues_ff_fields_param.snap @@ -0,0 +1,112 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "List issues" + }, + "description": "List issues in a GitHub repository. For pagination, use the 'endCursor' from the previous response's 'pageInfo' in the 'after' parameter.", + "inputSchema": { + "properties": { + "after": { + "description": "Cursor for pagination. Use the cursor from the previous response.", + "type": "string" + }, + "direction": { + "description": "Order direction. If provided, the 'orderBy' also needs to be provided.", + "enum": [ + "ASC", + "DESC" + ], + "type": "string" + }, + "field_filters": { + "description": "Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date).", + "items": { + "properties": { + "field_name": { + "description": "Name of the custom field (e.g. \"Priority\"). Case-insensitive.", + "type": "string" + }, + "value": { + "description": "Value to filter on. For single-select fields, the option name (e.g. \"P1\"). For dates, YYYY-MM-DD. For numbers, the numeric value as a string. For text, the text value.", + "type": "string" + } + }, + "required": [ + "field_name", + "value" + ], + "type": "object" + }, + "type": "array" + }, + "fields": { + "description": "Subset of fields to return for each issue. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body' and 'field_values' in particular drops the largest per-result data.", + "items": { + "enum": [ + "number", + "title", + "body", + "state", + "user", + "labels", + "comments", + "created_at", + "updated_at", + "field_values" + ], + "type": "string" + }, + "type": "array" + }, + "labels": { + "description": "Filter by labels", + "items": { + "type": "string" + }, + "type": "array" + }, + "orderBy": { + "description": "Order issues by field. If provided, the 'direction' also needs to be provided.", + "enum": [ + "CREATED_AT", + "UPDATED_AT", + "COMMENTS" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "since": { + "description": "Filter by date (ISO 8601 timestamp)", + "type": "string" + }, + "state": { + "description": "Filter by state, by default both open and closed issues are returned when not provided", + "enum": [ + "OPEN", + "CLOSED" + ], + "type": "string" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "name": "list_issues" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_pull_requests_ff_fields_param.snap b/pkg/github/__toolsnaps__/list_pull_requests_ff_fields_param.snap new file mode 100644 index 0000000000..d37986d529 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_pull_requests_ff_fields_param.snap @@ -0,0 +1,106 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "List pull requests" + }, + "description": "List pull requests in a GitHub repository. If the user specifies an author, then DO NOT use this tool and use the search_pull_requests tool instead.", + "inputSchema": { + "properties": { + "base": { + "description": "Filter by base branch", + "type": "string" + }, + "direction": { + "description": "Sort direction", + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, + "fields": { + "description": "Subset of fields to return for each pull request. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body' in particular drops the largest per-result data.", + "items": { + "enum": [ + "number", + "title", + "body", + "state", + "draft", + "merged", + "mergeable_state", + "html_url", + "user", + "labels", + "assignees", + "requested_reviewers", + "merged_by", + "head", + "base", + "additions", + "deletions", + "changed_files", + "commits", + "comments", + "created_at", + "updated_at", + "closed_at", + "merged_at", + "milestone" + ], + "type": "string" + }, + "type": "array" + }, + "head": { + "description": "Filter by head user/org and branch", + "type": "string" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "sort": { + "description": "Sort by", + "enum": [ + "created", + "updated", + "popularity", + "long-running" + ], + "type": "string" + }, + "state": { + "description": "Filter by state", + "enum": [ + "open", + "closed", + "all" + ], + "type": "string" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "name": "list_pull_requests" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_releases_ff_fields_param.snap b/pkg/github/__toolsnaps__/list_releases_ff_fields_param.snap new file mode 100644 index 0000000000..4eeef279e9 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_releases_ff_fields_param.snap @@ -0,0 +1,55 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "List releases" + }, + "description": "List releases in a GitHub repository", + "inputSchema": { + "properties": { + "fields": { + "description": "Subset of fields to return for each release. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body' in particular drops the largest per-release data.", + "items": { + "enum": [ + "id", + "tag_name", + "name", + "body", + "html_url", + "published_at", + "prerelease", + "draft", + "author" + ], + "type": "string" + }, + "type": "array" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "name": "list_releases" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_issues_ff_fields_param.snap b/pkg/github/__toolsnaps__/search_issues_ff_fields_param.snap new file mode 100644 index 0000000000..f705f14725 --- /dev/null +++ b/pkg/github/__toolsnaps__/search_issues_ff_fields_param.snap @@ -0,0 +1,98 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "Search issues" + }, + "description": "Search for issues in GitHub repositories using issues search syntax already scoped to is:issue", + "inputSchema": { + "properties": { + "fields": { + "description": "Subset of fields to return for each issue result. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body', 'reactions', and 'labels' in particular drops the largest per-result data.", + "items": { + "enum": [ + "number", + "title", + "body", + "state", + "state_reason", + "draft", + "locked", + "html_url", + "user", + "author_association", + "labels", + "assignee", + "assignees", + "milestone", + "comments", + "reactions", + "created_at", + "updated_at", + "closed_at", + "closed_by", + "type", + "repository_url", + "pull_request", + "field_values" + ], + "type": "string" + }, + "type": "array" + }, + "order": { + "description": "Sort order", + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, + "owner": { + "description": "Optional repository owner. If provided with repo, only issues for this repository are listed.", + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "query": { + "description": "Search query using GitHub issues search syntax", + "type": "string" + }, + "repo": { + "description": "Optional repository name. If provided with owner, only issues for this repository are listed.", + "type": "string" + }, + "sort": { + "description": "Sort field by number of matches of categories, defaults to best match", + "enum": [ + "comments", + "reactions", + "reactions-+1", + "reactions--1", + "reactions-smile", + "reactions-thinking_face", + "reactions-heart", + "reactions-tada", + "interactions", + "created", + "updated" + ], + "type": "string" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "search_issues" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_pull_requests_ff_fields_param.snap b/pkg/github/__toolsnaps__/search_pull_requests_ff_fields_param.snap new file mode 100644 index 0000000000..847168b471 --- /dev/null +++ b/pkg/github/__toolsnaps__/search_pull_requests_ff_fields_param.snap @@ -0,0 +1,96 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "Search pull requests" + }, + "description": "Search for pull requests in GitHub repositories using issues search syntax already scoped to is:pr", + "inputSchema": { + "properties": { + "fields": { + "description": "Subset of fields to return for each pull request result. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body', 'reactions', and 'labels' in particular drops the largest per-result data.", + "items": { + "enum": [ + "number", + "title", + "body", + "state", + "state_reason", + "draft", + "locked", + "html_url", + "user", + "author_association", + "labels", + "assignee", + "assignees", + "milestone", + "comments", + "reactions", + "created_at", + "updated_at", + "closed_at", + "closed_by", + "pull_request", + "repository_url" + ], + "type": "string" + }, + "type": "array" + }, + "order": { + "description": "Sort order", + "enum": [ + "asc", + "desc" + ], + "type": "string" + }, + "owner": { + "description": "Optional repository owner. If provided with repo, only pull requests for this repository are listed.", + "type": "string" + }, + "page": { + "description": "Page number for pagination (min 1)", + "minimum": 1, + "type": "number" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "query": { + "description": "Search query using GitHub pull request search syntax", + "type": "string" + }, + "repo": { + "description": "Optional repository name. If provided with owner, only pull requests for this repository are listed.", + "type": "string" + }, + "sort": { + "description": "Sort field by number of matches of categories, defaults to best match", + "enum": [ + "comments", + "reactions", + "reactions-+1", + "reactions--1", + "reactions-smile", + "reactions-thinking_face", + "reactions-heart", + "reactions-tada", + "interactions", + "created", + "updated" + ], + "type": "string" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "search_pull_requests" +} \ No newline at end of file diff --git a/pkg/github/fields_filtering_test.go b/pkg/github/fields_filtering_test.go new file mode 100644 index 0000000000..f421fe1ebb --- /dev/null +++ b/pkg/github/fields_filtering_test.go @@ -0,0 +1,540 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v89/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- list_commits --------------------------------------------------------- + +func Test_LegacyListCommits_Definition(t *testing.T) { + serverTool := LegacyListCommits(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + require.Equal(t, []string{FeatureFlagFieldsParam}, serverTool.FeatureFlagDisable) + + assert.Equal(t, "list_commits", tool.Name) + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.NotContains(t, schema.Properties, "fields") +} + +func mockListCommits() []*github.RepositoryCommit { + return []*github.RepositoryCommit{ + { + SHA: github.Ptr("abc123def456"), + HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"), + Commit: &github.Commit{ + Message: github.Ptr("First commit with a reasonably long message to add bytes"), + Author: &github.CommitAuthor{ + Name: github.Ptr("Test User"), + Email: github.Ptr("test@example.com"), + }, + }, + Author: &github.User{Login: github.Ptr("testuser")}, + }, + } +} + +func Test_ListCommits_FieldFiltering(t *testing.T) { + serverTool := ListCommits(translations.NullTranslationHelper) + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepo: mockResponse(t, http.StatusOK, mockListCommits()), + })) + deps := BaseDeps{Client: client} + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "fields": []any{"sha"}, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var items []map[string]any + require.NoError(t, json.Unmarshal([]byte(textContent.Text), &items)) + require.Len(t, items, 1) + require.Len(t, items[0], 1) + assert.Contains(t, items[0], "sha") + assert.NotContains(t, textContent.Text, "html_url") + assert.NotContains(t, textContent.Text, "commit") +} + +func Test_ListCommits_FieldsTelemetry(t *testing.T) { + serverTool := ListCommits(translations.NullTranslationHelper) + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepo: mockResponse(t, http.StatusOK, mockListCommits()), + })) + + assertFieldsTelemetry(t, serverTool, client, "list_commits", + map[string]any{"owner": "owner", "repo": "repo", "fields": []any{"sha"}}, + map[string]any{"owner": "owner", "repo": "repo"}) +} + +// --- list_releases -------------------------------------------------------- + +func Test_LegacyListReleases_Definition(t *testing.T) { + serverTool := LegacyListReleases(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + require.Equal(t, []string{FeatureFlagFieldsParam}, serverTool.FeatureFlagDisable) + + assert.Equal(t, "list_releases", tool.Name) + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.NotContains(t, schema.Properties, "fields") +} + +func mockListReleases() []*github.RepositoryRelease { + return []*github.RepositoryRelease{ + { + ID: 1, + TagName: "v1.0.0", + Name: github.Ptr("First Release"), + Body: github.Ptr("Release notes with a reasonably long body to add bytes"), + HTMLURL: "https://github.com/owner/repo/releases/tag/v1.0.0", + }, + } +} + +func Test_ListReleases_FieldFiltering(t *testing.T) { + serverTool := ListReleases(translations.NullTranslationHelper) + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposReleasesByOwnerByRepo: mockResponse(t, http.StatusOK, mockListReleases()), + })) + deps := BaseDeps{Client: client} + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "fields": []any{"tag_name", "name"}, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var items []map[string]any + require.NoError(t, json.Unmarshal([]byte(textContent.Text), &items)) + require.Len(t, items, 1) + require.Len(t, items[0], 2) + assert.Contains(t, items[0], "tag_name") + assert.Contains(t, items[0], "name") + assert.NotContains(t, textContent.Text, "body") + assert.NotContains(t, textContent.Text, "html_url") +} + +func Test_ListReleases_FieldsTelemetry(t *testing.T) { + serverTool := ListReleases(translations.NullTranslationHelper) + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposReleasesByOwnerByRepo: mockResponse(t, http.StatusOK, mockListReleases()), + })) + + assertFieldsTelemetry(t, serverTool, client, "list_releases", + map[string]any{"owner": "owner", "repo": "repo", "fields": []any{"tag_name"}}, + map[string]any{"owner": "owner", "repo": "repo"}) +} + +// --- list_pull_requests --------------------------------------------------- + +func Test_LegacyListPullRequests_Definition(t *testing.T) { + serverTool := LegacyListPullRequests(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + require.Equal(t, []string{FeatureFlagFieldsParam}, serverTool.FeatureFlagDisable) + + assert.Equal(t, "list_pull_requests", tool.Name) + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.NotContains(t, schema.Properties, "fields") +} + +func mockListPullRequests() []*github.PullRequest { + return []*github.PullRequest{ + { + Number: github.Ptr(42), + Title: github.Ptr("First PR"), + Body: github.Ptr("PR body with a reasonably long description to add bytes"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), + User: &github.User{Login: github.Ptr("user1")}, + }, + } +} + +func Test_ListPullRequests_FieldFiltering(t *testing.T) { + serverTool := ListPullRequests(translations.NullTranslationHelper) + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepo: mockResponse(t, http.StatusOK, mockListPullRequests()), + })) + deps := BaseDeps{Client: client} + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "fields": []any{"number", "title"}, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var items []map[string]any + require.NoError(t, json.Unmarshal([]byte(textContent.Text), &items)) + require.Len(t, items, 1) + require.Len(t, items[0], 2) + assert.Contains(t, items[0], "number") + assert.Contains(t, items[0], "title") + assert.NotContains(t, textContent.Text, "html_url") + assert.NotContains(t, textContent.Text, "body") +} + +func Test_ListPullRequests_FieldsTelemetry(t *testing.T) { + serverTool := ListPullRequests(translations.NullTranslationHelper) + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsByOwnerByRepo: mockResponse(t, http.StatusOK, mockListPullRequests()), + })) + + assertFieldsTelemetry(t, serverTool, client, "list_pull_requests", + map[string]any{"owner": "owner", "repo": "repo", "fields": []any{"number"}}, + map[string]any{"owner": "owner", "repo": "repo"}) +} + +// --- search_pull_requests ------------------------------------------------- + +func Test_LegacySearchPullRequests_Definition(t *testing.T) { + serverTool := LegacySearchPullRequests(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + require.Equal(t, []string{FeatureFlagFieldsParam}, serverTool.FeatureFlagDisable) + + assert.Equal(t, "search_pull_requests", tool.Name) + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.NotContains(t, schema.Properties, "fields") +} + +// mockIssueSearchResult returns a single-item issues search result. It is used +// for both search_pull_requests and search_issues since both hit the REST +// issues search endpoint. Issues intentionally omit NodeID so search_issues +// does not attempt the follow-up GraphQL field-values enrichment. +func mockIssueSearchResult() *github.IssuesSearchResult { + return &github.IssuesSearchResult{ + Total: github.Ptr(1), + IncompleteResults: github.Ptr(false), + Issues: []*github.Issue{ + { + Number: github.Ptr(42), + Title: github.Ptr("A result"), + Body: github.Ptr("Body with a reasonably long description to add bytes"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), + User: &github.User{Login: github.Ptr("user1")}, + }, + }, + } +} + +func Test_SearchPullRequests_FieldFiltering(t *testing.T) { + serverTool := SearchPullRequests(translations.NullTranslationHelper) + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: mockResponse(t, http.StatusOK, mockIssueSearchResult()), + })) + deps := BaseDeps{Client: client} + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "query": "fix", + "fields": []any{"number", "title"}, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + assertSearchWrapperFiltered(t, textContent.Text) +} + +func Test_SearchPullRequests_FieldsTelemetry(t *testing.T) { + serverTool := SearchPullRequests(translations.NullTranslationHelper) + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: mockResponse(t, http.StatusOK, mockIssueSearchResult()), + })) + + assertFieldsTelemetry(t, serverTool, client, "search_pull_requests", + map[string]any{"query": "fix", "fields": []any{"number"}}, + map[string]any{"query": "fix"}) +} + +// --- search_issues -------------------------------------------------------- + +func Test_LegacySearchIssues_Definition(t *testing.T) { + serverTool := LegacySearchIssues(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + require.Equal(t, []string{FeatureFlagFieldsParam}, serverTool.FeatureFlagDisable) + + assert.Equal(t, "search_issues", tool.Name) + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.NotContains(t, schema.Properties, "fields") +} + +func Test_SearchIssues_FieldFiltering(t *testing.T) { + serverTool := SearchIssues(translations.NullTranslationHelper) + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: mockResponse(t, http.StatusOK, mockIssueSearchResult()), + })) + deps := BaseDeps{Client: client} + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "query": "bug", + "fields": []any{"number", "title"}, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + assertSearchWrapperFiltered(t, textContent.Text) +} + +func Test_SearchIssues_FieldsTelemetry(t *testing.T) { + serverTool := SearchIssues(translations.NullTranslationHelper) + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: mockResponse(t, http.StatusOK, mockIssueSearchResult()), + })) + + assertFieldsTelemetry(t, serverTool, client, "search_issues", + map[string]any{"query": "bug", "fields": []any{"number"}}, + map[string]any{"query": "bug"}) +} + +// --- list_issues (GraphQL) ------------------------------------------------ + +func Test_LegacyListIssues_Definition(t *testing.T) { + serverTool := LegacyListIssues(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + require.Equal(t, []string{FeatureFlagFieldsParam}, serverTool.FeatureFlagDisable) + + assert.Equal(t, "list_issues", tool.Name) + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.NotContains(t, schema.Properties, "fields") +} + +// listIssuesFieldsQuery and listIssuesFieldsVars mirror the exact GraphQL query +// and variables list_issues issues for owner/repo with default parameters (no +// labels, no since). They must stay in sync with the query built in +// getIssueQueryType; see Test_ListIssues for the canonical copies. +const listIssuesFieldsFieldValuesSelection = "issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name,fullDatabaseId},... on IssueFieldNumber{name,fullDatabaseId},... on IssueFieldSingleSelect{name,fullDatabaseId},... on IssueFieldText{name,fullDatabaseId}},value}}}" + +const listIssuesFieldsQuery = "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}," + listIssuesFieldsFieldValuesSelection + "},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + +func listIssuesFieldsMockClient() *http.Client { + vars := map[string]any{ + "owner": "owner", + "repo": "repo", + "states": []any{"OPEN", "CLOSED"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + "issueFieldValues": []any{}, + } + response := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issues": map[string]any{ + "nodes": []map[string]any{ + { + "number": 123, + "title": "First Issue", + "body": "This is a reasonably long issue body to add bytes", + "state": "OPEN", + "databaseId": 1001, + "createdAt": "2023-01-01T00:00:00Z", + "updatedAt": "2023-01-01T00:00:00Z", + "author": map[string]any{"login": "user1"}, + "labels": map[string]any{"nodes": []map[string]any{}}, + "comments": map[string]any{"totalCount": 1}, + "issueFieldValues": map[string]any{"nodes": []map[string]any{}}, + }, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + "totalCount": 1, + }, + "isPrivate": false, + }, + }) + matcher := githubv4mock.NewQueryMatcher(listIssuesFieldsQuery, vars, response) + return githubv4mock.NewMockedHTTPClient(matcher) +} + +func Test_ListIssues_FieldFiltering(t *testing.T) { + serverTool := ListIssues(translations.NullTranslationHelper) + deps := BaseDeps{GQLClient: githubv4.NewClient(listIssuesFieldsMockClient())} + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "fields": []any{"number", "title"}, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + + // The wrapper metadata is preserved while each issue is reduced to the + // requested fields only. + var returned struct { + Issues []map[string]any `json:"issues"` + TotalCount int `json:"totalCount"` + PageInfo map[string]any `json:"pageInfo"` + } + require.NoError(t, json.Unmarshal([]byte(textContent.Text), &returned)) + assert.Equal(t, 1, returned.TotalCount) + require.NotNil(t, returned.PageInfo) + require.Len(t, returned.Issues, 1) + require.Len(t, returned.Issues[0], 2) + assert.Contains(t, returned.Issues[0], "number") + assert.Contains(t, returned.Issues[0], "title") + assert.NotContains(t, textContent.Text, "\"body\"") +} + +func Test_ListIssues_FieldsTelemetry(t *testing.T) { + serverTool := ListIssues(translations.NullTranslationHelper) + + t.Run("filtered call records savings", func(t *testing.T) { + deps, rec := depsWithRecordingMetrics(t, BaseDeps{GQLClient: githubv4.NewClient(listIssuesFieldsMockClient())}) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "fields": []any{"number"}, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + assertFilteredCounters(t, rec, "list_issues") + }) + + t.Run("unfiltered call records adoption only", func(t *testing.T) { + deps, rec := depsWithRecordingMetrics(t, BaseDeps{GQLClient: githubv4.NewClient(listIssuesFieldsMockClient())}) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + call, ok := rec.increment(metricFieldsToolCall) + require.True(t, ok) + assert.Equal(t, "false", call.tags["filtered"]) + _, ok = rec.counter(metricFieldsBytesFull) + assert.False(t, ok, "no byte counters when not filtered") + }) +} + +// --- shared assertion helpers --------------------------------------------- + +// assertSearchWrapperFiltered asserts that a filtered search response preserves +// the total_count / incomplete_results wrapper while reducing each item to the +// requested number/title fields only. +func assertSearchWrapperFiltered(t *testing.T, text string) { + t.Helper() + var returned struct { + TotalCount int `json:"total_count"` + IncompleteResults bool `json:"incomplete_results"` + Items []map[string]any `json:"items"` + } + require.NoError(t, json.Unmarshal([]byte(text), &returned)) + assert.Equal(t, 1, returned.TotalCount) + require.Len(t, returned.Items, 1) + require.Len(t, returned.Items[0], 2) + assert.Contains(t, returned.Items[0], "number") + assert.Contains(t, returned.Items[0], "title") + assert.NotContains(t, text, "html_url") + assert.NotContains(t, text, "\"body\"") +} + +// assertFilteredCounters asserts the full set of counters emitted for a filtered +// call: an increment tagged filtered=true plus positive byte counters where +// full > sent. +func assertFilteredCounters(t *testing.T, rec *recordingMetrics, tool string) { + t.Helper() + call, ok := rec.increment(metricFieldsToolCall) + require.True(t, ok) + assert.Equal(t, tool, call.tags["tool"]) + assert.Equal(t, "true", call.tags["filtered"]) + + full, ok := rec.counter(metricFieldsBytesFull) + require.True(t, ok) + sent, ok := rec.counter(metricFieldsBytesSent) + require.True(t, ok) + assert.Greater(t, full.value, sent.value, "filtering should remove bytes") +} + +// assertFieldsTelemetry runs a filtered and an unfiltered call against the given +// tool and asserts the expected adoption and savings telemetry for each. +func assertFieldsTelemetry(t *testing.T, serverTool inventory.ServerTool, client *github.Client, tool string, filteredArgs, unfilteredArgs map[string]any) { + t.Helper() + + t.Run("filtered call records savings", func(t *testing.T) { + deps, rec := depsWithRecordingMetrics(t, BaseDeps{Client: client}) + handler := serverTool.Handler(deps) + + request := createMCPRequest(filteredArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + assertFilteredCounters(t, rec, tool) + }) + + t.Run("unfiltered call records adoption only", func(t *testing.T) { + deps, rec := depsWithRecordingMetrics(t, BaseDeps{Client: client}) + handler := serverTool.Handler(deps) + + request := createMCPRequest(unfilteredArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + call, ok := rec.increment(metricFieldsToolCall) + require.True(t, ok) + assert.Equal(t, "false", call.tags["filtered"]) + _, ok = rec.counter(metricFieldsBytesFull) + assert.False(t, ok, "no byte counters when not filtered") + }) +} diff --git a/pkg/github/fields_param_gating_test.go b/pkg/github/fields_param_gating_test.go index 09b176914b..1e61d4eb81 100644 --- a/pkg/github/fields_param_gating_test.go +++ b/pkg/github/fields_param_gating_test.go @@ -11,14 +11,23 @@ import ( ) // Test_FieldsParamVariants_MutuallyExclusive guards the dual-variant -// registration for the fields_param feature flag. The flag-enabled tools -// (search_code, get_file_contents) and their Legacy* counterparts share a tool -// name, so exactly one of each pair must survive inventory filtering for any -// flag state. If both ever leaked, a client could be offered two tools with the -// same name. This asserts that each gated tool is present exactly once, -// advertising the `fields` parameter only when fields_param is enabled. +// registration for the fields_param feature flag. The flag-enabled tools and +// their Legacy* counterparts share a tool name, so exactly one of each pair must +// survive inventory filtering for any flag state. If both ever leaked, a client +// could be offered two tools with the same name. This asserts that each gated +// tool is present exactly once, advertising the `fields` parameter only when +// fields_param is enabled. func Test_FieldsParamVariants_MutuallyExclusive(t *testing.T) { - gatedTools := []string{"search_code", "get_file_contents"} + gatedTools := []string{ + "search_code", + "get_file_contents", + "list_issues", + "list_releases", + "list_pull_requests", + "search_issues", + "search_pull_requests", + "list_commits", + } for _, tc := range []struct { name string diff --git a/pkg/github/fields_telemetry.go b/pkg/github/fields_telemetry.go index b2c1528c6b..4e06d9f42b 100644 --- a/pkg/github/fields_telemetry.go +++ b/pkg/github/fields_telemetry.go @@ -2,6 +2,7 @@ package github import ( "context" + "encoding/json" "strconv" ) @@ -52,3 +53,19 @@ func recordFieldsUsage(ctx context.Context, deps ToolDependencies, tool string, m.Counter(metricFieldsBytesFull, toolTag, int64(fullBytes)) m.Counter(metricFieldsBytesSent, toolTag, int64(sentBytes)) } + +// recordFieldsUsageFor emits fields telemetry for a tool whose response is a +// list of items (optionally wrapped in a metadata envelope). sentBytes is the +// size of the payload actually returned. When the response was filtered, the +// unfiltered size is computed by marshalling full so the realized savings can be +// measured; full should be the complete, unfiltered payload. It centralizes the +// full-size computation shared by every fields-enabled tool. +func recordFieldsUsageFor(ctx context.Context, deps ToolDependencies, tool string, full any, filtered bool, sentBytes int) { + fullBytes := sentBytes + if filtered { + if data, err := json.Marshal(full); err == nil { + fullBytes = len(data) + } + } + recordFieldsUsage(ctx, deps, tool, filtered, fullBytes, sentBytes) +} diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 3d94ac856b..bbccdc35d8 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1605,8 +1605,34 @@ func ReprioritizeSubIssue(ctx context.Context, client *github.Client, owner stri return utils.NewToolResultText(string(r)), nil } -// SearchIssues creates a tool to search for issues. +// SearchIssues creates a tool to search for issues. It is the +// FeatureFlagFieldsParam-enabled variant: it advertises the optional `fields` +// parameter and filters each result to the requested subset. Both this and +// LegacySearchIssues register under the tool name "search_issues"; exactly one is +// active for any given request thanks to mutually exclusive FeatureFlagEnable / +// FeatureFlagDisable annotations. func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool { + st := searchIssuesTool(t, true) + st.FeatureFlagEnable = FeatureFlagFieldsParam + return st +} + +// LegacySearchIssues is the FeatureFlagFieldsParam-disabled variant of +// search_issues. It exposes the original schema (no `fields` parameter) and never +// filters results, so it acts as the kill switch when the flag is off. It owns +// the canonical search_issues.snap; the flag-enabled variant owns +// search_issues_ff_.snap. Delete this function when the flag is removed. +func LegacySearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool { + st := searchIssuesTool(t, false) + st.FeatureFlagDisable = []string{FeatureFlagFieldsParam} + return st +} + +// searchIssuesTool builds the search_issues tool. When includeFields is true the +// tool advertises the optional `fields` parameter, filters each result to the +// requested subset, and emits fields telemetry. When false it is the original +// tool with no fields parameter and no filtering. +func searchIssuesTool(t translations.TranslationHelperFunc, includeFields bool) inventory.ServerTool { schema := &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ @@ -1647,6 +1673,12 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool { }, Required: []string{"query"}, } + if includeFields { + schema.Properties["fields"] = fieldsSchemaProperty( + "Subset of fields to return for each issue result. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body', 'reactions', and 'labels' in particular drops the largest per-result data.", + searchIssuesItemFieldEnum, + ) + } WithPagination(schema) return NewTool( @@ -1662,7 +1694,15 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool { }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - result, err := searchIssuesHandler(ctx, deps, args, ifcSearchPostProcessOption(ctx, deps)) + options := []searchOption{ifcSearchPostProcessOption(ctx, deps)} + if includeFields { + fields, err := OptionalStringArrayParam(args, "fields") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + options = append(options, withFieldsFiltering(deps, "search_issues", fields)) + } + result, err := searchIssuesHandler(ctx, deps, args, options...) return result, nil, err }) } @@ -1983,16 +2023,36 @@ func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[st Items: items, } - r, err := json.Marshal(response) + cfg := searchConfig{} + for _, opt := range options { + opt(&cfg) + } + + filtered := false + var payload any = response + if len(cfg.fields) > 0 { + filteredItems, err := filterEachField(response.Items, cfg.fields) + if err != nil { + return utils.NewToolResultErrorFromErr(errorPrefix+": failed to filter results", err), nil + } + payload = map[string]any{ + "total_count": response.Total, + "incomplete_results": response.IncompleteResults, + "items": filteredItems, + } + filtered = true + } + + r, err := json.Marshal(payload) if err != nil { return utils.NewToolResultErrorFromErr(errorPrefix+": failed to marshal response", err), nil } - callResult := utils.NewToolResultText(string(r)) - cfg := searchConfig{} - for _, opt := range options { - opt(&cfg) + if cfg.fieldsTool != "" { + recordFieldsUsageFor(ctx, cfg.fieldsDeps, cfg.fieldsTool, response, filtered, len(r)) } + + callResult := utils.NewToolResultText(string(r)) if cfg.postProcess != nil { cfg.postProcess(ctx, result, callResult) } @@ -2602,9 +2662,34 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4 return utils.NewToolResultText(string(r)), nil } -// ListIssues creates a tool to list and filter repository issues. It exposes the -// Issues 2.0 field_filters input plus field_values output enrichment. +// ListIssues creates a tool to list issues in a GitHub repository. It is the +// FeatureFlagFieldsParam-enabled variant: it advertises the optional `fields` +// parameter and filters each issue to the requested subset. Both this and +// LegacyListIssues register under the tool name "list_issues"; exactly one is +// active for any given request thanks to mutually exclusive FeatureFlagEnable / +// FeatureFlagDisable annotations. func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { + st := listIssuesTool(t, true) + st.FeatureFlagEnable = FeatureFlagFieldsParam + return st +} + +// LegacyListIssues is the FeatureFlagFieldsParam-disabled variant of list_issues. +// It exposes the original schema (no `fields` parameter) and never filters +// results, so it acts as the kill switch when the flag is off. It owns the +// canonical list_issues.snap; the flag-enabled variant owns +// list_issues_ff_.snap. Delete this function when the flag is removed. +func LegacyListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { + st := listIssuesTool(t, false) + st.FeatureFlagDisable = []string{FeatureFlagFieldsParam} + return st +} + +// listIssuesTool builds the list_issues tool. When includeFields is true the +// tool advertises the optional `fields` parameter, filters each issue to the +// requested subset, and emits fields telemetry. When false it is the original +// tool with no fields parameter and no filtering. +func listIssuesTool(t translations.TranslationHelperFunc, includeFields bool) inventory.ServerTool { schema := &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ @@ -2663,6 +2748,12 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { }, Required: []string{"owner", "repo"}, } + if includeFields { + schema.Properties["fields"] = fieldsSchemaProperty( + "Subset of fields to return for each issue. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body' and 'field_values' in particular drops the largest per-result data.", + listIssuesItemFieldEnum, + ) + } WithCursorPagination(schema) st := NewTool( @@ -2687,6 +2778,14 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultError(err.Error()), nil, nil } + var fields []string + if includeFields { + fields, err = OptionalStringArrayParam(args, "fields") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + } + // Set optional parameters if provided state, err := OptionalParam[string](args, "state") if err != nil { @@ -2856,7 +2955,31 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { isPrivate = queryResult.GetIsPrivate() } - result := MarshalledTextResult(resp) + filtered := false + var payload any = resp + if includeFields && len(fields) > 0 { + filteredIssues, err := filterEachField(resp.Issues, fields) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to filter issues", err), nil, nil + } + payload = map[string]any{ + "issues": filteredIssues, + "totalCount": resp.TotalCount, + "pageInfo": resp.PageInfo, + } + filtered = true + } + + r, err := json.Marshal(payload) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + + if includeFields { + recordFieldsUsageFor(ctx, deps, "list_issues", resp, filtered, len(r)) + } + + result := utils.NewToolResultText(string(r)) result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelListIssues(isPrivate)) return result, nil, nil }) diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index dbd246af94..c3ea692464 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -812,7 +812,11 @@ func Test_SearchIssues(t *testing.T) { // Verify tool definition once serverTool := SearchIssues(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + // SearchIssues is the FeatureFlagFieldsParam-enabled variant; it owns the + // _ff_ snapshot. The canonical search_issues.snap is owned by + // LegacySearchIssues (see Test_LegacySearchIssues_Definition). + require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagFieldsParam, tool)) + require.Equal(t, FeatureFlagFieldsParam, serverTool.FeatureFlagEnable) assert.Equal(t, "search_issues", tool.Name) assert.NotEmpty(t, tool.Description) @@ -823,6 +827,7 @@ func Test_SearchIssues(t *testing.T) { assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "order") assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "perPage") assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "page") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "fields") assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"query"}) // Setup mock search results @@ -1892,8 +1897,11 @@ func Test_ListIssues(t *testing.T) { // Verify tool definition serverTool := ListIssues(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - require.Empty(t, serverTool.FeatureFlagEnable) + // ListIssues is the FeatureFlagFieldsParam-enabled variant; it owns the + // _ff_ snapshot. The canonical list_issues.snap is owned by + // LegacyListIssues (see Test_LegacyListIssues_Definition). + require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagFieldsParam, tool)) + require.Equal(t, FeatureFlagFieldsParam, serverTool.FeatureFlagEnable) assert.Equal(t, "list_issues", tool.Name) assert.NotEmpty(t, tool.Description) @@ -1906,6 +1914,7 @@ func Test_ListIssues(t *testing.T) { assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "since") assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "after") assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "perPage") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "fields") assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner", "repo"}) // Mock issues data diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 16273c7093..75bc8f48f1 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -10,6 +10,7 @@ import ( "time" "github.com/google/go-github/v89/github" + "github.com/google/jsonschema-go/jsonschema" "github.com/github/github-mcp-server/pkg/sanitize" ) @@ -26,6 +27,71 @@ var codeSearchItemFieldEnum = []any{"name", "path", "sha", "repository", "text_m // the requested path is a directory; ignored for single files. var fileContentFieldEnum = []any{"type", "name", "path", "size", "sha", "url", "git_url", "html_url", "download_url"} +// listIssuesItemFieldEnum lists the selectable fields for list_issues result +// items, matching the JSON field names MinimalIssue actually populates via the +// list_issues GraphQL fragment (fragmentToMinimalIssue). Fields that only the +// REST conversion sets (for example html_url, reactions, issue_field_values) are +// never emitted here and are intentionally omitted. The body and field_values +// fields are the heaviest, so omitting them is the main lever for shrinking large +// result sets. +var listIssuesItemFieldEnum = []any{ + "number", "title", "body", "state", "user", "labels", + "comments", "created_at", "updated_at", "field_values", +} + +// listPullRequestsItemFieldEnum lists the selectable fields for +// list_pull_requests result items, matching the JSON field names of +// MinimalPullRequest. The body field is the heaviest, so omitting it is the main +// lever for shrinking large result sets. +var listPullRequestsItemFieldEnum = []any{ + "number", "title", "body", "state", "draft", "merged", "mergeable_state", + "html_url", "user", "labels", "assignees", "requested_reviewers", "merged_by", + "head", "base", "additions", "deletions", "changed_files", "commits", + "comments", "created_at", "updated_at", "closed_at", "merged_at", "milestone", +} + +// listCommitsItemFieldEnum lists the selectable fields for list_commits result +// items, matching the JSON field names MinimalCommit populates for list_commits. +// list_commits requests commits without per-file detail (commitDetailNone), so +// the stats and files fields are never emitted and are intentionally omitted +// here. The commit field (message plus author/committer metadata) is the +// heaviest, so omitting it is the main lever for shrinking large result sets. +var listCommitsItemFieldEnum = []any{ + "sha", "html_url", "commit", "author", "committer", +} + +// listReleasesItemFieldEnum lists the selectable fields for list_releases result +// items, matching the JSON field names of MinimalRelease. The body field is the +// heaviest, so omitting it is the main lever for shrinking large result sets. +var listReleasesItemFieldEnum = []any{ + "id", "tag_name", "name", "body", "html_url", "published_at", + "prerelease", "draft", "author", +} + +// searchIssuesItemFieldEnum lists the selectable fields for search_issues result +// items. Items are full github.Issue objects enriched with normalized +// field_values, so this is a curated subset of the most useful JSON field names. +// The body, reactions, and labels fields are the heaviest, so omitting them is +// the main lever for shrinking large result sets. +var searchIssuesItemFieldEnum = []any{ + "number", "title", "body", "state", "state_reason", "draft", "locked", + "html_url", "user", "author_association", "labels", "assignee", "assignees", + "milestone", "comments", "reactions", "created_at", "updated_at", "closed_at", + "closed_by", "type", "repository_url", "pull_request", "field_values", +} + +// searchPullRequestsItemFieldEnum lists the selectable fields for +// search_pull_requests result items. Issue search returns pull requests as +// github.Issue objects, so this is a curated subset of those JSON field names. +// The body, reactions, and labels fields are the heaviest, so omitting them is +// the main lever for shrinking large result sets. +var searchPullRequestsItemFieldEnum = []any{ + "number", "title", "body", "state", "state_reason", "draft", "locked", + "html_url", "user", "author_association", "labels", "assignee", "assignees", + "milestone", "comments", "reactions", "created_at", "updated_at", "closed_at", + "closed_by", "pull_request", "repository_url", +} + // filterFields marshals v to a JSON object and returns a map containing only the // requested fields. Fields that are unknown or absent from the JSON (for example // empty values dropped via omitempty) are skipped. @@ -65,6 +131,20 @@ func filterEachField[T any](items []T, fields []string) ([]map[string]any, error return filtered, nil } +// fieldsSchemaProperty builds the optional `fields` array parameter shared by +// every fields-enabled tool: an array of strings constrained to the given enum +// of selectable field names, with a per-tool description. +func fieldsSchemaProperty(description string, enum []any) *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "array", + Description: description, + Items: &jsonschema.Schema{ + Type: "string", + Enum: enum, + }, + } +} + // MinimalUser is the output type for user and organization search results. type MinimalUser struct { Login string `json:"login"` diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 9b5b1b0750..942cfd3a91 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -1294,8 +1294,35 @@ func AddReplyToPullRequestComment(t translations.TranslationHelperFunc) inventor }) } -// ListPullRequests creates a tool to list and filter repository pull requests. +// ListPullRequests creates a tool to list pull requests in a GitHub repository. +// It is the FeatureFlagFieldsParam-enabled variant: it advertises the optional +// `fields` parameter and filters each pull request to the requested subset. Both +// this and LegacyListPullRequests register under the tool name +// "list_pull_requests"; exactly one is active for any given request thanks to +// mutually exclusive FeatureFlagEnable / FeatureFlagDisable annotations. func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool { + st := listPullRequestsTool(t, true) + st.FeatureFlagEnable = FeatureFlagFieldsParam + return st +} + +// LegacyListPullRequests is the FeatureFlagFieldsParam-disabled variant of +// list_pull_requests. It exposes the original schema (no `fields` parameter) and +// never filters results, so it acts as the kill switch when the flag is off. It +// owns the canonical list_pull_requests.snap; the flag-enabled variant owns +// list_pull_requests_ff_.snap. Delete this function when the flag is +// removed. +func LegacyListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool { + st := listPullRequestsTool(t, false) + st.FeatureFlagDisable = []string{FeatureFlagFieldsParam} + return st +} + +// listPullRequestsTool builds the list_pull_requests tool. When includeFields is +// true the tool advertises the optional `fields` parameter, filters each pull +// request to the requested subset, and emits fields telemetry. When false it is +// the original tool with no fields parameter and no filtering. +func listPullRequestsTool(t translations.TranslationHelperFunc, includeFields bool) inventory.ServerTool { schema := &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ @@ -1333,6 +1360,12 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool }, Required: []string{"owner", "repo"}, } + if includeFields { + schema.Properties["fields"] = fieldsSchemaProperty( + "Subset of fields to return for each pull request. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body' in particular drops the largest per-result data.", + listPullRequestsItemFieldEnum, + ) + } WithPagination(schema) return NewTool( @@ -1376,6 +1409,13 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + var fields []string + if includeFields { + fields, err = OptionalStringArrayParam(args, "fields") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + } pagination, err := OptionalPaginationParams(args) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -1435,11 +1475,26 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool } } - r, err := json.Marshal(minimalPRs) + filtered := false + var payload any = minimalPRs + if includeFields && len(fields) > 0 { + filteredPRs, err := filterEachField(minimalPRs, fields) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to filter pull requests", err), nil, nil + } + payload = filteredPRs + filtered = true + } + + r, err := json.Marshal(payload) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } + if includeFields { + recordFieldsUsageFor(ctx, deps, "list_pull_requests", minimalPRs, filtered, len(r)) + } + result := utils.NewToolResultText(string(r)) // Pull request titles/bodies are user-authored (untrusted); // confidentiality follows repo visibility. @@ -1557,8 +1612,35 @@ func MergePullRequest(t translations.TranslationHelperFunc) inventory.ServerTool }) } -// SearchPullRequests creates a tool to search for pull requests. +// SearchPullRequests creates a tool to search for pull requests. It is the +// FeatureFlagFieldsParam-enabled variant: it advertises the optional `fields` +// parameter and filters each result to the requested subset. Both this and +// LegacySearchPullRequests register under the tool name "search_pull_requests"; +// exactly one is active for any given request thanks to mutually exclusive +// FeatureFlagEnable / FeatureFlagDisable annotations. func SearchPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool { + st := searchPullRequestsTool(t, true) + st.FeatureFlagEnable = FeatureFlagFieldsParam + return st +} + +// LegacySearchPullRequests is the FeatureFlagFieldsParam-disabled variant of +// search_pull_requests. It exposes the original schema (no `fields` parameter) +// and never filters results, so it acts as the kill switch when the flag is off. +// It owns the canonical search_pull_requests.snap; the flag-enabled variant owns +// search_pull_requests_ff_.snap. Delete this function when the flag is +// removed. +func LegacySearchPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool { + st := searchPullRequestsTool(t, false) + st.FeatureFlagDisable = []string{FeatureFlagFieldsParam} + return st +} + +// searchPullRequestsTool builds the search_pull_requests tool. When +// includeFields is true the tool advertises the optional `fields` parameter, +// filters each result to the requested subset, and emits fields telemetry. When +// false it is the original tool with no fields parameter and no filtering. +func searchPullRequestsTool(t translations.TranslationHelperFunc, includeFields bool) inventory.ServerTool { schema := &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ @@ -1599,6 +1681,12 @@ func SearchPullRequests(t translations.TranslationHelperFunc) inventory.ServerTo }, Required: []string{"query"}, } + if includeFields { + schema.Properties["fields"] = fieldsSchemaProperty( + "Subset of fields to return for each pull request result. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body', 'reactions', and 'labels' in particular drops the largest per-result data.", + searchPullRequestsItemFieldEnum, + ) + } WithPagination(schema) return NewTool( @@ -1614,7 +1702,15 @@ func SearchPullRequests(t translations.TranslationHelperFunc) inventory.ServerTo }, []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", ifcSearchPostProcessOption(ctx, deps)) + options := []searchOption{ifcSearchPostProcessOption(ctx, deps)} + if includeFields { + fields, err := OptionalStringArrayParam(args, "fields") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + options = append(options, withFieldsFiltering(deps, "search_pull_requests", fields)) + } + result, err := searchHandler(ctx, deps.GetClient, args, "pr", "failed to search pull requests", options...) return result, nil, err }) } diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 11f51cece4..c032502c97 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -574,7 +574,11 @@ func Test_ListPullRequests(t *testing.T) { // Verify tool definition once serverTool := ListPullRequests(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + // ListPullRequests is the FeatureFlagFieldsParam-enabled variant; it owns + // the _ff_ snapshot. The canonical list_pull_requests.snap is owned by + // LegacyListPullRequests (see Test_LegacyListPullRequests_Definition). + require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagFieldsParam, tool)) + require.Equal(t, FeatureFlagFieldsParam, serverTool.FeatureFlagEnable) assert.Equal(t, "list_pull_requests", tool.Name) assert.NotEmpty(t, tool.Description) @@ -588,6 +592,7 @@ func Test_ListPullRequests(t *testing.T) { assert.Contains(t, schema.Properties, "direction") assert.Contains(t, schema.Properties, "perPage") assert.Contains(t, schema.Properties, "page") + assert.Contains(t, schema.Properties, "fields") assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"}) // Setup mock PRs for success case @@ -820,7 +825,11 @@ func Test_MergePullRequest(t *testing.T) { func Test_SearchPullRequests(t *testing.T) { serverTool := SearchPullRequests(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + // SearchPullRequests is the FeatureFlagFieldsParam-enabled variant; it owns + // the _ff_ snapshot. The canonical search_pull_requests.snap is owned + // by LegacySearchPullRequests (see Test_LegacySearchPullRequests_Definition). + require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagFieldsParam, tool)) + require.Equal(t, FeatureFlagFieldsParam, serverTool.FeatureFlagEnable) assert.Equal(t, "search_pull_requests", tool.Name) assert.NotEmpty(t, tool.Description) @@ -832,6 +841,7 @@ func Test_SearchPullRequests(t *testing.T) { assert.Contains(t, schema.Properties, "order") assert.Contains(t, schema.Properties, "perPage") assert.Contains(t, schema.Properties, "page") + assert.Contains(t, schema.Properties, "fields") assert.ElementsMatch(t, schema.Required, []string{"query"}) mockSearchResult := &github.IssuesSearchResult{ diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index fc49b61178..aa236cd53a 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -132,8 +132,76 @@ func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { ) } -// ListCommits creates a tool to get commits of a branch in a repository. +// ListCommits creates a tool to get the list of commits of a branch in a GitHub +// repository. It is the FeatureFlagFieldsParam-enabled variant: it advertises +// the optional `fields` parameter and filters each commit to the requested +// subset. Both this and LegacyListCommits register under the tool name +// "list_commits"; exactly one is active for any given request thanks to mutually +// exclusive FeatureFlagEnable / FeatureFlagDisable annotations. func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { + st := listCommitsTool(t, true) + st.FeatureFlagEnable = FeatureFlagFieldsParam + return st +} + +// LegacyListCommits is the FeatureFlagFieldsParam-disabled variant of +// list_commits. It exposes the original schema (no `fields` parameter) and never +// filters results, so it acts as the kill switch when the flag is off. It owns +// the canonical list_commits.snap; the flag-enabled variant owns +// list_commits_ff_.snap. Delete this function when the flag is removed. +func LegacyListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { + st := listCommitsTool(t, false) + st.FeatureFlagDisable = []string{FeatureFlagFieldsParam} + return st +} + +// listCommitsTool builds the list_commits tool. When includeFields is true the +// tool advertises the optional `fields` parameter, filters each commit to the +// requested subset, and emits fields telemetry. When false it is the original +// tool with no fields parameter and no filtering. +func listCommitsTool(t translations.TranslationHelperFunc, includeFields bool) inventory.ServerTool { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "sha": { + Type: "string", + Description: "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.", + }, + "author": { + Type: "string", + Description: "Author username or email address to filter commits by", + }, + "path": { + Type: "string", + Description: "Only commits containing this file path will be returned", + }, + "since": { + Type: "string", + Description: "Only commits after this date will be returned (ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DD)", + }, + "until": { + Type: "string", + Description: "Only commits before this date will be returned (ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DD)", + }, + }, + Required: []string{"owner", "repo"}, + } + if includeFields { + schema.Properties["fields"] = fieldsSchemaProperty( + "Subset of fields to return for each commit. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields, e.g. just 'sha' and 'html_url'.", + listCommitsItemFieldEnum, + ) + } + WithPagination(schema) + return NewTool( ToolsetMetadataRepos, mcp.Tool{ @@ -143,40 +211,7 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { Title: t("TOOL_LIST_COMMITS_USER_TITLE", "List commits"), ReadOnlyHint: true, }, - InputSchema: WithPagination(&jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: "Repository owner", - }, - "repo": { - Type: "string", - Description: "Repository name", - }, - "sha": { - Type: "string", - Description: "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.", - }, - "author": { - Type: "string", - Description: "Author username or email address to filter commits by", - }, - "path": { - Type: "string", - Description: "Only commits containing this file path will be returned", - }, - "since": { - Type: "string", - Description: "Only commits after this date will be returned (ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DD)", - }, - "until": { - Type: "string", - Description: "Only commits before this date will be returned (ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DD)", - }, - }, - Required: []string{"owner", "repo"}, - }), + InputSchema: schema, }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { @@ -200,6 +235,13 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + var fields []string + if includeFields { + fields, err = OptionalStringArrayParam(args, "fields") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + } sinceStr, err := OptionalParam[string](args, "since") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -269,11 +311,26 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { minimalCommits[i] = convertToMinimalCommit(commit, commitDetailNone) } - r, err := json.Marshal(minimalCommits) + filtered := false + var payload any = minimalCommits + if includeFields && len(fields) > 0 { + filteredCommits, err := filterEachField(minimalCommits, fields) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to filter commits", err), nil, nil + } + payload = filteredCommits + filtered = true + } + + r, err := json.Marshal(payload) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } + if includeFields { + recordFieldsUsageFor(ctx, deps, "list_commits", minimalCommits, filtered, len(r)) + } + result := utils.NewToolResultText(string(r)) // Commit content is reachable from the repo's history; integrity // follows the same public-untrusted / private-trusted rule as file @@ -750,14 +807,10 @@ func getFileContentsTool(t translations.TranslationHelperFunc, includeFields boo Required: []string{"owner", "repo"}, } if includeFields { - schema.Properties["fields"] = &jsonschema.Schema{ - Type: "array", - Description: "Subset of fields to return for each entry when the path is a directory. If omitted, all fields are returned. Ignored when the path is a single file. Use this to reduce response size when listing directories and you only need specific fields, e.g. just 'name' and 'type'.", - Items: &jsonschema.Schema{ - Type: "string", - Enum: fileContentFieldEnum, - }, - } + schema.Properties["fields"] = fieldsSchemaProperty( + "Subset of fields to return for each entry when the path is a directory. If omitted, all fields are returned. Ignored when the path is a single file. Use this to reduce response size when listing directories and you only need specific fields, e.g. just 'name' and 'type'.", + fileContentFieldEnum, + ) } return NewTool( @@ -957,16 +1010,8 @@ func getFileContentsTool(t translations.TranslationHelperFunc, includeFields boo // recordDirContentsFieldsUsage emits fields telemetry for a get_file_contents // directory listing. sentBytes is the size of the payload actually returned. -// When the listing was filtered, the unfiltered size is computed from the full -// directory content so the realized savings can be measured. func recordDirContentsFieldsUsage(ctx context.Context, deps ToolDependencies, full []*github.RepositoryContent, filtered bool, sentBytes int) { - fullBytes := sentBytes - if filtered { - if data, err := json.Marshal(full); err == nil { - fullBytes = len(data) - } - } - recordFieldsUsage(ctx, deps, "get_file_contents", filtered, fullBytes, sentBytes) + recordFieldsUsageFor(ctx, deps, "get_file_contents", full, filtered, sentBytes) } // ForkRepository creates a tool to fork a repository. @@ -1805,8 +1850,56 @@ func GetTag(t translations.TranslationHelperFunc) inventory.ServerTool { ) } -// ListReleases creates a tool to list releases in a GitHub repository. +// ListReleases creates a tool to list releases in a GitHub repository. It is the +// FeatureFlagFieldsParam-enabled variant: it advertises the optional `fields` +// parameter and filters each release to the requested subset. Both this and +// LegacyListReleases register under the tool name "list_releases"; exactly one is +// active for any given request thanks to mutually exclusive FeatureFlagEnable / +// FeatureFlagDisable annotations. func ListReleases(t translations.TranslationHelperFunc) inventory.ServerTool { + st := listReleasesTool(t, true) + st.FeatureFlagEnable = FeatureFlagFieldsParam + return st +} + +// LegacyListReleases is the FeatureFlagFieldsParam-disabled variant of +// list_releases. It exposes the original schema (no `fields` parameter) and never +// filters results, so it acts as the kill switch when the flag is off. It owns +// the canonical list_releases.snap; the flag-enabled variant owns +// list_releases_ff_.snap. Delete this function when the flag is removed. +func LegacyListReleases(t translations.TranslationHelperFunc) inventory.ServerTool { + st := listReleasesTool(t, false) + st.FeatureFlagDisable = []string{FeatureFlagFieldsParam} + return st +} + +// listReleasesTool builds the list_releases tool. When includeFields is true the +// tool advertises the optional `fields` parameter, filters each release to the +// requested subset, and emits fields telemetry. When false it is the original +// tool with no fields parameter and no filtering. +func listReleasesTool(t translations.TranslationHelperFunc, includeFields bool) inventory.ServerTool { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + }, + Required: []string{"owner", "repo"}, + } + if includeFields { + schema.Properties["fields"] = fieldsSchemaProperty( + "Subset of fields to return for each release. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body' in particular drops the largest per-release data.", + listReleasesItemFieldEnum, + ) + } + WithPagination(schema) + return NewTool( ToolsetMetadataRepos, mcp.Tool{ @@ -1816,20 +1909,7 @@ func ListReleases(t translations.TranslationHelperFunc) inventory.ServerTool { Title: t("TOOL_LIST_RELEASES_USER_TITLE", "List releases"), ReadOnlyHint: true, }, - InputSchema: WithPagination(&jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: "Repository owner", - }, - "repo": { - Type: "string", - Description: "Repository name", - }, - }, - Required: []string{"owner", "repo"}, - }), + InputSchema: schema, }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { @@ -1841,6 +1921,13 @@ func ListReleases(t translations.TranslationHelperFunc) inventory.ServerTool { if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + var fields []string + if includeFields { + fields, err = OptionalStringArrayParam(args, "fields") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + } pagination, err := OptionalPaginationParams(args) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -1877,11 +1964,26 @@ func ListReleases(t translations.TranslationHelperFunc) inventory.ServerTool { } } - r, err := json.Marshal(minimalReleases) + filtered := false + var payload any = minimalReleases + if includeFields && len(fields) > 0 { + filteredReleases, err := filterEachField(minimalReleases, fields) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to filter releases", err), nil, nil + } + payload = filteredReleases + filtered = true + } + + r, err := json.Marshal(payload) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } + if includeFields { + recordFieldsUsageFor(ctx, deps, "list_releases", minimalReleases, filtered, len(r)) + } + result := utils.NewToolResultText(string(r)) // Releases are published by collaborators with push access, so // integrity is trusted. Confidentiality follows repo visibility, diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 4c07430ee6..fcc5fa0634 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -1402,7 +1402,11 @@ func Test_ListCommits(t *testing.T) { // Verify tool definition once serverTool := ListCommits(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + // ListCommits is the FeatureFlagFieldsParam-enabled variant; it owns the + // _ff_ snapshot. The canonical list_commits.snap is owned by + // LegacyListCommits (see Test_LegacyListCommits_Definition). + require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagFieldsParam, tool)) + require.Equal(t, FeatureFlagFieldsParam, serverTool.FeatureFlagEnable) schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") @@ -1418,6 +1422,7 @@ func Test_ListCommits(t *testing.T) { assert.Contains(t, schema.Properties, "until") assert.Contains(t, schema.Properties, "page") assert.Contains(t, schema.Properties, "perPage") + assert.Contains(t, schema.Properties, "fields") assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"}) // Setup mock commits for success case @@ -3639,7 +3644,11 @@ func Test_GetTag(t *testing.T) { func Test_ListReleases(t *testing.T) { serverTool := ListReleases(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + // ListReleases is the FeatureFlagFieldsParam-enabled variant; it owns the + // _ff_ snapshot. The canonical list_releases.snap is owned by + // LegacyListReleases (see Test_LegacyListReleases_Definition). + require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagFieldsParam, tool)) + require.Equal(t, FeatureFlagFieldsParam, serverTool.FeatureFlagEnable) schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") @@ -3648,6 +3657,7 @@ func Test_ListReleases(t *testing.T) { assert.NotEmpty(t, tool.Description) assert.Contains(t, schema.Properties, "owner") assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "fields") assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"}) mockReleases := []*github.RepositoryRelease{ diff --git a/pkg/github/search.go b/pkg/github/search.go index 0cfbfb4f3c..28439e9fb7 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -239,14 +239,10 @@ func searchCodeTool(t translations.TranslationHelperFunc, includeFields bool) in Required: []string{"query"}, } if includeFields { - schema.Properties["fields"] = &jsonschema.Schema{ - Type: "array", - Description: "Subset of fields to return for each code search result. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'repository' and 'text_matches' in particular drops the largest per-result data.", - Items: &jsonschema.Schema{ - Type: "string", - Enum: codeSearchItemFieldEnum, - }, - } + schema.Properties["fields"] = fieldsSchemaProperty( + "Subset of fields to return for each code search result. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'repository' and 'text_matches' in particular drops the largest per-result data.", + codeSearchItemFieldEnum, + ) } WithPagination(schema) @@ -381,17 +377,9 @@ func searchCodeTool(t translations.TranslationHelperFunc, includeFields bool) in } // recordSearchCodeFieldsUsage emits fields telemetry for a search_code call. -// sentBytes is the size of the payload actually returned. When the response was -// filtered, the unfiltered size is computed from the full minimal result so the -// realized savings can be measured. +// sentBytes is the size of the payload actually returned. func recordSearchCodeFieldsUsage(ctx context.Context, deps ToolDependencies, full *MinimalCodeSearchResult, filtered bool, sentBytes int) { - fullBytes := sentBytes - if filtered { - if data, err := json.Marshal(full); err == nil { - fullBytes = len(data) - } - } - recordFieldsUsage(ctx, deps, "search_code", filtered, fullBytes, sentBytes) + recordFieldsUsageFor(ctx, deps, "search_code", full, filtered, sentBytes) } func userOrOrgHandler(ctx context.Context, accountType string, deps ToolDependencies, args map[string]any) (*mcp.CallToolResult, any, error) { diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go index 56eed37541..85fa5342f5 100644 --- a/pkg/github/search_utils.go +++ b/pkg/github/search_utils.go @@ -45,6 +45,12 @@ type searchPostProcessFn func(ctx context.Context, result *github.IssuesSearchRe type searchConfig struct { postProcess searchPostProcessFn + // fields, when non-empty, restricts each result item to the requested + // subset of fields. fieldsTool and fieldsDeps identify the calling tool and + // its dependencies so fields telemetry can be recorded. + fields []string + fieldsTool string + fieldsDeps ToolDependencies } type searchOption func(*searchConfig) @@ -55,6 +61,19 @@ func withSearchPostProcess(fn searchPostProcessFn) searchOption { return func(c *searchConfig) { c.postProcess = fn } } +// withFieldsFiltering enables the optional `fields` response filtering for a +// search tool. When fields is non-empty, each result item is reduced to the +// requested subset while the total_count / incomplete_results wrapper is +// preserved. tool and deps identify the caller so fields telemetry (adoption and +// realized savings) can be recorded. +func withFieldsFiltering(deps ToolDependencies, tool string, fields []string) searchOption { + return func(c *searchConfig) { + c.fieldsDeps = deps + c.fieldsTool = tool + c.fields = fields + } +} + // prepareSearchArgs resolves the search query string and REST search options from the tool args, // applying the standard is: / repo:/ munging shared by search_issues and // search_pull_requests. @@ -147,11 +166,30 @@ func searchHandler( return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, errorPrefix, resp, body), nil } - r, err := json.Marshal(result) + filtered := false + var payload any = result + if len(cfg.fields) > 0 { + filteredItems, err := filterEachField(result.Issues, cfg.fields) + if err != nil { + return utils.NewToolResultErrorFromErr(errorPrefix+": failed to filter results", err), nil + } + payload = map[string]any{ + "total_count": result.Total, + "incomplete_results": result.IncompleteResults, + "items": filteredItems, + } + filtered = true + } + + r, err := json.Marshal(payload) if err != nil { return utils.NewToolResultErrorFromErr(errorPrefix+": failed to marshal response", err), nil } + if cfg.fieldsTool != "" { + recordFieldsUsageFor(ctx, cfg.fieldsDeps, cfg.fieldsTool, result, filtered, len(r)) + } + callResult := utils.NewToolResultText(string(r)) if cfg.postProcess != nil { cfg.postProcess(ctx, result, callResult) diff --git a/pkg/github/tools.go b/pkg/github/tools.go index d352e9b3c8..5c6123c277 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -184,6 +184,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { GetFileContents(t), LegacyGetFileContents(t), ListCommits(t), + LegacyListCommits(t), SearchCode(t), LegacySearchCode(t), SearchCommits(t), @@ -193,6 +194,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { ListTags(t), GetTag(t), ListReleases(t), + LegacyListReleases(t), GetLatestRelease(t), GetReleaseByTag(t), CreateOrUpdateFile(t), @@ -212,7 +214,9 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { // Issue tools IssueRead(t), SearchIssues(t), + LegacySearchIssues(t), ListIssues(t), + LegacyListIssues(t), ListIssueTypes(t), ListIssueFields(t), IssueWrite(t), @@ -230,7 +234,9 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { // Pull request tools PullRequestRead(t), ListPullRequests(t), + LegacyListPullRequests(t), SearchPullRequests(t), + LegacySearchPullRequests(t), MergePullRequest(t), UpdatePullRequestBranch(t), CreatePullRequest(t), From c36e4e4493c76eafe2c1a9cbc5272e8eede29189 Mon Sep 17 00:00:00 2001 From: Lizeth Vera <47796851+veralizeth@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:25:32 -0700 Subject: [PATCH 15/16] MCP: name-based resolution for Projects fields (#2760) * Let agents address Project fields, single-select options, and item field values by name through the GitHub MCP server * Adding a method to resolve params resolveItemIDFromIssueArgs * Adding the DatabaseID to be able to match the graphQL * Changing the parsing to strconv.ParseInt * Changing description. * update readme * Fixing copilot comments * Adding Case-insensitive matching and fields + field_names guard * running snaps and readme --- README.md | 16 +- pkg/errors/error.go | 46 ++ pkg/github/__toolsnaps__/projects_get.snap | 9 +- pkg/github/__toolsnaps__/projects_list.snap | 9 +- pkg/github/__toolsnaps__/projects_write.snap | 10 +- pkg/github/projects.go | 184 ++++++- pkg/github/projects_resolver.go | 389 +++++++++++++ pkg/github/projects_resolver_test.go | 539 +++++++++++++++++++ pkg/github/projects_test.go | 47 ++ 9 files changed, 1209 insertions(+), 40 deletions(-) create mode 100644 pkg/github/projects_resolver.go create mode 100644 pkg/github/projects_resolver_test.go diff --git a/README.md b/README.md index 3a660845c1..10d987a262 100644 --- a/README.md +++ b/README.md @@ -1069,7 +1069,8 @@ The following sets of tools are available: - **Required OAuth Scopes**: `read:project` - **Accepted OAuth Scopes**: `project`, `read:project` - `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) + - `field_names`: Specific list of field names to include in the response when getting a project item (e.g. ["Status", "Priority"]). Resolved server-side to field IDs — pass this instead of 'fields' when you only know the human-readable names. Mutually exclusive with 'fields' — provide one, not both. Only used for 'get_project_item' method. (string[], optional) + - `fields`: Specific list of field IDs to include in the response when getting a project item (e.g. ["102589", "985201", "169875"]). If neither 'fields' nor 'field_names' is provided, only the title field is included. Mutually exclusive with 'field_names' — provide one, not both. 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`: The owner (user or organization login). The name is not case sensitive. (string, optional) @@ -1082,7 +1083,8 @@ The following sets of tools are available: - **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 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) + - `field_names`: Field names to include when listing project items (e.g. ["Status", "Priority"]). Resolved server-side to field IDs — pass this instead of 'fields' when you only know the human-readable names. Names that fail to resolve return a structured error. Mutually exclusive with 'fields' — provide one, not both. Only used for 'list_project_items' method. (string[], optional) + - `fields`: Field IDs to include when listing project items (e.g. ["102589", "985201"]). CRITICAL: Always provide to get field values. Without this (and without 'field_names'), only titles returned. Mutually exclusive with 'field_names' — provide one, not both. Only used for 'list_project_items' method. (string[], optional) - `method`: The action to perform (string, required) - `owner`: The owner (user or organization login). The name is not case sensitive. (string, required) - `owner_type`: Owner type (user or org). If not provided, will automatically try both. (string, optional) @@ -1094,10 +1096,10 @@ The following sets of tools are available: - **Required OAuth Scopes**: `project` - `body`: The body of the status update (markdown). Used for 'create_project_status_update' method. (string, optional) - `field_name`: The name of the iteration field (e.g. 'Sprint'). Required for 'create_iteration_field' method. (string, optional) - - `issue_number`: The issue number (use when item_type is 'issue' for 'add_project_item' method). Provide either issue_number or pull_request_number. (number, optional) - - `item_id`: The project item ID. Required for 'update_project_item' and 'delete_project_item' methods. (number, optional) - - `item_owner`: The owner (user or organization) of the repository containing the issue or pull request. Required for 'add_project_item' method. (string, optional) - - `item_repo`: The name of the repository containing the issue or pull request. Required for 'add_project_item' method. (string, optional) + - `issue_number`: The issue number. Required for 'add_project_item' when item_type is 'issue'. Also accepted by 'update_project_item' to resolve the item by issue number (combine with item_owner and item_repo). (number, optional) + - `item_id`: The project item ID. Required for 'delete_project_item'. For 'update_project_item', provide either item_id, or (item_owner + item_repo + issue_number) to resolve the item by issue. (number, optional) + - `item_owner`: The owner (user or organization) of the repository containing the issue or pull request. Required for 'add_project_item' method. Also accepted by 'update_project_item' when resolving the item by issue number. (string, optional) + - `item_repo`: The name of the repository containing the issue or pull request. Required for 'add_project_item' method. Also accepted by 'update_project_item' when resolving the item by issue number. (string, optional) - `item_type`: The item's type, either issue or pull_request. Required for 'add_project_item' method. (string, optional) - `iteration_duration`: Duration in days for iterations of the field (e.g. 7 for weekly, 14 for bi-weekly). Required for 'create_iteration_field' method. (number, optional) - `iterations`: Custom iterations for 'create_iteration_field' method. Only set this when you need iterations with varying durations, breaks between them, or specific titles. Otherwise omit it: GitHub auto-creates three iterations of 'iteration_duration' days starting on 'start_date', which is the right choice for most cases. (object[], optional) @@ -1110,7 +1112,7 @@ The following sets of tools are available: - `status`: The status of the project. Used for 'create_project_status_update' method. (string, optional) - `target_date`: The target date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method. (string, optional) - `title`: The project title. Required for 'create_project' method. (string, optional) - - `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) + - `updated_field`: Object describing the field to update and its new value. Required for 'update_project_item'. Two shapes are accepted: (1) by ID — {"id": 123456, "value": "..."}; (2) by name — {"name": "Status", "value": "In Progress"}. For single-select fields, option-name resolution requires the by-name shape; on the by-ID shape, pass the option ID. Set value to null to clear the field. (object, optional) diff --git a/pkg/errors/error.go b/pkg/errors/error.go index 4a791a7435..cb4e8b1f0f 100644 --- a/pkg/errors/error.go +++ b/pkg/errors/error.go @@ -2,6 +2,7 @@ package errors import ( "context" + "encoding/json" stderrors "errors" "fmt" "net/http" @@ -218,3 +219,48 @@ func NewGitHubAPIStatusErrorResponse(ctx context.Context, message string, resp * err := fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) return NewGitHubAPIErrorResponse(ctx, message, resp, err) } + +// StructuredResolutionError is a machine-readable error returned by name-resolution +// helpers (e.g. resolving a project field or single-select option by name). Agents +// can parse the JSON body to self-correct without re-prompting. +// +// Kind values: +// - "field_not_found" — no project field matches the supplied name +// - "field_ambiguous" — more than one project field shares the supplied name +// - "option_not_found" — no option on the resolved single-select field matches +// - "option_ambiguous" — duplicate option names on the resolved field +// - "item_not_in_project" — the issue/PR exists but is not an item on the project +// - "wrong_field_type" — the named field is not the data type the caller expected +type StructuredResolutionError struct { + Kind string `json:"error"` + Name string `json:"name,omitempty"` + Field string `json:"field,omitempty"` + Candidates []any `json:"candidates,omitempty"` + Hint string `json:"hint,omitempty"` +} + +// Error implements the error interface; the message is the JSON body so that the +// downstream tool result also carries the structured payload as plain text. +func (e *StructuredResolutionError) Error() string { + b, err := json.Marshal(e) + if err != nil { + return fmt.Sprintf(`{"error":%q,"name":%q}`, e.Kind, e.Name) + } + return string(b) +} + +// NewStructuredResolutionError constructs a StructuredResolutionError. +func NewStructuredResolutionError(kind, name, hint string, candidates []any) *StructuredResolutionError { + return &StructuredResolutionError{ + Kind: kind, + Name: name, + Hint: hint, + Candidates: candidates, + } +} + +// NewStructuredResolutionErrorResponse returns an mcp.CallToolResult whose text body +// is the JSON-serialised StructuredResolutionError, suitable for agent self-correction. +func NewStructuredResolutionErrorResponse(err *StructuredResolutionError) *mcp.CallToolResult { + return utils.NewToolResultError(err.Error()) +} diff --git a/pkg/github/__toolsnaps__/projects_get.snap b/pkg/github/__toolsnaps__/projects_get.snap index c1b93a9291..f6a48c9328 100644 --- a/pkg/github/__toolsnaps__/projects_get.snap +++ b/pkg/github/__toolsnaps__/projects_get.snap @@ -11,8 +11,15 @@ "description": "The field's ID. Required for 'get_project_field' method.", "type": "number" }, + "field_names": { + "description": "Specific list of field names to include in the response when getting a project item (e.g. [\"Status\", \"Priority\"]). Resolved server-side to field IDs — pass this instead of 'fields' when you only know the human-readable names. Mutually exclusive with 'fields' — provide one, not both. Only used for 'get_project_item' method.", + "items": { + "type": "string" + }, + "type": "array" + }, "fields": { - "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.", + "description": "Specific list of field IDs to include in the response when getting a project item (e.g. [\"102589\", \"985201\", \"169875\"]). If neither 'fields' nor 'field_names' is provided, only the title field is included. Mutually exclusive with 'field_names' — provide one, not both. Only used for 'get_project_item' method.", "items": { "type": "string" }, diff --git a/pkg/github/__toolsnaps__/projects_list.snap b/pkg/github/__toolsnaps__/projects_list.snap index bef9ec41d5..547417e983 100644 --- a/pkg/github/__toolsnaps__/projects_list.snap +++ b/pkg/github/__toolsnaps__/projects_list.snap @@ -15,8 +15,15 @@ "description": "Backward pagination cursor from previous pageInfo.prevCursor (rare).", "type": "string" }, + "field_names": { + "description": "Field names to include when listing project items (e.g. [\"Status\", \"Priority\"]). Resolved server-side to field IDs — pass this instead of 'fields' when you only know the human-readable names. Names that fail to resolve return a structured error. Mutually exclusive with 'fields' — provide one, not both. Only used for 'list_project_items' method.", + "items": { + "type": "string" + }, + "type": "array" + }, "fields": { - "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.", + "description": "Field IDs to include when listing project items (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this (and without 'field_names'), only titles returned. Mutually exclusive with 'field_names' — provide one, not both. Only used for 'list_project_items' method.", "items": { "type": "string" }, diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap index f01cd1368a..762ee08c93 100644 --- a/pkg/github/__toolsnaps__/projects_write.snap +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -17,19 +17,19 @@ "type": "string" }, "issue_number": { - "description": "The issue number (use when item_type is 'issue' for 'add_project_item' method). Provide either issue_number or pull_request_number.", + "description": "The issue number. Required for 'add_project_item' when item_type is 'issue'. Also accepted by 'update_project_item' to resolve the item by issue number (combine with item_owner and item_repo).", "type": "number" }, "item_id": { - "description": "The project item ID. Required for 'update_project_item' and 'delete_project_item' methods.", + "description": "The project item ID. Required for 'delete_project_item'. For 'update_project_item', provide either item_id, or (item_owner + item_repo + issue_number) to resolve the item by issue.", "type": "number" }, "item_owner": { - "description": "The owner (user or organization) of the repository containing the issue or pull request. Required for 'add_project_item' method.", + "description": "The owner (user or organization) of the repository containing the issue or pull request. Required for 'add_project_item' method. Also accepted by 'update_project_item' when resolving the item by issue number.", "type": "string" }, "item_repo": { - "description": "The name of the repository containing the issue or pull request. Required for 'add_project_item' method.", + "description": "The name of the repository containing the issue or pull request. Required for 'add_project_item' method. Also accepted by 'update_project_item' when resolving the item by issue number.", "type": "string" }, "item_type": { @@ -127,7 +127,7 @@ "type": "string" }, "updated_field": { - "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.", + "description": "Object describing the field to update and its new value. Required for 'update_project_item'. Two shapes are accepted: (1) by ID — {\"id\": 123456, \"value\": \"...\"}; (2) by name — {\"name\": \"Status\", \"value\": \"In Progress\"}. For single-select fields, option-name resolution requires the by-name shape; on the by-ID shape, pass the option ID. Set value to null to clear the field.", "type": "object" } }, diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 0758e3c4e4..308c2b87e8 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -3,6 +3,7 @@ package github import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -195,7 +196,14 @@ Use this tool to list projects for a user or organization, or list project field }, "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.", + Description: "Field IDs to include when listing project items (e.g. [\"102589\", \"985201\"]). CRITICAL: Always provide to get field values. Without this (and without 'field_names'), only titles returned. Mutually exclusive with 'field_names' — provide one, not both. Only used for 'list_project_items' method.", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + "field_names": { + Type: "array", + Description: "Field names to include when listing project items (e.g. [\"Status\", \"Priority\"]). Resolved server-side to field IDs — pass this instead of 'fields' when you only know the human-readable names. Names that fail to resolve return a structured error. Mutually exclusive with 'fields' — provide one, not both. Only used for 'list_project_items' method.", Items: &jsonschema.Schema{ Type: "string", }, @@ -267,7 +275,11 @@ Use this tool to list projects for a user or organization, or list project field } return result, payload, err case projectsMethodListProjectItems: - result, payload, err := listProjectItems(ctx, client, args, owner, ownerType) + gqlClient, gqlErr := deps.GetGQLClient(ctx) + if gqlErr != nil { + return utils.NewToolResultError(gqlErr.Error()), nil, nil + } + result, payload, err := listProjectItems(ctx, client, gqlClient, args, owner, ownerType) if shouldAttachIFCLabel(ctx, deps, result) { isPrivate, visibilityErr := FetchProjectIsPrivate(ctx, client, owner, ownerType, projectNumber) if visibilityErr == nil { @@ -343,7 +355,14 @@ Use this tool to get details about individual projects, project fields, and proj }, "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.", + Description: "Specific list of field IDs to include in the response when getting a project item (e.g. [\"102589\", \"985201\", \"169875\"]). If neither 'fields' nor 'field_names' is provided, only the title field is included. Mutually exclusive with 'field_names' — provide one, not both. Only used for 'get_project_item' method.", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + "field_names": { + Type: "array", + Description: "Specific list of field names to include in the response when getting a project item (e.g. [\"Status\", \"Priority\"]). Resolved server-side to field IDs — pass this instead of 'fields' when you only know the human-readable names. Mutually exclusive with 'fields' — provide one, not both. Only used for 'get_project_item' method.", Items: &jsonschema.Schema{ Type: "string", }, @@ -433,6 +452,28 @@ Use this tool to get details about individual projects, project fields, and proj if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + fieldNames, err := OptionalStringArrayParam(args, "field_names") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if len(fields) > 0 && len(fieldNames) > 0 { + return utils.NewToolResultError("provide either 'fields' or 'field_names', not both"), nil, nil + } + if len(fieldNames) > 0 { + gqlClient, gqlErr := deps.GetGQLClient(ctx) + if gqlErr != nil { + return utils.NewToolResultError(gqlErr.Error()), nil, nil + } + resolvedIDs, resolveErr := resolveFieldNamesToIDs(ctx, gqlClient, owner, ownerType, projectNumber, fieldNames) + if resolveErr != nil { + var structured *ghErrors.StructuredResolutionError + if errors.As(resolveErr, &structured) { + return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil + } + return utils.NewToolResultError(resolveErr.Error()), nil, nil + } + fields = append(fields, resolvedIDs...) + } result, payload, err := getProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fields) if shouldAttachIFCLabel(ctx, deps, result) { isPrivate, visibilityErr := FetchProjectIsPrivate(ctx, client, owner, ownerType, projectNumber) @@ -495,7 +536,7 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { }, "item_id": { Type: "number", - Description: "The project item ID. Required for 'update_project_item' and 'delete_project_item' methods.", + Description: "The project item ID. Required for 'delete_project_item'. For 'update_project_item', provide either item_id, or (item_owner + item_repo + issue_number) to resolve the item by issue.", }, "item_type": { Type: "string", @@ -504,15 +545,15 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { }, "item_owner": { Type: "string", - Description: "The owner (user or organization) of the repository containing the issue or pull request. Required for 'add_project_item' method.", + Description: "The owner (user or organization) of the repository containing the issue or pull request. Required for 'add_project_item' method. Also accepted by 'update_project_item' when resolving the item by issue number.", }, "item_repo": { Type: "string", - Description: "The name of the repository containing the issue or pull request. Required for 'add_project_item' method.", + Description: "The name of the repository containing the issue or pull request. Required for 'add_project_item' method. Also accepted by 'update_project_item' when resolving the item by issue number.", }, "issue_number": { Type: "number", - Description: "The issue number (use when item_type is 'issue' for 'add_project_item' method). Provide either issue_number or pull_request_number.", + Description: "The issue number. Required for 'add_project_item' when item_type is 'issue'. Also accepted by 'update_project_item' to resolve the item by issue number (combine with item_owner and item_repo).", }, "pull_request_number": { Type: "number", @@ -520,7 +561,7 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { }, "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.", + Description: "Object describing the field to update and its new value. Required for 'update_project_item'. Two shapes are accepted: (1) by ID — {\"id\": 123456, \"value\": \"...\"}; (2) by name — {\"name\": \"Status\", \"value\": \"In Progress\"}. For single-select fields, option-name resolution requires the by-name shape; on the by-ID shape, pass the option ID. Set value to null to clear the field.", }, "body": { Type: "string", @@ -652,10 +693,26 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { return addProjectItem(ctx, gqlClient, owner, ownerType, projectNumber, itemOwner, itemRepo, itemNumber, itemType) case projectsMethodUpdateProjectItem: - itemID, err := RequiredBigInt(args, "item_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + var itemID int64 + if _, hasItemID := args["item_id"]; hasItemID { + id, err := RequiredBigInt(args, "item_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + itemID = id + } else { + // Resolve the item by (item_owner, item_repo, issue_number). + resolvedItemID, resolveErr := resolveItemIDFromIssueArgs(ctx, gqlClient, owner, ownerType, projectNumber, args) + if resolveErr != nil { + var structured *ghErrors.StructuredResolutionError + if errors.As(resolveErr, &structured) { + return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil + } + return utils.NewToolResultError(resolveErr.Error()), nil, nil + } + itemID = resolvedItemID } + rawUpdatedField, exists := args["updated_field"] if !exists { return utils.NewToolResultError("missing required parameter: updated_field"), nil, nil @@ -664,7 +721,7 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { if !ok || fieldValue == nil { return utils.NewToolResultError("updated_field must be an object"), nil, nil } - return updateProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fieldValue) + return updateProjectItem(ctx, client, gqlClient, owner, ownerType, projectNumber, itemID, fieldValue) case projectsMethodDeleteProjectItem: itemID, err := RequiredBigInt(args, "item_id") if err != nil { @@ -881,7 +938,7 @@ func listProjectFields(ctx context.Context, client *github.Client, args map[stri 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) { +func listProjectItems(ctx context.Context, client *github.Client, gqlClient *githubv4.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 @@ -897,6 +954,25 @@ func listProjectItems(ctx context.Context, client *github.Client, args map[strin return utils.NewToolResultError(err.Error()), nil, nil } + fieldNames, err := OptionalStringArrayParam(args, "field_names") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if len(fields) > 0 && len(fieldNames) > 0 { + return utils.NewToolResultError("provide either 'fields' or 'field_names', not both"), nil, nil + } + if len(fieldNames) > 0 { + resolvedIDs, resolveErr := resolveFieldNamesToIDs(ctx, gqlClient, owner, ownerType, projectNumber, fieldNames) + if resolveErr != nil { + var structured *ghErrors.StructuredResolutionError + if errors.As(resolveErr, &structured) { + return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil + } + return utils.NewToolResultError(resolveErr.Error()), nil, nil + } + fields = append(fields, resolvedIDs...) + } + pagination, err := extractPaginationOptionsFromArgs(args) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -1074,9 +1150,13 @@ func getProjectItem(ctx context.Context, client *github.Client, owner, ownerType 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) +func updateProjectItem(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, itemID int64, fieldValue map[string]any) (*mcp.CallToolResult, any, error) { + updatePayload, err := buildUpdateProjectItem(ctx, gqlClient, owner, ownerType, projectNumber, fieldValue) if err != nil { + var structured *ghErrors.StructuredResolutionError + if errors.As(err, &structured) { + return ghErrors.NewStructuredResolutionErrorResponse(structured), nil, nil + } return utils.NewToolResultError(err.Error()), nil, nil } @@ -1450,25 +1530,77 @@ func validateAndConvertToInt64(value any) (int64, error) { } } -// buildUpdateProjectItem constructs UpdateProjectItemOptions from the input map. -func buildUpdateProjectItem(input map[string]any) (*github.UpdateProjectItemOptions, error) { +// buildUpdateProjectItem builds UpdateProjectItemOptions, resolving field names and SINGLE_SELECT option names server-side. +func buildUpdateProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, input map[string]any) (*github.UpdateProjectItemOptions, error) { if input == nil { return nil, fmt.Errorf("updated_field must be an object") } - idField, ok := input["id"] - if !ok { - return nil, fmt.Errorf("updated_field.id is required") + valueField, hasValue := input["value"] + if !hasValue { + return nil, fmt.Errorf("updated_field.value is required") } - fieldID, err := validateAndConvertToInt64(idField) - if err != nil { - return nil, fmt.Errorf("updated_field.id: %w", err) + idField, hasID := input["id"] + nameField, hasName := input["name"] + + switch { + case hasID && hasName: + return nil, fmt.Errorf("updated_field must set either id or name, not both") + case !hasID && !hasName: + return nil, fmt.Errorf("updated_field requires either id or name") } - valueField, ok := input["value"] - if !ok { - return nil, fmt.Errorf("updated_field.value is required") + var ( + fieldID int64 + resolved *ResolvedField + ) + + if hasID { + var err error + fieldID, err = validateAndConvertToInt64(idField) + if err != nil { + return nil, fmt.Errorf("updated_field.id: %w", err) + } + } else { + fieldName, ok := nameField.(string) + if !ok || fieldName == "" { + return nil, fmt.Errorf("updated_field.name must be a non-empty string") + } + if gqlClient == nil { + return nil, fmt.Errorf("internal error: gqlClient is required to resolve updated_field.name") + } + var err error + resolved, err = resolveProjectFieldByName(ctx, gqlClient, owner, ownerType, projectNumber, fieldName, "") + if err != nil { + return nil, err + } + parsedID, parseErr := parseInt64(resolved.ID) + if parseErr != nil { + return nil, fmt.Errorf("resolved field %q has non-numeric ID %q; pass updated_field.id directly", resolved.Name, resolved.ID) + } + fieldID = parsedID + } + + // SINGLE_SELECT: resolve option name to ID; pass through if it's already a known option ID. + if resolved != nil && resolved.DataType == "SINGLE_SELECT" { + if str, ok := valueField.(string); ok && str != "" { + if optID, optErr := resolveSingleSelectOptionByName(resolved, str); optErr == nil { + valueField = optID + } else { + // Fall back: if the string is already a known option ID, accept it. + known := false + for _, opt := range resolved.Options { + if opt.ID == str { + known = true + break + } + } + if !known { + return nil, optErr + } + } + } } payload := &github.UpdateProjectItemOptions{ diff --git a/pkg/github/projects_resolver.go b/pkg/github/projects_resolver.go new file mode 100644 index 0000000000..a5c93b5b93 --- /dev/null +++ b/pkg/github/projects_resolver.go @@ -0,0 +1,389 @@ +package github + +import ( + "context" + "fmt" + "strconv" + "strings" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/shurcooL/githubv4" +) + +// resolverFieldsPageSize is the GraphQL ProjectV2 max page size; covers most +// projects in a single round-trip. +const resolverFieldsPageSize = 100 + +// ResolvedFieldOption is one option on a SINGLE_SELECT project field. +type ResolvedFieldOption struct { + ID string + Name string +} + +// ResolvedField is a project field resolved by name; Options is only set when +// DataType == "SINGLE_SELECT". +type ResolvedField struct { + ID string + Name string + DataType string + Options []ResolvedFieldOption +} + +// projectFieldsQueryOrg fetches all fields on an org-owned project (paginated). +type projectFieldsQueryOrg struct { + Organization struct { + ProjectV2 struct { + Fields projectFieldsConnection `graphql:"fields(first: $first, after: $after)"` + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"organization(login: $owner)"` +} + +// projectFieldsQueryUser fetches all fields on a user-owned project (paginated). +type projectFieldsQueryUser struct { + User struct { + ProjectV2 struct { + Fields projectFieldsConnection `graphql:"fields(first: $first, after: $after)"` + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"user(login: $owner)"` +} + +// projectFieldsConnection is a paginated list of project fields. We select `id` +// to discriminate the union variant and `databaseId` for the numeric ID REST needs. +type projectFieldsConnection struct { + Nodes []struct { + ProjectV2Field struct { + ID githubv4.ID + DatabaseID githubv4.Int `graphql:"databaseId"` + Name githubv4.String + DataType githubv4.String + } `graphql:"... on ProjectV2Field"` + ProjectV2IterationField struct { + ID githubv4.ID + DatabaseID githubv4.Int `graphql:"databaseId"` + Name githubv4.String + DataType githubv4.String + } `graphql:"... on ProjectV2IterationField"` + ProjectV2SingleSelectField struct { + ID githubv4.ID + DatabaseID githubv4.Int `graphql:"databaseId"` + Name githubv4.String + DataType githubv4.String + Options []struct { + ID githubv4.String + Name githubv4.String + } + } `graphql:"... on ProjectV2SingleSelectField"` + } + PageInfo PageInfoFragment +} + +// listAllProjectFields fetches every field on a project, paginating as needed. +func listAllProjectFields(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int) ([]ResolvedField, error) { + all := []ResolvedField{} + var after *githubv4.String + + for { + vars := map[string]any{ + "owner": githubv4.String(owner), + "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // Project numbers are small + "first": githubv4.Int(resolverFieldsPageSize), + "after": (*githubv4.String)(nil), + } + if after != nil { + vars["after"] = after + } + + var conn projectFieldsConnection + if ownerType == "org" { + var q projectFieldsQueryOrg + if err := gqlClient.Query(ctx, &q, vars); err != nil { + return nil, fmt.Errorf("failed to list project fields: %w", err) + } + conn = q.Organization.ProjectV2.Fields + } else { + var q projectFieldsQueryUser + if err := gqlClient.Query(ctx, &q, vars); err != nil { + return nil, fmt.Errorf("failed to list project fields: %w", err) + } + conn = q.User.ProjectV2.Fields + } + + for _, n := range conn.Nodes { + switch { + case n.ProjectV2SingleSelectField.ID != nil: + opts := make([]ResolvedFieldOption, 0, len(n.ProjectV2SingleSelectField.Options)) + for _, o := range n.ProjectV2SingleSelectField.Options { + opts = append(opts, ResolvedFieldOption{ID: string(o.ID), Name: string(o.Name)}) + } + all = append(all, ResolvedField{ + ID: fmt.Sprintf("%d", n.ProjectV2SingleSelectField.DatabaseID), + Name: string(n.ProjectV2SingleSelectField.Name), + DataType: string(n.ProjectV2SingleSelectField.DataType), + Options: opts, + }) + case n.ProjectV2IterationField.ID != nil: + all = append(all, ResolvedField{ + ID: fmt.Sprintf("%d", n.ProjectV2IterationField.DatabaseID), + Name: string(n.ProjectV2IterationField.Name), + DataType: string(n.ProjectV2IterationField.DataType), + }) + case n.ProjectV2Field.ID != nil: + all = append(all, ResolvedField{ + ID: fmt.Sprintf("%d", n.ProjectV2Field.DatabaseID), + Name: string(n.ProjectV2Field.Name), + DataType: string(n.ProjectV2Field.DataType), + }) + } + } + + if !bool(conn.PageInfo.HasNextPage) { + break + } + end := conn.PageInfo.EndCursor + after = &end + } + + return all, nil +} + +// resolveProjectFieldByName resolves a field by display name. Returns a +// structured error on not-found, ambiguous, or wrong-data-type (when +// expectedDataType is set) so the agent can self-correct. +func resolveProjectFieldByName(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, fieldName, expectedDataType string) (*ResolvedField, error) { + if fieldName == "" { + return nil, fmt.Errorf("field name must not be empty") + } + + all, err := listAllProjectFields(ctx, gqlClient, owner, ownerType, projectNumber) + if err != nil { + return nil, err + } + + var matches []ResolvedField + for _, f := range all { + if strings.EqualFold(f.Name, fieldName) { + matches = append(matches, f) + } + } + + if len(matches) == 0 { + candidates := make([]any, 0, len(all)) + for _, f := range all { + candidates = append(candidates, map[string]any{ + "name": f.Name, + "data_type": f.DataType, + }) + } + return nil, ghErrors.NewStructuredResolutionError( + "field_not_found", + fieldName, + fmt.Sprintf("no project field named %q on project %s#%d; see candidates for available names", fieldName, owner, projectNumber), + candidates, + ) + } + + if len(matches) > 1 { + candidates := make([]any, 0, len(matches)) + for _, f := range matches { + candidates = append(candidates, map[string]any{ + "id": f.ID, + "data_type": f.DataType, + }) + } + return nil, ghErrors.NewStructuredResolutionError( + "field_ambiguous", + fieldName, + "multiple fields share this name; pass updated_field.id to disambiguate", + candidates, + ) + } + + field := matches[0] + + if expectedDataType != "" && field.DataType != expectedDataType { + return nil, ghErrors.NewStructuredResolutionError( + "wrong_field_type", + fieldName, + fmt.Sprintf("field %q has data type %q but %q was expected", fieldName, field.DataType, expectedDataType), + []any{map[string]any{"id": field.ID, "data_type": field.DataType}}, + ) + } + + return &field, nil +} + +// resolveSingleSelectOptionByName resolves an option name to its ID on a +// SINGLE_SELECT field. Returns a structured error if not found or ambiguous. +func resolveSingleSelectOptionByName(field *ResolvedField, optionName string) (string, error) { + if field == nil { + return "", fmt.Errorf("field must not be nil") + } + if field.DataType != "SINGLE_SELECT" { + return "", ghErrors.NewStructuredResolutionError( + "wrong_field_type", + field.Name, + fmt.Sprintf("cannot resolve option name on non-SINGLE_SELECT field %q (data type %q)", field.Name, field.DataType), + nil, + ) + } + + var matchIDs []string + for _, o := range field.Options { + if strings.EqualFold(o.Name, optionName) { + matchIDs = append(matchIDs, o.ID) + } + } + + switch len(matchIDs) { + case 0: + candidates := make([]any, 0, len(field.Options)) + for _, o := range field.Options { + candidates = append(candidates, map[string]any{"name": o.Name}) + } + return "", ghErrors.NewStructuredResolutionError( + "option_not_found", + optionName, + fmt.Sprintf("no option named %q on field %q; see candidates for available options", optionName, field.Name), + candidates, + ) + case 1: + return matchIDs[0], nil + default: + candidates := make([]any, 0, len(matchIDs)) + for _, id := range matchIDs { + candidates = append(candidates, map[string]any{"id": id}) + } + return "", ghErrors.NewStructuredResolutionError( + "option_ambiguous", + optionName, + fmt.Sprintf("multiple options on field %q share the name %q", field.Name, optionName), + candidates, + ) + } +} + +// resolveProjectItemIDByIssueNumber resolves a (project, issue) pair to the +// project item's full database ID in one GraphQL hop. Returns a structured +// error if the issue is not an item on the project. +func resolveProjectItemIDByIssueNumber(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, issueOwner, issueRepo string, issueNumber int) (int64, error) { + projectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber) + if err != nil { + return 0, err + } + + var query struct { + Repository struct { + Issue struct { + ProjectItems struct { + Nodes []struct { + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Project struct { + ID githubv4.ID + } + } + PageInfo PageInfoFragment + } `graphql:"projectItems(first: 50, includeArchived: true)"` + } `graphql:"issue(number: $issueNumber)"` + } `graphql:"repository(owner: $issueOwner, name: $issueRepo)"` + } + + vars := map[string]any{ + "issueOwner": githubv4.String(issueOwner), + "issueRepo": githubv4.String(issueRepo), + "issueNumber": githubv4.Int(int32(issueNumber)), //nolint:gosec // Issue numbers are small + } + + if err := gqlClient.Query(ctx, &query, vars); err != nil { + return 0, fmt.Errorf("failed to resolve project item for %s/%s#%d: %w", issueOwner, issueRepo, issueNumber, err) + } + + for _, item := range query.Repository.Issue.ProjectItems.Nodes { + if item.Project.ID == projectID { + itemID, parseErr := parseInt64(string(item.FullDatabaseID)) + if parseErr != nil { + return 0, fmt.Errorf("project item ID %q is not an integer: %w", string(item.FullDatabaseID), parseErr) + } + return itemID, nil + } + } + + return 0, ghErrors.NewStructuredResolutionError( + "item_not_in_project", + fmt.Sprintf("%s/%s#%d", issueOwner, issueRepo, issueNumber), + "the issue exists but is not an item on the named project; add it first via add_project_item", + nil, + ) +} + +// resolveItemIDFromIssueArgs reads (item_owner, item_repo, issue_number) from args +// and resolves them to a project item ID. Returns a single friendly error if any input is missing. +func resolveItemIDFromIssueArgs(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, args map[string]any) (int64, error) { + issueOwner, ownerErr := RequiredParam[string](args, "item_owner") + issueRepo, repoErr := RequiredParam[string](args, "item_repo") + issueNumber, numErr := RequiredInt(args, "issue_number") + if ownerErr != nil || repoErr != nil || numErr != nil { + return 0, fmt.Errorf("update_project_item requires either item_id, or item_owner + item_repo + issue_number to resolve the item by issue") + } + return resolveProjectItemIDByIssueNumber(ctx, gqlClient, owner, ownerType, projectNumber, issueOwner, issueRepo, issueNumber) +} + +func parseInt64(s string) (int64, error) { + return strconv.ParseInt(s, 10, 64) +} + +// resolveFieldNamesToIDs resolves field names to numeric IDs in one GraphQL +// hop. Fails fast with a structured error on any unresolved or ambiguous name. +func resolveFieldNamesToIDs(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, names []string) ([]int64, error) { + if len(names) == 0 { + return nil, nil + } + + all, err := listAllProjectFields(ctx, gqlClient, owner, ownerType, projectNumber) + if err != nil { + return nil, err + } + + // Build a name -> []ResolvedField map so we can detect duplicates per name. + // Matching is case-insensitive to align with the GraphQL API's behaviour. + byName := make(map[string][]ResolvedField, len(all)) + for _, f := range all { + key := strings.ToLower(f.Name) + byName[key] = append(byName[key], f) + } + + out := make([]int64, 0, len(names)) + for _, name := range names { + matches := byName[strings.ToLower(name)] + switch len(matches) { + case 0: + candidates := make([]any, 0, len(all)) + for _, f := range all { + candidates = append(candidates, map[string]any{"name": f.Name, "data_type": f.DataType}) + } + return nil, ghErrors.NewStructuredResolutionError( + "field_not_found", + name, + fmt.Sprintf("no project field named %q on project %s#%d", name, owner, projectNumber), + candidates, + ) + case 1: + id, parseErr := parseInt64(matches[0].ID) + if parseErr != nil { + return nil, fmt.Errorf("resolved field %q has non-numeric ID %q; pass it via 'fields' instead", name, matches[0].ID) + } + out = append(out, id) + default: + candidates := make([]any, 0, len(matches)) + for _, f := range matches { + candidates = append(candidates, map[string]any{"id": f.ID, "data_type": f.DataType}) + } + return nil, ghErrors.NewStructuredResolutionError( + "field_ambiguous", + name, + "multiple fields share this name; pass numeric IDs via 'fields' to disambiguate", + candidates, + ) + } + } + return out, nil +} diff --git a/pkg/github/projects_resolver_test.go b/pkg/github/projects_resolver_test.go new file mode 100644 index 0000000000..e701441c1e --- /dev/null +++ b/pkg/github/projects_resolver_test.go @@ -0,0 +1,539 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// projectFieldsQueryMatcher is the GraphQL shape we use for fields(first:100) resolution. +// Keep this in sync with projectFieldsConnection in projects_resolver.go. +type projectFieldsTestQuery struct { + Organization struct { + ProjectV2 struct { + Fields struct { + Nodes []struct { + ProjectV2Field struct { + ID githubv4.ID + DatabaseID githubv4.Int `graphql:"databaseId"` + Name githubv4.String + DataType githubv4.String + } `graphql:"... on ProjectV2Field"` + ProjectV2IterationField struct { + ID githubv4.ID + DatabaseID githubv4.Int `graphql:"databaseId"` + Name githubv4.String + DataType githubv4.String + } `graphql:"... on ProjectV2IterationField"` + ProjectV2SingleSelectField struct { + ID githubv4.ID + DatabaseID githubv4.Int `graphql:"databaseId"` + Name githubv4.String + DataType githubv4.String + Options []struct { + ID githubv4.String + Name githubv4.String + } + } `graphql:"... on ProjectV2SingleSelectField"` + } + PageInfo PageInfoFragment + } `graphql:"fields(first: $first, after: $after)"` + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"organization(login: $owner)"` +} + +func fieldsQueryVars(owner string, projectNumber int) map[string]any { + return map[string]any{ + "owner": githubv4.String(owner), + "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec + "first": githubv4.Int(resolverFieldsPageSize), + "after": (*githubv4.String)(nil), + } +} + +// statusFieldNode is a single-select field response node for use in mock data. +// `nodeID` is the global node ID (e.g. "PVTSSF_lADO...") and `databaseID` is +// the numeric database ID the REST API expects. +func statusFieldNode(nodeID string, databaseID int, name string, options []map[string]any) map[string]any { + return map[string]any{ + "id": nodeID, + "databaseId": databaseID, + "name": name, + "dataType": "SINGLE_SELECT", + "options": options, + } +} + +func fieldsResponse(nodes []map[string]any) map[string]any { + return map[string]any{ + "organization": map[string]any{ + "projectV2": map[string]any{ + "fields": map[string]any{ + "nodes": nodes, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + }, + }, + }, + } +} + +func Test_ResolveProjectFieldByName_Success(t *testing.T) { + mocked := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 7), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + statusFieldNode("PVTSSF_lADOBBcDeFg123", 12345, "Status", []map[string]any{ + {"id": "OPT_a", "name": "Todo"}, + {"id": "OPT_b", "name": "In Progress"}, + {"id": "OPT_c", "name": "Done"}, + }), + })), + ), + ) + gql := githubv4.NewClient(mocked) + + field, err := resolveProjectFieldByName(context.Background(), gql, "octo-org", "org", 7, "Status", "SINGLE_SELECT") + require.NoError(t, err) + require.NotNil(t, field) + assert.Equal(t, "12345", field.ID) + assert.Equal(t, "SINGLE_SELECT", field.DataType) + assert.Len(t, field.Options, 3) + + optionID, err := resolveSingleSelectOptionByName(field, "In Progress") + require.NoError(t, err) + assert.Equal(t, "OPT_b", optionID) +} + +func Test_ResolveProjectFieldByName_NotFound_ReturnsStructuredError(t *testing.T) { + mocked := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 7), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + statusFieldNode("PVTSSF_lADOBBcDeFg123", 12345, "Status", nil), + })), + ), + ) + gql := githubv4.NewClient(mocked) + + _, err := resolveProjectFieldByName(context.Background(), gql, "octo-org", "org", 7, "Priority", "") + require.Error(t, err) + + var msg map[string]any + require.NoError(t, json.Unmarshal([]byte(err.Error()), &msg)) + assert.Equal(t, "field_not_found", msg["error"]) + assert.Equal(t, "Priority", msg["name"]) + assert.NotEmpty(t, msg["candidates"]) +} + +func Test_ResolveProjectFieldByName_Ambiguous_ReturnsStructuredError(t *testing.T) { + mocked := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 7), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + statusFieldNode("PVTSSF_lADOBBcDeFg123", 12345, "Status", nil), + statusFieldNode("PVTSSF_lADOBBcDeFg678", 67890, "Status", nil), + })), + ), + ) + gql := githubv4.NewClient(mocked) + + _, err := resolveProjectFieldByName(context.Background(), gql, "octo-org", "org", 7, "Status", "") + require.Error(t, err) + + var msg map[string]any + require.NoError(t, json.Unmarshal([]byte(err.Error()), &msg)) + assert.Equal(t, "field_ambiguous", msg["error"]) + candidates, _ := msg["candidates"].([]any) + assert.Len(t, candidates, 2) +} + +func Test_ResolveSingleSelectOptionByName_NotFound(t *testing.T) { + field := &ResolvedField{ + ID: "12345", + Name: "Status", + DataType: "SINGLE_SELECT", + Options: []ResolvedFieldOption{ + {ID: "OPT_a", Name: "Todo"}, + {ID: "OPT_b", Name: "Done"}, + }, + } + + _, err := resolveSingleSelectOptionByName(field, "Blocked") + require.Error(t, err) + + var msg map[string]any + require.NoError(t, json.Unmarshal([]byte(err.Error()), &msg)) + assert.Equal(t, "option_not_found", msg["error"]) + assert.Equal(t, "Blocked", msg["name"]) +} + +func Test_ResolveSingleSelectOptionByName_WrongFieldType(t *testing.T) { + field := &ResolvedField{ + ID: "12345", + Name: "Description", + DataType: "TEXT", + } + + _, err := resolveSingleSelectOptionByName(field, "anything") + require.Error(t, err) + + var msg map[string]any + require.NoError(t, json.Unmarshal([]byte(err.Error()), &msg)) + assert.Equal(t, "wrong_field_type", msg["error"]) +} + +// resolveItemByIssueQuery matches the GraphQL shape used by +// resolveProjectItemIDByIssueNumber for the issue.projectItems traversal. +type resolveItemByIssueQuery struct { + Repository struct { + Issue struct { + ProjectItems struct { + Nodes []struct { + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Project struct { + ID githubv4.ID + } + } + PageInfo PageInfoFragment + } `graphql:"projectItems(first: 50, includeArchived: true)"` + } `graphql:"issue(number: $issueNumber)"` + } `graphql:"repository(owner: $issueOwner, name: $issueRepo)"` +} + +func Test_ResolveProjectItemIDByIssueNumber_Success(t *testing.T) { + mocked := githubv4mock.NewMockedHTTPClient( + // project node id lookup (org) + githubv4mock.NewQueryMatcher( + struct { + Organization struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"organization(login: $owner)"` + }{}, + map[string]any{ + "owner": githubv4.String("octo-org"), + "projectNumber": githubv4.Int(1), + }, + githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "projectV2": map[string]any{ + "id": "PVT_project1", + }, + }, + }), + ), + // issue.projectItems lookup + githubv4mock.NewQueryMatcher( + resolveItemByIssueQuery{}, + map[string]any{ + "issueOwner": githubv4.String("octo-issue-owner"), + "issueRepo": githubv4.String("repo"), + "issueNumber": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "projectItems": map[string]any{ + "nodes": []any{ + map[string]any{ + "fullDatabaseId": "9999", + "project": map[string]any{"id": "PVT_other"}, + }, + map[string]any{ + "fullDatabaseId": "4242", + "project": map[string]any{"id": "PVT_project1"}, + }, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + }, + }, + }, + }), + ), + ) + gql := githubv4.NewClient(mocked) + + itemID, err := resolveProjectItemIDByIssueNumber(context.Background(), gql, "octo-org", "org", 1, "octo-issue-owner", "repo", 123) + require.NoError(t, err) + assert.Equal(t, int64(4242), itemID) +} + +func Test_ResolveProjectItemIDByIssueNumber_NotInProject(t *testing.T) { + mocked := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Organization struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"organization(login: $owner)"` + }{}, + map[string]any{ + "owner": githubv4.String("octo-org"), + "projectNumber": githubv4.Int(1), + }, + githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "projectV2": map[string]any{ + "id": "PVT_project1", + }, + }, + }), + ), + githubv4mock.NewQueryMatcher( + resolveItemByIssueQuery{}, + map[string]any{ + "issueOwner": githubv4.String("octo-issue-owner"), + "issueRepo": githubv4.String("repo"), + "issueNumber": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "projectItems": map[string]any{ + "nodes": []any{ + map[string]any{ + "fullDatabaseId": "9999", + "project": map[string]any{"id": "PVT_other"}, + }, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + }, + }, + }, + }, + }), + ), + ) + gql := githubv4.NewClient(mocked) + + _, err := resolveProjectItemIDByIssueNumber(context.Background(), gql, "octo-org", "org", 1, "octo-issue-owner", "repo", 123) + require.Error(t, err) + + var msg map[string]any + require.NoError(t, json.Unmarshal([]byte(err.Error()), &msg)) + assert.Equal(t, "item_not_in_project", msg["error"]) +} + +func Test_ResolveFieldNamesToIDs_Success(t *testing.T) { + mocked := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + statusFieldNode("PVTSSF_lADOBBcDeFg100", 100, "Status", nil), + statusFieldNode("PVTSSF_lADOBBcDeFg200", 200, "Priority", nil), + })), + ), + ) + gql := githubv4.NewClient(mocked) + + ids, err := resolveFieldNamesToIDs(context.Background(), gql, "octo-org", "org", 1, []string{"Status", "Priority"}) + require.NoError(t, err) + assert.Equal(t, []int64{100, 200}, ids) +} + +// Field and single-select option name matching is case-insensitive so agents passing lowercase +// names like "status" or "in progress" resolve to "Status" and "In Progress" respectively. +func Test_ResolveProjectFieldByName_CaseInsensitive(t *testing.T) { + mocked := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 7), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + statusFieldNode("PVTSSF_lADOBBcDeFg123", 12345, "Status", []map[string]any{ + {"id": "OPT_a", "name": "Todo"}, + {"id": "OPT_b", "name": "In Progress"}, + }), + })), + ), + ) + gql := githubv4.NewClient(mocked) + + field, err := resolveProjectFieldByName(context.Background(), gql, "octo-org", "org", 7, "status", "") + require.NoError(t, err) + require.NotNil(t, field) + assert.Equal(t, "12345", field.ID) + + optionID, err := resolveSingleSelectOptionByName(field, "in progress") + require.NoError(t, err) + assert.Equal(t, "OPT_b", optionID) +} + +// Test_ResolveFieldNamesToIDs_CaseInsensitive verifies bulk name resolution +// also matches case-insensitively. +func Test_ResolveFieldNamesToIDs_CaseInsensitive(t *testing.T) { + mocked := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + statusFieldNode("PVTSSF_lADOBBcDeFg100", 100, "Status", nil), + statusFieldNode("PVTSSF_lADOBBcDeFg200", 200, "Priority", nil), + })), + ), + ) + gql := githubv4.NewClient(mocked) + + ids, err := resolveFieldNamesToIDs(context.Background(), gql, "octo-org", "org", 1, []string{"status", "PRIORITY"}) + require.NoError(t, err) + assert.Equal(t, []int64{100, 200}, ids) +} + +// Test_ProjectsWrite_UpdateProjectItem_ByName is the acceptance test for the +// write side: set Status = "In Progress" using only names plus an issue number. +func Test_ProjectsWrite_UpdateProjectItem_ByName(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + updatedItem := verbosePullRequestProjectItemFixture() + + mockedREST := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, updatedItem), + }) + restClient := mustNewGHClient(t, mockedREST) + + mockedGQL := githubv4mock.NewMockedHTTPClient( + // 1. project node id (used by resolveProjectItemIDByIssueNumber) + githubv4mock.NewQueryMatcher( + struct { + Organization struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"organization(login: $owner)"` + }{}, + map[string]any{ + "owner": githubv4.String("octo-org"), + "projectNumber": githubv4.Int(1), + }, + githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "projectV2": map[string]any{"id": "PVT_project1"}, + }, + }), + ), + // 2. issue -> projectItems lookup + githubv4mock.NewQueryMatcher( + resolveItemByIssueQuery{}, + map[string]any{ + "issueOwner": githubv4.String("github"), + "issueRepo": githubv4.String("planning-tracking"), + "issueNumber": githubv4.Int(123), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{ + "projectItems": map[string]any{ + "nodes": []any{ + map[string]any{ + "fullDatabaseId": "1001", + "project": map[string]any{"id": "PVT_project1"}, + }, + }, + "pageInfo": map[string]any{ + "hasNextPage": false, "hasPreviousPage": false, + "startCursor": "", "endCursor": "", + }, + }, + }, + }, + }), + ), + // 3. fields(first:100) for name resolution + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + statusFieldNode("PVTSSF_lADOBBcDeFg101", 101, "Status", []map[string]any{ + {"id": "OPT_in_progress", "name": "In Progress"}, + }), + })), + ), + ) + gqlClient := githubv4.NewClient(mockedGQL) + + deps := BaseDeps{Client: restClient, GQLClient: gqlClient} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "update_project_item", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "item_owner": "github", + "item_repo": "planning-tracking", + "issue_number": float64(123), + "updated_field": map[string]any{ + "name": "Status", + "value": "In Progress", + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) +} + +func Test_ProjectsWrite_UpdateProjectItem_NameNotFound_StructuredError(t *testing.T) { + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + mockedGQL := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + projectFieldsTestQuery{}, + fieldsQueryVars("octo-org", 1), + githubv4mock.DataResponse(fieldsResponse([]map[string]any{ + statusFieldNode("PVTSSF_lADOBBcDeFg101", 101, "Status", nil), + })), + ), + ) + gqlClient := githubv4.NewClient(mockedGQL) + restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + + deps := BaseDeps{Client: restClient, GQLClient: gqlClient} + 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{ + "name": "Doesnt Exist", + "value": "whatever", + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + + textContent := getTextResult(t, result) + var msg map[string]any + require.NoError(t, json.Unmarshal([]byte(textContent.Text), &msg)) + assert.Equal(t, "field_not_found", msg["error"]) + assert.Equal(t, "Doesnt Exist", msg["name"]) +} diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 05914975a0..553c2421a4 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -364,6 +364,29 @@ func Test_ProjectsList_ListProjectItems(t *testing.T) { require.True(t, ok) assertMinimalPullRequestProjectItem(t, textContent.Text, item) }) + + t.Run("rejects fields and field_names together", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) + client := mustNewGHClient(t, 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), + "fields": []any{"100"}, + "field_names": []any{"Status"}, + }) + 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, "provide either 'fields' or 'field_names', not both") + }) } func Test_detectOwnerType(t *testing.T) { @@ -811,6 +834,30 @@ func Test_ProjectsGet_GetProjectItem(t *testing.T) { textContent := getTextResult(t, result) assert.Contains(t, textContent.Text, "missing required parameter: item_id") }) + + t.Run("rejects fields and field_names together", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) + client := mustNewGHClient(t, 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), + "fields": []any{"100"}, + "field_names": []any{"Status"}, + }) + 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, "provide either 'fields' or 'field_names', not both") + }) } func Test_ProjectsWrite(t *testing.T) { From 7629b6e7337e1fc4b11a0ca5fdea4af4edd21f6e Mon Sep 17 00:00:00 2001 From: tommaso-moro Date: Wed, 15 Jul 2026 11:27:05 +0100 Subject: [PATCH 16/16] Enable fields param in Insiders mode Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/insiders-features.md | 90 ++++++++++++++++++++++++++++++++ pkg/github/feature_flags.go | 1 + pkg/github/feature_flags_test.go | 16 ++++++ 3 files changed, 107 insertions(+) diff --git a/docs/insiders-features.md b/docs/insiders-features.md index 10df187a91..f85870ef20 100644 --- a/docs/insiders-features.md +++ b/docs/insiders-features.md @@ -133,6 +133,96 @@ The list below is generated from the Go source. It covers tool **inventory and s - 'blocked_by' - the subject issue is blocked by the related issue. - 'blocking' - the subject issue blocks the related issue. (string, required) +### `fields_param` + +- **get_file_contents** - Get file or directory contents + - **Required OAuth Scopes**: `repo` + - `fields`: Subset of fields to return for each entry when the path is a directory. If omitted, all fields are returned. Ignored when the path is a single file. Use this to reduce response size when listing directories and you only need specific fields, e.g. just 'name' and 'type'. (string[], optional) + - `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) + - `repo`: Repository name (string, required) + - `sha`: Accepts optional commit SHA. If specified, it will be used instead of ref (string, optional) + +- **list_commits** - List commits + - **Required OAuth Scopes**: `repo` + - `author`: Author username or email address to filter commits by (string, optional) + - `fields`: Subset of fields to return for each commit. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields, e.g. just 'sha' and 'html_url'. (string[], optional) + - `owner`: Repository owner (string, required) + - `page`: Page number for pagination (min 1) (number, optional) + - `path`: Only commits containing this file path will be returned (string, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `repo`: Repository name (string, required) + - `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) + - `since`: Only commits after this date will be returned (ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DD) (string, optional) + - `until`: Only commits before this date will be returned (ISO 8601 format: YYYY-MM-DDTHH:MM:SSZ or YYYY-MM-DD) (string, optional) + +- **list_issues** - List issues + - **Required OAuth Scopes**: `repo` + - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional) + - `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional) + - `field_filters`: Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date). (object[], optional) + - `fields`: Subset of fields to return for each issue. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body' and 'field_values' in particular drops the largest per-result data. (string[], optional) + - `labels`: Filter by labels (string[], optional) + - `orderBy`: Order issues by field. If provided, the 'direction' also needs to be provided. (string, optional) + - `owner`: Repository owner (string, required) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `repo`: Repository name (string, required) + - `since`: Filter by date (ISO 8601 timestamp) (string, optional) + - `state`: Filter by state, by default both open and closed issues are returned when not provided (string, optional) + +- **list_pull_requests** - List pull requests + - **Required OAuth Scopes**: `repo` + - `base`: Filter by base branch (string, optional) + - `direction`: Sort direction (string, optional) + - `fields`: Subset of fields to return for each pull request. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body' in particular drops the largest per-result data. (string[], optional) + - `head`: Filter by head user/org and branch (string, optional) + - `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) + - `sort`: Sort by (string, optional) + - `state`: Filter by state (string, optional) + +- **list_releases** - List releases + - **Required OAuth Scopes**: `repo` + - `fields`: Subset of fields to return for each release. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body' in particular drops the largest per-release data. (string[], optional) + - `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) + +- **search_code** - Search code + - **Required OAuth Scopes**: `repo` + - `fields`: Subset of fields to return for each code search result. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'repository' and 'text_matches' in particular drops the largest per-result data. (string[], optional) + - `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) + - `query`: Search query (GitHub code search REST). Implicit AND between terms; supports `OR`, `NOT`, and `"quoted phrase"` for exact match. Qualifiers: `repo:owner/repo`, `org:`, `user:`, `language:`, `path:dir` (prefix match), `filename:exact.ext`, `extension:`, `in:file`, `in:path`, `size:`, `is:archived`, `is:fork`. Max 256 chars. Examples: `WithContext language:go org:github`; `"package main" repo:o/r`; `func extension:go path:cmd repo:o/r`; `NOT TODO language:go repo:o/r`. (string, required) + - `sort`: Sort field ('indexed' only) (string, optional) + +- **search_issues** - Search issues + - **Required OAuth Scopes**: `repo` + - `fields`: Subset of fields to return for each issue result. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body', 'reactions', and 'labels' in particular drops the largest per-result data. (string[], optional) + - `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) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `query`: Search query using GitHub issues search syntax (string, required) + - `repo`: Optional repository name. If provided with owner, only issues for this repository are listed. (string, optional) + - `sort`: Sort field by number of matches of categories, defaults to best match (string, optional) + +- **search_pull_requests** - Search pull requests + - **Required OAuth Scopes**: `repo` + - `fields`: Subset of fields to return for each pull request result. If omitted, all fields are returned. Use this to reduce response size when you only need specific fields; omitting 'body', 'reactions', and 'labels' in particular drops the largest per-result data. (string[], optional) + - `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) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `query`: Search query using GitHub pull request search syntax (string, required) + - `repo`: Optional repository name. If provided with owner, only pull requests for this repository are listed. (string, optional) + - `sort`: Sort field by number of matches of categories, defaults to best match (string, optional) + --- diff --git a/pkg/github/feature_flags.go b/pkg/github/feature_flags.go index 3b6334a4ea..442f427cb8 100644 --- a/pkg/github/feature_flags.go +++ b/pkg/github/feature_flags.go @@ -55,6 +55,7 @@ var InsidersFeatureFlags = []string{ FeatureFlagCSVOutput, FeatureFlagFileBlame, FeatureFlagIssueDependencies, + FeatureFlagFieldsParam, } // FeatureFlags defines runtime feature toggles that adjust tool behavior. diff --git a/pkg/github/feature_flags_test.go b/pkg/github/feature_flags_test.go index acb0da1bcd..aa4a6c9d28 100644 --- a/pkg/github/feature_flags_test.go +++ b/pkg/github/feature_flags_test.go @@ -155,12 +155,28 @@ func TestResolveFeatureFlags(t *testing.T) { enabledFeatures: []string{MCPAppsFeatureFlag}, expectedFlags: []string{MCPAppsFeatureFlag}, }, + { + name: "fields param is not enabled by default", + enabledFeatures: nil, + unexpectedFlags: []string{FeatureFlagFieldsParam}, + }, + { + name: "fields param can be directly enabled", + enabledFeatures: []string{FeatureFlagFieldsParam}, + expectedFlags: []string{FeatureFlagFieldsParam}, + }, { name: "insiders mode enables insiders flags", enabledFeatures: nil, insidersMode: true, expectedFlags: InsidersFeatureFlags, }, + { + name: "insiders mode enables fields param", + enabledFeatures: nil, + insidersMode: true, + expectedFlags: []string{FeatureFlagFieldsParam}, + }, { name: "insiders mode does not auto-enable ifc labels", enabledFeatures: nil,