From 1861a351f8b00ae5ff59bbd30a720545fdfc5f92 Mon Sep 17 00:00:00 2001 From: Iulia Bejan <64602043+iulia-b@users.noreply.github.com> Date: Mon, 18 May 2026 14:36:23 +0200 Subject: [PATCH 01/48] Upgrade go-github from v82 to v87 (#2452) Breaking changes addressed: - raw.NewClient: Use WithHTTPClient/WithEnterpriseURLs options, pass ctx to NewRequest, return (*Client, error) - internal/ghmcp/server.go: Use functional options for REST client creation, replace UserAgent field mutation with UserAgentTransport wrapper, add restUATransp field to githubClients struct - pkg/github/dependencies.go: Use functional options for REST client creation, handle raw.NewClient error return - pkg/github/actions.go: Handle new WorkflowDispatchRunDetails return value from CreateWorkflowDispatchEventByID/ByFileName - pkg/github/issues.go: Replace IssueListOptions with ListOptions for SubIssue.ListByIssue - pkg/github/notifications.go: MarkThreadDone now takes string instead of int64; remove ParseInt and strconv import - pkg/github/projects.go: Remove pointer indirection from ListProjectsPaginationOptions and ListProjectsOptions fields - pkg/github/issues_granular.go: Pass ctx to NewRequest, remove ctx from Do - Test files: Add mustNewGHClient helper, replace all NewClient calls, fix stubClientFnFromHTTP signature, fix lockdown_test.go BaseURL handling, fix raw_test.go, remove invalid threadID test case Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- e2e/e2e_test.go | 2 +- go.mod | 2 +- go.sum | 4 +- internal/ghmcp/server.go | 51 +++++++++++------- pkg/errors/error.go | 2 +- pkg/errors/error_test.go | 2 +- pkg/github/actions.go | 6 +-- pkg/github/actions_test.go | 26 ++++----- pkg/github/code_scanning.go | 2 +- pkg/github/code_scanning_test.go | 6 +-- pkg/github/context_tools_test.go | 16 +++--- pkg/github/copilot.go | 2 +- pkg/github/copilot_test.go | 4 +- pkg/github/dependabot.go | 2 +- pkg/github/dependabot_test.go | 6 +-- pkg/github/dependencies.go | 19 ++++--- pkg/github/discussions.go | 2 +- pkg/github/discussions_test.go | 2 +- pkg/github/gists.go | 2 +- pkg/github/gists_test.go | 10 ++-- pkg/github/git.go | 2 +- pkg/github/git_test.go | 4 +- pkg/github/granular_tools_test.go | 28 +++++----- pkg/github/helper_test.go | 17 ++++++ pkg/github/issues.go | 10 ++-- pkg/github/issues_granular.go | 6 +-- pkg/github/issues_test.go | 48 ++++++++--------- pkg/github/minimal_types.go | 2 +- pkg/github/notifications.go | 11 +--- pkg/github/notifications_test.go | 30 +++-------- pkg/github/params.go | 2 +- pkg/github/params_test.go | 2 +- pkg/github/projects.go | 29 +++------- pkg/github/projects_test.go | 31 ++++++----- pkg/github/pullrequests.go | 2 +- pkg/github/pullrequests_granular.go | 2 +- pkg/github/pullrequests_test.go | 34 ++++++------ pkg/github/repositories.go | 2 +- pkg/github/repositories_helper.go | 2 +- pkg/github/repositories_test.go | 53 ++++++++++--------- pkg/github/repository_resource.go | 2 +- pkg/github/repository_resource_completions.go | 2 +- .../repository_resource_completions_test.go | 2 +- pkg/github/repository_resource_test.go | 11 ++-- pkg/github/search.go | 2 +- pkg/github/search_test.go | 20 +++---- pkg/github/search_utils.go | 2 +- pkg/github/secret_scanning.go | 2 +- pkg/github/secret_scanning_test.go | 6 +-- pkg/github/security_advisories.go | 2 +- pkg/github/security_advisories_test.go | 10 ++-- pkg/github/server_test.go | 9 ++-- pkg/github/tools.go | 2 +- pkg/lockdown/lockdown.go | 2 +- pkg/lockdown/lockdown_test.go | 7 ++- pkg/raw/raw.go | 22 ++++---- pkg/raw/raw_test.go | 14 +++-- third-party-licenses.darwin.md | 2 +- third-party-licenses.linux.md | 2 +- third-party-licenses.windows.md | 2 +- .../go-github/{v82 => v87}/github/LICENSE | 0 61 files changed, 304 insertions(+), 304 deletions(-) rename third-party/github.com/google/go-github/{v82 => v87}/github/LICENSE (100%) diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index ad40ecad0..73d5f271c 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -18,7 +18,7 @@ import ( "github.com/github/github-mcp-server/internal/ghmcp" "github.com/github/github-mcp-server/pkg/github" "github.com/github/github-mcp-server/pkg/translations" - gogithub "github.com/google/go-github/v82/github" + gogithub "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" ) diff --git a/go.mod b/go.mod index 89cafc377..3d7ad06a5 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,7 @@ go 1.25.0 require ( github.com/go-chi/chi/v5 v5.2.5 github.com/go-viper/mapstructure/v2 v2.5.0 - github.com/google/go-github/v82 v82.0.0 + github.com/google/go-github/v87 v87.0.0 github.com/google/jsonschema-go v0.4.2 github.com/josephburnett/jd/v2 v2.5.0 github.com/lithammer/fuzzysearch v1.1.8 diff --git a/go.sum b/go.sum index 615b4e9c0..defedd481 100644 --- a/go.sum +++ b/go.sum @@ -16,8 +16,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-github/v82 v82.0.0 h1:OH09ESON2QwKCUVMYmMcVu1IFKFoaZHwqYaUtr/MVfk= -github.com/google/go-github/v82 v82.0.0/go.mod h1:hQ6Xo0VKfL8RZ7z1hSfB4fvISg0QqHOqe9BP0qo+WvM= +github.com/google/go-github/v87 v87.0.0 h1:9Ck3dcOxWJyfsN8tzdah4YvmqB/7ZsstMglv/PkOsl0= +github.com/google/go-github/v87 v87.0.0/go.mod h1:hGUoT5pwm/ck5uLL+wroSVQfg8mpe+buxllCcGV4VaM= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index b1925bffd..6c8c3934d 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -24,18 +24,19 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - gogithub "github.com/google/go-github/v82/github" + gogithub "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" ) // githubClients holds all the GitHub API clients created for a server instance. type githubClients struct { - rest *gogithub.Client - gql *githubv4.Client - gqlHTTP *http.Client // retained for middleware to modify transport - raw *raw.Client - repoAccess *lockdown.RepoAccessCache + rest *gogithub.Client + restUATransp *transport.UserAgentTransport + gql *githubv4.Client + gqlHTTP *http.Client // retained for middleware to modify transport + raw *raw.Client + repoAccess *lockdown.RepoAccessCache } // createGitHubClients creates all the GitHub API clients needed by the server. @@ -61,10 +62,18 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv } // Construct REST client - restClient := gogithub.NewClient(nil).WithAuthToken(cfg.Token) - restClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", cfg.Version) - restClient.BaseURL = restURL - restClient.UploadURL = uploadURL + restUATransport := &transport.UserAgentTransport{ + Transport: http.DefaultTransport, + Agent: fmt.Sprintf("github-mcp-server/%s", cfg.Version), + } + restClient, err := gogithub.NewClient( + gogithub.WithHTTPClient(&http.Client{Transport: restUATransport}), + gogithub.WithAuthToken(cfg.Token), + gogithub.WithEnterpriseURLs(restURL.String(), uploadURL.String()), + ) + if err != nil { + return nil, fmt.Errorf("failed to create REST client: %w", err) + } // Construct GraphQL client // We use NewEnterpriseClient unconditionally since we already parsed the API host @@ -80,7 +89,10 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv gqlClient := githubv4.NewEnterpriseClient(graphQLURL.String(), gqlHTTPClient) // Create raw content client (shares REST client's HTTP transport) - rawClient := raw.NewClient(restClient, rawURL) + rawClient, err := raw.NewClient(restClient, rawURL) + if err != nil { + return nil, fmt.Errorf("failed to create raw client: %w", err) + } // Set up repo access cache for lockdown mode var repoAccessCache *lockdown.RepoAccessCache @@ -95,11 +107,12 @@ func createGitHubClients(cfg github.MCPServerConfig, apiHost utils.APIHostResolv } return &githubClients{ - rest: restClient, - gql: gqlClient, - gqlHTTP: gqlHTTPClient, - raw: rawClient, - repoAccess: repoAccessCache, + rest: restClient, + restUATransp: restUATransport, + gql: gqlClient, + gqlHTTP: gqlHTTPClient, + raw: rawClient, + repoAccess: repoAccessCache, }, nil } @@ -170,7 +183,7 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se github.RegisterUIResources(ghServer) } - ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg, clients.rest, clients.gqlHTTP)) + ghServer.AddReceivingMiddleware(addUserAgentsMiddleware(cfg, clients.restUATransp, clients.gqlHTTP)) return ghServer, nil } @@ -345,7 +358,7 @@ func createFeatureChecker(enabledFeatures []string, insidersMode bool) inventory } } -func addUserAgentsMiddleware(cfg github.MCPServerConfig, restClient *gogithub.Client, gqlHTTPClient *http.Client) func(next mcp.MethodHandler) mcp.MethodHandler { +func addUserAgentsMiddleware(cfg github.MCPServerConfig, restUATransp *transport.UserAgentTransport, gqlHTTPClient *http.Client) func(next mcp.MethodHandler) mcp.MethodHandler { return func(next mcp.MethodHandler) mcp.MethodHandler { return func(ctx context.Context, method string, request mcp.Request) (result mcp.Result, err error) { if method != "initialize" { @@ -368,7 +381,7 @@ func addUserAgentsMiddleware(cfg github.MCPServerConfig, restClient *gogithub.Cl userAgent += " (insiders)" } - restClient.UserAgent = userAgent + restUATransp.Agent = userAgent gqlHTTPClient.Transport = &transport.UserAgentTransport{ Transport: gqlHTTPClient.Transport, diff --git a/pkg/errors/error.go b/pkg/errors/error.go index d75765159..7c1f28e66 100644 --- a/pkg/errors/error.go +++ b/pkg/errors/error.go @@ -6,7 +6,7 @@ import ( "net/http" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/errors/error_test.go b/pkg/errors/error_test.go index e33d5bd39..7459569f2 100644 --- a/pkg/errors/error_test.go +++ b/pkg/errors/error_test.go @@ -6,7 +6,7 @@ import ( "net/http" "testing" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 85afed6e1..a7ce039d8 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -16,7 +16,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -989,10 +989,10 @@ func runWorkflow(ctx context.Context, client *github.Client, owner, repo, workfl var workflowType string if workflowIDInt, parseErr := strconv.ParseInt(workflowID, 10, 64); parseErr == nil { - resp, err = client.Actions.CreateWorkflowDispatchEventByID(ctx, owner, repo, workflowIDInt, event) + _, resp, err = client.Actions.CreateWorkflowDispatchEventByID(ctx, owner, repo, workflowIDInt, event) workflowType = "workflow_id" } else { - resp, err = client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowID, event) + _, resp, err = client.Actions.CreateWorkflowDispatchEventByFileName(ctx, owner, repo, workflowID, event) workflowType = "workflow_file" } diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 6eba71b8b..371bbbe9d 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -86,7 +86,7 @@ func Test_ActionsList_ListWorkflows(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -136,7 +136,7 @@ func Test_ActionsList_ListWorkflowRuns(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -185,7 +185,7 @@ func Test_ActionsList_ListWorkflowRuns(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -241,7 +241,7 @@ func Test_ActionsGet_GetWorkflow(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -284,7 +284,7 @@ func Test_ActionsGet_GetWorkflowRun(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -412,7 +412,7 @@ func Test_ActionsRunTrigger_RunWorkflow(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -449,7 +449,7 @@ func Test_ActionsRunTrigger_CancelWorkflowRun(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -480,7 +480,7 @@ func Test_ActionsRunTrigger_CancelWorkflowRun(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -504,7 +504,7 @@ func Test_ActionsRunTrigger_CancelWorkflowRun(t *testing.T) { t.Run("missing run_id for non-run_workflow methods", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -556,7 +556,7 @@ func Test_ActionsGetJobLogs_SingleJob(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, ContentWindowSize: 5000, @@ -618,7 +618,7 @@ func Test_ActionsGetJobLogs_FailedJobs(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, ContentWindowSize: 5000, @@ -668,7 +668,7 @@ func Test_ActionsGetJobLogs_FailedJobs(t *testing.T) { }), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, ContentWindowSize: 5000, diff --git a/pkg/github/code_scanning.go b/pkg/github/code_scanning.go index 34249b212..2deefd321 100644 --- a/pkg/github/code_scanning.go +++ b/pkg/github/code_scanning.go @@ -11,7 +11,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/code_scanning_test.go b/pkg/github/code_scanning_test.go index 7a3c16fd1..64c61766e 100644 --- a/pkg/github/code_scanning_test.go +++ b/pkg/github/code_scanning_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -80,7 +80,7 @@ func Test_GetCodeScanningAlert(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -206,7 +206,7 @@ func Test_ListCodeScanningAlerts(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index 510372cd9..2b17be86d 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -10,7 +10,7 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -99,7 +99,7 @@ func Test_GetMe(t *testing.T) { deps = stubDeps{clientFn: stubClientFnErr(tc.clientErr), obsv: stubExporters()} } else { obs := stubExporters() - deps = BaseDeps{Client: github.NewClient(tc.mockedClient), Obsv: obs} + deps = BaseDeps{Client: mustNewGHClient(t, tc.mockedClient), Obsv: obs} } handler := serverTool.Handler(deps) @@ -155,7 +155,7 @@ func Test_GetMe_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode disabled omits ifc label from result meta", func(t *testing.T) { deps := BaseDeps{ - Client: github.NewClient(mockedHTTPClient), + Client: mustNewGHClient(t, mockedHTTPClient), Flags: FeatureFlags{InsidersMode: false}, } handler := serverTool.Handler(deps) @@ -170,7 +170,7 @@ func Test_GetMe_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode enabled includes ifc label in result meta", func(t *testing.T) { deps := BaseDeps{ - Client: github.NewClient(mockedHTTPClient), + Client: mustNewGHClient(t, mockedHTTPClient), Flags: FeatureFlags{InsidersMode: true}, } handler := serverTool.Handler(deps) @@ -326,7 +326,7 @@ func Test_GetTeams(t *testing.T) { name: "successful get teams", makeDeps: func() ToolDependencies { return BaseDeps{ - Client: github.NewClient(httpClientWithUser()), + Client: mustNewGHClient(t, httpClientWithUser()), GQLClient: gqlClientForTestuser(), } }, @@ -351,7 +351,7 @@ func Test_GetTeams(t *testing.T) { name: "no teams found", makeDeps: func() ToolDependencies { return BaseDeps{ - Client: github.NewClient(httpClientWithUser()), + Client: mustNewGHClient(t, httpClientWithUser()), GQLClient: gqlClientNoTeams(), } }, @@ -372,7 +372,7 @@ func Test_GetTeams(t *testing.T) { name: "get user fails", makeDeps: func() ToolDependencies { return BaseDeps{ - Client: github.NewClient(httpClientUserFails()), + Client: mustNewGHClient(t, httpClientUserFails()), Obsv: stubExporters(), } }, @@ -384,7 +384,7 @@ func Test_GetTeams(t *testing.T) { name: "getting GraphQL client fails", makeDeps: func() ToolDependencies { return stubDeps{ - clientFn: stubClientFnFromHTTP(httpClientWithUser()), + clientFn: stubClientFnFromHTTP(t, httpClientWithUser()), gqlClientFn: stubGQLClientFnErr("GraphQL client error"), obsv: stubExporters(), } diff --git a/pkg/github/copilot.go b/pkg/github/copilot.go index d95357e73..017bb98bc 100644 --- a/pkg/github/copilot.go +++ b/pkg/github/copilot.go @@ -17,7 +17,7 @@ import ( "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/go-viper/mapstructure/v2" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/copilot_test.go b/pkg/github/copilot_test.go index 0a1d5ef3b..b86f26f47 100644 --- a/pkg/github/copilot_test.go +++ b/pkg/github/copilot_test.go @@ -10,7 +10,7 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -932,7 +932,7 @@ func Test_RequestCopilotReview(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := RequestCopilotReview(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, diff --git a/pkg/github/dependabot.go b/pkg/github/dependabot.go index 541cc5c1e..ccb36f483 100644 --- a/pkg/github/dependabot.go +++ b/pkg/github/dependabot.go @@ -12,7 +12,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/dependabot_test.go b/pkg/github/dependabot_test.go index 6c9b95ca3..2196b6b13 100644 --- a/pkg/github/dependabot_test.go +++ b/pkg/github/dependabot_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -89,7 +89,7 @@ func Test_GetDependabotAlert(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} handler := toolDef.Handler(deps) @@ -243,7 +243,7 @@ func Test_ListDependabotAlerts(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} handler := toolDef.Handler(deps) diff --git a/pkg/github/dependencies.go b/pkg/github/dependencies.go index aad213e4e..16be84efb 100644 --- a/pkg/github/dependencies.go +++ b/pkg/github/dependencies.go @@ -18,7 +18,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - gogithub "github.com/google/go-github/v82/github" + gogithub "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" ) @@ -320,10 +320,14 @@ func (d *RequestDeps) GetClient(ctx context.Context) (*gogithub.Client, error) { } // Construct REST client - restClient := gogithub.NewClient(nil).WithAuthToken(token) - restClient.UserAgent = fmt.Sprintf("github-mcp-server/%s", d.version) - restClient.BaseURL = baseRestURL - restClient.UploadURL = uploadURL + restClient, err := gogithub.NewClient( + gogithub.WithAuthToken(token), + gogithub.WithUserAgent(fmt.Sprintf("github-mcp-server/%s", d.version)), + gogithub.WithEnterpriseURLs(baseRestURL.String(), uploadURL.String()), + ) + if err != nil { + return nil, fmt.Errorf("failed to create REST client: %w", err) + } return restClient, nil } @@ -370,7 +374,10 @@ func (d *RequestDeps) GetRawClient(ctx context.Context) (*raw.Client, error) { return nil, fmt.Errorf("failed to get Raw URL: %w", err) } - rawClient := raw.NewClient(client, rawURL) + rawClient, err := raw.NewClient(client, rawURL) + if err != nil { + return nil, fmt.Errorf("failed to create raw client: %w", err) + } return rawClient, nil } diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index 4ecf7e290..514a2d030 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -11,7 +11,7 @@ import ( "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/go-viper/mapstructure/v2" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index fb9d0c564..36fdb6c43 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -9,7 +9,7 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" diff --git a/pkg/github/gists.go b/pkg/github/gists.go index a0bc1b085..de577af04 100644 --- a/pkg/github/gists.go +++ b/pkg/github/gists.go @@ -12,7 +12,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/gists_test.go b/pkg/github/gists_test.go index 74cd45d27..342cd0c8f 100644 --- a/pkg/github/gists_test.go +++ b/pkg/github/gists_test.go @@ -9,7 +9,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -141,7 +141,7 @@ func Test_ListGists(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -252,7 +252,7 @@ func Test_GetGist(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -392,7 +392,7 @@ func Test_CreateGist(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -545,7 +545,7 @@ func Test_UpdateGist(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } diff --git a/pkg/github/git.go b/pkg/github/git.go index 33a1f94ef..515d8b65f 100644 --- a/pkg/github/git.go +++ b/pkg/github/git.go @@ -11,7 +11,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/git_test.go b/pkg/github/git_test.go index cef65c9ef..1ad714750 100644 --- a/pkg/github/git_test.go +++ b/pkg/github/git_test.go @@ -9,7 +9,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -125,7 +125,7 @@ func Test_GetRepositoryTree(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go index 37a718f37..72ed1939d 100644 --- a/pkg/github/granular_tools_test.go +++ b/pkg/github/granular_tools_test.go @@ -10,7 +10,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/translations" - gogithub "github.com/google/go-github/v82/github" + gogithub "github.com/google/go-github/v87/github" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -176,7 +176,7 @@ func TestGranularCreateIssue(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := gogithub.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} serverTool := GranularCreateIssue(translations.NullTranslationHelper) handler := serverTool.Handler(deps) @@ -196,7 +196,7 @@ func TestGranularCreateIssue(t *testing.T) { } func TestGranularUpdateIssueTitle(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, &gogithub.Issue{ Number: gogithub.Ptr(42), Title: gogithub.Ptr("New Title"), @@ -218,7 +218,7 @@ func TestGranularUpdateIssueTitle(t *testing.T) { } func TestGranularUpdateIssueBody(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ "body": "Updated body", }).andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{ @@ -242,7 +242,7 @@ func TestGranularUpdateIssueBody(t *testing.T) { } func TestGranularUpdateIssueAssignees(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ "assignees": []any{"user1", "user2"}, }).andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), @@ -263,7 +263,7 @@ func TestGranularUpdateIssueAssignees(t *testing.T) { } func TestGranularUpdateIssueLabels(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ "labels": []any{"bug", "enhancement"}, }).andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), @@ -284,7 +284,7 @@ func TestGranularUpdateIssueLabels(t *testing.T) { } func TestGranularUpdateIssueMilestone(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ "milestone": float64(5), }).andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), @@ -342,7 +342,7 @@ func TestGranularUpdateIssueType(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq). andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), })) @@ -390,7 +390,7 @@ func TestGranularUpdateIssueTypeInvalidRationale(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - deps := BaseDeps{Client: gogithub.NewClient(MockHTTPClientWithHandlers(nil))} + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(nil))} serverTool := GranularUpdateIssueType(translations.NullTranslationHelper) handler := serverTool.Handler(deps) @@ -440,7 +440,7 @@ func TestGranularUpdateIssueState(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq). andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{ Number: gogithub.Ptr(1), @@ -462,7 +462,7 @@ func TestGranularUpdateIssueState(t *testing.T) { // --- Pull request granular tool handler tests --- func TestGranularUpdatePullRequestTitle(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{ "title": "New PR Title", }).andThen(mockResponse(t, http.StatusOK, &gogithub.PullRequest{ @@ -486,7 +486,7 @@ func TestGranularUpdatePullRequestTitle(t *testing.T) { } func TestGranularUpdatePullRequestBody(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{ "body": "Updated description", }).andThen(mockResponse(t, http.StatusOK, &gogithub.PullRequest{ @@ -510,7 +510,7 @@ func TestGranularUpdatePullRequestBody(t *testing.T) { } func TestGranularUpdatePullRequestState(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposPullsByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{ "state": "closed", }).andThen(mockResponse(t, http.StatusOK, &gogithub.PullRequest{ @@ -534,7 +534,7 @@ func TestGranularUpdatePullRequestState(t *testing.T) { } func TestGranularRequestPullRequestReviewers(t *testing.T) { - client := gogithub.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, &gogithub.PullRequest{Number: gogithub.Ptr(1)}), })) deps := BaseDeps{Client: client} diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index 892b3045c..4181f102e 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -10,6 +10,7 @@ import ( "strings" "testing" + gogithub "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" testifymock "github.com/stretchr/testify/mock" @@ -179,6 +180,22 @@ type expectations struct { requestBody any } +// mustNewGHClient creates a new GitHub client for testing. +// If httpClient is nil, a client with no options is created. +// The test fails immediately if client creation fails. +func mustNewGHClient(t *testing.T, httpClient *http.Client) *gogithub.Client { + t.Helper() + var client *gogithub.Client + var err error + if httpClient == nil { + client, err = gogithub.NewClient() + } else { + client, err = gogithub.NewClient(gogithub.WithHTTPClient(httpClient)) + } + require.NoError(t, err) + return client +} + // expect is a helper function to create a partial mock that expects various // request behaviors, such as path, query parameters, and request body. func expect(t *testing.T, e expectations) *partialMock { diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 98585e291..52a024c29 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -16,7 +16,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" @@ -452,11 +452,9 @@ func GetSubIssues(ctx context.Context, client *github.Client, deps ToolDependenc } featureFlags := deps.GetFlags(ctx) - opts := &github.IssueListOptions{ - ListOptions: github.ListOptions{ - Page: pagination.Page, - PerPage: pagination.PerPage, - }, + opts := &github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, } subIssues, resp, err := client.SubIssue.ListByIssue(ctx, owner, repo, int64(issueNumber), opts) diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go index 973032c4a..5b335bd44 100644 --- a/pkg/github/issues_granular.go +++ b/pkg/github/issues_granular.go @@ -12,7 +12,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" @@ -410,13 +410,13 @@ func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.Ser } apiURL := fmt.Sprintf("repos/%s/%s/issues/%d", owner, repo, issueNumber) - req, err := client.NewRequest("PATCH", apiURL, body) + 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(ctx, req, issue) + resp, err := client.Do(req, issue) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update issue", resp, err), nil, nil } diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index d23c22ed5..6b4042bac 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -14,7 +14,7 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -225,7 +225,7 @@ func Test_GetIssue(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) var restClient *github.Client if tc.restPermission != "" { @@ -324,7 +324,7 @@ func Test_IssueRead_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode disabled omits ifc label", func(t *testing.T) { deps := BaseDeps{ - Client: github.NewClient(makeMockClient(false, 0)), + Client: mustNewGHClient(t, makeMockClient(false, 0)), Flags: FeatureFlags{InsidersMode: false}, } handler := serverTool.Handler(deps) @@ -339,7 +339,7 @@ func Test_IssueRead_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode enabled on public repo emits public untrusted", func(t *testing.T) { deps := BaseDeps{ - Client: github.NewClient(makeMockClient(false, 0)), + Client: mustNewGHClient(t, makeMockClient(false, 0)), Flags: FeatureFlags{InsidersMode: true}, } handler := serverTool.Handler(deps) @@ -357,7 +357,7 @@ func Test_IssueRead_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode enabled on private repo with get_comments emits private untrusted", func(t *testing.T) { deps := BaseDeps{ - Client: github.NewClient(makeMockClient(true, 0)), + Client: mustNewGHClient(t, makeMockClient(true, 0)), Flags: FeatureFlags{InsidersMode: true}, } handler := serverTool.Handler(deps) @@ -375,7 +375,7 @@ func Test_IssueRead_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode skips ifc label when visibility lookup fails", func(t *testing.T) { deps := BaseDeps{ - Client: github.NewClient(makeMockClient(false, http.StatusInternalServerError)), + Client: mustNewGHClient(t, makeMockClient(false, http.StatusInternalServerError)), Flags: FeatureFlags{InsidersMode: true}, } handler := serverTool.Handler(deps) @@ -461,7 +461,7 @@ func Test_AddIssueComment(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -763,7 +763,7 @@ func Test_SearchIssues(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -868,7 +868,7 @@ func Test_SearchIssues_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode disabled omits ifc label", func(t *testing.T) { searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{makeIssue("octocat", "public-repo", 1)}} deps := BaseDeps{ - Client: github.NewClient(makeMockClient(searchResult, []repoFixture{{owner: "octocat", repo: "public-repo"}})), + Client: mustNewGHClient(t, makeMockClient(searchResult, []repoFixture{{owner: "octocat", repo: "public-repo"}})), Flags: FeatureFlags{InsidersMode: false}, } handler := serverTool.Handler(deps) @@ -883,7 +883,7 @@ func Test_SearchIssues_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode all public emits public untrusted", func(t *testing.T) { searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{makeIssue("octocat", "public-repo", 1)}} deps := BaseDeps{ - Client: github.NewClient(makeMockClient(searchResult, []repoFixture{{owner: "octocat", repo: "public-repo"}})), + Client: mustNewGHClient(t, makeMockClient(searchResult, []repoFixture{{owner: "octocat", repo: "public-repo"}})), Flags: FeatureFlags{InsidersMode: true}, } handler := serverTool.Handler(deps) @@ -905,7 +905,7 @@ func Test_SearchIssues_IFC_InsidersMode(t *testing.T) { makeIssue("octocat", "public-repo", 2), }} deps := BaseDeps{ - Client: github.NewClient(makeMockClient(searchResult, []repoFixture{ + Client: mustNewGHClient(t, makeMockClient(searchResult, []repoFixture{ {owner: "octocat", repo: "private-repo", isPrivate: true}, {owner: "octocat", repo: "public-repo"}, })), @@ -927,7 +927,7 @@ func Test_SearchIssues_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode skips ifc label when visibility lookup fails", func(t *testing.T) { searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{makeIssue("octocat", "broken", 1)}} deps := BaseDeps{ - Client: github.NewClient(makeMockClient(searchResult, []repoFixture{ + Client: mustNewGHClient(t, makeMockClient(searchResult, []repoFixture{ {owner: "octocat", repo: "broken", repoStatus: http.StatusInternalServerError}, })), Flags: FeatureFlags{InsidersMode: true}, @@ -948,7 +948,7 @@ func Test_SearchIssues_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode empty results emits public untrusted", func(t *testing.T) { searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{}} deps := BaseDeps{ - Client: github.NewClient(makeMockClient(searchResult, nil)), + Client: mustNewGHClient(t, makeMockClient(searchResult, nil)), Flags: FeatureFlags{InsidersMode: true}, } handler := serverTool.Handler(deps) @@ -1090,7 +1090,7 @@ func Test_CreateIssue(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) gqlClient := githubv4.NewClient(nil) deps := BaseDeps{ Client: client, @@ -1144,7 +1144,7 @@ func Test_IssueWrite_InsidersMode_UIGate(t *testing.T) { serverTool := IssueWrite(translations.NullTranslationHelper) - client := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PostReposIssuesByOwnerByRepo: mockResponse(t, http.StatusCreated, mockIssue), })) @@ -1226,7 +1226,7 @@ func Test_IssueWrite_InsidersMode_UIGate(t *testing.T) { }) completedReason := IssueClosedStateReasonCompleted - closeClient := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + closeClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue), })) closeGQLClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( @@ -2191,7 +2191,7 @@ func Test_UpdateIssue(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup clients with mocks - restClient := github.NewClient(tc.mockedRESTClient) + restClient := mustNewGHClient(t, tc.mockedRESTClient) gqlClient := githubv4.NewClient(tc.mockedGQLClient) deps := BaseDeps{ Client: restClient, @@ -2417,7 +2417,7 @@ func Test_GetIssueComments(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) var restClient *github.Client if tc.lockdownEnabled { restClient = mockRESTPermissionServer(t, "read", map[string]string{ @@ -2546,7 +2546,7 @@ func Test_GetIssueLabels(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { gqlClient := githubv4.NewClient(tc.mockedClient) - client := github.NewClient(nil) + client := mustNewGHClient(t, nil) deps := BaseDeps{ Client: client, GQLClient: gqlClient, @@ -2753,7 +2753,7 @@ func Test_AddSubIssue(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -2974,7 +2974,7 @@ func Test_GetSubIssues(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) gqlClient := githubv4.NewClient(nil) deps := BaseDeps{ Client: client, @@ -3193,7 +3193,7 @@ func Test_RemoveSubIssue(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -3453,7 +3453,7 @@ func Test_ReprioritizeSubIssue(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -3569,7 +3569,7 @@ func Test_ListIssueTypes(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index bc9e25d1e..65a18ade8 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -3,7 +3,7 @@ package github import ( "time" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/github/github-mcp-server/pkg/sanitize" ) diff --git a/pkg/github/notifications.go b/pkg/github/notifications.go index ddd302393..61d8f40b2 100644 --- a/pkg/github/notifications.go +++ b/pkg/github/notifications.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "net/http" - "strconv" "time" ghErrors "github.com/github/github-mcp-server/pkg/errors" @@ -14,7 +13,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -209,13 +208,7 @@ func DismissNotification(t translations.TranslationHelperFunc) inventory.ServerT var resp *github.Response switch state { case "done": - // for some inexplicable reason, the API seems to have threadID as int64 and string depending on the endpoint - var threadIDInt int64 - threadIDInt, err = strconv.ParseInt(threadID, 10, 64) - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid threadID format: %v", err)), nil, nil - } - resp, err = client.Activity.MarkThreadDone(ctx, threadIDInt) + resp, err = client.Activity.MarkThreadDone(ctx, threadID) case "read": resp, err = client.Activity.MarkThreadRead(ctx, threadID) default: diff --git a/pkg/github/notifications_test.go b/pkg/github/notifications_test.go index 030367d06..bcfc28abc 100644 --- a/pkg/github/notifications_test.go +++ b/pkg/github/notifications_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -108,7 +108,7 @@ func Test_ListNotifications(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -232,7 +232,7 @@ func Test_ManageNotificationSubscription(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -386,7 +386,7 @@ func Test_ManageRepositoryNotificationSubscription(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -456,7 +456,6 @@ func Test_DismissNotification(t *testing.T) { expectError bool expectRead bool expectDone bool - expectInvalid bool expectedErrMsg string }{ { @@ -495,16 +494,6 @@ func Test_DismissNotification(t *testing.T) { expectError: false, expectDone: true, }, - { - name: "invalid threadID format", - mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), - requestArgs: map[string]any{ - "threadID": "notanumber", - "state": "done", - }, - expectError: false, - expectInvalid: true, - }, { name: "missing required threadID", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), @@ -534,7 +523,7 @@ func Test_DismissNotification(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -552,8 +541,6 @@ func Test_DismissNotification(t *testing.T) { assert.Contains(t, text, "missing required parameter: threadID") case tc.requestArgs["state"] == nil: assert.Contains(t, text, "missing required parameter: state") - case tc.name == "invalid threadID format": - assert.Contains(t, text, "invalid threadID format") case tc.name == "invalid state value": assert.Contains(t, text, "Invalid state. Must be one of: read, done.") default: @@ -571,9 +558,6 @@ func Test_DismissNotification(t *testing.T) { if tc.expectDone { assert.Contains(t, textContent.Text, "Notification marked as done") } - if tc.expectInvalid { - assert.Contains(t, textContent.Text, "invalid threadID format") - } }) } } @@ -647,7 +631,7 @@ func Test_MarkAllNotificationsRead(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -725,7 +709,7 @@ func Test_GetNotificationDetails(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } diff --git a/pkg/github/params.go b/pkg/github/params.go index 1b45d61bd..ecdc8c354 100644 --- a/pkg/github/params.go +++ b/pkg/github/params.go @@ -6,7 +6,7 @@ import ( "math" "strconv" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" ) diff --git a/pkg/github/params_test.go b/pkg/github/params_test.go index 2254b737e..b00efeb10 100644 --- a/pkg/github/params_test.go +++ b/pkg/github/params_test.go @@ -5,7 +5,7 @@ import ( "math" "testing" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/stretchr/testify/assert" ) diff --git a/pkg/github/projects.go b/pkg/github/projects.go index dcb9193ec..a5953f3be 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -13,7 +13,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" @@ -618,16 +618,11 @@ func listProjects(ctx context.Context, client *github.Client, args map[string]an var resp *github.Response var projects []*github.ProjectV2 - var queryPtr *string - - if queryStr != "" { - queryPtr = &queryStr - } minimalProjects := []MinimalProject{} opts := &github.ListProjectsOptions{ ListProjectsPaginationOptions: pagination, - Query: queryPtr, + Query: queryStr, } // If owner_type not provided, fetch from both user and org @@ -801,17 +796,12 @@ func listProjectItems(ctx context.Context, client *github.Client, args map[strin var resp *github.Response var projectItems []*github.ProjectV2Item - var queryPtr *string - - if queryStr != "" { - queryPtr = &queryStr - } opts := &github.ListProjectItemsOptions{ Fields: fields, ListProjectsOptions: github.ListProjectsOptions{ ListProjectsPaginationOptions: pagination, - Query: queryPtr, + Query: queryStr, }, } @@ -1387,16 +1377,9 @@ func extractPaginationOptionsFromArgs(args map[string]any) (github.ListProjectsP } opts := github.ListProjectsPaginationOptions{ - PerPage: &perPage, - } - - // Only set After/Before if they have non-empty values - if after != "" { - opts.After = &after - } - - if before != "" { - opts.Before = &before + PerPage: perPage, + After: after, + Before: before, } return opts, nil diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 9b0e07292..512506476 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -9,7 +9,6 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - gh "github.com/google/go-github/v82/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -100,7 +99,7 @@ func Test_ProjectsList_ListProjects(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := gh.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -140,7 +139,7 @@ func Test_ProjectsList_ListProjectFields(t *testing.T) { GetOrgsProjectsV2FieldsByProject: mockResponse(t, http.StatusOK, fields), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -167,7 +166,7 @@ func Test_ProjectsList_ListProjectFields(t *testing.T) { t.Run("missing project_number", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -196,7 +195,7 @@ func Test_ProjectsList_ListProjectItems(t *testing.T) { GetOrgsProjectsV2ItemsByProject: mockResponse(t, http.StatusOK, items), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -249,7 +248,7 @@ func Test_ProjectsGet_GetProject(t *testing.T) { GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, project), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -274,7 +273,7 @@ func Test_ProjectsGet_GetProject(t *testing.T) { t.Run("unknown method", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -304,7 +303,7 @@ func Test_ProjectsGet_GetProjectField(t *testing.T) { GetOrgsProjectsV2FieldsByProjectByFieldID: mockResponse(t, http.StatusOK, field), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -330,7 +329,7 @@ func Test_ProjectsGet_GetProjectField(t *testing.T) { t.Run("missing field_id", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -360,7 +359,7 @@ func Test_ProjectsGet_GetProjectItem(t *testing.T) { GetOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, item), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -386,7 +385,7 @@ func Test_ProjectsGet_GetProjectItem(t *testing.T) { t.Run("missing item_id", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -711,7 +710,7 @@ func Test_ProjectsWrite_UpdateProjectItem(t *testing.T) { PatchOrgsProjectsV2ItemsByProjectByItemID: mockResponse(t, http.StatusOK, updatedItem), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -741,7 +740,7 @@ func Test_ProjectsWrite_UpdateProjectItem(t *testing.T) { t.Run("missing updated_field", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -772,7 +771,7 @@ func Test_ProjectsWrite_DeleteProjectItem(t *testing.T) { }), }) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -795,7 +794,7 @@ func Test_ProjectsWrite_DeleteProjectItem(t *testing.T) { t.Run("missing item_id", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}) - client := gh.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) deps := BaseDeps{ Client: client, } @@ -864,7 +863,7 @@ func Test_ProjectsList_ListProjectStatusUpdates(t *testing.T) { gqlClient := githubv4.NewClient(gqlMockedClient) deps := BaseDeps{ - Client: gh.NewClient(restClient), + Client: mustNewGHClient(t, restClient), GQLClient: gqlClient, } handler := toolDef.Handler(deps) diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 0065b25a9..3653c906b 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -8,7 +8,7 @@ import ( "net/http" "github.com/go-viper/mapstructure/v2" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/pullrequests_granular.go b/pkg/github/pullrequests_granular.go index 4a616f1b2..30d7f78d6 100644 --- a/pkg/github/pullrequests_granular.go +++ b/pkg/github/pullrequests_granular.go @@ -12,7 +12,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - gogithub "github.com/google/go-github/v82/github" + gogithub "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 36a0207cc..29339ee7d 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -10,7 +10,7 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -95,7 +95,7 @@ func Test_GetPullRequest(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient()) deps := BaseDeps{ Client: client, @@ -327,7 +327,7 @@ func Test_UpdatePullRequest(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) gqlClient := githubv4.NewClient(nil) deps := BaseDeps{ Client: client, @@ -511,7 +511,7 @@ func Test_UpdatePullRequest_Draft(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // For draft-only tests, we need to mock both GraphQL and the final REST GET call - restClient := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + restClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockUpdatedPR), })) gqlClient := githubv4.NewClient(tc.mockedClient) @@ -641,7 +641,7 @@ func Test_ListPullRequests(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := ListPullRequests(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -759,7 +759,7 @@ func Test_MergePullRequest(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := MergePullRequest(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -1038,7 +1038,7 @@ func Test_SearchPullRequests(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := SearchPullRequests(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -1197,7 +1197,7 @@ func Test_GetPullRequestFiles(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := PullRequestRead(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -1357,7 +1357,7 @@ func Test_GetPullRequestStatus(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := PullRequestRead(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -1513,7 +1513,7 @@ func Test_GetPullRequestCheckRuns(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := PullRequestRead(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -1641,7 +1641,7 @@ func Test_UpdatePullRequestBranch(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := UpdatePullRequestBranch(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -1949,7 +1949,7 @@ func Test_GetPullRequestComments(t *testing.T) { flags := stubFeatureFlags(map[string]bool{"lockdown-mode": tc.lockdownEnabled}) serverTool := PullRequestRead(translations.NullTranslationHelper) deps := BaseDeps{ - Client: github.NewClient(nil), + Client: mustNewGHClient(t, nil), GQLClient: gqlClient, RepoAccessCache: cache, Flags: flags, @@ -2133,7 +2133,7 @@ func Test_GetPullRequestReviews(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) var restClient *github.Client if tc.lockdownEnabled { restClient = mockRESTPermissionServer(t, "read", map[string]string{ @@ -2300,7 +2300,7 @@ func Test_CreatePullRequest(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := CreatePullRequest(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -2356,7 +2356,7 @@ func Test_CreatePullRequest_InsidersMode_UIGate(t *testing.T) { serverTool := CreatePullRequest(translations.NullTranslationHelper) - client := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PostReposPullsByOwnerByRepo: mockResponse(t, http.StatusCreated, mockPR), })) @@ -3372,7 +3372,7 @@ index 5d6e7b2..8a4f5c3 100644 t.Parallel() // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := PullRequestRead(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -3609,7 +3609,7 @@ func TestAddReplyToPullRequestComment(t *testing.T) { t.Parallel() // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) serverTool := AddReplyToPullRequestComment(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 156df3dd3..2ca1cf3a7 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -16,7 +16,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/repositories_helper.go b/pkg/github/repositories_helper.go index a347ebdd6..be377f773 100644 --- a/pkg/github/repositories_helper.go +++ b/pkg/github/repositories_helper.go @@ -10,7 +10,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index d90a01069..a44bad65b 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -14,7 +14,7 @@ import ( "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" @@ -412,8 +412,9 @@ func Test_GetFileContents(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) - mockRawClient := raw.NewClient(client, &url.URL{Scheme: "https", Host: "raw.example.com", Path: "/"}) + client := mustNewGHClient(t, tc.mockedClient) + mockRawClient, err := raw.NewClient(client, &url.URL{Scheme: "https", Host: "raw.example.com", Path: "/"}) + require.NoError(t, err) deps := BaseDeps{ Client: client, RawClient: mockRawClient, @@ -519,7 +520,7 @@ func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode disabled omits ifc label from result meta", func(t *testing.T) { deps := BaseDeps{ - Client: github.NewClient(makeMockClient(false)), + Client: mustNewGHClient(t, makeMockClient(false)), Flags: FeatureFlags{InsidersMode: false}, } handler := serverTool.Handler(deps) @@ -534,7 +535,7 @@ func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode enabled on public repo emits public untrusted label", func(t *testing.T) { deps := BaseDeps{ - Client: github.NewClient(makeMockClient(false)), + Client: mustNewGHClient(t, makeMockClient(false)), Flags: FeatureFlags{InsidersMode: true}, } handler := serverTool.Handler(deps) @@ -559,7 +560,7 @@ func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode enabled on private repo emits private trusted label", func(t *testing.T) { deps := BaseDeps{ - Client: github.NewClient(makeMockClient(true)), + Client: mustNewGHClient(t, makeMockClient(true)), Flags: FeatureFlags{InsidersMode: true}, } handler := serverTool.Handler(deps) @@ -603,7 +604,7 @@ func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { }, }) deps := BaseDeps{ - Client: github.NewClient(mockedClient), + Client: mustNewGHClient(t, mockedClient), Flags: FeatureFlags{InsidersMode: true}, } handler := serverTool.Handler(deps) @@ -690,7 +691,7 @@ func Test_ForkRepository(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -862,7 +863,7 @@ func Test_CreateBranch(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -988,7 +989,7 @@ func Test_GetCommit(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -1279,7 +1280,7 @@ func Test_ListCommits(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -1636,7 +1637,7 @@ func Test_CreateOrUpdateFile(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -1825,7 +1826,7 @@ func Test_CreateRepository(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -2563,7 +2564,7 @@ func Test_PushFiles(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -2684,7 +2685,7 @@ func Test_ListBranches(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create mock client - mockClient := github.NewClient(NewMockedHTTPClient(tt.mockResponses...)) + mockClient := mustNewGHClient(t, NewMockedHTTPClient(tt.mockResponses...)) deps := BaseDeps{ Client: mockClient, } @@ -2872,7 +2873,7 @@ func Test_DeleteFile(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -2999,7 +3000,7 @@ func Test_ListTags(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -3190,7 +3191,7 @@ func Test_GetTag(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -3316,7 +3317,7 @@ func Test_ListReleases(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -3408,7 +3409,7 @@ func Test_GetLatestRelease(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -3556,7 +3557,7 @@ func Test_GetReleaseByTag(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -4001,7 +4002,7 @@ func Test_resolveGitReference(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockSetup()) + client := mustNewGHClient(t, tc.mockSetup()) opts, _, err := resolveGitReference(ctx, client, owner, repo, tc.ref, tc.sha) if tc.expectError { @@ -4147,7 +4148,7 @@ func Test_ListStarredRepositories(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -4248,7 +4249,7 @@ func Test_StarRepository(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -4339,7 +4340,7 @@ func Test_UnstarRepository(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -4468,7 +4469,7 @@ func Test_ListRepositoryCollaborators(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - mockClient := github.NewClient(NewMockedHTTPClient(tt.mockResponses...)) + mockClient := mustNewGHClient(t, NewMockedHTTPClient(tt.mockResponses...)) deps := BaseDeps{ Client: mockClient, } diff --git a/pkg/github/repository_resource.go b/pkg/github/repository_resource.go index be86cc451..3ab4cf390 100644 --- a/pkg/github/repository_resource.go +++ b/pkg/github/repository_resource.go @@ -17,7 +17,7 @@ import ( "github.com/github/github-mcp-server/pkg/octicons" "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/yosida95/uritemplate/v3" ) diff --git a/pkg/github/repository_resource_completions.go b/pkg/github/repository_resource_completions.go index ff9e23398..18e7eb5f0 100644 --- a/pkg/github/repository_resource_completions.go +++ b/pkg/github/repository_resource_completions.go @@ -6,7 +6,7 @@ import ( "fmt" "strings" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/repository_resource_completions_test.go b/pkg/github/repository_resource_completions_test.go index e5f1a35f9..33df2761e 100644 --- a/pkg/github/repository_resource_completions_test.go +++ b/pkg/github/repository_resource_completions_test.go @@ -6,7 +6,7 @@ import ( "fmt" "testing" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/repository_resource_test.go b/pkg/github/repository_resource_test.go index f0fba30df..cb57bae54 100644 --- a/pkg/github/repository_resource_test.go +++ b/pkg/github/repository_resource_test.go @@ -8,7 +8,6 @@ import ( "testing" "github.com/github/github-mcp-server/pkg/raw" - "github.com/google/go-github/v82/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" ) @@ -246,8 +245,9 @@ func Test_repositoryResourceContents(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) - mockRawClient := raw.NewClient(client, base) + client := mustNewGHClient(t, tc.mockedClient) + mockRawClient, err := raw.NewClient(client, base) + require.NoError(t, err) deps := BaseDeps{ Client: client, RawClient: mockRawClient, @@ -290,8 +290,9 @@ func Test_repositoryResourceContentsHandler_NetworkError(t *testing.T) { networkErr := errors.New("network error: connection refused") httpClient := &http.Client{Transport: &errorTransport{err: networkErr}} - client := github.NewClient(httpClient) - mockRawClient := raw.NewClient(client, base) + client := mustNewGHClient(t, httpClient) + mockRawClient, err := raw.NewClient(client, base) + require.NoError(t, err) deps := BaseDeps{ Client: client, RawClient: mockRawClient, diff --git a/pkg/github/search.go b/pkg/github/search.go index a44add8bb..a4acc4448 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/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index 13e787a67..74a3ca52f 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -123,7 +123,7 @@ func Test_SearchRepositories(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -205,7 +205,7 @@ func Test_SearchRepositories_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode disabled omits ifc label", func(t *testing.T) { deps := BaseDeps{ - Client: github.NewClient(makeMockClient([]repoFixture{{owner: "octocat", name: "public-repo"}})), + Client: mustNewGHClient(t, makeMockClient([]repoFixture{{owner: "octocat", name: "public-repo"}})), Flags: FeatureFlags{InsidersMode: false}, } handler := serverTool.Handler(deps) @@ -219,7 +219,7 @@ func Test_SearchRepositories_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode all public emits public untrusted", func(t *testing.T) { deps := BaseDeps{ - Client: github.NewClient(makeMockClient([]repoFixture{ + Client: mustNewGHClient(t, makeMockClient([]repoFixture{ {owner: "octocat", name: "public-a"}, {owner: "octocat", name: "public-b"}, })), @@ -240,7 +240,7 @@ func Test_SearchRepositories_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode any private match emits private untrusted", func(t *testing.T) { deps := BaseDeps{ - Client: github.NewClient(makeMockClient([]repoFixture{ + Client: mustNewGHClient(t, makeMockClient([]repoFixture{ {owner: "octocat", name: "private-repo", isPrivate: true}, {owner: "octocat", name: "public-repo"}, })), @@ -261,7 +261,7 @@ func Test_SearchRepositories_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode empty results emits public untrusted", func(t *testing.T) { deps := BaseDeps{ - Client: github.NewClient(makeMockClient(nil)), + Client: mustNewGHClient(t, makeMockClient(nil)), Flags: FeatureFlags{InsidersMode: true}, } handler := serverTool.Handler(deps) @@ -304,7 +304,7 @@ func Test_SearchRepositories_FullOutput(t *testing.T) { ), }) - client := github.NewClient(mockedClient) + client := mustNewGHClient(t, mockedClient) serverTool := SearchRepositories(translations.NullTranslationHelper) deps := BaseDeps{ Client: client, @@ -458,7 +458,7 @@ func Test_SearchCode(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -649,7 +649,7 @@ func Test_SearchUsers(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -812,7 +812,7 @@ func Test_SearchOrgs(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go index a0634d979..ac3aec90c 100644 --- a/pkg/github/search_utils.go +++ b/pkg/github/search_utils.go @@ -10,7 +10,7 @@ import ( ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/secret_scanning.go b/pkg/github/secret_scanning.go index 676c2c162..5cbe52c42 100644 --- a/pkg/github/secret_scanning.go +++ b/pkg/github/secret_scanning.go @@ -12,7 +12,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/secret_scanning_test.go b/pkg/github/secret_scanning_test.go index 7c53de35c..1aa451e05 100644 --- a/pkg/github/secret_scanning_test.go +++ b/pkg/github/secret_scanning_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -79,7 +79,7 @@ func Test_GetSecretScanningAlert(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } @@ -211,7 +211,7 @@ func Test_ListSecretScanningAlerts(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{ Client: client, } diff --git a/pkg/github/security_advisories.go b/pkg/github/security_advisories.go index e86e220ea..ec84e27b1 100644 --- a/pkg/github/security_advisories.go +++ b/pkg/github/security_advisories.go @@ -12,7 +12,7 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/security_advisories_test.go b/pkg/github/security_advisories_test.go index 3d4df43e6..f45c2e421 100644 --- a/pkg/github/security_advisories_test.go +++ b/pkg/github/security_advisories_test.go @@ -8,7 +8,7 @@ import ( "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -92,7 +92,7 @@ func Test_ListGlobalSecurityAdvisories(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} handler := toolDef.Handler(deps) @@ -204,7 +204,7 @@ func Test_GetGlobalSecurityAdvisory(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} handler := toolDef.Handler(deps) @@ -337,7 +337,7 @@ func Test_ListRepositorySecurityAdvisories(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} handler := toolDef.Handler(deps) @@ -467,7 +467,7 @@ func Test_ListOrgRepositorySecurityAdvisories(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - client := github.NewClient(tc.mockedClient) + client := mustNewGHClient(t, tc.mockedClient) deps := BaseDeps{Client: client} handler := toolDef.Handler(deps) diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go index 264ffa50f..7af388f73 100644 --- a/pkg/github/server_test.go +++ b/pkg/github/server_test.go @@ -16,7 +16,7 @@ import ( "github.com/github/github-mcp-server/pkg/observability/metrics" "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/translations" - gogithub "github.com/google/go-github/v82/github" + gogithub "github.com/google/go-github/v87/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -80,9 +80,10 @@ func stubExporters() observability.Exporters { return obs } -func stubClientFnFromHTTP(httpClient *http.Client) func(context.Context) (*gogithub.Client, error) { +func stubClientFnFromHTTP(t *testing.T, httpClient *http.Client) func(context.Context) (*gogithub.Client, error) { + t.Helper() return func(_ context.Context) (*gogithub.Client, error) { - return gogithub.NewClient(httpClient), nil + return mustNewGHClient(t, httpClient), nil } } @@ -110,7 +111,7 @@ func stubRepoAccessCache(restClient *gogithub.Client, ttl time.Duration) *lockdo func mockRESTPermissionServer(t *testing.T, defaultPerm string, overrides map[string]string) *gogithub.Client { t.Helper() - return gogithub.NewClient(MockHTTPClientWithHandler(func(w http.ResponseWriter, r *http.Request) { + return mustNewGHClient(t, MockHTTPClientWithHandler(func(w http.ResponseWriter, r *http.Request) { perm := defaultPerm for user, p := range overrides { if strings.Contains(r.URL.Path, "/collaborators/"+user+"/") { diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 413955323..f4c653bf8 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -7,7 +7,7 @@ import ( "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/shurcooL/githubv4" ) diff --git a/pkg/lockdown/lockdown.go b/pkg/lockdown/lockdown.go index 6edb4469d..f787875b2 100644 --- a/pkg/lockdown/lockdown.go +++ b/pkg/lockdown/lockdown.go @@ -8,7 +8,7 @@ import ( "sync" "time" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/muesli/cache2go" "github.com/shurcooL/githubv4" ) diff --git a/pkg/lockdown/lockdown_test.go b/pkg/lockdown/lockdown_test.go index 55e755a3e..bb8887e70 100644 --- a/pkg/lockdown/lockdown_test.go +++ b/pkg/lockdown/lockdown_test.go @@ -4,13 +4,12 @@ import ( "encoding/json" "net/http" "net/http/httptest" - "net/url" "sync" "testing" "time" "github.com/github/github-mcp-server/internal/githubv4mock" - gogithub "github.com/google/go-github/v82/github" + gogithub "github.com/google/go-github/v87/github" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/require" ) @@ -81,8 +80,8 @@ func newMockRepoAccessCache(t *testing.T, ttl time.Duration) (*RepoAccessCache, _ = json.NewEncoder(w).Encode(resp) })) t.Cleanup(restServer.Close) - restClient := gogithub.NewClient(nil) - restClient.BaseURL, _ = url.Parse(restServer.URL + "/") + restClient, err := gogithub.NewClient(gogithub.WithEnterpriseURLs(restServer.URL+"/", restServer.URL+"/")) + require.NoError(t, err) return NewRepoAccessCache(gqlClient, restClient, WithTTL(ttl)), counting } diff --git a/pkg/raw/raw.go b/pkg/raw/raw.go index df9cd0ad1..4f794ac1f 100644 --- a/pkg/raw/raw.go +++ b/pkg/raw/raw.go @@ -6,7 +6,7 @@ import ( "net/http" "net/url" - gogithub "github.com/google/go-github/v82/github" + gogithub "github.com/google/go-github/v87/github" ) // GetRawClientFn is a function type that returns a RawClient instance. @@ -19,19 +19,19 @@ type Client struct { } // NewClient creates a new instance of the raw API Client with the provided GitHub client and provided URL. -func NewClient(client *gogithub.Client, rawURL *url.URL) *Client { - client = gogithub.NewClient(client.Client()) - client.BaseURL = rawURL - return &Client{client: client, url: rawURL} -} - -func (c *Client) newRequest(ctx context.Context, method string, urlStr string, body any, opts ...gogithub.RequestOption) (*http.Request, error) { - req, err := c.client.NewRequest(method, urlStr, body, opts...) +func NewClient(client *gogithub.Client, rawURL *url.URL) (*Client, error) { + newClient, err := gogithub.NewClient( + gogithub.WithHTTPClient(client.Client()), + gogithub.WithEnterpriseURLs(rawURL.String(), rawURL.String()), + ) if err != nil { return nil, err } - req = req.WithContext(ctx) - return req, nil + return &Client{client: newClient, url: rawURL}, nil +} + +func (c *Client) newRequest(ctx context.Context, method string, urlStr string, body any, opts ...gogithub.RequestOption) (*http.Request, error) { + return c.client.NewRequest(ctx, method, urlStr, body, opts...) } func (c *Client) refURL(owner, repo, ref, path string) string { diff --git a/pkg/raw/raw_test.go b/pkg/raw/raw_test.go index 6897f492f..60137684d 100644 --- a/pkg/raw/raw_test.go +++ b/pkg/raw/raw_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "github.com/google/go-github/v82/github" + "github.com/google/go-github/v87/github" "github.com/stretchr/testify/require" ) @@ -108,8 +108,10 @@ func TestGetRawContent(t *testing.T) { body: tc.body, }, } - ghClient := github.NewClient(mockedClient) - client := NewClient(ghClient, base) + ghClient, err := github.NewClient(github.WithHTTPClient(mockedClient)) + require.NoError(t, err) + client, err := NewClient(ghClient, base) + require.NoError(t, err) resp, err := client.GetRawContent(context.Background(), tc.owner, tc.repo, tc.path, tc.opts) defer func() { _ = resp.Body.Close() @@ -133,8 +135,10 @@ func TestGetRawContent(t *testing.T) { func TestUrlFromOpts(t *testing.T) { base, _ := url.Parse("https://raw.example.com/") - ghClient := github.NewClient(nil) - client := NewClient(ghClient, base) + ghClient, err := github.NewClient(github.WithHTTPClient(&http.Client{})) + require.NoError(t, err) + client, err := NewClient(ghClient, base) + require.NoError(t, err) tests := []struct { name string diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index 2e5ca59ec..2aebd6fa0 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -17,7 +17,7 @@ The following packages are included for the amd64, arm64 architectures. - [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.2.5/LICENSE)) - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.5.0/LICENSE)) - - [github.com/google/go-github/v82/github](https://pkg.go.dev/github.com/google/go-github/v82/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v82.0.0/LICENSE)) + - [github.com/google/go-github/v87/github](https://pkg.go.dev/github.com/google/go-github/v87/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v87.0.0/LICENSE)) - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.2.0/LICENSE)) - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.2/LICENSE)) - [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index d81846989..4e6865667 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -17,7 +17,7 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.2.5/LICENSE)) - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.5.0/LICENSE)) - - [github.com/google/go-github/v82/github](https://pkg.go.dev/github.com/google/go-github/v82/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v82.0.0/LICENSE)) + - [github.com/google/go-github/v87/github](https://pkg.go.dev/github.com/google/go-github/v87/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v87.0.0/LICENSE)) - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.2.0/LICENSE)) - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.2/LICENSE)) - [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index 6efed3338..91b314dcb 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -17,7 +17,7 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.2.5/LICENSE)) - [github.com/go-viper/mapstructure/v2](https://pkg.go.dev/github.com/go-viper/mapstructure/v2) ([MIT](https://github.com/go-viper/mapstructure/blob/v2.5.0/LICENSE)) - - [github.com/google/go-github/v82/github](https://pkg.go.dev/github.com/google/go-github/v82/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v82.0.0/LICENSE)) + - [github.com/google/go-github/v87/github](https://pkg.go.dev/github.com/google/go-github/v87/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v87.0.0/LICENSE)) - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.2.0/LICENSE)) - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.2/LICENSE)) - [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) diff --git a/third-party/github.com/google/go-github/v82/github/LICENSE b/third-party/github.com/google/go-github/v87/github/LICENSE similarity index 100% rename from third-party/github.com/google/go-github/v82/github/LICENSE rename to third-party/github.com/google/go-github/v87/github/LICENSE From 754c64ca888540d40eae81723fc23c80c1de5d85 Mon Sep 17 00:00:00 2001 From: Iulia B Date: Tue, 12 May 2026 13:56:41 +0000 Subject: [PATCH 02/48] add support for fields in issue read --- pkg/github/issues_test.go | 84 +++++++++++++++++++++++++++++++++++++ pkg/github/minimal_types.go | 77 +++++++++++++++++++++++++--------- 2 files changed, 141 insertions(+), 20 deletions(-) diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 6b4042bac..30381d303 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -392,6 +392,90 @@ func Test_IssueRead_IFC_InsidersMode(t *testing.T) { }) } +func Test_GetIssue_FieldValues(t *testing.T) { + // Verify that issue_field_values from the REST API are present in the returned object. + serverTool := IssueRead(translations.NullTranslationHelper) + + mockIssueWithFields := &github.Issue{ + Number: github.Ptr(99), + Title: github.Ptr("Issue with field values"), + Body: github.Ptr("body"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/99"), + User: &github.User{ + Login: github.Ptr("testuser"), + }, + IssueFieldValues: []*github.IssueFieldValue{ + { + IssueFieldID: 1001, + NodeID: "FV_node_1", + DataType: "single_select", + Value: "High", + SingleSelectOption: &github.IssueFieldValueSingleSelectOption{ + ID: 42, + Name: "High", + Color: "red", + }, + }, + { + IssueFieldID: 1002, + NodeID: "FV_node_2", + DataType: "text", + Value: "some text value", + }, + }, + } + + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockIssueWithFields), + }) + + cache := stubRepoAccessCache(nil, 15*time.Minute) + flags := stubFeatureFlags(map[string]bool{"lockdown-mode": false}) + deps := BaseDeps{ + Client: mustNewGHClient(t, mockedClient), + GQLClient: defaultGQLClient, + RepoAccessCache: cache, + Flags: flags, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "method": "get", + "owner": "owner", + "repo": "repo", + "issue_number": float64(99), + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.NotNil(t, result) + + textContent := getTextResult(t, result) + + var returnedIssue MinimalIssue + err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) + require.NoError(t, err) + + require.Len(t, returnedIssue.IssueFieldValues, 2, "expected two issue field values") + + first := returnedIssue.IssueFieldValues[0] + assert.Equal(t, int64(1001), first.IssueFieldID) + assert.Equal(t, "FV_node_1", first.NodeID) + assert.Equal(t, "single_select", first.DataType) + assert.Equal(t, "High", first.Value) + require.NotNil(t, first.SingleSelectOption) + assert.Equal(t, int64(42), first.SingleSelectOption.ID) + assert.Equal(t, "High", first.SingleSelectOption.Name) + assert.Equal(t, "red", first.SingleSelectOption.Color) + + second := returnedIssue.IssueFieldValues[1] + assert.Equal(t, int64(1002), second.IssueFieldID) + assert.Equal(t, "FV_node_2", second.NodeID) + assert.Equal(t, "text", second.DataType) + assert.Equal(t, "some text value", second.Value) + assert.Nil(t, second.SingleSelectOption) +} + func Test_AddIssueComment(t *testing.T) { // Verify tool definition once serverTool := AddIssueComment(translations.NullTranslationHelper) diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 65a18ade8..7ad718a21 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -201,28 +201,45 @@ type MinimalReactions struct { Eyes int `json:"eyes"` } +// MinimalIssueFieldValueSingleSelectOption is the trimmed output type for a single-select option of an issue field value. +type MinimalIssueFieldValueSingleSelectOption struct { + ID int64 `json:"id"` + Name string `json:"name"` + Color string `json:"color"` +} + +// MinimalIssueFieldValue is the trimmed output type for a custom field value attached to an issue. +type MinimalIssueFieldValue struct { + IssueFieldID int64 `json:"issue_field_id"` + NodeID string `json:"node_id"` + DataType string `json:"data_type"` + Value any `json:"value"` + SingleSelectOption *MinimalIssueFieldValueSingleSelectOption `json:"single_select_option,omitempty"` +} + // MinimalIssue is the trimmed output type for issue objects to reduce verbosity. type MinimalIssue struct { - Number int `json:"number"` - Title string `json:"title"` - Body string `json:"body,omitempty"` - State string `json:"state"` - StateReason string `json:"state_reason,omitempty"` - Draft bool `json:"draft,omitempty"` - Locked bool `json:"locked,omitempty"` - HTMLURL string `json:"html_url,omitempty"` - User *MinimalUser `json:"user,omitempty"` - AuthorAssociation string `json:"author_association,omitempty"` - Labels []string `json:"labels,omitempty"` - Assignees []string `json:"assignees,omitempty"` - Milestone string `json:"milestone,omitempty"` - Comments int `json:"comments,omitempty"` - Reactions *MinimalReactions `json:"reactions,omitempty"` - CreatedAt string `json:"created_at,omitempty"` - UpdatedAt string `json:"updated_at,omitempty"` - ClosedAt string `json:"closed_at,omitempty"` - ClosedBy string `json:"closed_by,omitempty"` - IssueType string `json:"issue_type,omitempty"` + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body,omitempty"` + State string `json:"state"` + StateReason string `json:"state_reason,omitempty"` + Draft bool `json:"draft,omitempty"` + Locked bool `json:"locked,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + User *MinimalUser `json:"user,omitempty"` + AuthorAssociation string `json:"author_association,omitempty"` + Labels []string `json:"labels,omitempty"` + Assignees []string `json:"assignees,omitempty"` + Milestone string `json:"milestone,omitempty"` + Comments int `json:"comments,omitempty"` + Reactions *MinimalReactions `json:"reactions,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + ClosedAt string `json:"closed_at,omitempty"` + ClosedBy string `json:"closed_by,omitempty"` + IssueType string `json:"issue_type,omitempty"` + IssueFieldValues []MinimalIssueFieldValue `json:"issue_field_values,omitempty"` } // MinimalIssuesResponse is the trimmed output for a paginated list of issues. @@ -400,6 +417,26 @@ func convertToMinimalIssue(issue *github.Issue) MinimalIssue { m.IssueType = issueType.GetName() } + for _, fv := range issue.IssueFieldValues { + if fv == nil { + continue + } + mfv := MinimalIssueFieldValue{ + IssueFieldID: fv.IssueFieldID, + NodeID: fv.NodeID, + DataType: fv.DataType, + Value: fv.Value, + } + if opt := fv.SingleSelectOption; opt != nil { + mfv.SingleSelectOption = &MinimalIssueFieldValueSingleSelectOption{ + ID: opt.ID, + Name: opt.Name, + Color: opt.Color, + } + } + m.IssueFieldValues = append(m.IssueFieldValues, mfv) + } + if r := issue.Reactions; r != nil { m.Reactions = &MinimalReactions{ TotalCount: r.GetTotalCount(), From 562f8656233af47bdfac8bcf3620cb63ea9c98c8 Mon Sep 17 00:00:00 2001 From: Iulia B Date: Tue, 12 May 2026 13:56:58 +0000 Subject: [PATCH 03/48] add support for fields in issues write --- pkg/github/__toolsnaps__/issue_write.snap | 23 +++ pkg/github/issues.go | 203 +++++++++++++++++++++- pkg/github/issues_test.go | 148 +++++++++++++++- 3 files changed, 365 insertions(+), 9 deletions(-) diff --git a/pkg/github/__toolsnaps__/issue_write.snap b/pkg/github/__toolsnaps__/issue_write.snap index 24cff5df9..599c490f1 100644 --- a/pkg/github/__toolsnaps__/issue_write.snap +++ b/pkg/github/__toolsnaps__/issue_write.snap @@ -29,6 +29,29 @@ "description": "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.", "type": "number" }, + "issue_fields": { + "description": "Issue field values to set. Each item requires field_name and either value or field_option_name. field_option_name is for single-select fields and is resolved to the corresponding option ID automatically.", + "items": { + "properties": { + "field_name": { + "description": "Issue field name", + "type": "string" + }, + "field_option_name": { + "description": "Single-select option name to resolve and set for the field", + "type": "string" + }, + "value": { + "description": "Value for text/number/date/single-select fields. For single-select, you can use field_option_name instead." + } + }, + "required": [ + "field_name" + ], + "type": "object" + }, + "type": "array" + }, "issue_number": { "description": "Issue number to update", "type": "number" diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 52a024c29..505a0ad9e 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -35,6 +35,36 @@ type CloseIssueInput struct { // Used to extend the functionality of the githubv4 library to support closing issues as duplicates. type IssueClosedStateReason string +// IssueWriteFieldInput is a user-friendly issue field input for issue_write. +// Field IDs and option IDs are resolved internally before calling the REST API. +type IssueWriteFieldInput struct { + FieldName string + Value any + FieldOptionName string +} + +type issueFieldMetadataOption struct { + DatabaseID githubv4.Int `graphql:"databaseId"` + Name githubv4.String +} + +type issueFieldMetadataNode struct { + DatabaseID githubv4.Int `graphql:"databaseId"` + Name githubv4.String + DataType githubv4.String + SingleSelectField struct { + Options []issueFieldMetadataOption `graphql:"options"` + } `graphql:"... on IssueFieldSingleSelect"` +} + +type issueFieldMetadataQuery struct { + Repository struct { + IssueFields struct { + Nodes []issueFieldMetadataNode + } `graphql:"issueFields(first: 100)"` + } `graphql:"repository(owner: $owner, name: $repo)"` +} + const ( IssueClosedStateReasonCompleted IssueClosedStateReason = "COMPLETED" IssueClosedStateReasonDuplicate IssueClosedStateReason = "DUPLICATE" @@ -103,6 +133,127 @@ func getCloseStateReason(stateReason string) IssueClosedStateReason { } } +func optionalIssueWriteFields(args map[string]any) ([]IssueWriteFieldInput, error) { + issueFieldsRaw, exists := args["issue_fields"] + if !exists { + return nil, nil + } + + var inputMaps []map[string]any + switch v := issueFieldsRaw.(type) { + case []any: + for _, item := range v { + itemMap, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("each issue_fields item must be an object") + } + inputMaps = append(inputMaps, itemMap) + } + case []map[string]any: + inputMaps = v + default: + return nil, fmt.Errorf("issue_fields must be an array") + } + + issueFields := make([]IssueWriteFieldInput, 0, len(inputMaps)) + for _, itemMap := range inputMaps { + fieldName, err := RequiredParam[string](itemMap, "field_name") + if err != nil || strings.TrimSpace(fieldName) == "" { + return nil, fmt.Errorf("field_name is required for each issue_fields item") + } + + fieldOptionName, err := OptionalParam[string](itemMap, "field_option_name") + if err != nil { + return nil, err + } + + value, hasValue := itemMap["value"] + if hasValue && value == nil { + return nil, fmt.Errorf("value cannot be null for field %q", fieldName) + } + + if hasValue && fieldOptionName != "" { + return nil, fmt.Errorf("issue field %q cannot specify both value and field_option_name", fieldName) + } + + if !hasValue && fieldOptionName == "" { + return nil, fmt.Errorf("issue field %q must specify either value or field_option_name", fieldName) + } + + issueFields = append(issueFields, IssueWriteFieldInput{ + FieldName: fieldName, + Value: value, + FieldOptionName: fieldOptionName, + }) + } + + return issueFields, nil +} + +func resolveIssueRequestFieldValues(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, issueFields []IssueWriteFieldInput) ([]*github.IssueRequestFieldValue, error) { + if len(issueFields) == 0 { + return nil, nil + } + + query := issueFieldMetadataQuery{} + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + } + if err := gqlClient.Query(ctx, &query, vars); err != nil { + return nil, fmt.Errorf("failed to query issue fields metadata: %w", err) + } + + fieldByName := make(map[string]issueFieldMetadataNode, len(query.Repository.IssueFields.Nodes)) + for _, field := range query.Repository.IssueFields.Nodes { + fieldByName[strings.ToLower(strings.TrimSpace(string(field.Name)))] = field + } + + resolved := make([]*github.IssueRequestFieldValue, 0, len(issueFields)) + for _, fieldInput := range issueFields { + field, ok := fieldByName[strings.ToLower(strings.TrimSpace(fieldInput.FieldName))] + if !ok { + return nil, fmt.Errorf("issue field %q was not found in %s/%s", fieldInput.FieldName, owner, repo) + } + + fieldID := int64(field.DatabaseID) + if fieldID == 0 { + return nil, fmt.Errorf("issue field %q is missing databaseId", fieldInput.FieldName) + } + + resolvedValue := fieldInput.Value + if fieldInput.FieldOptionName != "" { + if !strings.EqualFold(string(field.DataType), "single_select") { + return nil, fmt.Errorf("issue field %q is %q, so field_option_name cannot be used", fieldInput.FieldName, field.DataType) + } + + optionFound := false + for _, option := range field.SingleSelectField.Options { + if strings.EqualFold(strings.TrimSpace(string(option.Name)), strings.TrimSpace(fieldInput.FieldOptionName)) { + optionID := int64(option.DatabaseID) + if optionID == 0 { + return nil, fmt.Errorf("issue field option %q on field %q is missing databaseId", fieldInput.FieldOptionName, fieldInput.FieldName) + } + resolvedValue = optionID + optionFound = true + break + } + } + + if !optionFound { + return nil, fmt.Errorf("issue field option %q was not found for field %q", fieldInput.FieldOptionName, fieldInput.FieldName) + } + } + + resolved = append(resolved, &github.IssueRequestFieldValue{ + FieldID: fieldID, + Value: resolvedValue, + }) + } + + return resolved, nil +} + // IssueFragment represents a fragment of an issue node in the GraphQL API. type IssueFragment struct { Number githubv4.Int @@ -1171,6 +1322,27 @@ Options are: Type: "number", Description: "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.", }, + "issue_fields": { + Type: "array", + Description: "Issue field values to set. Each item requires field_name and either value or field_option_name. field_option_name is for single-select fields and is resolved to the corresponding option ID automatically.", + Items: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "field_name": { + Type: "string", + Description: "Issue field name", + }, + "value": { + Description: "Value for text/number/date/single-select fields. For single-select, you can use field_option_name instead.", + }, + "field_option_name": { + Type: "string", + Description: "Single-select option name to resolve and set for the field", + }, + }, + Required: []string{"field_name"}, + }, + }, }, Required: []string{"method", "owner", "repo"}, }, @@ -1272,6 +1444,11 @@ Options are: return utils.NewToolResultError("duplicate_of can only be used when state_reason is 'duplicate'"), nil, nil } + issueFields, err := optionalIssueWriteFields(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil @@ -1282,16 +1459,21 @@ Options are: return utils.NewToolResultErrorFromErr("failed to get GraphQL client", err), nil, nil } + issueFieldValues, err := resolveIssueRequestFieldValues(ctx, gqlClient, owner, repo, issueFields) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to resolve issue_fields: %v", err)), nil, nil + } + switch method { case "create": - result, err := CreateIssue(ctx, client, owner, repo, title, body, assignees, labels, milestoneNum, issueType) + result, err := CreateIssue(ctx, client, owner, repo, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues) return result, nil, err case "update": issueNumber, err := RequiredInt(args, "issue_number") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, state, stateReason, duplicateOf) + result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues, state, stateReason, duplicateOf) return result, nil, err default: return utils.NewToolResultError("invalid method, must be either 'create' or 'update'"), nil, nil @@ -1301,17 +1483,18 @@ Options are: return st } -func CreateIssue(ctx context.Context, client *github.Client, owner string, repo string, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string) (*mcp.CallToolResult, error) { +func CreateIssue(ctx context.Context, client *github.Client, owner string, repo string, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string, issueFieldValues []*github.IssueRequestFieldValue) (*mcp.CallToolResult, error) { if title == "" { return utils.NewToolResultError("missing required parameter: title"), nil } // Create the issue request issueRequest := &github.IssueRequest{ - Title: github.Ptr(title), - Body: github.Ptr(body), - Assignees: &assignees, - Labels: &labels, + Title: github.Ptr(title), + Body: github.Ptr(body), + Assignees: &assignees, + Labels: &labels, + IssueFieldValues: issueFieldValues, } if milestoneNum != 0 { @@ -1354,7 +1537,7 @@ func CreateIssue(ctx context.Context, client *github.Client, owner string, repo return utils.NewToolResultText(string(r)), nil } -func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner string, repo string, issueNumber int, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string, state string, stateReason string, duplicateOf int) (*mcp.CallToolResult, error) { +func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner string, repo string, issueNumber int, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string, issueFieldValues []*github.IssueRequestFieldValue, state string, stateReason string, duplicateOf int) (*mcp.CallToolResult, error) { // Create the issue request with only provided fields issueRequest := &github.IssueRequest{} @@ -1383,6 +1566,10 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4 issueRequest.Type = github.Ptr(issueType) } + if len(issueFieldValues) > 0 { + issueRequest.IssueFieldValues = issueFieldValues + } + updatedIssue, resp, err := client.Issues.Edit(ctx, owner, repo, issueNumber, issueRequest) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 30381d303..8d65cdb5d 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -1076,6 +1076,7 @@ func Test_CreateIssue(t *testing.T) { assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "labels") assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "milestone") assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "type") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_fields") assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo"}) // Setup mock issue for success case @@ -1094,6 +1095,7 @@ func Test_CreateIssue(t *testing.T) { tests := []struct { name string mockedClient *http.Client + mockedGQLClient *http.Client requestArgs map[string]any expectError bool expectedIssue *github.Issue @@ -1152,6 +1154,75 @@ func Test_CreateIssue(t *testing.T) { State: github.Ptr("open"), }, }, + { + name: "successful issue creation with issue fields reconciled by names", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesByOwnerByRepo: expectRequestBody(t, map[string]any{ + "title": "Issue with fields", + "body": "", + "labels": []any{}, + "assignees": []any{}, + "issue_field_values": []any{ + map[string]any{"field_id": float64(101), "value": float64(9001)}, + map[string]any{"field_id": float64(102), "value": "Acme"}, + }, + }).andThen( + mockResponse(t, http.StatusCreated, &github.Issue{ + Number: github.Ptr(125), + Title: github.Ptr("Issue with fields"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/125"), + State: github.Ptr("open"), + }), + ), + }), + mockedGQLClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + issueFieldMetadataQuery{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []map[string]any{ + { + "databaseId": 101, + "name": "Priority", + "dataType": "single_select", + "options": []map[string]any{ + {"databaseId": 9001, "name": "P1"}, + }, + }, + { + "databaseId": 102, + "name": "Customer", + "dataType": "text", + }, + }, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Issue with fields", + "issue_fields": []any{ + map[string]any{"field_name": "Priority", "field_option_name": "P1"}, + map[string]any{"field_name": "Customer", "value": "Acme"}, + }, + }, + expectError: false, + expectedIssue: &github.Issue{ + Number: github.Ptr(125), + Title: github.Ptr("Issue with fields"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/125"), + State: github.Ptr("open"), + }, + }, { name: "issue creation fails", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ @@ -1169,13 +1240,32 @@ func Test_CreateIssue(t *testing.T) { expectError: false, expectedErrMsg: "missing required parameter: title", }, + { + name: "issue_fields rejects both value and field_option_name", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Invalid fields", + "issue_fields": []any{ + map[string]any{"field_name": "Priority", "value": "P1", "field_option_name": "P1"}, + }, + }, + expectError: false, + expectedErrMsg: "cannot specify both value and field_option_name", + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { // Setup client with mock client := mustNewGHClient(t, tc.mockedClient) - gqlClient := githubv4.NewClient(nil) + gqlHTTPClient := tc.mockedGQLClient + if gqlHTTPClient == nil { + gqlHTTPClient = githubv4mock.NewMockedHTTPClient() + } + gqlClient := githubv4.NewClient(gqlHTTPClient) deps := BaseDeps{ Client: client, GQLClient: gqlClient, @@ -1865,6 +1955,7 @@ func Test_UpdateIssue(t *testing.T) { assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "state") assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "state_reason") assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "duplicate_of") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "issue_fields") assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner", "repo"}) // Mock issues for reuse across test cases @@ -1976,6 +2067,61 @@ func Test_UpdateIssue(t *testing.T) { expectError: false, expectedIssue: mockUpdatedIssue, }, + { + name: "partial update with issue fields reconciled by names", + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ + "issue_field_values": []any{ + map[string]any{"field_id": float64(101), "value": float64(9001)}, + map[string]any{"field_id": float64(102), "value": "Acme"}, + }, + "title": "Updated Title", + }).andThen( + mockResponse(t, http.StatusOK, mockUpdatedIssue), + ), + }), + mockedGQLClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + issueFieldMetadataQuery{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []map[string]any{ + { + "databaseId": 101, + "name": "Priority", + "dataType": "single_select", + "options": []map[string]any{{"databaseId": 9001, "name": "P1"}}, + }, + { + "databaseId": 102, + "name": "Customer", + "dataType": "text", + }, + }, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "method": "update", + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "title": "Updated Title", + "issue_fields": []any{ + map[string]any{"field_name": "Priority", "field_option_name": "P1"}, + map[string]any{"field_name": "Customer", "value": "Acme"}, + }, + }, + expectError: false, + expectedIssue: mockUpdatedIssue, + }, { name: "issue not found when updating non-state fields only", mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ From ccd5b960074cbcab7f35fda7f6bf5ef8f4470ba2 Mon Sep 17 00:00:00 2001 From: Iulia B Date: Tue, 12 May 2026 14:32:05 +0000 Subject: [PATCH 04/48] add issues write support --- pkg/github/issues.go | 6 +++--- pkg/github/issues_test.go | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 505a0ad9e..99c26267e 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -49,9 +49,9 @@ type issueFieldMetadataOption struct { } type issueFieldMetadataNode struct { - DatabaseID githubv4.Int `graphql:"databaseId"` - Name githubv4.String - DataType githubv4.String + DatabaseID githubv4.Int `graphql:"databaseId"` + Name githubv4.String + DataType githubv4.String SingleSelectField struct { Options []issueFieldMetadataOption `graphql:"options"` } `graphql:"... on IssueFieldSingleSelect"` diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 8d65cdb5d..2a05354bf 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -1093,13 +1093,13 @@ func Test_CreateIssue(t *testing.T) { } tests := []struct { - name string - mockedClient *http.Client + name string + mockedClient *http.Client mockedGQLClient *http.Client - requestArgs map[string]any - expectError bool - expectedIssue *github.Issue - expectedErrMsg string + requestArgs map[string]any + expectError bool + expectedIssue *github.Issue + expectedErrMsg string }{ { name: "successful issue creation with all fields", @@ -2095,7 +2095,7 @@ func Test_UpdateIssue(t *testing.T) { "databaseId": 101, "name": "Priority", "dataType": "single_select", - "options": []map[string]any{{"databaseId": 9001, "name": "P1"}}, + "options": []map[string]any{{"databaseId": 9001, "name": "P1"}}, }, { "databaseId": 102, From f6f64900068dd3154b6373d1c8dd74beebffdbbe Mon Sep 17 00:00:00 2001 From: Iulia B Date: Mon, 18 May 2026 13:29:12 +0000 Subject: [PATCH 05/48] docs --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 1030f83ca..12867c119 100644 --- a/README.md +++ b/README.md @@ -859,6 +859,7 @@ The following sets of tools are available: - `assignees`: Usernames to assign to this issue (string[], optional) - `body`: Issue body content (string, optional) - `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional) + - `issue_fields`: Issue field values to set. Each item requires field_name and either value or field_option_name. field_option_name is for single-select fields and is resolved to the corresponding option ID automatically. (object[], optional) - `issue_number`: Issue number to update (number, optional) - `labels`: Labels to apply to this issue (string[], optional) - `method`: Write operation to perform on a single issue. From f4b95e6acc42a4ad4d1e161c1b2b65e8874f4453 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 16:28:19 +0200 Subject: [PATCH 06/48] build(deps): bump golang from 1.25.9-alpine to 1.25.10-alpine (#2455) Bumps golang from 1.25.9-alpine to 1.25.10-alpine. --- updated-dependencies: - dependency-name: golang dependency-version: 1.25.10-alpine 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> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 5036ba8b9..70603aed0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ COPY ui/ ./ui/ RUN mkdir -p ./pkg/github/ui_dist && \ cd ui && npm run build -FROM golang:1.25.9-alpine@sha256:5caaf1cca9dc351e13deafbc3879fd4754801acba8653fa9540cea125d01a71f AS build +FROM golang:1.25.10-alpine@sha256:8d22e29d960bc50cd025d93d5b7c7d220b1ee9aa7a239b3c8f55a57e987e8d45 AS build ARG VERSION="dev" # Set the working directory From 8d81376a599bb83eb8c6b2a9186c673179b8c8fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 16:38:27 +0200 Subject: [PATCH 07/48] build(deps): bump goreleaser/goreleaser-action from 6.4.0 to 7.2.1 (#2396) Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 6.4.0 to 7.2.1. - [Release notes](https://github.com/goreleaser/goreleaser-action/releases) - [Commits](https://github.com/goreleaser/goreleaser-action/compare/e435ccd777264be153ace6237001ef4d979d3a7a...1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8) --- updated-dependencies: - dependency-name: goreleaser/goreleaser-action dependency-version: 7.2.1 dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@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 f8eddc076..4ce1c9d34 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -35,7 +35,7 @@ jobs: run: go mod download - name: Run GoReleaser - uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a + uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 with: distribution: goreleaser # GoReleaser version From 8af3431ed4db0118b682d1f718642c6b4b187eaa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 16:45:49 +0200 Subject: [PATCH 08/48] build(deps): bump docker/login-action from 4.0.0 to 4.1.0 (#2395) Bumps [docker/login-action](https://github.com/docker/login-action) from 4.0.0 to 4.1.0. - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/b45d80f862d83dbcd57f89517bcf500b2ab88fb2...4907a6ddec9925e35a0a9e82d7399ccc52663121) --- updated-dependencies: - dependency-name: docker/login-action dependency-version: 4.1.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> --- .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 638713c70..61cbf5e8a 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -60,7 +60,7 @@ jobs: # https://github.com/docker/login-action - name: Log into registry ${{ env.REGISTRY }} if: github.event_name != 'pull_request' - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} From c471ae94bb04059dc26e12c305e219c8fd4299e4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 16:54:16 +0200 Subject: [PATCH 09/48] build(deps): bump sigstore/cosign-installer from 4.1.0 to 4.1.2 (#2394) Bumps [sigstore/cosign-installer](https://github.com/sigstore/cosign-installer) from 4.1.0 to 4.1.2. - [Release notes](https://github.com/sigstore/cosign-installer/releases) - [Commits](https://github.com/sigstore/cosign-installer/compare/ba7bc0a3fef59531c69a25acd34668d6d3fe6f22...6f9f17788090df1f26f669e9d70d6ae9567deba6) --- updated-dependencies: - dependency-name: sigstore/cosign-installer dependency-version: 4.1.1 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> --- .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 61cbf5e8a..5e579aaaf 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -46,7 +46,7 @@ jobs: # https://github.com/sigstore/cosign-installer - name: Install cosign if: github.event_name != 'pull_request' - uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 #v4.1.0 + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 #v4.1.2 with: cosign-release: "v2.2.4" From 07a12f03ff37256c40acdf61422e2898c042d9e1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 17:33:07 +0200 Subject: [PATCH 10/48] build(deps): bump github.com/google/jsonschema-go from 0.4.2 to 0.4.3 (#2393) * build(deps): bump github.com/google/jsonschema-go from 0.4.2 to 0.4.3 Bumps [github.com/google/jsonschema-go](https://github.com/google/jsonschema-go) from 0.4.2 to 0.4.3. - [Release notes](https://github.com/google/jsonschema-go/releases) - [Commits](https://github.com/google/jsonschema-go/compare/v0.4.2...0.4.3) --- updated-dependencies: - dependency-name: github.com/google/jsonschema-go dependency-version: 0.4.3 dependency-type: direct:production update-type: version-update:semver-patch ... 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 | 2 +- go.sum | 4 ++-- third-party-licenses.darwin.md | 2 +- third-party-licenses.linux.md | 2 +- third-party-licenses.windows.md | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 3d7ad06a5..32b2b38f5 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,7 @@ require ( github.com/go-chi/chi/v5 v5.2.5 github.com/go-viper/mapstructure/v2 v2.5.0 github.com/google/go-github/v87 v87.0.0 - github.com/google/jsonschema-go v0.4.2 + github.com/google/jsonschema-go v0.4.3 github.com/josephburnett/jd/v2 v2.5.0 github.com/lithammer/fuzzysearch v1.1.8 github.com/microcosm-cc/bluemonday v1.0.27 diff --git a/go.sum b/go.sum index defedd481..55fa5f7aa 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,8 @@ github.com/google/go-github/v87 v87.0.0 h1:9Ck3dcOxWJyfsN8tzdah4YvmqB/7ZsstMglv/ github.com/google/go-github/v87 v87.0.0/go.mod h1:hGUoT5pwm/ck5uLL+wroSVQfg8mpe+buxllCcGV4VaM= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= -github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= -github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index 2aebd6fa0..80d2f6c46 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -19,7 +19,7 @@ The following packages are included for the amd64, arm64 architectures. - [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-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.2.0/LICENSE)) - - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.2/LICENSE)) + - [github.com/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)) - [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)) diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index 4e6865667..4ccc5ba84 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -19,7 +19,7 @@ The following packages are included for the 386, amd64, arm64 architectures. - [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-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.2.0/LICENSE)) - - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.2/LICENSE)) + - [github.com/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)) - [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)) diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index 91b314dcb..6fc842379 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -19,7 +19,7 @@ The following packages are included for the 386, amd64, arm64 architectures. - [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-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.2.0/LICENSE)) - - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.2/LICENSE)) + - [github.com/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)) - [github.com/inconshreveable/mousetrap](https://pkg.go.dev/github.com/inconshreveable/mousetrap) ([Apache-2.0](https://github.com/inconshreveable/mousetrap/blob/v1.1.0/LICENSE)) - [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v2.5.0/v2/LICENSE)) From 0d560e75e3279807fbd0928ddd1adf9c1173e0e0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 17:40:57 +0200 Subject: [PATCH 11/48] build(deps): bump actions/attest-build-provenance from 3 to 4 (#2123) Bumps [actions/attest-build-provenance](https://github.com/actions/attest-build-provenance) from 3 to 4. - [Release notes](https://github.com/actions/attest-build-provenance/releases) - [Changelog](https://github.com/actions/attest-build-provenance/blob/main/RELEASE.md) - [Commits](https://github.com/actions/attest-build-provenance/compare/v3...v4) --- updated-dependencies: - dependency-name: actions/attest-build-provenance dependency-version: '4' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@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 4ce1c9d34..6e786cb2d 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -47,7 +47,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Generate signed build provenance attestations for workflow artifacts - uses: actions/attest-build-provenance@v3 + uses: actions/attest-build-provenance@v4 with: subject-path: | dist/*.tar.gz From 0725cd953c3435b7600b819b6794254c06e1e306 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 17:48:44 +0200 Subject: [PATCH 12/48] build(deps): bump actions/setup-node from 4 to 6 (#2015) Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6. - [Release notes](https://github.com/actions/setup-node/releases) - [Commits](https://github.com/actions/setup-node/compare/v4...v6) --- updated-dependencies: - dependency-name: actions/setup-node dependency-version: '6' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/code-scanning.yml | 2 +- .github/workflows/docs-check.yml | 2 +- .github/workflows/go.yml | 2 +- .github/workflows/goreleaser.yml | 2 +- .github/workflows/license-check.yml | 2 +- .github/workflows/lint.yml | 2 +- .github/workflows/mcp-diff.yml | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/code-scanning.yml b/.github/workflows/code-scanning.yml index e58a45e71..b3dbb5165 100644 --- a/.github/workflows/code-scanning.yml +++ b/.github/workflows/code-scanning.yml @@ -80,7 +80,7 @@ jobs: - name: Set up Node.js if: matrix.language == 'go' || matrix.language == 'javascript' - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: "20" cache: "npm" diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index de62d6282..f03460164 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -17,7 +17,7 @@ jobs: uses: actions/checkout@v6 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: "20" cache: "npm" diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index f874b2b59..d53618d8b 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -26,7 +26,7 @@ jobs: uses: actions/checkout@v6 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: "20" cache: "npm" diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index 6e786cb2d..3ec07ae11 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -17,7 +17,7 @@ jobs: uses: actions/checkout@v6 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: "20" cache: "npm" diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml index 9e352c3f6..0dbe41e6d 100644 --- a/.github/workflows/license-check.yml +++ b/.github/workflows/license-check.yml @@ -33,7 +33,7 @@ jobs: run: gh pr checkout ${{ github.event.pull_request.number }} - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: "20" cache: "npm" diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3676cb410..6eb86b466 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v6 with: node-version: "20" cache: "npm" diff --git a/.github/workflows/mcp-diff.yml b/.github/workflows/mcp-diff.yml index 3c6c0149a..5f43b4583 100644 --- a/.github/workflows/mcp-diff.yml +++ b/.github/workflows/mcp-diff.yml @@ -20,7 +20,7 @@ jobs: fetch-depth: 0 - name: Set up Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: '20' From fb8d48b7afc3f3727b0ebe28ba4c038c0d39fe9b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 17:56:34 +0200 Subject: [PATCH 13/48] build(deps): bump node from 20-alpine to 26-alpine (#2013) Bumps node from 20-alpine to 26-alpine. --- updated-dependencies: - dependency-name: node dependency-version: 25-alpine dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 70603aed0..faba562b4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-alpine@sha256:09e2b3d9726018aecf269bd35325f46bf75046a643a66d28360ec71132750ec8 AS ui-build +FROM node:26-alpine@sha256:e71ac5e964b9201072425d59d2e876359efa25dc96bb1768cb73295728d6e4ea AS ui-build WORKDIR /app COPY ui/package*.json ./ui/ RUN cd ui && npm ci From ea9d0c81a25dde963b775dd546b20889a46b01cd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 18:10:01 +0200 Subject: [PATCH 14/48] build(deps): bump hono (#2097) Bumps the npm_and_yarn group with 1 update in the /ui directory: [hono](https://github.com/honojs/hono). Updates `hono` from 4.12.0 to 4.12.19 - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.12.0...v4.12.19) --- updated-dependencies: - dependency-name: hono dependency-version: 4.12.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- ui/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index f5314fb08..67dd26e89 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -3697,9 +3697,9 @@ "peer": true }, "node_modules/hono": { - "version": "4.12.0", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.0.tgz", - "integrity": "sha512-NekXntS5M94pUfiVZ8oXXK/kkri+5WpX2/Ik+LVsl+uvw+soj4roXIsPqO+XsWrAw20mOzaXOZf3Q7PfB9A/IA==", + "version": "4.12.19", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.19.tgz", + "integrity": "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ==", "license": "MIT", "peer": true, "engines": { From 2a6229f45caf9c51324a8a88eba4b02adde3d559 Mon Sep 17 00:00:00 2001 From: sammorrowdrums Date: Mon, 18 May 2026 16:21:35 +0200 Subject: [PATCH 15/48] build(deps): bump ui dependencies to clear security advisories Bumps @modelcontextprotocol/ext-apps from ^1.0.0 to ^1.7.2 (which pulls in newer @modelcontextprotocol/sdk and hono), and runs npm audit fix to update the transitive vite/rollup/postcss/picomatch/lodash chain. Closes the following GHSAs (all reachable only through /ui build deps): - hono: GHSA-xh87-mx6m-69f3, and the SSR/cookie/serveStatic family - fast-uri: GHSA path-traversal/host-confusion - ip-address, express-rate-limit, path-to-regexp, picomatch - vite path-traversal + dev-server WebSocket file-read - rollup arbitrary file write, postcss XSS, lodash prototype pollution No source changes required: the ext-apps React API we consume (useApp / App / ontoolresult / ontoolinput) is unchanged; typecheck and the full vite build pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ui/package-lock.json | 555 +++++++++++++++++-------------------------- ui/package.json | 2 +- 2 files changed, 215 insertions(+), 342 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index 67dd26e89..50d9649ad 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0", "dependencies": { "@github/markdown-toolbar-element": "^2.2.3", - "@modelcontextprotocol/ext-apps": "^1.0.0", + "@modelcontextprotocol/ext-apps": "^1.7.2", "@primer/octicons-react": "^19.0.0", "@primer/react": "^36.0.0", "react": "^18.0.0", @@ -835,9 +835,9 @@ "license": "MIT" }, "node_modules/@hono/node-server": { - "version": "1.19.9", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", - "integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==", + "version": "1.19.14", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.14.tgz", + "integrity": "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==", "license": "MIT", "peer": true, "engines": { @@ -905,35 +905,21 @@ "license": "BSD-3-Clause" }, "node_modules/@modelcontextprotocol/ext-apps": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.0.1.tgz", - "integrity": "sha512-rAPzBbB5GNgYk216paQjGKUgbNXSy/yeR95c0ni6Y4uvhWI2AeF+ztEOqQFLBMQy/MPM+02pbVK1HaQmQjMwYQ==", - "hasInstallScript": true, + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.2.tgz", + "integrity": "sha512-OOWKDxdAjYDcgHkmzVzccyyag3FK+jBWPaWu4WvTxFsU4R/cgOX4eep66zPRA5n4v6WfxUNibPyvX4iJ7egYTg==", "license": "MIT", "workspaces": [ "examples/*" ], - "optionalDependencies": { - "@oven/bun-darwin-aarch64": "^1.2.21", - "@oven/bun-darwin-x64": "^1.2.21", - "@oven/bun-darwin-x64-baseline": "^1.2.21", - "@oven/bun-linux-aarch64": "^1.2.21", - "@oven/bun-linux-aarch64-musl": "^1.2.21", - "@oven/bun-linux-x64": "^1.2.21", - "@oven/bun-linux-x64-baseline": "^1.2.21", - "@oven/bun-linux-x64-musl": "^1.2.21", - "@oven/bun-linux-x64-musl-baseline": "^1.2.21", - "@oven/bun-windows-x64": "^1.2.21", - "@oven/bun-windows-x64-baseline": "^1.2.21", - "@rollup/rollup-darwin-arm64": "^4.53.3", - "@rollup/rollup-darwin-x64": "^4.53.3", - "@rollup/rollup-linux-arm64-gnu": "^4.53.3", - "@rollup/rollup-linux-x64-gnu": "^4.53.3", - "@rollup/rollup-win32-arm64-msvc": "^4.53.3", - "@rollup/rollup-win32-x64-msvc": "^4.53.3" + "dependencies": { + "@standard-schema/spec": "^1.1.0" + }, + "engines": { + "node": ">=20" }, "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.24.0", + "@modelcontextprotocol/sdk": "^1.29.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "zod": "^3.25.0 || ^4.0.0" @@ -948,9 +934,9 @@ } }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.26.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz", - "integrity": "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==", + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", "license": "MIT", "peer": true, "dependencies": { @@ -994,149 +980,6 @@ "integrity": "sha512-+aK7EHL3VggfsWGVqUwvtli2+kP5OWyseAsrefhzR2XWoi2oALUCeoDn63i5WS3ZOmLiXHRNBwHPeta8w+aM1g==", "license": "BSD-3-Clause" }, - "node_modules/@oven/bun-darwin-aarch64": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.8.tgz", - "integrity": "sha512-hPERz4IgXCM6Y6GdEEsJAFceyJMt29f3HlFzsvE/k+TQjChRhar6S+JggL35b9VmFfsdxyCOOTPqgnSrdV0etA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oven/bun-darwin-x64": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.8.tgz", - "integrity": "sha512-SaWIxsRQYiT/eA60bqA4l8iNO7cJ6YD8ie82RerRp9voceBxPIZiwX4y20cTKy5qNaSGr9LxfYq7vDywTipiog==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oven/bun-darwin-x64-baseline": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.8.tgz", - "integrity": "sha512-ArHVWpCRZI3vGLoN2/8ud8Kzqlgn1Gv+fNw+pMB9x18IzgAEhKxFxsWffnoaH21amam4tAOhpeewRIgdNtB0Cw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oven/bun-linux-aarch64": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.8.tgz", - "integrity": "sha512-rq0nNckobtS+ONoB95/Frfqr8jCtmSjjjEZlN4oyUx0KEBV11Vj4v3cDVaWzuI34ryL8FCog3HaqjfKn8R82Tw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-aarch64-musl": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.8.tgz", - "integrity": "sha512-HvJmhrfipL7GtuqFz6xNpmf27NGcCOMwCalPjNR6fvkLpe8A7Z1+QbxKKjOglelmlmZc3Vi2TgDUtxSqfqOToQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.8.tgz", - "integrity": "sha512-YDgqVx1MI8E0oDbCEUSkAMBKKGnUKfaRtMdLh9Bjhu7JQacQ/ZCpxwi4HPf5Q0O1TbWRrdxGw2tA2Ytxkn7s1Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64-baseline": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.8.tgz", - "integrity": "sha512-3IkS3TuVOzMqPW6Gg9/8FEoKF/rpKZ9DZUfNy9GQ54+k4PGcXpptU3+dy8D4iDFCt4qe6bvoiAOdM44OOsZ+Wg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64-musl": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.8.tgz", - "integrity": "sha512-o7Jm5zL4aw9UBs3BcZLVbgGm2V4F10MzAQAV+ziKzoEfYmYtvDqRVxgKEq7BzUOVy4LgfrfwzEXw5gAQGRrhQQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64-musl-baseline": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.8.tgz", - "integrity": "sha512-5g8XJwHhcTh8SGoKO7pR54ILYDbuFkGo+68DOMTiVB5eLxuLET+Or/camHgk4QWp3nUS5kNjip4G8BE8i0rHVQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-windows-x64": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.8.tgz", - "integrity": "sha512-UDI3rowMm/tI6DIynpE4XqrOhr+1Ztk1NG707Wxv2nygup+anTswgCwjfjgmIe78LdoRNFrux2GpeolhQGW6vQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@oven/bun-windows-x64-baseline": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.8.tgz", - "integrity": "sha512-K6qBUKAZLXsjAwFxGTG87dsWlDjyDl2fqjJr7+x7lmv2m+aSEzmLOK+Z5pSvGkpjBp3LXV35UUgj8G0UTd0pPg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@primer/behaviors": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/@primer/behaviors/-/behaviors-1.10.1.tgz", @@ -1982,9 +1825,9 @@ "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", - "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", "cpu": [ "arm" ], @@ -1996,9 +1839,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", - "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", "cpu": [ "arm64" ], @@ -2010,12 +1853,13 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", - "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2023,12 +1867,13 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", - "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2036,9 +1881,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", - "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", "cpu": [ "arm64" ], @@ -2050,9 +1895,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", - "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", "cpu": [ "x64" ], @@ -2064,9 +1909,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", - "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", "cpu": [ "arm" ], @@ -2078,9 +1923,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", - "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", "cpu": [ "arm" ], @@ -2092,12 +1937,13 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", - "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2105,9 +1951,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", - "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", "cpu": [ "arm64" ], @@ -2119,9 +1965,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", - "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", "cpu": [ "loong64" ], @@ -2133,9 +1979,9 @@ ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", - "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", "cpu": [ "loong64" ], @@ -2147,9 +1993,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", - "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", "cpu": [ "ppc64" ], @@ -2161,9 +2007,9 @@ ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", - "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", "cpu": [ "ppc64" ], @@ -2175,9 +2021,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", - "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", "cpu": [ "riscv64" ], @@ -2189,9 +2035,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", - "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", "cpu": [ "riscv64" ], @@ -2203,9 +2049,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", - "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", "cpu": [ "s390x" ], @@ -2217,12 +2063,13 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", - "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2230,9 +2077,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", - "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", "cpu": [ "x64" ], @@ -2244,9 +2091,9 @@ ] }, "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", - "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", "cpu": [ "x64" ], @@ -2258,9 +2105,9 @@ ] }, "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", - "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", "cpu": [ "arm64" ], @@ -2272,12 +2119,13 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", - "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2285,9 +2133,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", - "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", "cpu": [ "ia32" ], @@ -2299,9 +2147,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", - "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", "cpu": [ "x64" ], @@ -2313,18 +2161,25 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", - "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ] }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, "node_modules/@styled-system/background": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@styled-system/background/-/background-5.1.2.tgz", @@ -2668,9 +2523,9 @@ } }, "node_modules/ajv": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", - "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "license": "MIT", "peer": true, "dependencies": { @@ -2956,9 +2811,9 @@ } }, "node_modules/content-disposition": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", - "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "license": "MIT", "peer": true, "engines": { @@ -3339,9 +3194,9 @@ } }, "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.8.tgz", + "integrity": "sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==", "license": "MIT", "peer": true, "engines": { @@ -3393,13 +3248,13 @@ } }, "node_modules/express-rate-limit": { - "version": "8.2.1", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz", - "integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==", + "version": "8.5.2", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.2.tgz", + "integrity": "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==", "license": "MIT", "peer": true, "dependencies": { - "ip-address": "10.0.1" + "ip-address": "^10.2.0" }, "engines": { "node": ">= 16" @@ -3425,9 +3280,9 @@ "peer": true }, "node_modules/fast-uri": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", - "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "funding": [ { "type": "github", @@ -3618,9 +3473,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "license": "MIT", "peer": true, "dependencies": { @@ -3768,9 +3623,9 @@ "license": "MIT" }, "node_modules/ip-address": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz", - "integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", "license": "MIT", "peer": true, "engines": { @@ -3890,9 +3745,9 @@ "license": "ISC" }, "node_modules/jose": { - "version": "6.1.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", - "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", "license": "MIT", "peer": true, "funding": { @@ -3953,9 +3808,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT", "peer": true }, @@ -5131,9 +4986,9 @@ } }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", "license": "MIT", "peer": true, "funding": { @@ -5148,9 +5003,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { "node": ">=8.6" @@ -5170,9 +5025,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { @@ -5247,9 +5102,9 @@ } }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "peer": true, "dependencies": { @@ -5449,9 +5304,9 @@ } }, "node_modules/rollup": { - "version": "4.57.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", - "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", "dev": true, "license": "MIT", "dependencies": { @@ -5465,31 +5320,31 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.57.1", - "@rollup/rollup-android-arm64": "4.57.1", - "@rollup/rollup-darwin-arm64": "4.57.1", - "@rollup/rollup-darwin-x64": "4.57.1", - "@rollup/rollup-freebsd-arm64": "4.57.1", - "@rollup/rollup-freebsd-x64": "4.57.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", - "@rollup/rollup-linux-arm-musleabihf": "4.57.1", - "@rollup/rollup-linux-arm64-gnu": "4.57.1", - "@rollup/rollup-linux-arm64-musl": "4.57.1", - "@rollup/rollup-linux-loong64-gnu": "4.57.1", - "@rollup/rollup-linux-loong64-musl": "4.57.1", - "@rollup/rollup-linux-ppc64-gnu": "4.57.1", - "@rollup/rollup-linux-ppc64-musl": "4.57.1", - "@rollup/rollup-linux-riscv64-gnu": "4.57.1", - "@rollup/rollup-linux-riscv64-musl": "4.57.1", - "@rollup/rollup-linux-s390x-gnu": "4.57.1", - "@rollup/rollup-linux-x64-gnu": "4.57.1", - "@rollup/rollup-linux-x64-musl": "4.57.1", - "@rollup/rollup-openbsd-x64": "4.57.1", - "@rollup/rollup-openharmony-arm64": "4.57.1", - "@rollup/rollup-win32-arm64-msvc": "4.57.1", - "@rollup/rollup-win32-ia32-msvc": "4.57.1", - "@rollup/rollup-win32-x64-gnu": "4.57.1", - "@rollup/rollup-win32-x64-msvc": "4.57.1", + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" } }, @@ -5650,14 +5505,14 @@ } }, "node_modules/side-channel-list": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", - "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", "license": "MIT", "peer": true, "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3" + "object-inspect": "^1.13.4" }, "engines": { "node": ">= 0.4" @@ -5868,9 +5723,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -5924,18 +5779,36 @@ } }, "node_modules/type-is": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", - "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "peer": true, "dependencies": { - "content-type": "^1.0.5", + "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/typescript": { @@ -6153,9 +6026,9 @@ } }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6263,9 +6136,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -6304,9 +6177,9 @@ "license": "ISC" }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "peer": true, "funding": { @@ -6314,13 +6187,13 @@ } }, "node_modules/zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", "license": "ISC", "peer": true, "peerDependencies": { - "zod": "^3.25 || ^4" + "zod": "^3.25.28 || ^4" } }, "node_modules/zwitch": { diff --git a/ui/package.json b/ui/package.json index 6b26ca316..d4991448c 100644 --- a/ui/package.json +++ b/ui/package.json @@ -15,7 +15,7 @@ }, "dependencies": { "@github/markdown-toolbar-element": "^2.2.3", - "@modelcontextprotocol/ext-apps": "^1.0.0", + "@modelcontextprotocol/ext-apps": "^1.7.2", "@primer/octicons-react": "^19.0.0", "@primer/react": "^36.0.0", "react": "^18.0.0", From 6c56224bc7f32591a655b344e9877d56ee07bf08 Mon Sep 17 00:00:00 2001 From: sammorrowdrums Date: Mon, 18 May 2026 17:58:42 +0200 Subject: [PATCH 16/48] build(deps): declare Node >=20 engine requirement for /ui Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ui/package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/ui/package.json b/ui/package.json index d4991448c..1ddafdf94 100644 --- a/ui/package.json +++ b/ui/package.json @@ -4,6 +4,9 @@ "private": true, "type": "module", "description": "MCP App UIs for github-mcp-server using Primer React", + "engines": { + "node": ">=20" + }, "scripts": { "build": "npm run build:get-me && npm run build:issue-write && npm run build:pr-write", "build:get-me": "cross-env APP=get-me vite build", From 0ef8f9724a421992f4a4915598683456d2e1bd32 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 18 May 2026 21:34:26 +0200 Subject: [PATCH 17/48] feat(ui): opt into ext-apps autoResize and dev-mode strict Use the v1.7.0 useApp options to: - autoResize iframes to content height (helps issue-write/pr-write/get-me surfaces which all render variable-height forms and result cards) - enable strict handshake-ordering checks in development builds so any out-of-order handler registration surfaces immediately Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ui/src/hooks/useMcpApp.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/src/hooks/useMcpApp.ts b/ui/src/hooks/useMcpApp.ts index 05798f508..54bfa791a 100644 --- a/ui/src/hooks/useMcpApp.ts +++ b/ui/src/hooks/useMcpApp.ts @@ -30,6 +30,8 @@ export function useMcpApp({ const { app, error } = useExtApp({ appInfo: { name: appName, version: appVersion }, capabilities: {}, + autoResize: true, + strict: import.meta.env.DEV, onAppCreated: (app) => { app.ontoolresult = async (result) => { setToolResult(result); From 91336dc98236db497fcfc6d76fddbdc8fdcead37 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 09:05:58 +0200 Subject: [PATCH 18/48] build(deps): bump distroless/base-debian12 from `9dce90e` to `58695f4` (#2497) Bumps distroless/base-debian12 from `9dce90e` to `58695f4`. --- updated-dependencies: - dependency-name: distroless/base-debian12 dependency-version: 58695f439f772a00009c8f6be4c183f824c1f556d74b313c30900f167e4772f8 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index faba562b4..a4e8e8db7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,7 +30,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ -o /bin/github-mcp-server ./cmd/github-mcp-server # Make a stage to run the app -FROM gcr.io/distroless/base-debian12@sha256:9dce90e688a57e59ce473ff7bc4c80bc8fe52d2303b4d99b44f297310bbd2210 +FROM gcr.io/distroless/base-debian12@sha256:58695f439f772a00009c8f6be4c183f824c1f556d74b313c30900f167e4772f8 # Add required MCP server annotation LABEL io.modelcontextprotocol.server.name="io.github.github/github-mcp-server" From de2f17390c72d88c6f7808823c7734206691efb7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 09:12:42 +0200 Subject: [PATCH 19/48] build(deps): bump reproducible-containers/buildkit-cache-dance (#2500) Bumps [reproducible-containers/buildkit-cache-dance](https://github.com/reproducible-containers/buildkit-cache-dance) from 3.3.2 to 3.4.0. - [Release notes](https://github.com/reproducible-containers/buildkit-cache-dance/releases) - [Commits](https://github.com/reproducible-containers/buildkit-cache-dance/compare/1b8ab18fbda5ad3646e3fcc9ed9dd41ce2f297b4...5422eac04292c961a382e0f584ea0f03ad9da723) --- updated-dependencies: - dependency-name: reproducible-containers/buildkit-cache-dance dependency-version: 3.4.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> --- .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 5e579aaaf..f56d4f31a 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -93,7 +93,7 @@ jobs: key: ${{ runner.os }}-go-build-cache-${{ hashFiles('**/go.sum') }} - name: Inject go-build-cache - uses: reproducible-containers/buildkit-cache-dance@1b8ab18fbda5ad3646e3fcc9ed9dd41ce2f297b4 # v3.3.2 + uses: reproducible-containers/buildkit-cache-dance@5422eac04292c961a382e0f584ea0f03ad9da723 # v3.4.0 with: cache-map: | { From 66ec0763cc8137ddee0a1f45d4860dbcd3b3326b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 09:19:12 +0200 Subject: [PATCH 20/48] build(deps): bump goreleaser/goreleaser-action from 7.2.1 to 7.2.2 (#2499) Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 7.2.1 to 7.2.2. - [Release notes](https://github.com/goreleaser/goreleaser-action/releases) - [Commits](https://github.com/goreleaser/goreleaser-action/compare/1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8...5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89) --- updated-dependencies: - dependency-name: goreleaser/goreleaser-action dependency-version: 7.2.2 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> --- .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 3ec07ae11..164e24339 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -35,7 +35,7 @@ jobs: run: go mod download - name: Run GoReleaser - uses: goreleaser/goreleaser-action@1a80836c5c9d9e5755a25cb59ec6f45a3b5f41a8 + uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 with: distribution: goreleaser # GoReleaser version From d4e1231cf7d7d54b742fde715fff23e3b04d729b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 09:58:34 +0200 Subject: [PATCH 21/48] build(deps): bump github.com/modelcontextprotocol/go-sdk from 1.5.1-0.20260403154220-27f29c1cef3b to 1.6.0 (#2498) * build(deps): bump github.com/modelcontextprotocol/go-sdk Bumps [github.com/modelcontextprotocol/go-sdk](https://github.com/modelcontextprotocol/go-sdk) from 1.5.1-0.20260403154220-27f29c1cef3b to 1.6.0. - [Release notes](https://github.com/modelcontextprotocol/go-sdk/releases) - [Commits](https://github.com/modelcontextprotocol/go-sdk/commits/v1.6.0) --- updated-dependencies: - dependency-name: github.com/modelcontextprotocol/go-sdk dependency-version: 1.6.0 dependency-type: direct:production update-type: version-update:semver-minor ... 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 | 2 +- go.sum | 4 ++-- third-party-licenses.darwin.md | 4 ++-- third-party-licenses.linux.md | 4 ++-- third-party-licenses.windows.md | 4 ++-- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index 32b2b38f5..b2a12f257 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.5.1-0.20260403154220-27f29c1cef3b + github.com/modelcontextprotocol/go-sdk v1.6.0 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 diff --git a/go.sum b/go.sum index 55fa5f7aa..c0e9f0955 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.5.1-0.20260403154220-27f29c1cef3b h1:mB8zdpP8SX1TEqnEZpV2hHD30EQXivsZl4AP9hgm7F8= -github.com/modelcontextprotocol/go-sdk v1.5.1-0.20260403154220-27f29c1cef3b/go.mod h1:gggDIhoemhWs3BGkGwd1umzEXCEMMvAnhTrnbXJKKKA= +github.com/modelcontextprotocol/go-sdk v1.6.0 h1:PPLS3kn7WtOEnR+Af4X5H96SG0qSab8R/ZQT/HkhPkY= +github.com/modelcontextprotocol/go-sdk v1.6.0/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= 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= diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index 80d2f6c46..45b31069c 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/27f29c1cef3b/LICENSE)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/27f29c1cef3b/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.6.0/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.6.0/LICENSE)) - [github.com/muesli/cache2go](https://pkg.go.dev/github.com/muesli/cache2go) ([BSD-3-Clause](https://github.com/muesli/cache2go/blob/518229cd8021/LICENSE.txt)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE)) - [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.11.0/LICENSE)) diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index 4ccc5ba84..d7029fb47 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/27f29c1cef3b/LICENSE)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/27f29c1cef3b/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.6.0/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.6.0/LICENSE)) - [github.com/muesli/cache2go](https://pkg.go.dev/github.com/muesli/cache2go) ([BSD-3-Clause](https://github.com/muesli/cache2go/blob/518229cd8021/LICENSE.txt)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE)) - [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.11.0/LICENSE)) diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index 6fc842379..8d805400a 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/27f29c1cef3b/LICENSE)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/27f29c1cef3b/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.6.0/LICENSE)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([MIT](https://github.com/modelcontextprotocol/go-sdk/blob/v1.6.0/LICENSE)) - [github.com/muesli/cache2go](https://pkg.go.dev/github.com/muesli/cache2go) ([BSD-3-Clause](https://github.com/muesli/cache2go/blob/518229cd8021/LICENSE.txt)) - [github.com/pelletier/go-toml/v2](https://pkg.go.dev/github.com/pelletier/go-toml/v2) ([MIT](https://github.com/pelletier/go-toml/blob/v2.2.4/LICENSE)) - [github.com/sagikazarmark/locafero](https://pkg.go.dev/github.com/sagikazarmark/locafero) ([MIT](https://github.com/sagikazarmark/locafero/blob/v0.11.0/LICENSE)) From 7e394af647018d1c90b5aa4d492a553447b1b697 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Tue, 19 May 2026 11:52:24 +0200 Subject: [PATCH 22/48] =?UTF-8?q?chore(ui):=20migrate=20vite=206=20?= =?UTF-8?q?=E2=86=92=208,=20plugin-react=204=20=E2=86=92=206;=20cache=20UI?= =?UTF-8?q?=20build=20in=20CI=20(#2501)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(ui): migrate vite 6 -> 8 and plugin-react 4 -> 6 Supersedes the auto-generated bump in #2496, which only updated vite and left @vitejs/plugin-react on a peer range that excludes vite 8, breaking the UI build (and every Go job that embeds the UI assets) with ERESOLVE. - vite ^6.0.0 -> ^8.0.13 - @vitejs/plugin-react ^4.3.0 -> ^6.0.2 (peers vite ^8.0.0 only) - vite-plugin-singlefile ^2.0.0 -> ^2.3.3 (peers already allowed v8) - engines.node >=20 -> ^20.19.0 || >=22.12.0 (Vite 7+ requirement) Vite 8 ships Rolldown instead of Rollup, which rejects bundle mutation in generateBundle. The rename-output plugin was doing exactly that to flatten the singlefile-inlined HTML from src/apps//index.html down to .html. Refactored it to hoist the file in closeBundle (post-write) and renamed it to flatten-output to reflect what it actually does. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * chore(ui): give flatten-output a clearer error when the HTML is missing Addresses Copilot review feedback on #2501: if the singlefile-inlined HTML isn't where we expect it (e.g. because a future Vite/Rolldown change alters the output path), throw with the app name and expected path instead of letting renameSync surface a bare ENOENT. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * perf(ui+ci): cache build artifacts and run vite in single process Introduce a content-addressable cache for the embedded UI HTML and refactor the build script to invoke vite once per Node process instead of three times. * New ui/scripts/build.mjs runs vite build() in a loop within one process, removing the cross-env dev dependency and avoiding redundant plugin/JIT warm-up. Local build time drops from ~2.4s to ~1.5s. * New .github/actions/build-ui composite action restores pkg/github/ui_dist/{get-me,issue-write,pr-write}.html from cache keyed on hashes of ui/ sources and the lockfile. On cache hit it skips Node setup and the build entirely; on miss it sets up Node and runs script/build-ui as before. Saves ~6s per workflow on Go-only PRs, which is the common case across seven workflows. * Replace the duplicated setup-node + Build UI pair in seven workflows (go, lint, docs-check, license-check, goreleaser, mcp-diff, code-scanning) with a single uses: ./.github/actions/build-ui line. code-scanning keeps a dedicated setup-node for the JavaScript CodeQL path. Output files are byte-identical to the pre-refactor build. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * perf(ci): share UI artifact cache across runner OSes The cached HTML output is platform-independent, so set enableCrossOsArchive on the cache step. With this any OS can restore the cache populated by any other OS — one shared cache instead of three. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/actions/build-ui/action.yml | 38 + .github/workflows/code-scanning.yml | 6 +- .github/workflows/docs-check.yml | 9 +- .github/workflows/go.yml | 10 +- .github/workflows/goreleaser.yml | 9 +- .github/workflows/license-check.yml | 9 +- .github/workflows/lint.yml | 7 +- .github/workflows/mcp-diff.yml | 7 +- ui/package-lock.json | 1678 ++++++++++++++------------- ui/package.json | 14 +- ui/scripts/build.mjs | 14 + ui/vite.config.ts | 37 +- 12 files changed, 986 insertions(+), 852 deletions(-) create mode 100644 .github/actions/build-ui/action.yml create mode 100644 ui/scripts/build.mjs diff --git a/.github/actions/build-ui/action.yml b/.github/actions/build-ui/action.yml new file mode 100644 index 000000000..229057d5c --- /dev/null +++ b/.github/actions/build-ui/action.yml @@ -0,0 +1,38 @@ +name: Build UI +description: Restore cached UI HTML artifacts, or set up Node and run script/build-ui on cache miss. + +runs: + using: composite + steps: + - name: Cache UI artifacts + id: cache-ui + uses: actions/cache@v5 + with: + path: | + pkg/github/ui_dist/get-me.html + pkg/github/ui_dist/issue-write.html + pkg/github/ui_dist/pr-write.html + key: ui-dist-v1-${{ hashFiles('ui/package-lock.json', 'ui/package.json', 'ui/index.html', 'ui/tsconfig*.json', 'ui/vite.config.ts', 'ui/src/**', 'ui/scripts/**') }} + enableCrossOsArchive: true + + - name: Set up Node.js + if: steps.cache-ui.outputs.cache-hit != 'true' + uses: actions/setup-node@v6 + with: + node-version: "20" + cache: npm + cache-dependency-path: ui/package-lock.json + + - name: Build UI + if: steps.cache-ui.outputs.cache-hit != 'true' + shell: bash + run: script/build-ui + + - name: Report UI cache status + shell: bash + run: | + if [ "${{ steps.cache-ui.outputs.cache-hit }}" = "true" ]; then + echo "UI artifacts restored from cache (skipped build)." + else + echo "UI artifacts rebuilt from source." + fi diff --git a/.github/workflows/code-scanning.yml b/.github/workflows/code-scanning.yml index b3dbb5165..ecbe9f0dc 100644 --- a/.github/workflows/code-scanning.yml +++ b/.github/workflows/code-scanning.yml @@ -78,8 +78,8 @@ jobs: go-version: ${{ fromJSON(steps.resolve-environment.outputs.environment).configuration.go.version }} cache: false - - name: Set up Node.js - if: matrix.language == 'go' || matrix.language == 'javascript' + - name: Set up Node.js (for JavaScript CodeQL) + if: matrix.language == 'javascript' uses: actions/setup-node@v6 with: node-version: "20" @@ -88,7 +88,7 @@ jobs: - name: Build UI if: matrix.language == 'go' - run: script/build-ui + uses: ./.github/actions/build-ui - name: Autobuild uses: github/codeql-action/autobuild@v4 diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index f03460164..309eddb38 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -16,15 +16,8 @@ jobs: - name: Checkout code uses: actions/checkout@v6 - - name: Set up Node.js - uses: actions/setup-node@v6 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: ui/package-lock.json - - name: Build UI - run: script/build-ui + uses: ./.github/actions/build-ui - name: Set up Go uses: actions/setup-go@v6 diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index d53618d8b..1fea50114 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -25,16 +25,8 @@ jobs: - name: Check out code uses: actions/checkout@v6 - - name: Set up Node.js - uses: actions/setup-node@v6 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: ui/package-lock.json - - name: Build UI - shell: bash - run: script/build-ui + uses: ./.github/actions/build-ui - name: Set up Go uses: actions/setup-go@v6 diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index 164e24339..1004fc274 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -16,15 +16,8 @@ jobs: - name: Check out code uses: actions/checkout@v6 - - name: Set up Node.js - uses: actions/setup-node@v6 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: ui/package-lock.json - - name: Build UI - run: script/build-ui + uses: ./.github/actions/build-ui - name: Set up Go uses: actions/setup-go@v6 diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml index 0dbe41e6d..2f27353d8 100644 --- a/.github/workflows/license-check.yml +++ b/.github/workflows/license-check.yml @@ -32,15 +32,8 @@ jobs: GH_TOKEN: ${{ github.token }} run: gh pr checkout ${{ github.event.pull_request.number }} - - name: Set up Node.js - uses: actions/setup-node@v6 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: ui/package-lock.json - - name: Build UI - run: script/build-ui + uses: ./.github/actions/build-ui - name: Set up Go uses: actions/setup-go@v6 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6eb86b466..5b912cea0 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,13 +14,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: ui/package-lock.json - name: Build UI - run: script/build-ui + uses: ./.github/actions/build-ui - uses: actions/setup-go@v6 with: go-version: '1.25' diff --git a/.github/workflows/mcp-diff.yml b/.github/workflows/mcp-diff.yml index 5f43b4583..56f350081 100644 --- a/.github/workflows/mcp-diff.yml +++ b/.github/workflows/mcp-diff.yml @@ -19,13 +19,8 @@ jobs: with: fetch-depth: 0 - - name: Set up Node.js - uses: actions/setup-node@v6 - with: - node-version: '20' - - name: Build UI - run: script/build-ui + uses: ./.github/actions/build-ui - name: Run MCP Server Diff uses: SamMorrowDrums/mcp-server-diff@v2.3.5 diff --git a/ui/package-lock.json b/ui/package-lock.json index 50d9649ad..13d78a25a 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -21,11 +21,13 @@ "@types/node": "^25.2.0", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", - "@vitejs/plugin-react": "^4.3.0", - "cross-env": "^7.0.3", + "@vitejs/plugin-react": "^6.0.2", "typescript": "^5.7.0", - "vite": "^6.0.0", - "vite-plugin-singlefile": "^2.0.0" + "vite": "^8.0.13", + "vite-plugin-singlefile": "^2.3.3" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@babel/code-frame": { @@ -33,6 +35,7 @@ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", @@ -47,6 +50,7 @@ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -56,6 +60,7 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -86,6 +91,7 @@ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.0.tgz", "integrity": "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", @@ -115,6 +121,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", @@ -131,6 +138,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -140,6 +148,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" @@ -153,6 +162,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", @@ -170,6 +180,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -179,6 +190,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -188,6 +200,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -197,6 +210,7 @@ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -206,6 +220,7 @@ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "license": "MIT", + "peer": true, "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" @@ -219,6 +234,7 @@ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", "license": "MIT", + "peer": true, "dependencies": { "@babel/types": "^7.29.0" }, @@ -245,38 +261,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/runtime": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", @@ -291,6 +275,7 @@ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", @@ -305,6 +290,7 @@ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -323,6 +309,7 @@ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", "license": "MIT", + "peer": true, "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" @@ -331,478 +318,70 @@ "node": ">=6.9.0" } }, - "node_modules/@emotion/is-prop-valid": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", - "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@emotion/memoize": "^0.9.0" - } - }, - "node_modules/@emotion/memoize": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", - "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", - "license": "MIT", - "peer": true - }, - "node_modules/@emotion/stylis": { - "version": "0.8.5", - "resolved": "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.8.5.tgz", - "integrity": "sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ==", - "license": "MIT", - "peer": true - }, - "node_modules/@emotion/unitless": { - "version": "0.7.5", - "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", - "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", - "license": "MIT", - "peer": true - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", - "cpu": [ - "x64" - ], + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", - "cpu": [ - "arm64" - ], + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@emotion/is-prop-valid": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" + "peer": true, + "dependencies": { + "@emotion/memoize": "^0.9.0" } }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", - "cpu": [ - "arm64" - ], - "dev": true, + "node_modules/@emotion/memoize": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz", + "integrity": "sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", - "cpu": [ - "ia32" - ], - "dev": true, + "peer": true + }, + "node_modules/@emotion/stylis": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/@emotion/stylis/-/stylis-0.8.5.tgz", + "integrity": "sha512-h6KtPihKFn3T9fuIrwvXXUOwlx3rfUvfZIcP5a6rh8Y7zjE3O06hT5Ss4S/YI1AYhuZ1kjaE/5EaOOI2NqSylQ==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } + "peer": true }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", - "cpu": [ - "x64" - ], - "dev": true, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } + "peer": true }, "node_modules/@github/combobox-nav": { "version": "2.3.1", @@ -852,6 +431,7 @@ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" @@ -862,6 +442,7 @@ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" @@ -872,6 +453,7 @@ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.0.0" } @@ -880,13 +462,15 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", + "peer": true, "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -974,12 +558,41 @@ } } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@oddbird/popover-polyfill": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/@oddbird/popover-polyfill/-/popover-polyfill-0.3.8.tgz", "integrity": "sha512-+aK7EHL3VggfsWGVqUwvtli2+kP5OWyseAsrefhzR2XWoi2oALUCeoDn63i5WS3ZOmLiXHRNBwHPeta8w+aM1g==", "license": "BSD-3-Clause" }, + "node_modules/@oxc-project/types": { + "version": "0.130.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", + "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@primer/behaviors": { "version": "1.10.1", "resolved": "https://registry.npmjs.org/@primer/behaviors/-/behaviors-1.10.1.tgz", @@ -1696,131 +1309,388 @@ "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.4.4.tgz", "integrity": "sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg==", "license": "MIT", - "dependencies": { - "inline-style-parser": "0.1.1" + "dependencies": { + "inline-style-parser": "0.1.1" + } + }, + "node_modules/@primer/react/node_modules/unified": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/unified/-/unified-10.1.2.tgz", + "integrity": "sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "bail": "^2.0.0", + "extend": "^3.0.0", + "is-buffer": "^2.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@primer/react/node_modules/unist-util-is": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-5.2.1.tgz", + "integrity": "sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@primer/react/node_modules/unist-util-position": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-4.0.4.tgz", + "integrity": "sha512-kUBE91efOWfIVBo8xzh/uZQ7p9ffYRtUbMRZBNFYwf0RK8koUMx6dGUfwylLOKmaT2cs4wSW96QoYUSXAyEtpg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@primer/react/node_modules/unist-util-stringify-position": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-3.0.3.tgz", + "integrity": "sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@primer/react/node_modules/unist-util-visit": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-4.1.2.tgz", + "integrity": "sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^5.0.0", + "unist-util-visit-parents": "^5.1.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@primer/react/node_modules/unist-util-visit-parents": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-5.1.3.tgz", + "integrity": "sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@primer/react/node_modules/vfile": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-5.3.7.tgz", + "integrity": "sha512-r7qlzkgErKjobAmyNIkkSpizsFPYiUPuJb5pNW1RB4JcYVZhs4lIbVqk8XPk033CV/1z8ss5pkax8SuhGpcG8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "is-buffer": "^2.0.0", + "unist-util-stringify-position": "^3.0.0", + "vfile-message": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@primer/react/node_modules/vfile-message": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-3.1.4.tgz", + "integrity": "sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-stringify-position": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", + "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", + "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", + "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@primer/react/node_modules/unified": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/unified/-/unified-10.1.2.tgz", - "integrity": "sha512-pUSWAi/RAnVy1Pif2kAoeWNBa3JVrx0MId2LASj8G+7AiHWoKZNTomq6LG326T68U7/e263X6fTdcXIy7XnF7Q==", + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", + "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "cpu": [ + "ppc64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.0", - "bail": "^2.0.0", - "extend": "^3.0.0", - "is-buffer": "^2.0.0", - "is-plain-obj": "^4.0.0", - "trough": "^2.0.0", - "vfile": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@primer/react/node_modules/unist-util-is": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-5.2.1.tgz", - "integrity": "sha512-u9njyyfEh43npf1M+yGKDGVPbY/JWEemg5nH05ncKPfi+kBbKBJoTdsogMu33uhytuLlv9y0O7GH7fEdwLdLQw==", + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", + "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "cpu": [ + "s390x" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@primer/react/node_modules/unist-util-position": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-4.0.4.tgz", - "integrity": "sha512-kUBE91efOWfIVBo8xzh/uZQ7p9ffYRtUbMRZBNFYwf0RK8koUMx6dGUfwylLOKmaT2cs4wSW96QoYUSXAyEtpg==", + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@primer/react/node_modules/unist-util-stringify-position": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-3.0.3.tgz", - "integrity": "sha512-k5GzIBZ/QatR8N5X2y+drfpWG8IDBzdnVj6OInRNWm1oXrzydiaAT2OQiA8DPRRZyAKb9b6I2a6PxYklZD0gKg==", + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@primer/react/node_modules/unist-util-visit": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-4.1.2.tgz", - "integrity": "sha512-MSd8OUGISqHdVvfY9TPhyK2VdUrPgxkUtWSuMHF6XAAFuL4LokseigBnZtPnJMu+FbynTkFNnFlyjxpVKujMRg==", + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", + "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^5.0.0", - "unist-util-visit-parents": "^5.1.1" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@primer/react/node_modules/unist-util-visit-parents": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-5.1.3.tgz", - "integrity": "sha512-x6+y8g7wWMyQhL1iZfhIPhDAs7Xwbn9nRosDXl7qoPTSCy0yNxnKc+hWokFifWQIDGi154rdUqKvbCa4+1kLhg==", + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", + "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", + "cpu": [ + "wasm32" + ], + "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^5.0.0" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@primer/react/node_modules/vfile": { - "version": "5.3.7", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-5.3.7.tgz", - "integrity": "sha512-r7qlzkgErKjobAmyNIkkSpizsFPYiUPuJb5pNW1RB4JcYVZhs4lIbVqk8XPk033CV/1z8ss5pkax8SuhGpcG8g==", + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "cpu": [ + "arm64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.0", - "is-buffer": "^2.0.0", - "unist-util-stringify-position": "^3.0.0", - "vfile-message": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@primer/react/node_modules/vfile-message": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-3.1.4.tgz", - "integrity": "sha512-fa0Z6P8HUrQN4BZaX05SIVXic+7kE3b05PWAtPuYP9QLHsLKYR7/AlLW3NtOrpXRLeawpDLMsVkmk5DG0NXgWw==", + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "cpu": [ + "x64" + ], + "dev": true, "license": "MIT", - "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-stringify-position": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.27", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", - "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -1836,7 +1706,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.60.4", @@ -1850,7 +1721,8 @@ "optional": true, "os": [ "android" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.60.4", @@ -1864,7 +1736,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.60.4", @@ -1878,7 +1751,8 @@ "optional": true, "os": [ "darwin" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.60.4", @@ -1892,7 +1766,8 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.60.4", @@ -1906,7 +1781,8 @@ "optional": true, "os": [ "freebsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.60.4", @@ -1920,7 +1796,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.60.4", @@ -1934,7 +1811,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.60.4", @@ -1948,7 +1826,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.60.4", @@ -1962,7 +1841,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-loong64-gnu": { "version": "4.60.4", @@ -1976,7 +1856,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-loong64-musl": { "version": "4.60.4", @@ -1990,7 +1871,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.60.4", @@ -2004,7 +1886,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-ppc64-musl": { "version": "4.60.4", @@ -2018,7 +1901,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.60.4", @@ -2032,7 +1916,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.60.4", @@ -2046,7 +1931,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.60.4", @@ -2060,7 +1946,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.60.4", @@ -2074,7 +1961,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.60.4", @@ -2088,7 +1976,8 @@ "optional": true, "os": [ "linux" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-openbsd-x64": { "version": "4.60.4", @@ -2102,7 +1991,8 @@ "optional": true, "os": [ "openbsd" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.60.4", @@ -2116,7 +2006,8 @@ "optional": true, "os": [ "openharmony" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.60.4", @@ -2130,7 +2021,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.60.4", @@ -2144,7 +2036,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.60.4", @@ -2158,7 +2051,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.60.4", @@ -2172,7 +2066,8 @@ "optional": true, "os": [ "win32" - ] + ], + "peer": true }, "node_modules/@standard-schema/spec": { "version": "1.1.0", @@ -2313,49 +2208,15 @@ "@styled-system/css": "^5.1.5" } }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@babel/types": "^7.28.2" + "tslib": "^2.4.0" } }, "node_modules/@types/debug": { @@ -2488,24 +2349,29 @@ "license": "ISC" }, "node_modules/@vitejs/plugin-react": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", - "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.28.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.27", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.17.0" + "@rolldown/pluginutils": "^1.0.0" }, "engines": { - "node": "^14.18.0 || >=16.0.0" + "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } } }, "node_modules/accepts": { @@ -2589,6 +2455,7 @@ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", "license": "Apache-2.0", + "peer": true, "bin": { "baseline-browser-mapping": "dist/cli.js" } @@ -2650,6 +2517,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -2733,7 +2601,8 @@ "url": "https://github.com/sponsors/ai" } ], - "license": "CC-BY-4.0" + "license": "CC-BY-4.0", + "peer": true }, "node_modules/ccount": { "version": "2.0.1", @@ -2838,7 +2707,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/cookie": { "version": "0.7.2", @@ -2878,30 +2748,12 @@ "url": "https://opencollective.com/express" } }, - "node_modules/cross-env": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", - "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "cross-spawn": "^7.0.1" - }, - "bin": { - "cross-env": "src/bin/cross-env.js", - "cross-env-shell": "src/bin/cross-env-shell.js" - }, - "engines": { - "node": ">=10.14", - "npm": ">=6", - "yarn": ">=1" - } - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "license": "MIT", + "peer": true, "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -2997,6 +2849,16 @@ "node": ">=6" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -3045,7 +2907,8 @@ "version": "1.5.286", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/encodeurl": { "version": "2.0.0", @@ -3090,53 +2953,12 @@ "node": ">= 0.4" } }, - "node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "license": "MIT", + "peer": true, "engines": { "node": ">=6" } @@ -3393,6 +3215,7 @@ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -3742,7 +3565,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/jose": { "version": "6.2.3", @@ -3765,6 +3589,7 @@ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "license": "MIT", + "peer": true, "bin": { "jsesc": "bin/jsesc" }, @@ -3791,6 +3616,7 @@ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", "license": "MIT", + "peer": true, "bin": { "json5": "lib/cli.js" }, @@ -3807,6 +3633,267 @@ "node": ">=6" } }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lodash": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", @@ -3853,6 +3940,7 @@ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "license": "ISC", + "peer": true, "dependencies": { "yallist": "^3.0.2" } @@ -4894,7 +4982,8 @@ "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/object-assign": { "version": "4.1.1", @@ -4981,6 +5070,7 @@ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -5217,16 +5307,6 @@ "react": ">=18" } }, - "node_modules/react-refresh": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", - "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -5303,12 +5383,48 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", + "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.130.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.1", + "@rolldown/binding-darwin-arm64": "1.0.1", + "@rolldown/binding-darwin-x64": "1.0.1", + "@rolldown/binding-freebsd-x64": "1.0.1", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", + "@rolldown/binding-linux-arm64-gnu": "1.0.1", + "@rolldown/binding-linux-arm64-musl": "1.0.1", + "@rolldown/binding-linux-ppc64-gnu": "1.0.1", + "@rolldown/binding-linux-s390x-gnu": "1.0.1", + "@rolldown/binding-linux-x64-gnu": "1.0.1", + "@rolldown/binding-linux-x64-musl": "1.0.1", + "@rolldown/binding-openharmony-arm64": "1.0.1", + "@rolldown/binding-wasm32-wasi": "1.0.1", + "@rolldown/binding-win32-arm64-msvc": "1.0.1", + "@rolldown/binding-win32-x64-msvc": "1.0.1" + } + }, "node_modules/rollup": { "version": "4.60.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", "dev": true, "license": "MIT", + "optional": true, + "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -5398,6 +5514,7 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "license": "ISC", + "peer": true, "bin": { "semver": "bin/semver.js" } @@ -5468,6 +5585,7 @@ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "license": "MIT", + "peer": true, "dependencies": { "shebang-regex": "^3.0.0" }, @@ -5480,6 +5598,7 @@ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "license": "MIT", + "peer": true, "engines": { "node": ">=8" } @@ -5688,14 +5807,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -5778,6 +5897,14 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, "node_modules/type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", @@ -5958,6 +6085,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" @@ -6026,24 +6154,23 @@ } }, "node_modules/vite": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", - "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "version": "8.0.13", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", + "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.14", + "rolldown": "1.0.1", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + "node": "^20.19.0 || >=22.12.0" }, "funding": { "url": "https://github.com/vitejs/vite?sponsor=1" @@ -6052,14 +6179,15 @@ "fsevents": "~2.3.3" }, "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" @@ -6068,13 +6196,16 @@ "@types/node": { "optional": true }, - "jiti": { + "@vitejs/devtools": { "optional": true }, - "less": { + "esbuild": { + "optional": true + }, + "jiti": { "optional": true }, - "lightningcss": { + "less": { "optional": true }, "sass": { @@ -6101,9 +6232,9 @@ } }, "node_modules/vite-plugin-singlefile": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.0.tgz", - "integrity": "sha512-DAcHzYypM0CasNLSz/WG0VdKOCxGHErfrjOoyIPiNxTPTGmO6rRD/te93n1YL/s+miXq66ipF1brMBikf99c6A==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/vite-plugin-singlefile/-/vite-plugin-singlefile-2.3.3.tgz", + "integrity": "sha512-XVnGH0QzbOa8fxRSsHdCarVN1BSBXNi7uLMQYlrGRN5apdHkk62XQWRJhVever0lnfuyBkwn+kvVChdm/OoOUg==", "dev": true, "license": "MIT", "dependencies": { @@ -6113,24 +6244,11 @@ "node": ">18.0.0" }, "peerDependencies": { - "rollup": "^4.44.1", - "vite": "^5.4.11 || ^6.0.0 || ^7.0.0" - } - }, - "node_modules/vite/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" + "rollup": "^4.59.0", + "vite": "^5.4.21 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { - "picomatch": { + "rollup": { "optional": true } } @@ -6153,6 +6271,7 @@ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "license": "ISC", + "peer": true, "dependencies": { "isexe": "^2.0.0" }, @@ -6174,7 +6293,8 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "license": "ISC" + "license": "ISC", + "peer": true }, "node_modules/zod": { "version": "4.4.3", diff --git a/ui/package.json b/ui/package.json index 1ddafdf94..b5bf09585 100644 --- a/ui/package.json +++ b/ui/package.json @@ -5,13 +5,10 @@ "type": "module", "description": "MCP App UIs for github-mcp-server using Primer React", "engines": { - "node": ">=20" + "node": "^20.19.0 || >=22.12.0" }, "scripts": { - "build": "npm run build:get-me && npm run build:issue-write && npm run build:pr-write", - "build:get-me": "cross-env APP=get-me vite build", - "build:issue-write": "cross-env APP=issue-write vite build", - "build:pr-write": "cross-env APP=pr-write vite build", + "build": "node scripts/build.mjs", "dev": "npm run build", "typecheck": "tsc --noEmit", "clean": "rm -rf dist" @@ -30,10 +27,9 @@ "@types/node": "^25.2.0", "@types/react": "^18.0.0", "@types/react-dom": "^18.0.0", - "@vitejs/plugin-react": "^4.3.0", - "cross-env": "^7.0.3", + "@vitejs/plugin-react": "^6.0.2", "typescript": "^5.7.0", - "vite": "^6.0.0", - "vite-plugin-singlefile": "^2.0.0" + "vite": "^8.0.13", + "vite-plugin-singlefile": "^2.3.3" } } diff --git a/ui/scripts/build.mjs b/ui/scripts/build.mjs new file mode 100644 index 000000000..c99d84603 --- /dev/null +++ b/ui/scripts/build.mjs @@ -0,0 +1,14 @@ +// Build all UI apps in a single Node process. +// +// Replaces three serial `cross-env APP= vite build` invocations: doing it +// in one process avoids paying Vite/plugin startup cost three times and is +// portable without `cross-env`. + +import { build } from "vite"; + +const apps = ["get-me", "issue-write", "pr-write"]; + +for (const app of apps) { + process.env.APP = app; + await build(); +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 5b1777c70..963b39883 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,39 +1,44 @@ import { defineConfig, Plugin } from "vite"; import react from "@vitejs/plugin-react"; import { viteSingleFile } from "vite-plugin-singlefile"; +import { existsSync, renameSync, rmSync } from "fs"; import { resolve } from "path"; -// Get the app to build from environment variable const app = process.env.APP; if (!app) { throw new Error("APP environment variable must be set"); } -// Plugin to rename the output file and remove the nested directory structure -function renameOutput(): Plugin { +const outDir = resolve(__dirname, "../pkg/github/ui_dist"); + +// vite-plugin-singlefile inlines all JS/CSS into the HTML, but Vite preserves +// the input file's relative path in the output (src/apps//index.html). +// After the bundle is written, hoist that file to /.html and +// remove the now-empty nested directories. Done in closeBundle (post-write) +// because Rolldown disallows mutating the in-memory bundle in generateBundle. +function flattenOutput(): Plugin { return { - name: "rename-output", + name: "flatten-output", enforce: "post", - generateBundle(_, bundle) { - // Find the HTML file and rename it - for (const fileName of Object.keys(bundle)) { - if (fileName.endsWith("index.html")) { - const chunk = bundle[fileName]; - chunk.fileName = `${app}.html`; - delete bundle[fileName]; - bundle[`${app}.html`] = chunk; - break; - } + closeBundle() { + const nested = resolve(outDir, `src/apps/${app}/index.html`); + const flat = resolve(outDir, `${app}.html`); + if (!existsSync(nested)) { + throw new Error( + `flatten-output: expected built HTML at ${nested} for app "${app}" but it was not emitted`, + ); } + renameSync(nested, flat); + rmSync(resolve(outDir, "src"), { recursive: true, force: true }); }, }; } export default defineConfig({ - plugins: [react(), viteSingleFile(), renameOutput()], + plugins: [react(), viteSingleFile(), flattenOutput()], build: { - outDir: resolve(__dirname, "../pkg/github/ui_dist"), + outDir, emptyOutDir: false, rollupOptions: { input: resolve(__dirname, `src/apps/${app}/index.html`), From c88d2ecdd3bb07f7bdd75296e3ee676febf14f58 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Tue, 19 May 2026 11:55:44 +0200 Subject: [PATCH 23/48] fix: guard CompletionsHandler against nil params/ref (#2502) * fix: guard CompletionsHandler against nil params/ref A malformed completion/complete request with missing or empty parameters caused a nil pointer dereference in CompletionsHandler, panicking the process. Reject such requests with a clear error before dispatching on Ref.Type. Reported by @manthanghasadiya (GHSA-w4q6-qw23-4rg7). Co-authored-by: manthanghasadiya <68530736+manthanghasadiya@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Align error wording with repo convention Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: manthanghasadiya <68530736+manthanghasadiya@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/github/server.go | 3 +++ pkg/github/server_test.go | 24 ++++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/pkg/github/server.go b/pkg/github/server.go index ee41e90e9..a9a75642f 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -204,6 +204,9 @@ func NewServer(version, name, title string, opts *mcp.ServerOptions) *mcp.Server func CompletionsHandler(getClient GetClientFn) func(ctx context.Context, req *mcp.CompleteRequest) (*mcp.CompleteResult, error) { return func(ctx context.Context, req *mcp.CompleteRequest) (*mcp.CompleteResult, error) { + if req == nil || req.Params == nil || req.Params.Ref == nil { + return nil, fmt.Errorf("missing required parameter: ref") + } switch req.Params.Ref.Type { case "ref/resource": if strings.HasPrefix(req.Params.Ref.URI, "repo://") { diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go index 7af388f73..be078d360 100644 --- a/pkg/github/server_test.go +++ b/pkg/github/server_test.go @@ -349,3 +349,27 @@ func TestResolveEnabledToolsets(t *testing.T) { }) } } + +func TestCompletionsHandler_RejectsMissingRef(t *testing.T) { + getClient := func(_ context.Context) (*gogithub.Client, error) { + return &gogithub.Client{}, nil + } + handler := CompletionsHandler(getClient) + + tests := []struct { + name string + req *mcp.CompleteRequest + }{ + {name: "nil request", req: nil}, + {name: "nil params", req: &mcp.CompleteRequest{}}, + {name: "nil ref", req: &mcp.CompleteRequest{Params: &mcp.CompleteParams{}}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result, err := handler(context.Background(), tc.req) + require.Error(t, err) + assert.Nil(t, result) + assert.Contains(t, err.Error(), "missing required parameter: ref") + }) + } +} From f21dcd38abaafda1c555238fa018c88b5236afa7 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Tue, 19 May 2026 12:19:30 +0200 Subject: [PATCH 24/48] fix(ui): advertise get_me as an app via _meta.ui.visibility (#2503) --- pkg/github/__toolsnaps__/get_me.snap | 6 +++++- pkg/github/context_tools.go | 1 + 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/pkg/github/__toolsnaps__/get_me.snap b/pkg/github/__toolsnaps__/get_me.snap index b451b49de..6f287df09 100644 --- a/pkg/github/__toolsnaps__/get_me.snap +++ b/pkg/github/__toolsnaps__/get_me.snap @@ -1,7 +1,11 @@ { "_meta": { "ui": { - "resourceUri": "ui://github-mcp-server/get-me" + "resourceUri": "ui://github-mcp-server/get-me", + "visibility": [ + "model", + "app" + ] } }, "annotations": { diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 9f84c0211..191e56279 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -58,6 +58,7 @@ func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool { Meta: mcp.Meta{ "ui": map[string]any{ "resourceUri": GetMeUIResourceURI, + "visibility": []string{"model", "app"}, }, }, }, From bafcaf57c322d374299f54aa8b64eb4022694701 Mon Sep 17 00:00:00 2001 From: John CSA <103165870+jluocsa@users.noreply.github.com> Date: Tue, 19 May 2026 10:40:19 -0700 Subject: [PATCH 25/48] fix(pull_request_read): expose `after` cursor parameter in input schema (#2489) The `pull_request_read` tool description tells clients that `get_review_comments` uses cursor-based pagination (`perPage`, `after`), and the handler does plumb `after` through to the GraphQL query, but the input schema only declared `page` and `perPage` (via `WithPagination`). Because `after` was not advertised in `inputSchema`, MCP clients had no way to request it, leaving cursor pagination effectively broken: `perPage: 1` returned only the first thread with no way to advance, and `page` was silently ignored by the GraphQL path. This change adds `after` to the schema (string, optional) with a description making clear it only applies to `get_review_comments`. All other methods continue to ignore it. No handler behavior is changed. - Add `after` schema property after `WithPagination` in `PullRequestRead` - Regenerate `__toolsnaps__/pull_request_read.snap` and update README - Add a regression test asserting `after` is in the schema and a new table-driven case verifying the cursor is forwarded to the GraphQL query Fixes #2122 (for the `get_review_comments` pagination part). The remaining concerns in #2122 about unbounded response sizes for `get`, `get_diff`, and `get_reviews` are deferred to follow-up design. Co-authored-by: Sam Morrow --- README.md | 1 + .../__toolsnaps__/pull_request_read.snap | 4 ++ pkg/github/pullrequests.go | 7 +++ pkg/github/pullrequests_test.go | 53 +++++++++++++++++++ 4 files changed, 65 insertions(+) diff --git a/README.md b/README.md index 1030f83ca..b291af0e6 100644 --- a/README.md +++ b/README.md @@ -1111,6 +1111,7 @@ The following sets of tools are available: - **pull_request_read** - Get details for a single pull request - **Required OAuth Scopes**: `repo` + - `after`: Cursor for pagination, used only by the get_review_comments method. Pass the endCursor from the previous page's PageInfo to fetch the next page. (string, optional) - `method`: Action to specify what pull request data needs to be retrieved from GitHub. Possible options: 1. get - Get details of a specific pull request. diff --git a/pkg/github/__toolsnaps__/pull_request_read.snap b/pkg/github/__toolsnaps__/pull_request_read.snap index 26b4f14ca..d70f77e1e 100644 --- a/pkg/github/__toolsnaps__/pull_request_read.snap +++ b/pkg/github/__toolsnaps__/pull_request_read.snap @@ -6,6 +6,10 @@ "description": "Get information on a specific pull request in GitHub repository.", "inputSchema": { "properties": { + "after": { + "description": "Cursor for pagination, used only by the get_review_comments method. Pass the endCursor from the previous page's PageInfo to fetch the next page.", + "type": "string" + }, "method": { "description": "Action to specify what pull request data needs to be retrieved from GitHub. \nPossible options: \n 1. get - Get details of a specific pull request.\n 2. get_diff - Get the diff of a pull request.\n 3. get_status - Get combined commit status of a head commit in a pull request.\n 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.\n 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results.\n 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. Use with pagination parameters to control the number of results returned.\n 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned.\n 8. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR.\n", "enum": [ diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 3653c906b..9672f8524 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -58,6 +58,13 @@ Possible options: Required: []string{"method", "owner", "repo", "pullNumber"}, } WithPagination(schema) + // get_review_comments uses GraphQL cursor-based pagination and accepts the + // `after` cursor. Other methods rely on the `page`/`perPage` parameters + // added by WithPagination and ignore `after`. + schema.Properties["after"] = &jsonschema.Schema{ + Type: "string", + Description: "Cursor for pagination, used only by the get_review_comments method. Pass the endCursor from the previous page's PageInfo to fetch the next page.", + } return NewTool( ToolsetMetadataPullRequests, diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 29339ee7d..a73ba2e17 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -1687,6 +1687,11 @@ func Test_GetPullRequestComments(t *testing.T) { assert.Contains(t, schema.Properties, "owner") assert.Contains(t, schema.Properties, "repo") assert.Contains(t, schema.Properties, "pullNumber") + // `after` is required for cursor-based pagination on get_review_comments + // to be reachable from MCP clients; without it in the schema, callers + // cannot advance past the first page (issue #2122). + assert.Contains(t, schema.Properties, "after") + assert.Equal(t, "string", schema.Properties["after"].Type) assert.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo", "pullNumber"}) tests := []struct { @@ -1804,6 +1809,54 @@ func Test_GetPullRequestComments(t *testing.T) { assert.Equal(t, 1, result.TotalCount) }, }, + { + name: "after cursor is forwarded to GraphQL query", + gqlHTTPClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + reviewThreadsQuery{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "prNum": githubv4.Int(42), + "first": githubv4.Int(30), + "commentsPerThread": githubv4.Int(100), + "after": githubv4.String("cursor-page-2"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "pullRequest": map[string]any{ + "reviewThreads": map[string]any{ + "nodes": []map[string]any{}, + "pageInfo": map[string]any{ + "hasNextPage": false, + "hasPreviousPage": true, + "startCursor": "cursor3", + "endCursor": "cursor4", + }, + "totalCount": 5, + }, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "method": "get_review_comments", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "after": "cursor-page-2", + }, + expectError: false, + validateResult: func(t *testing.T, textContent string) { + var result MinimalReviewThreadsResponse + err := json.Unmarshal([]byte(textContent), &result) + require.NoError(t, err) + assert.Len(t, result.ReviewThreads, 0) + assert.Equal(t, true, result.PageInfo.HasPreviousPage) + assert.Equal(t, "cursor4", result.PageInfo.EndCursor) + }, + }, { name: "review threads fetch fails", gqlHTTPClient: githubv4mock.NewMockedHTTPClient( From 970155ad64d2866c78a7969350cf40d5e1cd95d2 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 20 May 2026 10:14:40 +0200 Subject: [PATCH 26/48] refactor: simplify NewServerTool naming (#2510) - Rename NewServerToolWithRawContextHandler -> NewServerTool. This is the preferred constructor for raw mcp.ToolHandler tools because it avoids creating closures at registration time, which matters for per-request servers that re-register all tools on every request. - Rename deprecated generic NewServerTool[In, Out] -> NewServerToolWithDeps to free up the simpler name and make its closure-based nature explicit. The dynamic tools package is the only legitimate user of this constructor because DynamicToolDependencies differs from the standard ToolDependencies. - Remove deprecated NewServerToolFromHandler. Its only callers can use the new NewServerTool directly via context-injected deps. - Update all call sites in dependencies.go, dynamic_tools.go, and registry_test.go. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/github/dependencies.go | 2 +- pkg/github/dynamic_tools.go | 2 +- pkg/inventory/registry_test.go | 24 +++++++++--------------- pkg/inventory/server_tool.go | 22 +++++++--------------- 4 files changed, 18 insertions(+), 32 deletions(-) diff --git a/pkg/github/dependencies.go b/pkg/github/dependencies.go index 16be84efb..eb856e0bd 100644 --- a/pkg/github/dependencies.go +++ b/pkg/github/dependencies.go @@ -253,7 +253,7 @@ func NewToolFromHandler( requiredScopes []scopes.Scope, handler func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest) (*mcp.CallToolResult, error), ) inventory.ServerTool { - st := inventory.NewServerToolWithRawContextHandler(tool, toolset, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + st := inventory.NewServerTool(tool, toolset, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { deps := MustDepsFromContext(ctx) return handler(ctx, deps, req) }) diff --git a/pkg/github/dynamic_tools.go b/pkg/github/dynamic_tools.go index 5c7d31d4e..1106616fa 100644 --- a/pkg/github/dynamic_tools.go +++ b/pkg/github/dynamic_tools.go @@ -31,7 +31,7 @@ type DynamicToolDependencies struct { // tools (ToolDependencies), so they intentionally use the closure pattern. func NewDynamicTool(toolset inventory.ToolsetMetadata, tool mcp.Tool, handler func(deps DynamicToolDependencies) mcp.ToolHandlerFor[map[string]any, any]) inventory.ServerTool { //nolint:staticcheck // SA1019: Dynamic tools use a different deps structure, closure pattern is intentional - return inventory.NewServerTool(tool, toolset, func(d any) mcp.ToolHandlerFor[map[string]any, any] { + return inventory.NewServerToolWithDeps(tool, toolset, func(d any) mcp.ToolHandlerFor[map[string]any, any] { return handler(d.(DynamicToolDependencies)) }) } diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go index 77c3bb57e..e6aedc620 100644 --- a/pkg/inventory/registry_test.go +++ b/pkg/inventory/registry_test.go @@ -38,7 +38,7 @@ func testToolsetMetadataWithDefault(id string, isDefault bool) ToolsetMetadata { // mockToolWithDefault creates a mock tool with a default toolset flag func mockToolWithDefault(name string, toolsetID string, readOnly bool, isDefault bool) ServerTool { - return NewServerToolFromHandler( + return NewServerTool( mcp.Tool{ Name: name, Annotations: &mcp.ToolAnnotations{ @@ -47,17 +47,15 @@ func mockToolWithDefault(name string, toolsetID string, readOnly bool, isDefault InputSchema: json.RawMessage(`{"type":"object","properties":{}}`), }, testToolsetMetadataWithDefault(toolsetID, isDefault), - func(_ any) mcp.ToolHandler { - return func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return nil, nil - } + func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return nil, nil }, ) } // mockTool creates a minimal ServerTool for testing func mockTool(name string, toolsetID string, readOnly bool) ServerTool { - return NewServerToolFromHandler( + return NewServerTool( mcp.Tool{ Name: name, Annotations: &mcp.ToolAnnotations{ @@ -66,10 +64,8 @@ func mockTool(name string, toolsetID string, readOnly bool) ServerTool { InputSchema: json.RawMessage(`{"type":"object","properties":{}}`), }, testToolsetMetadata(toolsetID), - func(_ any) mcp.ToolHandler { - return func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return nil, nil - } + func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return nil, nil }, ) } @@ -1839,7 +1835,7 @@ func TestWithTools_DeprecatedAliasAndFeatureFlag(t *testing.T) { // mockToolWithMeta creates a ServerTool with Meta for testing insiders mode func mockToolWithMeta(name string, toolsetID string, meta map[string]any) ServerTool { - return NewServerToolFromHandler( + return NewServerTool( mcp.Tool{ Name: name, Annotations: &mcp.ToolAnnotations{ @@ -1849,10 +1845,8 @@ func mockToolWithMeta(name string, toolsetID string, meta map[string]any) Server Meta: meta, }, testToolsetMetadata(toolsetID), - func(_ any) mcp.ToolHandler { - return func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - return nil, nil - } + func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return nil, nil }, ) } diff --git a/pkg/inventory/server_tool.go b/pkg/inventory/server_tool.go index 752a4c2bd..c80e9f4a3 100644 --- a/pkg/inventory/server_tool.go +++ b/pkg/inventory/server_tool.go @@ -118,13 +118,13 @@ func (st *ServerTool) RegisterFunc(s *mcp.Server, deps any) { s.AddTool(&toolCopy, handler) } -// NewServerTool creates a ServerTool from a tool definition, toolset metadata, and a typed handler function. +// NewServerToolWithDeps creates a ServerTool from a tool definition, toolset metadata, and a typed handler function. // The handler function takes dependencies (as any) and returns a typed handler. // Callers should type-assert deps to their typed dependencies struct. // // Deprecated: This creates closures at registration time. For better performance in // per-request server scenarios, use NewServerToolWithContextHandler instead. -func NewServerTool[In any, Out any](tool mcp.Tool, toolset ToolsetMetadata, handlerFn func(deps any) mcp.ToolHandlerFor[In, Out]) ServerTool { +func NewServerToolWithDeps[In any, Out any](tool mcp.Tool, toolset ToolsetMetadata, handlerFn func(deps any) mcp.ToolHandlerFor[In, Out]) ServerTool { return ServerTool{ Tool: tool, Toolset: toolset, @@ -166,22 +166,14 @@ func NewServerToolWithContextHandler[In any, Out any](tool mcp.Tool, toolset Too } } -// NewServerToolFromHandler creates a ServerTool from a tool definition, toolset metadata, and a raw handler function. -// Use this when you have a handler that already conforms to mcp.ToolHandler. -// -// Deprecated: This creates closures at registration time. For better performance in -// per-request server scenarios, use NewServerToolWithRawContextHandler instead. -func NewServerToolFromHandler(tool mcp.Tool, toolset ToolsetMetadata, handlerFn func(deps any) mcp.ToolHandler) ServerTool { - return ServerTool{Tool: tool, Toolset: toolset, HandlerFunc: handlerFn} -} - -// NewServerToolWithRawContextHandler creates a ServerTool with a raw handler that receives deps via context. -// This is the preferred approach for tools that use mcp.ToolHandler directly because it doesn't -// create closures at registration time. +// NewServerTool creates a ServerTool with a raw handler that receives deps via context. +// This is the preferred constructor for tools that use mcp.ToolHandler directly because +// it doesn't create closures at registration time, which is critical for performance in +// servers that create a new instance per request. // // The handler function is stored directly without wrapping in a deps closure. // Dependencies should be injected into context before calling tool handlers. -func NewServerToolWithRawContextHandler(tool mcp.Tool, toolset ToolsetMetadata, handler mcp.ToolHandler) ServerTool { +func NewServerTool(tool mcp.Tool, toolset ToolsetMetadata, handler mcp.ToolHandler) ServerTool { return ServerTool{ Tool: tool, Toolset: toolset, From 272d1605127f42b848c59fd053d07683728a8b45 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 20 May 2026 10:19:36 +0200 Subject: [PATCH 27/48] fix: return isError for argument validation failures (#2511) * refactor: simplify NewServerTool naming - Rename NewServerToolWithRawContextHandler -> NewServerTool. This is the preferred constructor for raw mcp.ToolHandler tools because it avoids creating closures at registration time, which matters for per-request servers that re-register all tools on every request. - Rename deprecated generic NewServerTool[In, Out] -> NewServerToolWithDeps to free up the simpler name and make its closure-based nature explicit. The dynamic tools package is the only legitimate user of this constructor because DynamicToolDependencies differs from the standard ToolDependencies. - Remove deprecated NewServerToolFromHandler. Its only callers can use the new NewServerTool directly via context-injected deps. - Update all call sites in dependencies.go, dynamic_tools.go, and registry_test.go. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: return isError for argument validation failures When tool argument unmarshalling fails (wrong types, malformed JSON), return a CallToolResult with IsError: true instead of a Go error. Returning a Go error is converted by the SDK into a JSON-RPC protocol error (-32603), which is invisible to agents and prevents self-correction. Returning IsError: true with the validation message lets agents see the problem and retry with corrected arguments. Affects: - NewServerToolWithDeps (was NewServerTool prior to the rename in #2510) - NewServerToolWithContextHandler Fixes #1952. Re-applies #2488 by @blackwell-systems on top of the NewServerTool rename. Co-authored-by: blackwell-systems <236632453+blackwell-systems@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: blackwell-systems <236632453+blackwell-systems@users.noreply.github.com> --- pkg/inventory/server_tool.go | 15 +++- pkg/inventory/server_tool_test.go | 118 ++++++++++++++++++++++++++++++ 2 files changed, 131 insertions(+), 2 deletions(-) create mode 100644 pkg/inventory/server_tool_test.go diff --git a/pkg/inventory/server_tool.go b/pkg/inventory/server_tool.go index c80e9f4a3..316fffaa9 100644 --- a/pkg/inventory/server_tool.go +++ b/pkg/inventory/server_tool.go @@ -3,6 +3,7 @@ package inventory import ( "context" "encoding/json" + "fmt" "github.com/github/github-mcp-server/pkg/octicons" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -133,7 +134,12 @@ func NewServerToolWithDeps[In any, Out any](tool mcp.Tool, toolset ToolsetMetada return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { var arguments In if err := json.Unmarshal(req.Params.Arguments, &arguments); err != nil { - return nil, err + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("invalid arguments: %s", err)}, + }, + IsError: true, + }, nil } resp, _, err := typedHandler(ctx, req, arguments) return resp, err @@ -157,7 +163,12 @@ func NewServerToolWithContextHandler[In any, Out any](tool mcp.Tool, toolset Too return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { var arguments In if err := json.Unmarshal(req.Params.Arguments, &arguments); err != nil { - return nil, err + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: fmt.Sprintf("invalid arguments: %s", err)}, + }, + IsError: true, + }, nil } resp, _, err := handler(ctx, req, arguments) return resp, err diff --git a/pkg/inventory/server_tool_test.go b/pkg/inventory/server_tool_test.go new file mode 100644 index 000000000..0263857c9 --- /dev/null +++ b/pkg/inventory/server_tool_test.go @@ -0,0 +1,118 @@ +package inventory + +import ( + "context" + "encoding/json" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewServerToolWithDeps_InvalidArguments_ReturnsIsError(t *testing.T) { + type expectedArgs struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + } + + tool := NewServerToolWithDeps( + mcp.Tool{Name: "test_tool"}, + testToolsetMetadata("test"), + func(_ any) mcp.ToolHandlerFor[expectedArgs, *mcp.CallToolResult] { + return func(_ context.Context, _ *mcp.CallToolRequest, _ expectedArgs) (*mcp.CallToolResult, *mcp.CallToolResult, error) { + t.Fatal("handler should not be called with invalid arguments") + return nil, nil, nil + } + }, + ) + + handler := tool.HandlerFunc(nil) + + badArgs, _ := json.Marshal(map[string]any{"owner": 12345, "repo": true}) + result, err := handler(context.Background(), &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{ + Name: "test_tool", + Arguments: badArgs, + }, + }) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsError) + assert.Len(t, result.Content, 1) + textContent, ok := result.Content[0].(*mcp.TextContent) + require.True(t, ok) + assert.Contains(t, textContent.Text, "invalid arguments") +} + +func TestNewServerToolWithContextHandler_InvalidArguments_ReturnsIsError(t *testing.T) { + type expectedArgs struct { + Query string `json:"query"` + Limit int `json:"limit"` + } + + tool := NewServerToolWithContextHandler( + mcp.Tool{Name: "test_context_tool"}, + testToolsetMetadata("test"), + func(_ context.Context, _ *mcp.CallToolRequest, _ expectedArgs) (*mcp.CallToolResult, any, error) { + t.Fatal("handler should not be called with invalid arguments") + return nil, nil, nil + }, + ) + + handler := tool.HandlerFunc(nil) + + result, err := handler(context.Background(), &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{ + Name: "test_context_tool", + Arguments: json.RawMessage(`{not valid json`), + }, + }) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsError) + assert.Len(t, result.Content, 1) + textContent, ok := result.Content[0].(*mcp.TextContent) + require.True(t, ok) + assert.Contains(t, textContent.Text, "invalid arguments") +} + +func TestNewServerToolWithDeps_ValidArguments_Succeeds(t *testing.T) { + type expectedArgs struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + } + + tool := NewServerToolWithDeps( + mcp.Tool{Name: "test_tool"}, + testToolsetMetadata("test"), + func(_ any) mcp.ToolHandlerFor[expectedArgs, *mcp.CallToolResult] { + return func(_ context.Context, _ *mcp.CallToolRequest, args expectedArgs) (*mcp.CallToolResult, *mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "success: " + args.Owner + "/" + args.Repo}, + }, + }, nil, nil + } + }, + ) + + handler := tool.HandlerFunc(nil) + + goodArgs, _ := json.Marshal(map[string]any{"owner": "octocat", "repo": "hello-world"}) + result, err := handler(context.Background(), &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{ + Name: "test_tool", + Arguments: goodArgs, + }, + }) + + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.IsError) + textContent, ok := result.Content[0].(*mcp.TextContent) + require.True(t, ok) + assert.Equal(t, "success: octocat/hello-world", textContent.Text) +} From 0f0506d2fd72ef2f5cb7a7b07b8186053d166782 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 20 May 2026 10:51:47 +0200 Subject: [PATCH 28/48] refactor: remove dynamic toolsets and deprecated closure constructor (#2512) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dynamic toolset discovery (the meta-tools enable_toolset, list_available_toolsets, get_toolset_tools and the --dynamic-toolsets / GITHUB_DYNAMIC_TOOLSETS switch) was a local-only feature never offered by the remote server. Removing it deletes a meaningful chunk of branching, configuration surface and tests for a path no longer in active use. The deprecated closure-based NewServerToolWithDeps generic constructor was only kept around for the dynamic tool registration path and is removed together with it. Going forward there are exactly two constructors: - NewServerTool — raw mcp.ToolHandler, no closure, no unmarshalling - NewServerToolWithContextHandler[In, Out] — typed handler, deps via context Inventory methods that only existed for the dynamic path (ToolsForToolset, IsToolsetEnabled, EnableToolset, EnabledToolsetIDs) are removed. ResolvedEnabledToolsets loses its dynamic flag. Also strips dynamic references from the README, server configuration docs, copilot-instructions, mcp-diff workflow, and conformance-test script. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 3 +- .github/workflows/mcp-diff.yml | 17 +- README.md | 35 +--- cmd/github-mcp-server/generate_docs.go | 8 +- cmd/github-mcp-server/main.go | 4 - docs/installation-guides/README.md | 1 - docs/server-configuration.md | 57 +----- docs/toolsets-and-icons.md | 1 - internal/ghmcp/server.go | 9 +- pkg/github/dynamic_tools.go | 217 ----------------------- pkg/github/dynamic_tools_test.go | 236 ------------------------- pkg/github/server.go | 48 +---- pkg/github/server_test.go | 27 +-- pkg/github/tools.go | 9 +- pkg/http/handler.go | 5 +- pkg/http/handler_test.go | 2 +- pkg/http/server.go | 3 - pkg/inventory/builder.go | 3 +- pkg/inventory/filters.go | 57 ------ pkg/inventory/registry.go | 1 - pkg/inventory/registry_test.go | 39 ---- pkg/inventory/server_tool.go | 29 --- pkg/inventory/server_tool_test.go | 54 +----- script/conformance-test | 128 +++----------- 24 files changed, 51 insertions(+), 942 deletions(-) delete mode 100644 pkg/github/dynamic_tools.go delete mode 100644 pkg/github/dynamic_tools_test.go diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index e0d6873d1..975df2a63 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -243,7 +243,6 @@ All workflows run on push/PR unless noted. Located in `.github/workflows/`: - **GITHUB_HOST** - For GitHub Enterprise Server (prefix with `https://`) - **GITHUB_TOOLSETS** - Comma-separated toolset list (overrides --toolsets flag) - **GITHUB_READ_ONLY** - Set to "1" for read-only mode -- **GITHUB_DYNAMIC_TOOLSETS** - Set to "1" for dynamic toolset discovery - **UPDATE_TOOLSNAPS** - Set to "true" when running tests to update snapshots - **GITHUB_MCP_SERVER_E2E_TOKEN** - Token for e2e tests - **GITHUB_MCP_SERVER_E2E_DEBUG** - Set to "true" for in-process e2e debugging @@ -273,7 +272,7 @@ server.json - MCP server registry metadata `cmd/github-mcp-server/main.go` - Uses cobra for CLI, viper for config, supports: - `stdio` command (default) - MCP stdio transport - `generate-docs` command - Documentation generation -- Flags: --toolsets, --read-only, --dynamic-toolsets, --gh-host, --log-file +- Flags: --toolsets, --read-only, --gh-host, --log-file ## Important Reminders diff --git a/.github/workflows/mcp-diff.yml b/.github/workflows/mcp-diff.yml index 56f350081..bb6341c09 100644 --- a/.github/workflows/mcp-diff.yml +++ b/.github/workflows/mcp-diff.yml @@ -34,8 +34,6 @@ jobs: [ {"name": "default", "args": ""}, {"name": "read-only", "args": "--read-only"}, - {"name": "dynamic-toolsets", "args": "--dynamic-toolsets"}, - {"name": "read-only+dynamic", "args": "--read-only --dynamic-toolsets"}, {"name": "toolsets-repos", "args": "--toolsets=repos"}, {"name": "toolsets-issues", "args": "--toolsets=issues"}, {"name": "toolsets-context", "args": "--toolsets=context"}, @@ -45,20 +43,7 @@ jobs: {"name": "toolsets-all", "args": "--toolsets=all"}, {"name": "tools-get_me", "args": "--tools=get_me"}, {"name": "tools-get_me,list_issues", "args": "--tools=get_me,list_issues"}, - {"name": "toolsets-repos+read-only", "args": "--toolsets=repos --read-only"}, - {"name": "toolsets-all+dynamic", "args": "--toolsets=all --dynamic-toolsets"}, - {"name": "toolsets-repos+dynamic", "args": "--toolsets=repos --dynamic-toolsets"}, - {"name": "toolsets-repos,issues+dynamic", "args": "--toolsets=repos,issues --dynamic-toolsets"}, - { - "name": "dynamic-tool-calls", - "args": "--dynamic-toolsets", - "custom_messages": [ - {"id": 10, "name": "list_toolsets_before", "message": {"jsonrpc": "2.0", "id": 10, "method": "tools/call", "params": {"name": "list_available_toolsets", "arguments": {}}}}, - {"id": 11, "name": "get_toolset_tools", "message": {"jsonrpc": "2.0", "id": 11, "method": "tools/call", "params": {"name": "get_toolset_tools", "arguments": {"toolset": "repos"}}}}, - {"id": 12, "name": "enable_toolset", "message": {"jsonrpc": "2.0", "id": 12, "method": "tools/call", "params": {"name": "enable_toolset", "arguments": {"toolset": "repos"}}}}, - {"id": 13, "name": "list_toolsets_after", "message": {"jsonrpc": "2.0", "id": 13, "method": "tools/call", "params": {"name": "list_available_toolsets", "arguments": {}}}} - ] - } + {"name": "toolsets-repos+read-only", "args": "--toolsets=repos --read-only"} ] - name: Add interpretation note diff --git a/README.md b/README.md index b291af0e6..e4f70b622 100644 --- a/README.md +++ b/README.md @@ -424,7 +424,7 @@ The environment variable `GITHUB_TOOLSETS` takes precedence over the command lin #### Specifying Individual Tools -You can also configure specific tools using the `--tools` flag. Tools can be used independently or combined with toolsets and dynamic toolsets discovery for fine-grained control. +You can also configure specific tools using the `--tools` flag. Tools can be used independently or combined with toolsets for fine-grained control. 1. **Using Command Line Argument**: @@ -446,17 +446,9 @@ You can also configure specific tools using the `--tools` flag. Tools can be use This registers all tools from `repos` and `issues` toolsets, plus `get_gist`. -4. **Combining with Dynamic Toolsets** (additive): - - ```bash - github-mcp-server --tools get_file_contents --dynamic-toolsets - ``` - - This registers `get_file_contents` plus the dynamic toolset tools (`enable_toolset`, `list_available_toolsets`, `get_toolset_tools`). - **Important Notes:** -- Tools, toolsets, and dynamic toolsets can all be used together +- Tools and toolsets can be used together - Read-only mode takes priority: write tools are skipped if `--read-only` is set, even if explicitly requested via `--tools` - Tool names must match exactly (e.g., `get_file_contents`, not `getFileContents`). Invalid tool names will cause the server to fail at startup with an error message - When tools are renamed, old names are preserved as aliases for backward compatibility. See [Tool Renaming](docs/tool-renaming.md) for details. @@ -1462,29 +1454,6 @@ The following sets of tools are available: -## Dynamic Tool Discovery - -**Note**: This feature is currently in beta and is not available in the Remote GitHub MCP Server. Please test it out and let us know if you encounter any issues. - -Instead of starting with all tools enabled, you can turn on dynamic toolset discovery. Dynamic toolsets allow the MCP host to list and enable toolsets in response to a user prompt. This should help to avoid situations where the model gets confused by the sheer number of tools available. - -### Using Dynamic Tool Discovery - -When using the binary, you can pass the `--dynamic-toolsets` flag. - -```bash -./github-mcp-server --dynamic-toolsets -``` - -When using Docker, you can pass the toolsets as environment variables: - -```bash -docker run -i --rm \ - -e GITHUB_PERSONAL_ACCESS_TOKEN= \ - -e GITHUB_DYNAMIC_TOOLSETS=1 \ - ghcr.io/github/github-mcp-server -``` - ## Read-Only Mode To run the server in read-only mode, you can use the `--read-only` flag. This will only offer read-only tools, preventing any modifications to repositories, issues, pull requests, etc. diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index 7d7b1f6ab..7a97e4f66 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -145,8 +145,8 @@ func generateToolsetsDoc(i *inventory.Inventory) string { fmt.Fprintf(&buf, "| %s | `context` | **Strongly recommended**: Tools that provide context about the current user and GitHub context you are operating in |\n", contextIcon) // AvailableToolsets() returns toolsets that have tools, sorted by ID - // Exclude context (custom description above) and dynamic (internal only) - for _, ts := range i.AvailableToolsets("context", "dynamic") { + // Exclude context (custom description above) + for _, ts := range i.AvailableToolsets("context") { icon := octiconImg(ts.Icon) fmt.Fprintf(&buf, "| %s | `%s` | %s |\n", icon, ts.ID, ts.Description) } @@ -346,8 +346,8 @@ func generateRemoteToolsetsDoc() string { fmt.Fprintf(&buf, "| %s
`all` | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2F%%22%%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2Freadonly%%22%%7D) |\n", allIcon) // AvailableToolsets() returns toolsets that have tools, sorted by ID - // Exclude context (handled separately) and dynamic (internal only) - for _, ts := range r.AvailableToolsets("context", "dynamic") { + // Exclude context (handled separately) + for _, ts := range r.AvailableToolsets("context") { idStr := string(ts.ID) apiURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s", idStr) diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 8f2ae5852..ec948ab6e 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -85,7 +85,6 @@ var ( EnabledToolsets: enabledToolsets, EnabledTools: enabledTools, EnabledFeatures: enabledFeatures, - DynamicToolsets: viper.GetBool("dynamic_toolsets"), ReadOnly: viper.GetBool("read-only"), ExportTranslations: viper.GetBool("export-translations"), EnableCommandLogging: viper.GetBool("enable-command-logging"), @@ -144,7 +143,6 @@ var ( ReadOnly: viper.GetBool("read-only"), EnabledToolsets: enabledToolsets, EnabledTools: enabledTools, - DynamicToolsets: viper.GetBool("dynamic_toolsets"), ExcludeTools: excludeTools, InsidersMode: viper.GetBool("insiders"), } @@ -165,7 +163,6 @@ func init() { rootCmd.PersistentFlags().StringSlice("tools", nil, "Comma-separated list of specific tools to enable") rootCmd.PersistentFlags().StringSlice("exclude-tools", nil, "Comma-separated list of tool names to disable regardless of other settings") rootCmd.PersistentFlags().StringSlice("features", nil, "Comma-separated list of feature flags to enable") - rootCmd.PersistentFlags().Bool("dynamic-toolsets", false, "Enable dynamic toolsets") rootCmd.PersistentFlags().Bool("read-only", false, "Restrict the server to read-only operations") rootCmd.PersistentFlags().String("log-file", "", "Path to log file") rootCmd.PersistentFlags().Bool("enable-command-logging", false, "When enabled, the server will log all command requests and responses to the log file") @@ -187,7 +184,6 @@ func init() { _ = viper.BindPFlag("tools", rootCmd.PersistentFlags().Lookup("tools")) _ = viper.BindPFlag("exclude_tools", rootCmd.PersistentFlags().Lookup("exclude-tools")) _ = viper.BindPFlag("features", rootCmd.PersistentFlags().Lookup("features")) - _ = viper.BindPFlag("dynamic_toolsets", rootCmd.PersistentFlags().Lookup("dynamic-toolsets")) _ = viper.BindPFlag("read-only", rootCmd.PersistentFlags().Lookup("read-only")) _ = viper.BindPFlag("log-file", rootCmd.PersistentFlags().Lookup("log-file")) _ = viper.BindPFlag("enable-command-logging", rootCmd.PersistentFlags().Lookup("enable-command-logging")) diff --git a/docs/installation-guides/README.md b/docs/installation-guides/README.md index aadfa6a04..0c0f7840e 100644 --- a/docs/installation-guides/README.md +++ b/docs/installation-guides/README.md @@ -104,6 +104,5 @@ If you encounter issues: After installation, you may want to explore: - **Toolsets**: Enable/disable specific GitHub API capabilities - **Read-Only Mode**: Restrict to read-only operations -- **Dynamic Tool Discovery**: Enable tools on-demand - **Lockdown Mode**: Hide public issue details created by users without push access diff --git a/docs/server-configuration.md b/docs/server-configuration.md index 693c096a1..2342664c3 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -11,7 +11,6 @@ We currently support the following ways in which the GitHub MCP Server can be co | Individual Tools | `X-MCP-Tools` header | `--tools` flag or `GITHUB_TOOLS` env var | | Exclude Tools | `X-MCP-Exclude-Tools` header | `--exclude-tools` flag or `GITHUB_EXCLUDE_TOOLS` env var | | Read-Only Mode | `X-MCP-Readonly` header or `/readonly` URL | `--read-only` flag or `GITHUB_READ_ONLY` env var | -| Dynamic Mode | Not available | `--dynamic-toolsets` flag or `GITHUB_DYNAMIC_TOOLSETS` env var | | Lockdown Mode | `X-MCP-Lockdown` header | `--lockdown-mode` flag or `GITHUB_LOCKDOWN_MODE` env var | | Insiders Mode | `X-MCP-Insiders` header or `/insiders` URL | `--insiders` flag or `GITHUB_INSIDERS` env var | | Feature Flags | `X-MCP-Features` header | `--features` flag | @@ -24,7 +23,7 @@ We currently support the following ways in which the GitHub MCP Server can be co ## How Configuration Works -All configuration options are **composable**: you can combine toolsets, individual tools, excluded tools, dynamic discovery, read-only mode and lockdown mode in any way that suits your workflow. +All configuration options are **composable**: you can combine toolsets, individual tools, excluded tools, read-only mode and lockdown mode in any way that suits your workflow. Note: **read-only** mode acts as a strict security filter that takes precedence over any other configuration, by disabling write tools even when explicitly requested. @@ -287,59 +286,6 @@ When active, this mode will disable all tools that are not read-only even if the --- -### Dynamic Discovery (Local Only) - -**Best for:** Letting the LLM discover and enable toolsets as needed. - -Starts with only discovery tools (`enable_toolset`, `list_available_toolsets`, `get_toolset_tools`), then expands on demand. - - - - - - -
Local Server Only
- -```json -{ - "type": "stdio", - "command": "go", - "args": [ - "run", - "./cmd/github-mcp-server", - "stdio", - "--dynamic-toolsets" - ], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}" - } -} -``` - -**With some tools pre-enabled:** -```json -{ - "type": "stdio", - "command": "go", - "args": [ - "run", - "./cmd/github-mcp-server", - "stdio", - "--dynamic-toolsets", - "--tools=get_me,search_code" - ], - "env": { - "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}" - } -} -``` - -
- -When both dynamic mode and specific tools are enabled in the server configuration, the server will start with the 3 dynamic tools + the specified tools. - ---- - ### Lockdown Mode **Best for:** Public repositories where you want to limit content from users without push access. @@ -521,7 +467,6 @@ See [Scope Filtering](./scope-filtering.md) for details on how filtering works w | Server fails to start | Invalid tool name in `--tools` or `X-MCP-Tools` | Check tool name spelling; use exact names from [Tools list](../README.md#tools) | | Write tools not working | Read-only mode enabled | Remove `--read-only` flag or `X-MCP-Readonly` header | | Tools missing | Toolset not enabled | Add the required toolset or specific tool | -| Dynamic tools not available | Using remote server | Dynamic mode is available in the local MCP server only | --- diff --git a/docs/toolsets-and-icons.md b/docs/toolsets-and-icons.md index 9c26b4aa1..9228248ec 100644 --- a/docs/toolsets-and-icons.md +++ b/docs/toolsets-and-icons.md @@ -161,7 +161,6 @@ icons := octicons.Icons("repo") | Labels | `tag` | | Stargazers | `star` | | Notifications | `bell` | -| Dynamic | `tools` | | Copilot | `copilot` | | Support Search | `book` | diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 6c8c3934d..3ca249dd1 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -153,7 +153,7 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se inventoryBuilder := github.NewInventory(cfg.Translator). WithDeprecatedAliases(github.DeprecatedToolAliases). WithReadOnly(cfg.ReadOnly). - WithToolsets(github.ResolvedEnabledToolsets(cfg.DynamicToolsets, cfg.EnabledToolsets, cfg.EnabledTools)). + WithToolsets(github.ResolvedEnabledToolsets(cfg.EnabledToolsets, cfg.EnabledTools)). WithTools(github.CleanTools(cfg.EnabledTools)). WithExcludeTools(cfg.ExcludeTools). WithServerInstructions(). @@ -210,10 +210,6 @@ type StdioServerConfig struct { // Items with FeatureFlagEnable matching an entry in this list will be available EnabledFeatures []string - // Whether to enable dynamic toolsets - // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#dynamic-tool-discovery - DynamicToolsets bool - // ReadOnly indicates if we should only register read-only tools ReadOnly bool @@ -267,7 +263,7 @@ func RunStdioServer(cfg StdioServerConfig) error { slogHandler = slog.NewTextHandler(logOutput, &slog.HandlerOptions{Level: slog.LevelInfo}) } logger := slog.New(slogHandler) - logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "dynamicToolsets", cfg.DynamicToolsets, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode) + logger.Info("starting server", "version", cfg.Version, "host", cfg.Host, "readOnly", cfg.ReadOnly, "lockdownEnabled", cfg.LockdownMode) // Fetch token scopes for scope-based tool filtering (PAT tokens only) // Only classic PATs (ghp_ prefix) return OAuth scopes via X-OAuth-Scopes header. @@ -292,7 +288,6 @@ func RunStdioServer(cfg StdioServerConfig) error { EnabledToolsets: cfg.EnabledToolsets, EnabledTools: cfg.EnabledTools, EnabledFeatures: cfg.EnabledFeatures, - DynamicToolsets: cfg.DynamicToolsets, ReadOnly: cfg.ReadOnly, Translator: t, ContentWindowSize: cfg.ContentWindowSize, diff --git a/pkg/github/dynamic_tools.go b/pkg/github/dynamic_tools.go deleted file mode 100644 index 1106616fa..000000000 --- a/pkg/github/dynamic_tools.go +++ /dev/null @@ -1,217 +0,0 @@ -package github - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" - "github.com/google/jsonschema-go/jsonschema" - "github.com/modelcontextprotocol/go-sdk/mcp" -) - -// DynamicToolDependencies contains dependencies for dynamic toolset management tools. -// It includes the managed Inventory, the server for registration, and the deps -// that will be passed to tools when they are dynamically enabled. -type DynamicToolDependencies struct { - // Server is the MCP server to register tools with - Server *mcp.Server - // Inventory contains all available tools, resources and prompts that can be enabled dynamically - Inventory *inventory.Inventory - // ToolDeps are the dependencies passed to tools when they are registered - ToolDeps any - // T is the translation helper function - T translations.TranslationHelperFunc -} - -// NewDynamicTool creates a ServerTool with fully-typed DynamicToolDependencies. -// Dynamic tools use a different dependency structure (DynamicToolDependencies) than regular -// tools (ToolDependencies), so they intentionally use the closure pattern. -func NewDynamicTool(toolset inventory.ToolsetMetadata, tool mcp.Tool, handler func(deps DynamicToolDependencies) mcp.ToolHandlerFor[map[string]any, any]) inventory.ServerTool { - //nolint:staticcheck // SA1019: Dynamic tools use a different deps structure, closure pattern is intentional - return inventory.NewServerToolWithDeps(tool, toolset, func(d any) mcp.ToolHandlerFor[map[string]any, any] { - return handler(d.(DynamicToolDependencies)) - }) -} - -// toolsetIDsEnum returns the list of toolset IDs as an enum for JSON Schema. -func toolsetIDsEnum(r *inventory.Inventory) []any { - toolsetIDs := r.ToolsetIDs() - result := make([]any, len(toolsetIDs)) - for i, id := range toolsetIDs { - result[i] = id - } - return result -} - -// DynamicTools returns the tools for dynamic toolset management. -// These tools allow runtime discovery and enablement of inventory. -// The r parameter provides the available toolset IDs for JSON Schema enums. -func DynamicTools(r *inventory.Inventory) []inventory.ServerTool { - return []inventory.ServerTool{ - ListAvailableToolsets(), - GetToolsetsTools(r), - EnableToolset(r), - } -} - -// EnableToolset creates a tool that enables a toolset at runtime. -func EnableToolset(r *inventory.Inventory) inventory.ServerTool { - return NewDynamicTool( - ToolsetMetadataDynamic, - mcp.Tool{ - Name: "enable_toolset", - Description: "Enable one of the sets of tools the GitHub MCP server provides, use get_toolset_tools and list_available_toolsets first to see what this will enable", - Annotations: &mcp.ToolAnnotations{ - Title: "Enable a toolset", - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "toolset": { - Type: "string", - Description: "The name of the toolset to enable", - Enum: toolsetIDsEnum(r), - }, - }, - Required: []string{"toolset"}, - }, - }, - func(deps DynamicToolDependencies) mcp.ToolHandlerFor[map[string]any, any] { - return func(_ context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - toolsetName, err := RequiredParam[string](args, "toolset") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - toolsetID := inventory.ToolsetID(toolsetName) - - if !deps.Inventory.HasToolset(toolsetID) { - return utils.NewToolResultError(fmt.Sprintf("Toolset %s not found", toolsetName)), nil, nil - } - - if deps.Inventory.IsToolsetEnabled(toolsetID) { - return utils.NewToolResultText(fmt.Sprintf("Toolset %s is already enabled", toolsetName)), nil, nil - } - - // Mark the toolset as enabled so IsToolsetEnabled returns true - deps.Inventory.EnableToolset(toolsetID) - - // Get tools for this toolset and register them with the managed deps - toolsForToolset := deps.Inventory.ToolsForToolset(toolsetID) - for _, st := range toolsForToolset { - st.RegisterFunc(deps.Server, deps.ToolDeps) - } - - return utils.NewToolResultText(fmt.Sprintf("Toolset %s enabled with %d tools", toolsetName, len(toolsForToolset))), nil, nil - } - }, - ) -} - -// ListAvailableToolsets creates a tool that lists all available inventory. -func ListAvailableToolsets() inventory.ServerTool { - return NewDynamicTool( - ToolsetMetadataDynamic, - mcp.Tool{ - Name: "list_available_toolsets", - Description: "List all available toolsets this GitHub MCP server can offer, providing the enabled status of each. Use this when a task could be achieved with a GitHub tool and the currently available tools aren't enough. Call get_toolset_tools with these toolset names to discover specific tools you can call", - Annotations: &mcp.ToolAnnotations{ - Title: "List available toolsets", - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{}, - }, - }, - func(deps DynamicToolDependencies) mcp.ToolHandlerFor[map[string]any, any] { - return func(_ context.Context, _ *mcp.CallToolRequest, _ map[string]any) (*mcp.CallToolResult, any, error) { - toolsetIDs := deps.Inventory.ToolsetIDs() - descriptions := deps.Inventory.ToolsetDescriptions() - - payload := make([]map[string]string, 0, len(toolsetIDs)) - for _, id := range toolsetIDs { - t := map[string]string{ - "name": string(id), - "description": descriptions[id], - "can_enable": "true", - "currently_enabled": fmt.Sprintf("%t", deps.Inventory.IsToolsetEnabled(id)), - } - payload = append(payload, t) - } - - r, err := json.Marshal(payload) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal features: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - } - }, - ) -} - -// GetToolsetsTools creates a tool that lists all tools in a specific toolset. -func GetToolsetsTools(r *inventory.Inventory) inventory.ServerTool { - return NewDynamicTool( - ToolsetMetadataDynamic, - mcp.Tool{ - Name: "get_toolset_tools", - Description: "Lists all the capabilities that are enabled with the specified toolset, use this to get clarity on whether enabling a toolset would help you to complete a task", - Annotations: &mcp.ToolAnnotations{ - Title: "List all tools in a toolset", - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "toolset": { - Type: "string", - Description: "The name of the toolset you want to get the tools for", - Enum: toolsetIDsEnum(r), - }, - }, - Required: []string{"toolset"}, - }, - }, - func(deps DynamicToolDependencies) mcp.ToolHandlerFor[map[string]any, any] { - return func(_ context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - toolsetName, err := RequiredParam[string](args, "toolset") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - toolsetID := inventory.ToolsetID(toolsetName) - - if !deps.Inventory.HasToolset(toolsetID) { - return utils.NewToolResultError(fmt.Sprintf("Toolset %s not found", toolsetName)), nil, nil - } - - // Get all tools for this toolset (ignoring current filters for discovery) - toolsInToolset := deps.Inventory.ToolsForToolset(toolsetID) - payload := make([]map[string]string, 0, len(toolsInToolset)) - - for _, st := range toolsInToolset { - tool := map[string]string{ - "name": st.Tool.Name, - "description": st.Tool.Description, - "can_enable": "true", - "toolset": toolsetName, - } - payload = append(payload, tool) - } - - r, err := json.Marshal(payload) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal features: %w", err) - } - - return utils.NewToolResultText(string(r)), nil, nil - } - }, - ) -} diff --git a/pkg/github/dynamic_tools_test.go b/pkg/github/dynamic_tools_test.go deleted file mode 100644 index ec559099e..000000000 --- a/pkg/github/dynamic_tools_test.go +++ /dev/null @@ -1,236 +0,0 @@ -package github - -import ( - "context" - "encoding/json" - "testing" - - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/google/jsonschema-go/jsonschema" - "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// createDynamicRequest creates an MCP request with the given arguments for dynamic tools. -func createDynamicRequest(args map[string]any) *mcp.CallToolRequest { - argsJSON, _ := json.Marshal(args) - return &mcp.CallToolRequest{ - Params: &mcp.CallToolParamsRaw{ - Arguments: json.RawMessage(argsJSON), - }, - } -} - -func TestDynamicTools_ListAvailableToolsets(t *testing.T) { - // Build a registry with no toolsets enabled (dynamic mode) - reg, err := NewInventory(translations.NullTranslationHelper). - WithToolsets([]string{}). - Build() - require.NoError(t, err) - - // Create a mock server - server := mcp.NewServer(&mcp.Implementation{Name: "test"}, nil) - - // Create dynamic tool dependencies - deps := DynamicToolDependencies{ - Server: server, - Inventory: reg, - ToolDeps: nil, - T: translations.NullTranslationHelper, - } - - // Get the list_available_toolsets tool - tool := ListAvailableToolsets() - handler := tool.Handler(deps) - - // Call the handler - result, err := handler(context.Background(), createDynamicRequest(map[string]any{})) - require.NoError(t, err) - require.NotNil(t, result) - require.Len(t, result.Content, 1) - - // Parse the result - var toolsets []map[string]string - textContent := result.Content[0].(*mcp.TextContent) - err = json.Unmarshal([]byte(textContent.Text), &toolsets) - require.NoError(t, err) - - // Verify we got toolsets - assert.NotEmpty(t, toolsets, "should have available toolsets") - - // Find the repos toolset and verify it's not enabled - var reposToolset map[string]string - for _, ts := range toolsets { - if ts["name"] == "repos" { - reposToolset = ts - break - } - } - require.NotNil(t, reposToolset, "repos toolset should exist") - assert.Equal(t, "false", reposToolset["currently_enabled"], "repos should not be enabled initially") -} - -func TestDynamicTools_GetToolsetTools(t *testing.T) { - // Build a registry with no toolsets enabled (dynamic mode) - reg, err := NewInventory(translations.NullTranslationHelper). - WithToolsets([]string{}). - Build() - require.NoError(t, err) - - // Create a mock server - server := mcp.NewServer(&mcp.Implementation{Name: "test"}, nil) - - // Create dynamic tool dependencies - deps := DynamicToolDependencies{ - Server: server, - Inventory: reg, - ToolDeps: nil, - T: translations.NullTranslationHelper, - } - - // Get the get_toolset_tools tool - tool := GetToolsetsTools(reg) - handler := tool.Handler(deps) - - // Call the handler for repos toolset - result, err := handler(context.Background(), createDynamicRequest(map[string]any{ - "toolset": "repos", - })) - require.NoError(t, err) - require.NotNil(t, result) - require.Len(t, result.Content, 1) - - // Parse the result - var tools []map[string]string - textContent := result.Content[0].(*mcp.TextContent) - err = json.Unmarshal([]byte(textContent.Text), &tools) - require.NoError(t, err) - - // Verify we got tools for the repos toolset - assert.NotEmpty(t, tools, "repos toolset should have tools") - - // Verify at least get_commit is there (a repos toolset tool) - var foundGetCommit bool - for _, tool := range tools { - if tool["name"] == "get_commit" { - foundGetCommit = true - break - } - } - assert.True(t, foundGetCommit, "get_commit should be in repos toolset") -} - -func TestDynamicTools_EnableToolset(t *testing.T) { - // Build a registry with no toolsets enabled (dynamic mode) - reg, err := NewInventory(translations.NullTranslationHelper). - WithToolsets([]string{}). - Build() - require.NoError(t, err) - - // Create a mock server - server := mcp.NewServer(&mcp.Implementation{Name: "test"}, nil) - - // Create dynamic tool dependencies - deps := DynamicToolDependencies{ - Server: server, - Inventory: reg, - ToolDeps: NewBaseDeps(nil, nil, nil, nil, translations.NullTranslationHelper, FeatureFlags{}, 0, nil, stubExporters()), - T: translations.NullTranslationHelper, - } - - // Verify repos is not enabled initially - assert.False(t, reg.IsToolsetEnabled(inventory.ToolsetID("repos"))) - - // Get the enable_toolset tool - tool := EnableToolset(reg) - handler := tool.Handler(deps) - - // Enable the repos toolset - result, err := handler(context.Background(), createDynamicRequest(map[string]any{ - "toolset": "repos", - })) - require.NoError(t, err) - require.NotNil(t, result) - require.Len(t, result.Content, 1) - - // Verify the toolset is now enabled - assert.True(t, reg.IsToolsetEnabled(inventory.ToolsetID("repos")), "repos should be enabled after enable_toolset") - - // Verify the success message - textContent := result.Content[0].(*mcp.TextContent) - assert.Contains(t, textContent.Text, "enabled") - - // Try enabling again - should say already enabled - result2, err := handler(context.Background(), createDynamicRequest(map[string]any{ - "toolset": "repos", - })) - require.NoError(t, err) - textContent2 := result2.Content[0].(*mcp.TextContent) - assert.Contains(t, textContent2.Text, "already enabled") -} - -func TestDynamicTools_EnableToolset_InvalidToolset(t *testing.T) { - // Build a registry with no toolsets enabled (dynamic mode) - reg, err := NewInventory(translations.NullTranslationHelper). - WithToolsets([]string{}). - Build() - require.NoError(t, err) - - // Create a mock server - server := mcp.NewServer(&mcp.Implementation{Name: "test"}, nil) - - // Create dynamic tool dependencies - deps := DynamicToolDependencies{ - Server: server, - Inventory: reg, - ToolDeps: nil, - T: translations.NullTranslationHelper, - } - - // Get the enable_toolset tool - tool := EnableToolset(reg) - handler := tool.Handler(deps) - - // Try to enable a non-existent toolset - result, err := handler(context.Background(), createDynamicRequest(map[string]any{ - "toolset": "nonexistent", - })) - require.NoError(t, err) - require.NotNil(t, result) - - // Should be an error result - textContent := result.Content[0].(*mcp.TextContent) - assert.Contains(t, textContent.Text, "not found") -} - -func TestDynamicTools_ToolsetsEnum(t *testing.T) { - // Build a registry - reg, err := NewInventory(translations.NullTranslationHelper).Build() - require.NoError(t, err) - - // Get tools to verify they have proper enum values - tools := DynamicTools(reg) - - // Find enable_toolset and get_toolset_tools - for _, tool := range tools { - if tool.Tool.Name == "enable_toolset" || tool.Tool.Name == "get_toolset_tools" { - // Verify the toolset property has an enum - schema := tool.Tool.InputSchema.(*jsonschema.Schema) - toolsetProp := schema.Properties["toolset"] - require.NotNil(t, toolsetProp, "toolset property should exist") - assert.NotEmpty(t, toolsetProp.Enum, "toolset property should have enum values") - - // Verify repos is in the enum - var foundRepos bool - for _, v := range toolsetProp.Enum { - if v == inventory.ToolsetID("repos") { - foundRepos = true - break - } - } - assert.True(t, foundRepos, "repos should be in toolset enum for %s", tool.Tool.Name) - } - } -} diff --git a/pkg/github/server.go b/pkg/github/server.go index a9a75642f..41e502db3 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -38,10 +38,6 @@ type MCPServerConfig struct { // Items with FeatureFlagEnable matching an entry in this list will be available EnabledFeatures []string - // Whether to enable dynamic toolsets - // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#dynamic-tool-discovery - DynamicToolsets bool - // ReadOnly indicates if we should only offer read-only tools ReadOnly bool @@ -91,16 +87,6 @@ func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependenci o(serverOpts) } - // In dynamic mode, explicitly advertise capabilities since tools/resources/prompts - // may be enabled at runtime even if none are registered initially. - if cfg.DynamicToolsets { - serverOpts.Capabilities = &mcp.ServerCapabilities{ - Tools: &mcp.ToolCapabilities{}, - Resources: &mcp.ResourceCapabilities{}, - Prompts: &mcp.PromptCapabilities{}, - } - } - ghServer := NewServer(cfg.Version, cfg.Translator("SERVER_NAME", "github-mcp-server"), cfg.Translator("SERVER_TITLE", "GitHub MCP Server"), serverOpts) // Add middlewares. Order matters - for example, the error context middleware should be applied last so that it runs FIRST (closest to the handler) to ensure all errors are captured, @@ -114,49 +100,17 @@ func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependenci } // Register GitHub tools/resources/prompts from the inventory. - // In dynamic mode with no explicit toolsets, this is a no-op since enabledToolsets - // is empty - users enable toolsets at runtime via the dynamic tools below (but can - // enable toolsets or tools explicitly that do need registration). inv.RegisterAll(ctx, ghServer, deps) - // Register dynamic toolset management tools (enable/disable) - these are separate - // meta-tools that control the inventory, not part of the inventory itself - if cfg.DynamicToolsets { - registerDynamicTools(ghServer, inv, deps, cfg.Translator) - } - return ghServer, nil } -// registerDynamicTools adds the dynamic toolset enable/disable tools to the server. -func registerDynamicTools(server *mcp.Server, inventory *inventory.Inventory, deps ToolDependencies, t translations.TranslationHelperFunc) { - dynamicDeps := DynamicToolDependencies{ - Server: server, - Inventory: inventory, - ToolDeps: deps, - T: t, - } - for _, tool := range DynamicTools(inventory) { - tool.RegisterFunc(server, dynamicDeps) - } -} - // ResolvedEnabledToolsets determines which toolsets should be enabled based on config. // Returns nil for "use defaults", empty slice for "none", or explicit list. -func ResolvedEnabledToolsets(dynamicToolsets bool, enabledToolsets []string, enabledTools []string) []string { - // In dynamic mode, remove "all" and "default" since users enable toolsets on demand - if dynamicToolsets && enabledToolsets != nil { - enabledToolsets = RemoveToolset(enabledToolsets, string(ToolsetMetadataAll.ID)) - enabledToolsets = RemoveToolset(enabledToolsets, string(ToolsetMetadataDefault.ID)) - } - +func ResolvedEnabledToolsets(enabledToolsets []string, enabledTools []string) []string { if enabledToolsets != nil { return enabledToolsets } - if dynamicToolsets { - // Dynamic mode with no toolsets specified: start empty so users enable on demand - return []string{} - } if len(enabledTools) > 0 { // When specific tools are requested but no toolsets, don't use default toolsets // This matches the original behavior: --tools=X alone registers only X diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go index be078d360..be37ca949 100644 --- a/pkg/github/server_test.go +++ b/pkg/github/server_test.go @@ -290,28 +290,17 @@ func TestResolveEnabledToolsets(t *testing.T) { expectedResult []string }{ { - name: "nil toolsets without dynamic mode and no tools - use defaults", + name: "nil toolsets and no tools - use defaults", cfg: MCPServerConfig{ EnabledToolsets: nil, - DynamicToolsets: false, EnabledTools: nil, }, expectedResult: nil, // nil means "use defaults" }, - { - name: "nil toolsets with dynamic mode - start empty", - cfg: MCPServerConfig{ - EnabledToolsets: nil, - DynamicToolsets: true, - EnabledTools: nil, - }, - expectedResult: []string{}, // empty slice means no toolsets - }, { name: "explicit toolsets", cfg: MCPServerConfig{ EnabledToolsets: []string{"repos", "issues"}, - DynamicToolsets: false, }, expectedResult: []string{"repos", "issues"}, }, @@ -319,32 +308,22 @@ func TestResolveEnabledToolsets(t *testing.T) { name: "empty toolsets - disable all", cfg: MCPServerConfig{ EnabledToolsets: []string{}, - DynamicToolsets: false, }, - expectedResult: []string{}, // empty slice means no toolsets + expectedResult: []string{}, }, { name: "specific tools without toolsets - no default toolsets", cfg: MCPServerConfig{ EnabledToolsets: nil, - DynamicToolsets: false, EnabledTools: []string{"get_me"}, }, expectedResult: []string{}, // empty slice when tools specified but no toolsets }, - { - name: "dynamic mode with explicit toolsets removes all and default", - cfg: MCPServerConfig{ - EnabledToolsets: []string{"all", "repos"}, - DynamicToolsets: true, - }, - expectedResult: []string{"repos"}, // "all" is removed in dynamic mode - }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - result := ResolvedEnabledToolsets(tc.cfg.DynamicToolsets, tc.cfg.EnabledToolsets, tc.cfg.EnabledTools) + result := ResolvedEnabledToolsets(tc.cfg.EnabledToolsets, tc.cfg.EnabledTools) assert.Equal(t, tc.expectedResult, result) }) } diff --git a/pkg/github/tools.go b/pkg/github/tools.go index f4c653bf8..c7f5abf3b 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -123,11 +123,6 @@ var ( Description: "GitHub Stargazers related tools", Icon: "star", } - ToolsetMetadataDynamic = inventory.ToolsetMetadata{ - ID: "dynamic", - Description: "Discover GitHub MCP tools that can help achieve tasks by enabling additional sets of tools, you can control the enablement of any toolset to access its tools when this toolset is enabled.", - Icon: "tools", - } ToolsetLabels = inventory.ToolsetMetadata{ ID: "labels", Description: "GitHub Labels related tools", @@ -350,8 +345,8 @@ func GenerateToolsetsHelp() string { defaultBuf.WriteString(string(id)) } - // Get all available toolsets (excludes context and dynamic for display) - allToolsets := r.AvailableToolsets("context", "dynamic") + // Get all available toolsets (excludes context for display) + allToolsets := r.AvailableToolsets("context") var availableBuf strings.Builder const maxLineLength = 70 currentLine := "" diff --git a/pkg/http/handler.go b/pkg/http/handler.go index 1ae471321..90423d93c 100644 --- a/pkg/http/handler.go +++ b/pkg/http/handler.go @@ -321,7 +321,6 @@ func hasStaticConfig(cfg *ServerConfig) bool { return cfg.ReadOnly || cfg.EnabledToolsets != nil || cfg.EnabledTools != nil || - cfg.DynamicToolsets || len(cfg.ExcludeTools) > 0 || cfg.InsidersMode } @@ -337,7 +336,7 @@ func buildStaticInventory(cfg *ServerConfig, t translations.TranslationHelperFun b := github.NewInventory(t). WithFeatureChecker(featureChecker). WithReadOnly(cfg.ReadOnly). - WithToolsets(github.ResolvedEnabledToolsets(cfg.DynamicToolsets, cfg.EnabledToolsets, cfg.EnabledTools)) + WithToolsets(github.ResolvedEnabledToolsets(cfg.EnabledToolsets, cfg.EnabledTools)) if len(cfg.EnabledTools) > 0 { b = b.WithTools(github.CleanTools(cfg.EnabledTools)) @@ -373,7 +372,7 @@ func InventoryFiltersForRequest(r *http.Request, builder *inventory.Builder) *in tools := ghcontext.GetTools(ctx) if len(toolsets) > 0 { - builder = builder.WithToolsets(github.ResolvedEnabledToolsets(false, toolsets, tools)) // No dynamic toolsets in HTTP mode + builder = builder.WithToolsets(github.ResolvedEnabledToolsets(toolsets, tools)) } if len(tools) > 0 { diff --git a/pkg/http/handler_test.go b/pkg/http/handler_test.go index 9887ff1f3..fd2966fd0 100644 --- a/pkg/http/handler_test.go +++ b/pkg/http/handler_test.go @@ -738,7 +738,7 @@ func buildStaticInventoryFromTools(cfg *ServerConfig, tools []inventory.ServerTo SetTools(tools). WithFeatureChecker(featureChecker). WithReadOnly(cfg.ReadOnly). - WithToolsets(github.ResolvedEnabledToolsets(cfg.DynamicToolsets, cfg.EnabledToolsets, cfg.EnabledTools)) + WithToolsets(github.ResolvedEnabledToolsets(cfg.EnabledToolsets, cfg.EnabledTools)) if len(cfg.EnabledTools) > 0 { b = b.WithTools(github.CleanTools(cfg.EnabledTools)) diff --git a/pkg/http/server.go b/pkg/http/server.go index f7cdaf909..b8c419ea0 100644 --- a/pkg/http/server.go +++ b/pkg/http/server.go @@ -78,9 +78,6 @@ type ServerConfig struct { // EnabledTools is a list of specific tools to enable (additive to toolsets). EnabledTools []string - // DynamicToolsets enables dynamic toolset discovery mode. - DynamicToolsets bool - // ExcludeTools is a list of tool names to disable regardless of other settings. // When set via CLI flag, per-request headers cannot re-include these tools. ExcludeTools []string diff --git a/pkg/inventory/builder.go b/pkg/inventory/builder.go index d656359bb..2642c6127 100644 --- a/pkg/inventory/builder.go +++ b/pkg/inventory/builder.go @@ -106,8 +106,7 @@ func (b *Builder) WithServerInstructions() *Builder { // - "default": expands to toolsets marked with Default: true in their metadata // // Input strings are trimmed of whitespace and duplicates are removed. -// Pass nil to use default toolsets. Pass an empty slice to disable all toolsets -// (useful for dynamic toolsets mode where tools are enabled on demand). +// Pass nil to use default toolsets. Pass an empty slice to disable all toolsets. // Returns self for chaining. func (b *Builder) WithToolsets(toolsetIDs []string) *Builder { b.toolsetIDs = toolsetIDs diff --git a/pkg/inventory/filters.go b/pkg/inventory/filters.go index 707457853..604aa1000 100644 --- a/pkg/inventory/filters.go +++ b/pkg/inventory/filters.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "os" - "slices" "sort" ) @@ -215,62 +214,6 @@ func (r *Inventory) filterPromptsByName(name string) []ServerPrompt { return []ServerPrompt{} } -// ToolsForToolset returns all tools belonging to a specific toolset. -// This method bypasses the toolset enabled filter (for dynamic toolset registration), -// but still respects the read-only filter. -func (r *Inventory) ToolsForToolset(toolsetID ToolsetID) []ServerTool { - var result []ServerTool - for i := range r.tools { - tool := &r.tools[i] - // Only check read-only filter, not toolset enabled filter - if tool.Toolset.ID == toolsetID { - if r.readOnly && !tool.IsReadOnly() { - continue - } - result = append(result, *tool) - } - } - - // Sort by tool name for deterministic order - sort.Slice(result, func(i, j int) bool { - return result[i].Tool.Name < result[j].Tool.Name - }) - - return result -} - -// IsToolsetEnabled checks if a toolset is currently enabled based on filters. -func (r *Inventory) IsToolsetEnabled(toolsetID ToolsetID) bool { - return r.isToolsetEnabled(toolsetID) -} - -// EnableToolset marks a toolset as enabled in this group. -// This is used by dynamic toolset management to track which toolsets have been enabled. -func (r *Inventory) EnableToolset(toolsetID ToolsetID) { - if r.enabledToolsets == nil { - // nil means all enabled, so nothing to do - return - } - r.enabledToolsets[toolsetID] = true -} - -// EnabledToolsetIDs returns the list of enabled toolset IDs based on current filters. -// Returns all toolset IDs if no filter is set. -func (r *Inventory) EnabledToolsetIDs() []ToolsetID { - if r.enabledToolsets == nil { - return r.ToolsetIDs() - } - - ids := make([]ToolsetID, 0, len(r.enabledToolsets)) - for id := range r.enabledToolsets { - if r.HasToolset(id) { - ids = append(ids, id) - } - } - slices.Sort(ids) - return ids -} - // FilteredTools returns tools filtered by the Enabled function and builder filters. // This provides an explicit API for accessing filtered tools, currently implemented // as an alias for AvailableTools. diff --git a/pkg/inventory/registry.go b/pkg/inventory/registry.go index a0bbc7a55..d54b3f12d 100644 --- a/pkg/inventory/registry.go +++ b/pkg/inventory/registry.go @@ -23,7 +23,6 @@ import ( // - Filtered access to tools/resources/prompts via Available* methods // - Deterministic ordering for documentation generation // - Lazy dependency injection during registration via RegisterAll() -// - Runtime toolset enabling for dynamic toolsets mode type Inventory struct { // tools holds all tools in this group (ordered for iteration) tools []ServerTool diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go index e6aedc620..8e35861f1 100644 --- a/pkg/inventory/registry_test.go +++ b/pkg/inventory/registry_test.go @@ -462,21 +462,6 @@ func TestToolsetDescriptions(t *testing.T) { } } -func TestToolsForToolset(t *testing.T) { - tools := []ServerTool{ - mockTool("tool1", "toolset1", true), - mockTool("tool2", "toolset1", true), - mockTool("tool3", "toolset2", true), - } - - reg := mustBuild(t, NewBuilder().SetTools(tools)) - toolset1Tools := reg.ToolsForToolset("toolset1") - - if len(toolset1Tools) != 2 { - t.Fatalf("Expected 2 tools for toolset1, got %d", len(toolset1Tools)) - } -} - func TestWithDeprecatedAliases(t *testing.T) { tools := []ServerTool{ mockTool("new_name", "toolset1", true), @@ -638,30 +623,6 @@ func TestHasToolset(t *testing.T) { } } -func TestEnabledToolsetIDs(t *testing.T) { - tools := []ServerTool{ - mockTool("tool1", "toolset1", true), - mockTool("tool2", "toolset2", true), - } - - // Without filter, all toolsets are enabled - reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"})) - ids := reg.EnabledToolsetIDs() - if len(ids) != 2 { - t.Fatalf("Expected 2 enabled toolset IDs, got %d", len(ids)) - } - - // With filter - filtered := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"toolset1"})) - filteredIDs := filtered.EnabledToolsetIDs() - if len(filteredIDs) != 1 { - t.Fatalf("Expected 1 enabled toolset ID, got %d", len(filteredIDs)) - } - if filteredIDs[0] != "toolset1" { - t.Errorf("Expected toolset1, got %s", filteredIDs[0]) - } -} - func TestAllTools(t *testing.T) { tools := []ServerTool{ mockTool("read_tool", "toolset1", true), diff --git a/pkg/inventory/server_tool.go b/pkg/inventory/server_tool.go index 316fffaa9..41d38b7ec 100644 --- a/pkg/inventory/server_tool.go +++ b/pkg/inventory/server_tool.go @@ -119,35 +119,6 @@ func (st *ServerTool) RegisterFunc(s *mcp.Server, deps any) { s.AddTool(&toolCopy, handler) } -// NewServerToolWithDeps creates a ServerTool from a tool definition, toolset metadata, and a typed handler function. -// The handler function takes dependencies (as any) and returns a typed handler. -// Callers should type-assert deps to their typed dependencies struct. -// -// Deprecated: This creates closures at registration time. For better performance in -// per-request server scenarios, use NewServerToolWithContextHandler instead. -func NewServerToolWithDeps[In any, Out any](tool mcp.Tool, toolset ToolsetMetadata, handlerFn func(deps any) mcp.ToolHandlerFor[In, Out]) ServerTool { - return ServerTool{ - Tool: tool, - Toolset: toolset, - HandlerFunc: func(deps any) mcp.ToolHandler { - typedHandler := handlerFn(deps) - return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - var arguments In - if err := json.Unmarshal(req.Params.Arguments, &arguments); err != nil { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: fmt.Sprintf("invalid arguments: %s", err)}, - }, - IsError: true, - }, nil - } - resp, _, err := typedHandler(ctx, req, arguments) - return resp, err - } - }, - } -} - // 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 0263857c9..69cee94af 100644 --- a/pkg/inventory/server_tool_test.go +++ b/pkg/inventory/server_tool_test.go @@ -10,42 +10,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestNewServerToolWithDeps_InvalidArguments_ReturnsIsError(t *testing.T) { - type expectedArgs struct { - Owner string `json:"owner"` - Repo string `json:"repo"` - } - - tool := NewServerToolWithDeps( - mcp.Tool{Name: "test_tool"}, - testToolsetMetadata("test"), - func(_ any) mcp.ToolHandlerFor[expectedArgs, *mcp.CallToolResult] { - return func(_ context.Context, _ *mcp.CallToolRequest, _ expectedArgs) (*mcp.CallToolResult, *mcp.CallToolResult, error) { - t.Fatal("handler should not be called with invalid arguments") - return nil, nil, nil - } - }, - ) - - handler := tool.HandlerFunc(nil) - - badArgs, _ := json.Marshal(map[string]any{"owner": 12345, "repo": true}) - result, err := handler(context.Background(), &mcp.CallToolRequest{ - Params: &mcp.CallToolParamsRaw{ - Name: "test_tool", - Arguments: badArgs, - }, - }) - - require.NoError(t, err) - require.NotNil(t, result) - assert.True(t, result.IsError) - assert.Len(t, result.Content, 1) - textContent, ok := result.Content[0].(*mcp.TextContent) - require.True(t, ok) - assert.Contains(t, textContent.Text, "invalid arguments") -} - func TestNewServerToolWithContextHandler_InvalidArguments_ReturnsIsError(t *testing.T) { type expectedArgs struct { Query string `json:"query"` @@ -79,23 +43,21 @@ func TestNewServerToolWithContextHandler_InvalidArguments_ReturnsIsError(t *test assert.Contains(t, textContent.Text, "invalid arguments") } -func TestNewServerToolWithDeps_ValidArguments_Succeeds(t *testing.T) { +func TestNewServerToolWithContextHandler_ValidArguments_Succeeds(t *testing.T) { type expectedArgs struct { Owner string `json:"owner"` Repo string `json:"repo"` } - tool := NewServerToolWithDeps( + tool := NewServerToolWithContextHandler( mcp.Tool{Name: "test_tool"}, testToolsetMetadata("test"), - func(_ any) mcp.ToolHandlerFor[expectedArgs, *mcp.CallToolResult] { - return func(_ context.Context, _ *mcp.CallToolRequest, args expectedArgs) (*mcp.CallToolResult, *mcp.CallToolResult, error) { - return &mcp.CallToolResult{ - Content: []mcp.Content{ - &mcp.TextContent{Text: "success: " + args.Owner + "/" + args.Repo}, - }, - }, nil, nil - } + func(_ context.Context, _ *mcp.CallToolRequest, args expectedArgs) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: "success: " + args.Owner + "/" + args.Repo}, + }, + }, nil, nil }, ) diff --git a/script/conformance-test b/script/conformance-test index 3ff0a55c2..549ced271 100755 --- a/script/conformance-test +++ b/script/conformance-test @@ -68,12 +68,6 @@ LIST_TOOLS_MSG='{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' LIST_RESOURCES_MSG='{"jsonrpc":"2.0","id":3,"method":"resources/listTemplates","params":{}}' LIST_PROMPTS_MSG='{"jsonrpc":"2.0","id":4,"method":"prompts/list","params":{}}' -# Dynamic toolset management tool calls (for dynamic mode testing) -LIST_TOOLSETS_MSG='{"jsonrpc":"2.0","id":10,"method":"tools/call","params":{"name":"list_available_toolsets","arguments":{}}}' -GET_TOOLSET_TOOLS_MSG='{"jsonrpc":"2.0","id":11,"method":"tools/call","params":{"name":"get_toolset_tools","arguments":{"toolset":"repos"}}}' -ENABLE_TOOLSET_MSG='{"jsonrpc":"2.0","id":12,"method":"tools/call","params":{"name":"enable_toolset","arguments":{"toolset":"repos"}}}' -LIST_TOOLSETS_AFTER_MSG='{"jsonrpc":"2.0","id":13,"method":"tools/call","params":{"name":"list_available_toolsets","arguments":{}}}' - # Function to normalize JSON for comparison # Sorts all arrays (including nested ones) and formats consistently # Also handles embedded JSON strings in "text" fields (from tool call responses) @@ -154,84 +148,18 @@ run_mcp_test() { echo "$duration" } -# Function to run MCP server with dynamic tool calls (for dynamic mode testing) -run_mcp_dynamic_test() { - local binary="$1" - local name="$2" - local flags="$3" - local output_prefix="$4" - - local start_time end_time duration - start_time=$(date +%s.%N) - - # Run the server with dynamic tool calls in sequence: - # 1. Initialize - # 2. List available toolsets (before enable) - # 3. Get tools for repos toolset - # 4. Enable repos toolset - # 5. List available toolsets (after enable - should show repos as enabled) - output=$( - ( - echo "$INIT_MSG" - echo "$INITIALIZED_MSG" - echo "$LIST_TOOLSETS_MSG" - sleep 0.1 - echo "$GET_TOOLSET_TOOLS_MSG" - sleep 0.1 - echo "$ENABLE_TOOLSET_MSG" - sleep 0.1 - echo "$LIST_TOOLSETS_AFTER_MSG" - sleep 0.3 - ) | GITHUB_PERSONAL_ACCESS_TOKEN=1 $binary stdio $flags 2>/dev/null - ) - - end_time=$(date +%s.%N) - duration=$(echo "$end_time - $start_time" | bc) - - # Parse and save each response by matching JSON-RPC id - echo "$output" | while IFS= read -r line; do - id=$(echo "$line" | jq -r '.id // empty' 2>/dev/null) - case "$id" in - 1) echo "$line" | jq -S '.' > "${output_prefix}_initialize.json" 2>/dev/null ;; - 10) echo "$line" | jq -S '.' > "${output_prefix}_list_toolsets_before.json" 2>/dev/null ;; - 11) echo "$line" | jq -S '.' > "${output_prefix}_get_toolset_tools.json" 2>/dev/null ;; - 12) echo "$line" | jq -S '.' > "${output_prefix}_enable_toolset.json" 2>/dev/null ;; - 13) echo "$line" | jq -S '.' > "${output_prefix}_list_toolsets_after.json" 2>/dev/null ;; - esac - done - - # Create empty files if not created - touch "${output_prefix}_initialize.json" "${output_prefix}_list_toolsets_before.json" \ - "${output_prefix}_get_toolset_tools.json" "${output_prefix}_enable_toolset.json" \ - "${output_prefix}_list_toolsets_after.json" - - # Normalize all JSON files - for endpoint in initialize list_toolsets_before get_toolset_tools enable_toolset list_toolsets_after; do - normalize_json "${output_prefix}_${endpoint}.json" - done - - echo "$duration" -} - -# Test configurations - array of "name|flags|type" -# type can be "standard" or "dynamic" (for dynamic tool call testing) +# Test configurations - array of "name|flags" declare -a TEST_CONFIGS=( - "default||standard" - "read-only|--read-only|standard" - "dynamic-toolsets|--dynamic-toolsets|standard" - "read-only+dynamic|--read-only --dynamic-toolsets|standard" - "toolsets-repos|--toolsets=repos|standard" - "toolsets-issues|--toolsets=issues|standard" - "toolsets-pull_requests|--toolsets=pull_requests|standard" - "toolsets-repos,issues|--toolsets=repos,issues|standard" - "toolsets-all|--toolsets=all|standard" - "tools-get_me|--tools=get_me|standard" - "tools-get_me,list_issues|--tools=get_me,list_issues|standard" - "toolsets-repos+read-only|--toolsets=repos --read-only|standard" - "toolsets-all+dynamic|--toolsets=all --dynamic-toolsets|standard" - "toolsets-repos+dynamic|--toolsets=repos --dynamic-toolsets|standard" - "toolsets-repos,issues+dynamic|--toolsets=repos,issues --dynamic-toolsets|standard" - "dynamic-tool-calls|--dynamic-toolsets|dynamic" + "default|" + "read-only|--read-only" + "toolsets-repos|--toolsets=repos" + "toolsets-issues|--toolsets=issues" + "toolsets-pull_requests|--toolsets=pull_requests" + "toolsets-repos,issues|--toolsets=repos,issues" + "toolsets-all|--toolsets=all" + "tools-get_me|--tools=get_me" + "tools-get_me,list_issues|--tools=get_me,list_issues" + "toolsets-repos+read-only|--toolsets=repos --read-only" ) # Summary arrays @@ -244,36 +172,24 @@ log "${YELLOW}Running conformance tests...${NC}" log "" for config in "${TEST_CONFIGS[@]}"; do - IFS='|' read -r test_name flags test_type <<< "$config" - + IFS='|' read -r test_name flags <<< "$config" + log "${BLUE}Test: ${test_name}${NC}" log " Flags: ${flags:-}" - log " Type: ${test_type}" # Create output directories mkdir -p "$REPORT_DIR/main/$test_name" mkdir -p "$REPORT_DIR/branch/$test_name" mkdir -p "$REPORT_DIR/diffs/$test_name" - if [ "$test_type" = "dynamic" ]; then - # Run dynamic tool call test - main_time=$(run_mcp_dynamic_test "$REPORT_DIR/main/github-mcp-server" "main" "$flags" "$REPORT_DIR/main/$test_name/output") - log " Main: ${main_time}s" - - branch_time=$(run_mcp_dynamic_test "$REPORT_DIR/branch/github-mcp-server" "branch" "$flags" "$REPORT_DIR/branch/$test_name/output") - log " Branch: ${branch_time}s" - - endpoints="initialize list_toolsets_before get_toolset_tools enable_toolset list_toolsets_after" - else - # Run standard test - main_time=$(run_mcp_test "$REPORT_DIR/main/github-mcp-server" "main" "$flags" "$REPORT_DIR/main/$test_name/output") - log " Main: ${main_time}s" - - branch_time=$(run_mcp_test "$REPORT_DIR/branch/github-mcp-server" "branch" "$flags" "$REPORT_DIR/branch/$test_name/output") - log " Branch: ${branch_time}s" - - endpoints="initialize tools resources prompts" - fi + # Run standard test + main_time=$(run_mcp_test "$REPORT_DIR/main/github-mcp-server" "main" "$flags" "$REPORT_DIR/main/$test_name/output") + log " Main: ${main_time}s" + + branch_time=$(run_mcp_test "$REPORT_DIR/branch/github-mcp-server" "branch" "$flags" "$REPORT_DIR/branch/$test_name/output") + log " Branch: ${branch_time}s" + + endpoints="initialize tools resources prompts" # Calculate time difference time_diff=$(echo "$branch_time - $main_time" | bc) @@ -393,7 +309,7 @@ for i in "${!TEST_NAMES[@]}"; do echo "" >> "$REPORT_FILE" # Check all possible endpoints - for endpoint in initialize tools resources prompts list_toolsets_before get_toolset_tools enable_toolset list_toolsets_after; do + for endpoint in initialize tools resources prompts; do diff_file="$REPORT_DIR/diffs/$name/${endpoint}.diff" if [ -f "$diff_file" ] && [ -s "$diff_file" ]; then echo "#### ${endpoint}" >> "$REPORT_FILE" From b2b49361fdde70b95e1740077c2cd54489eee235 Mon Sep 17 00:00:00 2001 From: Boaz Reicher <44614829+boazreicher@users.noreply.github.com> Date: Wed, 20 May 2026 12:00:19 +0300 Subject: [PATCH 29/48] Adding rationale for fields and labels in issues_granular (#2505) * Adding rationle for fields and labels in issues_granular Co-authored-by: Copilot * removed descriptions * test: update update_issue_labels toolsnap schema text * removed descriptions Co-authored-by: Copilot * fixed snaps --------- Co-authored-by: Copilot Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Sam Morrow --- .../__toolsnaps__/set_issue_fields.snap | 5 + .../__toolsnaps__/update_issue_labels.snap | 26 +- pkg/github/granular_tools_test.go | 247 ++++++++++++++++-- pkg/github/issues_granular.go | 204 +++++++++++++-- 4 files changed, 447 insertions(+), 35 deletions(-) diff --git a/pkg/github/__toolsnaps__/set_issue_fields.snap b/pkg/github/__toolsnaps__/set_issue_fields.snap index 7546ddc37..979dde4fb 100644 --- a/pkg/github/__toolsnaps__/set_issue_fields.snap +++ b/pkg/github/__toolsnaps__/set_issue_fields.snap @@ -27,6 +27,11 @@ "description": "The value to set for a number field", "type": "number" }, + "rationale": { + "description": "One concise sentence explaining what specifically about the issue led you to choose this field value. State the concrete signal (e.g. 'Reports a crash when saving' → high priority).", + "maxLength": 280, + "type": "string" + }, "single_select_option_id": { "description": "The GraphQL node ID of the option to set for a single select field", "type": "string" diff --git a/pkg/github/__toolsnaps__/update_issue_labels.snap b/pkg/github/__toolsnaps__/update_issue_labels.snap index 3acf98d93..89ff86b2f 100644 --- a/pkg/github/__toolsnaps__/update_issue_labels.snap +++ b/pkg/github/__toolsnaps__/update_issue_labels.snap @@ -13,9 +13,31 @@ "type": "number" }, "labels": { - "description": "Labels to apply to this issue", + "description": "Labels to apply to this issue.", "items": { - "type": "string" + "oneOf": [ + { + "description": "Label name", + "type": "string" + }, + { + "properties": { + "name": { + "description": "Label name", + "type": "string" + }, + "rationale": { + "description": "One concise sentence explaining what specifically about the issue led you to choose this label. State the concrete signal (e.g. 'Reports a crash when saving' → bug).", + "maxLength": 280, + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + ] }, "type": "array" }, diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go index 72ed1939d..59eb47822 100644 --- a/pkg/github/granular_tools_test.go +++ b/pkg/github/granular_tools_test.go @@ -263,24 +263,124 @@ func TestGranularUpdateIssueAssignees(t *testing.T) { } func TestGranularUpdateIssueLabels(t *testing.T) { - client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ - "labels": []any{"bug", "enhancement"}, - }).andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), - })) - deps := BaseDeps{Client: client} - serverTool := GranularUpdateIssueLabels(translations.NullTranslationHelper) - handler := serverTool.Handler(deps) + tests := []struct { + name string + requestArgs map[string]any + expectedReq map[string]any + }{ + { + name: "labels as plain strings", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "labels": []any{"bug", "enhancement"}, + }, + expectedReq: map[string]any{ + "labels": []any{"bug", "enhancement"}, + }, + }, + { + name: "label objects without rationale serialize as strings", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "labels": []any{ + map[string]any{"name": "bug"}, + "enhancement", + }, + }, + expectedReq: map[string]any{ + "labels": []any{"bug", "enhancement"}, + }, + }, + { + name: "mixed strings and label objects with rationale", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "labels": []any{ + "triage", + map[string]any{"name": "bug", "rationale": " Reports a crash when saving "}, + map[string]any{"name": "frontend", "rationale": "Mentions the UI button"}, + }, + }, + expectedReq: map[string]any{ + "labels": []any{ + "triage", + map[string]any{"name": "bug", "rationale": "Reports a crash when saving"}, + map[string]any{"name": "frontend", "rationale": "Mentions the UI button"}, + }, + }, + }, + } - request := createMCPRequest(map[string]any{ - "owner": "owner", - "repo": "repo", - "issue_number": float64(1), - "labels": []string{"bug", "enhancement"}, - }) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - require.NoError(t, err) - assert.False(t, result.IsError) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq). + andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), + })) + deps := BaseDeps{Client: client} + serverTool := GranularUpdateIssueLabels(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 TestGranularUpdateIssueLabelsInvalidRationale(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), + "labels": []any{ + map[string]any{"name": "bug", "rationale": strings.Repeat("a", 281)}, + }, + }, + expectedErrText: "label rationale must be 280 characters or less", + }, + { + name: "label object missing name", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "labels": []any{ + map[string]any{"rationale": "no name provided"}, + }, + }, + expectedErrText: "each label object must have a 'name' string", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(nil))} + serverTool := GranularUpdateIssueLabels(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 TestGranularUpdateIssueMilestone(t *testing.T) { @@ -1034,4 +1134,117 @@ func TestGranularSetIssueFields(t *testing.T) { textContent := getTextResult(t, result) assert.Contains(t, textContent.Text, "each field must have exactly one value") }) + + t.Run("successful set with text value and rationale", func(t *testing.T) { + matchers := []githubv4mock.Matcher{ + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Issue struct { + ID githubv4.ID + } `graphql:"issue(number: $issueNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "issueNumber": githubv4.Int(5), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{"id": "ISSUE_123"}, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + SetIssueFieldValue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + IssueFieldValues []struct { + TextValue struct { + Value string + } `graphql:"... on IssueFieldTextValue"` + SingleSelectValue struct { + Name string + } `graphql:"... on IssueFieldSingleSelectValue"` + DateValue struct { + Value string + } `graphql:"... on IssueFieldDateValue"` + NumberValue struct { + Value float64 + } `graphql:"... on IssueFieldNumberValue"` + } + } `graphql:"setIssueFieldValue(input: $input)"` + }{}, + SetIssueFieldValueInput{ + IssueID: githubv4.ID("ISSUE_123"), + IssueFields: []IssueFieldCreateOrUpdateInput{ + { + FieldID: githubv4.ID("FIELD_1"), + TextValue: githubv4.NewString(githubv4.String("hello")), + Rationale: githubv4.NewString(githubv4.String("Reflects the reported severity")), + }, + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "setIssueFieldValue": map[string]any{ + "issue": map[string]any{ + "id": "ISSUE_123", + "number": 5, + "url": "https://github.com/owner/repo/issues/5", + }, + }, + }), + ), + } + + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matchers...)) + deps := BaseDeps{GQLClient: gqlClient} + serverTool := GranularSetIssueFields(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(5), + "fields": []any{ + map[string]any{ + "field_id": "FIELD_1", + "text_value": "hello", + "rationale": " Reflects the reported severity ", + }, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + + t.Run("rationale too long returns error", func(t *testing.T) { + deps := BaseDeps{} + serverTool := GranularSetIssueFields(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(5), + "fields": []any{ + map[string]any{ + "field_id": "FIELD_1", + "text_value": "hello", + "rationale": strings.Repeat("a", 281), + }, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "field rationale must be 280 characters or less") + }) } diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go index 5b335bd44..400a22f5c 100644 --- a/pkg/github/issues_granular.go +++ b/pkg/github/issues_granular.go @@ -258,31 +258,182 @@ func GranularUpdateIssueAssignees(t translations.TranslationHelperFunc) inventor ) } +// labelWithRationale represents the object form of a label entry, allowing a +// rationale to be sent alongside the label name. +type labelWithRationale struct { + Name string `json:"name"` + Rationale string `json:"rationale,omitempty"` +} + +// labelsUpdateRequest is a custom request body for updating an issue's labels +// where individual labels may optionally include a rationale. Each element of +// Labels is either a string (label name) or a labelWithRationale object. +type labelsUpdateRequest struct { + Labels []any `json:"labels"` +} + // GranularUpdateIssueLabels creates a tool to update an issue's labels. func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.ServerTool { - return issueUpdateTool(t, - "update_issue_labels", - "Update the labels of an existing issue. This replaces the current labels with the provided list.", - "Update Issue Labels", - map[string]*jsonschema.Schema{ - "labels": { - Type: "array", - Description: "Labels to apply to this issue", - Items: &jsonschema.Schema{Type: "string"}, + st := NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: "update_issue_labels", + Description: t("TOOL_UPDATE_ISSUE_LABELS_DESCRIPTION", "Update the labels of an existing issue. This replaces the current labels with the provided list."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_UPDATE_ISSUE_LABELS_USER_TITLE", "Update Issue Labels"), + 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), + }, + "labels": { + Type: "array", + Description: "Labels to apply to this issue.", + Items: &jsonschema.Schema{ + OneOf: []*jsonschema.Schema{ + {Type: "string", Description: "Label name"}, + { + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "name": { + Type: "string", + Description: "Label name", + }, + "rationale": { + Type: "string", + Description: "One concise sentence explaining what specifically about the issue led you to choose this label. " + + "State the concrete signal (e.g. 'Reports a crash when saving' → bug).", + MaxLength: jsonschema.Ptr(280), + }, + }, + Required: []string{"name"}, + }, + }, + }, + }, + }, + Required: []string{"owner", "repo", "issue_number", "labels"}, }, }, - []string{"labels"}, - func(args map[string]any) (*github.IssueRequest, error) { - if _, ok := args["labels"]; !ok { - return nil, fmt.Errorf("missing required parameter: labels") + []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 } - labels, err := OptionalStringArrayParam(args, "labels") + 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 + } + + labelsRaw, ok := args["labels"] + if !ok { + return utils.NewToolResultError("missing required parameter: labels"), nil, nil + } + labelsSlice, ok := labelsRaw.([]any) + if !ok { + // Also accept []string for callers that pre-typed the array. + if strs, ok := labelsRaw.([]string); ok { + labelsSlice = make([]any, len(strs)) + for i, s := range strs { + labelsSlice[i] = s + } + } else { + return utils.NewToolResultError("parameter labels must be an array"), nil, nil + } + } + + anyRationale := false + payload := make([]any, 0, len(labelsSlice)) + for _, item := range labelsSlice { + switch v := item.(type) { + case string: + payload = append(payload, v) + case map[string]any: + name, err := RequiredParam[string](v, "name") + if err != nil { + return utils.NewToolResultError("each label object must have a 'name' 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("label rationale must be 280 characters or less"), nil, nil + } + if rationale == "" { + payload = append(payload, name) + } else { + anyRationale = true + payload = append(payload, labelWithRationale{Name: name, Rationale: rationale}) + } + default: + return utils.NewToolResultError("each label must be a string or an object with 'name' and optional 'rationale'"), nil, nil + } } - return &github.IssueRequest{Labels: &labels}, nil + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + var body any + if anyRationale { + body = &labelsUpdateRequest{Labels: payload} + } else { + // Preserve the standard wire format when no rationale is supplied. + names := make([]string, len(payload)) + for i, p := range payload { + names[i] = p.(string) + } + body = &github.IssueRequest{Labels: &names} + } + + 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 } // GranularUpdateIssueMilestone creates a tool to update an issue's milestone. @@ -714,6 +865,7 @@ type IssueFieldCreateOrUpdateInput struct { DateValue *githubv4.String `json:"dateValue,omitempty"` SingleSelectOptionID *githubv4.ID `json:"singleSelectOptionId,omitempty"` Delete *githubv4.Boolean `json:"delete,omitempty"` + Rationale *githubv4.String `json:"rationale,omitempty"` } // GranularSetIssueFields creates a tool to set issue field values on an issue using GraphQL. @@ -776,6 +928,12 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv Type: "boolean", Description: "Set to true to delete this field value", }, + "rationale": { + Type: "string", + Description: "One concise sentence explaining what specifically about the issue led you to choose this field value. " + + "State the concrete signal (e.g. 'Reports a crash when saving' → high priority).", + MaxLength: jsonschema.Ptr(280), + }, }, Required: []string{"field_id"}, }, @@ -874,6 +1032,20 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv return utils.NewToolResultError("each field must have exactly one value (text_value, number_value, date_value, single_select_option_id) or delete: true, but multiple were provided"), nil, nil } + if _, exists := fieldMap["rationale"]; exists { + rationale, err := OptionalParam[string](fieldMap, "rationale") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + rationale = strings.TrimSpace(rationale) + if len([]rune(rationale)) > 280 { + return utils.NewToolResultError("field rationale must be 280 characters or less"), nil, nil + } + if rationale != "" { + input.Rationale = githubv4.NewString(githubv4.String(rationale)) + } + } + issueFields = append(issueFields, input) } From cc2a95725a70a28f957b5df7a2cfc178fe6e1268 Mon Sep 17 00:00:00 2001 From: Kelsey Myers <52179263+kelsey-myers@users.noreply.github.com> Date: Wed, 20 May 2026 10:18:42 +0100 Subject: [PATCH 30/48] Include custom issue field values in list_issues response (#2466) * Include custom issue field values in list_issues response Adds Issues 2.0 custom field values to each issue returned by the list_issues GraphQL query, exposed on MinimalIssue as field_values: [{field, value}]. Filtering by field is a separate concern (needs the GraphQL IssueFilters input updated upstream) and is not included here. shurcooL/graphql's response decoder walks every inline fragment of a union regardless of __typename, so IssueFieldNumberValue.value is aliased to valueNumber to avoid a Float-vs-String type clash when the runtime variant is, e.g., a SingleSelectValue. * Extend list_issues tests to cover Date/Number/Text field value variants --------- Co-authored-by: Sam Morrow --- pkg/github/issues.go | 51 ++++++++++++++++++++++ pkg/github/issues_test.go | 54 ++++++++++++++++++++++-- pkg/github/minimal_types.go | 84 ++++++++++++++++++++++++++++--------- 3 files changed, 166 insertions(+), 23 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 52a024c29..d7f6f31d0 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -103,6 +103,54 @@ func getCloseStateReason(stateReason string) IssueClosedStateReason { } } +// IssueFieldRef resolves the name of an issue field across its concrete types. +// IssueFields is a union of IssueFieldDate, IssueFieldNumber, IssueFieldSingleSelect, IssueFieldText, +// so we have to ask for `name` on each member. +type IssueFieldRef struct { + Date struct{ Name githubv4.String } `graphql:"... on IssueFieldDate"` + Number struct{ Name githubv4.String } `graphql:"... on IssueFieldNumber"` + SingleSelect struct{ Name githubv4.String } `graphql:"... on IssueFieldSingleSelect"` + Text struct{ Name githubv4.String } `graphql:"... on IssueFieldText"` +} + +// Name returns the populated name from whichever IssueFields union variant the field resolved to. +func (r IssueFieldRef) Name() string { + switch { + case r.Date.Name != "": + return string(r.Date.Name) + case r.Number.Name != "": + return string(r.Number.Name) + case r.SingleSelect.Name != "": + return string(r.SingleSelect.Name) + case r.Text.Name != "": + return string(r.Text.Name) + } + return "" +} + +// IssueFieldValueFragment captures the value of a custom issue field. IssueFieldValue is a union +// of 4 concrete value types; each carries its own value scalar and a reference to its parent field. +// The Number variant's `value` is aliased to `valueNumber` to avoid a Float vs String type clash on decode. +type IssueFieldValueFragment struct { + TypeName string `graphql:"__typename"` + DateValue struct { + Field IssueFieldRef + Value githubv4.String + } `graphql:"... on IssueFieldDateValue"` + NumberValue struct { + Field IssueFieldRef + Value githubv4.Float `graphql:"valueNumber: value"` + } `graphql:"... on IssueFieldNumberValue"` + SingleSelectValue struct { + Field IssueFieldRef + Value githubv4.String + } `graphql:"... on IssueFieldSingleSelectValue"` + TextValue struct { + Field IssueFieldRef + Value githubv4.String + } `graphql:"... on IssueFieldTextValue"` +} + // IssueFragment represents a fragment of an issue node in the GraphQL API. type IssueFragment struct { Number githubv4.Int @@ -126,6 +174,9 @@ type IssueFragment struct { Comments struct { TotalCount githubv4.Int } `graphql:"comments"` + IssueFieldValues struct { + Nodes []IssueFieldValueFragment + } `graphql:"issueFieldValues(first: 25)"` } // Common interface for all issue query types diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 6b4042bac..c89aefb8c 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -1345,6 +1345,15 @@ func Test_ListIssues(t *testing.T) { "comments": map[string]any{ "totalCount": 5, }, + "issueFieldValues": map[string]any{ + "nodes": []map[string]any{ + { + "__typename": "IssueFieldSingleSelectValue", + "field": map[string]any{"name": "priority"}, + "value": "P1", + }, + }, + }, }, { "number": 456, @@ -1363,6 +1372,25 @@ func Test_ListIssues(t *testing.T) { "comments": map[string]any{ "totalCount": 3, }, + "issueFieldValues": map[string]any{ + "nodes": []map[string]any{ + { + "__typename": "IssueFieldDateValue", + "field": map[string]any{"name": "due"}, + "value": "2026-06-01", + }, + { + "__typename": "IssueFieldNumberValue", + "field": map[string]any{"name": "estimate"}, + "valueNumber": 2.5, + }, + { + "__typename": "IssueFieldTextValue", + "field": map[string]any{"name": "notes"}, + "value": "needs triage", + }, + }, + }, }, } @@ -1383,6 +1411,9 @@ func Test_ListIssues(t *testing.T) { "comments": map[string]any{ "totalCount": 1, }, + "issueFieldValues": map[string]any{ + "nodes": []map[string]any{}, + }, }, } @@ -1557,8 +1588,9 @@ func Test_ListIssues(t *testing.T) { } // Define the actual query strings that match the implementation - qBasicNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" - qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + issueFieldValuesSelection := "issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value}}}" + qBasicNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}," + issueFieldValuesSelection + "},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}," + issueFieldValuesSelection + "},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -1629,6 +1661,22 @@ func Test_ListIssues(t *testing.T) { for _, label := range issue.Labels { assert.NotEmpty(t, label, "Label should be a non-empty string") } + + // Field values should be flattened to {field, value} pairs. Issue #123 has a + // SingleSelectValue; issue #456 exercises the Date/Number/Text branches + // (including float formatting); #789 has no field values. + switch issue.Number { + case 123: + assert.Equal(t, []MinimalIssueFieldValue{{Field: "priority", Value: "P1"}}, issue.FieldValues) + case 456: + assert.Equal(t, []MinimalIssueFieldValue{ + {Field: "due", Value: "2026-06-01"}, + {Field: "estimate", Value: "2.5"}, + {Field: "notes", Value: "needs triage"}, + }, issue.FieldValues) + default: + assert.Empty(t, issue.FieldValues) + } } }) } @@ -1674,7 +1722,7 @@ func Test_ListIssues_IFC_InsidersMode(t *testing.T) { }) } - query := "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + query := "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount},issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value}}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" vars := map[string]any{ "owner": "octocat", diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 65a18ade8..89d8a0199 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -1,6 +1,7 @@ package github import ( + "strconv" "time" "github.com/google/go-github/v87/github" @@ -203,26 +204,35 @@ type MinimalReactions struct { // MinimalIssue is the trimmed output type for issue objects to reduce verbosity. type MinimalIssue struct { - Number int `json:"number"` - Title string `json:"title"` - Body string `json:"body,omitempty"` - State string `json:"state"` - StateReason string `json:"state_reason,omitempty"` - Draft bool `json:"draft,omitempty"` - Locked bool `json:"locked,omitempty"` - HTMLURL string `json:"html_url,omitempty"` - User *MinimalUser `json:"user,omitempty"` - AuthorAssociation string `json:"author_association,omitempty"` - Labels []string `json:"labels,omitempty"` - Assignees []string `json:"assignees,omitempty"` - Milestone string `json:"milestone,omitempty"` - Comments int `json:"comments,omitempty"` - Reactions *MinimalReactions `json:"reactions,omitempty"` - CreatedAt string `json:"created_at,omitempty"` - UpdatedAt string `json:"updated_at,omitempty"` - ClosedAt string `json:"closed_at,omitempty"` - ClosedBy string `json:"closed_by,omitempty"` - IssueType string `json:"issue_type,omitempty"` + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body,omitempty"` + State string `json:"state"` + StateReason string `json:"state_reason,omitempty"` + Draft bool `json:"draft,omitempty"` + Locked bool `json:"locked,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + User *MinimalUser `json:"user,omitempty"` + AuthorAssociation string `json:"author_association,omitempty"` + Labels []string `json:"labels,omitempty"` + Assignees []string `json:"assignees,omitempty"` + Milestone string `json:"milestone,omitempty"` + Comments int `json:"comments,omitempty"` + Reactions *MinimalReactions `json:"reactions,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + ClosedAt string `json:"closed_at,omitempty"` + ClosedBy string `json:"closed_by,omitempty"` + IssueType string `json:"issue_type,omitempty"` + FieldValues []MinimalIssueFieldValue `json:"field_values,omitempty"` +} + +// MinimalIssueFieldValue is the trimmed output type for a custom issue field value. +// Single-value variants (date, number, single-select, text) populate Value. Values is reserved for multi-select. +type MinimalIssueFieldValue struct { + Field string `json:"field"` + Value string `json:"value,omitempty"` + Values []string `json:"values,omitempty"` } // MinimalIssuesResponse is the trimmed output for a paginated list of issues. @@ -435,9 +445,43 @@ func fragmentToMinimalIssue(fragment IssueFragment) MinimalIssue { m.Labels = append(m.Labels, string(label.Name)) } + for _, fv := range fragment.IssueFieldValues.Nodes { + if mfv, ok := fragmentToMinimalIssueFieldValue(fv); ok { + m.FieldValues = append(m.FieldValues, mfv) + } + } + return m } +// fragmentToMinimalIssueFieldValue flattens the union value fragment into a single +// {field, value} pair. Returns ok=false if the typename is unrecognised. +func fragmentToMinimalIssueFieldValue(fv IssueFieldValueFragment) (MinimalIssueFieldValue, bool) { + switch fv.TypeName { + case "IssueFieldDateValue": + return MinimalIssueFieldValue{ + Field: fv.DateValue.Field.Name(), + Value: string(fv.DateValue.Value), + }, true + case "IssueFieldNumberValue": + return MinimalIssueFieldValue{ + Field: fv.NumberValue.Field.Name(), + Value: strconv.FormatFloat(float64(fv.NumberValue.Value), 'f', -1, 64), + }, true + case "IssueFieldSingleSelectValue": + return MinimalIssueFieldValue{ + Field: fv.SingleSelectValue.Field.Name(), + Value: string(fv.SingleSelectValue.Value), + }, true + case "IssueFieldTextValue": + return MinimalIssueFieldValue{ + Field: fv.TextValue.Field.Name(), + Value: string(fv.TextValue.Value), + }, true + } + return MinimalIssueFieldValue{}, false +} + func convertToMinimalIssuesResponse(fragment IssueQueryFragment) MinimalIssuesResponse { minimalIssues := make([]MinimalIssue, 0, len(fragment.Nodes)) for _, issue := range fragment.Nodes { From e9533330b0ec2ea2f81ec490cc0c84d30538fcb9 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 20 May 2026 12:30:38 +0200 Subject: [PATCH 31/48] fix(search_code): tighten query description for accurate model guidance (#2513) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The current `search_code` query description is hand-wavy and gives the model little usable guidance on GitHub code search syntax, which (per analysis in #2390 across thousands of agent sessions) leads to repeated 422 ERROR_TYPE_QUERY_PARSING_FATAL responses from agents that guess at plausible-but-invalid syntax. Re-applies the spirit of #2442 by @jluocsa, originally suggested by @danmoseley in #2390, but corrected against the actual endpoint this tool calls. Critically, this tool uses go-github's `client.Search.Code`, which hits the legacy REST `/search/code` endpoint — NOT the new code search ("Blackbird"). Verified against the live API: symbol:WithContext repo:github/github-mcp-server -> 0 /Get|Set/ repo:github/github-mcp-server -> 0 path:**/*.go func repo:github/github-mcp-server -> 0 filename:*.md repo:github/github-mcp-server -> 0 (Foo OR Bar) -path:vendor language:go -> 422 So `symbol:`, `/regex/`, path globs, filename globs, and parenthesized boolean groups — features the proposal in #2442 listed — silently return zero or fail. Documenting them would teach the model syntax that doesn't work on this endpoint. The new description focuses on what's actually supported by legacy code search and the real bugs observed in #2390: - `path:dir` is a prefix, NOT a glob (displaces `path:**/*.ts` guesses). - `filename:exact.ext` is exact, NOT a glob (displaces `filename:*.md`). - `/regex/` and `\|` inside quotes don't work — call this out so the model stops generating them. - `symbol:` doesn't work on this endpoint — call this out. - Parenthesized boolean groups 422 — call this out so the model stops wrapping `OR` chains in parens. - Adds `extension:`, `in:file`, `in:path`, `size:`, `filename:`, `user:` qualifiers that the previous text omitted. - Implicit AND, `OR`, `NOT`, and `"quoted phrase"` for exact match are documented positively. - 256-char query limit. All four examples in the new description are verified against the live GitHub API and return non-zero results. Co-authored-by: jluocsa <103165870+jluocsa@users.noreply.github.com> Co-authored-by: danmoseley <6385855+danmoseley@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- pkg/github/__toolsnaps__/search_code.snap | 2 +- pkg/github/search.go | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index e4f70b622..526d422aa 100644 --- a/README.md +++ b/README.md @@ -1295,7 +1295,7 @@ The following sets of tools are available: - `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 using GitHub's powerful code search syntax. Examples: 'content:Skill language:Java org:github', 'NOT is:archived language:Python OR language:go', 'repo:github/github-mcp-server'. Supports exact matching, language filters, path filters, and more. (string, required) + - `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_repositories** - Search repositories diff --git a/pkg/github/__toolsnaps__/search_code.snap b/pkg/github/__toolsnaps__/search_code.snap index 8b5510aa6..79cbbf04e 100644 --- a/pkg/github/__toolsnaps__/search_code.snap +++ b/pkg/github/__toolsnaps__/search_code.snap @@ -26,7 +26,7 @@ "type": "number" }, "query": { - "description": "Search query using GitHub's powerful code search syntax. Examples: 'content:Skill language:Java org:github', 'NOT is:archived language:Python OR language:go', 'repo:github/github-mcp-server'. Supports exact matching, language filters, path filters, and more.", + "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": { diff --git a/pkg/github/search.go b/pkg/github/search.go index a4acc4448..e360f08f8 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -200,7 +200,7 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { Properties: map[string]*jsonschema.Schema{ "query": { Type: "string", - Description: "Search query using GitHub's powerful code search syntax. Examples: 'content:Skill language:Java org:github', 'NOT is:archived language:Python OR language:go', 'repo:github/github-mcp-server'. Supports exact matching, language filters, path filters, and more.", + 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`.", }, "sort": { Type: "string", From 8f4680b90acb7a53e1810ae569a7ea508568e7eb Mon Sep 17 00:00:00 2001 From: Kelsey Myers <52179263+kelsey-myers@users.noreply.github.com> Date: Wed, 20 May 2026 13:59:56 +0100 Subject: [PATCH 32/48] Add field_values to search_issues results (#2474) * Add field_values to search_issues results * remove dupe keys * Fix advanced search not enabled for fields --------- Co-authored-by: Sam Morrow --- pkg/github/issues.go | 160 ++++++++++++++++++++++++++++++++++++- pkg/github/issues_test.go | 135 +++++++++++++++++++++++++++++++ pkg/github/search_utils.go | 55 ++++++++----- 3 files changed, 330 insertions(+), 20 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index d7f6f31d0..fe1e7b501 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1047,7 +1047,7 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool { if deps.GetFlags(ctx).InsidersMode { options = append(options, withSearchPostProcess(searchIssuesIFCPostProcess(deps))) } - result, err := searchHandler(ctx, deps.GetClient, args, "issue", "failed to search issues", options...) + result, err := searchIssuesHandler(ctx, deps, args, options...) return result, nil, err }) } @@ -1134,6 +1134,164 @@ func parseRepositoryURL(repoURL string) (string, string, bool) { return parts[0], parts[1], true } +// SearchIssueResult wraps a REST search hit with its custom issue field values, fetched in a follow-up GraphQL nodes() query. +type SearchIssueResult struct { + *github.Issue + FieldValues []MinimalIssueFieldValue `json:"field_values,omitempty"` +} + +// MarshalJSON serializes SearchIssueResult, suppressing the raw issue_field_values from the +// embedded REST response in favour of the normalized field_values populated via GraphQL enrichment. +func (r SearchIssueResult) MarshalJSON() ([]byte, error) { + issueBytes, err := json.Marshal(r.Issue) + if err != nil { + return nil, err + } + var m map[string]json.RawMessage + if err := json.Unmarshal(issueBytes, &m); err != nil { + return nil, err + } + delete(m, "issue_field_values") + if r.FieldValues != nil { + fv, err := json.Marshal(r.FieldValues) + if err != nil { + return nil, err + } + m["field_values"] = fv + } + return json.Marshal(m) +} + +// SearchIssuesResponse mirrors the REST IssuesSearchResult JSON shape and adds field_values +// per item, sourced from a single GraphQL nodes() round-trip. +type SearchIssuesResponse struct { + Total *int `json:"total_count,omitempty"` + IncompleteResults *bool `json:"incomplete_results,omitempty"` + Items []SearchIssueResult `json:"items"` +} + +// searchIssuesNodesQuery batches a nodes(ids:) lookup over the REST search results to retrieve +// each issue's custom field values in a single GraphQL request. +type searchIssuesNodesQuery struct { + Nodes []struct { + Issue struct { + ID githubv4.ID + IssueFieldValues struct { + Nodes []IssueFieldValueFragment + } `graphql:"issueFieldValues(first: 25)"` + } `graphql:"... on Issue"` + } `graphql:"nodes(ids: $ids)"` +} + +// fetchIssueFieldValuesByNodeID runs one GraphQL nodes() query for the given REST issues and +// returns a map of node_id -> flattened field values. Issues without a node_id are skipped, and +// an empty result set short-circuits the round-trip. +func fetchIssueFieldValuesByNodeID(ctx context.Context, gqlClient *githubv4.Client, issues []*github.Issue) (map[string][]MinimalIssueFieldValue, error) { + ids := make([]githubv4.ID, 0, len(issues)) + for _, iss := range issues { + if iss == nil || iss.NodeID == nil || *iss.NodeID == "" { + continue + } + ids = append(ids, githubv4.ID(*iss.NodeID)) + } + if len(ids) == 0 { + return nil, nil + } + + var q searchIssuesNodesQuery + if err := gqlClient.Query(ctx, &q, map[string]any{"ids": ids}); err != nil { + return nil, err + } + + result := make(map[string][]MinimalIssueFieldValue, len(q.Nodes)) + for _, n := range q.Nodes { + idStr, ok := n.Issue.ID.(string) + if !ok || idStr == "" { + continue + } + vals := make([]MinimalIssueFieldValue, 0, len(n.Issue.IssueFieldValues.Nodes)) + for _, fv := range n.Issue.IssueFieldValues.Nodes { + if m, ok := fragmentToMinimalIssueFieldValue(fv); ok { + vals = append(vals, m) + } + } + result[idStr] = vals + } + return result, 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). +func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[string]any, options ...searchOption) (*mcp.CallToolResult, error) { + const errorPrefix = "failed to search issues" + + query, opts, err := prepareSearchArgs(args, "issue") + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub client", err), nil + } + result, resp, err := client.Search.Issues(ctx, query, opts) + if err != nil { + return utils.NewToolResultErrorFromErr(errorPrefix, err), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return utils.NewToolResultErrorFromErr(errorPrefix+": failed to read response body", err), nil + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, errorPrefix, resp, body), nil + } + + var fieldValuesByID map[string][]MinimalIssueFieldValue + if len(result.Issues) > 0 { + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub GraphQL client", err), nil + } + fieldValuesByID, err = fetchIssueFieldValuesByNodeID(ctx, gqlClient, result.Issues) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, errorPrefix+": failed to fetch issue field values", err), nil + } + } + + items := make([]SearchIssueResult, 0, len(result.Issues)) + for _, iss := range result.Issues { + hit := SearchIssueResult{Issue: iss} + if iss != nil && iss.NodeID != nil { + hit.FieldValues = fieldValuesByID[*iss.NodeID] + } + items = append(items, hit) + } + + response := SearchIssuesResponse{ + Total: result.Total, + IncompleteResults: result.IncompleteResults, + Items: items, + } + + r, err := json.Marshal(response) + 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.postProcess != nil { + cfg.postProcess(ctx, result, callResult) + } + return callResult, nil +} + // IssueWrite creates a tool to create a new or update an existing issue in a GitHub repository. // IssueWriteUIResourceURI is the URI for the issue_write tool's MCP App UI resource. const IssueWriteUIResourceURI = "ui://github-mcp-server/issue-write" diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index c89aefb8c..ff4cb93a1 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -744,6 +744,47 @@ func Test_SearchIssues(t *testing.T) { expectError: false, expectedResult: mockSearchResult, }, + { + name: "query with field. qualifier enables advanced_search", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:issue field.priority:P1", + "page": "1", + "per_page": "30", + "advanced_search": "true", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + }), + requestArgs: map[string]any{ + "query": "field.priority:P1", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "query without field. qualifier does not set advanced_search", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: expectQueryParams( + t, + map[string]string{ + "q": "is:issue is:open", + "page": "1", + "per_page": "30", + }, + ).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + }), + requestArgs: map[string]any{ + "query": "is:open", + }, + expectError: false, + expectedResult: mockSearchResult, + }, { name: "search issues fails", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ @@ -975,6 +1016,100 @@ func unmarshalIFC(t *testing.T, ifcLabel any) map[string]any { return ifcMap } +func Test_SearchIssues_FieldValuesEnrichment(t *testing.T) { + serverTool := SearchIssues(translations.NullTranslationHelper) + + mockSearchResult := &github.IssuesSearchResult{ + Total: github.Ptr(2), + IncompleteResults: github.Ptr(false), + Issues: []*github.Issue{ + { + Number: github.Ptr(42), + Title: github.Ptr("Bug: Something is broken"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), + NodeID: github.Ptr("I_node_42"), + User: &github.User{Login: github.Ptr("user1")}, + }, + { + Number: github.Ptr(43), + Title: github.Ptr("Feature request"), + State: github.Ptr("open"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/43"), + NodeID: github.Ptr("I_node_43"), + User: &github.User{Login: github.Ptr("user2")}, + }, + }, + } + + restClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: mockResponse(t, http.StatusOK, mockSearchResult), + }) + + gqlVars := map[string]any{ + "ids": []any{"I_node_42", "I_node_43"}, + } + gqlResponse := githubv4mock.DataResponse(map[string]any{ + "nodes": []map[string]any{ + { + "id": "I_node_42", + "issueFieldValues": map[string]any{ + "nodes": []map[string]any{ + { + "__typename": "IssueFieldSingleSelectValue", + "field": map[string]any{"name": "priority"}, + "value": "P1", + }, + { + "__typename": "IssueFieldNumberValue", + "field": map[string]any{"name": "estimate"}, + "valueNumber": 2.5, + }, + }, + }, + }, + { + "id": "I_node_43", + "issueFieldValues": map[string]any{ + "nodes": []map[string]any{}, + }, + }, + }, + }) + + const nodesQueryString = "query($ids:[ID!]!){nodes(ids: $ids){... on Issue{id,issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value}}}}}}" + matcher := githubv4mock.NewQueryMatcher(nodesQueryString, gqlVars, gqlResponse) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) + + deps := BaseDeps{ + Client: mustNewGHClient(t, restClient), + GQLClient: gqlClient, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "query": "repo:owner/repo is:open", + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, "expected result to not be an error") + + textContent := getTextResult(t, result) + + var response SearchIssuesResponse + require.NoError(t, json.Unmarshal([]byte(textContent.Text), &response)) + require.Equal(t, 2, *response.Total) + require.Len(t, response.Items, 2) + assert.Equal(t, 42, *response.Items[0].Number) + assert.Equal(t, []MinimalIssueFieldValue{ + {Field: "priority", Value: "P1"}, + {Field: "estimate", Value: "2.5"}, + }, response.Items[0].FieldValues) + assert.Equal(t, 43, *response.Items[1].Number) + assert.Empty(t, response.Items[1].FieldValues) +} + func Test_CreateIssue(t *testing.T) { // Verify tool definition once serverTool := IssueWrite(translations.NullTranslationHelper) diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go index ac3aec90c..54213a240 100644 --- a/pkg/github/search_utils.go +++ b/pkg/github/search_utils.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "regexp" + "strings" ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/utils" @@ -54,21 +55,13 @@ func withSearchPostProcess(fn searchPostProcessFn) searchOption { return func(c *searchConfig) { c.postProcess = fn } } -func searchHandler( - ctx context.Context, - getClient GetClientFn, - args map[string]any, - searchType string, - errorPrefix string, - options ...searchOption, -) (*mcp.CallToolResult, error) { - cfg := searchConfig{} - for _, opt := range options { - opt(&cfg) - } +// 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. +func prepareSearchArgs(args map[string]any, searchType string) (string, *github.SearchOptions, error) { query, err := RequiredParam[string](args, "query") if err != nil { - return utils.NewToolResultError(err.Error()), nil + return "", nil, err } if !hasSpecificFilter(query, "is", searchType) { @@ -77,12 +70,12 @@ func searchHandler( owner, err := OptionalParam[string](args, "owner") if err != nil { - return utils.NewToolResultError(err.Error()), nil + return "", nil, err } repo, err := OptionalParam[string](args, "repo") if err != nil { - return utils.NewToolResultError(err.Error()), nil + return "", nil, err } if owner != "" && repo != "" && !hasRepoFilter(query) { @@ -91,19 +84,18 @@ func searchHandler( sort, err := OptionalParam[string](args, "sort") if err != nil { - return utils.NewToolResultError(err.Error()), nil + return "", nil, err } order, err := OptionalParam[string](args, "order") if err != nil { - return utils.NewToolResultError(err.Error()), nil + return "", nil, err } pagination, err := OptionalPaginationParams(args) if err != nil { - return utils.NewToolResultError(err.Error()), nil + return "", nil, err } opts := &github.SearchOptions{ - // Default to "created" if no sort is provided, as it's a common use case. Sort: sort, Order: order, ListOptions: github.ListOptions{ @@ -112,6 +104,31 @@ func searchHandler( }, } + // field.: qualifiers require the advanced search API. + if strings.Contains(query, "field.") { + opts.AdvancedSearch = github.Ptr(true) + } + + return query, opts, nil +} + +func searchHandler( + ctx context.Context, + getClient GetClientFn, + args map[string]any, + searchType string, + errorPrefix string, + options ...searchOption, +) (*mcp.CallToolResult, error) { + cfg := searchConfig{} + for _, opt := range options { + opt(&cfg) + } + query, opts, err := prepareSearchArgs(args, searchType) + if err != nil { + return utils.NewToolResultError(err.Error()), nil + } + client, err := getClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub client", err), nil From 0bd0bf0818df3c8e47dd03e6abc4f37cfa806425 Mon Sep 17 00:00:00 2001 From: Dvir Arad Date: Wed, 20 May 2026 16:27:04 +0300 Subject: [PATCH 33/48] feat: add pagination to list GHAS alerts tools (#2451) * feat(code_scanning): add pagination to list_code_scanning_alerts (#2363) * feat(dependabot): add pagination to list_dependabot_alerts (#2363) * feat(secret_scanning): add pagination to list_secret_scanning_alerts (#2363) * test(code_scanning): pagination expectations + new test case (#2363) * test(dependabot): pagination expectations + new test case (#2363) * test(secret_scanning): pagination expectations + new test case (#2363) * test(toolsnaps): refresh list_code_scanning_alerts with page/perPage (#2363) * test(toolsnaps): refresh list_dependabot_alerts with page/perPage (#2363) * test(toolsnaps): refresh list_secret_scanning_alerts with page/perPage (#2363) * docs: regenerate README for new pagination params Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Sam Morrow Co-authored-by: sammorrowdrums Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 6 ++ .../list_code_scanning_alerts.snap | 11 +++ .../__toolsnaps__/list_dependabot_alerts.snap | 11 +++ .../list_secret_scanning_alerts.snap | 11 +++ pkg/github/code_scanning.go | 85 +++++++++++-------- pkg/github/code_scanning_test.go | 23 +++++ pkg/github/dependabot.go | 62 ++++++++------ pkg/github/dependabot_test.go | 30 ++++++- pkg/github/secret_scanning.go | 74 +++++++++------- pkg/github/secret_scanning_test.go | 28 +++++- 10 files changed, 249 insertions(+), 92 deletions(-) diff --git a/README.md b/README.md index 526d422aa..6fde2c6cb 100644 --- a/README.md +++ b/README.md @@ -649,6 +649,8 @@ The following sets of tools are available: - **Required OAuth Scopes**: `security_events` - **Accepted OAuth Scopes**: `repo`, `security_events` - `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) - `ref`: The Git reference for the results you want to list. (string, optional) - `repo`: The name of the repository. (string, required) - `severity`: Filter code scanning alerts by severity (string, optional) @@ -712,6 +714,8 @@ The following sets of tools are available: - **Required OAuth Scopes**: `security_events` - **Accepted OAuth Scopes**: `repo`, `security_events` - `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) - `severity`: Filter dependabot alerts by severity (string, optional) - `state`: Filter dependabot alerts by state. Defaults to open (string, optional) @@ -1324,6 +1328,8 @@ The following sets of tools are available: - **Required OAuth Scopes**: `security_events` - **Accepted OAuth Scopes**: `repo`, `security_events` - `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) - `resolution`: Filter by resolution (string, optional) - `secret_type`: A comma-separated list of secret types to return. All default secret patterns are returned. To return generic patterns, pass the token name(s) in the parameter. (string, optional) diff --git a/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap b/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap index 5b7d79ef4..9eddf045d 100644 --- a/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap +++ b/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap @@ -10,6 +10,17 @@ "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, + "minimum": 1, + "type": "number" + }, "ref": { "description": "The Git reference for the results you want to list.", "type": "string" diff --git a/pkg/github/__toolsnaps__/list_dependabot_alerts.snap b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap index 83f725987..55d543779 100644 --- a/pkg/github/__toolsnaps__/list_dependabot_alerts.snap +++ b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap @@ -10,6 +10,17 @@ "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, + "minimum": 1, + "type": "number" + }, "repo": { "description": "The name of the repository.", "type": "string" diff --git a/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap b/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap index f2f7cb125..5c6a21a0a 100644 --- a/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap +++ b/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap @@ -10,6 +10,17 @@ "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, + "minimum": 1, + "type": "number" + }, "repo": { "description": "The name of the repository.", "type": "string" diff --git a/pkg/github/code_scanning.go b/pkg/github/code_scanning.go index 2deefd321..44307513b 100644 --- a/pkg/github/code_scanning.go +++ b/pkg/github/code_scanning.go @@ -94,6 +94,41 @@ func GetCodeScanningAlert(t translations.TranslationHelperFunc) inventory.Server } func ListCodeScanningAlerts(t translations.TranslationHelperFunc) inventory.ServerTool { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "The owner of the repository.", + }, + "repo": { + Type: "string", + Description: "The name of the repository.", + }, + "state": { + Type: "string", + Description: "Filter code scanning alerts by state. Defaults to open", + Enum: []any{"open", "closed", "dismissed", "fixed"}, + Default: json.RawMessage(`"open"`), + }, + "ref": { + Type: "string", + Description: "The Git reference for the results you want to list.", + }, + "severity": { + Type: "string", + Description: "Filter code scanning alerts by severity", + Enum: []any{"critical", "high", "medium", "low", "warning", "note", "error"}, + }, + "tool_name": { + Type: "string", + Description: "The name of the tool used for code scanning.", + }, + }, + Required: []string{"owner", "repo"}, + } + WithPagination(schema) + return NewTool( ToolsetMetadataCodeSecurity, mcp.Tool{ @@ -103,39 +138,7 @@ func ListCodeScanningAlerts(t translations.TranslationHelperFunc) inventory.Serv Title: t("TOOL_LIST_CODE_SCANNING_ALERTS_USER_TITLE", "List code scanning alerts"), ReadOnlyHint: true, }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: "The owner of the repository.", - }, - "repo": { - Type: "string", - Description: "The name of the repository.", - }, - "state": { - Type: "string", - Description: "Filter code scanning alerts by state. Defaults to open", - Enum: []any{"open", "closed", "dismissed", "fixed"}, - Default: json.RawMessage(`"open"`), - }, - "ref": { - Type: "string", - Description: "The Git reference for the results you want to list.", - }, - "severity": { - Type: "string", - Description: "Filter code scanning alerts by severity", - Enum: []any{"critical", "high", "medium", "low", "warning", "note", "error"}, - }, - "tool_name": { - Type: "string", - Description: "The name of the tool used for code scanning.", - }, - }, - Required: []string{"owner", "repo"}, - }, + InputSchema: schema, }, []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { @@ -164,11 +167,25 @@ func ListCodeScanningAlerts(t translations.TranslationHelperFunc) inventory.Serv return utils.NewToolResultError(err.Error()), nil, nil } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } - alerts, resp, err := client.CodeScanning.ListAlertsForRepo(ctx, owner, repo, &github.AlertListOptions{Ref: ref, State: state, Severity: severity, ToolName: toolName}) + alerts, resp, err := client.CodeScanning.ListAlertsForRepo(ctx, owner, repo, &github.AlertListOptions{ + Ref: ref, + State: state, + Severity: severity, + ToolName: toolName, + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + }) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list alerts", diff --git a/pkg/github/code_scanning_test.go b/pkg/github/code_scanning_test.go index 64c61766e..3d0f261d2 100644 --- a/pkg/github/code_scanning_test.go +++ b/pkg/github/code_scanning_test.go @@ -137,6 +137,8 @@ func Test_ListCodeScanningAlerts(t *testing.T) { assert.Contains(t, schema.Properties, "state") assert.Contains(t, schema.Properties, "severity") assert.Contains(t, schema.Properties, "tool_name") + assert.Contains(t, schema.Properties, "page") + assert.Contains(t, schema.Properties, "perPage") assert.ElementsMatch(t, schema.Required, []string{"owner", "repo"}) // Setup mock alerts for success case @@ -171,6 +173,8 @@ func Test_ListCodeScanningAlerts(t *testing.T) { "state": "open", "severity": "high", "tool_name": "codeql", + "page": "1", + "per_page": "30", }).andThen( mockResponse(t, http.StatusOK, mockAlerts), ), @@ -186,6 +190,25 @@ func Test_ListCodeScanningAlerts(t *testing.T) { expectError: false, expectedAlerts: mockAlerts, }, + { + name: "successful alerts listing with custom pagination", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCodeScanningAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "50", + }).andThen( + mockResponse(t, http.StatusOK, mockAlerts), + ), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "page": float64(2), + "perPage": float64(50), + }, + expectError: false, + expectedAlerts: mockAlerts, + }, { name: "alerts listing fails", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ diff --git a/pkg/github/dependabot.go b/pkg/github/dependabot.go index ccb36f483..02023da69 100644 --- a/pkg/github/dependabot.go +++ b/pkg/github/dependabot.go @@ -95,6 +95,33 @@ func GetDependabotAlert(t translations.TranslationHelperFunc) inventory.ServerTo } func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.ServerTool { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "The owner of the repository.", + }, + "repo": { + Type: "string", + Description: "The name of the repository.", + }, + "state": { + Type: "string", + Description: "Filter dependabot alerts by state. Defaults to open", + Enum: []any{"open", "fixed", "dismissed", "auto_dismissed"}, + Default: json.RawMessage(`"open"`), + }, + "severity": { + Type: "string", + Description: "Filter dependabot alerts by severity", + Enum: []any{"low", "medium", "high", "critical"}, + }, + }, + Required: []string{"owner", "repo"}, + } + WithPagination(schema) + return NewTool( ToolsetMetadataDependabot, mcp.Tool{ @@ -104,31 +131,7 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server Title: t("TOOL_LIST_DEPENDABOT_ALERTS_USER_TITLE", "List dependabot alerts"), ReadOnlyHint: true, }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: "The owner of the repository.", - }, - "repo": { - Type: "string", - Description: "The name of the repository.", - }, - "state": { - Type: "string", - Description: "Filter dependabot alerts by state. Defaults to open", - Enum: []any{"open", "fixed", "dismissed", "auto_dismissed"}, - Default: json.RawMessage(`"open"`), - }, - "severity": { - Type: "string", - Description: "Filter dependabot alerts by severity", - Enum: []any{"low", "medium", "high", "critical"}, - }, - }, - Required: []string{"owner", "repo"}, - }, + InputSchema: schema, }, []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { @@ -149,6 +152,11 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server return utils.NewToolResultError(err.Error()), nil, nil } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, err @@ -157,6 +165,10 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server alerts, resp, err := client.Dependabot.ListRepoAlerts(ctx, owner, repo, &github.ListAlertsOptions{ State: ToStringPtr(state), Severity: ToStringPtr(severity), + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, }) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, diff --git a/pkg/github/dependabot_test.go b/pkg/github/dependabot_test.go index 2196b6b13..781148390 100644 --- a/pkg/github/dependabot_test.go +++ b/pkg/github/dependabot_test.go @@ -165,7 +165,9 @@ func Test_ListDependabotAlerts(t *testing.T) { name: "successful open alerts listing", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ - "state": "open", + "state": "open", + "page": "1", + "per_page": "30", }).andThen( mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert}), ), @@ -183,6 +185,8 @@ func Test_ListDependabotAlerts(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ "severity": "high", + "page": "1", + "per_page": "30", }).andThen( mockResponse(t, http.StatusOK, []*github.DependabotAlert{&highSeverityAlert}), ), @@ -198,7 +202,10 @@ func Test_ListDependabotAlerts(t *testing.T) { { name: "successful all alerts listing", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{}).andThen( + GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "page": "1", + "per_page": "30", + }).andThen( mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert, &highSeverityAlert}), ), }), @@ -209,6 +216,25 @@ func Test_ListDependabotAlerts(t *testing.T) { expectError: false, expectedAlerts: []*github.DependabotAlert{&criticalAlert, &highSeverityAlert}, }, + { + name: "successful alerts listing with custom pagination", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "page": "3", + "per_page": "100", + }).andThen( + mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert}), + ), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "page": float64(3), + "perPage": float64(100), + }, + expectError: false, + expectedAlerts: []*github.DependabotAlert{&criticalAlert}, + }, { name: "alerts listing fails", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ diff --git a/pkg/github/secret_scanning.go b/pkg/github/secret_scanning.go index 5cbe52c42..e2605274f 100644 --- a/pkg/github/secret_scanning.go +++ b/pkg/github/secret_scanning.go @@ -95,6 +95,36 @@ func GetSecretScanningAlert(t translations.TranslationHelperFunc) inventory.Serv } func ListSecretScanningAlerts(t translations.TranslationHelperFunc) inventory.ServerTool { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "The owner of the repository.", + }, + "repo": { + Type: "string", + Description: "The name of the repository.", + }, + "state": { + Type: "string", + Description: "Filter by state", + Enum: []any{"open", "resolved"}, + }, + "secret_type": { + Type: "string", + Description: "A comma-separated list of secret types to return. All default secret patterns are returned. To return generic patterns, pass the token name(s) in the parameter.", + }, + "resolution": { + Type: "string", + Description: "Filter by resolution", + Enum: []any{"false_positive", "wont_fix", "revoked", "pattern_edited", "pattern_deleted", "used_in_tests"}, + }, + }, + Required: []string{"owner", "repo"}, + } + WithPagination(schema) + return NewTool( ToolsetMetadataSecretProtection, mcp.Tool{ @@ -104,34 +134,7 @@ func ListSecretScanningAlerts(t translations.TranslationHelperFunc) inventory.Se Title: t("TOOL_LIST_SECRET_SCANNING_ALERTS_USER_TITLE", "List secret scanning alerts"), ReadOnlyHint: true, }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: "The owner of the repository.", - }, - "repo": { - Type: "string", - Description: "The name of the repository.", - }, - "state": { - Type: "string", - Description: "Filter by state", - Enum: []any{"open", "resolved"}, - }, - "secret_type": { - Type: "string", - Description: "A comma-separated list of secret types to return. All default secret patterns are returned. To return generic patterns, pass the token name(s) in the parameter.", - }, - "resolution": { - Type: "string", - Description: "Filter by resolution", - Enum: []any{"false_positive", "wont_fix", "revoked", "pattern_edited", "pattern_deleted", "used_in_tests"}, - }, - }, - Required: []string{"owner", "repo"}, - }, + InputSchema: schema, }, []scopes.Scope{scopes.SecurityEvents}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { @@ -156,11 +159,24 @@ func ListSecretScanningAlerts(t translations.TranslationHelperFunc) inventory.Se return utils.NewToolResultError(err.Error()), nil, nil } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + client, err := deps.GetClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } - alerts, resp, err := client.SecretScanning.ListAlertsForRepo(ctx, owner, repo, &github.SecretScanningAlertListOptions{State: state, SecretType: secretType, Resolution: resolution}) + alerts, resp, err := client.SecretScanning.ListAlertsForRepo(ctx, owner, repo, &github.SecretScanningAlertListOptions{ + State: state, + SecretType: secretType, + Resolution: resolution, + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + }) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, fmt.Sprintf("failed to list alerts for repository '%s/%s'", owner, repo), diff --git a/pkg/github/secret_scanning_test.go b/pkg/github/secret_scanning_test.go index 1aa451e05..eb94fa5e9 100644 --- a/pkg/github/secret_scanning_test.go +++ b/pkg/github/secret_scanning_test.go @@ -165,7 +165,9 @@ func Test_ListSecretScanningAlerts(t *testing.T) { name: "successful resolved alerts listing", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposSecretScanningAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ - "state": "resolved", + "state": "resolved", + "page": "1", + "per_page": "30", }).andThen( mockResponse(t, http.StatusOK, []*github.SecretScanningAlert{&resolvedAlert}), ), @@ -181,7 +183,10 @@ func Test_ListSecretScanningAlerts(t *testing.T) { { name: "successful alerts listing", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - GetReposSecretScanningAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{}).andThen( + GetReposSecretScanningAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "page": "1", + "per_page": "30", + }).andThen( mockResponse(t, http.StatusOK, []*github.SecretScanningAlert{&resolvedAlert, &openAlert}), ), }), @@ -192,6 +197,25 @@ func Test_ListSecretScanningAlerts(t *testing.T) { expectError: false, expectedAlerts: []*github.SecretScanningAlert{&resolvedAlert, &openAlert}, }, + { + name: "successful alerts listing with custom pagination", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposSecretScanningAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "50", + }).andThen( + mockResponse(t, http.StatusOK, []*github.SecretScanningAlert{&openAlert}), + ), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "page": float64(2), + "perPage": float64(50), + }, + expectError: false, + expectedAlerts: []*github.SecretScanningAlert{&openAlert}, + }, { name: "alerts listing fails", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ From 6b4ca78505e719019e0b2e95ab7aec57f6c43a23 Mon Sep 17 00:00:00 2001 From: Dhananjay Mishra Date: Wed, 20 May 2026 19:54:00 +0530 Subject: [PATCH 34/48] feat: Add search commit tool (#2284) * add `SearchCommits` tool * run test * run script/generate-docs * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * refactor(search_commits): share commit conversion, surface repo, tighten query docs - Extract newMinimalCommitFromCore to share field mapping between convertToMinimalCommit (RepositoryCommit) and the new convertCommitResultToMinimalCommit (CommitResult), removing ~50 lines of duplicated logic from the search_commits handler. - Add MinimalRepoRef and a search-only MinimalCommitSearchItem type (embedding MinimalCommit) so cross-repo commit search results identify the repo each commit came from. Keeping the field off MinimalCommit avoids paying for a never-populated field on the get_commit/list_commits output types. - Rewrite the query description to teach the model the actual commit-search qualifier surface (repo:/org:/user: scoping, author/ committer/date qualifiers, hash/tree/parent, merge:, is:public) and reword the sort description to drop redundancy with the enum. - Extend tests to assert the repository field is surfaced and to cover commits with no resolved GitHub user (nil Author/Committer). - Refresh README and toolsnap. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Sam Morrow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 8 + pkg/github/__toolsnaps__/search_commits.snap | 47 ++++++ pkg/github/helper_test.go | 1 + pkg/github/minimal_types.go | 117 +++++++++++--- pkg/github/search.go | 106 ++++++++++++ pkg/github/search_test.go | 160 +++++++++++++++++++ pkg/github/tools.go | 1 + 7 files changed, 414 insertions(+), 26 deletions(-) create mode 100644 pkg/github/__toolsnaps__/search_commits.snap diff --git a/README.md b/README.md index 6fde2c6cb..b4a5927b1 100644 --- a/README.md +++ b/README.md @@ -1302,6 +1302,14 @@ The following sets of tools are available: - `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_commits** - Search commits + - **Required OAuth Scopes**: `repo` + - `order`: Sort order (string, optional) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `query`: Commit search query (GitHub commit search REST). Searches commit messages on the default branch only. Scope the search with `repo:owner/repo`, `org:`, or `user:` (queries without a scope qualifier match across all of GitHub and are usually not what you want). Other qualifiers: `author:`, `committer:`, `author-name:`, `committer-name:`, `author-email:`, `committer-email:`, `author-date:`, `committer-date:` (supports `>`, `<`, `>=`, `<=`, and `YYYY-MM-DD..YYYY-MM-DD` ranges), `merge:true|false`, `hash:`, `tree:`, `parent:`, `is:public`. Examples: `repo:owner/repo fix panic`; `org:github author:defunkt committer-date:>=2024-01-01`; `"refactor cache" repo:o/r`; `hash:abc1234 repo:o/r`. (string, required) + - `sort`: Sort by author or committer date (defaults to best match) (string, optional) + - **search_repositories** - Search repositories - **Required OAuth Scopes**: `repo` - `minimal_output`: Return minimal repository information (default: true). When false, returns full GitHub API repository objects. (boolean, optional) diff --git a/pkg/github/__toolsnaps__/search_commits.snap b/pkg/github/__toolsnaps__/search_commits.snap new file mode 100644 index 000000000..394bce9a1 --- /dev/null +++ b/pkg/github/__toolsnaps__/search_commits.snap @@ -0,0 +1,47 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Search commits" + }, + "description": "Search for commits across GitHub repositories using GitHub's commit search syntax. Useful for finding specific changes, authors, or messages across one or many repositories. Searches the default branch only.", + "inputSchema": { + "properties": { + "order": { + "description": "Sort order", + "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": "Commit search query (GitHub commit search REST). Searches commit messages on the default branch only. Scope the search with `repo:owner/repo`, `org:`, or `user:` (queries without a scope qualifier match across all of GitHub and are usually not what you want). Other qualifiers: `author:`, `committer:`, `author-name:`, `committer-name:`, `author-email:`, `committer-email:`, `author-date:`, `committer-date:` (supports `\u003e`, `\u003c`, `\u003e=`, `\u003c=`, and `YYYY-MM-DD..YYYY-MM-DD` ranges), `merge:true|false`, `hash:`, `tree:`, `parent:`, `is:public`. Examples: `repo:owner/repo fix panic`; `org:github author:defunkt committer-date:\u003e=2024-01-01`; `\"refactor cache\" repo:o/r`; `hash:abc1234 repo:o/r`.", + "type": "string" + }, + "sort": { + "description": "Sort by author or committer date (defaults to best match)", + "enum": [ + "author-date", + "committer-date" + ], + "type": "string" + } + }, + "required": [ + "query" + ], + "type": "object" + }, + "name": "search_commits" +} \ No newline at end of file diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index 4181f102e..fdac78ce3 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -140,6 +140,7 @@ const ( GetSearchIssues = "GET /search/issues" GetSearchUsers = "GET /search/users" GetSearchRepositories = "GET /search/repositories" + GetSearchCommits = "GET /search/commits" // Raw content endpoints (used for GitHub raw content API, not standard API) // These are used with the raw content client that interacts with raw.githubusercontent.com diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 89d8a0199..a33b401d5 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -130,6 +130,23 @@ type MinimalCommit struct { Files []MinimalCommitFile `json:"files,omitempty"` } +// MinimalRepoRef is a lightweight reference to a repository, used when a +// result needs to identify which repository it belongs to (for example, in +// cross-repo commit search results). +type MinimalRepoRef struct { + FullName string `json:"full_name"` + HTMLURL string `json:"html_url,omitempty"` + Private bool `json:"private,omitempty"` +} + +// MinimalCommitSearchItem extends MinimalCommit with the containing +// repository, since commit search spans repositories and callers need to +// know which repo each result came from. +type MinimalCommitSearchItem struct { + MinimalCommit + Repository *MinimalRepoRef `json:"repository,omitempty"` +} + // MinimalRelease is the trimmed output type for release objects. type MinimalRelease struct { ID int64 `json:"id"` @@ -254,6 +271,13 @@ type MinimalIssueComment struct { UpdatedAt string `json:"updated_at,omitempty"` } +// MinimalSearchCommitsResult is the trimmed output type for commit search results. +type MinimalSearchCommitsResult struct { + TotalCount int `json:"total_count"` + IncompleteResults bool `json:"incomplete_results"` + Items []MinimalCommitSearchItem `json:"items"` +} + // MinimalFileContentResponse is the trimmed output type for create/update/delete file responses. type MinimalFileContentResponse struct { Content *MinimalFileContent `json:"content,omitempty"` @@ -693,57 +717,73 @@ func convertToMinimalUser(user *github.User) *MinimalUser { } } -// convertToMinimalCommit converts a GitHub API RepositoryCommit to MinimalCommit -func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool) MinimalCommit { +// newMinimalCommitFromCore builds a MinimalCommit from the fields that are +// shared between *github.RepositoryCommit and *github.CommitResult. Caller +// is responsible for setting any type-specific extras (stats/files for +// RepositoryCommit, repository for CommitResult). +func newMinimalCommitFromCore(sha, htmlURL string, commit *github.Commit, author, committer *github.User) MinimalCommit { minimalCommit := MinimalCommit{ - SHA: commit.GetSHA(), - HTMLURL: commit.GetHTMLURL(), + SHA: sha, + HTMLURL: htmlURL, } - if commit.Commit != nil { + if commit != nil { minimalCommit.Commit = &MinimalCommitInfo{ - Message: commit.Commit.GetMessage(), + Message: commit.GetMessage(), } - if commit.Commit.Author != nil { + if commit.Author != nil { minimalCommit.Commit.Author = &MinimalCommitAuthor{ - Name: commit.Commit.Author.GetName(), - Email: commit.Commit.Author.GetEmail(), + Name: commit.Author.GetName(), + Email: commit.Author.GetEmail(), } - if commit.Commit.Author.Date != nil { - minimalCommit.Commit.Author.Date = commit.Commit.Author.Date.Format(time.RFC3339) + if commit.Author.Date != nil { + minimalCommit.Commit.Author.Date = commit.Author.Date.Format(time.RFC3339) } } - if commit.Commit.Committer != nil { + if commit.Committer != nil { minimalCommit.Commit.Committer = &MinimalCommitAuthor{ - Name: commit.Commit.Committer.GetName(), - Email: commit.Commit.Committer.GetEmail(), + Name: commit.Committer.GetName(), + Email: commit.Committer.GetEmail(), } - if commit.Commit.Committer.Date != nil { - minimalCommit.Commit.Committer.Date = commit.Commit.Committer.Date.Format(time.RFC3339) + if commit.Committer.Date != nil { + minimalCommit.Commit.Committer.Date = commit.Committer.Date.Format(time.RFC3339) } } } - if commit.Author != nil { + if author != nil { minimalCommit.Author = &MinimalUser{ - Login: commit.Author.GetLogin(), - ID: commit.Author.GetID(), - ProfileURL: commit.Author.GetHTMLURL(), - AvatarURL: commit.Author.GetAvatarURL(), + Login: author.GetLogin(), + ID: author.GetID(), + ProfileURL: author.GetHTMLURL(), + AvatarURL: author.GetAvatarURL(), } } - if commit.Committer != nil { + if committer != nil { minimalCommit.Committer = &MinimalUser{ - Login: commit.Committer.GetLogin(), - ID: commit.Committer.GetID(), - ProfileURL: commit.Committer.GetHTMLURL(), - AvatarURL: commit.Committer.GetAvatarURL(), + Login: committer.GetLogin(), + ID: committer.GetID(), + ProfileURL: committer.GetHTMLURL(), + AvatarURL: committer.GetAvatarURL(), } } + return minimalCommit +} + +// convertToMinimalCommit converts a GitHub API RepositoryCommit to MinimalCommit +func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool) MinimalCommit { + minimalCommit := newMinimalCommitFromCore( + commit.GetSHA(), + commit.GetHTMLURL(), + commit.Commit, + commit.Author, + commit.Committer, + ) + // Only include stats and files if includeDiffs is true if includeDiffs { if commit.Stats != nil { @@ -772,6 +812,31 @@ func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool) return minimalCommit } +// convertCommitResultToMinimalCommit converts a GitHub API commit search +// result, attaching the containing repository so the caller can tell which +// repo each result came from. +func convertCommitResultToMinimalCommit(commit *github.CommitResult) MinimalCommitSearchItem { + item := MinimalCommitSearchItem{ + MinimalCommit: newMinimalCommitFromCore( + commit.GetSHA(), + commit.GetHTMLURL(), + commit.Commit, + commit.Author, + commit.Committer, + ), + } + + if commit.Repository != nil { + item.Repository = &MinimalRepoRef{ + FullName: commit.Repository.GetFullName(), + HTMLURL: commit.Repository.GetHTMLURL(), + Private: commit.Repository.GetPrivate(), + } + } + + return item +} + // MinimalPageInfo contains pagination cursor information. type MinimalPageInfo struct { HasNextPage bool `json:"hasNextPage"` diff --git a/pkg/github/search.go b/pkg/github/search.go index e360f08f8..9d50a6310 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -478,3 +478,109 @@ func SearchOrgs(t translations.TranslationHelperFunc) inventory.ServerTool { }, ) } + +// SearchCommits creates a tool to search for commits across GitHub repositories. +func SearchCommits(t translations.TranslationHelperFunc) inventory.ServerTool { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "query": { + Type: "string", + Description: "Commit search query (GitHub commit search REST). Searches commit messages on the default branch only. Scope the search with `repo:owner/repo`, `org:`, or `user:` (queries without a scope qualifier match across all of GitHub and are usually not what you want). Other qualifiers: `author:`, `committer:`, `author-name:`, `committer-name:`, `author-email:`, `committer-email:`, `author-date:`, `committer-date:` (supports `>`, `<`, `>=`, `<=`, and `YYYY-MM-DD..YYYY-MM-DD` ranges), `merge:true|false`, `hash:`, `tree:`, `parent:`, `is:public`. Examples: `repo:owner/repo fix panic`; `org:github author:defunkt committer-date:>=2024-01-01`; `\"refactor cache\" repo:o/r`; `hash:abc1234 repo:o/r`.", + }, + "sort": { + Type: "string", + Description: "Sort by author or committer date (defaults to best match)", + Enum: []any{"author-date", "committer-date"}, + }, + "order": { + Type: "string", + Description: "Sort order", + Enum: []any{"asc", "desc"}, + }, + }, + Required: []string{"query"}, + } + WithPagination(schema) + + return NewTool( + ToolsetMetadataRepos, + mcp.Tool{ + Name: "search_commits", + Description: t("TOOL_SEARCH_COMMITS_DESCRIPTION", "Search for commits across GitHub repositories using GitHub's commit search syntax. Useful for finding specific changes, authors, or messages across one or many repositories. Searches the default branch only."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_SEARCH_COMMITS_USER_TITLE", "Search commits"), + ReadOnlyHint: true, + }, + InputSchema: schema, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + query, err := RequiredParam[string](args, "query") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + sort, err := OptionalParam[string](args, "sort") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + order, err := OptionalParam[string](args, "order") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + opts := &github.SearchOptions{ + Sort: sort, + Order: order, + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + } + + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + result, resp, err := client.Search.Commits(ctx, query, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + fmt.Sprintf("failed to search commits with query '%s'", query), + resp, + err, + ), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to search commits", resp, body), nil, nil + } + + minimalCommits := make([]MinimalCommitSearchItem, 0, len(result.Commits)) + for _, commit := range result.Commits { + minimalCommits = append(minimalCommits, convertCommitResultToMinimalCommit(commit)) + } + + minimalResult := &MinimalSearchCommitsResult{ + TotalCount: result.GetTotal(), + IncompleteResults: result.GetIncompleteResults(), + Items: minimalCommits, + } + + r, err := json.Marshal(minimalResult) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + + return utils.NewToolResultText(string(r)), nil, nil + }, + ) +} diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index 74a3ca52f..f1acec3e2 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "net/http" "testing" + "time" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" @@ -854,3 +855,162 @@ func Test_SearchOrgs(t *testing.T) { }) } } + +func Test_SearchCommits(t *testing.T) { + serverTool := SearchCommits(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "search_commits", tool.Name) + assert.NotEmpty(t, tool.Description) + + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.Contains(t, schema.Properties, "query") + assert.Contains(t, schema.Properties, "sort") + assert.Contains(t, schema.Properties, "order") + assert.Contains(t, schema.Properties, "page") + assert.Contains(t, schema.Properties, "perPage") + assert.ElementsMatch(t, schema.Required, []string{"query"}) + + now := time.Now().Truncate(time.Second) + mockSearchResult := &github.CommitsSearchResult{ + Total: github.Ptr(2), + IncompleteResults: github.Ptr(false), + Commits: []*github.CommitResult{ + { + SHA: github.Ptr("abc123commit"), + HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123commit"), + Commit: &github.Commit{ + Message: github.Ptr("Initial commit"), + Author: &github.CommitAuthor{ + Name: github.Ptr("Author Name"), + Email: github.Ptr("author@example.com"), + Date: &github.Timestamp{Time: now}, + }, + }, + Author: &github.User{ + Login: github.Ptr("author"), + ID: github.Ptr(int64(1)), + HTMLURL: github.Ptr("https://github.com/author"), + }, + Repository: &github.Repository{ + FullName: github.Ptr("owner/repo"), + HTMLURL: github.Ptr("https://github.com/owner/repo"), + Private: github.Ptr(false), + }, + }, + { + // Commit with no resolved GitHub user for author or committer + // (common when the commit email isn't linked to an account). + SHA: github.Ptr("def456commit"), + HTMLURL: github.Ptr("https://github.com/owner/repo/commit/def456commit"), + Commit: &github.Commit{ + Message: github.Ptr("Unlinked author"), + }, + Repository: &github.Repository{ + FullName: github.Ptr("owner/repo"), + }, + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedResult *github.CommitsSearchResult + expectedErrMsg string + }{ + { + name: "successful commit search", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchCommits: expectQueryParams(t, map[string]string{ + "q": "fix bug in:message repo:owner/repo", + "sort": "author-date", + "order": "desc", + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockSearchResult), + ), + }), + requestArgs: map[string]any{ + "query": "fix bug in:message repo:owner/repo", + "sort": "author-date", + "order": "desc", + }, + expectError: false, + expectedResult: mockSearchResult, + }, + { + name: "search fails", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchCommits: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"message": "Validation Failed"}`)) + }), + }), + requestArgs: map[string]any{ + "query": "invalid:syntax", + }, + expectError: true, + expectedErrMsg: "failed to search commits", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, tc.mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := serverTool.Handler(deps) + request := createMCPRequest(tc.requestArgs) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var returnedResult MinimalSearchCommitsResult + err = json.Unmarshal([]byte(textContent.Text), &returnedResult) + require.NoError(t, err) + + assert.Equal(t, tc.expectedResult.GetTotal(), returnedResult.TotalCount) + assert.Len(t, returnedResult.Items, len(tc.expectedResult.Commits)) + assert.Equal(t, *tc.expectedResult.Commits[0].SHA, returnedResult.Items[0].SHA) + assert.Equal(t, *tc.expectedResult.Commits[0].Commit.Message, returnedResult.Items[0].Commit.Message) + assert.Equal(t, *tc.expectedResult.Commits[0].Commit.Author.Name, returnedResult.Items[0].Commit.Author.Name) + assert.Equal(t, now.Format(time.RFC3339), returnedResult.Items[0].Commit.Author.Date) + assert.Equal(t, *tc.expectedResult.Commits[0].Author.Login, returnedResult.Items[0].Author.Login) + + // Repository info is required so callers can identify which repo + // each cross-repo search result belongs to. + require.NotNil(t, returnedResult.Items[0].Repository) + assert.Equal(t, "owner/repo", returnedResult.Items[0].Repository.FullName) + assert.Equal(t, "https://github.com/owner/repo", returnedResult.Items[0].Repository.HTMLURL) + + // Second commit has no resolved GitHub user for author/committer + // and no commit-level author block — the handler must not panic + // and must omit those fields cleanly. + require.Len(t, returnedResult.Items, 2) + assert.Equal(t, "def456commit", returnedResult.Items[1].SHA) + assert.Nil(t, returnedResult.Items[1].Author) + assert.Nil(t, returnedResult.Items[1].Committer) + require.NotNil(t, returnedResult.Items[1].Commit) + assert.Nil(t, returnedResult.Items[1].Commit.Author) + assert.Nil(t, returnedResult.Items[1].Commit.Committer) + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index c7f5abf3b..7d22c72fc 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -178,6 +178,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { GetFileContents(t), ListCommits(t), SearchCode(t), + SearchCommits(t), GetCommit(t), ListBranches(t), ListTags(t), From 805ad75c62f7c8572be982d83923a8e44cb50934 Mon Sep 17 00:00:00 2001 From: Jui Desai Date: Thu, 21 May 2026 07:33:28 -0500 Subject: [PATCH 35/48] Fix return Thread node ID in get_review_comments response (#2515) * fix return Thread ID in get_review_comments response * Fix syntax error in convertToMinimalReviewThread Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Jui Desai Co-authored-by: Sam Morrow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/github/minimal_types.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index a33b401d5..bad5196a9 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -1,6 +1,7 @@ package github import ( + "fmt" "strconv" "time" @@ -858,6 +859,7 @@ type MinimalReviewComment struct { // MinimalReviewThread is the trimmed output type for PR review thread objects. type MinimalReviewThread struct { + ID string IsResolved bool `json:"is_resolved"` IsOutdated bool `json:"is_outdated"` IsCollapsed bool `json:"is_collapsed"` @@ -994,6 +996,7 @@ func convertToMinimalReviewThread(thread reviewThreadNode) MinimalReviewThread { } return MinimalReviewThread{ + ID: fmt.Sprintf("%v", thread.ID), IsResolved: bool(thread.IsResolved), IsOutdated: bool(thread.IsOutdated), IsCollapsed: bool(thread.IsCollapsed), From 4a70290af06332c7e24601db9da679854ba3b186 Mon Sep 17 00:00:00 2001 From: Iulia B Date: Thu, 21 May 2026 12:58:49 +0000 Subject: [PATCH 36/48] fix(tests): use MinimalFieldValue for GraphQL field value assertions --- .github/workflows/code-scanning.yml | 8 ++++---- .github/workflows/docker-publish.yml | 6 +++--- .github/workflows/docs-check.yml | 9 ++++++++- .github/workflows/go.yml | 10 +++++++++- .github/workflows/goreleaser.yml | 13 ++++++++++--- .github/workflows/license-check.yml | 9 ++++++++- .github/workflows/lint.yml | 7 ++++++- .github/workflows/mcp-diff.yml | 24 ++++++++++++++++++++++-- pkg/github/issues_test.go | 6 +++--- 9 files changed, 73 insertions(+), 19 deletions(-) diff --git a/.github/workflows/code-scanning.yml b/.github/workflows/code-scanning.yml index ecbe9f0dc..e58a45e71 100644 --- a/.github/workflows/code-scanning.yml +++ b/.github/workflows/code-scanning.yml @@ -78,9 +78,9 @@ jobs: go-version: ${{ fromJSON(steps.resolve-environment.outputs.environment).configuration.go.version }} cache: false - - name: Set up Node.js (for JavaScript CodeQL) - if: matrix.language == 'javascript' - uses: actions/setup-node@v6 + - name: Set up Node.js + if: matrix.language == 'go' || matrix.language == 'javascript' + uses: actions/setup-node@v4 with: node-version: "20" cache: "npm" @@ -88,7 +88,7 @@ jobs: - name: Build UI if: matrix.language == 'go' - uses: ./.github/actions/build-ui + run: script/build-ui - name: Autobuild uses: github/codeql-action/autobuild@v4 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index f56d4f31a..638713c70 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -46,7 +46,7 @@ jobs: # https://github.com/sigstore/cosign-installer - name: Install cosign if: github.event_name != 'pull_request' - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 #v4.1.2 + uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 #v4.1.0 with: cosign-release: "v2.2.4" @@ -60,7 +60,7 @@ jobs: # https://github.com/docker/login-action - name: Log into registry ${{ env.REGISTRY }} if: github.event_name != 'pull_request' - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -93,7 +93,7 @@ jobs: key: ${{ runner.os }}-go-build-cache-${{ hashFiles('**/go.sum') }} - name: Inject go-build-cache - uses: reproducible-containers/buildkit-cache-dance@5422eac04292c961a382e0f584ea0f03ad9da723 # v3.4.0 + uses: reproducible-containers/buildkit-cache-dance@1b8ab18fbda5ad3646e3fcc9ed9dd41ce2f297b4 # v3.3.2 with: cache-map: | { diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index 309eddb38..de62d6282 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -16,8 +16,15 @@ jobs: - name: Checkout code uses: actions/checkout@v6 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: ui/package-lock.json + - name: Build UI - uses: ./.github/actions/build-ui + run: script/build-ui - name: Set up Go uses: actions/setup-go@v6 diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 1fea50114..f874b2b59 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -25,8 +25,16 @@ jobs: - name: Check out code uses: actions/checkout@v6 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: ui/package-lock.json + - name: Build UI - uses: ./.github/actions/build-ui + shell: bash + run: script/build-ui - name: Set up Go uses: actions/setup-go@v6 diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index 1004fc274..f8eddc076 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -16,8 +16,15 @@ jobs: - name: Check out code uses: actions/checkout@v6 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: ui/package-lock.json + - name: Build UI - uses: ./.github/actions/build-ui + run: script/build-ui - name: Set up Go uses: actions/setup-go@v6 @@ -28,7 +35,7 @@ jobs: run: go mod download - name: Run GoReleaser - uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 + uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a with: distribution: goreleaser # GoReleaser version @@ -40,7 +47,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Generate signed build provenance attestations for workflow artifacts - uses: actions/attest-build-provenance@v4 + uses: actions/attest-build-provenance@v3 with: subject-path: | dist/*.tar.gz diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml index 2f27353d8..9e352c3f6 100644 --- a/.github/workflows/license-check.yml +++ b/.github/workflows/license-check.yml @@ -32,8 +32,15 @@ jobs: GH_TOKEN: ${{ github.token }} run: gh pr checkout ${{ github.event.pull_request.number }} + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: ui/package-lock.json + - name: Build UI - uses: ./.github/actions/build-ui + run: script/build-ui - name: Set up Go uses: actions/setup-go@v6 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 5b912cea0..3676cb410 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,8 +14,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: "npm" + cache-dependency-path: ui/package-lock.json - name: Build UI - uses: ./.github/actions/build-ui + run: script/build-ui - uses: actions/setup-go@v6 with: go-version: '1.25' diff --git a/.github/workflows/mcp-diff.yml b/.github/workflows/mcp-diff.yml index bb6341c09..3c6c0149a 100644 --- a/.github/workflows/mcp-diff.yml +++ b/.github/workflows/mcp-diff.yml @@ -19,8 +19,13 @@ jobs: with: fetch-depth: 0 + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + - name: Build UI - uses: ./.github/actions/build-ui + run: script/build-ui - name: Run MCP Server Diff uses: SamMorrowDrums/mcp-server-diff@v2.3.5 @@ -34,6 +39,8 @@ jobs: [ {"name": "default", "args": ""}, {"name": "read-only", "args": "--read-only"}, + {"name": "dynamic-toolsets", "args": "--dynamic-toolsets"}, + {"name": "read-only+dynamic", "args": "--read-only --dynamic-toolsets"}, {"name": "toolsets-repos", "args": "--toolsets=repos"}, {"name": "toolsets-issues", "args": "--toolsets=issues"}, {"name": "toolsets-context", "args": "--toolsets=context"}, @@ -43,7 +50,20 @@ jobs: {"name": "toolsets-all", "args": "--toolsets=all"}, {"name": "tools-get_me", "args": "--tools=get_me"}, {"name": "tools-get_me,list_issues", "args": "--tools=get_me,list_issues"}, - {"name": "toolsets-repos+read-only", "args": "--toolsets=repos --read-only"} + {"name": "toolsets-repos+read-only", "args": "--toolsets=repos --read-only"}, + {"name": "toolsets-all+dynamic", "args": "--toolsets=all --dynamic-toolsets"}, + {"name": "toolsets-repos+dynamic", "args": "--toolsets=repos --dynamic-toolsets"}, + {"name": "toolsets-repos,issues+dynamic", "args": "--toolsets=repos,issues --dynamic-toolsets"}, + { + "name": "dynamic-tool-calls", + "args": "--dynamic-toolsets", + "custom_messages": [ + {"id": 10, "name": "list_toolsets_before", "message": {"jsonrpc": "2.0", "id": 10, "method": "tools/call", "params": {"name": "list_available_toolsets", "arguments": {}}}}, + {"id": 11, "name": "get_toolset_tools", "message": {"jsonrpc": "2.0", "id": 11, "method": "tools/call", "params": {"name": "get_toolset_tools", "arguments": {"toolset": "repos"}}}}, + {"id": 12, "name": "enable_toolset", "message": {"jsonrpc": "2.0", "id": 12, "method": "tools/call", "params": {"name": "enable_toolset", "arguments": {"toolset": "repos"}}}}, + {"id": 13, "name": "list_toolsets_after", "message": {"jsonrpc": "2.0", "id": 13, "method": "tools/call", "params": {"name": "list_available_toolsets", "arguments": {}}}} + ] + } ] - name: Add interpretation note diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index d14dfcfc1..3272f3772 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -1186,7 +1186,7 @@ func Test_SearchIssues_FieldValuesEnrichment(t *testing.T) { require.Equal(t, 2, *response.Total) require.Len(t, response.Items, 2) assert.Equal(t, 42, *response.Items[0].Number) - assert.Equal(t, []MinimalIssueFieldValue{ + assert.Equal(t, []MinimalFieldValue{ {Field: "priority", Value: "P1"}, {Field: "estimate", Value: "2.5"}, }, response.Items[0].FieldValues) @@ -1976,9 +1976,9 @@ func Test_ListIssues(t *testing.T) { // (including float formatting); #789 has no field values. switch issue.Number { case 123: - assert.Equal(t, []MinimalIssueFieldValue{{Field: "priority", Value: "P1"}}, issue.FieldValues) + assert.Equal(t, []MinimalFieldValue{{Field: "priority", Value: "P1"}}, issue.FieldValues) case 456: - assert.Equal(t, []MinimalIssueFieldValue{ + assert.Equal(t, []MinimalFieldValue{ {Field: "due", Value: "2026-06-01"}, {Field: "estimate", Value: "2.5"}, {Field: "notes", Value: "needs triage"}, From e4467977736507321844cb51b95ef220c630e011 Mon Sep 17 00:00:00 2001 From: Iulia B Date: Thu, 21 May 2026 13:22:15 +0000 Subject: [PATCH 37/48] chore: sync .github/workflows from upstream/main --- .github/workflows/code-scanning.yml | 8 ++++---- .github/workflows/docker-publish.yml | 6 +++--- .github/workflows/docs-check.yml | 9 +-------- .github/workflows/go.yml | 10 +--------- .github/workflows/goreleaser.yml | 13 +++---------- .github/workflows/license-check.yml | 9 +-------- .github/workflows/lint.yml | 7 +------ .github/workflows/mcp-diff.yml | 24 ++---------------------- 8 files changed, 16 insertions(+), 70 deletions(-) diff --git a/.github/workflows/code-scanning.yml b/.github/workflows/code-scanning.yml index e58a45e71..ecbe9f0dc 100644 --- a/.github/workflows/code-scanning.yml +++ b/.github/workflows/code-scanning.yml @@ -78,9 +78,9 @@ jobs: go-version: ${{ fromJSON(steps.resolve-environment.outputs.environment).configuration.go.version }} cache: false - - name: Set up Node.js - if: matrix.language == 'go' || matrix.language == 'javascript' - uses: actions/setup-node@v4 + - name: Set up Node.js (for JavaScript CodeQL) + if: matrix.language == 'javascript' + uses: actions/setup-node@v6 with: node-version: "20" cache: "npm" @@ -88,7 +88,7 @@ jobs: - name: Build UI if: matrix.language == 'go' - run: script/build-ui + uses: ./.github/actions/build-ui - name: Autobuild uses: github/codeql-action/autobuild@v4 diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 638713c70..f56d4f31a 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -46,7 +46,7 @@ jobs: # https://github.com/sigstore/cosign-installer - name: Install cosign if: github.event_name != 'pull_request' - uses: sigstore/cosign-installer@ba7bc0a3fef59531c69a25acd34668d6d3fe6f22 #v4.1.0 + uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 #v4.1.2 with: cosign-release: "v2.2.4" @@ -60,7 +60,7 @@ jobs: # https://github.com/docker/login-action - name: Log into registry ${{ env.REGISTRY }} if: github.event_name != 'pull_request' - uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0 + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -93,7 +93,7 @@ jobs: key: ${{ runner.os }}-go-build-cache-${{ hashFiles('**/go.sum') }} - name: Inject go-build-cache - uses: reproducible-containers/buildkit-cache-dance@1b8ab18fbda5ad3646e3fcc9ed9dd41ce2f297b4 # v3.3.2 + uses: reproducible-containers/buildkit-cache-dance@5422eac04292c961a382e0f584ea0f03ad9da723 # v3.4.0 with: cache-map: | { diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index de62d6282..309eddb38 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -16,15 +16,8 @@ jobs: - name: Checkout code uses: actions/checkout@v6 - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: ui/package-lock.json - - name: Build UI - run: script/build-ui + uses: ./.github/actions/build-ui - name: Set up Go uses: actions/setup-go@v6 diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index f874b2b59..1fea50114 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -25,16 +25,8 @@ jobs: - name: Check out code uses: actions/checkout@v6 - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: ui/package-lock.json - - name: Build UI - shell: bash - run: script/build-ui + uses: ./.github/actions/build-ui - name: Set up Go uses: actions/setup-go@v6 diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index f8eddc076..1004fc274 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -16,15 +16,8 @@ jobs: - name: Check out code uses: actions/checkout@v6 - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: ui/package-lock.json - - name: Build UI - run: script/build-ui + uses: ./.github/actions/build-ui - name: Set up Go uses: actions/setup-go@v6 @@ -35,7 +28,7 @@ jobs: run: go mod download - name: Run GoReleaser - uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a + uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 with: distribution: goreleaser # GoReleaser version @@ -47,7 +40,7 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Generate signed build provenance attestations for workflow artifacts - uses: actions/attest-build-provenance@v3 + uses: actions/attest-build-provenance@v4 with: subject-path: | dist/*.tar.gz diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml index 9e352c3f6..2f27353d8 100644 --- a/.github/workflows/license-check.yml +++ b/.github/workflows/license-check.yml @@ -32,15 +32,8 @@ jobs: GH_TOKEN: ${{ github.token }} run: gh pr checkout ${{ github.event.pull_request.number }} - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: ui/package-lock.json - - name: Build UI - run: script/build-ui + uses: ./.github/actions/build-ui - name: Set up Go uses: actions/setup-go@v6 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 3676cb410..5b912cea0 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -14,13 +14,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - - uses: actions/setup-node@v4 - with: - node-version: "20" - cache: "npm" - cache-dependency-path: ui/package-lock.json - name: Build UI - run: script/build-ui + uses: ./.github/actions/build-ui - uses: actions/setup-go@v6 with: go-version: '1.25' diff --git a/.github/workflows/mcp-diff.yml b/.github/workflows/mcp-diff.yml index 3c6c0149a..bb6341c09 100644 --- a/.github/workflows/mcp-diff.yml +++ b/.github/workflows/mcp-diff.yml @@ -19,13 +19,8 @@ jobs: with: fetch-depth: 0 - - name: Set up Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - name: Build UI - run: script/build-ui + uses: ./.github/actions/build-ui - name: Run MCP Server Diff uses: SamMorrowDrums/mcp-server-diff@v2.3.5 @@ -39,8 +34,6 @@ jobs: [ {"name": "default", "args": ""}, {"name": "read-only", "args": "--read-only"}, - {"name": "dynamic-toolsets", "args": "--dynamic-toolsets"}, - {"name": "read-only+dynamic", "args": "--read-only --dynamic-toolsets"}, {"name": "toolsets-repos", "args": "--toolsets=repos"}, {"name": "toolsets-issues", "args": "--toolsets=issues"}, {"name": "toolsets-context", "args": "--toolsets=context"}, @@ -50,20 +43,7 @@ jobs: {"name": "toolsets-all", "args": "--toolsets=all"}, {"name": "tools-get_me", "args": "--tools=get_me"}, {"name": "tools-get_me,list_issues", "args": "--tools=get_me,list_issues"}, - {"name": "toolsets-repos+read-only", "args": "--toolsets=repos --read-only"}, - {"name": "toolsets-all+dynamic", "args": "--toolsets=all --dynamic-toolsets"}, - {"name": "toolsets-repos+dynamic", "args": "--toolsets=repos --dynamic-toolsets"}, - {"name": "toolsets-repos,issues+dynamic", "args": "--toolsets=repos,issues --dynamic-toolsets"}, - { - "name": "dynamic-tool-calls", - "args": "--dynamic-toolsets", - "custom_messages": [ - {"id": 10, "name": "list_toolsets_before", "message": {"jsonrpc": "2.0", "id": 10, "method": "tools/call", "params": {"name": "list_available_toolsets", "arguments": {}}}}, - {"id": 11, "name": "get_toolset_tools", "message": {"jsonrpc": "2.0", "id": 11, "method": "tools/call", "params": {"name": "get_toolset_tools", "arguments": {"toolset": "repos"}}}}, - {"id": 12, "name": "enable_toolset", "message": {"jsonrpc": "2.0", "id": 12, "method": "tools/call", "params": {"name": "enable_toolset", "arguments": {"toolset": "repos"}}}}, - {"id": 13, "name": "list_toolsets_after", "message": {"jsonrpc": "2.0", "id": 13, "method": "tools/call", "params": {"name": "list_available_toolsets", "arguments": {}}}} - ] - } + {"name": "toolsets-repos+read-only", "args": "--toolsets=repos --read-only"} ] - name: Add interpretation note From 8f6050a5b8e42372ac40c1545ee8ca23dd3ac040 Mon Sep 17 00:00:00 2001 From: Kelsey Myers <52179263+kelsey-myers@users.noreply.github.com> Date: Thu, 21 May 2026 15:19:49 +0100 Subject: [PATCH 38/48] Add list_issue_fields tool (#2445) * Add list_org_issue_fields tool * Clean up code * complete struct fields & rename option type * Drop created_at/updated_at from IssueField and IssueSingleSelectFieldOption * Address feedback * Address Copilot review: close resp.Body, set expectError=true for missing org test * Adjust to list_issue_fields * Add feature flag * Allow tool to support read:org or repo * Docs * address comments * Add repo_issue_fields flag --------- Co-authored-by: Michael Jacholke <46944669+michaeljacholke@users.noreply.github.com> --- README.md | 6 + .../__toolsnaps__/list_issue_fields.snap | 24 ++ pkg/github/issue_fields.go | 223 +++++++++++++ pkg/github/issue_fields_test.go | 300 ++++++++++++++++++ pkg/github/tools.go | 1 + 5 files changed, 554 insertions(+) create mode 100644 pkg/github/__toolsnaps__/list_issue_fields.snap create mode 100644 pkg/github/issue_fields.go create mode 100644 pkg/github/issue_fields_test.go diff --git a/README.md b/README.md index b4a5927b1..71c7a1e7f 100644 --- a/README.md +++ b/README.md @@ -870,6 +870,12 @@ The following sets of tools are available: - `title`: Issue title (string, optional) - `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional) +- **list_issue_fields** - List issue fields + - **Required OAuth Scopes**: `repo`, `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org` + - `owner`: The account owner of the repository or organization. The name is not case sensitive. (string, required) + - `repo`: The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly. (string, optional) + - **list_issue_types** - List available issue types - **Required OAuth Scopes**: `read:org` - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org` diff --git a/pkg/github/__toolsnaps__/list_issue_fields.snap b/pkg/github/__toolsnaps__/list_issue_fields.snap new file mode 100644 index 000000000..0eec8bc9e --- /dev/null +++ b/pkg/github/__toolsnaps__/list_issue_fields.snap @@ -0,0 +1,24 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "List issue fields" + }, + "description": "List issue fields for a repository or organization. Returns field definitions including name, type (text, number, date, single_select), and for single_select fields the list of valid option names. When repo is omitted, returns org-level fields directly.", + "inputSchema": { + "properties": { + "owner": { + "description": "The account owner of the repository or organization. The name is not case sensitive.", + "type": "string" + }, + "repo": { + "description": "The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly.", + "type": "string" + } + }, + "required": [ + "owner" + ], + "type": "object" + }, + "name": "list_issue_fields" +} \ No newline at end of file diff --git a/pkg/github/issue_fields.go b/pkg/github/issue_fields.go new file mode 100644 index 000000000..0649e4714 --- /dev/null +++ b/pkg/github/issue_fields.go @@ -0,0 +1,223 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + + ghcontext "github.com/github/github-mcp-server/pkg/context" + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" +) + +// IssueField represents a repository issue field definition. +type IssueField struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + DataType string `json:"data_type"` + Visibility string `json:"visibility"` + Options []IssueSingleSelectFieldOption `json:"options,omitempty"` +} + +// IssueSingleSelectFieldOption represents an option for a single_select issue field. +type IssueSingleSelectFieldOption struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Color string `json:"color"` + Priority *int `json:"priority,omitempty"` +} + +// issueFieldNode is the GraphQL fragment for a single issue field in the IssueFields union. +// Only the fragment matching __typename is populated; read from the matching fragment. +type issueFieldNode struct { + TypeName githubv4.String `graphql:"__typename"` + IssueFieldText struct { + ID githubv4.ID + Name githubv4.String + Description githubv4.String + DataType githubv4.String + Visibility githubv4.String + } `graphql:"... on IssueFieldText"` + IssueFieldNumber struct { + ID githubv4.ID + Name githubv4.String + Description githubv4.String + DataType githubv4.String + Visibility githubv4.String + } `graphql:"... on IssueFieldNumber"` + IssueFieldDate struct { + ID githubv4.ID + Name githubv4.String + Description githubv4.String + DataType githubv4.String + Visibility githubv4.String + } `graphql:"... on IssueFieldDate"` + IssueFieldSingleSelect struct { + ID githubv4.ID + Name githubv4.String + Description githubv4.String + DataType githubv4.String + Visibility githubv4.String + Options []struct { + ID githubv4.ID + Name githubv4.String + Description githubv4.String + Color githubv4.String + Priority *int + } + } `graphql:"... on IssueFieldSingleSelect"` +} + +// issueFieldsRepoQuery is the GraphQL query for listing issue fields on a repository. +type issueFieldsRepoQuery struct { + Repository struct { + IssueFields struct { + Nodes []issueFieldNode + } `graphql:"issueFields(first: 100)"` + } `graphql:"repository(owner: $owner, name: $name)"` +} + +// issueFieldsOrgQuery is the GraphQL query for listing issue fields on an organization. +type issueFieldsOrgQuery struct { + Organization struct { + IssueFields struct { + Nodes []issueFieldNode + } `graphql:"issueFields(first: 100)"` + } `graphql:"organization(login: $login)"` +} + +// ListIssueFields creates a tool to list issue field definitions for a repository or organization. +func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: "list_issue_fields", + Description: t("TOOL_LIST_ISSUE_FIELDS_DESCRIPTION", "List issue fields for a repository or organization. Returns field definitions including name, type (text, number, date, single_select), and for single_select fields the list of valid option names. When repo is omitted, returns org-level fields directly."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_LIST_ISSUE_FIELDS_USER_TITLE", "List issue fields"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "The account owner of the repository or organization. The name is not case sensitive.", + }, + "repo": { + Type: "string", + Description: "The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly.", + }, + }, + Required: []string{"owner"}, + }, + }, + []scopes.Scope{scopes.Repo, scopes.ReadOrg}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := OptionalParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil + } + + ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields") + var nodes []issueFieldNode + if repo != "" { + var query issueFieldsRepoQuery + vars := map[string]any{ + "owner": githubv4.String(owner), + "name": githubv4.String(repo), + } + if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to list issue fields", err), nil, nil + } + nodes = query.Repository.IssueFields.Nodes + } else { + var query issueFieldsOrgQuery + vars := map[string]any{ + "login": githubv4.String(owner), + } + if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to list issue fields", err), nil, nil + } + nodes = query.Organization.IssueFields.Nodes + } + + fields := make([]IssueField, 0, len(nodes)) + for _, node := range nodes { + var f IssueField + // Read from the fragment matching __typename; the other fragments are zero-valued. + switch string(node.TypeName) { + case "IssueFieldSingleSelect": + opts := make([]IssueSingleSelectFieldOption, 0, len(node.IssueFieldSingleSelect.Options)) + for _, o := range node.IssueFieldSingleSelect.Options { + opts = append(opts, IssueSingleSelectFieldOption{ + ID: fmt.Sprintf("%v", o.ID), + Name: string(o.Name), + Description: string(o.Description), + Color: string(o.Color), + Priority: o.Priority, + }) + } + f = IssueField{ + ID: fmt.Sprintf("%v", node.IssueFieldSingleSelect.ID), + Name: string(node.IssueFieldSingleSelect.Name), + Description: string(node.IssueFieldSingleSelect.Description), + DataType: string(node.IssueFieldSingleSelect.DataType), + Visibility: string(node.IssueFieldSingleSelect.Visibility), + Options: opts, + } + case "IssueFieldText": + f = IssueField{ + ID: fmt.Sprintf("%v", node.IssueFieldText.ID), + Name: string(node.IssueFieldText.Name), + Description: string(node.IssueFieldText.Description), + DataType: string(node.IssueFieldText.DataType), + Visibility: string(node.IssueFieldText.Visibility), + } + case "IssueFieldNumber": + f = IssueField{ + ID: fmt.Sprintf("%v", node.IssueFieldNumber.ID), + Name: string(node.IssueFieldNumber.Name), + Description: string(node.IssueFieldNumber.Description), + DataType: string(node.IssueFieldNumber.DataType), + Visibility: string(node.IssueFieldNumber.Visibility), + } + case "IssueFieldDate": + f = IssueField{ + ID: fmt.Sprintf("%v", node.IssueFieldDate.ID), + Name: string(node.IssueFieldDate.Name), + Description: string(node.IssueFieldDate.Description), + DataType: string(node.IssueFieldDate.DataType), + Visibility: string(node.IssueFieldDate.Visibility), + } + default: + continue + } + fields = append(fields, f) + } + + r, err := json.Marshal(fields) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal issue fields", err), nil, nil + } + + return utils.NewToolResultText(string(r)), nil, nil + }) +} diff --git a/pkg/github/issue_fields_test.go b/pkg/github/issue_fields_test.go new file mode 100644 index 000000000..238c0455b --- /dev/null +++ b/pkg/github/issue_fields_test.go @@ -0,0 +1,300 @@ +package github + +import ( + "context" + "encoding/json" + "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" +) + +func Test_ListIssueFields(t *testing.T) { + // Verify tool definition + serverTool := ListIssueFields(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "list_issue_fields", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.True(t, tool.Annotations.ReadOnlyHint) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"owner"}) + assert.ElementsMatch(t, serverTool.RequiredScopes, []string{"repo", "read:org"}) + assert.ElementsMatch(t, serverTool.AcceptedScopes, []string{"repo", "read:org", "write:org", "admin:org"}) + + queryStruct := issueFieldsRepoQuery{} + defaultVars := map[string]any{ + "owner": githubv4.String("testowner"), + "name": githubv4.String("testrepo"), + } + orgQueryStruct := issueFieldsOrgQuery{} + defaultOrgVars := map[string]any{ + "login": githubv4.String("testowner"), + } + + tests := []struct { + name string + requestArgs map[string]any + mockQueryStruct any + mockVars map[string]any + gqlResponse githubv4mock.GQLResponse + expectError bool + expectedFields []IssueField + expectedErrMsg string + }{ + { + name: "no fields returns empty list", + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + }, + gqlResponse: githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{}, + }, + }, + }), + expectedFields: []IssueField{}, + }, + { + name: "text field returned", + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + }, + gqlResponse: githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldText", + "id": "IFT_1", + "name": "DRI", + "description": "Directly responsible individual", + "dataType": "TEXT", + "visibility": "ORG_ONLY", + }, + }, + }, + }, + }), + expectedFields: []IssueField{ + { + ID: "IFT_1", + Name: "DRI", + Description: "Directly responsible individual", + DataType: "TEXT", + Visibility: "ORG_ONLY", + }, + }, + }, + { + name: "single_select field with options returned", + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + }, + gqlResponse: githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldSingleSelect", + "id": "IFSS_1", + "name": "Priority", + "description": "Level of importance", + "dataType": "SINGLE_SELECT", + "visibility": "ALL", + "options": []any{ + map[string]any{ + "id": "OPT_1", + "name": "High", + "color": "red", + }, + map[string]any{ + "id": "OPT_2", + "name": "Low", + "color": "blue", + }, + }, + }, + }, + }, + }, + }), + expectedFields: []IssueField{ + { + ID: "IFSS_1", + Name: "Priority", + Description: "Level of importance", + DataType: "SINGLE_SELECT", + Visibility: "ALL", + Options: []IssueSingleSelectFieldOption{ + {ID: "OPT_1", Name: "High", Color: "red"}, + {ID: "OPT_2", Name: "Low", Color: "blue"}, + }, + }, + }, + }, + { + name: "missing owner parameter", + requestArgs: map[string]any{ + "repo": "testrepo", + }, + gqlResponse: githubv4mock.DataResponse(map[string]any{}), + expectError: true, + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "no repo returns org-level fields", + requestArgs: map[string]any{ + "owner": "testowner", + }, + mockQueryStruct: orgQueryStruct, + mockVars: defaultOrgVars, + gqlResponse: githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldText", + "id": "IFT_1", + "name": "DRI", + "dataType": "TEXT", + "visibility": "ORG_ONLY", + }, + }, + }, + }, + }), + expectedFields: []IssueField{ + {ID: "IFT_1", Name: "DRI", DataType: "TEXT", Visibility: "ORG_ONLY"}, + }, + }, + { + name: "number field returned", + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + }, + gqlResponse: githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldNumber", + "id": "IFN_1", + "name": "Engineering Staffing", + "dataType": "NUMBER", + "visibility": "ORG_ONLY", + }, + }, + }, + }, + }), + expectedFields: []IssueField{ + {ID: "IFN_1", Name: "Engineering Staffing", DataType: "NUMBER", Visibility: "ORG_ONLY"}, + }, + }, + { + name: "date field returned", + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + }, + gqlResponse: githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldDate", + "id": "IFD_1", + "name": "Target Date", + "dataType": "DATE", + "visibility": "ORG_ONLY", + }, + }, + }, + }, + }), + expectedFields: []IssueField{ + {ID: "IFD_1", Name: "Target Date", DataType: "DATE", Visibility: "ORG_ONLY"}, + }, + }, + { + name: "graphql error returns failure", + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + }, + gqlResponse: githubv4mock.ErrorResponse("boom"), + expectError: true, + expectedErrMsg: "failed to list issue fields", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + qs := tc.mockQueryStruct + if qs == nil { + qs = queryStruct + } + vars := tc.mockVars + if vars == nil { + vars = defaultVars + } + mockedHTTPClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher(qs, vars, tc.gqlResponse), + ) + gqlClient := githubv4.NewClient(mockedHTTPClient) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + if tc.expectError { + if err != nil { + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + require.NotNil(t, result) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, result.IsError) + textContent := getTextResult(t, result) + + var returnedFields []IssueField + err = json.Unmarshal([]byte(textContent.Text), &returnedFields) + require.NoError(t, err) + require.Equal(t, len(tc.expectedFields), len(returnedFields)) + for i, expected := range tc.expectedFields { + assert.Equal(t, expected.ID, returnedFields[i].ID) + assert.Equal(t, expected.Name, returnedFields[i].Name) + assert.Equal(t, expected.DataType, returnedFields[i].DataType) + assert.Equal(t, expected.Visibility, returnedFields[i].Visibility) + if expected.Options != nil { + require.Equal(t, len(expected.Options), len(returnedFields[i].Options)) + for j, opt := range expected.Options { + assert.Equal(t, opt.Name, returnedFields[i].Options[j].Name) + assert.Equal(t, opt.Color, returnedFields[i].Options[j].Color) + } + } + } + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 7d22c72fc..af59b74a5 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -205,6 +205,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { SearchIssues(t), ListIssues(t), ListIssueTypes(t), + ListIssueFields(t), IssueWrite(t), AddIssueComment(t), SubIssueWrite(t), From 13e7f10fc8ddff977e02043aec4574d9c8ded8ec Mon Sep 17 00:00:00 2001 From: Kelsey Myers <52179263+kelsey-myers@users.noreply.github.com> Date: Thu, 21 May 2026 15:36:17 +0100 Subject: [PATCH 39/48] Add custom field filtering to list_issues (#2480) * Add custom field filtering to list_issues * Flatten schema * add repo fields flag * test fix --------- Co-authored-by: Sam Morrow --- README.md | 1 + pkg/github/__toolsnaps__/list_issues.snap | 21 + pkg/github/issue_fields.go | 163 +++---- pkg/github/issues.go | 186 +++++++- pkg/github/issues_test.go | 517 ++++++++++++++++++++-- 5 files changed, 755 insertions(+), 133 deletions(-) diff --git a/README.md b/README.md index 71c7a1e7f..8455cd76f 100644 --- a/README.md +++ b/README.md @@ -885,6 +885,7 @@ The following sets of tools are available: - **Required OAuth Scopes**: `repo` - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) - `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) - `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) diff --git a/pkg/github/__toolsnaps__/list_issues.snap b/pkg/github/__toolsnaps__/list_issues.snap index a4be59bb0..b1d1c7a21 100644 --- a/pkg/github/__toolsnaps__/list_issues.snap +++ b/pkg/github/__toolsnaps__/list_issues.snap @@ -18,6 +18,27 @@ ], "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" + }, "labels": { "description": "Filter by labels", "items": { diff --git a/pkg/github/issue_fields.go b/pkg/github/issue_fields.go index 0649e4714..70f1a7c51 100644 --- a/pkg/github/issue_fields.go +++ b/pkg/github/issue_fields.go @@ -136,81 +136,9 @@ func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil } - ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields") - var nodes []issueFieldNode - if repo != "" { - var query issueFieldsRepoQuery - vars := map[string]any{ - "owner": githubv4.String(owner), - "name": githubv4.String(repo), - } - if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to list issue fields", err), nil, nil - } - nodes = query.Repository.IssueFields.Nodes - } else { - var query issueFieldsOrgQuery - vars := map[string]any{ - "login": githubv4.String(owner), - } - if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to list issue fields", err), nil, nil - } - nodes = query.Organization.IssueFields.Nodes - } - - fields := make([]IssueField, 0, len(nodes)) - for _, node := range nodes { - var f IssueField - // Read from the fragment matching __typename; the other fragments are zero-valued. - switch string(node.TypeName) { - case "IssueFieldSingleSelect": - opts := make([]IssueSingleSelectFieldOption, 0, len(node.IssueFieldSingleSelect.Options)) - for _, o := range node.IssueFieldSingleSelect.Options { - opts = append(opts, IssueSingleSelectFieldOption{ - ID: fmt.Sprintf("%v", o.ID), - Name: string(o.Name), - Description: string(o.Description), - Color: string(o.Color), - Priority: o.Priority, - }) - } - f = IssueField{ - ID: fmt.Sprintf("%v", node.IssueFieldSingleSelect.ID), - Name: string(node.IssueFieldSingleSelect.Name), - Description: string(node.IssueFieldSingleSelect.Description), - DataType: string(node.IssueFieldSingleSelect.DataType), - Visibility: string(node.IssueFieldSingleSelect.Visibility), - Options: opts, - } - case "IssueFieldText": - f = IssueField{ - ID: fmt.Sprintf("%v", node.IssueFieldText.ID), - Name: string(node.IssueFieldText.Name), - Description: string(node.IssueFieldText.Description), - DataType: string(node.IssueFieldText.DataType), - Visibility: string(node.IssueFieldText.Visibility), - } - case "IssueFieldNumber": - f = IssueField{ - ID: fmt.Sprintf("%v", node.IssueFieldNumber.ID), - Name: string(node.IssueFieldNumber.Name), - Description: string(node.IssueFieldNumber.Description), - DataType: string(node.IssueFieldNumber.DataType), - Visibility: string(node.IssueFieldNumber.Visibility), - } - case "IssueFieldDate": - f = IssueField{ - ID: fmt.Sprintf("%v", node.IssueFieldDate.ID), - Name: string(node.IssueFieldDate.Name), - Description: string(node.IssueFieldDate.Description), - DataType: string(node.IssueFieldDate.DataType), - Visibility: string(node.IssueFieldDate.Visibility), - } - default: - continue - } - fields = append(fields, f) + fields, err := fetchIssueFields(ctx, gqlClient, owner, repo) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to list issue fields", err), nil, nil } r, err := json.Marshal(fields) @@ -221,3 +149,88 @@ func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultText(string(r)), nil, nil }) } + +// fetchIssueFields returns the issue field definitions for the given owner. +// If repo is provided, fields are scoped to that repository (inherited from its +// organization); otherwise fields are returned directly from the organization. +func fetchIssueFields(ctx context.Context, gqlClient *githubv4.Client, owner, repo string) ([]IssueField, error) { + ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields") + if repo != "" { + var query issueFieldsRepoQuery + vars := map[string]any{ + "owner": githubv4.String(owner), + "name": githubv4.String(repo), + } + if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil { + return nil, err + } + return issueFieldsFromNodes(query.Repository.IssueFields.Nodes), nil + } + + var query issueFieldsOrgQuery + vars := map[string]any{ + "login": githubv4.String(owner), + } + if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil { + return nil, err + } + return issueFieldsFromNodes(query.Organization.IssueFields.Nodes), nil +} + +// issueFieldsFromNodes converts GraphQL issue field union nodes into IssueField values. +// Read from the fragment matching __typename; the other fragments are zero-valued. +func issueFieldsFromNodes(nodes []issueFieldNode) []IssueField { + fields := make([]IssueField, 0, len(nodes)) + for _, node := range nodes { + var f IssueField + switch string(node.TypeName) { + case "IssueFieldSingleSelect": + opts := make([]IssueSingleSelectFieldOption, 0, len(node.IssueFieldSingleSelect.Options)) + for _, o := range node.IssueFieldSingleSelect.Options { + opts = append(opts, IssueSingleSelectFieldOption{ + ID: fmt.Sprintf("%v", o.ID), + Name: string(o.Name), + Description: string(o.Description), + Color: string(o.Color), + Priority: o.Priority, + }) + } + f = IssueField{ + ID: fmt.Sprintf("%v", node.IssueFieldSingleSelect.ID), + Name: string(node.IssueFieldSingleSelect.Name), + Description: string(node.IssueFieldSingleSelect.Description), + DataType: string(node.IssueFieldSingleSelect.DataType), + Visibility: string(node.IssueFieldSingleSelect.Visibility), + Options: opts, + } + case "IssueFieldText": + f = IssueField{ + ID: fmt.Sprintf("%v", node.IssueFieldText.ID), + Name: string(node.IssueFieldText.Name), + Description: string(node.IssueFieldText.Description), + DataType: string(node.IssueFieldText.DataType), + Visibility: string(node.IssueFieldText.Visibility), + } + case "IssueFieldNumber": + f = IssueField{ + ID: fmt.Sprintf("%v", node.IssueFieldNumber.ID), + Name: string(node.IssueFieldNumber.Name), + Description: string(node.IssueFieldNumber.Description), + DataType: string(node.IssueFieldNumber.DataType), + Visibility: string(node.IssueFieldNumber.Visibility), + } + case "IssueFieldDate": + f = IssueField{ + ID: fmt.Sprintf("%v", node.IssueFieldDate.ID), + Name: string(node.IssueFieldDate.Name), + Description: string(node.IssueFieldDate.Description), + DataType: string(node.IssueFieldDate.DataType), + Visibility: string(node.IssueFieldDate.Visibility), + } + default: + continue + } + fields = append(fields, f) + } + return fields +} diff --git a/pkg/github/issues.go b/pkg/github/issues.go index fe1e7b501..8662845f6 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -6,9 +6,11 @@ import ( "fmt" "io" "net/http" + "strconv" "strings" "time" + ghcontext "github.com/github/github-mcp-server/pkg/context" 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" @@ -199,7 +201,7 @@ type IssueQueryFragment struct { // ListIssuesQuery is the root query structure for fetching issues with optional label filtering. type ListIssuesQuery struct { Repository struct { - Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction})"` + Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues})"` IsPrivate githubv4.Boolean } `graphql:"repository(owner: $owner, name: $repo)"` } @@ -207,7 +209,7 @@ type ListIssuesQuery struct { // ListIssuesQueryTypeWithLabels is the query structure for fetching issues with optional label filtering. type ListIssuesQueryTypeWithLabels struct { Repository struct { - Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction})"` + Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues})"` IsPrivate githubv4.Boolean } `graphql:"repository(owner: $owner, name: $repo)"` } @@ -215,7 +217,7 @@ type ListIssuesQueryTypeWithLabels struct { // ListIssuesQueryWithSince is the query structure for fetching issues without label filtering but with since filtering. type ListIssuesQueryWithSince struct { Repository struct { - Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` + Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since, issueFieldValues: $issueFieldValues})"` IsPrivate githubv4.Boolean } `graphql:"repository(owner: $owner, name: $repo)"` } @@ -223,11 +225,21 @@ type ListIssuesQueryWithSince struct { // ListIssuesQueryTypeWithLabelsWithSince is the query structure for fetching issues with both label and since filtering. type ListIssuesQueryTypeWithLabelsWithSince struct { Repository struct { - Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` + Issues IssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since, issueFieldValues: $issueFieldValues})"` IsPrivate githubv4.Boolean } `graphql:"repository(owner: $owner, name: $repo)"` } +// IssueFieldValueFilter mirrors the GraphQL IssueFieldValueFilter input. Exactly one typed value +// field should be set per filter (the monolith resolver rejects multiple). +type IssueFieldValueFilter struct { + FieldName githubv4.String `json:"fieldName"` + TextValue *githubv4.String `json:"textValue,omitempty"` + DateValue *githubv4.String `json:"dateValue,omitempty"` + NumberValue *githubv4.Float `json:"numberValue,omitempty"` + SingleSelectOptionValue *githubv4.String `json:"singleSelectOptionValue,omitempty"` +} + // Implement the interface for all query types func (q *ListIssuesQueryTypeWithLabels) GetIssueFragment() IssueQueryFragment { return q.Repository.Issues @@ -1727,6 +1739,24 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { Type: "string", Description: "Filter by date (ISO 8601 timestamp)", }, + "field_filters": { + Type: "array", + 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: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "field_name": { + Type: "string", + Description: "Name of the custom field (e.g. \"Priority\"). Case-insensitive.", + }, + "value": { + Type: "string", + 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.", + }, + }, + Required: []string{"field_name", "value"}, + }, + }, }, Required: []string{"owner", "repo"}, } @@ -1822,6 +1852,11 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { } hasLabels := len(labels) > 0 + rawFilters, err := parseRawFieldFilters(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + // Get pagination parameters and convert to GraphQL format pagination, err := OptionalCursorPaginationParams(args) if err != nil { @@ -1853,13 +1888,28 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil } + // Resolve field filters by looking up the repo's issue fields so we can + // coerce each value into the right typed slot on IssueFieldValueFilter. + fieldFilters := []IssueFieldValueFilter{} + if len(rawFilters) > 0 { + fields, err := fetchIssueFields(ctx, client, owner, repo) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to look up issue fields for field_filters", err), nil, nil + } + fieldFilters, err = resolveFieldFilters(rawFilters, fields) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + } + vars := map[string]any{ - "owner": githubv4.String(owner), - "repo": githubv4.String(repo), - "states": states, - "orderBy": githubv4.IssueOrderField(orderBy), - "direction": githubv4.OrderDirection(direction), - "first": githubv4.Int(*paginationParams.First), + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "states": states, + "orderBy": githubv4.IssueOrderField(orderBy), + "direction": githubv4.OrderDirection(direction), + "first": githubv4.Int(*paginationParams.First), + "issueFieldValues": fieldFilters, } if paginationParams.After != nil { @@ -1884,7 +1934,11 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { } issueQuery := getIssueQueryType(hasLabels, hasSince) - if err := client.Query(ctx, issueQuery, vars); err != nil { + // The list_issues query references the issue_fields-gated IssueFieldValueFilter + // input type unconditionally, so we always opt into the feature via header. This + // is a no-op once the flags are globally rolled out. + ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields") + if err := client.Query(ctxWithFeatures, issueQuery, vars); err != nil { return ghErrors.NewGitHubGraphQLErrorResponse( ctx, "failed to list issues", @@ -1910,6 +1964,116 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { }) } +// rawFieldFilter is the user-supplied {field_name, value} pair before type resolution. +type rawFieldFilter struct { + Name string + Value string +} + +// parseRawFieldFilters extracts the optional field_filters parameter into a list of +// {name, value} pairs. The value is always a string here; type-aware coercion happens +// later in resolveFieldFilters once we know each field's data_type. +func parseRawFieldFilters(args map[string]any) ([]rawFieldFilter, error) { + raw, ok := args["field_filters"] + if !ok { + return nil, nil + } + + var entries []map[string]any + switch v := raw.(type) { + case []any: + for _, f := range v { + entry, ok := f.(map[string]any) + if !ok { + return nil, fmt.Errorf("each field_filters entry must be an object") + } + entries = append(entries, entry) + } + case []map[string]any: + entries = v + default: + return nil, fmt.Errorf("field_filters must be an array") + } + + filters := make([]rawFieldFilter, 0, len(entries)) + for _, entry := range entries { + fieldName, err := RequiredParam[string](entry, "field_name") + if err != nil { + return nil, fmt.Errorf("field_filters entry: %s", err.Error()) + } + value, err := RequiredParam[string](entry, "value") + if err != nil { + return nil, fmt.Errorf("field_filters entry %q: %s", fieldName, err.Error()) + } + filters = append(filters, rawFieldFilter{Name: fieldName, Value: value}) + } + return filters, nil +} + +// resolveFieldFilters matches each raw filter against a known field definition and +// coerces the value into the right typed slot on IssueFieldValueFilter. Matching is +// case-insensitive on field name; option names are also matched case-insensitively for +// single-select fields. +func resolveFieldFilters(rawFilters []rawFieldFilter, fields []IssueField) ([]IssueFieldValueFilter, error) { + byName := make(map[string]IssueField, len(fields)) + knownNames := make([]string, 0, len(fields)) + for _, f := range fields { + byName[strings.ToLower(f.Name)] = f + knownNames = append(knownNames, f.Name) + } + + out := make([]IssueFieldValueFilter, 0, len(rawFilters)) + for _, rf := range rawFilters { + field, ok := byName[strings.ToLower(rf.Name)] + if !ok { + return nil, fmt.Errorf("field_filters: unknown field %q. Known fields: %s", rf.Name, strings.Join(knownNames, ", ")) + } + + filter := IssueFieldValueFilter{FieldName: githubv4.String(field.Name)} + switch field.DataType { + case "SINGLE_SELECT": + // Validate the option name against the field's options so we fail fast + // with a useful error instead of an opaque GraphQL one. + var matched string + for _, o := range field.Options { + if strings.EqualFold(o.Name, rf.Value) { + matched = o.Name + break + } + } + if matched == "" { + optionNames := make([]string, 0, len(field.Options)) + for _, o := range field.Options { + optionNames = append(optionNames, o.Name) + } + return nil, fmt.Errorf("field_filters: %q is not a valid option for %q. Valid options: %s", rf.Value, field.Name, strings.Join(optionNames, ", ")) + } + v := githubv4.String(matched) + filter.SingleSelectOptionValue = &v + case "TEXT": + v := githubv4.String(rf.Value) + filter.TextValue = &v + case "DATE": + if _, err := time.Parse("2006-01-02", rf.Value); err != nil { + return nil, fmt.Errorf("field_filters: %q is not a valid date for %q (expected YYYY-MM-DD): %s", rf.Value, field.Name, err.Error()) + } + v := githubv4.String(rf.Value) + filter.DateValue = &v + case "NUMBER": + n, err := strconv.ParseFloat(rf.Value, 64) + if err != nil { + return nil, fmt.Errorf("field_filters: %q is not a valid number for %q: %s", rf.Value, field.Name, err.Error()) + } + v := githubv4.Float(n) + filter.NumberValue = &v + default: + return nil, fmt.Errorf("field_filters: field %q has unsupported data_type %q", field.Name, field.DataType) + } + out = append(out, filter) + } + return out, nil +} + // parseISOTimestamp parses an ISO 8601 timestamp string into a time.Time object. // Returns the parsed time or an error if parsing fails. // Example formats supported: "2023-01-15T14:30:00Z", "2023-01-15" diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index ff4cb93a1..887918ea2 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -13,6 +13,8 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/http/headers" + transportpkg "github.com/github/github-mcp-server/pkg/http/transport" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" @@ -1603,56 +1605,63 @@ func Test_ListIssues(t *testing.T) { mockErrorRepoNotFound := githubv4mock.ErrorResponse("repository not found") - // Variables matching what GraphQL receives after JSON marshaling/unmarshaling + // Variables matching what GraphQL receives after JSON marshaling/unmarshaling. + // issueFieldValues is always sent as an (empty by default) list because the query + // declares the variable unconditionally; the server treats an empty list as no filter. varsListAll := map[string]any{ - "owner": "owner", - "repo": "repo", - "states": []any{"OPEN", "CLOSED"}, - "orderBy": "CREATED_AT", - "direction": "DESC", - "first": float64(30), - "after": (*string)(nil), + "owner": "owner", + "repo": "repo", + "states": []any{"OPEN", "CLOSED"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + "issueFieldValues": []any{}, } varsOpenOnly := map[string]any{ - "owner": "owner", - "repo": "repo", - "states": []any{"OPEN"}, - "orderBy": "CREATED_AT", - "direction": "DESC", - "first": float64(30), - "after": (*string)(nil), + "owner": "owner", + "repo": "repo", + "states": []any{"OPEN"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + "issueFieldValues": []any{}, } varsClosedOnly := map[string]any{ - "owner": "owner", - "repo": "repo", - "states": []any{"CLOSED"}, - "orderBy": "CREATED_AT", - "direction": "DESC", - "first": float64(30), - "after": (*string)(nil), + "owner": "owner", + "repo": "repo", + "states": []any{"CLOSED"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + "issueFieldValues": []any{}, } varsWithLabels := map[string]any{ - "owner": "owner", - "repo": "repo", - "states": []any{"OPEN", "CLOSED"}, - "labels": []any{"bug", "enhancement"}, - "orderBy": "CREATED_AT", - "direction": "DESC", - "first": float64(30), - "after": (*string)(nil), + "owner": "owner", + "repo": "repo", + "states": []any{"OPEN", "CLOSED"}, + "labels": []any{"bug", "enhancement"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + "issueFieldValues": []any{}, } varsRepoNotFound := map[string]any{ - "owner": "owner", - "repo": "nonexistent-repo", - "states": []any{"OPEN", "CLOSED"}, - "orderBy": "CREATED_AT", - "direction": "DESC", - "first": float64(30), - "after": (*string)(nil), + "owner": "owner", + "repo": "nonexistent-repo", + "states": []any{"OPEN", "CLOSED"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + "issueFieldValues": []any{}, } tests := []struct { @@ -1724,8 +1733,8 @@ func Test_ListIssues(t *testing.T) { // Define the actual query strings that match the implementation issueFieldValuesSelection := "issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value}}}" - qBasicNoLabels := "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}," + issueFieldValuesSelection + "},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" - qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}," + issueFieldValuesSelection + "},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + qBasicNoLabels := "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}," + issueFieldValuesSelection + "},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}," + issueFieldValuesSelection + "},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { @@ -1817,6 +1826,419 @@ func Test_ListIssues(t *testing.T) { } } +func Test_ListIssues_FieldFilters(t *testing.T) { + t.Parallel() + + serverTool := ListIssues(translations.NullTranslationHelper) + + mockIssues := []map[string]any{ + { + "number": 1, + "title": "An issue", + "body": "body", + "state": "OPEN", + "databaseId": 1, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + "author": map[string]any{"login": "user1"}, + "labels": map[string]any{"nodes": []map[string]any{}}, + "comments": map[string]any{"totalCount": 0}, + }, + } + + pageInfo := map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "", + "endCursor": "", + } + + response := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issues": map[string]any{ + "nodes": mockIssues, + "pageInfo": pageInfo, + "totalCount": 1, + }, + "isPrivate": false, + }, + }) + + // Field-lookup matcher used by every subtest that supplies field_filters. + // The handler calls fetchIssueFields(owner, repo) before issuing the issues query. + fieldsResponse := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldSingleSelect", + "id": "IFSS_1", + "name": "Priority", + "dataType": "SINGLE_SELECT", + "visibility": "ALL", + "options": []any{ + map[string]any{"id": "OPT_P1", "name": "P1", "color": "red"}, + map[string]any{"id": "OPT_P2", "name": "P2", "color": "yellow"}, + }, + }, + map[string]any{ + "__typename": "IssueFieldText", + "id": "IFT_1", + "name": "Notes", + "dataType": "TEXT", + "visibility": "ALL", + }, + map[string]any{ + "__typename": "IssueFieldNumber", + "id": "IFN_1", + "name": "Estimate", + "dataType": "NUMBER", + "visibility": "ALL", + }, + map[string]any{ + "__typename": "IssueFieldDate", + "id": "IFD_1", + "name": "Due", + "dataType": "DATE", + "visibility": "ALL", + }, + }, + }, + }, + }) + fieldsMatcher := func() githubv4mock.Matcher { + return githubv4mock.NewQueryMatcher( + issueFieldsRepoQuery{}, + map[string]any{ + "owner": githubv4.String("owner"), + "name": githubv4.String("repo"), + }, + fieldsResponse, + ) + } + + qNoLabels := "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},issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value}}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + qWithLabels := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount},issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value}}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + + baseVars := func() map[string]any { + return map[string]any{ + "owner": "owner", + "repo": "repo", + "states": []any{"OPEN", "CLOSED"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + } + } + + t.Run("single select field filter", func(t *testing.T) { + vars := baseVars() + vars["issueFieldValues"] = []any{ + map[string]any{"fieldName": "Priority", "singleSelectOptionValue": "P1"}, + } + matcher := githubv4mock.NewQueryMatcher(qNoLabels, vars, response) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher(), matcher)) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "field_filters": []any{ + map[string]any{"field_name": "Priority", "value": "P1"}, + }, + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.False(t, res.IsError, getTextResult(t, res).Text) + }) + + t.Run("text field filter combined with labels", func(t *testing.T) { + vars := baseVars() + vars["labels"] = []any{"bug"} + vars["issueFieldValues"] = []any{ + map[string]any{"fieldName": "Notes", "textValue": "needs triage"}, + } + matcher := githubv4mock.NewQueryMatcher(qWithLabels, vars, response) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher(), matcher)) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "labels": []any{"bug"}, + "field_filters": []any{ + map[string]any{"field_name": "Notes", "value": "needs triage"}, + }, + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.False(t, res.IsError, getTextResult(t, res).Text) + }) + + t.Run("number and date field filters", func(t *testing.T) { + vars := baseVars() + vars["issueFieldValues"] = []any{ + map[string]any{"fieldName": "Estimate", "numberValue": float64(2.5)}, + map[string]any{"fieldName": "Due", "dateValue": "2026-06-01"}, + } + matcher := githubv4mock.NewQueryMatcher(qNoLabels, vars, response) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher(), matcher)) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "field_filters": []any{ + map[string]any{"field_name": "Estimate", "value": "2.5"}, + map[string]any{"field_name": "Due", "value": "2026-06-01"}, + }, + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.False(t, res.IsError, getTextResult(t, res).Text) + }) + + t.Run("number field accepts zero values", func(t *testing.T) { + for _, value := range []string{"0", "0.0"} { + t.Run(value, func(t *testing.T) { + vars := baseVars() + vars["issueFieldValues"] = []any{ + map[string]any{"fieldName": "Estimate", "numberValue": float64(0)}, + } + matcher := githubv4mock.NewQueryMatcher(qNoLabels, vars, response) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher(), matcher)) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "field_filters": []any{ + map[string]any{"field_name": "Estimate", "value": value}, + }, + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.False(t, res.IsError, getTextResult(t, res).Text) + }) + } + }) + + t.Run("validation error when value missing", func(t *testing.T) { + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(githubv4mock.NewQueryMatcher("", nil, response))) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "field_filters": []any{ + map[string]any{"field_name": "Priority"}, + }, + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.True(t, res.IsError) + text := getTextResult(t, res).Text + assert.Contains(t, text, "field_filters entry") + assert.Contains(t, text, "Priority") + assert.Contains(t, text, "value") + }) + + t.Run("validation error when field_name missing", func(t *testing.T) { + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(githubv4mock.NewQueryMatcher("", nil, response))) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "field_filters": []any{ + map[string]any{"value": "P1"}, + }, + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.True(t, res.IsError) + text := getTextResult(t, res).Text + assert.Contains(t, text, "field_filters entry") + assert.Contains(t, text, "field_name") + }) + + t.Run("error when field is unknown", func(t *testing.T) { + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher())) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "field_filters": []any{ + map[string]any{"field_name": "NotARealField", "value": "x"}, + }, + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.True(t, res.IsError) + text := getTextResult(t, res).Text + assert.Contains(t, text, "unknown field") + assert.Contains(t, text, "Priority") + }) + + t.Run("error when single-select option is invalid", func(t *testing.T) { + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher())) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "field_filters": []any{ + map[string]any{"field_name": "Priority", "value": "P9"}, + }, + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.True(t, res.IsError) + text := getTextResult(t, res).Text + assert.Contains(t, text, "not a valid option") + assert.Contains(t, text, "P1") + }) + + t.Run("error when number value is non-numeric", func(t *testing.T) { + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher())) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "field_filters": []any{ + map[string]any{"field_name": "Estimate", "value": "not-a-number"}, + }, + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.True(t, res.IsError) + assert.Contains(t, getTextResult(t, res).Text, "not a valid number") + }) + + t.Run("error when date value is malformed", func(t *testing.T) { + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher())) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "field_filters": []any{ + map[string]any{"field_name": "Due", "value": "06/01/2026"}, + }, + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.True(t, res.IsError) + assert.Contains(t, getTextResult(t, res).Text, "not a valid date") + }) + + // Query string fragments for the `since` variants. Built by string concatenation + // because they only differ from the base variants by the variable declaration and + // the filterBy clause. + qNoLabelsWithSince := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$since:DateTime!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since, issueFieldValues: $issueFieldValues})" + qNoLabels[len("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})"):] + qLabelsWithSince := "query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$since:DateTime!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since, issueFieldValues: $issueFieldValues})" + qWithLabels[len("query($after:String$direction:OrderDirection!$first:Int!$issueFieldValues:[IssueFieldValueFilter!]!$labels:[String!]!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {issueFieldValues: $issueFieldValues})"):] + + t.Run("field filter with since", func(t *testing.T) { + vars := baseVars() + vars["since"] = "2026-01-01T00:00:00Z" + vars["issueFieldValues"] = []any{ + map[string]any{"fieldName": "Priority", "singleSelectOptionValue": "P1"}, + } + matcher := githubv4mock.NewQueryMatcher(qNoLabelsWithSince, vars, response) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher(), matcher)) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "since": "2026-01-01T00:00:00Z", + "field_filters": []any{ + map[string]any{"field_name": "Priority", "value": "P1"}, + }, + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.False(t, res.IsError, getTextResult(t, res).Text) + }) + + t.Run("field filter with labels and since", func(t *testing.T) { + vars := baseVars() + vars["labels"] = []any{"bug"} + vars["since"] = "2026-01-01T00:00:00Z" + vars["issueFieldValues"] = []any{ + map[string]any{"fieldName": "Priority", "singleSelectOptionValue": "P1"}, + } + matcher := githubv4mock.NewQueryMatcher(qLabelsWithSince, vars, response) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(fieldsMatcher(), matcher)) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + req := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "labels": []any{"bug"}, + "since": "2026-01-01T00:00:00Z", + "field_filters": []any{ + map[string]any{"field_name": "Priority", "value": "P1"}, + }, + }) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.False(t, res.IsError, getTextResult(t, res).Text) + }) + + t.Run("sends GraphQL-Features: issue_fields, repo_issue_fields header", func(t *testing.T) { + vars := baseVars() + vars["issueFieldValues"] = []any{} + matcher := githubv4mock.NewQueryMatcher(qNoLabels, vars, response) + + // Build a transport chain matching production: GraphQLFeaturesTransport + // wraps a header-capturing spy, which forwards to the mock's RoundTripper. + // This verifies the handler sets the issue_fields context value and the + // transport translates it into the outgoing header. + mockClient := githubv4mock.NewMockedHTTPClient(matcher) + spy := &headerCaptureTransport{inner: mockClient.Transport} + httpClient := &http.Client{ + Transport: &transportpkg.GraphQLFeaturesTransport{Transport: spy}, + } + gqlClient := githubv4.NewClient(httpClient) + deps := BaseDeps{GQLClient: gqlClient} + handler := serverTool.Handler(deps) + + req := createMCPRequest(map[string]any{"owner": "owner", "repo": "repo"}) + res, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.False(t, res.IsError, getTextResult(t, res).Text) + assert.Equal(t, "issue_fields, repo_issue_fields", spy.captured.Get(headers.GraphQLFeaturesHeader)) + }) +} + +// headerCaptureTransport records the headers of the most recent request that passed +// through it before forwarding to the inner RoundTripper. +type headerCaptureTransport struct { + inner http.RoundTripper + captured http.Header +} + +func (t *headerCaptureTransport) RoundTrip(req *http.Request) (*http.Response, error) { + t.captured = req.Header.Clone() + return t.inner.RoundTrip(req) +} + func Test_ListIssues_IFC_InsidersMode(t *testing.T) { t.Parallel() @@ -1857,16 +2279,17 @@ func Test_ListIssues_IFC_InsidersMode(t *testing.T) { }) } - query := "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount},issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value}}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + query := "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},issueFieldValues(first: 25){nodes{__typename,... on IssueFieldDateValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value},... on IssueFieldNumberValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},valueNumber: value},... on IssueFieldSingleSelectValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value},... on IssueFieldTextValue{field{... on IssueFieldDate{name},... on IssueFieldNumber{name},... on IssueFieldSingleSelect{name},... on IssueFieldText{name}},value}}}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" vars := map[string]any{ - "owner": "octocat", - "repo": "hello", - "states": []any{"OPEN", "CLOSED"}, - "orderBy": "CREATED_AT", - "direction": "DESC", - "first": float64(30), - "after": (*string)(nil), + "owner": "octocat", + "repo": "hello", + "states": []any{"OPEN", "CLOSED"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": (*string)(nil), + "issueFieldValues": []any{}, } reqParams := map[string]any{"owner": "octocat", "repo": "hello"} From f39f758d6f46475795140065972528e035a0c769 Mon Sep 17 00:00:00 2001 From: Tim Rogers Date: Thu, 21 May 2026 16:41:21 +0200 Subject: [PATCH 40/48] Remove trailing periods from tool title annotations (#2518) Tool title annotations should be consistent with the rest of the tool catalog and render cleanly in agent UIs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Sam Morrow --- README.md | 10 +++++----- pkg/github/__toolsnaps__/get_label.snap | 2 +- pkg/github/__toolsnaps__/issue_write.snap | 2 +- pkg/github/__toolsnaps__/label_write.snap | 2 +- pkg/github/__toolsnaps__/list_label.snap | 2 +- .../__toolsnaps__/pull_request_review_write.snap | 2 +- pkg/github/issues.go | 2 +- pkg/github/labels.go | 6 +++--- pkg/github/pullrequests.go | 2 +- 9 files changed, 15 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 8455cd76f..516fb3bfd 100644 --- a/README.md +++ b/README.md @@ -829,7 +829,7 @@ The following sets of tools are available: - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) -- **get_label** - Get a specific label from a repository. +- **get_label** - Get a specific label from a repository - **Required OAuth Scopes**: `repo` - `name`: Label name. (string, required) - `owner`: Repository owner (username or organization name) (string, required) @@ -850,7 +850,7 @@ The following sets of tools are available: - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: The name of the repository (string, required) -- **issue_write** - Create or update issue. +- **issue_write** - Create or update issue - **Required OAuth Scopes**: `repo` - `assignees`: Usernames to assign to this issue (string[], optional) - `body`: Issue body content (string, optional) @@ -926,13 +926,13 @@ The following sets of tools are available: tag Labels -- **get_label** - Get a specific label from a repository. +- **get_label** - Get a specific label from a repository - **Required OAuth Scopes**: `repo` - `name`: Label name. (string, required) - `owner`: Repository owner (username or organization name) (string, required) - `repo`: Repository name (string, required) -- **label_write** - Write operations on repository labels. +- **label_write** - Write operations on repository labels - **Required OAuth Scopes**: `repo` - `color`: Label color as 6-character hex code without '#' prefix (e.g., 'f29513'). Required for 'create', optional for 'update'. (string, optional) - `description`: Label description text. Optional for 'create' and 'update'. (string, optional) @@ -1132,7 +1132,7 @@ The following sets of tools are available: - `pullNumber`: Pull request number (number, required) - `repo`: Repository name (string, required) -- **pull_request_review_write** - Write operations (create, submit, delete) on pull request reviews. +- **pull_request_review_write** - Write operations (create, submit, delete) on pull request reviews - **Required OAuth Scopes**: `repo` - `body`: Review comment text (string, optional) - `commitID`: SHA of commit to review (string, optional) diff --git a/pkg/github/__toolsnaps__/get_label.snap b/pkg/github/__toolsnaps__/get_label.snap index 854f048c2..379ca7d8d 100644 --- a/pkg/github/__toolsnaps__/get_label.snap +++ b/pkg/github/__toolsnaps__/get_label.snap @@ -1,7 +1,7 @@ { "annotations": { "readOnlyHint": true, - "title": "Get a specific label from a repository." + "title": "Get a specific label from a repository" }, "description": "Get a specific label from a repository.", "inputSchema": { diff --git a/pkg/github/__toolsnaps__/issue_write.snap b/pkg/github/__toolsnaps__/issue_write.snap index 24cff5df9..a125864f0 100644 --- a/pkg/github/__toolsnaps__/issue_write.snap +++ b/pkg/github/__toolsnaps__/issue_write.snap @@ -9,7 +9,7 @@ } }, "annotations": { - "title": "Create or update issue." + "title": "Create or update issue" }, "description": "Create a new or update an existing issue in a GitHub repository.", "inputSchema": { diff --git a/pkg/github/__toolsnaps__/label_write.snap b/pkg/github/__toolsnaps__/label_write.snap index f0aca8cc9..de4b98bef 100644 --- a/pkg/github/__toolsnaps__/label_write.snap +++ b/pkg/github/__toolsnaps__/label_write.snap @@ -1,6 +1,6 @@ { "annotations": { - "title": "Write operations on repository labels." + "title": "Write operations on repository labels" }, "description": "Perform write operations on repository labels. To set labels on issues, use the 'update_issue' tool.", "inputSchema": { diff --git a/pkg/github/__toolsnaps__/list_label.snap b/pkg/github/__toolsnaps__/list_label.snap index debc2d44e..9bf8a9f3e 100644 --- a/pkg/github/__toolsnaps__/list_label.snap +++ b/pkg/github/__toolsnaps__/list_label.snap @@ -1,7 +1,7 @@ { "annotations": { "readOnlyHint": true, - "title": "List labels from a repository." + "title": "List labels from a repository" }, "description": "List labels from a repository", "inputSchema": { diff --git a/pkg/github/__toolsnaps__/pull_request_review_write.snap b/pkg/github/__toolsnaps__/pull_request_review_write.snap index 7e314005f..d4a7c30d3 100644 --- a/pkg/github/__toolsnaps__/pull_request_review_write.snap +++ b/pkg/github/__toolsnaps__/pull_request_review_write.snap @@ -1,6 +1,6 @@ { "annotations": { - "title": "Write operations (create, submit, delete) on pull request reviews." + "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", "inputSchema": { diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 8662845f6..3508e0928 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1315,7 +1315,7 @@ func IssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool { Name: "issue_write", Description: t("TOOL_ISSUE_WRITE_DESCRIPTION", "Create a new or update an existing issue in a GitHub repository."), Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_ISSUE_WRITE_USER_TITLE", "Create or update issue."), + Title: t("TOOL_ISSUE_WRITE_USER_TITLE", "Create or update issue"), ReadOnlyHint: false, }, Meta: mcp.Meta{ diff --git a/pkg/github/labels.go b/pkg/github/labels.go index 0dbb622d9..e8d8102cb 100644 --- a/pkg/github/labels.go +++ b/pkg/github/labels.go @@ -24,7 +24,7 @@ func GetLabel(t translations.TranslationHelperFunc) inventory.ServerTool { Name: "get_label", Description: t("TOOL_GET_LABEL_DESCRIPTION", "Get a specific label from a repository."), Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_GET_LABEL_TITLE", "Get a specific label from a repository."), + Title: t("TOOL_GET_LABEL_TITLE", "Get a specific label from a repository"), ReadOnlyHint: true, }, InputSchema: &jsonschema.Schema{ @@ -126,7 +126,7 @@ func ListLabels(t translations.TranslationHelperFunc) inventory.ServerTool { Name: "list_label", Description: t("TOOL_LIST_LABEL_DESCRIPTION", "List labels from a repository"), Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LIST_LABEL_DESCRIPTION", "List labels from a repository."), + Title: t("TOOL_LIST_LABEL_DESCRIPTION", "List labels from a repository"), ReadOnlyHint: true, }, InputSchema: &jsonschema.Schema{ @@ -217,7 +217,7 @@ func LabelWrite(t translations.TranslationHelperFunc) inventory.ServerTool { Name: "label_write", Description: t("TOOL_LABEL_WRITE_DESCRIPTION", "Perform write operations on repository labels. To set labels on issues, use the 'update_issue' tool."), Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LABEL_WRITE_TITLE", "Write operations on repository labels."), + Title: t("TOOL_LABEL_WRITE_TITLE", "Write operations on repository labels"), ReadOnlyHint: false, }, InputSchema: &jsonschema.Schema{ diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 9672f8524..819b04929 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -1581,7 +1581,7 @@ Available methods: - 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. `), Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_PULL_REQUEST_REVIEW_WRITE_USER_TITLE", "Write operations (create, submit, delete) on pull request reviews."), + Title: t("TOOL_PULL_REQUEST_REVIEW_WRITE_USER_TITLE", "Write operations (create, submit, delete) on pull request reviews"), ReadOnlyHint: false, }, InputSchema: schema, From f929c58c6b97a03a865484bb5a347b6cb7ade3fc Mon Sep 17 00:00:00 2001 From: Ross Tarrant Date: Thu, 21 May 2026 15:50:55 +0100 Subject: [PATCH 41/48] feat: Add CSV output format for default list tools under insiders mode (#2450) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add CSV output for list tools under insiders mode * fix: resolve rebase feature flag conflicts Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Simplify feature-flag handling: collapse CSV dual-variant + skip filtering when no checker (#2516) * refactor: generic toolset+name sort, clarify feature flag intent Address review feedback on #2450: - Collapse the three near-identical sort helpers in pkg/inventory/filters.go into a generic sortByToolsetThenName so adding new inventory item types doesn't require copying the comparator. - Expand the doc comments on the three *WithoutFeatureFiltering helpers to spell out why they exist: HTTP mode builds a static (process-wide) inventory as an upper bound, but per-request feature flags from headers (X-MCP-Features, X-MCP-Insiders) are evaluated later, so feature-flagged variants must be preserved here. - Strengthen the doc comment on ResolveFeatureFlags to make the contract explicit: user-supplied flags are validated against AllowedFeatureFlags, but insiders expansion deliberately is not — InsidersFeatureFlags may include server-controlled flags that are not user-toggleable. CORS comments are intentionally left for the PR author. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(feature-flags): clarify allowed and insiders sets are independent Also add tests covering: - a user-toggleable flag (FeatureFlagIssuesGranular) that insiders does not turn on automatically - insiders mode not turning on user-only allowed flags Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(inventory): collapse three *WithoutFeatureFiltering helpers into StaticUpperBound The three parallel methods (AvailableToolsWithoutFeatureFiltering, AvailableResourceTemplatesWithoutFeatureFiltering, AvailablePromptsWithoutFeatureFiltering) were always called as a triple in exactly two places: HTTP buildStaticInventory and its test mirror. They exist because the dual-variant pattern (sibling tools with mirrored FeatureFlagEnable / FeatureFlagDisable on the same name, e.g. CSV output) makes feature filtering at static-build time impossible — both variants must be kept and resolved per-request. Replace the three with one method, Inventory.StaticUpperBound(ctx), that returns (tools, resources, prompts) and carries the rationale in its doc comment. Reduces API surface, eliminates the triplication, and makes the single "skip feature filtering" concept obvious to readers. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor: simplify feature-flag handling Two related simplifications, both about treating insiders as a meta flag that expands once at startup and then stops mattering: - Collapse CSV's dual-variant pattern into a single tool whose handler performs a runtime feature-flag check via deps.IsFeatureEnabled. CSV is a pure response-format toggle, not a schema change, so it does not need the dual-name pattern that genuine schema variants (granular issues/PRs) still use. - When no feature checker is installed, skip feature-flag filtering and return the full upper bound. The static HTTP inventory now uses plain AvailableTools/Resources/Prompts; the per-request inventory always installs a checker, so MCP registration (which serves a tool name once) always sees a deduplicated set. The bespoke StaticUpperBound helper and the isToolEnabledWithFeatureFlags split go away. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * ci(mcp-diff): add insiders + per-feature configs The mcp-diff matrix now includes: - --insiders (and --insiders --read-only) - one config per github.AllowedFeatureFlags entry, generated by script/print-mcp-diff-configs so new user-controllable flags get diffed automatically without editing the workflow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs(insiders): explain feature-flag resolution for contributors Adds a 'How feature flags are resolved' section covering: - Insiders is a meta flag, like 'all'/'default' for toolsets - User input -> allowlist filter -> insiders expansion -> server-side fallback (remote only) - AllowedFeatureFlags vs InsidersFeatureFlags are independent - How to add a new feature flag, including the TestGitHubPackageDoesNotReadInsidersMode guard Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(inventory): make feature-flag gating a regular ToolFilter Move tool feature-flag evaluation out of isToolEnabled and into a ToolFilter installed at the head of the pipeline by Build() when WithFeatureChecker received a non-nil checker. The 'no checker = no filtering' contract is now expressed structurally (the filter isn't installed) instead of by a runtime nil check inside the helper. Resources and prompts have no filter pipeline, so they call the now-pure featureFlagAllowed helper behind an explicit r.featureChecker != nil guard at the iteration site. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * perf(inventory): cache extracted toolset IDs in sort comparator Avoid evaluating the extractor closures up to three times per comparison. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: correct MCP features header in cors * docs: regenerate README for CSV output toolset Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: remove duplicate MCPFeaturesHeader from CORS headers * ci(mcp-diff): add streamable-http job with header-based configs Adds a sibling mcp-diff-http job that exercises the streamable-http transport against a shared HTTP server, with per-config settings supplied via X-MCP-* request headers — mirroring how the remote server is invoked in production (server-side defaults + per-user header overrides). The config generator gains a -transport flag: - stdio (default, unchanged behaviour) - http-headers (emits headers-only configs targeting a shared server) Two new combined entries layer multiple headers together as a smoke test for header-merging regressions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * docs: regenerate after merging main Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Sam Morrow Co-authored-by: sammorrowdrums --- .github/workflows/mcp-diff.yml | 83 +++++- README.md | 168 +++++++++++ cmd/github-mcp-server/main.go | 8 + docs/insiders-features.md | 57 ++++ internal/ghmcp/server.go | 5 +- pkg/github/context_tools.go | 2 +- pkg/github/context_tools_test.go | 33 +- pkg/github/csv_output.go | 409 +++++++++++++++++++++++++ pkg/github/csv_output_test.go | 413 ++++++++++++++++++++++++++ pkg/github/dependencies.go | 1 - pkg/github/feature_flags.go | 38 ++- pkg/github/feature_flags_test.go | 121 +++----- pkg/github/issues.go | 12 +- pkg/github/issues_test.go | 51 ++-- pkg/github/pullrequests.go | 4 +- pkg/github/pullrequests_test.go | 10 +- pkg/github/repositories.go | 4 +- pkg/github/repositories_test.go | 13 +- pkg/github/search.go | 2 +- pkg/github/search_test.go | 9 +- pkg/github/server.go | 2 +- pkg/github/server_test.go | 2 - pkg/github/tools.go | 4 +- pkg/github/tools_validation_test.go | 31 ++ pkg/github/ui_embed.go | 2 +- pkg/http/handler.go | 17 +- pkg/http/handler_test.go | 32 +- pkg/http/server.go | 21 +- pkg/http/server_test.go | 36 ++- pkg/inventory/builder.go | 28 +- pkg/inventory/filters.go | 131 +++++--- pkg/inventory/registry_test.go | 51 ++-- script/print-mcp-diff-configs/main.go | 217 ++++++++++++++ 33 files changed, 1738 insertions(+), 279 deletions(-) create mode 100644 pkg/github/csv_output.go create mode 100644 pkg/github/csv_output_test.go create mode 100644 script/print-mcp-diff-configs/main.go diff --git a/.github/workflows/mcp-diff.yml b/.github/workflows/mcp-diff.yml index bb6341c09..305428923 100644 --- a/.github/workflows/mcp-diff.yml +++ b/.github/workflows/mcp-diff.yml @@ -19,32 +19,35 @@ jobs: with: fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + - name: Build UI uses: ./.github/actions/build-ui + - name: Generate diff configurations + id: configs + # The generator imports pkg/github so any new entry in + # AllowedFeatureFlags is automatically diffed without touching this + # workflow. See script/print-mcp-diff-configs/main.go. + run: | + { + echo 'configurations<> "$GITHUB_OUTPUT" + - name: Run MCP Server Diff uses: SamMorrowDrums/mcp-server-diff@v2.3.5 with: - setup_go: "true" + setup_go: "false" install_command: go mod download start_command: go run ./cmd/github-mcp-server stdio env_vars: | GITHUB_PERSONAL_ACCESS_TOKEN=test-token - configurations: | - [ - {"name": "default", "args": ""}, - {"name": "read-only", "args": "--read-only"}, - {"name": "toolsets-repos", "args": "--toolsets=repos"}, - {"name": "toolsets-issues", "args": "--toolsets=issues"}, - {"name": "toolsets-context", "args": "--toolsets=context"}, - {"name": "toolsets-pull_requests", "args": "--toolsets=pull_requests"}, - {"name": "toolsets-repos,issues", "args": "--toolsets=repos,issues"}, - {"name": "toolsets-issues,context", "args": "--toolsets=issues,context"}, - {"name": "toolsets-all", "args": "--toolsets=all"}, - {"name": "tools-get_me", "args": "--tools=get_me"}, - {"name": "tools-get_me,list_issues", "args": "--tools=get_me,list_issues"}, - {"name": "toolsets-repos+read-only", "args": "--toolsets=repos --read-only"} - ] + configurations: ${{ steps.configs.outputs.configurations }} - name: Add interpretation note if: always() @@ -58,3 +61,51 @@ jobs: echo "- New tools/toolsets added" >> $GITHUB_STEP_SUMMARY echo "- Tool descriptions updated" >> $GITHUB_STEP_SUMMARY echo "- Capability changes (intentional improvements)" >> $GITHUB_STEP_SUMMARY + + mcp-diff-http: + runs-on: ubuntu-latest + + steps: + - name: Check out code + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Build UI + uses: ./.github/actions/build-ui + + - name: Generate diff configurations + id: configs + # See script/print-mcp-diff-configs/main.go. The http-headers variant + # points every config at a shared HTTP server started by the action + # and carries per-config settings via X-MCP-* headers, mirroring how + # the remote server is invoked in production (server-side defaults + + # per-user header overrides). + run: | + { + echo 'configurations<> "$GITHUB_OUTPUT" + + - name: Run MCP Server Diff (streamable-http) + uses: SamMorrowDrums/mcp-server-diff@v2.3.5 + with: + setup_go: "false" + install_command: go mod download + http_start_command: go run ./cmd/github-mcp-server http --port 8082 + http_startup_wait_ms: "5000" + configurations: ${{ steps.configs.outputs.configurations }} + + - name: Add interpretation note + if: always() + run: | + echo "" >> $GITHUB_STEP_SUMMARY + echo "---" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + echo "ℹ️ **Note:** This job exercises the streamable-http transport against a shared server, with per-config settings supplied via X-MCP-* request headers." >> $GITHUB_STEP_SUMMARY diff --git a/README.md b/README.md index 516fb3bfd..6d2964965 100644 --- a/README.md +++ b/README.md @@ -829,6 +829,21 @@ The following sets of tools are available: - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) +- **add_sub_issue** - Add Sub-Issue + - **Required OAuth Scopes**: `repo` + - `issue_number`: The parent issue number (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `replace_parent`: If true, reparent the sub-issue if it already has a parent (boolean, optional) + - `repo`: Repository name (string, required) + - `sub_issue_id`: The ID of the sub-issue to add. ID is not the same as issue number (number, required) + +- **create_issue** - Create Issue + - **Required OAuth Scopes**: `repo` + - `body`: Issue body content (optional) (string, optional) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + - `title`: Issue title (string, required) + - **get_label** - Get a specific label from a repository - **Required OAuth Scopes**: `repo` - `name`: Label name. (string, required) @@ -894,6 +909,22 @@ The following sets of tools are available: - `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) +- **remove_sub_issue** - Remove Sub-Issue + - **Required OAuth Scopes**: `repo` + - `issue_number`: The parent issue number (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + - `sub_issue_id`: The ID of the sub-issue to remove. ID is not the same as issue number (number, required) + +- **reprioritize_sub_issue** - Reprioritize Sub-Issue + - **Required OAuth Scopes**: `repo` + - `after_id`: The ID of the sub-issue to place this after (either after_id OR before_id should be specified) (number, optional) + - `before_id`: The ID of the sub-issue to place this before (either after_id OR before_id should be specified) (number, optional) + - `issue_number`: The parent issue number (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + - `sub_issue_id`: The ID of the sub-issue to reorder. ID is not the same as issue number (number, required) + - **search_issues** - Search issues - **Required OAuth Scopes**: `repo` - `order`: Sort order (string, optional) @@ -904,6 +935,13 @@ The following sets of tools are available: - `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) +- **set_issue_fields** - Set Issue Fields + - **Required OAuth Scopes**: `repo` + - `fields`: Array of issue field values to set. Each element must have a 'field_id' (string, the GraphQL node ID of the field) and exactly one value field: 'text_value' for text fields, 'number_value' for number fields, 'date_value' (ISO 8601 date string) for date fields, or 'single_select_option_id' (the GraphQL node ID of the option) for single select fields. Set 'delete' to true to remove a field value. (object[], required) + - `issue_number`: The issue number to update (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + - **sub_issue_write** - Change sub-issue - **Required OAuth Scopes**: `repo` - `after_id`: The ID of the sub-issue to be prioritized after (either after_id OR before_id should be specified) (number, optional) @@ -920,6 +958,57 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - `sub_issue_id`: The ID of the sub-issue to add. ID is not the same as issue number (number, required) +- **update_issue_assignees** - Update Issue Assignees + - **Required OAuth Scopes**: `repo` + - `assignees`: GitHub usernames to assign to this issue (string[], required) + - `issue_number`: The issue number to update (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + +- **update_issue_body** - Update Issue Body + - **Required OAuth Scopes**: `repo` + - `body`: The new body content for the issue (string, required) + - `issue_number`: The issue number to update (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + +- **update_issue_labels** - Update Issue Labels + - **Required OAuth Scopes**: `repo` + - `issue_number`: The issue number to update (number, required) + - `labels`: Labels to apply to this issue. ([], required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + +- **update_issue_milestone** - Update Issue Milestone + - **Required OAuth Scopes**: `repo` + - `issue_number`: The issue number to update (number, required) + - `milestone`: The milestone number to set on the issue (integer, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + +- **update_issue_state** - Update Issue State + - **Required OAuth Scopes**: `repo` + - `issue_number`: The issue number to update (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `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) + +- **update_issue_title** - Update Issue Title + - **Required OAuth Scopes**: `repo` + - `issue_number`: The issue number to update (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + - `title`: The new title for the issue (string, required) + +- **update_issue_type** - Update Issue Type + - **Required OAuth Scopes**: `repo` + - `issue_number`: The issue number to update (number, required) + - `issue_type`: The issue type to set (string, required) + - `owner`: Repository owner (username or organization) (string, required) + - `rationale`: One concise sentence explaining what specifically about the issue led you to choose this type. State the concrete signal (e.g. 'Reports a crash when saving' → bug, 'Asks for dark mode support' → feature). (string, optional) + - `repo`: Repository name (string, required) +
@@ -1072,6 +1161,19 @@ The following sets of tools are available: - `startSide`: For multi-line comments, the starting side of the diff that the comment applies to. LEFT indicates the previous state, RIGHT indicates the new state (string, optional) - `subjectType`: The level at which the comment is targeted (string, required) +- **add_pull_request_review_comment** - Add Pull Request Review Comment + - **Required OAuth Scopes**: `repo` + - `body`: The comment body (string, required) + - `line`: The line number in the diff to comment on (optional) (number, optional) + - `owner`: Repository owner (username or organization) (string, required) + - `path`: The relative path of the file to comment on (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + - `side`: The side of the diff to comment on (optional) (string, optional) + - `startLine`: The start line of a multi-line comment (optional) (number, optional) + - `startSide`: The start side of a multi-line comment (optional) (string, optional) + - `subjectType`: The subject type of the comment (string, required) + - **add_reply_to_pull_request_comment** - Add reply to pull request comment - **Required OAuth Scopes**: `repo` - `body`: The text of the reply (string, required) @@ -1091,6 +1193,21 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - `title`: PR title (string, required) +- **create_pull_request_review** - Create Pull Request Review + - **Required OAuth Scopes**: `repo` + - `body`: The review body text (optional) (string, optional) + - `commitID`: The SHA of the commit to review (optional, defaults to latest) (string, optional) + - `event`: The review action to perform. If omitted, creates a pending review. (string, optional) + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + +- **delete_pending_pull_request_review** - Delete Pending Pull Request Review + - **Required OAuth Scopes**: `repo` + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + - **list_pull_requests** - List pull requests - **Required OAuth Scopes**: `repo` - `base`: Filter by base branch (string, optional) @@ -1143,6 +1260,17 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - `threadId`: The node ID of the review thread (e.g., PRRT_kwDOxxx). Required for resolve_thread and unresolve_thread methods. Get thread IDs from pull_request_read with method get_review_comments. (string, optional) +- **request_pull_request_reviewers** - Request Pull Request Reviewers + - **Required OAuth Scopes**: `repo` + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + - `reviewers`: GitHub usernames to request reviews from (string[], required) + +- **resolve_review_thread** - Resolve Review Thread + - **Required OAuth Scopes**: `repo` + - `threadID`: The node ID of the review thread to resolve (e.g., PRRT_kwDOxxx) (string, required) + - **search_pull_requests** - Search pull requests - **Required OAuth Scopes**: `repo` - `order`: Sort order (string, optional) @@ -1153,6 +1281,18 @@ The following sets of tools are available: - `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) +- **submit_pending_pull_request_review** - Submit Pending Pull Request Review + - **Required OAuth Scopes**: `repo` + - `body`: The review body text (optional) (string, optional) + - `event`: The review action to perform (string, required) + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + +- **unresolve_review_thread** - Unresolve Review Thread + - **Required OAuth Scopes**: `repo` + - `threadID`: The node ID of the review thread to unresolve (e.g., PRRT_kwDOxxx) (string, required) + - **update_pull_request** - Edit pull request - **Required OAuth Scopes**: `repo` - `base`: New base branch name (string, optional) @@ -1166,6 +1306,13 @@ The following sets of tools are available: - `state`: New state (string, optional) - `title`: New title (string, optional) +- **update_pull_request_body** - Update Pull Request Body + - **Required OAuth Scopes**: `repo` + - `body`: The new body content for the pull request (string, required) + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + - **update_pull_request_branch** - Update pull request branch - **Required OAuth Scopes**: `repo` - `expectedHeadSha`: The expected SHA of the pull request's HEAD ref (string, optional) @@ -1173,6 +1320,27 @@ The following sets of tools are available: - `pullNumber`: Pull request number (number, required) - `repo`: Repository name (string, required) +- **update_pull_request_draft_state** - Update Pull Request Draft State + - **Required OAuth Scopes**: `repo` + - `draft`: Set to true to convert to draft, false to mark as ready for review (boolean, required) + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + +- **update_pull_request_state** - Update Pull Request State + - **Required OAuth Scopes**: `repo` + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + - `state`: The new state for the pull request (string, required) + +- **update_pull_request_title** - Update Pull Request Title + - **Required OAuth Scopes**: `repo` + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + - `title`: The new title for the pull request (string, required) +
diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index ec948ab6e..ab8b27bb3 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -126,6 +126,13 @@ var ( } } + var enabledFeatures []string + if viper.IsSet("features") { + if err := viper.UnmarshalKey("features", &enabledFeatures); err != nil { + return fmt.Errorf("failed to unmarshal features: %w", err) + } + } + ttl := viper.GetDuration("repo-access-cache-ttl") httpConfig := ghhttp.ServerConfig{ Version: version, @@ -144,6 +151,7 @@ var ( EnabledToolsets: enabledToolsets, EnabledTools: enabledTools, ExcludeTools: excludeTools, + EnabledFeatures: enabledFeatures, InsidersMode: viper.GetBool("insiders"), } diff --git a/docs/insiders-features.md b/docs/insiders-features.md index 911257ae4..90afe7219 100644 --- a/docs/insiders-features.md +++ b/docs/insiders-features.md @@ -42,3 +42,60 @@ MCP Apps requires a host that supports the [MCP Apps extension](https://modelcon - **VS Code Insiders** — enable via the `chat.mcp.apps.enabled` setting - **Visual Studio Code** — enable via the `chat.mcp.apps.enabled` setting + +--- + +## CSV output for list tools + +CSV output mode returns supported list tool responses as CSV instead of JSON. This is intended to reduce response context for agents when scanning or summarising lists of GitHub data. + +CSV output applies only to tools in default toolsets whose names start with `list_`, such as `list_issues`, `list_pull_requests`, `list_commits`, and `list_branches`. It does not add new tools or expose a tool argument for selecting the format; the server controls the response format through the Insiders feature flag. + +### Format + +- Nested objects are flattened into dot-notation columns, for example `user.login`, `category.name`, or `head.ref`. +- Arrays are represented as compact single-cell values joined with `;`. +- `body` fields are whitespace-normalized so multiline Markdown does not expand a list response into many output lines. +- Response metadata present in wrapped responses, such as `pageInfo.*` and `totalCount`, is emitted as `#`-prefixed lines before the CSV rows, followed by a blank line. Tools that return a root JSON array do not include metadata preamble lines. + +### Enabling CSV output + +CSV output is enabled by Insiders Mode. For local development, it can also be enabled explicitly with the `csv_output` feature flag: + +```bash +github-mcp-server stdio --features csv_output +``` + +Because this changes list tool response shape, clients that require JSON list responses should avoid enabling this feature. + +--- + +## How feature flags are resolved + +> [!NOTE] +> This section is for contributors. End users only need the table at the top of this page. + +Insiders is a **meta feature flag** — the same shape as `default` or `all` for toolsets. It expands once at startup into a curated set of individual feature flags, and from that point on every code path keys off concrete flags, never `InsidersMode` directly. New experimental work should always get its own flag and then be added to the insiders expansion list, never folded into `insiders` as a catch-all. + +### Resolution order + +1. **User input.** Users may opt into specific features: + - Local server: `--features=,` CLI flag (or `GITHUB_FEATURES` env var). + - Self-hosted HTTP server: `X-MCP-Features: ,` request header. +2. **Allowlist filter.** User-supplied flags are filtered against [`AllowedFeatureFlags`](../pkg/github/feature_flags.go). Anything not on the allowlist is silently dropped — flags missing from the allowlist can only be turned on by remote-server feature management, not by end users. +3. **Insiders expansion.** If insiders mode is on (`--insiders`, `/insiders` route, or `X-MCP-Insiders: true`), every flag in [`InsidersFeatureFlags`](../pkg/github/feature_flags.go) is unioned in. The insiders expansion is **not** re-validated against the allowlist — insiders is a server-controlled switch that can reach internal-only flags. +4. **Server-side fallback (remote server only).** Any flag not yet decided falls back to the remote server's feature manager, which can roll a feature out independently of user input or insiders membership. + +`AllowedFeatureFlags` and `InsidersFeatureFlags` are deliberately independent sets: + +- A flag in **`AllowedFeatureFlags` only** is a regular opt-in: users can turn it on, but insiders does not auto-enable it. Granular issues/PRs flags work this way. +- A flag in **`InsidersFeatureFlags` only** is reachable through insiders (and remote-server rollouts), but cannot be enabled by user input. Internal-only experiments work this way. +- A flag in **both** is opt-in for end users *and* automatically on under insiders. + +### Adding a new feature flag + +1. Add a constant in `pkg/github/feature_flags.go`. +2. Add it to `AllowedFeatureFlags` if end users should be able to opt in via `--features` / `X-MCP-Features`. +3. Add it to `InsidersFeatureFlags` if insiders mode should turn it on automatically. +4. Gate the behavior on the concrete flag (`deps.IsFeatureEnabled(ctx, FeatureFlagX)`), never on `cfg.InsidersMode`. There is a `TestGitHubPackageDoesNotReadInsidersMode` guard test that fails if `pkg/github` reads `InsidersMode` directly. +5. The MCP-diff CI workflow picks up new entries in `AllowedFeatureFlags` automatically — see `.github/workflows/mcp-diff.yml`. diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 3ca249dd1..38106b6d9 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -143,7 +143,6 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se cfg.Translator, github.FeatureFlags{ LockdownMode: cfg.LockdownMode, - InsidersMode: cfg.InsidersMode, }, cfg.ContentWindowSize, featureChecker, @@ -229,7 +228,7 @@ type StdioServerConfig struct { // LockdownMode indicates if we should enable lockdown mode LockdownMode bool - // InsidersMode indicates if we should enable experimental features + // InsidersMode expands to the curated set of feature flags enabled for insiders. InsidersMode bool // ExcludeTools is a list of tool names to disable regardless of other settings. @@ -345,7 +344,7 @@ func RunStdioServer(cfg StdioServerConfig) error { // createFeatureChecker returns a FeatureFlagChecker that resolves features // using the centralized ResolveFeatureFlags function. For the local server, -// features are resolved once at startup from --features CLI flag + insiders mode. +// features are resolved once at startup from --features CLI flag and insiders mode. func createFeatureChecker(enabledFeatures []string, insidersMode bool) inventory.FeatureFlagChecker { featureSet := github.ResolveFeatureFlags(enabledFeatures, insidersMode) return func(_ context.Context, flagName string) (bool, error) { diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 191e56279..4008c2f4a 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -106,7 +106,7 @@ func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool { } result := MarshalledTextResult(minimalUser) - if deps.GetFlags(ctx).InsidersMode { + if deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { if result.Meta == nil { result.Meta = mcp.Meta{} } diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index 2b17be86d..ade54aba1 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -139,7 +139,7 @@ func Test_GetMe(t *testing.T) { } } -func Test_GetMe_IFC_InsidersMode(t *testing.T) { +func Test_GetMe_IFC_FeatureFlag(t *testing.T) { t.Parallel() serverTool := GetMe(translations.NullTranslationHelper) @@ -153,11 +153,21 @@ func Test_GetMe_IFC_InsidersMode(t *testing.T) { GetUser: mockResponse(t, http.StatusOK, mockUser), }) - t.Run("insiders mode disabled omits ifc label from result meta", func(t *testing.T) { - deps := BaseDeps{ - Client: mustNewGHClient(t, mockedHTTPClient), - Flags: FeatureFlags{InsidersMode: false}, - } + depsWithIFCFeature := func(enabled bool) *BaseDeps { + return NewBaseDeps( + mustNewGHClient(t, mockedHTTPClient), nil, nil, nil, + translations.NullTranslationHelper, + FeatureFlags{}, + 0, + func(_ context.Context, flagName string) (bool, error) { + return flagName == FeatureFlagIFCLabels && enabled, nil + }, + stubExporters(), + ) + } + + t.Run("feature disabled omits ifc label from result meta", func(t *testing.T) { + deps := depsWithIFCFeature(false) handler := serverTool.Handler(deps) request := createMCPRequest(map[string]any{}) @@ -165,14 +175,11 @@ func Test_GetMe_IFC_InsidersMode(t *testing.T) { require.NoError(t, err) require.False(t, result.IsError) - assert.Nil(t, result.Meta, "result meta should be nil when insiders mode is disabled") + assert.Nil(t, result.Meta, "result meta should be nil when IFC labels are disabled") }) - t.Run("insiders mode enabled includes ifc label in result meta", func(t *testing.T) { - deps := BaseDeps{ - Client: mustNewGHClient(t, mockedHTTPClient), - Flags: FeatureFlags{InsidersMode: true}, - } + t.Run("feature enabled includes ifc label in result meta", func(t *testing.T) { + deps := depsWithIFCFeature(true) handler := serverTool.Handler(deps) request := createMCPRequest(map[string]any{}) @@ -180,7 +187,7 @@ func Test_GetMe_IFC_InsidersMode(t *testing.T) { require.NoError(t, err) require.False(t, result.IsError) - require.NotNil(t, result.Meta, "result meta should be set when insiders mode is enabled") + require.NotNil(t, result.Meta, "result meta should be set when IFC labels are enabled") ifcLabel, ok := result.Meta["ifc"] require.True(t, ok, "result meta should contain ifc key") diff --git a/pkg/github/csv_output.go b/pkg/github/csv_output.go new file mode 100644 index 000000000..cb70e32d7 --- /dev/null +++ b/pkg/github/csv_output.go @@ -0,0 +1,409 @@ +package github + +import ( + "bytes" + "context" + "encoding/csv" + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// Ordered by preference when a response wrapper contains multiple arrays. +var primaryCSVRowKeys = []string{ + "items", + "issues", + "discussions", + "categories", + "labels", + "alerts", + "advisories", + "notifications", + "gists", + "repositories", + "commits", + "branches", + "tags", + "releases", + "users", + "teams", + "members", + "projects", + "nodes", +} + +type csvOutputDocument struct { + metadata map[string]string + rows []map[string]string +} + +// withCSVOutput wraps the handler of every default-toolset list_* tool so that, +// at request time, it checks the csv_output feature flag and converts the JSON +// text response to CSV when enabled. The tool's schema, name, and scope are +// unchanged — only the response payload format differs. +func withCSVOutput(tools []inventory.ServerTool) []inventory.ServerTool { + for i := range tools { + if !isCSVOutputTool(tools[i]) { + continue + } + tools[i].HandlerFunc = wrapHandlerWithCSVOutput(tools[i].HandlerFunc) + } + return tools +} + +func isCSVOutputTool(tool inventory.ServerTool) bool { + if !tool.Toolset.Default { + return false + } + if !strings.HasPrefix(tool.Tool.Name, "list_") { + return false + } + return tool.FeatureFlagEnable == "" && tool.FeatureFlagDisable == "" +} + +func wrapHandlerWithCSVOutput(next inventory.HandlerFunc) inventory.HandlerFunc { + return func(deps any) mcp.ToolHandler { + handler := next(deps) + csvDeps, _ := deps.(ToolDependencies) + return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + result, err := handler(ctx, req) + if err != nil || result == nil || result.IsError { + return result, err + } + if csvDeps == nil || !csvDeps.IsFeatureEnabled(ctx, FeatureFlagCSVOutput) { + return result, nil + } + return convertJSONTextResultToCSV(result), nil + } + } +} + +func convertJSONTextResultToCSV(result *mcp.CallToolResult) *mcp.CallToolResult { + if len(result.Content) != 1 { + return utils.NewToolResultError("failed to convert response to CSV: expected a single text content response") + } + + text, ok := result.Content[0].(*mcp.TextContent) + if !ok { + return utils.NewToolResultError("failed to convert response to CSV: expected a text content response") + } + + csvText, err := jsonTextToCSV(text.Text) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to convert response to CSV", err) + } + + result.Content = []mcp.Content{&mcp.TextContent{Text: csvText}} + result.StructuredContent = nil + return result +} + +func jsonTextToCSV(text string) (string, error) { + decoder := json.NewDecoder(strings.NewReader(text)) + decoder.UseNumber() + + var value any + if err := decoder.Decode(&value); err != nil { + return "", fmt.Errorf("failed to unmarshal JSON text: %w", err) + } + + doc := csvDocument(value) + if len(doc.metadata) == 0 && len(doc.rows) == 0 { + return "", nil + } + + var buf bytes.Buffer + writeCSVMetadata(&buf, doc.metadata) + if len(doc.rows) == 0 { + return buf.String(), nil + } + + headers := csvHeaders(doc.rows) + if len(headers) == 0 { + return buf.String(), nil + } + + writer := csv.NewWriter(&buf) + if err := writer.Write(headers); err != nil { + return "", fmt.Errorf("failed to write CSV header: %w", err) + } + + for _, row := range doc.rows { + record := make([]string, len(headers)) + for i, header := range headers { + record[i] = row[header] + } + if err := writer.Write(record); err != nil { + return "", fmt.Errorf("failed to write CSV row: %w", err) + } + } + + writer.Flush() + if err := writer.Error(); err != nil { + return "", fmt.Errorf("failed to flush CSV: %w", err) + } + return buf.String(), nil +} + +func csvDocument(value any) csvOutputDocument { + switch v := value.(type) { + case []any: + return csvOutputDocument{rows: csvRowsFromArray(v)} + case map[string]any: + if rows, metadata, ok := primaryRowsFromMap(v); ok { + return csvOutputDocument{ + metadata: newFlattenedCSVRow(metadata), + rows: csvRowsFromArray(rows), + } + } + return csvOutputDocument{rows: []map[string]string{newFlattenedCSVRow(v)}} + default: + return csvOutputDocument{rows: []map[string]string{scalarCSVRow(v)}} + } +} + +func primaryRowsFromMap(value map[string]any) ([]any, map[string]any, bool) { + if rows, path, ok := primaryRowsAtCurrentLevel(value); ok { + return rows, metadataWithoutPath(value, path), true + } + if rows, path, ok := primaryRowsOneLevelDown(value); ok { + return rows, metadataWithoutPath(value, path), true + } + return nil, nil, false +} + +func primaryRowsAtCurrentLevel(value map[string]any) ([]any, []string, bool) { + if key, ok := preferredPrimaryRowKey(value); ok { + rows, _ := value[key].([]any) + return rows, []string{key}, true + } + if key, ok := singleArrayKey(value); ok { + rows, _ := value[key].([]any) + return rows, []string{key}, true + } + return nil, nil, false +} + +func primaryRowsOneLevelDown(value map[string]any) ([]any, []string, bool) { + var matchedRows []any + var matchedPath []string + for key, raw := range value { + child, ok := raw.(map[string]any) + if !ok { + continue + } + rows, path, ok := primaryRowsAtCurrentLevel(child) + if !ok { + continue + } + if matchedPath != nil { + return nil, nil, false + } + matchedRows = rows + matchedPath = append([]string{key}, path...) + } + if matchedPath == nil { + return nil, nil, false + } + return matchedRows, matchedPath, true +} + +func metadataWithoutPath(value map[string]any, path []string) map[string]any { + metadata := make(map[string]any, len(value)) + for key, raw := range value { + if key != path[0] { + metadata[key] = raw + continue + } + + if len(path) == 1 { + continue + } + child, ok := raw.(map[string]any) + if !ok { + continue + } + childMetadata := metadataWithoutPath(child, path[1:]) + if len(childMetadata) > 0 { + metadata[key] = childMetadata + } + } + return metadata +} + +func csvRowsFromArray(values []any) []map[string]string { + if len(values) == 0 { + return nil + } + + rows := make([]map[string]string, 0, len(values)) + for _, value := range values { + var row map[string]string + switch v := value.(type) { + case map[string]any: + row = make(map[string]string) + appendFlattenedCSVFields(row, v, "") + default: + row = scalarCSVRow(v) + } + rows = append(rows, row) + } + return rows +} + +func writeCSVMetadata(buf *bytes.Buffer, metadata map[string]string) { + if len(metadata) == 0 { + return + } + + headers := make([]string, 0, len(metadata)) + for header := range metadata { + headers = append(headers, header) + } + sort.Strings(headers) + + for _, header := range headers { + fmt.Fprintf(buf, "# %s: %s\n", header, normalizeCSVWhitespace(metadata[header])) + } + buf.WriteByte('\n') +} + +func newFlattenedCSVRow(value map[string]any) map[string]string { + row := make(map[string]string) + appendFlattenedCSVFields(row, value, "") + return row +} + +func appendFlattenedCSVFields(row map[string]string, value map[string]any, prefix string) { + if value == nil { + return + } + + for key, raw := range value { + column := csvColumnName(prefix, key) + switch v := raw.(type) { + case map[string]any: + appendFlattenedCSVFields(row, v, column) + case []any: + row[column] = csvArrayValue(v) + default: + row[column] = csvColumnValue(column, v) + } + } +} + +func csvHeaders(rows []map[string]string) []string { + headerSet := make(map[string]struct{}) + for _, row := range rows { + for header := range row { + headerSet[header] = struct{}{} + } + } + + headers := make([]string, 0, len(headerSet)) + for header := range headerSet { + headers = append(headers, header) + } + sort.Strings(headers) + return headers +} + +func csvColumnName(prefix, key string) string { + if prefix == "" { + return key + } + return prefix + "." + key +} + +func preferredPrimaryRowKey(value map[string]any) (string, bool) { + for _, key := range primaryCSVRowKeys { + if _, ok := value[key].([]any); ok { + return key, true + } + } + return "", false +} + +func singleArrayKey(value map[string]any) (string, bool) { + var arrayKey string + for key, raw := range value { + if _, ok := raw.([]any); !ok { + continue + } + if arrayKey != "" { + return "", false + } + arrayKey = key + } + if arrayKey == "" { + return "", false + } + return arrayKey, true +} + +func csvColumnValue(column string, value any) string { + str := scalarCSVValue(value) + if isBodyColumn(column) { + return normalizeCSVWhitespace(str) + } + return str +} + +func csvArrayValue(values []any) string { + if len(values) == 0 { + return "" + } + + // Scalar arrays use semicolons for compactness. This is lossy if an + // element contains a semicolon; use JSON mode when exact reconstruction matters. + parts := make([]string, 0, len(values)) + for _, value := range values { + switch value.(type) { + case map[string]any, []any: + encoded, err := json.Marshal(value) + if err != nil { + parts = append(parts, scalarCSVValue(value)) + } else { + parts = append(parts, string(encoded)) + } + default: + parts = append(parts, scalarCSVValue(value)) + } + } + return strings.Join(parts, ";") +} + +func scalarCSVRow(value any) map[string]string { + return map[string]string{"value": scalarCSVValue(value)} +} + +func scalarCSVValue(value any) string { + switch v := value.(type) { + case nil: + return "" + case string: + return v + case json.Number: + return v.String() + case bool: + if v { + return "true" + } + return "false" + default: + return fmt.Sprint(v) + } +} + +func isBodyColumn(column string) bool { + return column == "body" || strings.HasSuffix(column, ".body") +} + +func normalizeCSVWhitespace(value string) string { + return strings.Join(strings.Fields(value), " ") +} diff --git a/pkg/github/csv_output_test.go b/pkg/github/csv_output_test.go new file mode 100644 index 000000000..d0bef3893 --- /dev/null +++ b/pkg/github/csv_output_test.go @@ -0,0 +1,413 @@ +package github + +import ( + "context" + "encoding/csv" + "encoding/json" + "strings" + "testing" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCSVOutputAppliedToDefaultListTools(t *testing.T) { + listTool := testCSVOutputTool("list_things", `[{"number":1}]`) + getTool := testCSVOutputTool("get_thing", `{"number":1}`) + + tools := withCSVOutput([]inventory.ServerTool{listTool, getTool}) + require.Len(t, tools, 2) + + // CSV mode does not introduce variants or change tool gating; both tools + // remain visible regardless of feature flag state. + for _, csvOutputEnabled := range []bool{false, true} { + inv := buildCSVOutputInventory(t, tools, csvOutputEnabled) + available := inv.AvailableTools(context.Background()) + require.Len(t, available, 2) + + listing := requireToolByName(t, available, "list_things") + assert.Empty(t, listing.FeatureFlagEnable) + assert.Empty(t, listing.FeatureFlagDisable) + + getting := requireToolByName(t, available, "get_thing") + assert.Empty(t, getting.FeatureFlagEnable) + assert.Empty(t, getting.FeatureFlagDisable) + } +} + +func TestCSVOutputOnlyAppliesToDefaultToolsets(t *testing.T) { + nonDefaultListTool := testCSVOutputToolWithToolset("list_discussions", `[{"number":1}]`, ToolsetMetadataDiscussions) + + tools := withCSVOutput([]inventory.ServerTool{nonDefaultListTool}) + require.Len(t, tools, 1) + + // Non-default toolset list tools are not wrapped: even with the flag on, + // the response stays in JSON form. + deps := newCSVOutputTestDeps(true) + result, err := tools[0].Handler(deps)(ContextWithDeps(context.Background(), deps), testCSVOutputRequest()) + require.NoError(t, err) + assert.JSONEq(t, `[{"number":1}]`, textResult(t, result)) +} + +func TestCSVOutputDoesNotExposeFormatParameter(t *testing.T) { + tools := withCSVOutput([]inventory.ServerTool{testCSVOutputTool("list_things", `[{"number":1}]`)}) + require.Len(t, tools, 1) + + schema, ok := tools[0].Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok) + assert.NotContains(t, schema.Properties, "output_format") +} + +func TestCSVOutputConvertsJSONTextToCSVWhenFlagOn(t *testing.T) { + tools := withCSVOutput([]inventory.ServerTool{ + testCSVOutputTool("list_things", `[ + { + "number": 1, + "body": "first line\n\tsecond line", + "labels": ["bug", "help wanted"], + "user": {"login": "octocat"} + } + ]`), + }) + require.Len(t, tools, 1) + + deps := newCSVOutputTestDeps(true) + result, err := tools[0].Handler(deps)(ContextWithDeps(context.Background(), deps), testCSVOutputRequest()) + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, result.IsError) + + assert.NotContains(t, textResult(t, result), "#") + + records := readCSVResult(t, result) + require.Len(t, records, 2) + + row := csvRow(t, records[0], records[1]) + assert.Equal(t, "first line second line", row["body"]) + assert.Equal(t, "bug;help wanted", row["labels"]) + assert.Equal(t, "1", row["number"]) + assert.Equal(t, "octocat", row["user.login"]) +} + +func TestCSVOutputPreservesOriginalJSONWhenFlagOff(t *testing.T) { + const jsonResponse = `[{"number":1,"user":{"login":"octocat"}}]` + tools := withCSVOutput([]inventory.ServerTool{testCSVOutputTool("list_things", jsonResponse)}) + require.Len(t, tools, 1) + + deps := newCSVOutputTestDeps(false) + result, err := tools[0].Handler(deps)(ContextWithDeps(context.Background(), deps), testCSVOutputRequest()) + require.NoError(t, err) + require.NotNil(t, result) + + require.Len(t, result.Content, 1) + text, ok := result.Content[0].(*mcp.TextContent) + require.True(t, ok) + assert.JSONEq(t, jsonResponse, text.Text) +} + +func TestCSVOutputVariantMovesMetadataToPreamble(t *testing.T) { + csvText, err := jsonTextToCSV(`{ + "issues": [ + {"number": 1, "title": "First issue"} + ], + "pageInfo": { + "endCursor": "cursor-1", + "hasNextPage": true + }, + "totalCount": 2 + }`) + require.NoError(t, err) + assert.Contains(t, csvText, "# pageInfo.endCursor: cursor-1\n") + assert.Contains(t, csvText, "# pageInfo.hasNextPage: true\n") + assert.Contains(t, csvText, "# totalCount: 2\n\n") + + records := readCSVText(t, csvText) + require.Len(t, records, 2) + + row := csvRow(t, records[0], records[1]) + assert.Equal(t, "1", row["number"]) + assert.Equal(t, "First issue", row["title"]) + assert.NotContains(t, row, "pageInfo.endCursor") + assert.NotContains(t, row, "totalCount") +} + +func TestJSONTextToCSVFlattensPrimaryRows(t *testing.T) { + csvText, err := jsonTextToCSV(`{ + "discussions": [ + { + "number": 5, + "title": "Discussion tools testing", + "category": {"name": "Q&A"}, + "user": {"login": "octocat"} + } + ] + }`) + require.NoError(t, err) + + records := readCSVText(t, csvText) + require.Len(t, records, 2) + + row := csvRow(t, records[0], records[1]) + assert.Equal(t, "Q&A", row["category.name"]) + assert.Equal(t, "5", row["number"]) + assert.Equal(t, "Discussion tools testing", row["title"]) + assert.Equal(t, "octocat", row["user.login"]) +} + +func TestJSONTextToCSVFindsPrimaryRowsOneLevelDeeper(t *testing.T) { + csvText, err := jsonTextToCSV(`{ + "issues": { + "nodes": [ + {"number": 5, "title": "Nested issue"} + ], + "pageInfo": {"hasNextPage": false}, + "totalCount": 1 + } + }`) + require.NoError(t, err) + + assert.Contains(t, csvText, "# issues.pageInfo.hasNextPage: false\n") + assert.Contains(t, csvText, "# issues.totalCount: 1\n\n") + + records := readCSVText(t, csvText) + require.Len(t, records, 2) + + row := csvRow(t, records[0], records[1]) + assert.Equal(t, "5", row["number"]) + assert.Equal(t, "Nested issue", row["title"]) +} + +func TestJSONTextToCSVUsesSingleArrayAsPrimaryRows(t *testing.T) { + csvText, err := jsonTextToCSV(`{ + "results": [ + {"number": 1, "title": "Single array result"} + ], + "pageInfo": {"hasNextPage": true} + }`) + require.NoError(t, err) + + assert.Contains(t, csvText, "# pageInfo.hasNextPage: true\n\n") + + records := readCSVText(t, csvText) + require.Len(t, records, 2) + + row := csvRow(t, records[0], records[1]) + assert.Equal(t, "1", row["number"]) + assert.Equal(t, "Single array result", row["title"]) + assert.NotContains(t, row, "pageInfo.hasNextPage") +} + +func TestJSONTextToCSVFlattensRootObjectWithoutPrimaryRows(t *testing.T) { + csvText, err := jsonTextToCSV(`{ + "name": "summary", + "pageInfo": {"hasNextPage": false}, + "totalCount": 2 + }`) + require.NoError(t, err) + assert.NotContains(t, csvText, "#") + + records := readCSVText(t, csvText) + require.Len(t, records, 2) + + row := csvRow(t, records[0], records[1]) + assert.Equal(t, "summary", row["name"]) + assert.Equal(t, "false", row["pageInfo.hasNextPage"]) + assert.Equal(t, "2", row["totalCount"]) +} + +func TestJSONTextToCSVConvertsScalarToValueRow(t *testing.T) { + csvText, err := jsonTextToCSV(`"plain value"`) + require.NoError(t, err) + + records := readCSVText(t, csvText) + require.Len(t, records, 2) + + row := csvRow(t, records[0], records[1]) + assert.Equal(t, "plain value", row["value"]) +} + +func TestJSONTextToCSVReturnsEmptyForEmptyArray(t *testing.T) { + csvText, err := jsonTextToCSV(`[]`) + require.NoError(t, err) + assert.Empty(t, csvText) +} + +func TestJSONTextToCSVReturnsEmptyForEmptyObject(t *testing.T) { + csvText, err := jsonTextToCSV(`{}`) + require.NoError(t, err) + assert.Empty(t, csvText) +} + +func TestJSONTextToCSVReturnsEmptyForOnlyEmptyNestedObjects(t *testing.T) { + csvText, err := jsonTextToCSV(`{ + "repository": { + "owner": {} + } + }`) + require.NoError(t, err) + assert.Empty(t, csvText) +} + +func TestJSONTextToCSVReturnsMetadataOnlyWhenRowsHaveNoColumns(t *testing.T) { + csvText, err := jsonTextToCSV(`{ + "items": [ + {} + ], + "totalCount": 1 + }`) + require.NoError(t, err) + assert.Equal(t, "# totalCount: 1\n\n", csvText) +} + +func TestJSONTextToCSVFlattensAmbiguousArraysAsSingleRow(t *testing.T) { + csvText, err := jsonTextToCSV(`{ + "foo": ["a", "b"], + "bar": ["c"] + }`) + require.NoError(t, err) + assert.NotContains(t, csvText, "#") + + records := readCSVText(t, csvText) + require.Len(t, records, 2) + + row := csvRow(t, records[0], records[1]) + assert.Equal(t, "c", row["bar"]) + assert.Equal(t, "a;b", row["foo"]) +} + +func TestJSONTextToCSVUsesPreferredArrayWhenMultipleArraysExist(t *testing.T) { + csvText, err := jsonTextToCSV(`{ + "items": [ + {"id": 1} + ], + "other": [ + {"id": 2} + ], + "totalCount": 1 + }`) + require.NoError(t, err) + + assert.Contains(t, csvText, "# other: {\"id\":2}\n") + assert.Contains(t, csvText, "# totalCount: 1\n\n") + + records := readCSVText(t, csvText) + require.Len(t, records, 2) + + row := csvRow(t, records[0], records[1]) + assert.Equal(t, "1", row["id"]) +} + +func testCSVOutputTool(name string, response string) inventory.ServerTool { + return testCSVOutputToolWithToolset(name, response, ToolsetMetadataRepos) +} + +func testCSVOutputToolWithToolset(name string, response string, toolset inventory.ToolsetMetadata) inventory.ServerTool { + return inventory.ServerTool{ + Tool: mcp.Tool{ + Name: name, + Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{}, + }, + }, + Toolset: toolset, + HandlerFunc: func(_ any) mcp.ToolHandler { + return func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: response}, + }, + }, nil + } + }, + } +} + +func buildCSVOutputInventory(t *testing.T, tools []inventory.ServerTool, _ bool) *inventory.Inventory { + t.Helper() + + inv, err := inventory.NewBuilder(). + SetTools(tools). + Build() + require.NoError(t, err) + return inv +} + +func newCSVOutputTestDeps(csvOutputEnabled bool) ToolDependencies { + return csvOutputTestDeps{stubDeps: stubDeps{obsv: stubExporters()}, csvOn: csvOutputEnabled} +} + +type csvOutputTestDeps struct { + stubDeps + csvOn bool +} + +func (d csvOutputTestDeps) IsFeatureEnabled(_ context.Context, flag string) bool { + return flag == FeatureFlagCSVOutput && d.csvOn +} + +func requireToolByName(t *testing.T, tools []inventory.ServerTool, name string) inventory.ServerTool { + t.Helper() + + for _, tool := range tools { + if tool.Tool.Name == name { + return tool + } + } + require.Failf(t, "tool not found", "tool %q not found", name) + return inventory.ServerTool{} +} + +func testCSVOutputRequest() *mcp.CallToolRequest { + return &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{ + Arguments: json.RawMessage(`{}`), + }, + } +} + +func readCSVResult(t *testing.T, result *mcp.CallToolResult) [][]string { + t.Helper() + + require.Len(t, result.Content, 1) + text, ok := result.Content[0].(*mcp.TextContent) + require.True(t, ok) + + return readCSVText(t, text.Text) +} + +func textResult(t *testing.T, result *mcp.CallToolResult) string { + t.Helper() + + require.Len(t, result.Content, 1) + text, ok := result.Content[0].(*mcp.TextContent) + require.True(t, ok) + return text.Text +} + +func readCSVText(t *testing.T, text string) [][]string { + t.Helper() + + reader := csv.NewReader(strings.NewReader(text)) + reader.Comment = '#' + records, err := reader.ReadAll() + require.NoError(t, err) + return records +} + +func csvRow(t *testing.T, headers []string, record []string) map[string]string { + t.Helper() + require.Len(t, record, len(headers)) + + row := make(map[string]string, len(headers)) + for i, header := range headers { + row[header] = record[i] + } + return row +} diff --git a/pkg/github/dependencies.go b/pkg/github/dependencies.go index eb856e0bd..e3a031f99 100644 --- a/pkg/github/dependencies.go +++ b/pkg/github/dependencies.go @@ -410,7 +410,6 @@ func (d *RequestDeps) GetT() translations.TranslationHelperFunc { return d.T } func (d *RequestDeps) GetFlags(ctx context.Context) FeatureFlags { return FeatureFlags{ LockdownMode: d.lockdownMode && ghcontext.IsLockdownMode(ctx), - InsidersMode: ghcontext.IsInsidersMode(ctx), } } diff --git a/pkg/github/feature_flags.go b/pkg/github/feature_flags.go index 3f3d7bf97..19399e7ac 100644 --- a/pkg/github/feature_flags.go +++ b/pkg/github/feature_flags.go @@ -1,14 +1,23 @@ package github +import "slices" + // MCPAppsFeatureFlag is the feature flag name for MCP Apps (interactive UI forms). const MCPAppsFeatureFlag = "remote_mcp_ui_apps" +// FeatureFlagCSVOutput is the feature flag name for CSV output on list tools. +const FeatureFlagCSVOutput = "csv_output" + +// FeatureFlagIFCLabels is the feature flag name for IFC security labels in tool results. +const FeatureFlagIFCLabels = "ifc_labels" + // 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. // This is the single source of truth for which flags are user-controllable. var AllowedFeatureFlags = []string{ MCPAppsFeatureFlag, + FeatureFlagCSVOutput, FeatureFlagIssuesGranular, FeatureFlagPullRequestsGranular, } @@ -19,37 +28,40 @@ var AllowedFeatureFlags = []string{ // feature flag expansion. var InsidersFeatureFlags = []string{ MCPAppsFeatureFlag, + FeatureFlagCSVOutput, + FeatureFlagIFCLabels, } // FeatureFlags defines runtime feature toggles that adjust tool behavior. type FeatureFlags struct { LockdownMode bool - InsidersMode bool } // ResolveFeatureFlags computes the effective set of enabled feature flags by: -// 1. Taking explicitly enabled features (from CLI flags or HTTP headers) -// 2. Adding insiders-expanded features when insiders mode is active -// 3. Validating all features against the AllowedFeatureFlags allowlist +// 1. Taking the user-supplied flags (from --features or X-MCP-Features) and +// keeping only those present in AllowedFeatureFlags. Unknown or unsafe +// flags from request input are silently dropped here. +// 2. If insiders mode is on, unioning in every flag from InsidersFeatureFlags. +// Insiders is a server-controlled meta switch, so its expansion is NOT +// re-validated against AllowedFeatureFlags. +// +// AllowedFeatureFlags and InsidersFeatureFlags are independent sets: +// - A flag in AllowedFeatureFlags but not InsidersFeatureFlags is a regular +// opt-in flag that insiders mode does not turn on automatically. +// - A flag in InsidersFeatureFlags but not AllowedFeatureFlags is reachable +// only through insiders mode and cannot be enabled by user input. // // Returns a set (map) for O(1) lookup by the feature checker. func ResolveFeatureFlags(enabledFeatures []string, insidersMode bool) map[string]bool { - allowed := make(map[string]bool, len(AllowedFeatureFlags)) - for _, f := range AllowedFeatureFlags { - allowed[f] = true - } - effective := make(map[string]bool) for _, f := range enabledFeatures { - if allowed[f] { + if slices.Contains(AllowedFeatureFlags, f) { effective[f] = true } } if insidersMode { for _, f := range InsidersFeatureFlags { - if allowed[f] { - effective[f] = true - } + effective[f] = true } } return effective diff --git a/pkg/github/feature_flags_test.go b/pkg/github/feature_flags_test.go index b0c1a4305..9f31ada38 100644 --- a/pkg/github/feature_flags_test.go +++ b/pkg/github/feature_flags_test.go @@ -18,10 +18,14 @@ import ( // RemoteMCPEnthusiasticGreeting is a dummy test feature flag . const RemoteMCPEnthusiasticGreeting = "remote_mcp_enthusiastic_greeting" -// FeatureChecker is an interface for checking if a feature flag is enabled. -type FeatureChecker interface { - // IsFeatureEnabled checks if a feature flag is enabled. - IsFeatureEnabled(ctx context.Context, flagName string) bool +func featureCheckerFor(enabledFlags ...string) func(context.Context, string) (bool, error) { + enabled := make(map[string]bool, len(enabledFlags)) + for _, flag := range enabledFlags { + enabled[flag] = true + } + return func(_ context.Context, flagName string) (bool, error) { + return enabled[flagName], nil + } } // HelloWorld returns a simple greeting tool that demonstrates feature flag conditional behavior. @@ -45,9 +49,6 @@ func HelloWorldTool(t translations.TranslationHelperFunc) inventory.ServerTool { if deps.IsFeatureEnabled(ctx, RemoteMCPEnthusiasticGreeting) { greeting += " Welcome to the future of MCP! 🎉" } - if deps.GetFlags(ctx).InsidersMode { - greeting += " Experimental features are enabled! 🚀" - } // Build response response := map[string]any{ @@ -89,12 +90,9 @@ func TestHelloWorld_ConditionalBehavior_Featureflag(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - // Create feature checker based on test case - checker := func(_ context.Context, flagName string) (bool, error) { - if flagName == RemoteMCPEnthusiasticGreeting { - return tt.featureFlagEnabled, nil - } - return false, nil + var enabledFlags []string + if tt.featureFlagEnabled { + enabledFlags = append(enabledFlags, RemoteMCPEnthusiasticGreeting) } // Create deps with the checker @@ -103,7 +101,7 @@ func TestHelloWorld_ConditionalBehavior_Featureflag(t *testing.T) { translations.NullTranslationHelper, FeatureFlags{}, 0, - checker, + featureCheckerFor(enabledFlags...), stubExporters(), ) @@ -149,14 +147,12 @@ func TestResolveFeatureFlags(t *testing.T) { { name: "no features, no insiders", enabledFeatures: nil, - insidersMode: false, expectedFlags: nil, - unexpectedFlags: []string{MCPAppsFeatureFlag}, + unexpectedFlags: []string{MCPAppsFeatureFlag, FeatureFlagIFCLabels}, }, { name: "explicit feature enabled", enabledFeatures: []string{MCPAppsFeatureFlag}, - insidersMode: false, expectedFlags: []string{MCPAppsFeatureFlag}, }, { @@ -165,24 +161,46 @@ func TestResolveFeatureFlags(t *testing.T) { insidersMode: true, expectedFlags: InsidersFeatureFlags, }, + { + name: "insiders mode enables internal-only flags", + enabledFeatures: nil, + insidersMode: true, + expectedFlags: []string{FeatureFlagIFCLabels}, + }, + { + name: "internal-only flags are not directly enabled", + enabledFeatures: []string{FeatureFlagIFCLabels}, + expectedFlags: nil, + unexpectedFlags: []string{FeatureFlagIFCLabels}, + }, { name: "unknown flags are filtered out", enabledFeatures: []string{"unknown_flag", "another_unknown"}, - insidersMode: false, unexpectedFlags: []string{"unknown_flag", "another_unknown"}, }, { name: "mix of known and unknown flags", enabledFeatures: []string{MCPAppsFeatureFlag, "unknown_flag"}, - insidersMode: false, expectedFlags: []string{MCPAppsFeatureFlag}, unexpectedFlags: []string{"unknown_flag"}, }, + { + name: "user-only flags can be enabled but are not turned on by insiders", + enabledFeatures: []string{FeatureFlagIssuesGranular}, + insidersMode: false, + expectedFlags: []string{FeatureFlagIssuesGranular}, + }, + { + name: "insiders does not enable user-only allowed flags", + enabledFeatures: nil, + insidersMode: true, + unexpectedFlags: []string{FeatureFlagIssuesGranular, FeatureFlagPullRequestsGranular}, + }, { name: "explicit plus insiders deduplicates", enabledFeatures: []string{MCPAppsFeatureFlag}, insidersMode: true, - expectedFlags: []string{MCPAppsFeatureFlag}, + expectedFlags: InsidersFeatureFlags, }, } @@ -199,66 +217,3 @@ func TestResolveFeatureFlags(t *testing.T) { }) } } - -func TestHelloWorld_ConditionalBehavior_Config(t *testing.T) { - t.Parallel() - - tests := []struct { - name string - insidersMode bool - expectedGreeting string - }{ - { - name: "Experimental disabled - default greeting", - insidersMode: false, - expectedGreeting: "Hello, world!", - }, - { - name: "Experimental enabled - experimental greeting", - insidersMode: true, - expectedGreeting: "Hello, world! Experimental features are enabled! 🚀", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - t.Parallel() - - // Create deps with the checker - deps := NewBaseDeps( - nil, nil, nil, nil, - translations.NullTranslationHelper, - FeatureFlags{InsidersMode: tt.insidersMode}, - 0, - nil, - stubExporters(), - ) - - // Get the tool and its handler - tool := HelloWorldTool(translations.NullTranslationHelper) - handler := tool.Handler(deps) - - // Call the handler with deps in context - ctx := ContextWithDeps(context.Background(), deps) - result, err := handler(ctx, &mcp.CallToolRequest{ - Params: &mcp.CallToolParamsRaw{ - Arguments: json.RawMessage(`{}`), - }, - }) - require.NoError(t, err) - require.NotNil(t, result) - require.Len(t, result.Content, 1) - - // Parse the response - should be TextContent - textContent, ok := result.Content[0].(*mcp.TextContent) - require.True(t, ok, "expected content to be TextContent") - - var response map[string]any - err = json.Unmarshal([]byte(textContent.Text), &response) - require.NoError(t, err) - - // Verify the greeting matches expected based on feature flag - assert.Equal(t, tt.expectedGreeting, response["greeting"]) - }) - } -} diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 3508e0928..e56e793a4 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -360,10 +360,10 @@ Options are: } // attachIFC adds the IFC label to a successful tool result when - // InsidersMode is enabled. If the visibility lookup fails the + // IFC labels are enabled. If the visibility lookup fails the // label is omitted rather than misclassifying the result. attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult { - if r == nil || r.IsError || !deps.GetFlags(ctx).InsidersMode { + if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { return r } isPrivate, err := FetchRepoIsPrivate(ctx, client, owner, repo) @@ -1056,7 +1056,7 @@ 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) { var options []searchOption - if deps.GetFlags(ctx).InsidersMode { + if deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { options = append(options, withSearchPostProcess(searchIssuesIFCPostProcess(deps))) } result, err := searchIssuesHandler(ctx, deps, args, options...) @@ -1412,12 +1412,12 @@ Options are: return utils.NewToolResultError(err.Error()), nil, nil } - // When insiders mode is enabled and the client supports MCP Apps UI, + // When MCP Apps are enabled and the client supports UI, // check if this is a UI form submission. The UI sends _ui_submitted=true // to distinguish form submissions from LLM calls. uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") - if deps.GetFlags(ctx).InsidersMode && clientSupportsUI(ctx, req) && !uiSubmitted { + if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted { if method == "update" { // Skip the UI form when a state change is requested because // the form only handles title/body editing and would lose the @@ -1954,7 +1954,7 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { } result := MarshalledTextResult(resp) - if deps.GetFlags(ctx).InsidersMode { + if deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { if result.Meta == nil { result.Meta = mcp.Meta{} } diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 887918ea2..4f08b7214 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -327,7 +327,6 @@ func Test_IssueRead_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode disabled omits ifc label", func(t *testing.T) { deps := BaseDeps{ Client: mustNewGHClient(t, makeMockClient(false, 0)), - Flags: FeatureFlags{InsidersMode: false}, } handler := serverTool.Handler(deps) @@ -341,8 +340,8 @@ func Test_IssueRead_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode enabled on public repo emits public untrusted", func(t *testing.T) { deps := BaseDeps{ - Client: mustNewGHClient(t, makeMockClient(false, 0)), - Flags: FeatureFlags{InsidersMode: true}, + Client: mustNewGHClient(t, makeMockClient(false, 0)), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), } handler := serverTool.Handler(deps) @@ -359,8 +358,8 @@ func Test_IssueRead_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode enabled on private repo with get_comments emits private untrusted", func(t *testing.T) { deps := BaseDeps{ - Client: mustNewGHClient(t, makeMockClient(true, 0)), - Flags: FeatureFlags{InsidersMode: true}, + Client: mustNewGHClient(t, makeMockClient(true, 0)), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), } handler := serverTool.Handler(deps) @@ -377,8 +376,8 @@ func Test_IssueRead_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode skips ifc label when visibility lookup fails", func(t *testing.T) { deps := BaseDeps{ - Client: mustNewGHClient(t, makeMockClient(false, http.StatusInternalServerError)), - Flags: FeatureFlags{InsidersMode: true}, + Client: mustNewGHClient(t, makeMockClient(false, http.StatusInternalServerError)), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), } handler := serverTool.Handler(deps) @@ -912,7 +911,6 @@ func Test_SearchIssues_IFC_InsidersMode(t *testing.T) { searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{makeIssue("octocat", "public-repo", 1)}} deps := BaseDeps{ Client: mustNewGHClient(t, makeMockClient(searchResult, []repoFixture{{owner: "octocat", repo: "public-repo"}})), - Flags: FeatureFlags{InsidersMode: false}, } handler := serverTool.Handler(deps) @@ -926,8 +924,8 @@ func Test_SearchIssues_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode all public emits public untrusted", func(t *testing.T) { searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{makeIssue("octocat", "public-repo", 1)}} deps := BaseDeps{ - Client: mustNewGHClient(t, makeMockClient(searchResult, []repoFixture{{owner: "octocat", repo: "public-repo"}})), - Flags: FeatureFlags{InsidersMode: true}, + Client: mustNewGHClient(t, makeMockClient(searchResult, []repoFixture{{owner: "octocat", repo: "public-repo"}})), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), } handler := serverTool.Handler(deps) @@ -952,7 +950,7 @@ func Test_SearchIssues_IFC_InsidersMode(t *testing.T) { {owner: "octocat", repo: "private-repo", isPrivate: true}, {owner: "octocat", repo: "public-repo"}, })), - Flags: FeatureFlags{InsidersMode: true}, + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), } handler := serverTool.Handler(deps) @@ -973,7 +971,7 @@ func Test_SearchIssues_IFC_InsidersMode(t *testing.T) { Client: mustNewGHClient(t, makeMockClient(searchResult, []repoFixture{ {owner: "octocat", repo: "broken", repoStatus: http.StatusInternalServerError}, })), - Flags: FeatureFlags{InsidersMode: true}, + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), } handler := serverTool.Handler(deps) @@ -991,8 +989,8 @@ func Test_SearchIssues_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode empty results emits public untrusted", func(t *testing.T) { searchResult := &github.IssuesSearchResult{Issues: []*github.Issue{}} deps := BaseDeps{ - Client: mustNewGHClient(t, makeMockClient(searchResult, nil)), - Flags: FeatureFlags{InsidersMode: true}, + Client: mustNewGHClient(t, makeMockClient(searchResult, nil)), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), } handler := serverTool.Handler(deps) @@ -1268,9 +1266,9 @@ func Test_CreateIssue(t *testing.T) { } } -// Test_IssueWrite_InsidersMode_UIGate verifies the insiders mode UI gate +// Test_IssueWrite_MCPAppsFeature_UIGate verifies the MCP Apps feature UI gate // behavior: UI clients get a form message, non-UI clients execute directly. -func Test_IssueWrite_InsidersMode_UIGate(t *testing.T) { +func Test_IssueWrite_MCPAppsFeature_UIGate(t *testing.T) { t.Parallel() mockIssue := &github.Issue{ @@ -1286,9 +1284,9 @@ func Test_IssueWrite_InsidersMode_UIGate(t *testing.T) { })) deps := BaseDeps{ - Client: client, - GQLClient: githubv4.NewClient(nil), - Flags: FeatureFlags{InsidersMode: true}, + Client: client, + GQLClient: githubv4.NewClient(nil), + featureChecker: featureCheckerFor(MCPAppsFeatureFlag), } handler := serverTool.Handler(deps) @@ -1403,9 +1401,9 @@ func Test_IssueWrite_InsidersMode_UIGate(t *testing.T) { )) closeDeps := BaseDeps{ - Client: closeClient, - GQLClient: closeGQLClient, - Flags: FeatureFlags{InsidersMode: true}, + Client: closeClient, + GQLClient: closeGQLClient, + featureChecker: featureCheckerFor(MCPAppsFeatureFlag), } closeHandler := serverTool.Handler(closeDeps) @@ -2299,7 +2297,6 @@ func Test_ListIssues_IFC_InsidersMode(t *testing.T) { gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) deps := BaseDeps{ GQLClient: gqlClient, - Flags: FeatureFlags{InsidersMode: false}, } handler := serverTool.Handler(deps) @@ -2315,8 +2312,8 @@ func Test_ListIssues_IFC_InsidersMode(t *testing.T) { matcher := githubv4mock.NewQueryMatcher(query, vars, makeResponse(false)) gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) deps := BaseDeps{ - GQLClient: gqlClient, - Flags: FeatureFlags{InsidersMode: true}, + GQLClient: gqlClient, + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), } handler := serverTool.Handler(deps) @@ -2342,8 +2339,8 @@ func Test_ListIssues_IFC_InsidersMode(t *testing.T) { matcher := githubv4mock.NewQueryMatcher(query, vars, makeResponse(true)) gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) deps := BaseDeps{ - GQLClient: gqlClient, - Flags: FeatureFlags{InsidersMode: true}, + GQLClient: gqlClient, + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), } handler := serverTool.Handler(deps) diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 819b04929..c298d875a 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -611,12 +611,12 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultError(err.Error()), nil, nil } - // When insiders mode is enabled and the client supports MCP Apps UI, + // When MCP Apps are enabled and the client supports UI, // check if this is a UI form submission. The UI sends _ui_submitted=true // to distinguish form submissions from LLM calls. uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") - if deps.GetFlags(ctx).InsidersMode && clientSupportsUI(ctx, req) && !uiSubmitted { + if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted { return utils.NewToolResultText(fmt.Sprintf("Ready to create a pull request in %s/%s. IMPORTANT: The PR has NOT been created yet. Do NOT tell the user the PR was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil } diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index a73ba2e17..097651b66 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -2393,9 +2393,9 @@ func Test_CreatePullRequest(t *testing.T) { } } -// Test_CreatePullRequest_InsidersMode_UIGate verifies the insiders mode UI gate +// Test_CreatePullRequest_MCPAppsFeature_UIGate verifies the MCP Apps feature UI gate // behavior: UI clients get a form message, non-UI clients execute directly. -func Test_CreatePullRequest_InsidersMode_UIGate(t *testing.T) { +func Test_CreatePullRequest_MCPAppsFeature_UIGate(t *testing.T) { t.Parallel() mockPR := &github.PullRequest{ @@ -2414,9 +2414,9 @@ func Test_CreatePullRequest_InsidersMode_UIGate(t *testing.T) { })) deps := BaseDeps{ - Client: client, - GQLClient: githubv4.NewClient(nil), - Flags: FeatureFlags{InsidersMode: true}, + Client: client, + GQLClient: githubv4.NewClient(nil), + featureChecker: featureCheckerFor(MCPAppsFeatureFlag), } handler := serverTool.Handler(deps) diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 2ca1cf3a7..d682b5c3d 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -741,7 +741,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool } // attachIFC adds the IFC label to a successful tool result when - // InsidersMode is enabled. The visibility lookup is performed + // IFC labels are enabled. The visibility lookup is performed // lazily on first use and cached because GetFileContents has // many possible return paths and would otherwise re-fetch on // each. If the visibility lookup fails we skip the label rather @@ -752,7 +752,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool ifcIsPrivate bool ) attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult { - if r == nil || r.IsError || !deps.GetFlags(ctx).InsidersMode { + if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { return r } if !ifcLabelKnown { diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index a44bad65b..03535f1d2 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -521,7 +521,6 @@ func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode disabled omits ifc label from result meta", func(t *testing.T) { deps := BaseDeps{ Client: mustNewGHClient(t, makeMockClient(false)), - Flags: FeatureFlags{InsidersMode: false}, } handler := serverTool.Handler(deps) @@ -535,8 +534,8 @@ func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode enabled on public repo emits public untrusted label", func(t *testing.T) { deps := BaseDeps{ - Client: mustNewGHClient(t, makeMockClient(false)), - Flags: FeatureFlags{InsidersMode: true}, + Client: mustNewGHClient(t, makeMockClient(false)), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), } handler := serverTool.Handler(deps) @@ -560,8 +559,8 @@ func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode enabled on private repo emits private trusted label", func(t *testing.T) { deps := BaseDeps{ - Client: mustNewGHClient(t, makeMockClient(true)), - Flags: FeatureFlags{InsidersMode: true}, + Client: mustNewGHClient(t, makeMockClient(true)), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), } handler := serverTool.Handler(deps) @@ -604,8 +603,8 @@ func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { }, }) deps := BaseDeps{ - Client: mustNewGHClient(t, mockedClient), - Flags: FeatureFlags{InsidersMode: true}, + Client: mustNewGHClient(t, mockedClient), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), } handler := serverTool.Handler(deps) diff --git a/pkg/github/search.go b/pkg/github/search.go index 9d50a6310..9a8d18288 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -163,7 +163,7 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo } callResult := utils.NewToolResultText(string(r)) - if deps.GetFlags(ctx).InsidersMode { + if deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { attachSearchRepositoriesIFCLabel(result.Repositories, callResult) } return callResult, nil, nil diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index f1acec3e2..fa48bf19a 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -207,7 +207,6 @@ func Test_SearchRepositories_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode disabled omits ifc label", func(t *testing.T) { deps := BaseDeps{ Client: mustNewGHClient(t, makeMockClient([]repoFixture{{owner: "octocat", name: "public-repo"}})), - Flags: FeatureFlags{InsidersMode: false}, } handler := serverTool.Handler(deps) @@ -224,7 +223,7 @@ func Test_SearchRepositories_IFC_InsidersMode(t *testing.T) { {owner: "octocat", name: "public-a"}, {owner: "octocat", name: "public-b"}, })), - Flags: FeatureFlags{InsidersMode: true}, + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), } handler := serverTool.Handler(deps) @@ -245,7 +244,7 @@ func Test_SearchRepositories_IFC_InsidersMode(t *testing.T) { {owner: "octocat", name: "private-repo", isPrivate: true}, {owner: "octocat", name: "public-repo"}, })), - Flags: FeatureFlags{InsidersMode: true}, + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), } handler := serverTool.Handler(deps) @@ -262,8 +261,8 @@ func Test_SearchRepositories_IFC_InsidersMode(t *testing.T) { t.Run("insiders mode empty results emits public untrusted", func(t *testing.T) { deps := BaseDeps{ - Client: mustNewGHClient(t, makeMockClient(nil)), - Flags: FeatureFlags{InsidersMode: true}, + Client: mustNewGHClient(t, makeMockClient(nil)), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), } handler := serverTool.Handler(deps) diff --git a/pkg/github/server.go b/pkg/github/server.go index 41e502db3..9df7c59b6 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -50,7 +50,7 @@ type MCPServerConfig struct { // LockdownMode indicates if we should enable lockdown mode LockdownMode bool - // InsidersMode indicates if we should enable experimental features + // InsidersMode expands to the curated set of feature flags enabled for insiders. InsidersMode bool // Logger is used for logging within the server diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go index be37ca949..7f909f431 100644 --- a/pkg/github/server_test.go +++ b/pkg/github/server_test.go @@ -130,7 +130,6 @@ func mockRESTPermissionServer(t *testing.T, defaultPerm string, overrides map[st func stubFeatureFlags(enabledFlags map[string]bool) FeatureFlags { return FeatureFlags{ LockdownMode: enabledFlags["lockdown-mode"], - InsidersMode: enabledFlags["insiders-mode"], } } @@ -164,7 +163,6 @@ func TestNewMCPServer_CreatesSuccessfully(t *testing.T) { Translator: translations.NullTranslationHelper, ContentWindowSize: 5000, LockdownMode: false, - InsidersMode: false, } deps := stubDeps{obsv: stubExporters()} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index af59b74a5..70dfab8d9 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -167,7 +167,7 @@ var ( // AllTools returns all tools with their embedded toolset metadata. // Tool functions return ServerTool directly with toolset info. func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { - return []inventory.ServerTool{ + return withCSVOutput([]inventory.ServerTool{ // Context tools GetMe(t), GetTeams(t), @@ -314,7 +314,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { GranularAddPullRequestReviewComment(t), GranularResolveReviewThread(t), GranularUnresolveReviewThread(t), - } + }) } // ToBoolPtr converts a bool to a *bool pointer. diff --git a/pkg/github/tools_validation_test.go b/pkg/github/tools_validation_test.go index 90e3c744c..0a4a4eb7b 100644 --- a/pkg/github/tools_validation_test.go +++ b/pkg/github/tools_validation_test.go @@ -1,6 +1,11 @@ package github import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "strings" "testing" "github.com/github/github-mcp-server/pkg/inventory" @@ -184,3 +189,29 @@ func TestToolsetMetadataConsistency(t *testing.T) { } } } + +func TestGitHubPackageDoesNotReadInsidersMode(t *testing.T) { + files, err := filepath.Glob("*.go") + require.NoError(t, err) + + for _, file := range files { + if strings.HasSuffix(file, "_test.go") { + continue + } + + fset := token.NewFileSet() + node, err := parser.ParseFile(fset, file, nil, 0) + require.NoError(t, err, "failed to parse %s", file) + + ast.Inspect(node, func(n ast.Node) bool { + selector, ok := n.(*ast.SelectorExpr) + if !ok || selector.Sel.Name != "InsidersMode" { + return true + } + + position := fset.Position(selector.Sel.Pos()) + t.Errorf("%s reads InsidersMode directly; gate behavior on concrete feature flags instead", position) + return true + }) + } +} diff --git a/pkg/github/ui_embed.go b/pkg/github/ui_embed.go index 257856e15..c3f1cef9d 100644 --- a/pkg/github/ui_embed.go +++ b/pkg/github/ui_embed.go @@ -34,7 +34,7 @@ func MustGetUIAsset(name string) string { // UIAssetsAvailable returns true if the MCP App UI assets have been built. // This checks for a known UI asset file to determine if `script/build-ui` has been run. // Use this to gracefully skip UI registration when assets aren't available, -// allowing Insiders mode to work for non-UI features without requiring a UI build. +// allowing non-UI features to work without requiring a UI build. func UIAssetsAvailable() bool { _, err := GetUIAsset("get-me.html") return err == nil diff --git a/pkg/http/handler.go b/pkg/http/handler.go index 90423d93c..e585a8656 100644 --- a/pkg/http/handler.go +++ b/pkg/http/handler.go @@ -249,7 +249,7 @@ func DefaultGitHubMCPServerFactory(r *http.Request, deps github.ToolDependencies func DefaultInventoryFactory(cfg *ServerConfig, t translations.TranslationHelperFunc, featureChecker inventory.FeatureFlagChecker, scopeFetcher scopes.FetcherInterface) InventoryFactoryFunc { // Build the static tool/resource/prompt universe from CLI flags. // This is done once at startup and captured in the closure. - staticTools, staticResources, staticPrompts := buildStaticInventory(cfg, t, featureChecker) + staticTools, staticResources, staticPrompts := buildStaticInventory(cfg, t) hasStaticFilters := hasStaticConfig(cfg) // Pre-compute valid tool names for filtering per-request tool headers. @@ -321,20 +321,23 @@ func hasStaticConfig(cfg *ServerConfig) bool { return cfg.ReadOnly || cfg.EnabledToolsets != nil || cfg.EnabledTools != nil || - len(cfg.ExcludeTools) > 0 || - cfg.InsidersMode + len(cfg.ExcludeTools) > 0 } // buildStaticInventory pre-filters the full tool/resource/prompt universe using -// the static CLI flags (--toolsets, --read-only, --exclude-tools, etc.). -// The returned slices serve as the upper bound for per-request inventory builders. -func buildStaticInventory(cfg *ServerConfig, t translations.TranslationHelperFunc, featureChecker inventory.FeatureFlagChecker) ([]inventory.ServerTool, []inventory.ServerResourceTemplate, []inventory.ServerPrompt) { +// the static config (toolsets, read-only, --tools, --exclude-tools). It does +// NOT install a feature checker: HTTP feature flags can come from per-request +// context (/insiders, X-MCP-Features), so dual-name feature variants — for +// example the granular issues/PRs tools that share a name with their +// non-granular siblings — must be carried through to the per-request +// inventory, which then installs a checker and resolves the flag before +// registering tools with the MCP server. +func buildStaticInventory(cfg *ServerConfig, t translations.TranslationHelperFunc) ([]inventory.ServerTool, []inventory.ServerResourceTemplate, []inventory.ServerPrompt) { if !hasStaticConfig(cfg) { return github.AllTools(t), github.AllResources(t), github.AllPrompts(t) } b := github.NewInventory(t). - WithFeatureChecker(featureChecker). WithReadOnly(cfg.ReadOnly). WithToolsets(github.ResolvedEnabledToolsets(cfg.EnabledToolsets, cfg.EnabledTools)) diff --git a/pkg/http/handler_test.go b/pkg/http/handler_test.go index fd2966fd0..74e28a6e4 100644 --- a/pkg/http/handler_test.go +++ b/pkg/http/handler_test.go @@ -554,7 +554,7 @@ func TestStaticConfigEnforcement(t *testing.T) { require.NoError(t, err) // Build static tools the same way the production code does - staticTools, staticResources, staticPrompts := buildStaticInventoryFromTools(tt.config, tools, featureChecker) + staticTools, staticResources, staticPrompts := buildStaticInventoryFromTools(tt.config, tools) hasStatic := hasStaticConfig(tt.config) validToolNames := make(map[string]bool, len(staticTools)) @@ -632,6 +632,31 @@ func TestStaticConfigEnforcement(t *testing.T) { } } +func TestStaticInventoryPreservesPerRequestFeatureVariants(t *testing.T) { + tools := []inventory.ServerTool{ + mockToolWithFeatureFlag("list_issues", "issues", true, "", github.FeatureFlagCSVOutput), + mockToolWithFeatureFlag("list_issues", "issues", true, github.FeatureFlagCSVOutput, ""), + } + cfg := &ServerConfig{Version: "test", EnabledToolsets: []string{"issues"}} + featureChecker := createHTTPFeatureChecker(nil, false) + + staticTools, _, _ := buildStaticInventoryFromTools(cfg, tools) + require.Len(t, staticTools, 2, "static upper bounds should preserve both feature variants") + + inv, err := inventory.NewBuilder(). + SetTools(staticTools). + WithFeatureChecker(featureChecker). + WithToolsets([]string{"all"}). + Build() + require.NoError(t, err) + + ctx := ghcontext.WithInsidersMode(context.Background(), true) + available := inv.AvailableTools(ctx) + require.Len(t, available, 1) + assert.Equal(t, "list_issues", available[0].Tool.Name) + assert.Equal(t, github.FeatureFlagCSVOutput, available[0].FeatureFlagEnable) +} + // TestContentTypeHandling verifies that the MCP StreamableHTTP handler // accepts Content-Type values with additional parameters like charset=utf-8. // This is a regression test for https://github.com/github/github-mcp-server/issues/2333 @@ -729,14 +754,13 @@ func TestContentTypeHandling(t *testing.T) { // buildStaticInventoryFromTools is a test helper that mirrors buildStaticInventory // but uses the provided mock tools instead of calling github.AllTools. -func buildStaticInventoryFromTools(cfg *ServerConfig, tools []inventory.ServerTool, featureChecker inventory.FeatureFlagChecker) ([]inventory.ServerTool, []inventory.ServerResourceTemplate, []inventory.ServerPrompt) { +func buildStaticInventoryFromTools(cfg *ServerConfig, tools []inventory.ServerTool) ([]inventory.ServerTool, []inventory.ServerResourceTemplate, []inventory.ServerPrompt) { if !hasStaticConfig(cfg) { return tools, nil, nil } b := inventory.NewBuilder(). SetTools(tools). - WithFeatureChecker(featureChecker). WithReadOnly(cfg.ReadOnly). WithToolsets(github.ResolvedEnabledToolsets(cfg.EnabledToolsets, cfg.EnabledTools)) @@ -847,7 +871,7 @@ func TestInsidersRoutePreservesUIMeta(t *testing.T) { uiTool := mockTool("with_ui", "repos", true) uiTool.Tool.Meta = mcp.Meta{"ui": map[string]any{"resourceUri": uiURI}} - checker := createHTTPFeatureChecker() + checker := createHTTPFeatureChecker(nil, false) build := func() *inventory.Inventory { inv, err := inventory.NewBuilder(). SetTools([]inventory.ServerTool{uiTool}). diff --git a/pkg/http/server.go b/pkg/http/server.go index b8c419ea0..6fd19a8b9 100644 --- a/pkg/http/server.go +++ b/pkg/http/server.go @@ -82,7 +82,10 @@ type ServerConfig struct { // When set via CLI flag, per-request headers cannot re-include these tools. ExcludeTools []string - // InsidersMode indicates if we should enable experimental features. + // EnabledFeatures is a list of feature flags that are enabled. + EnabledFeatures []string + + // InsidersMode expands to the curated set of feature flags enabled for insiders. InsidersMode bool } @@ -121,7 +124,7 @@ func RunHTTPServer(cfg ServerConfig) error { repoAccessOpts = append(repoAccessOpts, lockdown.WithTTL(*cfg.RepoAccessCacheTTL)) } - featureChecker := createHTTPFeatureChecker() + featureChecker := createHTTPFeatureChecker(cfg.EnabledFeatures, cfg.InsidersMode) obs, err := observability.NewExporters(logger, metrics.NewNoopMetrics()) if err != nil { @@ -228,14 +231,16 @@ func initGlobalToolScopeMap(t translations.TranslationHelperFunc) error { return nil } -// createHTTPFeatureChecker creates a feature checker that resolves features -// per-request by reading header features and insiders mode from context, -// then validating against the centralized AllowedFeatureFlags allowlist. -func createHTTPFeatureChecker() inventory.FeatureFlagChecker { +// createHTTPFeatureChecker creates a feature checker that resolves static CLI +// features plus per-request header features and insiders mode. +func createHTTPFeatureChecker(enabledFeatures []string, insidersMode bool) inventory.FeatureFlagChecker { return func(ctx context.Context, flag string) (bool, error) { headerFeatures := ghcontext.GetHeaderFeatures(ctx) - insidersMode := ghcontext.IsInsidersMode(ctx) - effective := github.ResolveFeatureFlags(headerFeatures, insidersMode) + features := make([]string, 0, len(enabledFeatures)+len(headerFeatures)) + features = append(features, enabledFeatures...) + features = append(features, headerFeatures...) + + effective := github.ResolveFeatureFlags(features, insidersMode || ghcontext.IsInsidersMode(ctx)) return effective[flag], nil } } diff --git a/pkg/http/server_test.go b/pkg/http/server_test.go index 23c82d048..5458a6b39 100644 --- a/pkg/http/server_test.go +++ b/pkg/http/server_test.go @@ -11,10 +11,10 @@ import ( ) func TestCreateHTTPFeatureChecker(t *testing.T) { - checker := createHTTPFeatureChecker() - tests := []struct { name string + staticFeatures []string + staticInsiders bool flagName string headerFeatures []string insidersMode bool @@ -74,6 +74,37 @@ func TestCreateHTTPFeatureChecker(t *testing.T) { insidersMode: true, wantEnabled: true, }, + { + name: "static feature is enabled without header", + staticFeatures: []string{github.FeatureFlagCSVOutput}, + flagName: github.FeatureFlagCSVOutput, + wantEnabled: true, + }, + { + name: "static features combine with header features", + staticFeatures: []string{github.FeatureFlagCSVOutput}, + flagName: github.FeatureFlagIssuesGranular, + headerFeatures: []string{github.FeatureFlagIssuesGranular}, + wantEnabled: true, + }, + { + name: "internal-only flag in header is ignored", + flagName: github.FeatureFlagIFCLabels, + headerFeatures: []string{github.FeatureFlagIFCLabels}, + wantEnabled: false, + }, + { + name: "static insiders enables insiders flags without route context", + staticInsiders: true, + flagName: github.FeatureFlagCSVOutput, + wantEnabled: true, + }, + { + name: "insiders mode enables internal-only insiders flags", + flagName: github.FeatureFlagIFCLabels, + insidersMode: true, + wantEnabled: true, + }, { name: "insiders mode does not enable granular flags", flagName: github.FeatureFlagIssuesGranular, @@ -84,6 +115,7 @@ func TestCreateHTTPFeatureChecker(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + checker := createHTTPFeatureChecker(tt.staticFeatures, tt.staticInsiders) ctx := context.Background() if len(tt.headerFeatures) > 0 { ctx = ghcontext.WithHeaderFeatures(ctx, tt.headerFeatures) diff --git a/pkg/inventory/builder.go b/pkg/inventory/builder.go index 2642c6127..9ecaca1f5 100644 --- a/pkg/inventory/builder.go +++ b/pkg/inventory/builder.go @@ -127,8 +127,20 @@ func (b *Builder) WithTools(toolNames []string) *Builder { // WithFeatureChecker sets the feature flag checker function. // The checker receives a context (for actor extraction) and feature flag name, -// returns (enabled, error). If error occurs, it will be logged and treated as false. -// If checker is nil, all feature flag checks return false. +// and returns (enabled, error). Errors are logged and treated as "not enabled". +// +// When the checker is non-nil, Build() installs a feature-flag ToolFilter +// at the head of the filter pipeline so that tools annotated with +// FeatureFlagEnable / FeatureFlagDisable are gated accordingly. Resources +// and prompts use the same checker via an explicit guard at their iteration +// site. +// +// When the checker is nil, no feature-flag filter is installed; tools, +// resources, and prompts pass through feature-flag gating unchanged. The +// per-request inventory in HTTP mode must always install a checker so that +// MCP registration (which can only serve a given tool name once) sees a +// deduplicated set of dual-name variants. +// // Returns self for chaining. func (b *Builder) WithFeatureChecker(checker FeatureFlagChecker) *Builder { b.featureChecker = checker @@ -200,6 +212,16 @@ func cleanTools(tools []string) []string { func (b *Builder) Build() (*Inventory, error) { tools := b.tools + // Install the feature-flag filter at the head of the pipeline so that + // flag-gated tools are excluded before any user-supplied WithFilter sees + // them. Doing this in Build() (rather than inside WithFeatureChecker) + // keeps the install idempotent — repeated WithFeatureChecker calls + // replace the checker without stacking duplicate filters. + filters := b.filters + if b.featureChecker != nil { + filters = append([]ToolFilter{createFeatureFlagFilter(b.featureChecker)}, filters...) + } + r := &Inventory{ tools: tools, resourceTemplates: b.resourceTemplates, @@ -207,7 +229,7 @@ func (b *Builder) Build() (*Inventory, error) { deprecatedAliases: b.deprecatedAliases, readOnly: b.readOnly, featureChecker: b.featureChecker, - filters: b.filters, + filters: filters, } // Process toolsets and pre-compute metadata in a single pass diff --git a/pkg/inventory/filters.go b/pkg/inventory/filters.go index 604aa1000..e2effd8ca 100644 --- a/pkg/inventory/filters.go +++ b/pkg/inventory/filters.go @@ -35,28 +35,55 @@ func (r *Inventory) checkFeatureFlag(ctx context.Context, flagName string) bool return enabled } -// isFeatureFlagAllowed checks if an item passes feature flag filtering. -// - If FeatureFlagEnable is set, the item is only allowed if the flag is enabled -// - If FeatureFlagDisable is set, the item is excluded if the flag is enabled -func (r *Inventory) isFeatureFlagAllowed(ctx context.Context, enableFlag, disableFlag string) bool { - // Check enable flag - item requires this flag to be on - if enableFlag != "" && !r.checkFeatureFlag(ctx, enableFlag) { +// featureFlagAllowed reports whether an item with the given enable/disable +// flag pair is permitted under the supplied checker. The checker must be +// non-nil — callers that don't want feature filtering should not call this at +// all (this is also the contract for createFeatureFlagFilter, which is only +// installed when WithFeatureChecker received a non-nil checker). +// +// - If FeatureFlagEnable is set, the item is only allowed if the flag is enabled. +// - If FeatureFlagDisable is set, the item is excluded if the flag is enabled. +func featureFlagAllowed(ctx context.Context, checker FeatureFlagChecker, enableFlag, disableFlag string) bool { + // Error semantics match the previous checkFeatureFlag helper: a checker + // error is logged and treated as "flag not enabled". So an enable-flag + // check on error excludes the tool, but a disable-flag check on error + // keeps it (the disable condition wasn't met). + check := func(flag string) bool { + enabled, err := checker(ctx, flag) + if err != nil { + fmt.Fprintf(os.Stderr, "Feature flag check error for %q: %v\n", flag, err) + return false + } + return enabled + } + if enableFlag != "" && !check(enableFlag) { return false } - // Check disable flag - item is excluded if this flag is on - if disableFlag != "" && r.checkFeatureFlag(ctx, disableFlag) { + if disableFlag != "" && check(disableFlag) { return false } return true } +// createFeatureFlagFilter returns a ToolFilter that gates tools on their +// FeatureFlagEnable / FeatureFlagDisable annotations using the given checker. +// Builder.Build() installs this filter exactly once when WithFeatureChecker +// has been called with a non-nil checker, so "no feature filtering" is +// expressed structurally — by the absence of the filter — rather than by a +// runtime nil check inside the filter itself. +func createFeatureFlagFilter(checker FeatureFlagChecker) ToolFilter { + return func(ctx context.Context, tool *ServerTool) (bool, error) { + return featureFlagAllowed(ctx, checker, tool.FeatureFlagEnable, tool.FeatureFlagDisable), nil + } +} + // isToolEnabled checks if a specific tool is enabled based on current filters. // Filter evaluation order: // 1. Tool.Enabled (tool self-filtering) -// 2. FeatureFlagEnable/FeatureFlagDisable -// 3. Read-only filter -// 4. Builder filters (via WithFilter) -// 5. Toolset/additional tools +// 2. Read-only filter +// 3. Builder filters (via WithFilter; the feature-flag filter, when +// installed via WithFeatureChecker, runs as part of this step) +// 4. Toolset/additional tools func (r *Inventory) isToolEnabled(ctx context.Context, tool *ServerTool) bool { // 1. Check tool's own Enabled function first if tool.Enabled != nil { @@ -69,15 +96,11 @@ func (r *Inventory) isToolEnabled(ctx context.Context, tool *ServerTool) bool { return false } } - // 2. Check feature flags - if !r.isFeatureFlagAllowed(ctx, tool.FeatureFlagEnable, tool.FeatureFlagDisable) { - return false - } - // 3. Check read-only filter (applies to all tools) + // 2. Check read-only filter (applies to all tools) if r.readOnly && !tool.IsReadOnly() { return false } - // 4. Apply builder filters + // 3. Apply builder filters (includes the feature-flag filter when set) for _, filter := range r.filters { allowed, err := filter(ctx, tool) if err != nil { @@ -88,17 +111,38 @@ func (r *Inventory) isToolEnabled(ctx context.Context, tool *ServerTool) bool { return false } } - // 5. Check if tool is in additionalTools (bypasses toolset filter) + // 4. Check if tool is in additionalTools (bypasses toolset filter) if r.additionalTools != nil && r.additionalTools[tool.Tool.Name] { return true } - // 5. Check toolset filter + // 4. Check toolset filter if !r.isToolsetEnabled(tool.Toolset.ID) { return false } return true } +// sortByToolsetThenName sorts items deterministically by their toolset ID, +// breaking ties by name. The two extractor closures keep this generic helper +// independent of the concrete inventory item shape (tools, resource templates, +// prompts). +func sortByToolsetThenName[T any](items []T, toolsetID func(T) ToolsetID, name func(T) string) { + sort.Slice(items, func(i, j int) bool { + idI, idJ := toolsetID(items[i]), toolsetID(items[j]) + if idI != idJ { + return idI < idJ + } + return name(items[i]) < name(items[j]) + }) +} + +func sortTools(tools []ServerTool) { + sortByToolsetThenName(tools, + func(t ServerTool) ToolsetID { return t.Toolset.ID }, + func(t ServerTool) string { return t.Tool.Name }, + ) +} + // AvailableTools returns the tools that pass all current filters, // sorted deterministically by toolset ID, then tool name. // The context is used for feature flag evaluation. @@ -112,16 +156,18 @@ func (r *Inventory) AvailableTools(ctx context.Context) []ServerTool { } // Sort deterministically: by toolset ID, then by tool name - sort.Slice(result, func(i, j int) bool { - if result[i].Toolset.ID != result[j].Toolset.ID { - return result[i].Toolset.ID < result[j].Toolset.ID - } - return result[i].Tool.Name < result[j].Tool.Name - }) + sortTools(result) return result } +func sortResourceTemplates(resourceTemplates []ServerResourceTemplate) { + sortByToolsetThenName(resourceTemplates, + func(r ServerResourceTemplate) ToolsetID { return r.Toolset.ID }, + func(r ServerResourceTemplate) string { return r.Template.Name }, + ) +} + // AvailableResourceTemplates returns resource templates that pass all current filters, // sorted deterministically by toolset ID, then template name. // The context is used for feature flag evaluation. @@ -129,8 +175,11 @@ func (r *Inventory) AvailableResourceTemplates(ctx context.Context) []ServerReso var result []ServerResourceTemplate for i := range r.resourceTemplates { res := &r.resourceTemplates[i] - // Check feature flags - if !r.isFeatureFlagAllowed(ctx, res.FeatureFlagEnable, res.FeatureFlagDisable) { + // Resources have no filter pipeline, so feature gating runs inline. + // The featureChecker != nil guard mirrors the structural "no checker + // = no filtering" contract used for tools (where the absence of a + // pipeline step expresses the same thing). + if r.featureChecker != nil && !featureFlagAllowed(ctx, r.featureChecker, res.FeatureFlagEnable, res.FeatureFlagDisable) { continue } if r.isToolsetEnabled(res.Toolset.ID) { @@ -139,16 +188,18 @@ func (r *Inventory) AvailableResourceTemplates(ctx context.Context) []ServerReso } // Sort deterministically: by toolset ID, then by template name - sort.Slice(result, func(i, j int) bool { - if result[i].Toolset.ID != result[j].Toolset.ID { - return result[i].Toolset.ID < result[j].Toolset.ID - } - return result[i].Template.Name < result[j].Template.Name - }) + sortResourceTemplates(result) return result } +func sortPrompts(prompts []ServerPrompt) { + sortByToolsetThenName(prompts, + func(p ServerPrompt) ToolsetID { return p.Toolset.ID }, + func(p ServerPrompt) string { return p.Prompt.Name }, + ) +} + // AvailablePrompts returns prompts that pass all current filters, // sorted deterministically by toolset ID, then prompt name. // The context is used for feature flag evaluation. @@ -156,8 +207,9 @@ func (r *Inventory) AvailablePrompts(ctx context.Context) []ServerPrompt { var result []ServerPrompt for i := range r.prompts { prompt := &r.prompts[i] - // Check feature flags - if !r.isFeatureFlagAllowed(ctx, prompt.FeatureFlagEnable, prompt.FeatureFlagDisable) { + // Prompts have no filter pipeline; see AvailableResourceTemplates for + // the rationale behind the explicit nil guard. + if r.featureChecker != nil && !featureFlagAllowed(ctx, r.featureChecker, prompt.FeatureFlagEnable, prompt.FeatureFlagDisable) { continue } if r.isToolsetEnabled(prompt.Toolset.ID) { @@ -166,12 +218,7 @@ func (r *Inventory) AvailablePrompts(ctx context.Context) []ServerPrompt { } // Sort deterministically: by toolset ID, then by prompt name - sort.Slice(result, func(i, j int) bool { - if result[i].Toolset.ID != result[j].Toolset.ID { - return result[i].Toolset.ID < result[j].Toolset.ID - } - return result[i].Prompt.Name < result[j].Prompt.Name - }) + sortPrompts(result) return result } diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go index 8e35861f1..75de9c574 100644 --- a/pkg/inventory/registry_test.go +++ b/pkg/inventory/registry_test.go @@ -1057,23 +1057,23 @@ func TestFeatureFlagEnable(t *testing.T) { mockToolWithFlags("needs_flag", "toolset1", true, "my_feature", ""), } - // Without feature checker, tool with FeatureFlagEnable should be excluded + // Without feature checker, feature-flag filtering is skipped: both tools pass reg := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"})) available := reg.AvailableTools(context.Background()) - if len(available) != 1 { - t.Fatalf("Expected 1 tool without feature checker, got %d", len(available)) - } - if available[0].Tool.Name != "always_available" { - t.Errorf("Expected always_available, got %s", available[0].Tool.Name) + if len(available) != 2 { + t.Fatalf("Expected 2 tools without feature checker (filtering skipped), got %d", len(available)) } - // With feature checker returning false, tool should still be excluded + // With feature checker returning false, FeatureFlagEnable tool is excluded checkerFalse := func(_ context.Context, _ string) (bool, error) { return false, nil } regFalse := mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).WithFeatureChecker(checkerFalse)) availableFalse := regFalse.AvailableTools(context.Background()) if len(availableFalse) != 1 { t.Fatalf("Expected 1 tool with false checker, got %d", len(availableFalse)) } + if availableFalse[0].Tool.Name != "always_available" { + t.Errorf("Expected always_available, got %s", availableFalse[0].Tool.Name) + } // With feature checker returning true for "my_feature", tool should be included checkerTrue := func(_ context.Context, flag string) (bool, error) { @@ -1167,11 +1167,11 @@ func TestFeatureFlagResources(t *testing.T) { }, } - // Without checker, resource with enable flag should be excluded + // Without checker, feature-flag filtering is skipped: both resources pass reg := mustBuild(t, NewBuilder().SetResources(resources).WithToolsets([]string{"all"})) available := reg.AvailableResourceTemplates(context.Background()) - if len(available) != 1 { - t.Fatalf("Expected 1 resource without checker, got %d", len(available)) + if len(available) != 2 { + t.Fatalf("Expected 2 resources without checker (filtering skipped), got %d", len(available)) } // With checker returning true, both should be included @@ -1192,11 +1192,11 @@ func TestFeatureFlagPrompts(t *testing.T) { }, } - // Without checker, prompt with enable flag should be excluded + // Without checker, feature-flag filtering is skipped: both prompts pass reg := mustBuild(t, NewBuilder().SetPrompts(prompts).WithToolsets([]string{"all"})) available := reg.AvailablePrompts(context.Background()) - if len(available) != 1 { - t.Fatalf("Expected 1 prompt without checker, got %d", len(available)) + if len(available) != 2 { + t.Fatalf("Expected 2 prompts without checker (filtering skipped), got %d", len(available)) } // With checker returning true, both should be included @@ -1482,9 +1482,11 @@ func TestEnabledAndFeatureFlagInteraction(t *testing.T) { } // Feature flag not enabled - tool should be excluded despite Enabled returning true + checkerOff := func(_ context.Context, _ string) (bool, error) { return false, nil } reg1 := mustBuild(t, NewBuilder(). SetTools([]ServerTool{tool}). - WithToolsets([]string{"all"})) + WithToolsets([]string{"all"}). + WithFeatureChecker(checkerOff)) available1 := reg1.AvailableTools(context.Background()) if len(available1) != 0 { t.Error("Tool should be excluded when feature flag is not enabled") @@ -1650,10 +1652,10 @@ func TestFilteredToolsMatchesAvailableTools(t *testing.T) { func TestFilteringOrder(t *testing.T) { // Test that filters are applied in the correct order: // 1. Tool.Enabled - // 2. Feature flags - // 3. Read-only - // 4. Builder filters - // 5. Toolset/additional tools + // 2. Read-only + // 3. Builder filters (feature-flag filter is at the head of this list + // when WithFeatureChecker is set) + // 4. Toolset/additional tools callOrder := []string{} @@ -1686,8 +1688,9 @@ func TestFilteringOrder(t *testing.T) { _ = reg.AvailableTools(context.Background()) - // Expected order: Enabled, FeatureFlag, ReadOnly (stops here because it's write tool) - expectedOrder := []string{"Enabled", "FeatureFlag"} + // Expected order: Enabled, then Read-only stops (write tool, read-only mode); + // neither the feature-flag filter nor the user filter is reached. + expectedOrder := []string{"Enabled"} if len(callOrder) != len(expectedOrder) { t.Errorf("Expected %d checks, got %d: %v", len(expectedOrder), len(callOrder), callOrder) } @@ -1710,9 +1713,11 @@ func TestForMCPRequest_ToolsCall_FeatureFlaggedVariants(t *testing.T) { } // Test 1: Flag is OFF - first tool variant should be available + checkerOff := func(_ context.Context, _ string) (bool, error) { return false, nil } regFlagOff := mustBuild(t, NewBuilder(). SetTools(tools). - WithToolsets([]string{"all"})) + WithToolsets([]string{"all"}). + WithFeatureChecker(checkerOff)) filteredOff := regFlagOff.ForMCPRequest(MCPMethodToolsCall, "get_job_logs") availableOff := filteredOff.AvailableTools(context.Background()) if len(availableOff) != 1 { @@ -1762,11 +1767,13 @@ func TestWithTools_DeprecatedAliasAndFeatureFlag(t *testing.T) { // Test 1: Flag OFF - old_tool should be available via direct name match // (not via alias resolution to new_tool, since old_tool still exists) + checkerOff := func(_ context.Context, _ string) (bool, error) { return false, nil } regFlagOff := mustBuild(t, NewBuilder(). SetTools(tools). WithDeprecatedAliases(deprecatedAliases). WithToolsets([]string{}). // No toolsets enabled - WithTools([]string{"old_tool"})) // Explicitly request old tool + WithTools([]string{"old_tool"}). // Explicitly request old tool + WithFeatureChecker(checkerOff)) availableOff := regFlagOff.AvailableTools(context.Background()) if len(availableOff) != 1 { t.Fatalf("Flag OFF: Expected 1 tool, got %d", len(availableOff)) diff --git a/script/print-mcp-diff-configs/main.go b/script/print-mcp-diff-configs/main.go new file mode 100644 index 000000000..421c9fce4 --- /dev/null +++ b/script/print-mcp-diff-configs/main.go @@ -0,0 +1,217 @@ +// Command print-mcp-diff-configs emits the configuration matrix consumed by +// the mcp-server-diff GitHub Action. The matrix is composed of three parts: +// +// 1. Hand-curated baseline configs (default, read-only, common toolset combos) +// 2. Insiders configs (--insiders, --insiders --read-only) — meta flag that +// expands to the curated insiders feature set +// 3. One config per entry in github.AllowedFeatureFlags — automatically kept +// in sync with the Go source so any new user-controllable feature flag +// gets diffed without touching the workflow +// +// The same logical matrix is rendered for two transports, selected by +// -transport: +// +// stdio Default. Args are appended to the action's top-level +// +// start_command (one stdio process per config). +// +// http-headers streamable-http transport against a shared HTTP server. The +// +// server is started once with no extra flags and every config +// provides its settings via X-MCP-* request headers, mirroring +// how the remote server is invoked in production (server-side +// defaults + per-user header overrides). +// +// Usage: +// +// go run ./script/print-mcp-diff-configs +// go run ./script/print-mcp-diff-configs -transport http-headers +package main + +import ( + "encoding/json" + "flag" + "fmt" + "os" + "sort" + "strings" + + "github.com/github/github-mcp-server/pkg/github" + mcphdr "github.com/github/github-mcp-server/pkg/http/headers" +) + +type config struct { + Name string `json:"name"` + Args string `json:"args,omitempty"` + Transport string `json:"transport,omitempty"` + ServerURL string `json:"server_url,omitempty"` + Headers map[string]string `json:"headers,omitempty"` +} + +// baseEntry describes one logical configuration in transport-agnostic form. +// settings are translated to either CLI flags or X-MCP-* headers depending on +// the target transport. +type baseEntry struct { + name string + settings settings +} + +type settings struct { + toolsets string // comma-separated, "" for defaults + tools string + excludeTools string + features string + readOnly bool + insiders bool + lockdown bool +} + +const httpServerURL = "http://localhost:8082/mcp" + +func main() { + transport := flag.String("transport", "stdio", "Transport to target: stdio or http-headers") + flag.Parse() + + entries := baseEntries() + + var out []config + switch *transport { + case "stdio": + for _, e := range entries { + out = append(out, config{Name: e.name, Args: e.settings.toArgs()}) + } + case "http-headers": + for _, e := range entries { + h := e.settings.toHeaders() + if h == nil { + h = map[string]string{} + } + // The action's top-level headers may be replaced (not merged) by + // per-config headers, so always include the bearer token here. + // The token must match a recognized GitHub prefix so the server's + // Authorization parser accepts it without contacting the API. + h[mcphdr.AuthorizationHeader] = "Bearer ghp_test" + out = append(out, config{ + Name: e.name, + Transport: "streamable-http", + ServerURL: httpServerURL, + Headers: h, + }) + } + default: + fmt.Fprintf(os.Stderr, "unknown transport %q (want stdio or http-headers)\n", *transport) + os.Exit(2) + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(out); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func baseEntries() []baseEntry { + entries := []baseEntry{ + {name: "default"}, + {name: "read-only", settings: settings{readOnly: true}}, + {name: "toolsets-repos", settings: settings{toolsets: "repos"}}, + {name: "toolsets-issues", settings: settings{toolsets: "issues"}}, + {name: "toolsets-context", settings: settings{toolsets: "context"}}, + {name: "toolsets-pull_requests", settings: settings{toolsets: "pull_requests"}}, + {name: "toolsets-repos,issues", settings: settings{toolsets: "repos,issues"}}, + {name: "toolsets-issues,context", settings: settings{toolsets: "issues,context"}}, + {name: "toolsets-all", settings: settings{toolsets: "all"}}, + {name: "tools-get_me", settings: settings{tools: "get_me"}}, + {name: "tools-get_me,list_issues", settings: settings{tools: "get_me,list_issues"}}, + {name: "toolsets-repos+read-only", settings: settings{toolsets: "repos", readOnly: true}}, + {name: "insiders", settings: settings{insiders: true}}, + {name: "insiders+read-only", settings: settings{insiders: true, readOnly: true}}, + // Combined entries: exercise multiple settings together so we catch + // regressions when several X-MCP-* headers (or CLI flags) are merged. + {name: "combined-toolsets+exclude+readonly", settings: settings{ + toolsets: "repos,issues", + excludeTools: "delete_file", + readOnly: true, + }}, + {name: "combined-insiders+toolsets+features", settings: settings{ + insiders: true, + toolsets: "repos", + features: firstFeatureFlag(), + }}, + } + + flags := append([]string(nil), github.AllowedFeatureFlags...) + sort.Strings(flags) + for _, f := range flags { + entries = append(entries, baseEntry{ + name: "feature-" + f, + settings: settings{features: f}, + }) + } + return entries +} + +func (s settings) toArgs() string { + var parts []string + if s.toolsets != "" { + parts = append(parts, "--toolsets="+s.toolsets) + } + if s.tools != "" { + parts = append(parts, "--tools="+s.tools) + } + if s.excludeTools != "" { + parts = append(parts, "--exclude-tools="+s.excludeTools) + } + if s.features != "" { + parts = append(parts, "--features="+s.features) + } + if s.readOnly { + parts = append(parts, "--read-only") + } + if s.insiders { + parts = append(parts, "--insiders") + } + if s.lockdown { + parts = append(parts, "--lockdown-mode") + } + return strings.Join(parts, " ") +} + +func (s settings) toHeaders() map[string]string { + h := map[string]string{} + if s.toolsets != "" { + h[mcphdr.MCPToolsetsHeader] = s.toolsets + } + if s.tools != "" { + h[mcphdr.MCPToolsHeader] = s.tools + } + if s.excludeTools != "" { + h[mcphdr.MCPExcludeToolsHeader] = s.excludeTools + } + if s.features != "" { + h[mcphdr.MCPFeaturesHeader] = s.features + } + if s.readOnly { + h[mcphdr.MCPReadOnlyHeader] = "true" + } + if s.insiders { + h[mcphdr.MCPInsidersHeader] = "true" + } + if s.lockdown { + h[mcphdr.MCPLockdownHeader] = "true" + } + if len(h) == 0 { + return nil + } + return h +} + +func firstFeatureFlag() string { + flags := append([]string(nil), github.AllowedFeatureFlags...) + if len(flags) == 0 { + return "" + } + sort.Strings(flags) + return flags[0] +} From f5e26a85407cd7995606086bd3ae641261756662 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 21 May 2026 23:01:19 +0200 Subject: [PATCH 42/48] feat(issues): gate issue-fields features behind remote_mcp_issue_fields flag (#2520) * feat(issues): gate issue-fields features behind remote_mcp_issue_fields flag Gates the recently merged issue-fields work (list_issue_fields tool, field_values enrichment on list_issues/search_issues, and field_filters input on list_issues) behind a new feature flag, also enabled in insiders mode. - list_issues splits into two same-named registrations: the field-aware variant requires the flag, while LegacyListIssues (FeatureFlagDisable) preserves the prior schema and GraphQL selection set so disabled callers don't pay the extra wire/server cost. - search_issues skips the field-values lookup when the flag is off. - list_issue_fields requires the flag to be registered at all. - Adopts _ff_.snap naming for flagged toolsnap variants so same-named duplicates each get a distinct snapshot. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: address PR review on issue-fields gating - docs generator: install a no-flags feature checker so README reflects the default user experience (tools enabled with no special flags), fixing duplicate `list_issues` and removing granular/flagged-only tools that were never meant to appear in the default docs. - csv_output: drop the FeatureFlagEnable/Disable exclusion in isCSVOutputTool. Wrapping happens before the per-request flag filter picks the live variant, so flag-gated list_* tools wrap safely; this restores CSV conversion for `list_issues` and enables it for `list_issue_fields` when both flags are on. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 175 --------- cmd/github-mcp-server/generate_docs.go | 17 +- pkg/github/__toolsnaps__/list_issues.snap | 21 -- ...ist_issues_ff_remote_mcp_issue_fields.snap | 92 +++++ pkg/github/csv_output.go | 10 +- pkg/github/csv_output_test.go | 20 ++ pkg/github/feature_flags.go | 7 + pkg/github/issue_fields.go | 5 +- pkg/github/issues.go | 332 +++++++++++++++++- pkg/github/issues_test.go | 97 ++++- pkg/github/minimal_types.go | 45 +++ pkg/github/tools.go | 1 + 12 files changed, 613 insertions(+), 209 deletions(-) create mode 100644 pkg/github/__toolsnaps__/list_issues_ff_remote_mcp_issue_fields.snap diff --git a/README.md b/README.md index 6d2964965..b387b61f1 100644 --- a/README.md +++ b/README.md @@ -829,21 +829,6 @@ The following sets of tools are available: - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) -- **add_sub_issue** - Add Sub-Issue - - **Required OAuth Scopes**: `repo` - - `issue_number`: The parent issue number (number, required) - - `owner`: Repository owner (username or organization) (string, required) - - `replace_parent`: If true, reparent the sub-issue if it already has a parent (boolean, optional) - - `repo`: Repository name (string, required) - - `sub_issue_id`: The ID of the sub-issue to add. ID is not the same as issue number (number, required) - -- **create_issue** - Create Issue - - **Required OAuth Scopes**: `repo` - - `body`: Issue body content (optional) (string, optional) - - `owner`: Repository owner (username or organization) (string, required) - - `repo`: Repository name (string, required) - - `title`: Issue title (string, required) - - **get_label** - Get a specific label from a repository - **Required OAuth Scopes**: `repo` - `name`: Label name. (string, required) @@ -885,12 +870,6 @@ The following sets of tools are available: - `title`: Issue title (string, optional) - `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional) -- **list_issue_fields** - List issue fields - - **Required OAuth Scopes**: `repo`, `read:org` - - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org` - - `owner`: The account owner of the repository or organization. The name is not case sensitive. (string, required) - - `repo`: The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly. (string, optional) - - **list_issue_types** - List available issue types - **Required OAuth Scopes**: `read:org` - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `write:org` @@ -900,7 +879,6 @@ The following sets of tools are available: - **Required OAuth Scopes**: `repo` - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) - `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) - `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) @@ -909,22 +887,6 @@ The following sets of tools are available: - `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) -- **remove_sub_issue** - Remove Sub-Issue - - **Required OAuth Scopes**: `repo` - - `issue_number`: The parent issue number (number, required) - - `owner`: Repository owner (username or organization) (string, required) - - `repo`: Repository name (string, required) - - `sub_issue_id`: The ID of the sub-issue to remove. ID is not the same as issue number (number, required) - -- **reprioritize_sub_issue** - Reprioritize Sub-Issue - - **Required OAuth Scopes**: `repo` - - `after_id`: The ID of the sub-issue to place this after (either after_id OR before_id should be specified) (number, optional) - - `before_id`: The ID of the sub-issue to place this before (either after_id OR before_id should be specified) (number, optional) - - `issue_number`: The parent issue number (number, required) - - `owner`: Repository owner (username or organization) (string, required) - - `repo`: Repository name (string, required) - - `sub_issue_id`: The ID of the sub-issue to reorder. ID is not the same as issue number (number, required) - - **search_issues** - Search issues - **Required OAuth Scopes**: `repo` - `order`: Sort order (string, optional) @@ -935,13 +897,6 @@ The following sets of tools are available: - `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) -- **set_issue_fields** - Set Issue Fields - - **Required OAuth Scopes**: `repo` - - `fields`: Array of issue field values to set. Each element must have a 'field_id' (string, the GraphQL node ID of the field) and exactly one value field: 'text_value' for text fields, 'number_value' for number fields, 'date_value' (ISO 8601 date string) for date fields, or 'single_select_option_id' (the GraphQL node ID of the option) for single select fields. Set 'delete' to true to remove a field value. (object[], required) - - `issue_number`: The issue number to update (number, required) - - `owner`: Repository owner (username or organization) (string, required) - - `repo`: Repository name (string, required) - - **sub_issue_write** - Change sub-issue - **Required OAuth Scopes**: `repo` - `after_id`: The ID of the sub-issue to be prioritized after (either after_id OR before_id should be specified) (number, optional) @@ -958,57 +913,6 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - `sub_issue_id`: The ID of the sub-issue to add. ID is not the same as issue number (number, required) -- **update_issue_assignees** - Update Issue Assignees - - **Required OAuth Scopes**: `repo` - - `assignees`: GitHub usernames to assign to this issue (string[], required) - - `issue_number`: The issue number to update (number, required) - - `owner`: Repository owner (username or organization) (string, required) - - `repo`: Repository name (string, required) - -- **update_issue_body** - Update Issue Body - - **Required OAuth Scopes**: `repo` - - `body`: The new body content for the issue (string, required) - - `issue_number`: The issue number to update (number, required) - - `owner`: Repository owner (username or organization) (string, required) - - `repo`: Repository name (string, required) - -- **update_issue_labels** - Update Issue Labels - - **Required OAuth Scopes**: `repo` - - `issue_number`: The issue number to update (number, required) - - `labels`: Labels to apply to this issue. ([], required) - - `owner`: Repository owner (username or organization) (string, required) - - `repo`: Repository name (string, required) - -- **update_issue_milestone** - Update Issue Milestone - - **Required OAuth Scopes**: `repo` - - `issue_number`: The issue number to update (number, required) - - `milestone`: The milestone number to set on the issue (integer, required) - - `owner`: Repository owner (username or organization) (string, required) - - `repo`: Repository name (string, required) - -- **update_issue_state** - Update Issue State - - **Required OAuth Scopes**: `repo` - - `issue_number`: The issue number to update (number, required) - - `owner`: Repository owner (username or organization) (string, required) - - `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) - -- **update_issue_title** - Update Issue Title - - **Required OAuth Scopes**: `repo` - - `issue_number`: The issue number to update (number, required) - - `owner`: Repository owner (username or organization) (string, required) - - `repo`: Repository name (string, required) - - `title`: The new title for the issue (string, required) - -- **update_issue_type** - Update Issue Type - - **Required OAuth Scopes**: `repo` - - `issue_number`: The issue number to update (number, required) - - `issue_type`: The issue type to set (string, required) - - `owner`: Repository owner (username or organization) (string, required) - - `rationale`: One concise sentence explaining what specifically about the issue led you to choose this type. State the concrete signal (e.g. 'Reports a crash when saving' → bug, 'Asks for dark mode support' → feature). (string, optional) - - `repo`: Repository name (string, required) -
@@ -1161,19 +1065,6 @@ The following sets of tools are available: - `startSide`: For multi-line comments, the starting side of the diff that the comment applies to. LEFT indicates the previous state, RIGHT indicates the new state (string, optional) - `subjectType`: The level at which the comment is targeted (string, required) -- **add_pull_request_review_comment** - Add Pull Request Review Comment - - **Required OAuth Scopes**: `repo` - - `body`: The comment body (string, required) - - `line`: The line number in the diff to comment on (optional) (number, optional) - - `owner`: Repository owner (username or organization) (string, required) - - `path`: The relative path of the file to comment on (string, required) - - `pullNumber`: The pull request number (number, required) - - `repo`: Repository name (string, required) - - `side`: The side of the diff to comment on (optional) (string, optional) - - `startLine`: The start line of a multi-line comment (optional) (number, optional) - - `startSide`: The start side of a multi-line comment (optional) (string, optional) - - `subjectType`: The subject type of the comment (string, required) - - **add_reply_to_pull_request_comment** - Add reply to pull request comment - **Required OAuth Scopes**: `repo` - `body`: The text of the reply (string, required) @@ -1193,21 +1084,6 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - `title`: PR title (string, required) -- **create_pull_request_review** - Create Pull Request Review - - **Required OAuth Scopes**: `repo` - - `body`: The review body text (optional) (string, optional) - - `commitID`: The SHA of the commit to review (optional, defaults to latest) (string, optional) - - `event`: The review action to perform. If omitted, creates a pending review. (string, optional) - - `owner`: Repository owner (username or organization) (string, required) - - `pullNumber`: The pull request number (number, required) - - `repo`: Repository name (string, required) - -- **delete_pending_pull_request_review** - Delete Pending Pull Request Review - - **Required OAuth Scopes**: `repo` - - `owner`: Repository owner (username or organization) (string, required) - - `pullNumber`: The pull request number (number, required) - - `repo`: Repository name (string, required) - - **list_pull_requests** - List pull requests - **Required OAuth Scopes**: `repo` - `base`: Filter by base branch (string, optional) @@ -1260,17 +1136,6 @@ The following sets of tools are available: - `repo`: Repository name (string, required) - `threadId`: The node ID of the review thread (e.g., PRRT_kwDOxxx). Required for resolve_thread and unresolve_thread methods. Get thread IDs from pull_request_read with method get_review_comments. (string, optional) -- **request_pull_request_reviewers** - Request Pull Request Reviewers - - **Required OAuth Scopes**: `repo` - - `owner`: Repository owner (username or organization) (string, required) - - `pullNumber`: The pull request number (number, required) - - `repo`: Repository name (string, required) - - `reviewers`: GitHub usernames to request reviews from (string[], required) - -- **resolve_review_thread** - Resolve Review Thread - - **Required OAuth Scopes**: `repo` - - `threadID`: The node ID of the review thread to resolve (e.g., PRRT_kwDOxxx) (string, required) - - **search_pull_requests** - Search pull requests - **Required OAuth Scopes**: `repo` - `order`: Sort order (string, optional) @@ -1281,18 +1146,6 @@ The following sets of tools are available: - `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) -- **submit_pending_pull_request_review** - Submit Pending Pull Request Review - - **Required OAuth Scopes**: `repo` - - `body`: The review body text (optional) (string, optional) - - `event`: The review action to perform (string, required) - - `owner`: Repository owner (username or organization) (string, required) - - `pullNumber`: The pull request number (number, required) - - `repo`: Repository name (string, required) - -- **unresolve_review_thread** - Unresolve Review Thread - - **Required OAuth Scopes**: `repo` - - `threadID`: The node ID of the review thread to unresolve (e.g., PRRT_kwDOxxx) (string, required) - - **update_pull_request** - Edit pull request - **Required OAuth Scopes**: `repo` - `base`: New base branch name (string, optional) @@ -1306,13 +1159,6 @@ The following sets of tools are available: - `state`: New state (string, optional) - `title`: New title (string, optional) -- **update_pull_request_body** - Update Pull Request Body - - **Required OAuth Scopes**: `repo` - - `body`: The new body content for the pull request (string, required) - - `owner`: Repository owner (username or organization) (string, required) - - `pullNumber`: The pull request number (number, required) - - `repo`: Repository name (string, required) - - **update_pull_request_branch** - Update pull request branch - **Required OAuth Scopes**: `repo` - `expectedHeadSha`: The expected SHA of the pull request's HEAD ref (string, optional) @@ -1320,27 +1166,6 @@ The following sets of tools are available: - `pullNumber`: Pull request number (number, required) - `repo`: Repository name (string, required) -- **update_pull_request_draft_state** - Update Pull Request Draft State - - **Required OAuth Scopes**: `repo` - - `draft`: Set to true to convert to draft, false to mark as ready for review (boolean, required) - - `owner`: Repository owner (username or organization) (string, required) - - `pullNumber`: The pull request number (number, required) - - `repo`: Repository name (string, required) - -- **update_pull_request_state** - Update Pull Request State - - **Required OAuth Scopes**: `repo` - - `owner`: Repository owner (username or organization) (string, required) - - `pullNumber`: The pull request number (number, required) - - `repo`: Repository name (string, required) - - `state`: The new state for the pull request (string, required) - -- **update_pull_request_title** - Update Pull Request Title - - **Required OAuth Scopes**: `repo` - - `owner`: Repository owner (username or organization) (string, required) - - `pullNumber`: The pull request number (number, required) - - `repo`: Repository name (string, required) - - `title`: The new title for the pull request (string, required) -
diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index 7a97e4f66..7295c9ccf 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -29,6 +29,12 @@ func init() { rootCmd.AddCommand(generateDocsCmd) } +// noFeatureFlagsChecker reports every feature flag as disabled. It models the +// default user experience used by the generated documentation. +func noFeatureFlagsChecker(_ context.Context, _ string) (bool, error) { + return false, nil +} + func generateAllDocs() error { for _, doc := range []struct { path string @@ -51,9 +57,16 @@ func generateReadmeDocs(readmePath string) error { // Create translation helper t, _ := translations.TranslationHelper() - // (not available to regular users) while including tools with FeatureFlagDisable. + // The README documents the default user experience: tools that are + // enabled with no special flags set. Installing a checker that reports + // every flag as disabled excludes tools gated by FeatureFlagEnable and + // keeps the legacy variants of tools gated by FeatureFlagDisable, so + // flag-gated duplicates don't appear twice. // Build() can only fail if WithTools specifies invalid tools - not used here - r, _ := github.NewInventory(t).WithToolsets([]string{"all"}).Build() + r, _ := github.NewInventory(t). + WithToolsets([]string{"all"}). + WithFeatureChecker(noFeatureFlagsChecker). + Build() // Generate toolsets documentation toolsetsDoc := generateToolsetsDoc(r) diff --git a/pkg/github/__toolsnaps__/list_issues.snap b/pkg/github/__toolsnaps__/list_issues.snap index b1d1c7a21..a4be59bb0 100644 --- a/pkg/github/__toolsnaps__/list_issues.snap +++ b/pkg/github/__toolsnaps__/list_issues.snap @@ -18,27 +18,6 @@ ], "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" - }, "labels": { "description": "Filter by labels", "items": { diff --git a/pkg/github/__toolsnaps__/list_issues_ff_remote_mcp_issue_fields.snap b/pkg/github/__toolsnaps__/list_issues_ff_remote_mcp_issue_fields.snap new file mode 100644 index 000000000..b1d1c7a21 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_issues_ff_remote_mcp_issue_fields.snap @@ -0,0 +1,92 @@ +{ + "annotations": { + "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 endCursor from the previous page's PageInfo for GraphQL APIs.", + "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" + }, + "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/csv_output.go b/pkg/github/csv_output.go index cb70e32d7..6acb8b2fd 100644 --- a/pkg/github/csv_output.go +++ b/pkg/github/csv_output.go @@ -56,14 +56,16 @@ func withCSVOutput(tools []inventory.ServerTool) []inventory.ServerTool { return tools } +// isCSVOutputTool reports whether the given tool should have its handler +// wrapped to honor the csv_output feature flag. Wrapping happens at slice +// construction time, before the per-request feature-flag filter chooses which +// variant of a flag-gated tool to register, so flag-gated list_* tools are +// included on equal footing — only the live variant ever runs at request time. func isCSVOutputTool(tool inventory.ServerTool) bool { if !tool.Toolset.Default { return false } - if !strings.HasPrefix(tool.Tool.Name, "list_") { - return false - } - return tool.FeatureFlagEnable == "" && tool.FeatureFlagDisable == "" + return strings.HasPrefix(tool.Tool.Name, "list_") } func wrapHandlerWithCSVOutput(next inventory.HandlerFunc) inventory.HandlerFunc { diff --git a/pkg/github/csv_output_test.go b/pkg/github/csv_output_test.go index d0bef3893..246902d49 100644 --- a/pkg/github/csv_output_test.go +++ b/pkg/github/csv_output_test.go @@ -38,6 +38,26 @@ func TestCSVOutputAppliedToDefaultListTools(t *testing.T) { } } +func TestCSVOutputAppliesToFlagGatedListTools(t *testing.T) { + enabledOnly := testCSVOutputTool("list_things", `[{"number":1}]`) + enabledOnly.FeatureFlagEnable = FeatureFlagIssueFields + disabledOnly := testCSVOutputTool("list_legacy_things", `[{"number":2}]`) + disabledOnly.FeatureFlagDisable = FeatureFlagIssueFields + + tools := withCSVOutput([]inventory.ServerTool{enabledOnly, disabledOnly}) + require.Len(t, tools, 2) + + // Both flag-gated variants get the CSV wrapper; the per-request flag filter + // decides which one actually registers, and the runtime csv_output check + // decides whether the wrapper converts the response. + deps := newCSVOutputTestDeps(true) + for _, tool := range tools { + result, err := tool.Handler(deps)(ContextWithDeps(context.Background(), deps), testCSVOutputRequest()) + require.NoError(t, err) + assert.Contains(t, textResult(t, result), "number\n") + } +} + func TestCSVOutputOnlyAppliesToDefaultToolsets(t *testing.T) { nonDefaultListTool := testCSVOutputToolWithToolset("list_discussions", `[{"number":1}]`, ToolsetMetadataDiscussions) diff --git a/pkg/github/feature_flags.go b/pkg/github/feature_flags.go index 19399e7ac..6f04be7f1 100644 --- a/pkg/github/feature_flags.go +++ b/pkg/github/feature_flags.go @@ -11,6 +11,11 @@ const FeatureFlagCSVOutput = "csv_output" // FeatureFlagIFCLabels is the feature flag name for IFC security labels in tool results. const FeatureFlagIFCLabels = "ifc_labels" +// FeatureFlagIssueFields is the feature flag name for Issues 2.0 custom field +// support: the list_issue_fields tool, the field_filters input on list_issues, +// and field_values enrichment in list_issues / search_issues output. +const FeatureFlagIssueFields = "remote_mcp_issue_fields" + // 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. @@ -18,6 +23,7 @@ const FeatureFlagIFCLabels = "ifc_labels" var AllowedFeatureFlags = []string{ MCPAppsFeatureFlag, FeatureFlagCSVOutput, + FeatureFlagIssueFields, FeatureFlagIssuesGranular, FeatureFlagPullRequestsGranular, } @@ -30,6 +36,7 @@ var InsidersFeatureFlags = []string{ MCPAppsFeatureFlag, FeatureFlagCSVOutput, FeatureFlagIFCLabels, + FeatureFlagIssueFields, } // FeatureFlags defines runtime feature toggles that adjust tool behavior. diff --git a/pkg/github/issue_fields.go b/pkg/github/issue_fields.go index 70f1a7c51..a7b7c429d 100644 --- a/pkg/github/issue_fields.go +++ b/pkg/github/issue_fields.go @@ -95,8 +95,9 @@ type issueFieldsOrgQuery struct { } // ListIssueFields creates a tool to list issue field definitions for a repository or organization. +// Gated by FeatureFlagIssueFields: the tool is only registered when the flag is on. func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + st := NewTool( ToolsetMetadataIssues, mcp.Tool{ Name: "list_issue_fields", @@ -148,6 +149,8 @@ func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultText(string(r)), nil, nil }) + st.FeatureFlagEnable = FeatureFlagIssueFields + return st } // fetchIssueFields returns the issue field definitions for the given owner. diff --git a/pkg/github/issues.go b/pkg/github/issues.go index e56e793a4..0074bbd58 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -280,6 +280,123 @@ func getIssueQueryType(hasLabels bool, hasSince bool) any { } } +// --- Legacy list_issues GraphQL types --- +// +// These mirror the pre-Issues-2.0 shape of the list_issues query and exist solely +// to back the FeatureFlagIssueFields-disabled variant of the tool. They omit the +// IssueFieldValues selection and the filterBy: {issueFieldValues: ...} clause so +// the request does not depend on server-side issue_fields GraphQL features and +// does not pay the wire/server cost of fetching custom field values when the flag +// is off. Delete this whole block (and its callers) when FeatureFlagIssueFields +// is removed. + +type LegacyIssueFragment struct { + Number githubv4.Int + Title githubv4.String + Body githubv4.String + State githubv4.String + DatabaseID int64 + + Author struct { + Login githubv4.String + } + CreatedAt githubv4.DateTime + UpdatedAt githubv4.DateTime + Labels struct { + Nodes []struct { + Name githubv4.String + ID githubv4.String + Description githubv4.String + } + } `graphql:"labels(first: 100)"` + Comments struct { + TotalCount githubv4.Int + } `graphql:"comments"` +} + +type LegacyIssueQueryFragment struct { + Nodes []LegacyIssueFragment `graphql:"nodes"` + PageInfo struct { + HasNextPage githubv4.Boolean + HasPreviousPage githubv4.Boolean + StartCursor githubv4.String + EndCursor githubv4.String + } + TotalCount int +} + +type LegacyIssueQueryResult interface { + GetLegacyIssueFragment() LegacyIssueQueryFragment + GetIsPrivate() bool +} + +type LegacyListIssuesQuery struct { + Repository struct { + Issues LegacyIssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction})"` + IsPrivate githubv4.Boolean + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +type LegacyListIssuesQueryTypeWithLabels struct { + Repository struct { + Issues LegacyIssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction})"` + IsPrivate githubv4.Boolean + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +type LegacyListIssuesQueryWithSince struct { + Repository struct { + Issues LegacyIssueQueryFragment `graphql:"issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` + IsPrivate githubv4.Boolean + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +type LegacyListIssuesQueryTypeWithLabelsWithSince struct { + Repository struct { + Issues LegacyIssueQueryFragment `graphql:"issues(first: $first, after: $after, labels: $labels, states: $states, orderBy: {field: $orderBy, direction: $direction}, filterBy: {since: $since})"` + IsPrivate githubv4.Boolean + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +func (q *LegacyListIssuesQuery) GetLegacyIssueFragment() LegacyIssueQueryFragment { + return q.Repository.Issues +} +func (q *LegacyListIssuesQuery) GetIsPrivate() bool { return bool(q.Repository.IsPrivate) } + +func (q *LegacyListIssuesQueryTypeWithLabels) GetLegacyIssueFragment() LegacyIssueQueryFragment { + return q.Repository.Issues +} +func (q *LegacyListIssuesQueryTypeWithLabels) GetIsPrivate() bool { + return bool(q.Repository.IsPrivate) +} + +func (q *LegacyListIssuesQueryWithSince) GetLegacyIssueFragment() LegacyIssueQueryFragment { + return q.Repository.Issues +} +func (q *LegacyListIssuesQueryWithSince) GetIsPrivate() bool { + return bool(q.Repository.IsPrivate) +} + +func (q *LegacyListIssuesQueryTypeWithLabelsWithSince) GetLegacyIssueFragment() LegacyIssueQueryFragment { + return q.Repository.Issues +} +func (q *LegacyListIssuesQueryTypeWithLabelsWithSince) GetIsPrivate() bool { + return bool(q.Repository.IsPrivate) +} + +func getLegacyIssueQueryType(hasLabels bool, hasSince bool) any { + switch { + case hasLabels && hasSince: + return &LegacyListIssuesQueryTypeWithLabelsWithSince{} + case hasLabels: + return &LegacyListIssuesQueryTypeWithLabels{} + case hasSince: + return &LegacyListIssuesQueryWithSince{} + default: + return &LegacyListIssuesQuery{} + } +} + // IssueRead creates a tool to get details of a specific issue in a GitHub repository. func IssueRead(t translations.TranslationHelperFunc) inventory.ServerTool { schema := &jsonschema.Schema{ @@ -1262,7 +1379,7 @@ func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[st } var fieldValuesByID map[string][]MinimalIssueFieldValue - if len(result.Issues) > 0 { + if deps.IsFeatureEnabled(ctx, FeatureFlagIssueFields) && len(result.Issues) > 0 { gqlClient, err := deps.GetGQLClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub GraphQL client", err), nil @@ -1700,7 +1817,11 @@ 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 +// ListIssues creates a tool to list and filter repository issues. This variant is +// gated by FeatureFlagIssueFields and exposes the Issues 2.0 field_filters input +// plus field_values output enrichment. When the flag is off, LegacyListIssues is +// served instead. Both registrations share the tool name "list_issues" and rely on +// the inventory's feature-flag filter to make exactly one active at a time. func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { schema := &jsonschema.Schema{ Type: "object", @@ -1762,7 +1883,7 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { } WithCursorPagination(schema) - return NewTool( + st := NewTool( ToolsetMetadataIssues, mcp.Tool{ Name: "list_issues", @@ -1962,6 +2083,211 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { } return result, nil, nil }) + st.FeatureFlagEnable = FeatureFlagIssueFields + return st +} + +// LegacyListIssues is the FeatureFlagIssueFields-disabled variant of list_issues. +// It exposes the pre-Issues-2.0 schema (no field_filters) and uses a GraphQL query +// path that does not select issueFieldValues or pass the issue_fields filter, so +// the request does not depend on server-side issue_fields features and does not pay +// for custom field values when the flag is off. Both this and ListIssues register +// under the tool name "list_issues"; exactly one is active for any given request +// thanks to mutually exclusive FeatureFlagEnable / FeatureFlagDisable annotations. +// Delete this function (and the rest of the Legacy* block) when the flag is removed. +func LegacyListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "state": { + Type: "string", + Description: "Filter by state, by default both open and closed issues are returned when not provided", + Enum: []any{"OPEN", "CLOSED"}, + }, + "labels": { + Type: "array", + Description: "Filter by labels", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, + "orderBy": { + Type: "string", + Description: "Order issues by field. If provided, the 'direction' also needs to be provided.", + Enum: []any{"CREATED_AT", "UPDATED_AT", "COMMENTS"}, + }, + "direction": { + Type: "string", + Description: "Order direction. If provided, the 'orderBy' also needs to be provided.", + Enum: []any{"ASC", "DESC"}, + }, + "since": { + Type: "string", + Description: "Filter by date (ISO 8601 timestamp)", + }, + }, + Required: []string{"owner", "repo"}, + } + WithCursorPagination(schema) + + st := NewTool( + ToolsetMetadataIssues, + mcp.Tool{ + Name: "list_issues", + Description: t("TOOL_LIST_ISSUES_DESCRIPTION", "List issues in a GitHub repository. For pagination, use the 'endCursor' from the previous response's 'pageInfo' in the 'after' parameter."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_LIST_ISSUES_USER_TITLE", "List issues"), + ReadOnlyHint: true, + }, + InputSchema: schema, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + state, err := OptionalParam[string](args, "state") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + state = strings.ToUpper(state) + var states []githubv4.IssueState + switch state { + case "OPEN", "CLOSED": + states = []githubv4.IssueState{githubv4.IssueState(state)} + default: + states = []githubv4.IssueState{githubv4.IssueStateOpen, githubv4.IssueStateClosed} + } + + labels, err := OptionalStringArrayParam(args, "labels") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + orderBy, err := OptionalParam[string](args, "orderBy") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + direction, err := OptionalParam[string](args, "direction") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + orderBy = strings.ToUpper(orderBy) + switch orderBy { + case "CREATED_AT", "UPDATED_AT", "COMMENTS": + default: + orderBy = "CREATED_AT" + } + direction = strings.ToUpper(direction) + switch direction { + case "ASC", "DESC": + default: + direction = "DESC" + } + + since, err := OptionalParam[string](args, "since") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + var sinceTime time.Time + var hasSince bool + if since != "" { + sinceTime, err = parseISOTimestamp(since) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to list issues: %s", err.Error())), nil, nil + } + hasSince = true + } + hasLabels := len(labels) > 0 + + pagination, err := OptionalCursorPaginationParams(args) + if err != nil { + return nil, nil, err + } + 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 + } + _, perPageProvided := args["perPage"] + paginationExplicit := perPageProvided + paginationParams, err := pagination.ToGraphQLParams() + if err != nil { + return nil, nil, err + } + if !paginationExplicit { + defaultFirst := int32(DefaultGraphQLPageSize) + paginationParams.First = &defaultFirst + } + + client, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil + } + + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "states": states, + "orderBy": githubv4.IssueOrderField(orderBy), + "direction": githubv4.OrderDirection(direction), + "first": githubv4.Int(*paginationParams.First), + } + if paginationParams.After != nil { + vars["after"] = githubv4.String(*paginationParams.After) + } else { + vars["after"] = (*githubv4.String)(nil) + } + if hasLabels { + labelStrings := make([]githubv4.String, len(labels)) + for i, label := range labels { + labelStrings[i] = githubv4.String(label) + } + vars["labels"] = labelStrings + } + if hasSince { + vars["since"] = githubv4.DateTime{Time: sinceTime} + } + + issueQuery := getLegacyIssueQueryType(hasLabels, hasSince) + if err := client.Query(ctx, issueQuery, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse( + ctx, + "failed to list issues", + err, + ), nil, nil + } + + var resp MinimalIssuesResponse + var isPrivate bool + if queryResult, ok := issueQuery.(LegacyIssueQueryResult); ok { + resp = convertLegacyToMinimalIssuesResponse(queryResult.GetLegacyIssueFragment()) + isPrivate = queryResult.GetIsPrivate() + } + + result := MarshalledTextResult(resp) + if deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { + if result.Meta == nil { + result.Meta = mcp.Meta{} + } + result.Meta["ifc"] = ifc.LabelListIssues(isPrivate) + } + return result, nil, nil + }) + st.FeatureFlagDisable = FeatureFlagIssueFields + return st } // rawFieldFilter is the user-supplied {field_name, value} pair before type resolution. diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 4f08b7214..3bac59722 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -1082,8 +1082,9 @@ func Test_SearchIssues_FieldValuesEnrichment(t *testing.T) { gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)) deps := BaseDeps{ - Client: mustNewGHClient(t, restClient), - GQLClient: gqlClient, + Client: mustNewGHClient(t, restClient), + GQLClient: gqlClient, + featureChecker: featureCheckerFor(FeatureFlagIssueFields), } handler := serverTool.Handler(deps) @@ -1446,7 +1447,8 @@ 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.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagIssueFields, tool)) + require.Equal(t, FeatureFlagIssueFields, serverTool.FeatureFlagEnable) assert.Equal(t, "list_issues", tool.Name) assert.NotEmpty(t, tool.Description) @@ -2363,6 +2365,95 @@ func Test_ListIssues_IFC_InsidersMode(t *testing.T) { }) } +func Test_LegacyListIssues_Definition(t *testing.T) { + serverTool := LegacyListIssues(translations.NullTranslationHelper) + tool := serverTool.Tool + + // LegacyListIssues claims the base tool name "list_issues" and produces the + // FeatureFlagIssueFields-disabled schema (no field_filters). It owns the + // canonical list_issues.snap; the FeatureFlagIssueFields-enabled variant + // owns list_issues_ff_.snap. + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + require.Equal(t, "list_issues", tool.Name) + require.Equal(t, FeatureFlagIssueFields, serverTool.FeatureFlagDisable) + require.Empty(t, serverTool.FeatureFlagEnable) + + props := tool.InputSchema.(*jsonschema.Schema).Properties + assert.Contains(t, props, "owner") + assert.Contains(t, props, "repo") + assert.Contains(t, props, "state") + assert.Contains(t, props, "labels") + assert.Contains(t, props, "since") + assert.NotContains(t, props, "field_filters", "legacy list_issues must not advertise field_filters") +} + +func Test_LegacyListIssues_OmitsFieldValuesAndFilters(t *testing.T) { + t.Parallel() + + serverTool := LegacyListIssues(translations.NullTranslationHelper) + + mockIssues := []map[string]any{ + { + "number": 7, + "title": "Legacy issue", + "body": "body", + "state": "OPEN", + "databaseId": 7, + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + "author": map[string]any{"login": "octocat"}, + "labels": map[string]any{"nodes": []map[string]any{}}, + "comments": map[string]any{"totalCount": 0}, + }, + } + pageInfo := map[string]any{ + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": "c1", + "endCursor": "c1", + } + + // The legacy query must NOT reference issueFieldValues (neither in the selection + // set nor in filterBy). The matcher's query string therefore omits both. + const legacyQuery = "query($after:String$direction:OrderDirection!$first:Int!$orderBy:IssueOrderField!$owner:String!$repo:String!$states:[IssueState!]!){repository(owner: $owner, name: $repo){issues(first: $first, after: $after, states: $states, orderBy: {field: $orderBy, direction: $direction}){nodes{number,title,body,state,databaseId,author{login},createdAt,updatedAt,labels(first: 100){nodes{name,id,description}},comments{totalCount}},pageInfo{hasNextPage,hasPreviousPage,startCursor,endCursor},totalCount},isPrivate}}" + vars := map[string]any{ + "owner": "owner", + "repo": "repo", + "states": []any{"OPEN", "CLOSED"}, + "orderBy": "CREATED_AT", + "direction": "DESC", + "first": float64(30), + "after": nil, + } + response := githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "isPrivate": false, + "issues": map[string]any{ + "nodes": mockIssues, + "pageInfo": pageInfo, + "totalCount": 1, + }, + }, + }) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(githubv4mock.NewQueryMatcher(legacyQuery, vars, response))) + + deps := BaseDeps{GQLClient: gqlClient} + 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, "expected non-error result; got: %v", getTextResult(t, result).Text) + + var resp MinimalIssuesResponse + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &resp)) + require.Len(t, resp.Issues, 1) + assert.Equal(t, 7, resp.Issues[0].Number) + assert.Nil(t, resp.Issues[0].FieldValues, "legacy list_issues must not return field_values") +} + func Test_UpdateIssue(t *testing.T) { // Verify tool definition serverTool := IssueWrite(translations.NullTranslationHelper) diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index bad5196a9..02309db45 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -525,6 +525,51 @@ func convertToMinimalIssuesResponse(fragment IssueQueryFragment) MinimalIssuesRe } } +// legacyFragmentToMinimalIssue converts the FeatureFlagIssueFields-disabled +// LegacyIssueFragment into a MinimalIssue. MinimalIssue.FieldValues is left +// nil so omitempty drops it from JSON output. Delete with the rest of the +// Legacy* block when the flag is removed. +func legacyFragmentToMinimalIssue(fragment LegacyIssueFragment) MinimalIssue { + m := MinimalIssue{ + Number: int(fragment.Number), + Title: sanitize.Sanitize(string(fragment.Title)), + Body: sanitize.Sanitize(string(fragment.Body)), + State: string(fragment.State), + Comments: int(fragment.Comments.TotalCount), + CreatedAt: fragment.CreatedAt.Format(time.RFC3339), + UpdatedAt: fragment.UpdatedAt.Format(time.RFC3339), + User: &MinimalUser{ + Login: string(fragment.Author.Login), + }, + } + + for _, label := range fragment.Labels.Nodes { + m.Labels = append(m.Labels, string(label.Name)) + } + + return m +} + +// convertLegacyToMinimalIssuesResponse mirrors convertToMinimalIssuesResponse for +// the FeatureFlagIssueFields-disabled list_issues variant. +func convertLegacyToMinimalIssuesResponse(fragment LegacyIssueQueryFragment) MinimalIssuesResponse { + minimalIssues := make([]MinimalIssue, 0, len(fragment.Nodes)) + for _, issue := range fragment.Nodes { + minimalIssues = append(minimalIssues, legacyFragmentToMinimalIssue(issue)) + } + + return MinimalIssuesResponse{ + Issues: minimalIssues, + TotalCount: fragment.TotalCount, + PageInfo: MinimalPageInfo{ + HasNextPage: bool(fragment.PageInfo.HasNextPage), + HasPreviousPage: bool(fragment.PageInfo.HasPreviousPage), + StartCursor: string(fragment.PageInfo.StartCursor), + EndCursor: string(fragment.PageInfo.EndCursor), + }, + } +} + func convertToMinimalIssueComment(comment *github.IssueComment) MinimalIssueComment { m := MinimalIssueComment{ ID: comment.GetID(), diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 70dfab8d9..49edb00ff 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -204,6 +204,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { IssueRead(t), SearchIssues(t), ListIssues(t), + LegacyListIssues(t), ListIssueTypes(t), ListIssueFields(t), IssueWrite(t), From 6fd9d070ba1671cf683f34faeff214a63cf43ad0 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 21 May 2026 23:02:07 +0200 Subject: [PATCH 43/48] chore(gitignore): anchor binary names to repo root (#2522) The bare `github-mcp-server`, `mcpcurl`, and `e2e.test` rules matched those names anywhere in the tree, which silently ignored new files created under `cmd/github-mcp-server/` (the rule treats the directory component as a match). The intent was to ignore the binaries produced by `go build` at repo root, so anchor each rule with a leading slash. The existing `cmd/github-mcp-server/github-mcp-server` rule on line 2 continues to ignore the binary when built inside the cmd directory. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .gitignore | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 8d5d8b7ea..dc0a5f3a3 100644 --- a/.gitignore +++ b/.gitignore @@ -17,9 +17,9 @@ bin/ .DS_Store # binary -github-mcp-server -mcpcurl -e2e.test +/github-mcp-server +/mcpcurl +/e2e.test .history conformance-report/ From 0b644d7b746adf99bdf9cd0fac8ba962c1ada55c Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 21 May 2026 23:18:55 +0200 Subject: [PATCH 44/48] ci(mcp-diff): build UI artifacts available to baseline checkout (#2523) The mcp-server-diff action checks the baseline ref out into a separate working directory and runs install_command there. Without prebuilt UI artifacts, pkg/github/ui_dist/ is empty on the baseline side and UIAssetsAvailable() returns false, producing a false-positive diff that "adds" _meta.ui to MCP Apps tools on every PR. Stash the artifacts to RUNNER_TEMP after the workflow's build-ui step, then restore them from install_command so both the baseline and PR checkouts register identical MCP Apps UI metadata. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/mcp-diff.yml | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/.github/workflows/mcp-diff.yml b/.github/workflows/mcp-diff.yml index 305428923..f901e31f8 100644 --- a/.github/workflows/mcp-diff.yml +++ b/.github/workflows/mcp-diff.yml @@ -27,6 +27,16 @@ jobs: - name: Build UI uses: ./.github/actions/build-ui + - name: Stash UI artifacts for baseline checkout + # mcp-server-diff checks the baseline ref out into a separate working + # directory and runs install_command there. Without these prebuilt + # artifacts, pkg/github/ui_dist/ would be empty on the baseline side + # and UIAssetsAvailable() would return false, producing a false-positive + # diff that "adds" _meta.ui to MCP Apps tools on every PR. + run: | + mkdir -p "${RUNNER_TEMP}/ui_dist" + cp pkg/github/ui_dist/*.html "${RUNNER_TEMP}/ui_dist/" + - name: Generate diff configurations id: configs # The generator imports pkg/github so any new entry in @@ -43,7 +53,10 @@ jobs: uses: SamMorrowDrums/mcp-server-diff@v2.3.5 with: setup_go: "false" - install_command: go mod download + install_command: | + go mod download + mkdir -p pkg/github/ui_dist + cp "${RUNNER_TEMP}"/ui_dist/*.html pkg/github/ui_dist/ start_command: go run ./cmd/github-mcp-server stdio env_vars: | GITHUB_PERSONAL_ACCESS_TOKEN=test-token @@ -79,6 +92,13 @@ jobs: - name: Build UI uses: ./.github/actions/build-ui + - name: Stash UI artifacts for baseline checkout + # See the stdio job above for rationale: the action's baseline checkout + # has no UI artifacts unless we hand them over via RUNNER_TEMP. + run: | + mkdir -p "${RUNNER_TEMP}/ui_dist" + cp pkg/github/ui_dist/*.html "${RUNNER_TEMP}/ui_dist/" + - name: Generate diff configurations id: configs # See script/print-mcp-diff-configs/main.go. The http-headers variant @@ -97,7 +117,10 @@ jobs: uses: SamMorrowDrums/mcp-server-diff@v2.3.5 with: setup_go: "false" - install_command: go mod download + install_command: | + go mod download + mkdir -p pkg/github/ui_dist + cp "${RUNNER_TEMP}"/ui_dist/*.html pkg/github/ui_dist/ http_start_command: go run ./cmd/github-mcp-server http --port 8082 http_startup_wait_ms: "5000" configurations: ${{ steps.configs.outputs.configurations }} From 1add5fe231040119934d18eac6da5a2aab4cfffe Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Fri, 22 May 2026 00:31:39 +0200 Subject: [PATCH 45/48] docs: auto-generate per-flag tool lists for insiders and feature flags (#2521) Adds two auto-generated documentation sections that describe how feature flags shape the tool surface: - docs/insiders-features.md gets a per-flag block under its existing hand-written prose. Each Insiders flag whose tools differ from the default surface is listed with the full tool schema rendered through the same writer used for README, so contributors can see exactly what Insiders Mode adds or changes. - docs/feature-flags.md is new and gives the same treatment to every flag in AllowedFeatureFlags (user-controllable flags). It links back to the Insiders doc for the auto-enabled subset. Both sections are produced by a single generator that diffs the flag-on inventory against the default-flagged inventory and reports any tool that is new or has a different InputSchema/Meta. No reason classification - just tools and their schemas, kept intentionally simple so contributors don't have to update the generator when adding a new flag. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/github-mcp-server/feature_flag_docs.go | 139 +++++++++++ cmd/github-mcp-server/generate_docs.go | 13 +- docs/feature-flags.md | 267 +++++++++++++++++++++ docs/insiders-features.md | 70 ++++++ pkg/inventory/registry.go | 19 +- 5 files changed, 502 insertions(+), 6 deletions(-) create mode 100644 cmd/github-mcp-server/feature_flag_docs.go create mode 100644 docs/feature-flags.md diff --git a/cmd/github-mcp-server/feature_flag_docs.go b/cmd/github-mcp-server/feature_flag_docs.go new file mode 100644 index 000000000..e52237b13 --- /dev/null +++ b/cmd/github-mcp-server/feature_flag_docs.go @@ -0,0 +1,139 @@ +package main + +import ( + "context" + "fmt" + "os" + "reflect" + "sort" + "strings" + + "github.com/github/github-mcp-server/pkg/github" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" +) + +// generateInsidersFeaturesDocs refreshes the auto-generated section of +// docs/insiders-features.md with the tools and schemas affected by each +// Insiders feature flag. +func generateInsidersFeaturesDocs(docsPath string) error { + body := generateFlaggedToolsDoc(github.InsidersFeatureFlags, "_No Insiders-only tool changes._") + return rewriteAutomatedSection(docsPath, "START AUTOMATED INSIDERS TOOLS", "END AUTOMATED INSIDERS TOOLS", body) +} + +// generateFeatureFlagsDocs refreshes the auto-generated section of +// docs/feature-flags.md with the tools and schemas affected by each +// user-controllable feature flag. +func generateFeatureFlagsDocs(docsPath string) error { + body := generateFlaggedToolsDoc(github.AllowedFeatureFlags, "_No user-controllable feature flags affect tool registration._") + return rewriteAutomatedSection(docsPath, "START AUTOMATED FEATURE FLAG TOOLS", "END AUTOMATED FEATURE FLAG TOOLS", body) +} + +// generateFlaggedToolsDoc renders, for each flag in the input set, the tools +// whose registration or definition differs from the default user experience. +// Each affected tool is printed with its full schema using the same writer +// used by the README so the output style stays consistent. +func generateFlaggedToolsDoc(flags []string, emptyMessage string) string { + t, _ := translations.TranslationHelper() + defaultTools := indexToolsByName(buildInventoryWithFlags(t, nil).ToolsForRegistration(context.Background())) + + var buf strings.Builder + hasAny := false + + for _, flag := range flags { + affected := flaggedToolDiff(t, flag, defaultTools) + if len(affected) == 0 { + continue + } + + if hasAny { + buf.WriteString("\n\n") + } + hasAny = true + + fmt.Fprintf(&buf, "### `%s`\n\n", flag) + for i, tool := range affected { + writeToolDoc(&buf, tool) + if i < len(affected)-1 { + buf.WriteString("\n\n") + } + } + } + + if !hasAny { + return emptyMessage + } + // Leading/trailing newlines around the body produce blank lines between + // our content and the surrounding marker comments, so the trailing comment + // doesn't get absorbed into the final list item by markdown renderers. + return "\n" + strings.TrimSuffix(buf.String(), "\n") + "\n" +} + +// flaggedToolDiff returns the tools whose definition (input schema or meta) +// differs from the default-flagged inventory when only the given flag is on, +// plus tools that exist only in the flag-on inventory. Results are sorted by +// tool name. +func flaggedToolDiff(t translations.TranslationHelperFunc, flag string, defaultTools map[string]inventory.ServerTool) []inventory.ServerTool { + flagTools := buildInventoryWithFlags(t, map[string]bool{flag: true}).ToolsForRegistration(context.Background()) + + out := make([]inventory.ServerTool, 0) + seen := make(map[string]struct{}, len(flagTools)) + + for _, tool := range flagTools { + if _, ok := seen[tool.Tool.Name]; ok { + continue + } + seen[tool.Tool.Name] = struct{}{} + + baseline, hadBaseline := defaultTools[tool.Tool.Name] + if hadBaseline && reflect.DeepEqual(tool.Tool.InputSchema, baseline.Tool.InputSchema) && reflect.DeepEqual(tool.Tool.Meta, baseline.Tool.Meta) { + continue + } + out = append(out, tool) + } + + sort.Slice(out, func(i, j int) bool { return out[i].Tool.Name < out[j].Tool.Name }) + return out +} + +// buildInventoryWithFlags constructs an inventory whose feature checker treats +// the given flags as enabled and every other flag as disabled. Passing nil +// produces the default-flagged inventory. +func buildInventoryWithFlags(t translations.TranslationHelperFunc, enabled map[string]bool) *inventory.Inventory { + checker := func(_ context.Context, flag string) (bool, error) { + return enabled[flag], nil + } + inv, _ := github.NewInventory(t). + WithToolsets([]string{"all"}). + WithFeatureChecker(checker). + Build() + return inv +} + +// indexToolsByName returns a map keyed by tool name. When duplicates exist +// (e.g. flag-gated dual registrations), the first occurrence wins, mirroring +// AvailableTools' deterministic sort order. +func indexToolsByName(tools []inventory.ServerTool) map[string]inventory.ServerTool { + out := make(map[string]inventory.ServerTool, len(tools)) + for _, tool := range tools { + if _, ok := out[tool.Tool.Name]; ok { + continue + } + out[tool.Tool.Name] = tool + } + return out +} + +// rewriteAutomatedSection reads a markdown file, replaces the content between +// the named markers with body, and writes it back. +func rewriteAutomatedSection(path, startMarker, endMarker, body string) error { + content, err := os.ReadFile(path) //#nosec G304 + if err != nil { + return fmt.Errorf("failed to read docs file: %w", err) + } + updated, err := replaceSection(string(content), startMarker, endMarker, body) + if err != nil { + return err + } + return os.WriteFile(path, []byte(updated), 0600) //#nosec G306 +} diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index 7295c9ccf..efa8f7c39 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -43,6 +43,8 @@ func generateAllDocs() error { // File to edit, function to generate its docs {"README.md", generateReadmeDocs}, {"docs/remote-server.md", generateRemoteServerDocs}, + {"docs/insiders-features.md", generateInsidersFeaturesDocs}, + {"docs/feature-flags.md", generateFeatureFlagsDocs}, {"docs/tool-renaming.md", generateDeprecatedAliasesDocs}, } { if err := doc.fn(doc.path); err != nil { @@ -168,7 +170,7 @@ func generateToolsetsDoc(i *inventory.Inventory) string { } func generateToolsDoc(r *inventory.Inventory) string { - tools := r.AvailableTools(context.Background()) + tools := r.ToolsForRegistration(context.Background()) if len(tools) == 0 { return "" } @@ -227,6 +229,15 @@ func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) { } } + // MCP App UI metadata (only rendered when the remote_mcp_ui_apps flag + // applied to the inventory; for the no-flags README this section is + // stripped by inventory.ToolsForRegistration before rendering). + if ui, ok := tool.Tool.Meta["ui"].(map[string]any); ok { + if uri, ok := ui["resourceUri"].(string); ok && uri != "" { + fmt.Fprintf(buf, " - **MCP App UI**: `%s`\n", uri) + } + } + // Parameters if tool.Tool.InputSchema == nil { buf.WriteString(" - No parameters required") diff --git a/docs/feature-flags.md b/docs/feature-flags.md new file mode 100644 index 000000000..a552e71a0 --- /dev/null +++ b/docs/feature-flags.md @@ -0,0 +1,267 @@ +# Feature Flags + +Feature flags let you opt into experimental tool behavior on top of the default +GitHub MCP Server surface. Insiders Mode turns on a curated subset of these +flags automatically — see [Insiders Features](./insiders-features.md) for that +specific set. + +For background on how flags resolve at request time, see the [resolution +section in the Insiders docs](./insiders-features.md#how-feature-flags-are-resolved). + +## Enabling a flag + +| Method | Remote Server | Local Server | +|--------|---------------|--------------| +| Header | `X-MCP-Features: ,` | N/A | +| CLI flag | N/A | `--features=,` | +| Environment variable | N/A | `GITHUB_FEATURES=,` | + +Only flags listed in +[`AllowedFeatureFlags`](../pkg/github/feature_flags.go) can be enabled by +end users. Insiders-only flags are not user-toggleable. + +--- + +## Tools affected by each flag + +The list below is regenerated from the Go source. For each user-controllable +feature flag, it lists every tool whose **inventory or input schema** differs +from the default — either because the flag introduces a new tool, or because +it selects a flag-aware variant of an existing tool. Flags that only affect +runtime behavior (such as output formatting) won't appear here. + + + +### `remote_mcp_ui_apps` + +- **create_pull_request** - Open new pull request + - **Required OAuth Scopes**: `repo` + - **MCP App UI**: `ui://github-mcp-server/pr-write` + - `base`: Branch to merge into (string, required) + - `body`: PR description (string, optional) + - `draft`: Create as draft PR (boolean, optional) + - `head`: Branch containing changes (string, required) + - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `title`: PR title (string, required) + +- **get_me** - Get my user profile + - **MCP App UI**: `ui://github-mcp-server/get-me` + - No parameters required + +- **issue_write** - Create or update issue + - **Required OAuth Scopes**: `repo` + - **MCP App UI**: `ui://github-mcp-server/issue-write` + - `assignees`: Usernames to assign to this issue (string[], optional) + - `body`: Issue body content (string, optional) + - `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional) + - `issue_number`: Issue number to update (number, optional) + - `labels`: Labels to apply to this issue (string[], optional) + - `method`: Write operation to perform on a single issue. + Options are: + - 'create' - creates a new issue. + - 'update' - updates an existing issue. + (string, required) + - `milestone`: Milestone number (number, optional) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `state`: New state (string, optional) + - `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional) + - `title`: Issue title (string, optional) + - `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional) + +### `remote_mcp_issue_fields` + +- **list_issue_fields** - List issue fields + - **Required OAuth Scopes**: `repo`, `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org` + - `owner`: The account owner of the repository or organization. The name is not case sensitive. (string, required) + - `repo`: The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly. (string, optional) + +- **list_issues** - List issues + - **Required OAuth Scopes**: `repo` + - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) + - `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional) + - `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) + - `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) + +### `issues_granular` + +- **add_sub_issue** - Add Sub-Issue + - **Required OAuth Scopes**: `repo` + - `issue_number`: The parent issue number (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `replace_parent`: If true, reparent the sub-issue if it already has a parent (boolean, optional) + - `repo`: Repository name (string, required) + - `sub_issue_id`: The ID of the sub-issue to add. ID is not the same as issue number (number, required) + +- **create_issue** - Create Issue + - **Required OAuth Scopes**: `repo` + - `body`: Issue body content (optional) (string, optional) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + - `title`: Issue title (string, required) + +- **remove_sub_issue** - Remove Sub-Issue + - **Required OAuth Scopes**: `repo` + - `issue_number`: The parent issue number (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + - `sub_issue_id`: The ID of the sub-issue to remove. ID is not the same as issue number (number, required) + +- **reprioritize_sub_issue** - Reprioritize Sub-Issue + - **Required OAuth Scopes**: `repo` + - `after_id`: The ID of the sub-issue to place this after (either after_id OR before_id should be specified) (number, optional) + - `before_id`: The ID of the sub-issue to place this before (either after_id OR before_id should be specified) (number, optional) + - `issue_number`: The parent issue number (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + - `sub_issue_id`: The ID of the sub-issue to reorder. ID is not the same as issue number (number, required) + +- **set_issue_fields** - Set Issue Fields + - **Required OAuth Scopes**: `repo` + - `fields`: Array of issue field values to set. Each element must have a 'field_id' (string, the GraphQL node ID of the field) and exactly one value field: 'text_value' for text fields, 'number_value' for number fields, 'date_value' (ISO 8601 date string) for date fields, or 'single_select_option_id' (the GraphQL node ID of the option) for single select fields. Set 'delete' to true to remove a field value. (object[], required) + - `issue_number`: The issue number to update (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + +- **update_issue_assignees** - Update Issue Assignees + - **Required OAuth Scopes**: `repo` + - `assignees`: GitHub usernames to assign to this issue (string[], required) + - `issue_number`: The issue number to update (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + +- **update_issue_body** - Update Issue Body + - **Required OAuth Scopes**: `repo` + - `body`: The new body content for the issue (string, required) + - `issue_number`: The issue number to update (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + +- **update_issue_labels** - Update Issue Labels + - **Required OAuth Scopes**: `repo` + - `issue_number`: The issue number to update (number, required) + - `labels`: Labels to apply to this issue. ([], required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + +- **update_issue_milestone** - Update Issue Milestone + - **Required OAuth Scopes**: `repo` + - `issue_number`: The issue number to update (number, required) + - `milestone`: The milestone number to set on the issue (integer, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + +- **update_issue_state** - Update Issue State + - **Required OAuth Scopes**: `repo` + - `issue_number`: The issue number to update (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `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) + +- **update_issue_title** - Update Issue Title + - **Required OAuth Scopes**: `repo` + - `issue_number`: The issue number to update (number, required) + - `owner`: Repository owner (username or organization) (string, required) + - `repo`: Repository name (string, required) + - `title`: The new title for the issue (string, required) + +- **update_issue_type** - Update Issue Type + - **Required OAuth Scopes**: `repo` + - `issue_number`: The issue number to update (number, required) + - `issue_type`: The issue type to set (string, required) + - `owner`: Repository owner (username or organization) (string, required) + - `rationale`: One concise sentence explaining what specifically about the issue led you to choose this type. State the concrete signal (e.g. 'Reports a crash when saving' → bug, 'Asks for dark mode support' → feature). (string, optional) + - `repo`: Repository name (string, required) + +### `pull_requests_granular` + +- **add_pull_request_review_comment** - Add Pull Request Review Comment + - **Required OAuth Scopes**: `repo` + - `body`: The comment body (string, required) + - `line`: The line number in the diff to comment on (optional) (number, optional) + - `owner`: Repository owner (username or organization) (string, required) + - `path`: The relative path of the file to comment on (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + - `side`: The side of the diff to comment on (optional) (string, optional) + - `startLine`: The start line of a multi-line comment (optional) (number, optional) + - `startSide`: The start side of a multi-line comment (optional) (string, optional) + - `subjectType`: The subject type of the comment (string, required) + +- **create_pull_request_review** - Create Pull Request Review + - **Required OAuth Scopes**: `repo` + - `body`: The review body text (optional) (string, optional) + - `commitID`: The SHA of the commit to review (optional, defaults to latest) (string, optional) + - `event`: The review action to perform. If omitted, creates a pending review. (string, optional) + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + +- **delete_pending_pull_request_review** - Delete Pending Pull Request Review + - **Required OAuth Scopes**: `repo` + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + +- **request_pull_request_reviewers** - Request Pull Request Reviewers + - **Required OAuth Scopes**: `repo` + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + - `reviewers`: GitHub usernames to request reviews from (string[], required) + +- **resolve_review_thread** - Resolve Review Thread + - **Required OAuth Scopes**: `repo` + - `threadID`: The node ID of the review thread to resolve (e.g., PRRT_kwDOxxx) (string, required) + +- **submit_pending_pull_request_review** - Submit Pending Pull Request Review + - **Required OAuth Scopes**: `repo` + - `body`: The review body text (optional) (string, optional) + - `event`: The review action to perform (string, required) + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + +- **unresolve_review_thread** - Unresolve Review Thread + - **Required OAuth Scopes**: `repo` + - `threadID`: The node ID of the review thread to unresolve (e.g., PRRT_kwDOxxx) (string, required) + +- **update_pull_request_body** - Update Pull Request Body + - **Required OAuth Scopes**: `repo` + - `body`: The new body content for the pull request (string, required) + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + +- **update_pull_request_draft_state** - Update Pull Request Draft State + - **Required OAuth Scopes**: `repo` + - `draft`: Set to true to convert to draft, false to mark as ready for review (boolean, required) + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + +- **update_pull_request_state** - Update Pull Request State + - **Required OAuth Scopes**: `repo` + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + - `state`: The new state for the pull request (string, required) + +- **update_pull_request_title** - Update Pull Request Title + - **Required OAuth Scopes**: `repo` + - `owner`: Repository owner (username or organization) (string, required) + - `pullNumber`: The pull request number (number, required) + - `repo`: Repository name (string, required) + - `title`: The new title for the pull request (string, required) + + diff --git a/docs/insiders-features.md b/docs/insiders-features.md index 90afe7219..c221b8758 100644 --- a/docs/insiders-features.md +++ b/docs/insiders-features.md @@ -20,6 +20,76 @@ For configuration examples, see the [Server Configuration Guide](./server-config --- +## Tools added or changed by Insiders Mode + +The list below is generated from the Go source. It covers tool **inventory and schema deltas** introduced by each Insiders feature flag — newly registered tools, or existing tools whose input schema or MCP metadata changes when the flag is on. Flags that only affect runtime behavior (e.g. output formatting or extra field lookups behind an existing schema) won't appear here; those are documented in the prose sections of this file. + + + +### `remote_mcp_ui_apps` + +- **create_pull_request** - Open new pull request + - **Required OAuth Scopes**: `repo` + - **MCP App UI**: `ui://github-mcp-server/pr-write` + - `base`: Branch to merge into (string, required) + - `body`: PR description (string, optional) + - `draft`: Create as draft PR (boolean, optional) + - `head`: Branch containing changes (string, required) + - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `title`: PR title (string, required) + +- **get_me** - Get my user profile + - **MCP App UI**: `ui://github-mcp-server/get-me` + - No parameters required + +- **issue_write** - Create or update issue + - **Required OAuth Scopes**: `repo` + - **MCP App UI**: `ui://github-mcp-server/issue-write` + - `assignees`: Usernames to assign to this issue (string[], optional) + - `body`: Issue body content (string, optional) + - `duplicate_of`: Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'. (number, optional) + - `issue_number`: Issue number to update (number, optional) + - `labels`: Labels to apply to this issue (string[], optional) + - `method`: Write operation to perform on a single issue. + Options are: + - 'create' - creates a new issue. + - 'update' - updates an existing issue. + (string, required) + - `milestone`: Milestone number (number, optional) + - `owner`: Repository owner (string, required) + - `repo`: Repository name (string, required) + - `state`: New state (string, optional) + - `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional) + - `title`: Issue title (string, optional) + - `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional) + +### `remote_mcp_issue_fields` + +- **list_issue_fields** - List issue fields + - **Required OAuth Scopes**: `repo`, `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org` + - `owner`: The account owner of the repository or organization. The name is not case sensitive. (string, required) + - `repo`: The name of the repository. When provided, returns fields for this specific repository (inherited from its organization). When omitted, returns org-level fields directly. (string, optional) + +- **list_issues** - List issues + - **Required OAuth Scopes**: `repo` + - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) + - `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional) + - `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) + - `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) + + + +--- + ## MCP Apps [MCP Apps](https://modelcontextprotocol.io/docs/extensions/apps) is an extension to the Model Context Protocol that enables servers to deliver interactive user interfaces to end users. Instead of returning plain text that the LLM must interpret and relay, tools can render forms, profiles, and dashboards right in the chat using MCP Apps. diff --git a/pkg/inventory/registry.go b/pkg/inventory/registry.go index d54b3f12d..d147cbfc6 100644 --- a/pkg/inventory/registry.go +++ b/pkg/inventory/registry.go @@ -167,6 +167,19 @@ func (r *Inventory) ToolsetDescriptions() map[ToolsetID]string { return r.toolsetDescriptions } +// ToolsForRegistration returns AvailableTools(ctx) post-processed exactly as +// RegisterTools would expose them: with MCP Apps UI metadata stripped when +// the remote_mcp_ui_apps feature flag is not enabled in ctx. Useful for +// documentation generators and diagnostics that need the same view of the +// tool surface the server would register. +func (r *Inventory) ToolsForRegistration(ctx context.Context) []ServerTool { + tools := r.AvailableTools(ctx) + if !r.checkFeatureFlag(ctx, mcpAppsFeatureFlag) { + tools = stripMCPAppsMetadata(tools) + } + return tools +} + // RegisterTools registers all available tools with the server using the provided dependencies. // The context is used for feature flag evaluation. // @@ -177,11 +190,7 @@ func (r *Inventory) ToolsetDescriptions() map[ToolsetID]string { // from ctx would otherwise see context.Background() and falsely report the // flag off, even when the actual request arrived on the /insiders route. func (r *Inventory) RegisterTools(ctx context.Context, s *mcp.Server, deps any) { - tools := r.AvailableTools(ctx) - if !r.checkFeatureFlag(ctx, mcpAppsFeatureFlag) { - tools = stripMCPAppsMetadata(tools) - } - for _, tool := range tools { + for _, tool := range r.ToolsForRegistration(ctx) { tool.RegisterFunc(s, deps) } } From 4c6465e09815c6e760007790449c534fc8a9898d Mon Sep 17 00:00:00 2001 From: Iulia B Date: Fri, 22 May 2026 04:20:38 -0700 Subject: [PATCH 46/48] feat(issue-fields): expose fullDatabaseId (BigInt) in list_issue_fields - Add DatabaseID (int64) to IssueField struct, populated from fullDatabaseId BigInt scalar (returned as string) on all 4 concrete GQL union types - Repeat fullDatabaseId per union fragment (shurcooL/githubv4 cannot use interface-level fragments at union top-level) - Add parseFullDatabaseID helper to parse BigInt string to int64 - Update tests to assert DatabaseID is populated from fullDatabaseId --- pkg/github/issue_fields.go | 67 ++++++++++++++++++++++---------- pkg/github/issue_fields_test.go | 68 ++++++++++++++++++--------------- 2 files changed, 84 insertions(+), 51 deletions(-) diff --git a/pkg/github/issue_fields.go b/pkg/github/issue_fields.go index a7b7c429d..1eabbc02f 100644 --- a/pkg/github/issue_fields.go +++ b/pkg/github/issue_fields.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "strconv" ghcontext "github.com/github/github-mcp-server/pkg/context" ghErrors "github.com/github/github-mcp-server/pkg/errors" @@ -19,6 +20,7 @@ import ( // IssueField represents a repository issue field definition. type IssueField struct { ID string `json:"id"` + DatabaseID int64 `json:"full_database_id,omitempty"` Name string `json:"name"` Description string `json:"description,omitempty"` DataType string `json:"data_type"` @@ -37,36 +39,42 @@ type IssueSingleSelectFieldOption struct { // issueFieldNode is the GraphQL fragment for a single issue field in the IssueFields union. // Only the fragment matching __typename is populated; read from the matching fragment. +// fullDatabaseId (BigInt scalar, returned as string) is fetched on each concrete type because +// shurcooL/githubv4 does not support interface fragments at the top level of a union. type issueFieldNode struct { TypeName githubv4.String `graphql:"__typename"` IssueFieldText struct { - ID githubv4.ID - Name githubv4.String - Description githubv4.String - DataType githubv4.String - Visibility githubv4.String + ID githubv4.ID + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + Description githubv4.String + DataType githubv4.String + Visibility githubv4.String } `graphql:"... on IssueFieldText"` IssueFieldNumber struct { - ID githubv4.ID - Name githubv4.String - Description githubv4.String - DataType githubv4.String - Visibility githubv4.String + ID githubv4.ID + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + Description githubv4.String + DataType githubv4.String + Visibility githubv4.String } `graphql:"... on IssueFieldNumber"` IssueFieldDate struct { - ID githubv4.ID - Name githubv4.String - Description githubv4.String - DataType githubv4.String - Visibility githubv4.String + ID githubv4.ID + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + Description githubv4.String + DataType githubv4.String + Visibility githubv4.String } `graphql:"... on IssueFieldDate"` IssueFieldSingleSelect struct { - ID githubv4.ID - Name githubv4.String - Description githubv4.String - DataType githubv4.String - Visibility githubv4.String - Options []struct { + ID githubv4.ID + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + Description githubv4.String + DataType githubv4.String + Visibility githubv4.String + Options []struct { ID githubv4.ID Name githubv4.String Description githubv4.String @@ -200,6 +208,7 @@ func issueFieldsFromNodes(nodes []issueFieldNode) []IssueField { } f = IssueField{ ID: fmt.Sprintf("%v", node.IssueFieldSingleSelect.ID), + DatabaseID: parseFullDatabaseID(string(node.IssueFieldSingleSelect.FullDatabaseID)), Name: string(node.IssueFieldSingleSelect.Name), Description: string(node.IssueFieldSingleSelect.Description), DataType: string(node.IssueFieldSingleSelect.DataType), @@ -209,6 +218,7 @@ func issueFieldsFromNodes(nodes []issueFieldNode) []IssueField { case "IssueFieldText": f = IssueField{ ID: fmt.Sprintf("%v", node.IssueFieldText.ID), + DatabaseID: parseFullDatabaseID(string(node.IssueFieldText.FullDatabaseID)), Name: string(node.IssueFieldText.Name), Description: string(node.IssueFieldText.Description), DataType: string(node.IssueFieldText.DataType), @@ -217,6 +227,7 @@ func issueFieldsFromNodes(nodes []issueFieldNode) []IssueField { case "IssueFieldNumber": f = IssueField{ ID: fmt.Sprintf("%v", node.IssueFieldNumber.ID), + DatabaseID: parseFullDatabaseID(string(node.IssueFieldNumber.FullDatabaseID)), Name: string(node.IssueFieldNumber.Name), Description: string(node.IssueFieldNumber.Description), DataType: string(node.IssueFieldNumber.DataType), @@ -225,6 +236,7 @@ func issueFieldsFromNodes(nodes []issueFieldNode) []IssueField { case "IssueFieldDate": f = IssueField{ ID: fmt.Sprintf("%v", node.IssueFieldDate.ID), + DatabaseID: parseFullDatabaseID(string(node.IssueFieldDate.FullDatabaseID)), Name: string(node.IssueFieldDate.Name), Description: string(node.IssueFieldDate.Description), DataType: string(node.IssueFieldDate.DataType), @@ -237,3 +249,16 @@ func issueFieldsFromNodes(nodes []issueFieldNode) []IssueField { } return fields } + +// parseFullDatabaseID converts a BigInt scalar string (e.g. "12345") to int64. +// Returns 0 if the string is empty or cannot be parsed. +func parseFullDatabaseID(s string) int64 { + if s == "" { + return 0 + } + n, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return 0 + } + return n +} diff --git a/pkg/github/issue_fields_test.go b/pkg/github/issue_fields_test.go index 238c0455b..2c2b26ee2 100644 --- a/pkg/github/issue_fields_test.go +++ b/pkg/github/issue_fields_test.go @@ -75,12 +75,13 @@ func Test_ListIssueFields(t *testing.T) { "issueFields": map[string]any{ "nodes": []any{ map[string]any{ - "__typename": "IssueFieldText", - "id": "IFT_1", - "name": "DRI", - "description": "Directly responsible individual", - "dataType": "TEXT", - "visibility": "ORG_ONLY", + "__typename": "IssueFieldText", + "id": "IFT_1", + "fullDatabaseId": "42", + "name": "DRI", + "description": "Directly responsible individual", + "dataType": "TEXT", + "visibility": "ORG_ONLY", }, }, }, @@ -89,6 +90,7 @@ func Test_ListIssueFields(t *testing.T) { expectedFields: []IssueField{ { ID: "IFT_1", + DatabaseID: 42, Name: "DRI", Description: "Directly responsible individual", DataType: "TEXT", @@ -107,12 +109,13 @@ func Test_ListIssueFields(t *testing.T) { "issueFields": map[string]any{ "nodes": []any{ map[string]any{ - "__typename": "IssueFieldSingleSelect", - "id": "IFSS_1", - "name": "Priority", - "description": "Level of importance", - "dataType": "SINGLE_SELECT", - "visibility": "ALL", + "__typename": "IssueFieldSingleSelect", + "id": "IFSS_1", + "fullDatabaseId": "99", + "name": "Priority", + "description": "Level of importance", + "dataType": "SINGLE_SELECT", + "visibility": "ALL", "options": []any{ map[string]any{ "id": "OPT_1", @@ -133,6 +136,7 @@ func Test_ListIssueFields(t *testing.T) { expectedFields: []IssueField{ { ID: "IFSS_1", + DatabaseID: 99, Name: "Priority", Description: "Level of importance", DataType: "SINGLE_SELECT", @@ -165,18 +169,19 @@ func Test_ListIssueFields(t *testing.T) { "issueFields": map[string]any{ "nodes": []any{ map[string]any{ - "__typename": "IssueFieldText", - "id": "IFT_1", - "name": "DRI", - "dataType": "TEXT", - "visibility": "ORG_ONLY", + "__typename": "IssueFieldText", + "id": "IFT_1", + "fullDatabaseId": "77", + "name": "DRI", + "dataType": "TEXT", + "visibility": "ORG_ONLY", }, }, }, }, }), expectedFields: []IssueField{ - {ID: "IFT_1", Name: "DRI", DataType: "TEXT", Visibility: "ORG_ONLY"}, + {ID: "IFT_1", DatabaseID: 77, Name: "DRI", DataType: "TEXT", Visibility: "ORG_ONLY"}, }, }, { @@ -190,18 +195,19 @@ func Test_ListIssueFields(t *testing.T) { "issueFields": map[string]any{ "nodes": []any{ map[string]any{ - "__typename": "IssueFieldNumber", - "id": "IFN_1", - "name": "Engineering Staffing", - "dataType": "NUMBER", - "visibility": "ORG_ONLY", + "__typename": "IssueFieldNumber", + "id": "IFN_1", + "fullDatabaseId": "101", + "name": "Engineering Staffing", + "dataType": "NUMBER", + "visibility": "ORG_ONLY", }, }, }, }, }), expectedFields: []IssueField{ - {ID: "IFN_1", Name: "Engineering Staffing", DataType: "NUMBER", Visibility: "ORG_ONLY"}, + {ID: "IFN_1", DatabaseID: 101, Name: "Engineering Staffing", DataType: "NUMBER", Visibility: "ORG_ONLY"}, }, }, { @@ -215,18 +221,19 @@ func Test_ListIssueFields(t *testing.T) { "issueFields": map[string]any{ "nodes": []any{ map[string]any{ - "__typename": "IssueFieldDate", - "id": "IFD_1", - "name": "Target Date", - "dataType": "DATE", - "visibility": "ORG_ONLY", + "__typename": "IssueFieldDate", + "id": "IFD_1", + "fullDatabaseId": "202", + "name": "Target Date", + "dataType": "DATE", + "visibility": "ORG_ONLY", }, }, }, }, }), expectedFields: []IssueField{ - {ID: "IFD_1", Name: "Target Date", DataType: "DATE", Visibility: "ORG_ONLY"}, + {ID: "IFD_1", DatabaseID: 202, Name: "Target Date", DataType: "DATE", Visibility: "ORG_ONLY"}, }, }, { @@ -284,6 +291,7 @@ func Test_ListIssueFields(t *testing.T) { require.Equal(t, len(tc.expectedFields), len(returnedFields)) for i, expected := range tc.expectedFields { assert.Equal(t, expected.ID, returnedFields[i].ID) + assert.Equal(t, expected.DatabaseID, returnedFields[i].DatabaseID) assert.Equal(t, expected.Name, returnedFields[i].Name) assert.Equal(t, expected.DataType, returnedFields[i].DataType) assert.Equal(t, expected.Visibility, returnedFields[i].Visibility) From ecb3ef1eddaea362c05beb169ccd066f747dc24b Mon Sep 17 00:00:00 2001 From: Iulia B Date: Fri, 22 May 2026 04:27:37 -0700 Subject: [PATCH 47/48] feat(issue-fields): support issue_fields in issue_write using fullDatabaseId - Look up field IDs via fullDatabaseId (BigInt) from GQL issueFields query - Accept field values as strings; pass option names directly to REST (REST single-select expects option name string, not numeric option ID) - Add issue_fields parameter to issue_write schema with strict-mode additionalProperties:false - Wire field value resolution into CreateIssue and UpdateIssue --- pkg/github/__toolsnaps__/issue_write.snap | 22 +++ pkg/github/issues.go | 196 +++++++++++++++++++++- 2 files changed, 210 insertions(+), 8 deletions(-) diff --git a/pkg/github/__toolsnaps__/issue_write.snap b/pkg/github/__toolsnaps__/issue_write.snap index a125864f0..cd6166a2c 100644 --- a/pkg/github/__toolsnaps__/issue_write.snap +++ b/pkg/github/__toolsnaps__/issue_write.snap @@ -29,6 +29,28 @@ "description": "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.", "type": "number" }, + "issue_fields": { + "description": "Custom issue field values to set. Each entry specifies a field by name and its value. For single-select fields, value must be the option name (e.g. \"P1\"). For date fields, value must be YYYY-MM-DD.", + "items": { + "additionalProperties": false, + "properties": { + "field_name": { + "description": "Name of the custom field (case-insensitive).", + "type": "string" + }, + "value": { + "description": "Value to set. For single-select, the option name. For dates, YYYY-MM-DD. For numbers, the numeric value as a string.", + "type": "string" + } + }, + "required": [ + "field_name", + "value" + ], + "type": "object" + }, + "type": "array" + }, "issue_number": { "description": "Issue number to update", "type": "number" diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 0074bbd58..4fe8842da 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -105,6 +105,152 @@ func getCloseStateReason(stateReason string) IssueClosedStateReason { } } +// IssueWriteFieldInput is a user-supplied issue field assignment: a field name and a string value. +// The value is forwarded directly to the REST API — for single-select fields it must be the +// option name (e.g. "P1"), not an option ID. Field ID resolution is done internally via GQL. +type IssueWriteFieldInput struct { + FieldName string + Value string +} + +// issueFieldWriteMetadataNode queries only the fields needed to resolve a write: the field's +// fullDatabaseId (BigInt scalar, returned as string) plus its name and data type for validation. +// shurcooL/githubv4 cannot use interface-level fragments at union top-level, so we repeat +// fullDatabaseId on each concrete type; all four implement IssueFieldCommon. +type issueFieldWriteMetadataNode struct { + TypeName githubv4.String `graphql:"__typename"` + IssueFieldText struct { + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + DataType githubv4.String + } `graphql:"... on IssueFieldText"` + IssueFieldNumber struct { + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + DataType githubv4.String + } `graphql:"... on IssueFieldNumber"` + IssueFieldDate struct { + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + DataType githubv4.String + } `graphql:"... on IssueFieldDate"` + IssueFieldSingleSelect struct { + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + DataType githubv4.String + } `graphql:"... on IssueFieldSingleSelect"` +} + +// issueFieldWriteMetadata holds the resolved name, database ID, and data type for a single field. +type issueFieldWriteMetadata struct { + DatabaseID int64 + Name string + DataType string +} + +type issueFieldWriteMetadataQuery struct { + Repository struct { + IssueFields struct { + Nodes []issueFieldWriteMetadataNode + } `graphql:"issueFields(first: 100)"` + } `graphql:"repository(owner: $owner, name: $repo)"` +} + +func optionalIssueWriteFields(args map[string]any) ([]IssueWriteFieldInput, error) { + raw, exists := args["issue_fields"] + if !exists { + return nil, nil + } + + var inputMaps []map[string]any + switch v := raw.(type) { + case []any: + for _, item := range v { + m, ok := item.(map[string]any) + if !ok { + return nil, fmt.Errorf("each issue_fields item must be an object") + } + inputMaps = append(inputMaps, m) + } + case []map[string]any: + inputMaps = v + default: + return nil, fmt.Errorf("issue_fields must be an array") + } + + out := make([]IssueWriteFieldInput, 0, len(inputMaps)) + for _, m := range inputMaps { + fieldName, err := RequiredParam[string](m, "field_name") + if err != nil || strings.TrimSpace(fieldName) == "" { + return nil, fmt.Errorf("field_name is required for each issue_fields item") + } + value, err := RequiredParam[string](m, "value") + if err != nil { + return nil, fmt.Errorf("issue_fields item %q: value is required", fieldName) + } + out = append(out, IssueWriteFieldInput{FieldName: fieldName, Value: value}) + } + return out, nil +} + +func resolveIssueWriteFieldValues(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, inputs []IssueWriteFieldInput) ([]*github.IssueRequestFieldValue, error) { + if len(inputs) == 0 { + return nil, nil + } + + ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields") + var query issueFieldWriteMetadataQuery + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + } + if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil { + return nil, fmt.Errorf("failed to query issue field metadata: %w", err) + } + + // Build name → metadata map from the GQL response. + byName := make(map[string]issueFieldWriteMetadata, len(query.Repository.IssueFields.Nodes)) + for _, node := range query.Repository.IssueFields.Nodes { + var name, dataType, fullDBID string + switch string(node.TypeName) { + case "IssueFieldText": + name, dataType, fullDBID = string(node.IssueFieldText.Name), string(node.IssueFieldText.DataType), string(node.IssueFieldText.FullDatabaseID) + case "IssueFieldNumber": + name, dataType, fullDBID = string(node.IssueFieldNumber.Name), string(node.IssueFieldNumber.DataType), string(node.IssueFieldNumber.FullDatabaseID) + case "IssueFieldDate": + name, dataType, fullDBID = string(node.IssueFieldDate.Name), string(node.IssueFieldDate.DataType), string(node.IssueFieldDate.FullDatabaseID) + case "IssueFieldSingleSelect": + name, dataType, fullDBID = string(node.IssueFieldSingleSelect.Name), string(node.IssueFieldSingleSelect.DataType), string(node.IssueFieldSingleSelect.FullDatabaseID) + default: + continue + } + dbID, _ := strconv.ParseInt(fullDBID, 10, 64) + byName[strings.ToLower(strings.TrimSpace(name))] = issueFieldWriteMetadata{ + DatabaseID: dbID, + Name: name, + DataType: dataType, + } + } + + resolved := make([]*github.IssueRequestFieldValue, 0, len(inputs)) + for _, input := range inputs { + meta, ok := byName[strings.ToLower(strings.TrimSpace(input.FieldName))] + if !ok { + return nil, fmt.Errorf("issue field %q was not found in %s/%s", input.FieldName, owner, repo) + } + if meta.DatabaseID == 0 { + return nil, fmt.Errorf("issue field %q is missing fullDatabaseId", input.FieldName) + } + // For single-select the REST API expects the option name as a string value. + // For all other types, pass the value through as-is. + resolved = append(resolved, &github.IssueRequestFieldValue{ + FieldID: meta.DatabaseID, + Value: input.Value, + }) + } + return resolved, nil +} + // IssueFieldRef resolves the name of an issue field across its concrete types. // IssueFields is a union of IssueFieldDate, IssueFieldNumber, IssueFieldSingleSelect, IssueFieldText, // so we have to ask for `name` on each member. @@ -1509,6 +1655,25 @@ Options are: Type: "number", Description: "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.", }, + "issue_fields": { + Type: "array", + Description: "Custom issue field values to set. Each entry specifies a field by name and its value. For single-select fields, value must be the option name (e.g. \"P1\"). For date fields, value must be YYYY-MM-DD.", + Items: &jsonschema.Schema{ + Type: "object", + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + Properties: map[string]*jsonschema.Schema{ + "field_name": { + Type: "string", + Description: "Name of the custom field (case-insensitive).", + }, + "value": { + Type: "string", + Description: "Value to set. For single-select, the option name. For dates, YYYY-MM-DD. For numbers, the numeric value as a string.", + }, + }, + Required: []string{"field_name", "value"}, + }, + }, }, Required: []string{"method", "owner", "repo"}, }, @@ -1610,6 +1775,11 @@ Options are: return utils.NewToolResultError("duplicate_of can only be used when state_reason is 'duplicate'"), nil, nil } + issueFieldInputs, err := optionalIssueWriteFields(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil @@ -1620,16 +1790,21 @@ Options are: return utils.NewToolResultErrorFromErr("failed to get GraphQL client", err), nil, nil } + issueFieldValues, err := resolveIssueWriteFieldValues(ctx, gqlClient, owner, repo, issueFieldInputs) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to resolve issue_fields: %v", err)), nil, nil + } + switch method { case "create": - result, err := CreateIssue(ctx, client, owner, repo, title, body, assignees, labels, milestoneNum, issueType) + result, err := CreateIssue(ctx, client, owner, repo, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues) return result, nil, err case "update": issueNumber, err := RequiredInt(args, "issue_number") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, state, stateReason, duplicateOf) + result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues, state, stateReason, duplicateOf) return result, nil, err default: return utils.NewToolResultError("invalid method, must be either 'create' or 'update'"), nil, nil @@ -1639,17 +1814,18 @@ Options are: return st } -func CreateIssue(ctx context.Context, client *github.Client, owner string, repo string, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string) (*mcp.CallToolResult, error) { +func CreateIssue(ctx context.Context, client *github.Client, owner string, repo string, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string, issueFieldValues []*github.IssueRequestFieldValue) (*mcp.CallToolResult, error) { if title == "" { return utils.NewToolResultError("missing required parameter: title"), nil } // Create the issue request issueRequest := &github.IssueRequest{ - Title: github.Ptr(title), - Body: github.Ptr(body), - Assignees: &assignees, - Labels: &labels, + Title: github.Ptr(title), + Body: github.Ptr(body), + Assignees: &assignees, + Labels: &labels, + IssueFieldValues: issueFieldValues, } if milestoneNum != 0 { @@ -1692,7 +1868,7 @@ func CreateIssue(ctx context.Context, client *github.Client, owner string, repo return utils.NewToolResultText(string(r)), nil } -func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner string, repo string, issueNumber int, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string, state string, stateReason string, duplicateOf int) (*mcp.CallToolResult, error) { +func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner string, repo string, issueNumber int, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string, issueFieldValues []*github.IssueRequestFieldValue, state string, stateReason string, duplicateOf int) (*mcp.CallToolResult, error) { // Create the issue request with only provided fields issueRequest := &github.IssueRequest{} @@ -1721,6 +1897,10 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4 issueRequest.Type = github.Ptr(issueType) } + if len(issueFieldValues) > 0 { + issueRequest.IssueFieldValues = issueFieldValues + } + updatedIssue, resp, err := client.Issues.Edit(ctx, owner, repo, issueNumber, issueRequest) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, From 097829e898430bb860d99c8ff2b7ffeef9f0d9c4 Mon Sep 17 00:00:00 2001 From: Iulia B Date: Tue, 26 May 2026 06:43:32 -0700 Subject: [PATCH 48/48] fix merge of full db id --- pkg/github/issues.go | 193 ++++++++++---------------------------- pkg/github/issues_test.go | 50 +++++----- 2 files changed, 75 insertions(+), 168 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 55c40b65f..0e4ad9c2f 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -45,28 +45,6 @@ type IssueWriteFieldInput struct { FieldOptionName string } -type issueFieldMetadataOption struct { - DatabaseID githubv4.Int `graphql:"databaseId"` - Name githubv4.String -} - -type issueFieldMetadataNode struct { - DatabaseID githubv4.Int `graphql:"databaseId"` - Name githubv4.String - DataType githubv4.String - SingleSelectField struct { - Options []issueFieldMetadataOption `graphql:"options"` - } `graphql:"... on IssueFieldSingleSelect"` -} - -type issueFieldMetadataQuery struct { - Repository struct { - IssueFields struct { - Nodes []issueFieldMetadataNode - } `graphql:"issueFields(first: 100)"` - } `graphql:"repository(owner: $owner, name: $repo)"` -} - const ( IssueClosedStateReasonCompleted IssueClosedStateReason = "COMPLETED" IssueClosedStateReasonDuplicate IssueClosedStateReason = "DUPLICATE" @@ -135,14 +113,6 @@ func getCloseStateReason(stateReason string) IssueClosedStateReason { } } -// IssueWriteFieldInput is a user-supplied issue field assignment: a field name and a string value. -// The value is forwarded directly to the REST API — for single-select fields it must be the -// option name (e.g. "P1"), not an option ID. Field ID resolution is done internally via GQL. -type IssueWriteFieldInput struct { - FieldName string - Value string -} - // issueFieldWriteMetadataNode queries only the fields needed to resolve a write: the field's // fullDatabaseId (BigInt scalar, returned as string) plus its name and data type for validation. // shurcooL/githubv4 cannot use interface-level fragments at union top-level, so we repeat @@ -168,16 +138,13 @@ type issueFieldWriteMetadataNode struct { FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` Name githubv4.String DataType githubv4.String + Options []struct { + FullDatabaseID githubv4.String `graphql:"fullDatabaseId"` + Name githubv4.String + } } `graphql:"... on IssueFieldSingleSelect"` } -// issueFieldWriteMetadata holds the resolved name, database ID, and data type for a single field. -type issueFieldWriteMetadata struct { - DatabaseID int64 - Name string - DataType string -} - type issueFieldWriteMetadataQuery struct { Repository struct { IssueFields struct { @@ -186,101 +153,6 @@ type issueFieldWriteMetadataQuery struct { } `graphql:"repository(owner: $owner, name: $repo)"` } -func optionalIssueWriteFields(args map[string]any) ([]IssueWriteFieldInput, error) { - raw, exists := args["issue_fields"] - if !exists { - return nil, nil - } - - var inputMaps []map[string]any - switch v := raw.(type) { - case []any: - for _, item := range v { - m, ok := item.(map[string]any) - if !ok { - return nil, fmt.Errorf("each issue_fields item must be an object") - } - inputMaps = append(inputMaps, m) - } - case []map[string]any: - inputMaps = v - default: - return nil, fmt.Errorf("issue_fields must be an array") - } - - out := make([]IssueWriteFieldInput, 0, len(inputMaps)) - for _, m := range inputMaps { - fieldName, err := RequiredParam[string](m, "field_name") - if err != nil || strings.TrimSpace(fieldName) == "" { - return nil, fmt.Errorf("field_name is required for each issue_fields item") - } - value, err := RequiredParam[string](m, "value") - if err != nil { - return nil, fmt.Errorf("issue_fields item %q: value is required", fieldName) - } - out = append(out, IssueWriteFieldInput{FieldName: fieldName, Value: value}) - } - return out, nil -} - -func resolveIssueWriteFieldValues(ctx context.Context, gqlClient *githubv4.Client, owner, repo string, inputs []IssueWriteFieldInput) ([]*github.IssueRequestFieldValue, error) { - if len(inputs) == 0 { - return nil, nil - } - - ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields") - var query issueFieldWriteMetadataQuery - vars := map[string]any{ - "owner": githubv4.String(owner), - "repo": githubv4.String(repo), - } - if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil { - return nil, fmt.Errorf("failed to query issue field metadata: %w", err) - } - - // Build name → metadata map from the GQL response. - byName := make(map[string]issueFieldWriteMetadata, len(query.Repository.IssueFields.Nodes)) - for _, node := range query.Repository.IssueFields.Nodes { - var name, dataType, fullDBID string - switch string(node.TypeName) { - case "IssueFieldText": - name, dataType, fullDBID = string(node.IssueFieldText.Name), string(node.IssueFieldText.DataType), string(node.IssueFieldText.FullDatabaseID) - case "IssueFieldNumber": - name, dataType, fullDBID = string(node.IssueFieldNumber.Name), string(node.IssueFieldNumber.DataType), string(node.IssueFieldNumber.FullDatabaseID) - case "IssueFieldDate": - name, dataType, fullDBID = string(node.IssueFieldDate.Name), string(node.IssueFieldDate.DataType), string(node.IssueFieldDate.FullDatabaseID) - case "IssueFieldSingleSelect": - name, dataType, fullDBID = string(node.IssueFieldSingleSelect.Name), string(node.IssueFieldSingleSelect.DataType), string(node.IssueFieldSingleSelect.FullDatabaseID) - default: - continue - } - dbID, _ := strconv.ParseInt(fullDBID, 10, 64) - byName[strings.ToLower(strings.TrimSpace(name))] = issueFieldWriteMetadata{ - DatabaseID: dbID, - Name: name, - DataType: dataType, - } - } - - resolved := make([]*github.IssueRequestFieldValue, 0, len(inputs)) - for _, input := range inputs { - meta, ok := byName[strings.ToLower(strings.TrimSpace(input.FieldName))] - if !ok { - return nil, fmt.Errorf("issue field %q was not found in %s/%s", input.FieldName, owner, repo) - } - if meta.DatabaseID == 0 { - return nil, fmt.Errorf("issue field %q is missing fullDatabaseId", input.FieldName) - } - // For single-select the REST API expects the option name as a string value. - // For all other types, pass the value through as-is. - resolved = append(resolved, &github.IssueRequestFieldValue{ - FieldID: meta.DatabaseID, - Value: input.Value, - }) - } - return resolved, nil -} - // IssueFieldRef resolves the name of an issue field across its concrete types. // IssueFields is a union of IssueFieldDate, IssueFieldNumber, IssueFieldSingleSelect, IssueFieldText, // so we have to ask for `name` on each member. @@ -391,44 +263,75 @@ func resolveIssueRequestFieldValues(ctx context.Context, gqlClient *githubv4.Cli return nil, nil } - query := issueFieldMetadataQuery{} + ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "issue_fields", "repo_issue_fields") + var query issueFieldWriteMetadataQuery vars := map[string]any{ "owner": githubv4.String(owner), "repo": githubv4.String(repo), } - if err := gqlClient.Query(ctx, &query, vars); err != nil { + if err := gqlClient.Query(ctxWithFeatures, &query, vars); err != nil { return nil, fmt.Errorf("failed to query issue fields metadata: %w", err) } - fieldByName := make(map[string]issueFieldMetadataNode, len(query.Repository.IssueFields.Nodes)) - for _, field := range query.Repository.IssueFields.Nodes { - fieldByName[strings.ToLower(strings.TrimSpace(string(field.Name)))] = field + // Build name → node map, dispatching on concrete type to extract name. + fieldByName := make(map[string]issueFieldWriteMetadataNode, len(query.Repository.IssueFields.Nodes)) + for _, node := range query.Repository.IssueFields.Nodes { + var name string + switch string(node.TypeName) { + case "IssueFieldText": + name = string(node.IssueFieldText.Name) + case "IssueFieldNumber": + name = string(node.IssueFieldNumber.Name) + case "IssueFieldDate": + name = string(node.IssueFieldDate.Name) + case "IssueFieldSingleSelect": + name = string(node.IssueFieldSingleSelect.Name) + default: + continue + } + fieldByName[strings.ToLower(strings.TrimSpace(name))] = node } resolved := make([]*github.IssueRequestFieldValue, 0, len(issueFields)) for _, fieldInput := range issueFields { - field, ok := fieldByName[strings.ToLower(strings.TrimSpace(fieldInput.FieldName))] + node, ok := fieldByName[strings.ToLower(strings.TrimSpace(fieldInput.FieldName))] if !ok { return nil, fmt.Errorf("issue field %q was not found in %s/%s", fieldInput.FieldName, owner, repo) } - fieldID := int64(field.DatabaseID) + var fullDatabaseIDStr, dataType string + switch string(node.TypeName) { + case "IssueFieldText": + fullDatabaseIDStr = string(node.IssueFieldText.FullDatabaseID) + dataType = string(node.IssueFieldText.DataType) + case "IssueFieldNumber": + fullDatabaseIDStr = string(node.IssueFieldNumber.FullDatabaseID) + dataType = string(node.IssueFieldNumber.DataType) + case "IssueFieldDate": + fullDatabaseIDStr = string(node.IssueFieldDate.FullDatabaseID) + dataType = string(node.IssueFieldDate.DataType) + case "IssueFieldSingleSelect": + fullDatabaseIDStr = string(node.IssueFieldSingleSelect.FullDatabaseID) + dataType = string(node.IssueFieldSingleSelect.DataType) + } + + fieldID := parseFullDatabaseID(fullDatabaseIDStr) if fieldID == 0 { - return nil, fmt.Errorf("issue field %q is missing databaseId", fieldInput.FieldName) + return nil, fmt.Errorf("issue field %q is missing fullDatabaseId", fieldInput.FieldName) } resolvedValue := fieldInput.Value if fieldInput.FieldOptionName != "" { - if !strings.EqualFold(string(field.DataType), "single_select") { - return nil, fmt.Errorf("issue field %q is %q, so field_option_name cannot be used", fieldInput.FieldName, field.DataType) + if !strings.EqualFold(dataType, "single_select") { + return nil, fmt.Errorf("issue field %q is %q, so field_option_name cannot be used", fieldInput.FieldName, dataType) } optionFound := false - for _, option := range field.SingleSelectField.Options { + for _, option := range node.IssueFieldSingleSelect.Options { if strings.EqualFold(strings.TrimSpace(string(option.Name)), strings.TrimSpace(fieldInput.FieldOptionName)) { - optionID := int64(option.DatabaseID) + optionID := parseFullDatabaseID(string(option.FullDatabaseID)) if optionID == 0 { - return nil, fmt.Errorf("issue field option %q on field %q is missing databaseId", fieldInput.FieldOptionName, fieldInput.FieldName) + return nil, fmt.Errorf("issue field option %q on field %q is missing fullDatabaseId", fieldInput.FieldOptionName, fieldInput.FieldName) } resolvedValue = optionID optionFound = true diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 73dc105fd..1b51bd88f 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -1313,7 +1313,7 @@ func Test_CreateIssue(t *testing.T) { }), mockedGQLClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( - issueFieldMetadataQuery{}, + issueFieldWriteMetadataQuery{}, map[string]any{ "owner": githubv4.String("owner"), "repo": githubv4.String("repo"), @@ -1321,19 +1321,21 @@ func Test_CreateIssue(t *testing.T) { githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "issueFields": map[string]any{ - "nodes": []map[string]any{ - { - "databaseId": 101, - "name": "Priority", - "dataType": "single_select", - "options": []map[string]any{ - {"databaseId": 9001, "name": "P1"}, + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldSingleSelect", + "fullDatabaseId": "101", + "name": "Priority", + "dataType": "single_select", + "options": []any{ + map[string]any{"fullDatabaseId": "9001", "name": "P1"}, }, }, - { - "databaseId": 102, - "name": "Customer", - "dataType": "text", + map[string]any{ + "__typename": "IssueFieldText", + "fullDatabaseId": "102", + "name": "Customer", + "dataType": "text", }, }, }, @@ -2776,7 +2778,7 @@ func Test_UpdateIssue(t *testing.T) { }), mockedGQLClient: githubv4mock.NewMockedHTTPClient( githubv4mock.NewQueryMatcher( - issueFieldMetadataQuery{}, + issueFieldWriteMetadataQuery{}, map[string]any{ "owner": githubv4.String("owner"), "repo": githubv4.String("repo"), @@ -2784,17 +2786,19 @@ func Test_UpdateIssue(t *testing.T) { githubv4mock.DataResponse(map[string]any{ "repository": map[string]any{ "issueFields": map[string]any{ - "nodes": []map[string]any{ - { - "databaseId": 101, - "name": "Priority", - "dataType": "single_select", - "options": []map[string]any{{"databaseId": 9001, "name": "P1"}}, + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldSingleSelect", + "fullDatabaseId": "101", + "name": "Priority", + "dataType": "single_select", + "options": []any{map[string]any{"fullDatabaseId": "9001", "name": "P1"}}, }, - { - "databaseId": 102, - "name": "Customer", - "dataType": "text", + map[string]any{ + "__typename": "IssueFieldText", + "fullDatabaseId": "102", + "name": "Customer", + "dataType": "text", }, }, },