From a00dc319edcb5f8a10f118b1dad649c94928aac4 Mon Sep 17 00:00:00 2001 From: Tommaso Moro <37270480+tommaso-moro@users.noreply.github.com> Date: Wed, 26 Aug 2026 16:12:13 +0100 Subject: [PATCH 01/61] Remove documentation for unavailable tool-search command (#3162) * fix(cli): register tool search command Wire the documented tool-search subcommand to the existing discovery engine and configured inventory. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * docs: remove unavailable tool-search command Remove the CLI section that advertised a subcommand the binary does not provide. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f1d58829-0794-4caa-aa18-86543d177609 --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f1d58829-0794-4caa-aa18-86543d177609 --- README.md | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/README.md b/README.md index d8d8695d2a..145281bcdb 100644 --- a/README.md +++ b/README.md @@ -421,17 +421,6 @@ If you don't have Docker, you can use `go build` to build the binary in the } ``` -### CLI utilities - -The `github-mcp-server` binary includes a few CLI subcommands that are helpful for debugging and exploring the server. - -- `github-mcp-server tool-search ""` searches tools by name, description, and input parameter names. Use `--max-results` to return more matches. -Example (color output requires a TTY; use `docker run -t` (or `-it`) when running in Docker): -```bash -docker run -it --rm ghcr.io/github/github-mcp-server tool-search "issue" --max-results 5 -github-mcp-server tool-search "issue" --max-results 5 -``` - ## Tool Configuration The GitHub MCP Server supports enabling or disabling specific groups of functionalities via the `--toolsets` flag. This allows you to control which GitHub API capabilities are available to your AI tools. Enabling only the toolsets that you need can help the LLM with tool choice and reduce the context size. From febc3293a4feb70e62399f39a26b082f78b9b176 Mon Sep 17 00:00:00 2001 From: Annie Gesellchen Date: Thu, 27 Aug 2026 02:05:08 -0700 Subject: [PATCH 02/61] Expose Copilot review thread resolution reasons (#3123) --- docs/feature-flags.md | 14 +++++ pkg/github/feature_flags.go | 4 ++ pkg/github/feature_flags_test.go | 71 ++++++++++++++++++++++ pkg/github/granular_tools_test.go | 90 +++++++++++++++++---------- pkg/github/pullrequests.go | 71 ++++++++++++++++++---- pkg/github/pullrequests_granular.go | 61 ++++++++++++++++--- pkg/github/pullrequests_test.go | 94 ++++++++++++++++++++++++++--- pkg/github/tools.go | 7 ++- pkg/inventory/filters.go | 13 +++- pkg/inventory/server_tool.go | 6 +- 10 files changed, 365 insertions(+), 66 deletions(-) diff --git a/docs/feature-flags.md b/docs/feature-flags.md index f0bf7df984..0ed3f9dc0e 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -354,4 +354,18 @@ runtime behavior (such as output formatting) won't appear here. - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: The name of the repository (string, required) +### `thread_resolution_reason` + +- **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) + - `event`: Review action to perform. (string, optional) + - `method`: The write operation to perform on pull request review. (string, required) + - `owner`: Repository owner (string, required) + - `pullNumber`: Pull request number (number, required) + - `repo`: Repository name (string, required) + - `resolutionReason`: Optional reason for resolving a Copilot code review thread: addressed, wont-fix, or invalid. (string, optional) + - `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) + diff --git a/pkg/github/feature_flags.go b/pkg/github/feature_flags.go index 4ecd42b653..27202c5c83 100644 --- a/pkg/github/feature_flags.go +++ b/pkg/github/feature_flags.go @@ -34,6 +34,9 @@ const FeatureFlagIssueDependencies = "issue_dependencies" // opt-in. const FeatureFlagDuplicateDetection = "duplicate_detection" +// FeatureFlagThreadResolutionReason exposes resolution reasons for Copilot review threads. +const FeatureFlagThreadResolutionReason = "thread_resolution_reason" + // 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. @@ -48,6 +51,7 @@ var AllowedFeatureFlags = []string{ FeatureFlagFileBlame, FeatureFlagIssueDependencies, FeatureFlagDuplicateDetection, + FeatureFlagThreadResolutionReason, } // InsidersFeatureFlags is the list of feature flags that insiders mode enables. diff --git a/pkg/github/feature_flags_test.go b/pkg/github/feature_flags_test.go index 5a67b4bf21..dafacfa79a 100644 --- a/pkg/github/feature_flags_test.go +++ b/pkg/github/feature_flags_test.go @@ -6,6 +6,7 @@ import ( "testing" "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" @@ -200,6 +201,11 @@ func TestResolveFeatureFlags(t *testing.T) { insidersMode: false, expectedFlags: []string{FeatureFlagIssuesGranular}, }, + { + name: "thread resolution reason can be directly enabled", + enabledFeatures: []string{FeatureFlagThreadResolutionReason}, + expectedFlags: []string{FeatureFlagThreadResolutionReason}, + }, { name: "insiders does not enable user-only allowed flags", enabledFeatures: nil, @@ -227,3 +233,68 @@ func TestResolveFeatureFlags(t *testing.T) { }) } } + +func TestThreadResolutionReasonToolVariants(t *testing.T) { + tests := []struct { + name string + flags []string + host utils.HostType + toolName string + hasReason bool + }{ + { + name: "consolidated flag off", + toolName: "pull_request_review_write", + }, + { + name: "consolidated flag on", + flags: []string{FeatureFlagThreadResolutionReason}, + toolName: "pull_request_review_write", + hasReason: true, + }, + { + name: "granular flag off", + flags: []string{FeatureFlagPullRequestsGranular}, + toolName: "resolve_review_thread", + }, + { + name: "granular flag on", + flags: []string{FeatureFlagPullRequestsGranular, FeatureFlagThreadResolutionReason}, + toolName: "resolve_review_thread", + hasReason: true, + }, + { + name: "consolidated flag on GHES", + flags: []string{FeatureFlagThreadResolutionReason}, + host: utils.HostTypeGHES, + toolName: "pull_request_review_write", + }, + { + name: "granular flag on GHES", + flags: []string{FeatureFlagPullRequestsGranular, FeatureFlagThreadResolutionReason}, + host: utils.HostTypeGHES, + toolName: "resolve_review_thread", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + inv, err := NewInventory(translations.NullTranslationHelper, WithHost(tt.host)). + WithToolsets([]string{"all"}). + WithFeatureChecker(featureCheckerFor(tt.flags...)). + Build() + require.NoError(t, err) + + var matches []inventory.ServerTool + for _, tool := range inv.AvailableTools(context.Background()) { + if tool.Tool.Name == tt.toolName { + matches = append(matches, tool) + } + } + require.Len(t, matches, 1) + schema := matches[0].Tool.InputSchema.(*jsonschema.Schema) + _, hasReason := schema.Properties["resolutionReason"] + assert.Equal(t, tt.hasReason, hasReason) + }) + } +} diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go index d70dd568dc..5ef0c0a662 100644 --- a/pkg/github/granular_tools_test.go +++ b/pkg/github/granular_tools_test.go @@ -23,7 +23,7 @@ import ( func granularToolsForToolset(toolsetID inventory.ToolsetID, featureFlag string) []inventory.ServerTool { var result []inventory.ServerTool for _, tool := range AllTools(translations.NullTranslationHelper) { - if tool.Toolset.ID == toolsetID && tool.FeatureFlagEnable == featureFlag { + if tool.Toolset.ID == toolsetID && tool.FeatureFlagEnable == featureFlag && len(tool.FeatureFlagEnableAll) == 0 { result = append(result, tool) } } @@ -1728,38 +1728,64 @@ func TestGranularAddPullRequestReviewComment(t *testing.T) { } func TestGranularResolveReviewThread(t *testing.T) { - mockedClient := githubv4mock.NewMockedHTTPClient( - githubv4mock.NewMutationMatcher( - struct { - ResolveReviewThread struct { - Thread struct { - ID githubv4.ID - IsResolved githubv4.Boolean - } - } `graphql:"resolveReviewThread(input: $input)"` - }{}, - githubv4.ResolveReviewThreadInput{ - ThreadID: githubv4.ID("PRRT_123"), - }, - nil, - githubv4mock.DataResponse(map[string]any{ - "resolveReviewThread": map[string]any{ - "thread": map[string]any{"id": "PRRT_123", "isResolved": true}, - }, - }), - ), - ) - gqlClient := githubv4.NewClient(mockedClient) - deps := BaseDeps{GQLClient: gqlClient} - serverTool := GranularResolveReviewThread(translations.NullTranslationHelper) - handler := serverTool.Handler(deps) + tests := []struct { + name string + withResolutionReason bool + resolutionReason *string + expectedReason *string + }{ + { + name: "enabled variant forwards resolution reason", + withResolutionReason: true, + resolutionReason: gogithub.Ptr("addressed"), + expectedReason: gogithub.Ptr("addressed"), + }, + {name: "default variant omits resolution reason", resolutionReason: gogithub.Ptr("addressed")}, + {name: "without resolution reason"}, + } - request := createMCPRequest(map[string]any{ - "threadID": "PRRT_123", - }) - 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) { + mockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + ResolveReviewThread struct { + Thread struct { + ID githubv4.ID + IsResolved githubv4.Boolean + } + } `graphql:"resolveReviewThread(input: $input)"` + }{}, + ResolveReviewThreadInput{ + ThreadID: githubv4.ID("PRRT_123"), + ResolutionReason: newGQLStringlikePtr[githubv4.String](tc.expectedReason), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "resolveReviewThread": map[string]any{ + "thread": map[string]any{"id": "PRRT_123", "isResolved": true}, + }, + }), + ), + ) + gqlClient := githubv4.NewClient(mockedClient) + deps := BaseDeps{GQLClient: gqlClient} + serverTool := GranularResolveReviewThread(translations.NullTranslationHelper) + if tc.withResolutionReason { + serverTool = GranularResolveReviewThreadWithResolutionReason(translations.NullTranslationHelper) + } + handler := serverTool.Handler(deps) + + args := map[string]any{"threadID": "PRRT_123"} + if tc.resolutionReason != nil { + args["resolutionReason"] = *tc.resolutionReason + } + request := createMCPRequest(args) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + } } func TestGranularUnresolveReviewThread(t *testing.T) { diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 38e6e3d820..afd2778510 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -1760,17 +1760,34 @@ func UpdatePullRequestBranch(t translations.TranslationHelperFunc) inventory.Ser } type PullRequestReviewWriteParams struct { - Method string - Owner string - Repo string - PullNumber int32 - Body string - Event string - CommitID *string - ThreadID string + Method string + Owner string + Repo string + PullNumber int32 + Body string + Event string + CommitID *string + ThreadID string + ResolutionReason *string } func PullRequestReviewWrite(t translations.TranslationHelperFunc) inventory.ServerTool { + return pullRequestReviewWrite(t, false, toolConfig{}) +} + +// PullRequestReviewWriteWithResolutionReason creates the feature-gated review write variant with resolution reasons. +func PullRequestReviewWriteWithResolutionReason(t translations.TranslationHelperFunc, opts ...ToolOption) inventory.ServerTool { + cfg := newToolConfig(opts) + st := pullRequestReviewWrite(t, true, cfg) + if cfg.hostType == utils.HostTypeGHES { + st.Enabled = func(context.Context) (bool, error) { return false, nil } + } + return st +} + +func pullRequestReviewWrite(t translations.TranslationHelperFunc, withResolutionReason bool, cfg toolConfig) inventory.ServerTool { + withResolutionReason = withResolutionReason && cfg.hostType != utils.HostTypeGHES + schema := &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ @@ -1814,6 +1831,12 @@ func PullRequestReviewWrite(t translations.TranslationHelperFunc) inventory.Serv }, Required: []string{"method", "owner", "repo", "pullNumber"}, } + if withResolutionReason { + schema.Properties["resolutionReason"] = &jsonschema.Schema{ + Type: "string", + Description: "Optional reason for resolving a Copilot code review thread: addressed, wont-fix, or invalid.", + } + } st := NewTool( ToolsetMetadataPullRequests, @@ -1858,7 +1881,11 @@ Available methods: result, err := DeletePendingPullRequestReview(ctx, client, params) return result, nil, err case "resolve_thread": - result, err := ResolveReviewThread(ctx, client, params.ThreadID, true) + if !withResolutionReason { + result, err := ResolveReviewThread(ctx, client, params.ThreadID, true) + return result, nil, err + } + result, err := ResolveReviewThreadWithReason(ctx, client, params.ThreadID, params.ResolutionReason, true) return result, nil, err case "unresolve_thread": result, err := ResolveReviewThread(ctx, client, params.ThreadID, false) @@ -1867,7 +1894,15 @@ Available methods: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", params.Method)), nil, nil } }) - st.FeatureFlagDisable = []string{FeatureFlagPullRequestsGranular} + if withResolutionReason { + st.FeatureFlagEnable = FeatureFlagThreadResolutionReason + st.FeatureFlagDisable = []string{FeatureFlagPullRequestsGranular} + } else { + st.FeatureFlagDisable = []string{FeatureFlagPullRequestsGranular} + if cfg.hostType != utils.HostTypeGHES { + st.FeatureFlagDisable = append(st.FeatureFlagDisable, FeatureFlagThreadResolutionReason) + } + } return st } @@ -2094,8 +2129,19 @@ func DeletePendingPullRequestReview(ctx context.Context, client *githubv4.Client return utils.NewToolResultText("pending pull request review successfully deleted"), nil } +// ResolveReviewThreadInput is the GraphQL input for resolving a review thread. +type ResolveReviewThreadInput struct { + ThreadID githubv4.ID `json:"threadId"` + ResolutionReason *githubv4.String `json:"resolutionReason,omitempty"` +} + // ResolveReviewThread resolves or unresolves a PR review thread using GraphQL mutations. func ResolveReviewThread(ctx context.Context, client *githubv4.Client, threadID string, resolve bool) (*mcp.CallToolResult, error) { + return ResolveReviewThreadWithReason(ctx, client, threadID, nil, resolve) +} + +// ResolveReviewThreadWithReason resolves or unresolves a PR review thread with an optional resolution reason. +func ResolveReviewThreadWithReason(ctx context.Context, client *githubv4.Client, threadID string, resolutionReason *string, resolve bool) (*mcp.CallToolResult, error) { if threadID == "" { return utils.NewToolResultError("threadId is required for resolve_thread and unresolve_thread methods"), nil } @@ -2110,8 +2156,9 @@ func ResolveReviewThread(ctx context.Context, client *githubv4.Client, threadID } `graphql:"resolveReviewThread(input: $input)"` } - input := githubv4.ResolveReviewThreadInput{ - ThreadID: githubv4.ID(threadID), + input := ResolveReviewThreadInput{ + ThreadID: githubv4.ID(threadID), + ResolutionReason: newGQLStringlikePtr[githubv4.String](resolutionReason), } if err := client.Mutate(ctx, &mutation, input, nil); err != nil { diff --git a/pkg/github/pullrequests_granular.go b/pkg/github/pullrequests_granular.go index 5da812f271..3a4b9fc810 100644 --- a/pkg/github/pullrequests_granular.go +++ b/pkg/github/pullrequests_granular.go @@ -672,6 +672,35 @@ func GranularAddPullRequestReviewComment(t translations.TranslationHelperFunc) i // GranularResolveReviewThread creates a tool to resolve a review thread. func GranularResolveReviewThread(t translations.TranslationHelperFunc) inventory.ServerTool { + return granularResolveReviewThread(t, false, toolConfig{}) +} + +// GranularResolveReviewThreadWithResolutionReason creates the feature-gated variant with resolution reasons. +func GranularResolveReviewThreadWithResolutionReason(t translations.TranslationHelperFunc, opts ...ToolOption) inventory.ServerTool { + cfg := newToolConfig(opts) + st := granularResolveReviewThread(t, true, cfg) + if cfg.hostType == utils.HostTypeGHES { + st.Enabled = func(context.Context) (bool, error) { return false, nil } + } + return st +} + +func granularResolveReviewThread(t translations.TranslationHelperFunc, withResolutionReason bool, cfg toolConfig) inventory.ServerTool { + withResolutionReason = withResolutionReason && cfg.hostType != utils.HostTypeGHES + + properties := map[string]*jsonschema.Schema{ + "threadID": { + Type: "string", + Description: "The node ID of the review thread to resolve (e.g., PRRT_kwDOxxx)", + }, + } + if withResolutionReason { + properties["resolutionReason"] = &jsonschema.Schema{ + Type: "string", + Description: "Optional reason for resolving a Copilot code review thread: addressed, wont-fix, or invalid.", + } + } + st := NewTool( ToolsetMetadataPullRequests, mcp.Tool{ @@ -684,14 +713,9 @@ func GranularResolveReviewThread(t translations.TranslationHelperFunc) inventory OpenWorldHint: jsonschema.Ptr(true), }, InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "threadID": { - Type: "string", - Description: "The node ID of the review thread to resolve (e.g., PRRT_kwDOxxx)", - }, - }, - Required: []string{"threadID"}, + Type: "object", + Properties: properties, + Required: []string{"threadID"}, }, }, scopes.RequireAll(scopes.Repo), @@ -700,17 +724,36 @@ func GranularResolveReviewThread(t translations.TranslationHelperFunc) inventory if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + var resolutionReasonPtr *string + if withResolutionReason { + resolutionReason, hasResolutionReason, err := OptionalParamOK[string](args, "resolutionReason") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if hasResolutionReason { + resolutionReasonPtr = &resolutionReason + } + } gqlClient, err := deps.GetGQLClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil } - result, err := ResolveReviewThread(ctx, gqlClient, threadID, true) + if !withResolutionReason { + result, err := ResolveReviewThread(ctx, gqlClient, threadID, true) + return result, nil, err + } + result, err := ResolveReviewThreadWithReason(ctx, gqlClient, threadID, resolutionReasonPtr, true) return result, nil, err }, ) st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + if withResolutionReason { + st.FeatureFlagEnableAll = []string{FeatureFlagThreadResolutionReason} + } else if cfg.hostType != utils.HostTypeGHES { + st.FeatureFlagDisable = []string{FeatureFlagThreadResolutionReason} + } return st } diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 03ec851cf4..92fb4b24a3 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -4546,12 +4546,13 @@ func TestResolveReviewThread(t *testing.T) { t.Parallel() tests := []struct { - name string - requestArgs map[string]any - mockedClient *http.Client - expectToolError bool - expectedToolErrMsg string - expectedResult string + name string + requestArgs map[string]any + mockedClient *http.Client + withResolutionReason bool + expectToolError bool + expectedToolErrMsg string + expectedResult string }{ { name: "successful resolve thread", @@ -4572,7 +4573,81 @@ func TestResolveReviewThread(t *testing.T) { } } `graphql:"resolveReviewThread(input: $input)"` }{}, - githubv4.ResolveReviewThreadInput{ + ResolveReviewThreadInput{ + ThreadID: githubv4.ID("PRRT_kwDOTest123"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "resolveReviewThread": map[string]any{ + "thread": map[string]any{ + "id": "PRRT_kwDOTest123", + "isResolved": true, + }, + }, + }), + ), + ), + expectedResult: "review thread resolved successfully", + }, + { + name: "successful resolve thread with resolution reason", + withResolutionReason: true, + requestArgs: map[string]any{ + "method": "resolve_thread", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "threadId": "PRRT_kwDOTest123", + "resolutionReason": "wont-fix", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + ResolveReviewThread struct { + Thread struct { + ID githubv4.ID + IsResolved githubv4.Boolean + } + } `graphql:"resolveReviewThread(input: $input)"` + }{}, + ResolveReviewThreadInput{ + ThreadID: githubv4.ID("PRRT_kwDOTest123"), + ResolutionReason: newGQLStringlike[githubv4.String]("wont-fix"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "resolveReviewThread": map[string]any{ + "thread": map[string]any{ + "id": "PRRT_kwDOTest123", + "isResolved": true, + }, + }, + }), + ), + ), + expectedResult: "review thread resolved successfully", + }, + { + name: "default variant omits resolution reason", + requestArgs: map[string]any{ + "method": "resolve_thread", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "threadId": "PRRT_kwDOTest123", + "resolutionReason": "wont-fix", + }, + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewMutationMatcher( + struct { + ResolveReviewThread struct { + Thread struct { + ID githubv4.ID + IsResolved githubv4.Boolean + } + } `graphql:"resolveReviewThread(input: $input)"` + }{}, + ResolveReviewThreadInput{ ThreadID: githubv4.ID("PRRT_kwDOTest123"), }, nil, @@ -4692,7 +4767,7 @@ func TestResolveReviewThread(t *testing.T) { } } `graphql:"resolveReviewThread(input: $input)"` }{}, - githubv4.ResolveReviewThreadInput{ + ResolveReviewThreadInput{ ThreadID: githubv4.ID("PRRT_invalid"), }, nil, @@ -4711,6 +4786,9 @@ func TestResolveReviewThread(t *testing.T) { // Setup client with mock client := githubv4.NewClient(tc.mockedClient) serverTool := PullRequestReviewWrite(translations.NullTranslationHelper) + if tc.withResolutionReason { + serverTool = PullRequestReviewWriteWithResolutionReason(translations.NullTranslationHelper) + } deps := BaseDeps{ GQLClient: client, } diff --git a/pkg/github/tools.go b/pkg/github/tools.go index f9b51159b5..8d568878db 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -210,6 +210,7 @@ func newToolConfig(opts []ToolOption) toolConfig { // AllTools returns all tools with their embedded toolset metadata. // Tool functions return ServerTool directly with toolset info. func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []inventory.ServerTool { + cfg := newToolConfig(opts) return withCSVOutput([]inventory.ServerTool{ // Context tools GetMe(t), @@ -272,7 +273,8 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent UpdatePullRequestBranch(t), CreatePullRequest(t), UpdatePullRequest(t), - PullRequestReviewWrite(t), + pullRequestReviewWrite(t, false, cfg), + PullRequestReviewWriteWithResolutionReason(t, opts...), AddCommentToPendingReview(t), AddReplyToPullRequestComment(t), @@ -371,7 +373,8 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent GranularSubmitPendingPullRequestReview(t), GranularDeletePendingPullRequestReview(t), GranularAddPullRequestReviewComment(t), - GranularResolveReviewThread(t), + granularResolveReviewThread(t, false, cfg), + GranularResolveReviewThreadWithResolutionReason(t, opts...), GranularUnresolveReviewThread(t), GranularAddPullRequestReviewCommentReaction(t), }) diff --git a/pkg/inventory/filters.go b/pkg/inventory/filters.go index fd3579fa6f..96e46f215f 100644 --- a/pkg/inventory/filters.go +++ b/pkg/inventory/filters.go @@ -43,6 +43,7 @@ func (r *Inventory) checkFeatureFlag(ctx context.Context, flagName string) bool // installed when WithFeatureChecker received a non-nil checker). // // - If FeatureFlagEnable is set, the item is only allowed if the flag is enabled. +// - Every FeatureFlagEnableAll entry must also be enabled. // - If FeatureFlagDisable is non-empty, the item is excluded if any listed flag is enabled. func featureFlagAllowed(ctx context.Context, checker FeatureFlagChecker, enableFlag string, disableFlags []string) bool { // Error semantics match the previous checkFeatureFlag helper: a checker @@ -64,14 +65,22 @@ func featureFlagAllowed(ctx context.Context, checker FeatureFlagChecker, enableF } // createFeatureFlagFilter returns a ToolFilter that gates tools on their -// FeatureFlagEnable / FeatureFlagDisable annotations using the given checker. +// FeatureFlagEnable / FeatureFlagEnableAll / 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 + if !featureFlagAllowed(ctx, checker, tool.FeatureFlagEnable, tool.FeatureFlagDisable) { + return false, nil + } + for _, flag := range tool.FeatureFlagEnableAll { + if !featureFlagAllowed(ctx, checker, flag, nil) { + return false, nil + } + } + return true, nil } } diff --git a/pkg/inventory/server_tool.go b/pkg/inventory/server_tool.go index 110a803fe2..9c458c3d12 100644 --- a/pkg/inventory/server_tool.go +++ b/pkg/inventory/server_tool.go @@ -98,13 +98,17 @@ type ServerTool struct { // to be available. If set and the flag is not enabled, the tool is omitted. FeatureFlagEnable string + // FeatureFlagEnableAll specifies additional feature flags that must all be enabled + // for this tool to be available. + FeatureFlagEnableAll []string + // FeatureFlagDisable specifies feature flags that, when any is enabled, cause this // tool to be omitted. Used to disable tools when a feature flag is on. FeatureFlagDisable []string // Enabled is an optional function called at build/filter time to determine // if this tool should be available. If nil, the tool is considered enabled - // (subject to FeatureFlagEnable/FeatureFlagDisable checks). + // (subject to feature flag checks). // The context carries request-scoped information for the consumer to use. // Returns (enabled, error). On error, the tool should be treated as disabled. Enabled func(ctx context.Context) (bool, error) From 5a3c558bceeff301fe969f8c2c11d0829a01726c Mon Sep 17 00:00:00 2001 From: Dylan Pulver <35541198+dylanpulver@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:05:10 +0300 Subject: [PATCH 03/61] fix(repos): give create_or_update_file callers a SHA they can actually get (#3131) * fix(repos): give create_or_update_file callers a SHA they can actually get The create_or_update_file tool description and both of its SHA errors told the caller to run `git rev-parse :`. The caller is an MCP client talking to the GitHub API, and the same description tells it not to use this tool for local file operations, so it has no working tree to run that command against. Point the description at get_file_contents instead, which returns the blob SHA over the API. In the already-exists error the server has just fetched the file, so return that SHA directly rather than asking for a round trip. The stale-SHA error already interpolates the current SHA, so it only needed the impossible instruction removed. * fix(repos): stop disclosing the blob SHA when no sha was supplied The already-exists path is reached only when the caller sent no sha, so it has not read the file. Returning the current blob SHA there let it overwrite content it never saw on the next call, which is the race the SHA gate exists to prevent. Send the caller to get_file_contents for the path and ref instead, so obtaining the SHA still requires reading the file. The stale-SHA path is unaffected: the caller did supply a sha, and that message already reported the current SHA before this change. Only its recovery step moved off git rev-parse, and it is now imperative rather than conditional, since a stale SHA means the file definitely changed. Assert the already-exists error does not contain the blob SHA so the gate cannot be loosened again without a test failing. * fix(repos): make SHA recovery guidance actionable Map create_or_update_file branch arguments to get_file_contents ref arguments, identify where the requested path SHA is returned, and cover symlink, branch, and API error behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Dylan Pulver Co-authored-by: Sam Morrow Co-authored-by: Sam Morrow Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- docs/feature-flags.md | 2 +- .../__toolsnaps__/create_or_update_file.snap | 4 +- pkg/github/repositories.go | 17 +-- pkg/github/repositories_test.go | 108 +++++++++++++++--- 5 files changed, 109 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 145281bcdb..555284a370 100644 --- a/README.md +++ b/README.md @@ -1272,7 +1272,7 @@ The following sets of tools are available: - `owner`: Repository owner (username or organization) (string, required) - `path`: Path where to create/update the file (string, required) - `repo`: Repository name (string, required) - - `sha`: The blob SHA of the file being replaced. Required if the file already exists. (string, optional) + - `sha`: The blob SHA of the file being replaced. Required if the file already exists. Retrieve it with get_file_contents using the same owner, repo, and path, with ref set to this tool's branch value. (string, optional) - **create_repository** - Create repository - **OAuth Challenge Scopes**: `repo` diff --git a/docs/feature-flags.md b/docs/feature-flags.md index 0ed3f9dc0e..77ad68f0b2 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -357,7 +357,7 @@ runtime behavior (such as output formatting) won't appear here. ### `thread_resolution_reason` - **pull_request_review_write** - Write operations (create, submit, delete) on pull request reviews - - **Required OAuth Scopes**: `repo` + - **OAuth Challenge Scopes**: `repo` - `body`: Review comment text (string, optional) - `commitID`: SHA of commit to review (string, optional) - `event`: Review action to perform. (string, optional) diff --git a/pkg/github/__toolsnaps__/create_or_update_file.snap b/pkg/github/__toolsnaps__/create_or_update_file.snap index 37bf3b46bf..faf468567d 100644 --- a/pkg/github/__toolsnaps__/create_or_update_file.snap +++ b/pkg/github/__toolsnaps__/create_or_update_file.snap @@ -4,7 +4,7 @@ "readOnlyHint": false, "title": "Create or update file" }, - "description": "Create or update a single file in a GitHub repository. \nIf updating, you should provide the SHA of the file you want to update. Use this tool to create or update a file in a GitHub repository remotely; do not use it for local file operations.\n\nIn order to obtain the SHA of original file version before updating, use the following git command:\ngit rev-parse \u003cbranch\u003e:\u003cpath to file\u003e\n\nSHA MUST be provided for existing file updates.\n", + "description": "Create or update a single file in a GitHub repository. \nIf updating, you should provide the SHA of the file you want to update. Use this tool to create or update a file in a GitHub repository remotely; do not use it for local file operations.\n\nTo obtain the current blob SHA before updating, call the get_file_contents tool with the same owner, repo, and path, and set its ref parameter to this tool's branch value. The first text result reports the blob SHA for the requested path.\n\nSHA MUST be provided for existing file updates.\n", "inputSchema": { "properties": { "allow_symlink_write": { @@ -37,7 +37,7 @@ "type": "string" }, "sha": { - "description": "The blob SHA of the file being replaced. Required if the file already exists.", + "description": "The blob SHA of the file being replaced. Required if the file already exists. Retrieve it with get_file_contents using the same owner, repo, and path, with ref set to this tool's branch value.", "type": "string" } }, diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 8575d994cc..33f1237f91 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -412,8 +412,7 @@ func CreateOrUpdateFile(t translations.TranslationHelperFunc) inventory.ServerTo Description: t("TOOL_CREATE_OR_UPDATE_FILE_DESCRIPTION", `Create or update a single file in a GitHub repository. If updating, you should provide the SHA of the file you want to update. Use this tool to create or update a file in a GitHub repository remotely; do not use it for local file operations. -In order to obtain the SHA of original file version before updating, use the following git command: -git rev-parse : +To obtain the current blob SHA before updating, call the get_file_contents tool with the same owner, repo, and path, and set its ref parameter to this tool's branch value. The first text result reports the blob SHA for the requested path. SHA MUST be provided for existing file updates. `), @@ -450,7 +449,7 @@ SHA MUST be provided for existing file updates. }, "sha": { Type: "string", - Description: "The blob SHA of the file being replaced. Required if the file already exists.", + Description: "The blob SHA of the file being replaced. Required if the file already exists. Retrieve it with get_file_contents using the same owner, repo, and path, with ref set to this tool's branch value.", }, "allow_symlink_write": { Type: "boolean", @@ -551,8 +550,10 @@ SHA MUST be provided for existing file updates. if currentSHA != sha { return utils.NewToolResultError(fmt.Sprintf( "SHA mismatch: provided SHA %s is stale. Current file SHA is %s. "+ - "Pull the latest changes and use git rev-parse %s:%s to get the current SHA.", - sha, currentSHA, branch, path)), nil, nil + "The file changed since you read it. Call get_file_contents with owner=%q, repo=%q, path=%q, and ref=%q; "+ + "its first text result reports the blob SHA for the requested path. "+ + "Rebuild your content against what it returns, and retry with the sha parameter set to the SHA that call reports.", + sha, currentSHA, owner, repo, path, branch)), nil, nil } if !allowSymlinkWrite { if existingFile.GetType() == "symlink" { @@ -596,8 +597,10 @@ SHA MUST be provided for existing file updates. // File exists but no SHA was provided - reject to prevent blind overwrites return utils.NewToolResultError(fmt.Sprintf( "File already exists at %s. You must provide the current file's SHA when updating. "+ - "Use git rev-parse %s:%s to get the blob SHA, then retry with the sha parameter.", - path, branch, path)), nil, nil + "Call get_file_contents with owner=%q, repo=%q, path=%q, and ref=%q to read the file you are about to overwrite; "+ + "its first text result reports the blob SHA for the requested path. "+ + "Then retry with the sha parameter set to the blob SHA that call reports.", + path, owner, repo, path, branch)), nil, nil } // If file not found, no previous SHA needed (new file creation) } diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index a1ea5ff340..71b04faa3e 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -170,6 +170,7 @@ func Test_GetFileContents(t *testing.T) { Text: "# Test Repository\n\nThis is a test repository.", MIMEType: "text/plain; charset=utf-8", }, + expectedMsg: "SHA: " + gitBlobSHA(mockRawContent), }, { name: "successful binary file content fetch (PNG)", @@ -625,6 +626,7 @@ func Test_GetFileContents_SymlinkDisclosure(t *testing.T) { metadata := repositoryPathMetadataFromResult(t, result) assert.Equal(t, "symlink", metadata.Type) assert.Equal(t, "docs/link", metadata.Path) + assert.Equal(t, linkSHA, metadata.SHA) assert.Equal(t, target, metadata.Target) assert.Equal(t, "target/"+tc.name, metadata.ResolvedTargetPath) assert.Equal(t, "dereferenced_target", metadata.Content) @@ -649,6 +651,7 @@ func Test_GetFileContents_SymlinkDisclosure(t *testing.T) { require.False(t, result.IsError) assert.Equal(t, 1, requests) metadata := repositoryPathMetadataFromResult(t, result) + assert.Equal(t, gitBlobSHA([]byte(target)), metadata.SHA) assert.Equal(t, target, metadata.Target) assert.Empty(t, metadata.ResolvedTargetPath) assert.Equal(t, "not_returned", metadata.Content) @@ -2057,6 +2060,9 @@ func Test_CreateOrUpdateFile(t *testing.T) { assert.Equal(t, "create_or_update_file", tool.Name) assert.NotEmpty(t, tool.Description) + assert.NotContains(t, tool.Description, "git rev-parse") + assert.Contains(t, tool.Description, "get_file_contents") + assert.Contains(t, tool.Description, "set its ref parameter to this tool's branch value") assert.Contains(t, schema.Properties, "owner") assert.Contains(t, schema.Properties, "repo") assert.Contains(t, schema.Properties, "path") @@ -2065,6 +2071,7 @@ func Test_CreateOrUpdateFile(t *testing.T) { assert.Contains(t, schema.Properties, "branch") assert.Contains(t, schema.Properties, "sha") assert.Contains(t, schema.Properties, "allow_symlink_write") + assert.Contains(t, schema.Properties["sha"].Description, "with ref set to this tool's branch value") assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "path", "content", "message", "branch"}) // Setup mock file content response @@ -2135,6 +2142,7 @@ func Test_CreateOrUpdateFile(t *testing.T) { expectedContent *github.RepositoryContentResponse expectedErrMsg string expectedErrMsgs []string + unexpectedErrMsgs []string expectedRequestCount int }{ { @@ -2449,26 +2457,60 @@ func Test_CreateOrUpdateFile(t *testing.T) { { name: "sha validation - stale sha detected", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("newsha999888"), - Type: github.Ptr("file"), + "GET /repos/owner/repo/contents/docs/example.md": expectQueryParams(t, map[string]string{ + "ref": "main", + }).andThen(mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: github.Ptr(symlinkSHA), + Type: github.Ptr("symlink"), + Target: github.Ptr(string(symlinkTarget)), + })), + "GET /repos/{owner}/{repo}/contents/{path:.*}": expectQueryParams(t, map[string]string{ + "ref": "main", + }).andThen(mockResponse(t, http.StatusOK, &github.RepositoryContent{ + SHA: github.Ptr(symlinkSHA), + Type: github.Ptr("symlink"), + Target: github.Ptr(string(symlinkTarget)), + })), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "path": "docs/example.md", + "content": "# Updated Example\n\nThis file has been updated.", + "message": "Update example file", + "branch": "main", + "sha": "oldsha123456", + }, + expectError: true, + expectedErrMsgs: []string{ + "SHA mismatch: provided SHA oldsha123456 is stale. Current file SHA is " + symlinkSHA, + `Call get_file_contents with owner="owner", repo="repo", path="docs/example.md", and ref="main"`, + "its first text result reports the blob SHA for the requested path", + "retry with the sha parameter set to the SHA that call reports", + }, + expectedRequestCount: 1, + }, + { + name: "sha validation - api error is surfaced", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusForbidden, map[string]any{ + "message": "Resource not accessible", }), - "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("newsha999888"), - Type: github.Ptr("file"), + "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusForbidden, map[string]any{ + "message": "Resource not accessible", }), }), requestArgs: map[string]any{ "owner": "owner", "repo": "repo", "path": "docs/example.md", - "content": "# Updated Example\n\nThis file has been updated.", + "content": "updated", "message": "Update example file", "branch": "main", "sha": "oldsha123456", }, expectError: true, - expectedErrMsg: "SHA mismatch: provided SHA oldsha123456 is stale. Current file SHA is newsha999888", + expectedErrMsg: "failed to verify file SHA", expectedRequestCount: 1, }, { @@ -2513,14 +2555,18 @@ func Test_CreateOrUpdateFile(t *testing.T) { { name: "no sha provided - file exists, rejects update", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ + "GET /repos/owner/repo/contents/docs/example.md": expectQueryParams(t, map[string]string{ + "ref": "release/#candidate", + }).andThen(mockResponse(t, http.StatusOK, &github.RepositoryContent{ SHA: github.Ptr("existing123"), Type: github.Ptr("file"), - }), - "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ + })), + "GET /repos/{owner}/{repo}/contents/{path:.*}": expectQueryParams(t, map[string]string{ + "ref": "release/#candidate", + }).andThen(mockResponse(t, http.StatusOK, &github.RepositoryContent{ SHA: github.Ptr("existing123"), Type: github.Ptr("file"), - }), + })), }), requestArgs: map[string]any{ "owner": "owner", @@ -2528,10 +2574,40 @@ func Test_CreateOrUpdateFile(t *testing.T) { "path": "docs/example.md", "content": "# Updated\n\nUpdated without SHA.", "message": "Update without SHA", + "branch": "release/#candidate", + }, + expectError: true, + expectedErrMsgs: []string{ + "File already exists at docs/example.md", + `Call get_file_contents with owner="owner", repo="repo", path="docs/example.md", and ref="release/#candidate"`, + "its first text result reports the blob SHA for the requested path", + "retry with the sha parameter set to the blob SHA that call reports", + }, + // A caller that never supplied a SHA has not read this file, so the + // error must not hand it one to overwrite with. + unexpectedErrMsgs: []string{"existing123"}, + expectedRequestCount: 1, + }, + { + name: "no sha provided - api error is surfaced", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusInternalServerError, map[string]any{ + "message": "Internal Server Error", + }), + "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusInternalServerError, map[string]any{ + "message": "Internal Server Error", + }), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "path": "docs/example.md", + "content": "updated", + "message": "Update example file", "branch": "main", }, expectError: true, - expectedErrMsg: "File already exists at docs/example.md", + expectedErrMsg: "failed to check if file exists", expectedRequestCount: 1, }, { @@ -2606,6 +2682,12 @@ func Test_CreateOrUpdateFile(t *testing.T) { for _, expectedErrMsg := range tc.expectedErrMsgs { assert.Contains(t, errorText.String(), expectedErrMsg) } + for _, unexpectedErrMsg := range tc.unexpectedErrMsgs { + assert.NotContains(t, errorText.String(), unexpectedErrMsg) + } + // The caller of this tool works over the API and has no working + // tree, so errors must never ask it to run a local git command. + assert.NotContains(t, errorText.String(), "git rev-parse") return } From 198bc16b8af77c7b0ecc5c2317b011cea8c25429 Mon Sep 17 00:00:00 2001 From: Paulcake Date: Tue, 1 Sep 2026 14:07:30 +0100 Subject: [PATCH 04/61] fix: allow public_repo for public contribution tools (#3140) * fix: allow public_repo for contribution tools * test: tighten public repo scope coverage Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix: separate public repo visibility from OAuth scopes Keep public_repo as a fixed-token visibility capability while requiring repo for per-call OAuth challenges. Challenge only missing scopes for workflow pushes. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Paulcake <171492698+paulcakeface@users.noreply.github.com> Co-authored-by: Sam Morrow Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/github/issues.go | 4 +- pkg/github/public_repo_scopes_test.go | 103 ++++++++++++++++++++++++++ pkg/github/pullrequests.go | 2 +- pkg/github/repositories.go | 6 +- pkg/github/repository_path.go | 10 ++- pkg/github/repository_path_test.go | 2 +- pkg/github/tool_scopes.go | 8 ++ pkg/scopes/scopes_test.go | 1 + 8 files changed, 126 insertions(+), 10 deletions(-) create mode 100644 pkg/github/public_repo_scopes_test.go diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 9b6ee5da6b..5ab2f86d27 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1403,7 +1403,7 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo", "issue_number"}, }, }, - scopes.RequireAll(scopes.Repo), + publicRepositoryWriteScopeAccess(), 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 { @@ -2524,7 +2524,7 @@ Options are: Required: []string{"method", "owner", "repo"}, }, }, - scopes.RequireAll(scopes.Repo), + publicRepositoryWriteScopeAccess(), func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { method, err := RequiredParam[string](args, "method") if err != nil { diff --git a/pkg/github/public_repo_scopes_test.go b/pkg/github/public_repo_scopes_test.go new file mode 100644 index 0000000000..754ca93d78 --- /dev/null +++ b/pkg/github/public_repo_scopes_test.go @@ -0,0 +1,103 @@ +package github + +import ( + "testing" + + "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/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPublicRepoContributionToolScopeAccess(t *testing.T) { + t.Parallel() + + tools := []struct { + name string + tool inventory.ServerTool + }{ + {name: "fork_repository", tool: ForkRepository(translations.NullTranslationHelper)}, + {name: "create_branch", tool: CreateBranch(translations.NullTranslationHelper)}, + {name: "create_pull_request", tool: CreatePullRequest(translations.NullTranslationHelper)}, + {name: "issue_write", tool: IssueWrite(translations.NullTranslationHelper)}, + {name: "add_issue_comment", tool: AddIssueComment(translations.NullTranslationHelper)}, + } + + for _, tt := range tools { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, []string{string(scopes.Repo)}, tt.tool.ScopeAccess.Scopes) + require.NotNil(t, tt.tool.ScopeAccess.Visible) + assert.False(t, tt.tool.ScopeAccess.Visible(nil)) + assert.True(t, tt.tool.ScopeAccess.Visible([]string{string(scopes.PublicRepo)})) + assert.True(t, tt.tool.ScopeAccess.Visible([]string{string(scopes.Repo)})) + + require.NotNil(t, tt.tool.ScopeAccess.Challenge) + assert.Equal(t, []string{string(scopes.Repo)}, tt.tool.ScopeAccess.Challenge(nil, nil)) + assert.Equal(t, []string{string(scopes.Repo)}, tt.tool.ScopeAccess.Challenge(nil, []string{string(scopes.PublicRepo)})) + assert.Empty(t, tt.tool.ScopeAccess.Challenge(nil, []string{string(scopes.Repo)})) + }) + } +} + +func TestPublicRepoContributionToolsVisibleToPATs(t *testing.T) { + t.Parallel() + + tools := []inventory.ServerTool{ + ForkRepository(translations.NullTranslationHelper), + CreateBranch(translations.NullTranslationHelper), + PushFiles(translations.NullTranslationHelper), + CreatePullRequest(translations.NullTranslationHelper), + IssueWrite(translations.NullTranslationHelper), + AddIssueComment(translations.NullTranslationHelper), + } + + tests := []struct { + name string + tokenScopes []string + wantVisible bool + }{ + {name: "no scopes"}, + {name: "public_repo", tokenScopes: []string{string(scopes.PublicRepo)}, wantVisible: true}, + {name: "repo", tokenScopes: []string{string(scopes.Repo)}, wantVisible: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filter := CreateToolScopeFilter(tt.tokenScopes) + for i := range tools { + included, err := filter(t.Context(), &tools[i]) + require.NoError(t, err) + assert.Equal(t, tt.wantVisible, included, tools[i].Tool.Name) + } + }) + } +} + +func TestPushFilesOAuthScopeChallenges(t *testing.T) { + t.Parallel() + + tool := PushFiles(translations.NullTranslationHelper) + regularFiles := map[string]any{ + "files": []any{map[string]any{"path": "README.md"}}, + } + workflowFiles := map[string]any{ + "files": []any{map[string]any{"path": ".github/workflows/ci.yml"}}, + } + + assert.Equal(t, []string{string(scopes.Repo), string(scopes.Workflow)}, tool.ScopeAccess.Scopes) + require.NotNil(t, tool.ScopeAccess.Visible) + assert.True(t, tool.ScopeAccess.Visible([]string{string(scopes.PublicRepo)})) + assert.True(t, tool.ScopeAccess.Visible([]string{string(scopes.Repo)})) + + assert.Equal(t, []string{string(scopes.Repo)}, tool.ScopeAccess.Challenge(regularFiles, nil)) + assert.Equal(t, []string{string(scopes.Repo)}, tool.ScopeAccess.Challenge(regularFiles, []string{string(scopes.PublicRepo)})) + assert.Empty(t, tool.ScopeAccess.Challenge(regularFiles, []string{string(scopes.Repo)})) + + assert.Equal(t, []string{string(scopes.Repo), string(scopes.Workflow)}, tool.ScopeAccess.Challenge(workflowFiles, nil)) + assert.Equal(t, []string{string(scopes.Repo), string(scopes.Workflow)}, tool.ScopeAccess.Challenge(workflowFiles, []string{string(scopes.PublicRepo)})) + assert.Equal(t, []string{string(scopes.Workflow)}, tool.ScopeAccess.Challenge(workflowFiles, []string{string(scopes.Repo)})) + assert.Equal(t, []string{string(scopes.Repo)}, tool.ScopeAccess.Challenge(workflowFiles, []string{string(scopes.Workflow)})) + assert.Equal(t, []string{string(scopes.Repo)}, tool.ScopeAccess.Challenge(workflowFiles, []string{string(scopes.PublicRepo), string(scopes.Workflow)})) + assert.Empty(t, tool.ScopeAccess.Challenge(workflowFiles, []string{string(scopes.Repo), string(scopes.Workflow)})) +} diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index afd2778510..cb6d1be3a3 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -706,7 +706,7 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner", "repo", "title", "head", "base"}, }, }, - scopes.RequireAll(scopes.Repo), + publicRepositoryWriteScopeAccess(), func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 33f1237f91..8dfa19b4a2 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -1232,7 +1232,7 @@ func ForkRepository(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo"}, }, }, - scopes.RequireAll(scopes.Repo), + publicRepositoryWriteScopeAccess(), 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 { @@ -1529,7 +1529,7 @@ func CreateBranch(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "branch"}, }, }, - scopes.RequireAll(scopes.Repo), + publicRepositoryWriteScopeAccess(), 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 { @@ -1661,7 +1661,7 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "branch", "files", "message"}, }, }, - scopes.RequireAll(scopes.Repo), + publicRepositoryWriteScopeAccess(), func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { diff --git a/pkg/github/repository_path.go b/pkg/github/repository_path.go index 4d502fe1db..78c8de5f96 100644 --- a/pkg/github/repository_path.go +++ b/pkg/github/repository_path.go @@ -75,8 +75,12 @@ func workflowScopeChallengeForFiles(arguments map[string]any, activeScopes []str containsWorkflow = true } } - if containsWorkflow { - return scopes.ChallengeAll(activeScopes, scopes.Repo, scopes.Workflow) + var challenge []string + if !scopes.HasAll(activeScopes, scopes.Repo) { + challenge = append(challenge, string(scopes.Repo)) } - return scopes.ChallengeAll(activeScopes, scopes.Repo) + if containsWorkflow && !scopes.HasAll(activeScopes, scopes.Workflow) { + challenge = append(challenge, string(scopes.Workflow)) + } + return challenge } diff --git a/pkg/github/repository_path_test.go b/pkg/github/repository_path_test.go index d9178ae2af..4ec51304ee 100644 --- a/pkg/github/repository_path_test.go +++ b/pkg/github/repository_path_test.go @@ -84,7 +84,7 @@ func TestFileWriteWorkflowScopeChallenges(t *testing.T) { map[string]any{"path": "README.md"}, map[string]any{"path": ".github/workflows/ci.yml"}, }}, - want: []string{"repo", "workflow"}, + want: []string{"workflow"}, }, { name: "push validates entries after workflow", diff --git a/pkg/github/tool_scopes.go b/pkg/github/tool_scopes.go index c61dbd3829..4482a6e243 100644 --- a/pkg/github/tool_scopes.go +++ b/pkg/github/tool_scopes.go @@ -5,6 +5,14 @@ import ( "github.com/github/github-mcp-server/pkg/scopes" ) +func publicRepositoryWriteScopeAccess() inventory.ScopeAccess { + access := scopes.RequireAll(scopes.Repo) + access.Visible = func(activeScopes []string) bool { + return scopes.HasAll(activeScopes, scopes.PublicRepo) + } + return access +} + func repositoryOrOrganizationScopeAccess() inventory.ScopeAccess { return scopes.DynamicChallenge( []scopes.Scope{scopes.Repo, scopes.ReadOrg}, diff --git a/pkg/scopes/scopes_test.go b/pkg/scopes/scopes_test.go index 8240e8454b..62bfc6178e 100644 --- a/pkg/scopes/scopes_test.go +++ b/pkg/scopes/scopes_test.go @@ -21,6 +21,7 @@ func TestOAuthScopeCatalog(t *testing.T) { func TestScopeChecks(t *testing.T) { assert.True(t, HasAll([]string{"repo", "workflow"}, Repo, Workflow)) + assert.True(t, HasAll([]string{"repo"}, PublicRepo)) assert.False(t, HasAll([]string{"repo"}, Repo, Workflow)) assert.True(t, HasAll([]string{"admin:org"}, ReadOrg)) assert.True(t, HasAllScopeNames([]string{"admin:org"}, []string{"read:org"})) From ffa16d00e608c09fc5bc19b508155f2065ef088f Mon Sep 17 00:00:00 2001 From: CAOShurong <3502119616@qq.com> Date: Tue, 1 Sep 2026 21:08:56 +0800 Subject: [PATCH 05/61] Enable feature flags via URL query parameter (?features=) for headerless hosted connections (#3146) * fix(http): preserve feature query across OAuth resource metadata Review on #3146 identified that a query-bearing MCP server URL breaks OAuth protected-resource metadata discovery: go-sdk v1.7.0 validates metadata.resource with exact string equality against the full server URL, but BuildResourceMetadataURL and buildResourceURL dropped the request's RawQuery, so clients connecting to e.g. /mcp/x/issues?features=issue_dependencies received challenge and metadata URLs without the query and could reject the metadata as belonging to a different resource (RFC 9728). - Preserve r.URL.RawQuery in both the advertised resource_metadata URL and the metadata document's resource via a shared AppendQuery helper. - Make feature selection presence-based: the features query parameter and the X-MCP-Features header are separate channels that are never combined; query wins when both are present. - Extend TestOAuthChallengeMetadataRouteContracts with a query-bearing MCP URL round-trip (challenge URL + metadata.resource exact match). - Add TestWithRequestConfigFeatureSelection covering all four channel combinations, plus unit tests for query preservation in TestBuildResourceMetadataURL. * fix(http): give feature header precedence over query * fix(http): harden URL feature flag handling Preserve exact OAuth resource queries across route variants, retain presence-based header precedence, and mark feature-dependent responses with Vary. Expand request, allowlist, metadata, route, and cache behavior coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Shurong Cao <170531907+CAOShurong@users.noreply.github.com> Co-authored-by: Sam Morrow Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/feature-flags.md | 15 +++ docs/insiders-features.md | 7 +- docs/server-configuration.md | 2 +- pkg/context/request.go | 6 +- pkg/github/feature_flags.go | 6 +- pkg/github/tools.go | 6 +- pkg/http/handler_test.go | 40 ++++++-- pkg/http/middleware/request_config.go | 15 ++- pkg/http/middleware/request_config_test.go | 112 +++++++++++++++++++++ pkg/http/oauth/oauth.go | 18 +++- pkg/http/oauth/oauth_test.go | 69 ++++++++++++- pkg/http/server.go | 8 +- pkg/http/server_test.go | 40 ++++++++ 13 files changed, 314 insertions(+), 30 deletions(-) create mode 100644 pkg/http/middleware/request_config_test.go diff --git a/docs/feature-flags.md b/docs/feature-flags.md index 77ad68f0b2..fe72955b08 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -13,9 +13,24 @@ section in the Insiders docs](./insiders-features.md#how-feature-flags-are-resol | Method | Remote Server | Local Server | |--------|---------------|--------------| | Header | `X-MCP-Features: ,` | N/A | +| URL query parameter | `?features=,` on the server URL | N/A | | CLI flag | N/A | `--features=,` | | Environment variable | N/A | `GITHUB_FEATURES=,` | +The URL query parameter exists for clients that compose the server URL on the +user's behalf (hosted IDEs, agent platforms) and cannot set custom headers. +When both the query parameter and the header are present, the header wins — +even when its value is empty, whitespace-only, or contains only unknown flags. +The two channels are never combined. + +The complete query string is preserved during OAuth protected-resource metadata +discovery because it is part of the canonical resource identifier. Query +parameters also participate in HTTP cache keys, while MCP responses vary on +`X-MCP-Features` so a header override cannot reuse a response selected for a +different feature set. Feature names are configuration identifiers, not +secrets; as with any URL query value, they may appear in client history, proxy +logs, and server access logs. + Only flags listed in [`AllowedFeatureFlags`](../pkg/github/feature_flags.go) can be enabled by end users. Insiders-only flags are not user-toggleable. diff --git a/docs/insiders-features.md b/docs/insiders-features.md index 6c941d97a1..6191857ac8 100644 --- a/docs/insiders-features.md +++ b/docs/insiders-features.md @@ -200,7 +200,9 @@ Insiders is a **meta feature flag** — the same shape as `default` or `all` for 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. + - HTTP server: `X-MCP-Features: ,` request header or a + `?features=,` server URL. Header presence takes precedence, + and the two request channels are never combined. 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. @@ -214,7 +216,8 @@ Insiders is a **meta feature flag** — the same shape as `default` or `all` for ### 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`. +2. Add it to `AllowedFeatureFlags` if end users should be able to opt in via + `--features`, `X-MCP-Features`, or the `features` URL query parameter. 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/docs/server-configuration.md b/docs/server-configuration.md index 5ec78c6ae4..42584362d9 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -13,7 +13,7 @@ We currently support the following ways in which the GitHub MCP Server can be co | Read-Only Mode | `X-MCP-Readonly` header or `/readonly` URL | `--read-only` flag or `GITHUB_READ_ONLY` env var | | 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 | +| Feature Flags | `X-MCP-Features` header or `?features=` URL query parameter | `--features` flag | | Scope Filtering | Always enabled | Always enabled | | Server Name/Title | Not available | `GITHUB_MCP_SERVER_NAME` / `GITHUB_MCP_SERVER_TITLE` env vars or `github-mcp-server-config.json` | diff --git a/pkg/context/request.go b/pkg/context/request.go index 6d8d8a1060..548dc7f575 100644 --- a/pkg/context/request.go +++ b/pkg/context/request.go @@ -98,15 +98,15 @@ func GetExcludeTools(ctx context.Context) []string { return nil } -// headerFeaturesCtxKey is a context key for raw header feature flags +// headerFeaturesCtxKey is a context key for raw HTTP request feature flags. type headerFeaturesCtxKey struct{} -// WithHeaderFeatures stores the raw feature flags from the X-MCP-Features header into context +// WithHeaderFeatures stores raw HTTP request feature flags in context. func WithHeaderFeatures(ctx context.Context, features []string) context.Context { return context.WithValue(ctx, headerFeaturesCtxKey{}, features) } -// GetHeaderFeatures retrieves the raw feature flags from context +// GetHeaderFeatures retrieves raw HTTP request feature flags from context. func GetHeaderFeatures(ctx context.Context) []string { if features, ok := ctx.Value(headerFeaturesCtxKey{}).([]string); ok { return features diff --git a/pkg/github/feature_flags.go b/pkg/github/feature_flags.go index 27202c5c83..a388f30d6b 100644 --- a/pkg/github/feature_flags.go +++ b/pkg/github/feature_flags.go @@ -38,7 +38,8 @@ const FeatureFlagDuplicateDetection = "duplicate_detection" const FeatureFlagThreadResolutionReason = "thread_resolution_reason" // AllowedFeatureFlags is the allowlist of feature flags that can be enabled -// by users via --features CLI flag or X-MCP-Features HTTP header. +// by users via --features CLI flag, X-MCP-Features HTTP header, or the +// features URL query parameter. // 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{ @@ -71,7 +72,8 @@ type FeatureFlags struct { } // ResolveFeatureFlags computes the effective set of enabled feature flags by: -// 1. Taking the user-supplied flags (from --features or X-MCP-Features) and +// 1. Taking the user-supplied flags (from --features or HTTP request +// configuration) 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. diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 8d568878db..ca46deadd2 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -159,9 +159,9 @@ var ( FeatureFlagPullRequestsGranular = "pull_requests_granular" ) -// HeaderAllowedFeatureFlags returns the feature flags that clients may enable via -// the X-MCP-Features header. It delegates to AllowedFeatureFlags as the single -// source of truth. +// HeaderAllowedFeatureFlags returns the feature flags that clients may enable +// through the X-MCP-Features header or features URL query parameter. It +// delegates to AllowedFeatureFlags as the single source of truth. func HeaderAllowedFeatureFlags() []string { return slices.Clone(AllowedFeatureFlags) } diff --git a/pkg/http/handler_test.go b/pkg/http/handler_test.go index f051084785..406f845897 100644 --- a/pkg/http/handler_test.go +++ b/pkg/http/handler_test.go @@ -177,9 +177,9 @@ func testTools() []inventory.ServerTool { mockTool("create_issue", "issues", false), mockTool("list_pull_requests", "pull_requests", true), mockTool("create_pull_request", "pull_requests", false), - // Feature-flagged tools for testing X-MCP-Features header - mockToolWithFeatureFlag("needs_holdback", "repos", true, "mcp_holdback_consolidated_projects", ""), - mockToolWithFeatureFlag("hidden_by_holdback", "repos", true, "", "mcp_holdback_consolidated_projects"), + // Feature-flagged tools for testing per-request feature selection. + mockToolWithFeatureFlag("needs_holdback", "repos", true, github.FeatureFlagIssueDependencies, ""), + mockToolWithFeatureFlag("hidden_by_holdback", "repos", true, "", github.FeatureFlagIssueDependencies), } } @@ -293,7 +293,7 @@ func TestHTTPHandlerRoutes(t *testing.T) { name: "X-MCP-Features header enables flagged tool", path: "/", headers: map[string]string{ - headers.MCPFeaturesHeader: "mcp_holdback_consolidated_projects", + headers.MCPFeaturesHeader: github.FeatureFlagIssueDependencies, }, expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "needs_holdback"}, }, @@ -305,6 +305,29 @@ func TestHTTPHandlerRoutes(t *testing.T) { }, expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "hidden_by_holdback"}, }, + { + name: "features query parameter enables allowlisted feature", + path: "/?features=" + github.FeatureFlagIssueDependencies, + expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "needs_holdback"}, + }, + { + name: "features query parameter works with toolset and readonly routes", + path: "/x/repos/readonly?features=" + github.FeatureFlagIssueDependencies, + expectedTools: []string{"get_file_contents", "needs_holdback"}, + }, + { + name: "unknown feature in query parameter is ignored", + path: "/?features=unknown_flag", + expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "hidden_by_holdback"}, + }, + { + name: "unknown header suppresses allowlisted query feature", + path: "/?features=" + github.FeatureFlagIssueDependencies, + headers: map[string]string{ + headers.MCPFeaturesHeader: "unknown_flag", + }, + expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "hidden_by_holdback"}, + }, { name: "X-MCP-Exclude-Tools header removes specific tools", path: "/", @@ -346,10 +369,13 @@ func TestHTTPHandlerRoutes(t *testing.T) { var capturedInventory *inventory.Inventory var capturedCtx context.Context - // Create feature checker that reads from context without whitelist validation - // (the whitelist is tested separately; here we test the filtering logic) + // Match the production allowlist and insiders expansion behavior. featureChecker := func(ctx context.Context, flag string) (bool, error) { - return slices.Contains(ghcontext.GetHeaderFeatures(ctx), flag), nil + effective := github.ResolveFeatureFlags( + ghcontext.GetHeaderFeatures(ctx), + ghcontext.IsInsidersMode(ctx), + ) + return effective[flag], nil } apiHost, err := utils.NewAPIHost("https://api.github.com") diff --git a/pkg/http/middleware/request_config.go b/pkg/http/middleware/request_config.go index a7311334d3..dee8a2c6f4 100644 --- a/pkg/http/middleware/request_config.go +++ b/pkg/http/middleware/request_config.go @@ -9,10 +9,15 @@ import ( "github.com/github/github-mcp-server/pkg/http/headers" ) +const queryParamFeatures = "features" + // WithRequestConfig is a middleware that extracts MCP-related headers and sets them in the request context. // This includes readonly mode, toolsets, tools, lockdown mode, insiders mode, and feature flags. func WithRequestConfig(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Header-selected features can change the response for the same URL. + w.Header().Add(headers.VaryHeader, headers.MCPFeaturesHeader) + ctx := r.Context() // Readonly mode @@ -45,9 +50,13 @@ func WithRequestConfig(next http.Handler) http.Handler { ctx = ghcontext.WithInsidersMode(ctx, true) } - // Feature flags - if features := headers.ParseCommaSeparated(r.Header.Get(headers.MCPFeaturesHeader)); len(features) > 0 { - ctx = ghcontext.WithHeaderFeatures(ctx, features) + query := r.URL.Query() + _, hasHeaderFeatures := r.Header[http.CanonicalHeaderKey(headers.MCPFeaturesHeader)] + _, hasQueryFeatures := query[queryParamFeatures] + if hasHeaderFeatures { + ctx = ghcontext.WithHeaderFeatures(ctx, headers.ParseCommaSeparated(r.Header.Get(headers.MCPFeaturesHeader))) + } else if hasQueryFeatures { + ctx = ghcontext.WithHeaderFeatures(ctx, headers.ParseCommaSeparated(query.Get(queryParamFeatures))) } next.ServeHTTP(w, r.WithContext(ctx)) diff --git a/pkg/http/middleware/request_config_test.go b/pkg/http/middleware/request_config_test.go new file mode 100644 index 0000000000..8aef0fa17a --- /dev/null +++ b/pkg/http/middleware/request_config_test.go @@ -0,0 +1,112 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/stretchr/testify/assert" +) + +func TestWithRequestConfigFeatureSelection(t *testing.T) { + tests := []struct { + name string + url string + headerSet bool + headerValue string + wantFeatures []string + wantPresent bool + }{ + { + name: "query parameter only", + url: "/?features=mcp_holdback_consolidated_projects", + wantFeatures: []string{"mcp_holdback_consolidated_projects"}, + wantPresent: true, + }, + { + name: "header only", + url: "/", + headerSet: true, + headerValue: "mcp_holdback_consolidated_projects", + wantFeatures: []string{"mcp_holdback_consolidated_projects"}, + wantPresent: true, + }, + { + name: "header wins over query parameter, never combined", + url: "/?features=flag_from_query", + headerSet: true, + headerValue: "flag_from_header", + wantFeatures: []string{"flag_from_header"}, + wantPresent: true, + }, + { + name: "empty header suppresses query parameter", + url: "/?features=flag_from_query", + headerSet: true, + wantFeatures: []string{}, + wantPresent: true, + }, + { + name: "whitespace-only header suppresses query parameter", + url: "/?features=flag_from_query", + headerSet: true, + headerValue: " , \t ", + wantFeatures: []string{}, + wantPresent: true, + }, + { + name: "unknown header suppresses query parameter", + url: "/?features=flag_from_query", + headerSet: true, + headerValue: "unknown_from_header", + wantFeatures: []string{"unknown_from_header"}, + wantPresent: true, + }, + { + name: "empty query value with header", + url: "/?features=", + headerSet: true, + headerValue: "flag_from_header", + wantFeatures: []string{"flag_from_header"}, + wantPresent: true, + }, + { + name: "empty query value stores an explicit empty selection", + url: "/?features=", + wantFeatures: []string{}, + wantPresent: true, + }, + { + name: "no channel present stores nothing", + url: "/", + wantFeatures: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var got []string + handler := WithRequestConfig(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got = ghcontext.GetHeaderFeatures(r.Context()) + w.WriteHeader(http.StatusNoContent) + })) + + req := httptest.NewRequest(http.MethodPost, tc.url, nil) + if tc.headerSet { + req.Header.Set(headers.MCPFeaturesHeader, tc.headerValue) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + assert.Equal(t, tc.wantFeatures, got) + if tc.wantPresent { + assert.NotNil(t, got) + } else { + assert.Nil(t, got) + } + assert.Contains(t, rec.Header().Values(headers.VaryHeader), headers.MCPFeaturesHeader) + }) + } +} diff --git a/pkg/http/oauth/oauth.go b/pkg/http/oauth/oauth.go index 77cfe8fa10..1d8ba70afa 100644 --- a/pkg/http/oauth/oauth.go +++ b/pkg/http/oauth/oauth.go @@ -199,7 +199,16 @@ func (h *AuthHandler) buildResourceURL(r *http.Request, resourcePath string) str if !strings.HasPrefix(resourcePath, "/") { resourcePath = "/" + resourcePath } - return baseURL + resourcePath + return appendRawQuery(baseURL+resourcePath, r.URL.RawQuery) +} + +// appendRawQuery avoids re-encoding the resource identifier that RFC 9728 +// clients compare as an exact string. +func appendRawQuery(target, rawQuery string) string { + if rawQuery == "" { + return target + } + return target + "?" + rawQuery } // GetEffectiveHostAndScheme returns the effective host and scheme for a request. @@ -248,10 +257,13 @@ func BuildResourceMetadataURL(r *http.Request, cfg *Config, resourcePath string) suffix = resourcePath } } + metadataURL := "" if cfg != nil && cfg.BaseURL != "" { - return strings.TrimSuffix(cfg.BaseURL, "/") + OAuthProtectedResourcePrefix + suffix + metadataURL = strings.TrimSuffix(cfg.BaseURL, "/") + OAuthProtectedResourcePrefix + suffix + } else { + metadataURL = fmt.Sprintf("%s://%s%s%s", scheme, host, OAuthProtectedResourcePrefix, suffix) } - return fmt.Sprintf("%s://%s%s%s", scheme, host, OAuthProtectedResourcePrefix, suffix) + return appendRawQuery(metadataURL, r.URL.RawQuery) } func normalizeBasePath(path string) string { diff --git a/pkg/http/oauth/oauth_test.go b/pkg/http/oauth/oauth_test.go index 52baae3b6c..39c7e953b4 100644 --- a/pkg/http/oauth/oauth_test.go +++ b/pkg/http/oauth/oauth_test.go @@ -7,6 +7,7 @@ import ( "net/http" "net/http/httptest" "net/url" + "strings" "testing" "github.com/github/github-mcp-server/pkg/http/headers" @@ -362,6 +363,43 @@ func TestBuildResourceMetadataURL(t *testing.T) { resourcePath: "", expectedURL: "http://api.example.com/.well-known/oauth-protected-resource", }, + { + name: "query string is preserved on base URL config", + cfg: &Config{ + BaseURL: "https://custom.example.com", + }, + setupRequest: func() *http.Request { + return httptest.NewRequest(http.MethodGet, "/mcp/x/issues?features=issue_dependencies", nil) + }, + resourcePath: "/mcp/x/issues", + expectedURL: "https://custom.example.com/.well-known/oauth-protected-resource/mcp/x/issues?features=issue_dependencies", + }, + { + name: "query string is preserved without base URL config", + cfg: &Config{}, + setupRequest: func() *http.Request { + req := httptest.NewRequest(http.MethodGet, "/mcp?features=a,b", nil) + req.Host = "api.example.com" + return req + }, + resourcePath: "/mcp", + expectedURL: "http://api.example.com/.well-known/oauth-protected-resource/mcp?features=a,b", + }, + { + name: "raw query encoding and ordering are preserved", + cfg: &Config{ + BaseURL: "https://custom.example.com", + }, + setupRequest: func() *http.Request { + return httptest.NewRequest( + http.MethodGet, + "/mcp?features=issue_dependencies%2Cfile_blame&client=web%20ide", + nil, + ) + }, + resourcePath: "/mcp", + expectedURL: "https://custom.example.com/.well-known/oauth-protected-resource/mcp?features=issue_dependencies%2Cfile_blame&client=web%20ide", + }, } for _, tc := range tests { @@ -462,6 +500,20 @@ func TestHandleProtectedResource(t *testing.T) { assert.Equal(t, "https://api.example.com/mcp/", body["resource"]) }, }, + { + name: "path with feature query", + cfg: &Config{ + BaseURL: "https://api.example.com", + }, + path: OAuthProtectedResourcePrefix + "/mcp/x/repos?features=issue_dependencies", + host: "api.example.com", + method: http.MethodGet, + expectedStatusCode: http.StatusOK, + validateResponse: func(t *testing.T, body map[string]any) { + t.Helper() + assert.Equal(t, "https://api.example.com/mcp/x/repos?features=issue_dependencies", body["resource"]) + }, + }, { name: "custom authorization server in response", cfg: &Config{ @@ -558,11 +610,24 @@ func TestRegisterRoutes(t *testing.T) { for _, trailingSlash := range []string{"", "/"} { route := OAuthProtectedResourcePrefix + basePath + resourcePath + trailingSlash t.Run("route:"+route, func(t *testing.T) { - req := httptest.NewRequest(http.MethodGet, route, nil) + queryRoute := route + "?features=issue_dependencies" + req := httptest.NewRequest(http.MethodGet, queryRoute, nil) req.Host = "api.example.com" rec := httptest.NewRecorder() router.ServeHTTP(rec, req) - assert.Equal(t, http.StatusOK, rec.Code, "GET %s should return 200", route) + require.Equal(t, http.StatusOK, rec.Code, "GET %s should return 200", queryRoute) + + var metadata map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &metadata)) + resourcePath := resolveResourcePath( + strings.TrimPrefix(route, OAuthProtectedResourcePrefix), + "", + ) + assert.Equal( + t, + "https://api.example.com"+resourcePath+"?features=issue_dependencies", + metadata["resource"], + ) req = httptest.NewRequest(http.MethodOptions, route, nil) req.Host = "api.example.com" diff --git a/pkg/http/server.go b/pkg/http/server.go index cc2d23d3ac..8a3a305e49 100644 --- a/pkg/http/server.go +++ b/pkg/http/server.go @@ -310,13 +310,13 @@ func initGlobalToolScopeMap(t translations.TranslationHelperFunc, hostType utils } // createHTTPFeatureChecker creates a feature checker that resolves static CLI -// features plus per-request header features and insiders mode. +// features plus per-request 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) - features := make([]string, 0, len(enabledFeatures)+len(headerFeatures)) + requestFeatures := ghcontext.GetHeaderFeatures(ctx) + features := make([]string, 0, len(enabledFeatures)+len(requestFeatures)) features = append(features, enabledFeatures...) - features = append(features, headerFeatures...) + features = append(features, requestFeatures...) 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 a8c4e1a90b..8c62c01a40 100644 --- a/pkg/http/server_test.go +++ b/pkg/http/server_test.go @@ -259,6 +259,46 @@ func TestOAuthChallengeMetadataRouteContracts(t *testing.T) { }) } + // Query-bearing MCP server URLs must round-trip: the challenge's + // resource_metadata URL and the served metadata document's "resource" + // must both carry the exact same query as the URL the client connects to, + // because go-sdk validates metadata.resource with exact string equality. + queryPath := "/x/repos?features=issue_dependencies" + t.Run(queryPath, func(t *testing.T) { + req := httptest.NewRequest(http.MethodPost, queryPath, nil) + req.Header.Set("Origin", "https://confer.to") + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusUnauthorized, rec.Code) + challenge := rec.Header().Get("WWW-Authenticate") + require.True(t, strings.HasPrefix(challenge, `Bearer resource_metadata="`)) + metadataURL := strings.TrimSuffix( + strings.TrimPrefix(challenge, `Bearer resource_metadata="`), + `"`, + ) + assert.Equal(t, + baseURL+"/.well-known/oauth-protected-resource/mcp/x/repos?features=issue_dependencies", + metadataURL, + ) + + metadataPaths := []string{ + strings.TrimPrefix(metadataURL, baseURL), + oauth.OAuthProtectedResourcePrefix + queryPath, + } + for _, metadataPath := range metadataPaths { + req = httptest.NewRequest(http.MethodGet, metadataPath, nil) + req.Header.Set("Origin", "https://confer.to") + rec = httptest.NewRecorder() + router.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var metadata map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &metadata)) + assert.Equal(t, baseURL+"/mcp"+queryPath, metadata["resource"]) + } + }) + req := httptest.NewRequest( http.MethodGet, oauth.OAuthProtectedResourcePrefix+"/mcp/unknown", From b8f107b9ceb0cfeef8f22f629e39c15bb27522c4 Mon Sep 17 00:00:00 2001 From: Jorge Costa <78289831+jcosta1970@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:12:25 +0100 Subject: [PATCH 06/61] Add expectedHeadSha pinning to merge_pull_request (#3182) * Add head SHA pinning to merge_pull_request * Update merge_pull_request generated documentation * test(pull-requests): harden merge SHA pinning Make the schema and unpinned compatibility coverage explicit, and verify stale-head conflicts surface without a retry. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Sam Morrow Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 1 + .../__toolsnaps__/merge_pull_request.snap | 6 +- pkg/github/pullrequests.go | 9 ++ pkg/github/pullrequests_sha_pinning_test.go | 93 +++++++++++++++++++ pkg/github/pullrequests_test.go | 3 +- 5 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 pkg/github/pullrequests_sha_pinning_test.go diff --git a/README.md b/README.md index 555284a370..e808f6fd12 100644 --- a/README.md +++ b/README.md @@ -1182,6 +1182,7 @@ The following sets of tools are available: - **OAuth Challenge Scopes**: `repo` - `commit_message`: Extra detail for merge commit (string, optional) - `commit_title`: Title for merge commit (string, optional) + - `expectedHeadSha`: The expected SHA of the pull request's HEAD ref (string, optional) - `merge_method`: Merge method (string, optional) - `owner`: Repository owner (string, required) - `pullNumber`: Pull request number (number, required) diff --git a/pkg/github/__toolsnaps__/merge_pull_request.snap b/pkg/github/__toolsnaps__/merge_pull_request.snap index 4a5bdfd8b3..35275cb0e8 100644 --- a/pkg/github/__toolsnaps__/merge_pull_request.snap +++ b/pkg/github/__toolsnaps__/merge_pull_request.snap @@ -27,6 +27,10 @@ "description": "Title for merge commit", "type": "string" }, + "expectedHeadSha": { + "description": "The expected SHA of the pull request's HEAD ref", + "type": "string" + }, "merge_method": { "description": "Merge method", "enum": [ @@ -57,4 +61,4 @@ "type": "object" }, "name": "merge_pull_request" -} \ No newline at end of file +} diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index cb6d1be3a3..9fffef7e98 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -1513,6 +1513,10 @@ func MergePullRequest(t translations.TranslationHelperFunc) inventory.ServerTool Description: "Merge method", Enum: []any{"merge", "squash", "rebase"}, }, + "expectedHeadSha": { + Type: "string", + Description: "The expected SHA of the pull request's HEAD ref", + }, }, Required: []string{"owner", "repo", "pullNumber"}, } @@ -1555,9 +1559,14 @@ func MergePullRequest(t translations.TranslationHelperFunc) inventory.ServerTool if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + expectedHeadSHA, err := OptionalParam[string](args, "expectedHeadSha") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } options := &github.PullRequestOptions{ CommitTitle: commitTitle, + SHA: expectedHeadSHA, MergeMethod: mergeMethod, } diff --git a/pkg/github/pullrequests_sha_pinning_test.go b/pkg/github/pullrequests_sha_pinning_test.go new file mode 100644 index 0000000000..77e2d5152a --- /dev/null +++ b/pkg/github/pullrequests_sha_pinning_test.go @@ -0,0 +1,93 @@ +package github + +import ( + "context" + "net/http" + "testing" + + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v89/github" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_MergePullRequestSHAPinning(t *testing.T) { + serverTool := MergePullRequest(translations.NullTranslationHelper) + + const pinnedSHA = "0123456789abcdef0123456789abcdef01234567" + + mockMergeResult := &github.PullRequestMergeResult{ + SHA: github.Ptr("merge-result-sha"), + Merged: github.Ptr(true), + Message: github.Ptr("Pull Request successfully merged"), + } + + t.Run("expected head SHA is sent as REST sha", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposPullsMergeByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{ + "merge_method": "merge", + "sha": pinnedSHA, + }).andThen( + mockResponse(t, http.StatusOK, mockMergeResult), + ), + })) + + deps := BaseDeps{Client: client} + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "merge_method": "merge", + "expectedHeadSha": pinnedSHA, + }) + + result, err := handler( + ContextWithDeps(context.Background(), deps), + &request, + ) + + require.NoError(t, err) + require.False(t, result.IsError) + }) + + t.Run("head mismatch fails closed without retry", func(t *testing.T) { + calls := 0 + + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PutReposPullsMergeByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{ + "merge_method": "merge", + "sha": pinnedSHA, + }).andThen(func(w http.ResponseWriter, _ *http.Request) { + calls++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte( + `{"message":"Head branch was modified. Review and try the merge again."}`, + )) + }), + })) + + deps := BaseDeps{Client: client} + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "merge_method": "merge", + "expectedHeadSha": pinnedSHA, + }) + + result, err := handler( + ContextWithDeps(context.Background(), deps), + &request, + ) + + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "Head branch was modified") + assert.Equal(t, 1, calls, "merge must not retry after a head-SHA mismatch") + }) +} diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 92fb4b24a3..5511cfd278 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -760,6 +760,7 @@ func Test_MergePullRequest(t *testing.T) { assert.Contains(t, schema.Properties, "commit_title") assert.Contains(t, schema.Properties, "commit_message") assert.Contains(t, schema.Properties, "merge_method") + assert.Contains(t, schema.Properties, "expectedHeadSha") assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "pullNumber"}) // Setup mock merge result for success case @@ -778,7 +779,7 @@ func Test_MergePullRequest(t *testing.T) { expectedErrMsg string }{ { - name: "successful merge", + name: "successful merge without expected head SHA", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PutReposPullsMergeByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{ "commit_title": "Merge PR #42", From 4ee459402d77543df00d6498f6baef2485898a7b Mon Sep 17 00:00:00 2001 From: Ruipeng Zhang Date: Tue, 1 Sep 2026 06:13:12 -0700 Subject: [PATCH 07/61] Fix e2e harness compilation against go-github v89 and go-sdk v1.7 (#3187) * Fix e2e harness compilation against go-github v89 and go-sdk v1.7 The e2e test package no longer compiled under --tags e2e because it lagged behind two dependency migrations: - go-github v89: NewClient now returns (*Client, error) and WithEnterpriseURLs moved from a *Client method to a ClientOptionsFunc. Update getRESTClient and the inline cleanup call sites, and drop the trailing nil options arg from ListReviewers. - go-sdk v1.7: ghmcp.NewMCPServer(MCPServerConfig) was replaced by ghmcp.NewStdioMCPServer(ctx, github.MCPServerConfig). Update the in-process setupMCPClient branch accordingly, supplying the required Logger and Version fields. * fix(e2e): align client host resolution Reuse the server API host resolver so GHEC REST and upload clients target the correct subdomains. Add token-free coverage for host URL resolution and the in-process stdio server lifecycle. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: ppoffice <8849362+ppoffice@users.noreply.github.com> Co-authored-by: Sam Morrow Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- e2e/e2e_test.go | 129 ++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 114 insertions(+), 15 deletions(-) diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 82075ec55c..112f653a37 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "net/http" "os" "os/exec" @@ -18,6 +19,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" + "github.com/github/github-mcp-server/pkg/utils" gogithub "github.com/google/go-github/v89/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" @@ -62,21 +64,116 @@ func getE2EHost() string { } func getRESTClient(t *testing.T) *gogithub.Client { - // Get token and ensure Docker image is built - token := getE2EToken(t) + ghClient, err := newRESTClient(getE2EToken(t), getE2EHost()) + require.NoError(t, err, "expected to create GitHub client successfully") - // Create a new GitHub client with the token - ghClient := gogithub.NewClient(nil).WithAuthToken(token) + return ghClient +} - if host := getE2EHost(); host != "" && host != "https://github.com" { - var err error - // Currently this works for GHEC because the API is exposed at the api subdomain and the path prefix - // but it would be preferable to extract the host parsing from the main server logic, and use it here. - ghClient, err = ghClient.WithEnterpriseURLs(host, host) - require.NoError(t, err, "expected to create GitHub client with host") +func newRESTClient(token, host string) (*gogithub.Client, error) { + apiHost, err := utils.NewAPIHost(host) + if err != nil { + return nil, fmt.Errorf("failed to parse API host: %w", err) } - return ghClient + restURL, err := apiHost.BaseRESTURL(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to get base REST URL: %w", err) + } + + uploadURL, err := apiHost.UploadURL(context.Background()) + if err != nil { + return nil, fmt.Errorf("failed to get upload URL: %w", err) + } + + return gogithub.NewClient( + gogithub.WithAuthToken(token), + gogithub.WithEnterpriseURLs(restURL.String(), uploadURL.String()), + ) +} + +func TestRESTClientURLs(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + host string + wantBaseURL string + wantUploadURL string + }{ + { + name: "dotcom default", + wantBaseURL: "https://api.github.com/", + wantUploadURL: "https://uploads.github.com/", + }, + { + name: "dotcom explicit", + host: "https://github.com", + wantBaseURL: "https://api.github.com/", + wantUploadURL: "https://uploads.github.com/", + }, + { + name: "GHEC", + host: "https://example.ghe.com", + wantBaseURL: "https://api.example.ghe.com/", + wantUploadURL: "https://uploads.example.ghe.com/", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + client, err := newRESTClient("test-token", tt.host) + require.NoError(t, err) + require.Equal(t, tt.wantBaseURL, client.BaseURL()) + require.Equal(t, tt.wantUploadURL, client.UploadURL()) + }) + } +} + +func TestInProcessStdioServer(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + server, err := ghmcp.NewStdioMCPServer(ctx, github.MCPServerConfig{ + Version: "e2e-test", + Token: "test-token", + EnabledToolsets: []string{"context"}, + Translator: translations.NullTranslationHelper, + Logger: slog.New(slog.DiscardHandler), + }) + require.NoError(t, err) + + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverErr := make(chan error, 1) + go func() { + serverErr <- server.Run(ctx, serverTransport) + }() + + client := mcp.NewClient(&mcp.Implementation{ + Name: "e2e-test-client", + Version: "0.0.1", + }, nil) + session, err := client.Connect(ctx, clientTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = session.Close() }) + + tools, err := session.ListTools(ctx, nil) + require.NoError(t, err) + require.True(t, slices.ContainsFunc(tools.Tools, func(tool *mcp.Tool) bool { + return tool.Name == "get_me" + })) + + require.NoError(t, session.Close()) + select { + case err := <-serverErr: + require.NoError(t, err) + case <-time.After(time.Second): + t.Fatal("timed out waiting for the in-process MCP server to stop") + } } // waitForRateLimit checks the current rate limit and waits if necessary. @@ -221,11 +318,13 @@ func setupMCPClient(t *testing.T, options ...clientOption) *mcp.ClientSession { enabledToolsets = github.GetDefaultToolsetIDs() } - ghServer, err := ghmcp.NewMCPServer(ghmcp.MCPServerConfig{ + ghServer, err := ghmcp.NewStdioMCPServer(ctx, github.MCPServerConfig{ + Version: "e2e-test", Token: token, EnabledToolsets: enabledToolsets, Host: getE2EHost(), Translator: translations.NullTranslationHelper, + Logger: slog.New(slog.DiscardHandler), }) require.NoError(t, err, "expected to construct MCP server successfully") @@ -968,7 +1067,7 @@ func TestRequestCopilotReview(t *testing.T) { // Cleanup the repository after the test t.Cleanup(func() { // MCP Server doesn't support deletions, but we can use the GitHub Client - ghClient := gogithub.NewClient(nil).WithAuthToken(getE2EToken(t)) + ghClient := getRESTClient(t) t.Logf("Deleting repository %s/%s...", currentOwner, repoName) _, err := ghClient.Repositories.Delete(context.Background(), currentOwner, repoName) require.NoError(t, err, "expected to delete repository successfully") @@ -1063,9 +1162,9 @@ func TestRequestCopilotReview(t *testing.T) { // Finally, get requested reviews and see copilot is in there // MCP Server doesn't support requesting reviews yet, but we can use the GitHub Client - ghClient := gogithub.NewClient(nil).WithAuthToken(getE2EToken(t)) + ghClient := getRESTClient(t) t.Logf("Getting reviews for pull request in %s/%s...", currentOwner, repoName) - reviewRequests, _, err := ghClient.PullRequests.ListReviewers(context.Background(), currentOwner, repoName, 1, nil) + reviewRequests, _, err := ghClient.PullRequests.ListReviewers(context.Background(), currentOwner, repoName, 1) require.NoError(t, err, "expected to get review requests successfully") // Check if Copilot was added as a reviewer - skip if not available From c2a9364cbeb56d8f3c8976fd24faba7e37752312 Mon Sep 17 00:00:00 2001 From: yiheng-kkk Date: Tue, 1 Sep 2026 21:14:13 +0800 Subject: [PATCH 08/61] Return range coordinates for review comments (#3193) * Return range coordinates for review comments * docs(pull-requests): describe review comment coordinates Document nullable current and original range fields and assert their serialized omission behavior for single-line and outdated comments. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: yiheng-kkk <272397091+yiheng-kkk@users.noreply.github.com> Co-authored-by: Sam Morrow Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- .../__toolsnaps__/pull_request_read.snap | 2 +- pkg/github/minimal_types.go | 29 +++++-- pkg/github/pullrequests.go | 15 ++-- pkg/github/pullrequests_test.go | 82 ++++++++++++++++--- 5 files changed, 105 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index e808f6fd12..87965cb09d 100644 --- a/README.md +++ b/README.md @@ -1198,7 +1198,7 @@ The following sets of tools are available: 3. get_status - Get combined commit status of a head commit in a pull request. 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned. 5. get_commits - Get the list of commits on a pull request. Use with pagination parameters to control the number of results returned. - 6. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results. + 6. get_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 thread metadata and comments with nullable current and original line-range coordinates (line, start_line, original_line, original_start_line). Current coordinates are omitted when unavailable, such as for outdated comments. Use cursor-based pagination (perPage, after) to control results. 7. 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. 8. 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. 9. 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. diff --git a/pkg/github/__toolsnaps__/pull_request_read.snap b/pkg/github/__toolsnaps__/pull_request_read.snap index 41bc90b597..d518c7cad9 100644 --- a/pkg/github/__toolsnaps__/pull_request_read.snap +++ b/pkg/github/__toolsnaps__/pull_request_read.snap @@ -12,7 +12,7 @@ "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_commits - Get the list of commits on a pull request. Use with pagination parameters to control the number of results returned.\n 6. 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 7. 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 8. 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 9. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR.\n", + "description": "Action to specify what pull request data needs to be retrieved from GitHub. \nPossible options: \n 1. get - Get details of a specific pull request.\n 2. get_diff - Get the diff of a pull request.\n 3. get_status - Get combined commit status of a head commit in a pull request.\n 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.\n 5. get_commits - Get the list of commits on a pull request. Use with pagination parameters to control the number of results returned.\n 6. 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 thread metadata and comments with nullable current and original line-range coordinates (line, start_line, original_line, original_start_line). Current coordinates are omitted when unavailable, such as for outdated comments. Use cursor-based pagination (perPage, after) to control results.\n 7. 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 8. 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 9. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR.\n", "enum": [ "get", "get_diff", diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 675ec050bc..f8cf9f307c 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -1942,13 +1942,16 @@ type MinimalPageInfo struct { // MinimalReviewComment is the trimmed output type for PR review comment objects. type MinimalReviewComment struct { - Body string `json:"body,omitempty"` - Path string `json:"path"` - Line *int `json:"line,omitempty"` - Author string `json:"author,omitempty"` - CreatedAt string `json:"created_at,omitempty"` - UpdatedAt string `json:"updated_at,omitempty"` - HTMLURL string `json:"html_url"` + Body string `json:"body,omitempty"` + Path string `json:"path"` + Line *int `json:"line,omitempty"` + OriginalLine *int `json:"original_line,omitempty"` + StartLine *int `json:"start_line,omitempty"` + OriginalStartLine *int `json:"original_start_line,omitempty"` + Author string `json:"author,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + HTMLURL string `json:"html_url"` } // MinimalReviewThread is the trimmed output type for PR review thread objects. @@ -2287,6 +2290,18 @@ func convertToMinimalReviewComment(c reviewCommentNode) MinimalReviewComment { line := int(*c.Line) m.Line = &line } + if c.OriginalLine != nil { + originalLine := int(*c.OriginalLine) + m.OriginalLine = &originalLine + } + if c.StartLine != nil { + startLine := int(*c.StartLine) + m.StartLine = &startLine + } + if c.OriginalStartLine != nil { + originalStartLine := int(*c.OriginalStartLine) + m.OriginalStartLine = &originalStartLine + } if !c.CreatedAt.IsZero() { m.CreatedAt = c.CreatedAt.Format(time.RFC3339) diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 9fffef7e98..925e7d55a0 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -36,7 +36,7 @@ Possible options: 3. get_status - Get combined commit status of a head commit in a pull request. 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned. 5. get_commits - Get the list of commits on a pull request. Use with pagination parameters to control the number of results returned. - 6. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results. + 6. get_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 thread metadata and comments with nullable current and original line-range coordinates (line, start_line, original_line, original_start_line). Current coordinates are omitted when unavailable, such as for outdated comments. Use cursor-based pagination (perPage, after) to control results. 7. 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. 8. 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. 9. 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. @@ -461,11 +461,14 @@ type reviewThreadNode struct { } type reviewCommentNode struct { - ID githubv4.ID - Body githubv4.String - Path githubv4.String - Line *githubv4.Int - Author struct { + ID githubv4.ID + Body githubv4.String + Path githubv4.String + Line *githubv4.Int + OriginalLine *githubv4.Int + StartLine *githubv4.Int + OriginalStartLine *githubv4.Int + Author struct { Login githubv4.String } CreatedAt githubv4.DateTime diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 5511cfd278..c0e392aea6 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -2188,13 +2188,16 @@ func Test_GetPullRequestComments(t *testing.T) { "isOutdated": false, "isCollapsed": false, "comments": map[string]any{ - "totalCount": 2, + "totalCount": 3, "nodes": []map[string]any{ { - "id": "PRRC_kwDOA0xdyM4AX1Y0", - "body": "This looks good", - "path": "file1.go", - "line": 5, + "id": "PRRC_kwDOA0xdyM4AX1Y0", + "body": "This looks good", + "path": "file1.go", + "line": 86, + "originalLine": 84, + "startLine": 73, + "originalStartLine": 73, "author": map[string]any{ "login": "reviewer1", }, @@ -2203,10 +2206,11 @@ func Test_GetPullRequestComments(t *testing.T) { "url": "https://github.com/owner/repo/pull/42#discussion_r101", }, { - "id": "PRRC_kwDOA0xdyM4AX1Y1", - "body": "Please fix this", - "path": "file1.go", - "line": 10, + "id": "PRRC_kwDOA0xdyM4AX1Y1", + "body": "Please fix this", + "path": "file1.go", + "line": 159, + "originalLine": 157, "author": map[string]any{ "login": "reviewer2", }, @@ -2214,6 +2218,21 @@ func Test_GetPullRequestComments(t *testing.T) { "updatedAt": "2024-01-01T13:00:00Z", "url": "https://github.com/owner/repo/pull/42#discussion_r102", }, + { + "id": "PRRC_kwDOA0xdyM4AX1Y2", + "body": "Comment with current coordinates unavailable", + "path": "file1.go", + "line": nil, + "originalLine": 178, + "startLine": nil, + "originalStartLine": 176, + "author": map[string]any{ + "login": "reviewer3", + }, + "createdAt": "2024-01-01T14:00:00Z", + "updatedAt": "2024-01-01T14:00:00Z", + "url": "https://github.com/owner/repo/pull/42#discussion_r103", + }, }, }, }, @@ -2252,13 +2271,56 @@ func Test_GetPullRequestComments(t *testing.T) { assert.Equal(t, false, thread.IsCollapsed) // Validate comments within thread - assert.Len(t, thread.Comments, 2) + assert.Len(t, thread.Comments, 3) // Validate first comment comment1 := thread.Comments[0] assert.Equal(t, "This looks good", comment1.Body) assert.Equal(t, "file1.go", comment1.Path) assert.Equal(t, "reviewer1", comment1.Author) + require.NotNil(t, comment1.Line) + assert.Equal(t, 86, *comment1.Line) + require.NotNil(t, comment1.OriginalLine) + assert.Equal(t, 84, *comment1.OriginalLine) + require.NotNil(t, comment1.StartLine) + assert.Equal(t, 73, *comment1.StartLine) + require.NotNil(t, comment1.OriginalStartLine) + assert.Equal(t, 73, *comment1.OriginalStartLine) + + comment2 := thread.Comments[1] + require.NotNil(t, comment2.Line) + assert.Equal(t, 159, *comment2.Line) + require.NotNil(t, comment2.OriginalLine) + assert.Equal(t, 157, *comment2.OriginalLine) + assert.Nil(t, comment2.StartLine) + assert.Nil(t, comment2.OriginalStartLine) + + var raw struct { + ReviewThreads []struct { + Comments []map[string]any `json:"comments"` + } `json:"review_threads"` + } + require.NoError(t, json.Unmarshal([]byte(textContent), &raw)) + require.Len(t, raw.ReviewThreads, 1) + require.Len(t, raw.ReviewThreads[0].Comments, 3) + assert.Equal(t, float64(86), raw.ReviewThreads[0].Comments[0]["line"]) + assert.Equal(t, float64(84), raw.ReviewThreads[0].Comments[0]["original_line"]) + assert.Equal(t, float64(73), raw.ReviewThreads[0].Comments[0]["start_line"]) + assert.Equal(t, float64(73), raw.ReviewThreads[0].Comments[0]["original_start_line"]) + assert.NotContains(t, raw.ReviewThreads[0].Comments[1], "start_line") + assert.NotContains(t, raw.ReviewThreads[0].Comments[1], "original_start_line") + assert.NotContains(t, raw.ReviewThreads[0].Comments[2], "line") + assert.NotContains(t, raw.ReviewThreads[0].Comments[2], "start_line") + assert.Equal(t, float64(178), raw.ReviewThreads[0].Comments[2]["original_line"]) + assert.Equal(t, float64(176), raw.ReviewThreads[0].Comments[2]["original_start_line"]) + + comment3 := thread.Comments[2] + assert.Nil(t, comment3.Line) + require.NotNil(t, comment3.OriginalLine) + assert.Equal(t, 178, *comment3.OriginalLine) + assert.Nil(t, comment3.StartLine) + require.NotNil(t, comment3.OriginalStartLine) + assert.Equal(t, 176, *comment3.OriginalStartLine) // Validate pagination info assert.Equal(t, false, result.PageInfo.HasNextPage) From 7b6646c373660e9554a3e2799a44b79e88b9ea9e Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Tue, 1 Sep 2026 15:15:10 +0200 Subject: [PATCH 09/61] fix(issues): report silently dropped labels (#3195) Compare requested labels with the authoritative issue returned by create and update writes, and return a precise partial-failure result when GitHub omits them. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/github/issues.go | 56 ++++++++++++++++++++ pkg/github/issues_test.go | 106 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 5ab2f86d27..fd7ea36873 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -2941,6 +2941,50 @@ func resolveIssueTypeID(ctx context.Context, client *github.Client, owner, repo, return "", resp, fmt.Errorf("issue type %q was not found in %s/%s", issueTypeName, owner, repo) } +func unappliedIssueLabelsError(requested []string, issue *github.Issue) error { + applied := make([]string, 0, len(issue.Labels)) + for _, label := range issue.Labels { + if label != nil { + applied = append(applied, label.GetName()) + } + } + + missing := issueLabelDifference(requested, applied) + unexpected := issueLabelDifference(applied, requested) + if len(missing) == 0 && len(unexpected) == 0 { + return nil + } + + return fmt.Errorf( + "requested=%q, applied=%q, missing=%q, unexpected=%q, issue_url=%q; the caller may lack AddLabelsToLabelable permission", + requested, + applied, + missing, + unexpected, + issue.GetHTMLURL(), + ) +} + +func issueLabelDifference(labels, other []string) []string { + var difference []string + for _, label := range labels { + if containsIssueLabel(other, label) || containsIssueLabel(difference, label) { + continue + } + difference = append(difference, label) + } + return difference +} + +func containsIssueLabel(labels []string, target string) bool { + for _, label := range labels { + if strings.EqualFold(label, target) { + return true + } + } + return false +} + 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 @@ -2981,6 +3025,12 @@ func CreateIssue(ctx context.Context, client *github.Client, owner string, repo return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to create issue", resp, body), nil } + if len(labels) > 0 { + if err := unappliedIssueLabelsError(labels, issue); err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "issue created but requested labels were not fully applied", resp, err), nil + } + } + // Return minimal response with just essential information minimalResponse := MinimalResponse{ ID: fmt.Sprintf("%d", issue.GetID()), @@ -3206,6 +3256,12 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4 } } + if updateOptions.LabelsProvided { + if err := unappliedIssueLabelsError(labels, updatedIssue); err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "issue updated but requested labels were not fully applied", resp, err), nil + } + } + // Return minimal response with just essential information minimalResponse := MinimalResponse{ ID: fmt.Sprintf("%d", updatedIssue.GetID()), diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index bf024b545a..e8b4cd2c13 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -2090,6 +2090,112 @@ func Test_CreateIssue(t *testing.T) { } } +func TestIssueWriteReportsUnappliedLabels(t *testing.T) { + tests := []struct { + name string + method string + requestedLabels []string + appliedLabels []string + expectedMissing string + expectedUnexpected string + }{ + { + name: "create reports partially applied labels", + method: "create", + requestedLabels: []string{"bug", "enhancement"}, + appliedLabels: []string{"bug"}, + expectedMissing: `missing=["enhancement"]`, + expectedUnexpected: "unexpected=[]", + }, + { + name: "update reports silently dropped labels", + method: "update", + requestedLabels: []string{"enhancement"}, + appliedLabels: []string{"existing"}, + expectedMissing: `missing=["enhancement"]`, + expectedUnexpected: `unexpected=["existing"]`, + }, + { + name: "update reports labels that were not cleared", + method: "update", + requestedLabels: []string{}, + appliedLabels: []string{"existing"}, + expectedMissing: "missing=[]", + expectedUnexpected: `unexpected=["existing"]`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + responseLabels := make([]*github.Label, 0, len(tc.appliedLabels)) + for _, label := range tc.appliedLabels { + responseLabels = append(responseLabels, &github.Label{Name: label}) + } + responseIssue := &github.Issue{ + ID: github.Ptr(int64(123)), + Number: github.Ptr(123), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), + Labels: responseLabels, + } + + endpoint := PostReposIssuesByOwnerByRepo + status := http.StatusCreated + if tc.method == "update" { + endpoint = PatchReposIssuesByOwnerByRepoByIssueNumber + status = http.StatusOK + } + restHTTPClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + endpoint: mockResponse(t, status, responseIssue), + }) + restRequests := &requestCountingTransport{inner: restHTTPClient.Transport} + restHTTPClient.Transport = restRequests + + gqlHTTPClient := githubv4mock.NewMockedHTTPClient() + gqlRequests := &requestCountingTransport{inner: gqlHTTPClient.Transport} + gqlHTTPClient.Transport = gqlRequests + + requestLabels := make([]any, len(tc.requestedLabels)) + for i, label := range tc.requestedLabels { + requestLabels[i] = label + } + requestArgs := map[string]any{ + "method": tc.method, + "owner": "owner", + "repo": "repo", + "labels": requestLabels, + } + if tc.method == "create" { + requestArgs["title"] = "Test issue" + } else { + requestArgs["issue_number"] = float64(123) + } + + deps := BaseDeps{ + Client: mustNewGHClient(t, restHTTPClient), + GQLClient: githubv4.NewClient(gqlHTTPClient), + } + serverTool := IssueWrite(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + request := createMCPRequest(requestArgs) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Equal(t, 1, restRequests.count, "label verification must use the write response without a readback") + assert.Zero(t, gqlRequests.count, "label verification must not make a GraphQL readback") + + resultText := getErrorResult(t, result).Text + assert.Contains(t, resultText, "issue "+tc.method+"d but requested labels were not fully applied") + assert.Contains(t, resultText, fmt.Sprintf("requested=%q", tc.requestedLabels)) + assert.Contains(t, resultText, fmt.Sprintf("applied=%q", tc.appliedLabels)) + assert.Contains(t, resultText, tc.expectedMissing) + assert.Contains(t, resultText, tc.expectedUnexpected) + assert.Contains(t, resultText, `issue_url="https://github.com/owner/repo/issues/123"`) + assert.Contains(t, resultText, "AddLabelsToLabelable permission") + }) + } +} + // 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_MCPAppsFeature_UIGate(t *testing.T) { From 12d16ed05310876a1e6988701b109da63d69dd49 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Tue, 1 Sep 2026 15:23:41 +0200 Subject: [PATCH 10/61] fix(http): allow projected MCP headers in preflights (#3167) * fix(http): allow projected MCP headers in preflights Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * fix(http): bound projected preflight headers Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] --- go.mod | 2 +- pkg/http/headers/headers.go | 8 + pkg/http/middleware/cors.go | 84 +++++++-- pkg/http/middleware/cors_test.go | 160 +++++++++++++----- pkg/http/server_test.go | 41 ++--- third-party-licenses.darwin.md | 2 +- third-party-licenses.linux.md | 2 +- third-party-licenses.windows.md | 2 +- .../golang.org/x/net/{html => }/LICENSE | 0 9 files changed, 223 insertions(+), 78 deletions(-) rename third-party/golang.org/x/net/{html => }/LICENSE (100%) diff --git a/go.mod b/go.mod index 45e0dcebdc..f0237a1625 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.12.1 github.com/yosida95/uritemplate/v3 v3.0.2 + golang.org/x/net v0.55.0 golang.org/x/oauth2 v0.36.0 ) @@ -38,7 +39,6 @@ require ( github.com/stretchr/objx v0.5.3 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/net v0.55.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.45.0 // indirect golang.org/x/text v0.37.0 // indirect diff --git a/pkg/http/headers/headers.go b/pkg/http/headers/headers.go index 280875ef5b..94981081a7 100644 --- a/pkg/http/headers/headers.go +++ b/pkg/http/headers/headers.go @@ -41,6 +41,14 @@ const ( // MCPMethodHeader mirrors the JSON-RPC method for request routing. MCPMethodHeader = "Mcp-Method" + // MCPNameHeader identifies the requested MCP primitive. + MCPNameHeader = "Mcp-Name" + // MCPParamHeaderPrefix prefixes request headers projected from MCP parameters. + MCPParamHeaderPrefix = "Mcp-Param-" + // MCPParamOwnerHeader carries the projected owner parameter. + MCPParamOwnerHeader = MCPParamHeaderPrefix + "owner" + // MCPParamRepoHeader carries the projected repo parameter. + MCPParamRepoHeader = MCPParamHeaderPrefix + "repo" // MCPReadOnlyHeader indicates whether the MCP is in read-only mode. MCPReadOnlyHeader = "X-MCP-Readonly" // MCPToolsetsHeader is a comma-separated list of MCP toolsets that the request is for. diff --git a/pkg/http/middleware/cors.go b/pkg/http/middleware/cors.go index 409d134127..00fd6d0a8e 100644 --- a/pkg/http/middleware/cors.go +++ b/pkg/http/middleware/cors.go @@ -2,42 +2,96 @@ package middleware import ( "net/http" + "sort" "strings" "github.com/github/github-mcp-server/pkg/http/headers" + "golang.org/x/net/http/httpguts" ) +const maxCORSProjectedRequestHeaders = 64 + +var corsAllowedRequestHeaders = []string{ + headers.ContentTypeHeader, + "Mcp-Session-Id", + "Mcp-Protocol-Version", + headers.MCPMethodHeader, + headers.MCPNameHeader, + "Last-Event-ID", + headers.AuthorizationHeader, + headers.MCPReadOnlyHeader, + headers.MCPToolsetsHeader, + headers.MCPToolsHeader, + headers.MCPExcludeToolsHeader, + headers.MCPFeaturesHeader, + headers.MCPLockdownHeader, + headers.MCPInsidersHeader, + headers.MCPParamOwnerHeader, + headers.MCPParamRepoHeader, +} + // SetCorsHeaders is middleware that sets CORS headers to allow browser-based // MCP clients to connect from any origin. This is safe because the server // authenticates via bearer tokens (not cookies), so cross-origin requests // cannot exploit ambient credentials. func SetCorsHeaders(h http.Handler) http.Handler { - allowHeaders := strings.Join([]string{ - "Content-Type", - "Mcp-Session-Id", - "Mcp-Protocol-Version", - "Last-Event-ID", - headers.AuthorizationHeader, - headers.MCPReadOnlyHeader, - headers.MCPToolsetsHeader, - headers.MCPToolsHeader, - headers.MCPExcludeToolsHeader, - headers.MCPFeaturesHeader, - headers.MCPLockdownHeader, - headers.MCPInsidersHeader, - }, ", ") + fixedAllowHeaders := strings.Join(corsAllowedRequestHeaders, ", ") return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS") w.Header().Set("Access-Control-Max-Age", "86400") w.Header().Add("Access-Control-Expose-Headers", "Mcp-Session-Id, WWW-Authenticate") - w.Header().Set("Access-Control-Allow-Headers", allowHeaders) + w.Header().Set("Access-Control-Allow-Headers", fixedAllowHeaders) if r.Method == http.MethodOptions { + w.Header().Set("Access-Control-Allow-Headers", corsPreflightAllowedRequestHeaders(r.Header)) w.WriteHeader(http.StatusOK) return } h.ServeHTTP(w, r) }) } + +// corsPreflightAllowedRequestHeaders reflects validated projected arguments because CORS has no prefix wildcard. +func corsPreflightAllowedRequestHeaders(requestHeaders http.Header) string { + allowed := make([]string, 0, len(corsAllowedRequestHeaders)) + allowed = append(allowed, corsAllowedRequestHeaders...) + + seen := make(map[string]struct{}, len(allowed)) + for _, header := range allowed { + seen[strings.ToLower(header)] = struct{}{} + } + + prefix := strings.ToLower(headers.MCPParamHeaderPrefix) + projected := make([]string, 0, maxCORSProjectedRequestHeaders) +requestedHeaders: + for _, value := range requestHeaders.Values("Access-Control-Request-Headers") { + for header := range strings.SplitSeq(value, ",") { + header = strings.TrimSpace(header) + if !httpguts.ValidHeaderFieldName(header) { + continue + } + + key := strings.ToLower(header) + if !strings.HasPrefix(key, prefix) || len(key) == len(prefix) { + continue + } + if _, ok := seen[key]; ok { + continue + } + + seen[key] = struct{}{} + projected = append(projected, key) + if len(projected) == maxCORSProjectedRequestHeaders { + break requestedHeaders + } + } + } + + sort.Strings(projected) + for _, header := range projected { + allowed = append(allowed, http.CanonicalHeaderKey(header)) + } + return strings.Join(allowed, ", ") +} diff --git a/pkg/http/middleware/cors_test.go b/pkg/http/middleware/cors_test.go index 67305e0ac8..4cc2dc2b4f 100644 --- a/pkg/http/middleware/cors_test.go +++ b/pkg/http/middleware/cors_test.go @@ -1,6 +1,7 @@ package middleware_test import ( + "fmt" "net/http" "net/http/httptest" "strings" @@ -8,54 +9,133 @@ import ( "github.com/github/github-mcp-server/pkg/http/middleware" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -func TestSetCorsHeaders(t *testing.T) { +const fixedAllowedRequestHeaders = "Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Mcp-Method, Mcp-Name, Last-Event-ID, Authorization, X-MCP-Readonly, X-MCP-Toolsets, X-MCP-Tools, X-MCP-Exclude-Tools, X-MCP-Features, X-MCP-Lockdown, X-MCP-Insiders, Mcp-Param-owner, Mcp-Param-repo" + +func TestSetCorsHeadersPreflight(t *testing.T) { + tests := []struct { + name string + requestedHeadersValues []string + expectedAllowedHeaders string + }{ + { + name: "current MCP request headers", + requestedHeadersValues: []string{ + "authorization, content-type, mcp-protocol-version, mcp-method, mcp-name, mcp-param-owner, mcp-param-repo", + }, + expectedAllowedHeaders: fixedAllowedRequestHeaders, + }, + { + name: "future projected parameter", + requestedHeadersValues: []string{"Mcp-Param-region"}, + expectedAllowedHeaders: fixedAllowedRequestHeaders + ", Mcp-Param-Region", + }, + { + name: "mixed case duplicates across values", + requestedHeadersValues: []string{ + "mCp-PaRaM-ReGiOn, MCP-PARAM-ZONE", + "MCP-PARAM-REGION, mcp-param-zone", + }, + expectedAllowedHeaders: fixedAllowedRequestHeaders + ", Mcp-Param-Region, Mcp-Param-Zone", + }, + { + name: "invalid unrelated bare and lookalike names", + requestedHeadersValues: []string{ + "Mcp-Param-, X-Evil, XMcp-Param-region, Mcp_Param-region, Mcp-Param-\x00region, Mcp-Param-bad name", + }, + expectedAllowedHeaders: fixedAllowedRequestHeaders, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + innerCalled := false + handler := middleware.SetCorsHeaders(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + innerCalled = true + })) + req := httptest.NewRequest(http.MethodOptions, "/", nil) + req.Header.Set("Origin", "https://confer.to") + req.Header.Set("Access-Control-Request-Method", http.MethodPost) + for _, value := range tt.requestedHeadersValues { + req.Header.Add("Access-Control-Request-Headers", value) + } + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + assert.False(t, innerCalled) + assert.Equal(t, "*", rr.Header().Get("Access-Control-Allow-Origin")) + assert.Empty(t, rr.Header().Get("Access-Control-Allow-Credentials")) + assert.Equal(t, "GET, POST, DELETE, OPTIONS", rr.Header().Get("Access-Control-Allow-Methods")) + assert.Equal(t, "86400", rr.Header().Get("Access-Control-Max-Age")) + assert.Equal(t, tt.expectedAllowedHeaders, rr.Header().Get("Access-Control-Allow-Headers")) + assert.Equal(t, "Mcp-Session-Id, WWW-Authenticate", rr.Header().Get("Access-Control-Expose-Headers")) + assert.NotContains(t, rr.Header().Get("Access-Control-Expose-Headers"), "Mcp-Param-") + }) + } +} + +func TestSetCorsHeadersPreflightBoundsProjectedHeaders(t *testing.T) { + const ( + maxProjectedHeaders = 64 + requestedHeaders = 1024 + ) + + requested := make([]string, 0, requestedHeaders*2) + expected := strings.Split(fixedAllowedRequestHeaders, ", ") + for i := range requestedHeaders { + header := fmt.Sprintf("mcp-param-%04d", i) + requested = append(requested, header, strings.ToUpper(header)) + if i < maxProjectedHeaders { + expected = append(expected, http.CanonicalHeaderKey(header)) + } + } + requestedValue := strings.Join(requested, ", ") + + handler := middleware.SetCorsHeaders(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + t.Fatal("preflight reached the inner handler") + })) + req := httptest.NewRequest(http.MethodOptions, "/", nil) + req.Header.Set("Access-Control-Request-Headers", requestedValue) + rr := httptest.NewRecorder() + + handler.ServeHTTP(rr, req) + + allowedValue := rr.Header().Get("Access-Control-Allow-Headers") + assert.Equal(t, expected, strings.Split(allowedValue, ", ")) + assert.NotContains(t, allowedValue, "Mcp-Param-0064") + assert.Less(t, len(allowedValue), len(requestedValue)) +} + +func TestSetCorsHeadersPostPreservesBehavior(t *testing.T) { innerCalled := false inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { innerCalled = true w.Header().Add("Access-Control-Expose-Headers", "X-Existing-Response") - w.WriteHeader(http.StatusOK) + w.WriteHeader(http.StatusCreated) + _, err := w.Write([]byte("created")) + require.NoError(t, err) }) handler := middleware.SetCorsHeaders(inner) + req := httptest.NewRequest(http.MethodPost, "/", nil) + req.Header.Set("Origin", "https://confer.to") + req.Header.Set("Access-Control-Request-Headers", "Mcp-Param-region") + rr := httptest.NewRecorder() - t.Run("OPTIONS preflight returns 200 with CORS headers", func(t *testing.T) { - innerCalled = false - req := httptest.NewRequest(http.MethodOptions, "/", nil) - req.Header.Set("Origin", "https://confer.to") - req.Header.Set("Access-Control-Request-Method", http.MethodPost) - req.Header.Set("Access-Control-Request-Headers", "content-type") - rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) - - assert.Equal(t, http.StatusOK, rr.Code) - assert.False(t, innerCalled) - assert.Equal(t, "*", rr.Header().Get("Access-Control-Allow-Origin")) - assert.Empty(t, rr.Header().Get("Access-Control-Allow-Credentials")) - assert.Contains(t, rr.Header().Get("Access-Control-Allow-Methods"), "POST") - assert.Contains(t, rr.Header().Get("Access-Control-Allow-Headers"), "Authorization") - assert.Contains(t, rr.Header().Get("Access-Control-Allow-Headers"), "Content-Type") - assert.Contains(t, rr.Header().Get("Access-Control-Allow-Headers"), "Mcp-Session-Id") - assert.Contains(t, rr.Header().Get("Access-Control-Allow-Headers"), "X-MCP-Lockdown") - assert.Contains(t, rr.Header().Get("Access-Control-Allow-Headers"), "X-MCP-Insiders") - assert.Contains(t, rr.Header().Get("Access-Control-Expose-Headers"), "Mcp-Session-Id") - assert.Contains(t, rr.Header().Get("Access-Control-Expose-Headers"), "WWW-Authenticate") - }) + handler.ServeHTTP(rr, req) - t.Run("POST request includes CORS headers without replacing existing exposed headers", func(t *testing.T) { - innerCalled = false - req := httptest.NewRequest(http.MethodPost, "/", nil) - req.Header.Set("Origin", "https://confer.to") - rr := httptest.NewRecorder() - handler.ServeHTTP(rr, req) - - assert.Equal(t, http.StatusOK, rr.Code) - assert.True(t, innerCalled) - assert.Equal(t, "*", rr.Header().Get("Access-Control-Allow-Origin")) - assert.Empty(t, rr.Header().Get("Access-Control-Allow-Credentials")) - exposedHeaders := strings.Join(rr.Header().Values("Access-Control-Expose-Headers"), ", ") - assert.Contains(t, exposedHeaders, "Mcp-Session-Id") - assert.Contains(t, exposedHeaders, "WWW-Authenticate") - assert.Contains(t, exposedHeaders, "X-Existing-Response") - }) + assert.Equal(t, http.StatusCreated, rr.Code) + assert.True(t, innerCalled) + assert.Equal(t, "created", rr.Body.String()) + assert.Equal(t, "*", rr.Header().Get("Access-Control-Allow-Origin")) + assert.Empty(t, rr.Header().Get("Access-Control-Allow-Credentials")) + assert.Equal(t, fixedAllowedRequestHeaders, rr.Header().Get("Access-Control-Allow-Headers")) + exposedHeaders := strings.Join(rr.Header().Values("Access-Control-Expose-Headers"), ", ") + assert.Contains(t, exposedHeaders, "Mcp-Session-Id") + assert.Contains(t, exposedHeaders, "WWW-Authenticate") + assert.Contains(t, exposedHeaders, "X-Existing-Response") + assert.NotContains(t, exposedHeaders, "Mcp-Param-") } diff --git a/pkg/http/server_test.go b/pkg/http/server_test.go index 8c62c01a40..500bb40611 100644 --- a/pkg/http/server_test.go +++ b/pkg/http/server_test.go @@ -106,26 +106,29 @@ func TestHTTPRouterCORSContract(t *testing.T) { ) tests := []struct { - name string - method string - path string - expectedStatus int - expectChallenge bool - expectAllowHeader bool + name string + method string + path string + requestHeaders string + expectedStatus int + expectChallenge bool + expectedAllow []string }{ { - name: "MCP preflight", - method: http.MethodOptions, - path: "/", - expectedStatus: http.StatusOK, - expectAllowHeader: true, + name: "MCP preflight", + method: http.MethodOptions, + path: "/", + requestHeaders: "content-type, mcp-method, mcp-name, mcp-param-owner, mcp-param-region", + expectedStatus: http.StatusOK, + expectedAllow: []string{"Content-Type", "Mcp-Method", "Mcp-Name", "Mcp-Param-owner", "Mcp-Param-Region"}, }, { - name: "metadata preflight", - method: http.MethodOptions, - path: "/metadata", - expectedStatus: http.StatusOK, - expectAllowHeader: true, + name: "metadata preflight", + method: http.MethodOptions, + path: "/metadata", + requestHeaders: "content-type", + expectedStatus: http.StatusOK, + expectedAllow: []string{"Content-Type"}, }, { name: "authentication challenge", @@ -166,7 +169,7 @@ func TestHTTPRouterCORSContract(t *testing.T) { req.Header.Set("Origin", "https://confer.to") if tt.method == http.MethodOptions { req.Header.Set("Access-Control-Request-Method", http.MethodPost) - req.Header.Set("Access-Control-Request-Headers", "content-type") + req.Header.Set("Access-Control-Request-Headers", tt.requestHeaders) } rec := httptest.NewRecorder() @@ -177,8 +180,8 @@ func TestHTTPRouterCORSContract(t *testing.T) { assert.Empty(t, rec.Header().Get("Access-Control-Allow-Credentials")) assert.Contains(t, rec.Header().Get("Access-Control-Expose-Headers"), "Mcp-Session-Id") assert.Contains(t, rec.Header().Get("Access-Control-Expose-Headers"), "WWW-Authenticate") - if tt.expectAllowHeader { - assert.Contains(t, rec.Header().Get("Access-Control-Allow-Headers"), "Content-Type") + for _, header := range tt.expectedAllow { + assert.Contains(t, rec.Header().Get("Access-Control-Allow-Headers"), header) } if tt.expectChallenge { assert.Equal(t, diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index f1d33c5130..9e8cd0b794 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -42,7 +42,7 @@ The following packages are included for the amd64, arm64 architectures. - [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE)) - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.5/LICENSE)) - - [golang.org/x/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.55.0:LICENSE)) + - [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.55.0:LICENSE)) - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.36.0:LICENSE)) - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.20.0:LICENSE)) - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.45.0:LICENSE)) diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index bd98a92cb8..f5b267f28d 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -42,7 +42,7 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE)) - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.5/LICENSE)) - - [golang.org/x/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.55.0:LICENSE)) + - [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.55.0:LICENSE)) - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.36.0:LICENSE)) - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.20.0:LICENSE)) - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.45.0:LICENSE)) diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index 05086f41c2..7e834bc36f 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -43,7 +43,7 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE)) - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.5/LICENSE)) - - [golang.org/x/net/html](https://pkg.go.dev/golang.org/x/net/html) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.55.0:LICENSE)) + - [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.55.0:LICENSE)) - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.36.0:LICENSE)) - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.20.0:LICENSE)) - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.45.0:LICENSE)) diff --git a/third-party/golang.org/x/net/html/LICENSE b/third-party/golang.org/x/net/LICENSE similarity index 100% rename from third-party/golang.org/x/net/html/LICENSE rename to third-party/golang.org/x/net/LICENSE From 2e6941920b11136c7997cc0e504b6ab2577ec800 Mon Sep 17 00:00:00 2001 From: Anton Karpov <30812217+karpovantonme@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:45:38 +0300 Subject: [PATCH 11/61] Spell perPage the same way in every paginated tool (#3142) * Fix perPage parameter name in actions_list schema The schema declared per_page while the handler reads pagination through OptionalPaginationParams, which looks for perPage, so the value was always dropped and perPage fell back to the default 30. * fix(projects): spell the page size the way the rest of the server does projects_list advertised per_page while every other paginated tool advertises perPage. The handlers read per_page, so it worked, but it left one tool spelling pagination differently from the other 30 and that is how actions_list ended up advertising a name nothing read. The schema now says perPage. per_page is still read when perPage is absent: the projects tools have advertised it since September 2025 and clients sending it get the size they ask for today. TestAllToolInputSchemasUseCanonicalPaginationNames walks the whole tool inventory and rejects case and underscore variants of page, perPage, after and before. On main it fails on actions_list and projects_list. --- README.md | 4 +-- pkg/github/__toolsnaps__/actions_list.snap | 2 +- pkg/github/__toolsnaps__/projects_list.snap | 2 +- pkg/github/actions.go | 2 +- pkg/github/projects.go | 23 +++++++++--- pkg/github/projects_test.go | 37 +++++++++++++++++++ pkg/github/tools_validation_test.go | 40 +++++++++++++++++++++ 7 files changed, 101 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 87965cb09d..1b9f693a34 100644 --- a/README.md +++ b/README.md @@ -631,7 +631,7 @@ The following sets of tools are available: - `method`: The action to perform (string, required) - `owner`: Repository owner (string, required) - `page`: Page number for pagination (default: 1) (number, optional) - - `per_page`: Results per page for pagination (default: 30, max: 100) (number, optional) + - `perPage`: Results per page for pagination (default: 30, max: 100) (number, optional) - `repo`: Repository name (string, required) - `resource_id`: The unique identifier of the resource. This will vary based on the "method" provided, so ensure you provide the correct ID: - Do not provide any resource ID for 'list_workflows' method. @@ -1092,7 +1092,7 @@ The following sets of tools are available: - `method`: The action to perform (string, required) - `owner`: The owner (user or organization login). The name is not case sensitive. (string, required) - `owner_type`: Owner type (user or org). If not provided, will automatically try both. (string, optional) - - `per_page`: Results per page (max 50) (number, optional) + - `perPage`: Results per page (max 50) (number, optional) - `project_number`: The project's number. Required for 'list_project_fields', 'list_project_items', 'list_project_views', and 'list_project_status_updates' methods. (number, optional) - `query`: Filter/query string. For list_projects: filter by title text and state (e.g. "roadmap is:open"). For list_project_items: advanced filtering using GitHub's project filtering syntax. (string, optional) diff --git a/pkg/github/__toolsnaps__/actions_list.snap b/pkg/github/__toolsnaps__/actions_list.snap index be97affbdb..4df628bf85 100644 --- a/pkg/github/__toolsnaps__/actions_list.snap +++ b/pkg/github/__toolsnaps__/actions_list.snap @@ -26,7 +26,7 @@ "minimum": 1, "type": "number" }, - "per_page": { + "perPage": { "description": "Results per page for pagination (default: 30, max: 100)", "maximum": 100, "minimum": 1, diff --git a/pkg/github/__toolsnaps__/projects_list.snap b/pkg/github/__toolsnaps__/projects_list.snap index 487119f04a..c64737900f 100644 --- a/pkg/github/__toolsnaps__/projects_list.snap +++ b/pkg/github/__toolsnaps__/projects_list.snap @@ -52,7 +52,7 @@ ], "type": "string" }, - "per_page": { + "perPage": { "description": "Results per page (max 50)", "type": "number" }, diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 667901e623..3f5654db10 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -313,7 +313,7 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an Description: "Page number for pagination (default: 1)", Minimum: jsonschema.Ptr(1.0), }, - "per_page": { + "perPage": { Type: "number", Description: "Results per page for pagination (default: 30, max: 100)", Minimum: jsonschema.Ptr(1.0), diff --git a/pkg/github/projects.go b/pkg/github/projects.go index df6d8ac190..fe702cfbbe 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -341,7 +341,7 @@ Use this tool to list projects for a user or organization, or list project field Type: "string", }, }, - "per_page": { + "perPage": { Type: "number", Description: fmt.Sprintf("Results per page (max %d)", MaxProjectsPerPage), }, @@ -1783,7 +1783,7 @@ func listProjectStatusUpdates(ctx context.Context, gqlClient *githubv4.Client, a return utils.NewToolResultError(err.Error()), false, nil, nil } - perPage, err := OptionalIntParamWithDefault(args, "per_page", MaxProjectsPerPage) + perPage, err := optionalProjectsPerPage(args) if err != nil { return utils.NewToolResultError(err.Error()), false, nil, nil } @@ -1940,7 +1940,7 @@ func listProjectViews(ctx context.Context, gqlClient *githubv4.Client, args map[ if err != nil { return utils.NewToolResultError(err.Error()), false, nil, nil } - perPage, err := OptionalIntParamWithDefault(args, "per_page", MaxProjectsPerPage) + perPage, err := optionalProjectsPerPage(args) if err != nil { return utils.NewToolResultError(err.Error()), false, nil, nil } @@ -2512,8 +2512,23 @@ func invalidIssueFieldValue(field *ResolvedField, hint string) error { ) } +// optionalProjectsPerPage reads the page size for the projects tools. +// +// The schema advertises perPage, the name every other paginated tool uses. The +// projects tools advertised per_page from September 2025 until this change and +// clients sending it get the size they asked for today, so it is still read when +// perPage is absent. +func optionalProjectsPerPage(args map[string]any) (int, error) { + if _, ok := args["perPage"]; !ok { + if _, legacy := args["per_page"]; legacy { + return OptionalIntParamWithDefault(args, "per_page", MaxProjectsPerPage) + } + } + return OptionalIntParamWithDefault(args, "perPage", MaxProjectsPerPage) +} + func extractPaginationOptionsFromArgs(args map[string]any) (github.ListProjectsPaginationOptions, error) { - perPage, err := OptionalIntParamWithDefault(args, "per_page", MaxProjectsPerPage) + perPage, err := optionalProjectsPerPage(args) if err != nil { return github.ListProjectsPaginationOptions{}, err } diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index e7d67f5264..8b40a569e0 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -395,6 +395,43 @@ func Test_ProjectsList_ListProjectItems(t *testing.T) { }) } +func Test_optionalProjectsPerPage(t *testing.T) { + tests := []struct { + name string + args map[string]any + want int + }{ + { + name: "canonical perPage", + args: map[string]any{"perPage": float64(10)}, + want: 10, + }, + { + name: "per_page still read for clients on the previous name", + args: map[string]any{"per_page": float64(10)}, + want: 10, + }, + { + name: "perPage wins when both are sent", + args: map[string]any{"perPage": float64(10), "per_page": float64(25)}, + want: 10, + }, + { + name: "neither sent falls back to the maximum", + args: map[string]any{}, + want: MaxProjectsPerPage, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := optionalProjectsPerPage(tc.args) + require.NoError(t, err) + assert.Equal(t, tc.want, got) + }) + } +} + func Test_detectOwnerType(t *testing.T) { t.Run("uses organization account type", func(t *testing.T) { mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ diff --git a/pkg/github/tools_validation_test.go b/pkg/github/tools_validation_test.go index dac04b5d54..cdc12348ae 100644 --- a/pkg/github/tools_validation_test.go +++ b/pkg/github/tools_validation_test.go @@ -71,6 +71,46 @@ func TestAllToolInputSchemasAvoidTopLevelCombinators(t *testing.T) { } } +// TestAllToolInputSchemasUseCanonicalPaginationNames keeps pagination properties +// spelled one way across the whole inventory. The pagination helpers read page, +// perPage, after and before, so a schema that advertises a case or underscore +// variant of one of those names promises a knob the handler never turns: whatever +// the client sends is dropped and the default is used instead. actions_list +// advertised per_page for months that way. +func TestAllToolInputSchemasUseCanonicalPaginationNames(t *testing.T) { + canonical := map[string]string{ + "page": "page", + "perpage": "perPage", + "after": "after", + "before": "before", + } + + tools := AllTools(stubTranslation) + require.NotEmpty(t, tools, "AllTools should return at least one tool") + + for _, serverTool := range tools { + tool := serverTool.Tool + t.Run(tool.Name, func(t *testing.T) { + data, err := json.Marshal(tool.InputSchema) + require.NoError(t, err, "Tool %q InputSchema must marshal", tool.Name) + + var schema struct { + Properties map[string]json.RawMessage `json:"properties"` + } + require.NoError(t, json.Unmarshal(data, &schema), "Tool %q InputSchema must be a JSON object", tool.Name) + + for name := range schema.Properties { + want, ok := canonical[strings.ToLower(strings.ReplaceAll(name, "_", ""))] + if !ok { + continue + } + assert.Equal(t, want, name, + "Tool %q advertises pagination property %q; the canonical spelling is %q", tool.Name, name, want) + } + }) + } +} + // TestAllResourcesHaveRequiredMetadata validates that all resources have mandatory metadata func TestAllResourcesHaveRequiredMetadata(t *testing.T) { // Resources are now stateless - no client functions needed From bd47e638b8c16f780e4b277b5cc5e9ba366efba5 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 2 Sep 2026 15:09:04 +0200 Subject: [PATCH 12/61] Add MCP Server Card (SEP-2127) types + handler (#2768) Define the GitHub MCP Server's Server Card (SEP-2127, in review) and a public no-auth HTTP handler that serves it at the canonical /server-card backend path. OSS owns all stable identity/metadata and serving behavior; the remote server only supplies an environment-specific remote URL. Negotiate Accept and If-None-Match as RFC 9110 list values across repeated header field-lines. Accept honors quoted commas, media-range parameters, and q-values (q=0 rejects); entity-tag lists split on quoted commas without backslash escaping, since an opaque-tag treats a backslash literally. Vary lists Accept and X-Forwarded-Host and is appended rather than set: it composes with values added by deployment middleware and, since the response is publicly cacheable and multi-tenant deployments derive the remote URL from the trusted X-Forwarded-Host, it keeps a shared cache from serving one tenant's card to another. Refs github/copilot-mcp-core#1855, epic github/copilot-mcp-core#1853 Spec: modelcontextprotocol/experimental-ext-server-card Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4a9f522f-6942-4b77-98a4-b2d42f19625d --- pkg/http/servercard/card.go | 145 +++++++++++++ pkg/http/servercard/card_test.go | 96 +++++++++ pkg/http/servercard/handler.go | 241 +++++++++++++++++++++ pkg/http/servercard/handler_test.go | 318 ++++++++++++++++++++++++++++ 4 files changed, 800 insertions(+) create mode 100644 pkg/http/servercard/card.go create mode 100644 pkg/http/servercard/card_test.go create mode 100644 pkg/http/servercard/handler.go create mode 100644 pkg/http/servercard/handler_test.go diff --git a/pkg/http/servercard/card.go b/pkg/http/servercard/card.go new file mode 100644 index 0000000000..9a2d3ac13b --- /dev/null +++ b/pkg/http/servercard/card.go @@ -0,0 +1,145 @@ +// Package servercard provides the GitHub MCP Server's MCP Server Card +// (SEP-2127) types and a public, no-auth HTTP handler that serves it. +// +// A Server Card is a static metadata document that describes a remote MCP +// server — its identity, repository, and HTTP transport — so clients can +// discover and connect to it before the protocol handshake. It is remote-only +// and deliberately does NOT enumerate primitives (tools, resources, prompts) +// or installable packages; those remain in the MCP Registry document +// (server.json) and runtime listing. +// +// See: +// - https://github.com/modelcontextprotocol/experimental-ext-server-card +// - https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2127 +package servercard + +import "net/http" + +const ( + // SchemaURL is the v1 Server Card JSON Schema URI that emitted cards + // conform to. The schema is versioned by its `vN` path segment. + SchemaURL = "https://static.modelcontextprotocol.io/schemas/v1/server-card.schema.json" + + // MediaType is the media type used to serve and request a Server Card. + MediaType = "application/mcp-server-card+json" + + // Path is the suffix, relative to a server's streamable-HTTP URL, at which + // MCP reserves the recommended Server Card location. A server hosted at + // `https://host/mcp` therefore serves its card at `https://host/mcp/server-card`. + Path = "/server-card" + + // DefaultRemoteURL is the streamable-HTTP endpoint of the hosted GitHub MCP + // Server on github.com. The remote repository overrides this per environment. + DefaultRemoteURL = "https://api.githubcopilot.com/mcp/" +) + +// Identity fields reused from the MCP Registry document (server.json) so the +// Server Card and the registry entry describe the same server. +const ( + serverName = "io.github.github/github-mcp-server" + serverTitle = "GitHub" + serverDescription = "Connect AI assistants to GitHub - manage repos, issues, PRs, and workflows through natural language." + repositoryURL = "https://github.com/github/github-mcp-server" + repositorySource = "github" + // repositoryID is the github.com repository ID for github/github-mcp-server. + // It is stable across renames but changes if the repository is recreated. + repositoryID = "942771284" +) + +// ServerCard is a static metadata document describing a remote MCP server, +// suitable for pre-connection discovery. It mirrors the ServerCard interface in +// modelcontextprotocol/experimental-ext-server-card. Server Cards are +// remote-only and never carry installable packages. +type ServerCard struct { + // Schema is the Server Card JSON Schema URI this document conforms to. + Schema string `json:"$schema"` + // Name is the server name in reverse-DNS format with exactly one slash. + Name string `json:"name"` + // Version is the server version, equivalent to Implementation.version. + Version string `json:"version"` + // Description is a short, human-readable explanation of server functionality. + Description string `json:"description"` + // Title is an optional human-readable display name. + Title string `json:"title,omitempty"` + // WebsiteURL optionally links to the server's homepage or documentation. + WebsiteURL string `json:"websiteUrl,omitempty"` + // Repository optionally describes the server's source code for inspection. + Repository *Repository `json:"repository,omitempty"` + // Remotes lists the HTTP-based endpoints for connecting to the server. + Remotes []Remote `json:"remotes,omitempty"` +} + +// Repository describes the MCP server's source code location. +type Repository struct { + // URL is the repository URL for browsing source and cloning. + URL string `json:"url"` + // Source is the hosting service identifier (e.g. "github"). + Source string `json:"source"` + // ID is the optional repository identifier owned by the hosting service. + ID string `json:"id,omitempty"` +} + +// Remote describes a remote (HTTP-based) MCP server endpoint. Authentication is +// intentionally not described here: the hosted server advertises its auth +// requirements via OAuth protected-resource-metadata discovery, so duplicating +// them on the card would risk drift and cannot capture every accepted mode. +type Remote struct { + // Type is the transport type ("streamable-http" or "sse"). + Type string `json:"type"` + // URL is the endpoint URL. + URL string `json:"url"` +} + +// Config controls how the GitHub MCP Server card is built and served. +type Config struct { + // Version is advertised as the card's version and SHOULD match the + // runtime serverInfo version. When empty, "0.0.0-dev" is used. + Version string + + // RemoteURL is the absolute streamable-HTTP endpoint advertised in the + // card's single remote. When empty, DefaultRemoteURL is used. The remote + // repository supplies a per-environment URL here. + RemoteURL string + + // RemoteURLFunc, when set, derives the streamable-HTTP remote URL from the + // incoming request, taking precedence over RemoteURL whenever it returns a + // non-empty value. This supports multi-tenant deployments (e.g. proxima) + // where the absolute URL varies per request (e.g. from X-Forwarded-Host). + // + // It is consumed by the Handler when serving a card; NewServerCard ignores + // it, since the card constructor is not request-aware. + RemoteURLFunc func(*http.Request) string +} + +// NewServerCard builds the GitHub MCP Server's Server Card from cfg. +func NewServerCard(cfg Config) *ServerCard { + version := cfg.Version + if version == "" { + version = "0.0.0-dev" + } + + remoteURL := cfg.RemoteURL + if remoteURL == "" { + remoteURL = DefaultRemoteURL + } + + // supportedProtocolVersions is intentionally omitted: the go-sdk does not + // export the versions it negotiates, so advertising a hand-maintained list + // here would risk drifting from what the server actually serves. + return &ServerCard{ + Schema: SchemaURL, + Name: serverName, + Version: version, + Description: serverDescription, + Title: serverTitle, + WebsiteURL: repositoryURL, + Repository: &Repository{ + URL: repositoryURL, + Source: repositorySource, + ID: repositoryID, + }, + Remotes: []Remote{ + {Type: "streamable-http", URL: remoteURL}, + }, + } +} diff --git a/pkg/http/servercard/card_test.go b/pkg/http/servercard/card_test.go new file mode 100644 index 0000000000..c10c08831c --- /dev/null +++ b/pkg/http/servercard/card_test.go @@ -0,0 +1,96 @@ +package servercard + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// assertCardContract checks the required Server Card fields defined by the +// experimental-ext-server-card v1 schema, plus the remote-only invariant. The +// card is small and stable, so asserting its required fields is a focused +// stand-in for vendoring the upstream JSON schema. +func assertCardContract(t *testing.T, card *ServerCard) { + t.Helper() + + raw, err := json.Marshal(card) + require.NoError(t, err) + + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw, &fields)) + + // Required by the schema: $schema, name, version, description. + assert.Equal(t, SchemaURL, card.Schema) + assert.NotEmpty(t, card.Name) + assert.NotEmpty(t, card.Version) + require.NotEmpty(t, card.Description) + assert.LessOrEqual(t, len(card.Description), 100, "description must respect the schema maxLength") + for _, key := range []string{"$schema", "name", "version", "description"} { + assert.Contains(t, fields, key, "required field %q must be serialized", key) + } + + // Remote-only: a Server Card never enumerates installable packages — those + // stay in the registry server.json. + assert.NotContains(t, fields, "packages", "Server Card must be remote-only and omit packages") + require.Len(t, card.Remotes, 1) + assert.Equal(t, "streamable-http", card.Remotes[0].Type) +} + +func TestNewServerCard(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + cfg Config + expectedVersion string + expectedRemoteURL string + }{ + { + name: "defaults", + cfg: Config{}, + expectedVersion: "0.0.0-dev", + expectedRemoteURL: DefaultRemoteURL, + }, + { + name: "explicit version", + cfg: Config{Version: "1.2.3"}, + expectedVersion: "1.2.3", + expectedRemoteURL: DefaultRemoteURL, + }, + { + name: "per-environment remote URL", + cfg: Config{Version: "1.2.3", RemoteURL: "https://api.example.test/mcp/"}, + expectedVersion: "1.2.3", + expectedRemoteURL: "https://api.example.test/mcp/", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + card := NewServerCard(tc.cfg) + + // Identity is reused from the registry document (server.json): it is + // the stable Server Card / registry server name. The AI Catalog + // identifier is assigned independently and is not derived from it. + assert.Equal(t, "io.github.github/github-mcp-server", card.Name) + assert.Equal(t, "GitHub", card.Title) + assert.True(t, strings.HasPrefix(card.Description, "Connect AI assistants to GitHub")) + assert.Equal(t, tc.expectedVersion, card.Version) + assert.Equal(t, "https://github.com/github/github-mcp-server", card.WebsiteURL) + + require.NotNil(t, card.Repository) + assert.Equal(t, "https://github.com/github/github-mcp-server", card.Repository.URL) + assert.Equal(t, "github", card.Repository.Source) + assert.Equal(t, "942771284", card.Repository.ID) + + assert.Equal(t, tc.expectedRemoteURL, card.Remotes[0].URL) + + assertCardContract(t, card) + }) + } +} diff --git a/pkg/http/servercard/handler.go b/pkg/http/servercard/handler.go new file mode 100644 index 0000000000..6a1ce970aa --- /dev/null +++ b/pkg/http/servercard/handler.go @@ -0,0 +1,241 @@ +package servercard + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "strconv" + "strings" + + "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/go-chi/chi/v5" +) + +// Handler serves the GitHub MCP Server's Server Card over HTTP as public, +// no-auth metadata. It mirrors the OAuth protected-resource-metadata handler so +// the remote server repository can mount it and supply a per-environment remote +// URL via Config. +type Handler struct { + cfg Config +} + +// NewHandler returns a Handler that serves the card built from cfg. +func NewHandler(cfg Config) *Handler { + return &Handler{cfg: cfg} +} + +// RegisterRoutes mounts the handler at the single canonical Path for every +// method (mirroring oauth.AuthHandler) so it owns the route — answering non-GET +// requests itself rather than falling through to the auth-gated MCP endpoint — +// and is deliberately exposed at no alternate path. +func (h *Handler) RegisterRoutes(r chi.Router) { + r.Handle(Path, h) +} + +// ServeHTTP serves the Server Card as application/mcp-server-card+json. +// +// It honors GET and HEAD (with OPTIONS preflight), performs content negotiation +// against the Accept header, supports ETag conditional requests, and is safe to +// mount at /server-card without authentication middleware. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodOptions: + setCORSHeaders(w) + w.WriteHeader(http.StatusOK) + return + case http.MethodGet, http.MethodHead: + // served below + default: + setCORSHeaders(w) + w.Header().Set("Allow", "GET, HEAD, OPTIONS") + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + if !acceptsCard(joinFieldLines(r.Header.Values(headers.AcceptHeader))) { + setCORSHeaders(w) + http.Error(w, "not acceptable: expected "+MediaType, http.StatusNotAcceptable) + return + } + + ServeCard(w, r, h.resolveCard(r)) +} + +// resolveCard builds the card for a request, applying the per-request +// RemoteURLFunc override when configured. +func (h *Handler) resolveCard(r *http.Request) *ServerCard { + cfg := h.cfg + if h.cfg.RemoteURLFunc != nil { + if url := h.cfg.RemoteURLFunc(r); url != "" { + cfg.RemoteURL = url + } + } + return NewServerCard(cfg) +} + +// ServeCard writes card to w as the canonical Server Card response and is the +// single source of truth for its headers and conditional-request behavior, so +// callers that build a card per request get byte-identical ETag and headers. +// +// It sets the CORS headers, a one-hour Cache-Control, and a strong ETag. A +// matching If-None-Match (strong, weak, or `*`) yields 304; HEAD omits the body. +// Callers handle method dispatch and Accept negotiation first. +func ServeCard(w http.ResponseWriter, r *http.Request, card *ServerCard) { + body, err := json.Marshal(card) + if err != nil { + setCORSHeaders(w) + http.Error(w, "failed to encode server card", http.StatusInternalServerError) + return + } + + etag := computeETag(body) + + setCORSHeaders(w) + w.Header().Set("Cache-Control", "public, max-age=3600") + w.Header().Set("ETag", etag) + + if ifNoneMatchSatisfied(joinFieldLines(r.Header.Values("If-None-Match")), etag) { + w.WriteHeader(http.StatusNotModified) + return + } + + w.Header().Set(headers.ContentTypeHeader, MediaType) + w.WriteHeader(http.StatusOK) + if r.Method == http.MethodHead { + return + } + _, _ = w.Write(body) +} + +// computeETag returns a strong ETag: the lowercase-hex SHA-256 of body, wrapped +// in double quotes. It is deterministic for identical content. +func computeETag(body []byte) string { + sum := sha256.Sum256(body) + return `"` + hex.EncodeToString(sum[:]) + `"` +} + +// ifNoneMatchSatisfied reports whether an If-None-Match header value matches the +// given strong ETag using RFC 9110 weak comparison: `*` always matches, and a +// listed entity-tag matches if its opaque tag equals etag's, ignoring any weak +// `W/` prefix. +func ifNoneMatchSatisfied(ifNoneMatch, etag string) bool { + ifNoneMatch = strings.TrimSpace(ifNoneMatch) + if ifNoneMatch == "" { + return false + } + if ifNoneMatch == "*" { + return true + } + + target := strings.TrimPrefix(etag, "W/") + for _, candidate := range splitList(ifNoneMatch, ',', false) { + if strings.TrimPrefix(strings.TrimSpace(candidate), "W/") == target { + return true + } + } + return false +} + +// setCORSHeaders applies the read-only CORS + caching headers required by the +// experimental-ext-server-card discovery spec. The card is public metadata, so +// any origin may read it. Because the response is publicly cacheable and the +// remote URL derives from the trusted X-Forwarded-Host, Vary declares Accept and +// X-Forwarded-Host so a shared cache cannot serve one tenant's card to another; +// it is added (not set) to preserve values contributed by upstream middleware. +func setCORSHeaders(w http.ResponseWriter) { + h := w.Header() + h.Set("Access-Control-Allow-Origin", "*") + h.Set("Access-Control-Allow-Methods", "GET") + h.Set("Access-Control-Allow-Headers", "Content-Type, If-None-Match") + h.Set("Access-Control-Expose-Headers", "ETag") + h.Add("Vary", "Accept, X-Forwarded-Host") +} + +// joinFieldLines merges a list-valued header's repeated field-lines, since net/http's Header.Get returns only the first. +func joinFieldLines(values []string) string { + return strings.Join(values, ",") +} + +// acceptsCard reports whether an Accept header value permits the Server Card +// media type, honoring RFC 9110 quality values. An empty header accepts +// anything. Otherwise the most specific matching media range decides the +// result — the exact card type over application/* over */* — and an explicit +// q=0 on that range rejects the representation (yielding 406). A range that +// carries a media parameter (one appearing before q) does not match the +// parameterless card and is skipped. +func acceptsCard(accept string) bool { + if strings.TrimSpace(accept) == "" { + return true + } + + bestSpecificity := -1 + var bestQuality float64 + for _, part := range splitList(accept, ',', true) { + mediaRange, quality, hasParams := parseMediaRange(part) + specificity := -1 + if !hasParams { + switch mediaRange { + case MediaType: + specificity = 2 + case "application/*": + specificity = 1 + case "*/*": + specificity = 0 + } + } + if specificity > bestSpecificity { + bestSpecificity = specificity + bestQuality = quality + } + } + return bestSpecificity >= 0 && bestQuality > 0 +} + +// parseMediaRange splits one Accept media range into its lowercased media type, +// quality value, and whether it carries a media parameter. Parameters up to the +// first q are media parameters (which the parameterless card cannot satisfy, so +// hasParams is set); q sets the quality (default 1.0); segments after q are +// accept extensions and are ignored. +func parseMediaRange(part string) (mediaType string, quality float64, hasParams bool) { + segments := splitList(part, ';', true) + mediaType = strings.ToLower(strings.TrimSpace(segments[0])) + quality = 1.0 + seenQ := false + for _, seg := range segments[1:] { + name, value, _ := strings.Cut(seg, "=") + if strings.EqualFold(strings.TrimSpace(name), "q") { + seenQ = true + if q, err := strconv.ParseFloat(strings.TrimSpace(value), 64); err == nil { + quality = q + } + continue + } + if !seenQ && strings.TrimSpace(name) != "" { + hasParams = true + } + } + return mediaType, quality, hasParams +} + +// splitList splits an HTTP list value on sep, honoring RFC 9110 quoted strings so a +// quoted separator does not divide the list. escape enables backslash quoted-pairs +// (Accept parameter values); entity-tags pass false and treat backslash literally. +func splitList(s string, sep byte, escape bool) []string { + var out []string + start, inQuote, esc := 0, false, false + for i := range len(s) { + switch { + case esc: + esc = false + case inQuote && escape && s[i] == '\\': + esc = true + case s[i] == '"': + inQuote = !inQuote + case s[i] == sep && !inQuote: + out = append(out, s[start:i]) + start = i + 1 + } + } + return append(out, s[start:]) +} diff --git a/pkg/http/servercard/handler_test.go b/pkg/http/servercard/handler_test.go new file mode 100644 index 0000000000..a63a2d7cb5 --- /dev/null +++ b/pkg/http/servercard/handler_test.go @@ -0,0 +1,318 @@ +package servercard + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/go-chi/chi/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// assertCanonicalCORS asserts the CORS and cache-negotiation headers the +// discovery spec mandates on every card response (errors, preflight, and 304s). +func assertCanonicalCORS(t *testing.T, res *http.Response) { + t.Helper() + h := res.Header + assert.Equal(t, "*", h.Get("Access-Control-Allow-Origin")) + assert.Equal(t, "GET", h.Get("Access-Control-Allow-Methods")) + assert.Equal(t, "Content-Type, If-None-Match", h.Get("Access-Control-Allow-Headers")) + assert.Equal(t, "ETag", h.Get("Access-Control-Expose-Headers")) + assert.Equal(t, "Accept, X-Forwarded-Host", h.Get("Vary")) +} + +// assertStrongETag asserts a quoted, strong SHA-256 ETag and returns it. +func assertStrongETag(t *testing.T, res *http.Response) string { + t.Helper() + etag := res.Header.Get("ETag") + assert.True(t, strings.HasPrefix(etag, `"`) && strings.HasSuffix(etag, `"`), "ETag must be a quoted strong tag, got %q", etag) + assert.Len(t, etag, 66, "ETag should wrap a 64-char hex SHA-256 in quotes") + return etag +} + +func TestHandlerServeHTTP(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + method string + accept string + expectedStatus int + expectBody bool + }{ + { + name: "GET returns the card", + method: http.MethodGet, + expectedStatus: http.StatusOK, + expectBody: true, + }, + { + name: "GET with incompatible Accept is rejected", + method: http.MethodGet, + accept: "text/html", + expectedStatus: http.StatusNotAcceptable, + }, + { + name: "HEAD returns headers without body", + method: http.MethodHead, + expectedStatus: http.StatusOK, + expectBody: false, + }, + { + name: "OPTIONS preflight", + method: http.MethodOptions, + expectedStatus: http.StatusOK, + }, + { + name: "POST is not allowed", + method: http.MethodPost, + expectedStatus: http.StatusMethodNotAllowed, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + handler := NewHandler(Config{Version: "1.2.3"}) + req := httptest.NewRequest(tc.method, Path, nil) + if tc.accept != "" { + req.Header.Set(headers.AcceptHeader, tc.accept) + } + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + res := rec.Result() + defer res.Body.Close() + + assert.Equal(t, tc.expectedStatus, res.StatusCode) + assertCanonicalCORS(t, res) + + if tc.expectedStatus == http.StatusOK && tc.method != http.MethodOptions { + assert.Equal(t, MediaType, res.Header.Get(headers.ContentTypeHeader)) + assertStrongETag(t, res) + } + + if tc.expectBody { + var card ServerCard + require.NoError(t, json.NewDecoder(res.Body).Decode(&card)) + assert.Equal(t, SchemaURL, card.Schema) + assert.Equal(t, "1.2.3", card.Version) + require.Len(t, card.Remotes, 1) + assert.Equal(t, DefaultRemoteURL, card.Remotes[0].URL) + } + }) + } +} + +// TestHandlerMultiLineAccept guards the list-valued-header fix: the handler joins +// Accept's repeated field-lines and negotiates them as one list (Header.Get would +// read only the first line and 406). +func TestHandlerMultiLineAccept(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, Path, nil) + req.Header.Add(headers.AcceptHeader, "text/html") + req.Header.Add(headers.AcceptHeader, MediaType) + rec := httptest.NewRecorder() + + NewHandler(Config{}).ServeHTTP(rec, req) + + assert.Equal(t, http.StatusOK, rec.Result().StatusCode) +} + +// TestAcceptsCard exercises RFC 9110 Accept negotiation directly, covering quoted +// commas/escapes, pre-q media parameters, post-q extensions, and specificity/q=0. +func TestAcceptsCard(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + accept string + want bool + }{ + {name: "empty accepts anything", accept: "", want: true}, + {name: "exact type", accept: MediaType, want: true}, + {name: "exact type refused", accept: MediaType + ";q=0", want: false}, + {name: "full wildcard", accept: "*/*", want: true}, + {name: "full wildcard refused", accept: "*/*;q=0", want: false}, + {name: "application wildcard", accept: "application/*", want: true}, + {name: "application wildcard refused", accept: "application/*;q=0", want: false}, + {name: "unrelated type", accept: "text/html", want: false}, + {name: "quoted comma in refused param", accept: `application/mcp-server-card+json;note="a,b";q=0`, want: false}, + {name: "escaped quote in refused param", accept: `application/mcp-server-card+json;note="a\",b";q=0`, want: false}, + {name: "pre-q param falls through to wildcard", accept: "application/mcp-server-card+json;profile=x;q=0, */*;q=1", want: true}, + {name: "pre-q param without fallback does not match", accept: "application/mcp-server-card+json;profile=x", want: false}, + {name: "post-q extension is ignored", accept: "application/mcp-server-card+json;q=1;ext=foo", want: true}, + {name: "post-q extension on a refused range", accept: "application/mcp-server-card+json;q=0;ext=foo", want: false}, + {name: "most specific refusal wins over broad accept", accept: "application/mcp-server-card+json;q=0, */*;q=1", want: false}, + {name: "specific accept wins over broad refusal", accept: "application/mcp-server-card+json, */*;q=0", want: true}, + {name: "quoted wildcard param does not spoof a match", accept: `application/mcp-server-card+json;x="*/*";q=0`, want: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, acceptsCard(tc.accept)) + }) + } +} + +// TestHandlerVaryComposition asserts the handler appends Accept to Vary without +// discarding values a deployment's middleware set earlier in the chain. +func TestHandlerVaryComposition(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(http.MethodGet, Path, nil) + rec := httptest.NewRecorder() + rec.Header().Set("Vary", "Origin") + + NewHandler(Config{}).ServeHTTP(rec, req) + + assert.Equal(t, []string{"Origin", "Accept, X-Forwarded-Host"}, rec.Result().Header.Values("Vary")) +} + +func TestHandlerRegisterRoutes(t *testing.T) { + t.Parallel() + + // The remote mounts the MCP endpoint as a catch-all at "/" (mirroring + // pkg/http/handler.go: r.Mount("/", h)). The card's static route must take + // precedence so the card — not the auth-gated MCP endpoint — answers its + // single canonical path, with no alternate (e.g. /mcp/server-card). + const mcpStatus = http.StatusUnauthorized // sentinel for the auth-gated MCP catch-all + + r := chi.NewRouter() + r.Group(func(r chi.Router) { + r.Mount("/", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(mcpStatus) + })) + }) + r.Group(func(r chi.Router) { + NewHandler(Config{}).RegisterRoutes(r) + }) + + tests := []struct { + name string + method string + path string + wantStatus int + wantCard bool + }{ + {name: "canonical GET is served by the card, not the catch-all", method: http.MethodGet, path: Path, wantStatus: http.StatusOK, wantCard: true}, + {name: "non-GET at the card path is the card's 405, not the catch-all", method: http.MethodPost, path: Path, wantStatus: http.StatusMethodNotAllowed}, + {name: "no alternate path is registered", method: http.MethodGet, path: "/mcp" + Path, wantStatus: mcpStatus}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + req := httptest.NewRequest(tc.method, tc.path, nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + res := rec.Result() + defer res.Body.Close() + + assert.Equal(t, tc.wantStatus, res.StatusCode) + if tc.wantCard { + assert.Equal(t, MediaType, res.Header.Get(headers.ContentTypeHeader)) + } + }) + } +} + +func TestHandlerETagConditionalRequests(t *testing.T) { + t.Parallel() + + handler := NewHandler(Config{Version: "1.2.3"}) + + get := func(t *testing.T, ifNoneMatch string) *http.Response { + t.Helper() + req := httptest.NewRequest(http.MethodGet, Path, nil) + if ifNoneMatch != "" { + req.Header.Set("If-None-Match", ifNoneMatch) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec.Result() + } + + // Baseline GET yields a quoted strong ETag; every table case re-asserts it, proving stability. + res := get(t, "") + etag := assertStrongETag(t, res) + res.Body.Close() + + tests := []struct { + name string + ifNoneMatch string + expectedStatus int + expectBody bool + }{ + {name: "matching strong tag", ifNoneMatch: etag, expectedStatus: http.StatusNotModified, expectBody: false}, + {name: "matching weak form", ifNoneMatch: "W/" + etag, expectedStatus: http.StatusNotModified, expectBody: false}, + {name: "wildcard", ifNoneMatch: "*", expectedStatus: http.StatusNotModified, expectBody: false}, + {name: "within a list", ifNoneMatch: `"other", ` + etag, expectedStatus: http.StatusNotModified, expectBody: false}, + {name: "list with backslash in tag", ifNoneMatch: `"o\", ` + etag, expectedStatus: http.StatusNotModified, expectBody: false}, + {name: "non-matching tag", ifNoneMatch: `"deadbeef"`, expectedStatus: http.StatusOK, expectBody: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + res := get(t, tc.ifNoneMatch) + defer res.Body.Close() + + assert.Equal(t, tc.expectedStatus, res.StatusCode) + assert.Equal(t, etag, res.Header.Get("ETag")) + assert.Equal(t, "public, max-age=3600", res.Header.Get("Cache-Control")) + + body, err := io.ReadAll(res.Body) + require.NoError(t, err) + if tc.expectBody { + assert.NotEmpty(t, body) + assert.Equal(t, MediaType, res.Header.Get(headers.ContentTypeHeader)) + } else { + assert.Empty(t, body, "304 must have an empty body") + assert.Empty(t, res.Header.Get(headers.ContentTypeHeader), "304 should not carry Content-Type") + } + }) + } +} + +func TestHandlerRemoteURLFunc(t *testing.T) { + t.Parallel() + + // Simulate a multi-tenant deployment deriving the remote URL per request. + handler := NewHandler(Config{ + Version: "1.2.3", + RemoteURLFunc: func(r *http.Request) string { + return "https://" + r.Host + "/mcp/" + }, + }) + + fetch := func(host string) (string, string) { + req := httptest.NewRequest(http.MethodGet, Path, nil) + req.Host = host + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + res := rec.Result() + defer res.Body.Close() + var card ServerCard + require.NoError(t, json.NewDecoder(res.Body).Decode(&card)) + require.Len(t, card.Remotes, 1) + return card.Remotes[0].URL, res.Header.Get("ETag") + } + + urlA, etagA := fetch("tenant-a.example.test") + urlB, etagB := fetch("tenant-b.example.test") + + assert.Equal(t, "https://tenant-a.example.test/mcp/", urlA) + assert.Equal(t, "https://tenant-b.example.test/mcp/", urlB) + assert.NotEqual(t, etagA, etagB, "different per-tenant bodies must yield different ETags") +} From 7689727b3edadc12ec8063a2f5d45a6ec2df499b Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 3 Sep 2026 15:02:03 +0200 Subject: [PATCH 13/61] Refactor feature flags to use functional availability rules (#3166) * refactor(features)!: use functional availability rules Resolve declared inventory features once per request and share the request-owned cache with in-handler feature checks. BREAKING CHANGE: Inventory items now use FeatureRule instead of FeatureFlagEnable, FeatureFlagEnableAll, and FeatureFlagDisable; FeatureFlagChecker now accepts FeatureFlag. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e4a1ca6-53f7-4158-af22-35d2448d0b13 * fix(features): address resolver review feedback Keep legacy string APIs compatible, seed feature state from each inventory's checker, persist caching for stdio calls, and fail closed for empty undeclared flags. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e4a1ca6-53f7-4158-af22-35d2448d0b13 * fix(features): harden request resolution Make feature checks reentrant and single-flight, validate rule declarations across short-circuit paths, cache inventory feature metadata, and codify the HTTP context boundary used by the remote server. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e4a1ca6-53f7-4158-af22-35d2448d0b13 * test(features): preserve query flag coverage after rebase Resolve the feature-query test against the final string-compatible flag API. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e4a1ca6-53f7-4158-af22-35d2448d0b13 * fix(features): close cycle and ownership gaps Preserve the public string dependency API, detect concurrent resolution cycles without blocking, and isolate cached feature metadata from caller mutation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e4a1ca6-53f7-4158-af22-35d2448d0b13 * fix(features): share registration resolution Seed feature state once for direct registration so tools, resources, and prompts use one consistent snapshot. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e4a1ca6-53f7-4158-af22-35d2448d0b13 * fix(features): isolate metadata and validate variants Deep-clone JSON metadata at inventory boundaries and exhaustively reject duplicate tool variants that can be enabled together. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e4a1ca6-53f7-4158-af22-35d2448d0b13 * fix(inventory): isolate tool lookup results Return owned tool metadata from unfiltered lookup APIs so callers cannot mutate cached inventory state. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e4a1ca6-53f7-4158-af22-35d2448d0b13 * fix(inventory): normalize owned metadata Canonicalize JSON metadata at builder boundaries, report invalid values from Build, and preserve wire semantics for typed mutable values. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e4a1ca6-53f7-4158-af22-35d2448d0b13 * fix(inventory): preserve metadata number precision Decode normalized metadata with UseNumber so large integers and other JSON number forms retain their exact wire representation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e4a1ca6-53f7-4158-af22-35d2448d0b13 * refactor(features): simplify lazy resolution Preserve string checker APIs, evaluate functional rules lazily through one request memo, remove metadata normalization and eager feature caches, and add remote-sized benchmarks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e4a1ca6-53f7-4158-af22-35d2448d0b13 * fix(features): fail nested checker resolution closed Disallow recursive ResolveFeature calls from feature checkers so direct, negating, multi-node, and concurrent cycles cannot cache enabled results or wait on one another. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e4a1ca6-53f7-4158-af22-35d2448d0b13 * docs(features): describe lazy rule resolution Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e4a1ca6-53f7-4158-af22-35d2448d0b13 * perf(features): filter static exclusions first Skip feature checks for read-only, filtered, unselected, protocol-ineligible, and capability-ineligible tools, and reject declared rules without predicates. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e4a1ca6-53f7-4158-af22-35d2448d0b13 * fix(features): defer unknown client availability Apply pre-feature protocol and elicitation filtering only when stateless request metadata is known, leaving existing middleware to enforce unknown contexts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e4a1ca6-53f7-4158-af22-35d2448d0b13 * fix(features): preserve unavailable call errors Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e4a1ca6-53f7-4158-af22-35d2448d0b13 --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e4a1ca6-53f7-4158-af22-35d2448d0b13 --- cmd/github-mcp-server/generate_docs.go | 5 +- docs/feature-flags.md | 49 +++- docs/insiders-features.md | 10 +- internal/ghmcp/server.go | 2 +- pkg/context/mcp_info.go | 6 + pkg/github/actions_test.go | 2 +- pkg/github/csv_output_test.go | 10 +- pkg/github/dependencies.go | 43 +-- pkg/github/feature_flags.go | 39 ++- pkg/github/feature_flags_benchmark_test.go | 188 ++++++++++++ pkg/github/feature_flags_test.go | 22 +- pkg/github/find_duplicate.go | 2 +- pkg/github/find_duplicate_test.go | 5 +- pkg/github/granular_tools_test.go | 13 +- pkg/github/issue_dependencies.go | 4 +- pkg/github/issue_dependencies_test.go | 9 +- pkg/github/issues.go | 4 +- pkg/github/issues_granular.go | 24 +- pkg/github/issues_test.go | 2 +- pkg/github/pullrequests.go | 26 +- pkg/github/pullrequests_granular.go | 42 ++- pkg/github/repositories.go | 2 +- pkg/github/repositories_test.go | 2 +- pkg/github/server.go | 11 +- pkg/github/server_test.go | 31 +- pkg/github/tools_validation_test.go | 76 ++++- pkg/github/ui_capability_test.go | 9 +- pkg/github/ui_tools.go | 2 +- pkg/github/ui_tools_test.go | 3 +- pkg/http/handler.go | 9 + pkg/http/handler_test.go | 85 +++++- pkg/http/middleware/mcp_parse.go | 13 +- pkg/http/middleware/mcp_parse_test.go | 42 ++- pkg/inventory/builder.go | 29 +- pkg/inventory/features.go | 269 +++++++++++++++++ pkg/inventory/features_test.go | 312 ++++++++++++++++++++ pkg/inventory/filters.go | 134 +++------ pkg/inventory/prompts.go | 8 +- pkg/inventory/registry.go | 84 +++++- pkg/inventory/registry_test.go | 324 +++++++++++++++++++-- pkg/inventory/resources.go | 8 +- pkg/inventory/server_tool.go | 14 +- pkg/inventory/tool_availability.go | 43 +++ pkg/inventory/tool_availability_test.go | 142 +++++++++ script/print-mcp-diff-configs/main.go | 4 +- 45 files changed, 1836 insertions(+), 327 deletions(-) create mode 100644 pkg/github/feature_flags_benchmark_test.go create mode 100644 pkg/inventory/features.go create mode 100644 pkg/inventory/features_test.go diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index 791a40b886..a9a6c20f83 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -61,9 +61,8 @@ func generateReadmeDocs(readmePath string) error { // 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. + // every flag as disabled keeps the default variants selected by functional + // feature rules, 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"}). diff --git a/docs/feature-flags.md b/docs/feature-flags.md index fe72955b08..33f1f9ea82 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -35,15 +35,54 @@ Only flags listed in [`AllowedFeatureFlags`](../pkg/github/feature_flags.go) can be enabled by end users. Insiders-only flags are not user-toggleable. +## Declaring tool availability + +Tools, resources, and prompts use `inventory.NewFeatureRule` when feature flags +change whether they are available. Each rule declares the flags it references +and evaluates them with a fail-closed `FeatureResolver`, so normal Go boolean +expressions can represent AND, OR, NOT, and mixed conditions: + +```go +tool.FeatureRule = inventory.NewFeatureRule( + []inventory.FeatureFlag{x, y}, + func(featureAsBool inventory.FeatureResolver) bool { + return !(featureAsBool(x) && featureAsBool(y)) + }, +) +``` + +Library consumers migrating existing inventory declarations should replace +`FeatureFlagEnable`, `FeatureFlagEnableAll`, and `FeatureFlagDisable` on +`ServerTool`, `ServerResourceTemplate`, and `ServerPrompt` with `FeatureRule`. +`FeatureFlagChecker` and `ToolDependencies.IsFeatureEnabled` continue to accept +string flag names. + +Rules are evaluated lazily after request narrowing and static availability +filters. Normal Go short-circuiting avoids checks that cannot affect the result, +while one request-owned memo ensures each flag actually reached is resolved at +most once across tools, resources, prompts, and `deps.IsFeatureEnabled`. +Unavailable named tool calls skip rule evaluation but remain registered so the +handler can return the specific client-availability error. + +Feature predicates are pure and may depend only on their resolver. Construction +validates every combination of up to 16 declared flags, so an undeclared lookup +fails immediately even when ordinary evaluation would short-circuit that +branch. + +The inventory's string-based checker owns request feature state. Once installed, +that state is authoritative; a checker stored on tool dependencies is used only +as a fallback when handlers are invoked directly without request state. +Feature checkers must not call `ResolveFeature`; nested resolution fails the +owning check closed. + --- ## 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. +The list below is regenerated by comparing the default tool surface with each +user-controllable flag enabled individually. Complex multi-flag rules may +require separate documentation. Flags that only affect runtime behavior (such +as output formatting) won't appear here. diff --git a/docs/insiders-features.md b/docs/insiders-features.md index 6191857ac8..4255e5bdd9 100644 --- a/docs/insiders-features.md +++ b/docs/insiders-features.md @@ -207,6 +207,11 @@ Insiders is a **meta feature flag** — the same shape as `default` or `all` for 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. +For tool availability, functional rules declare the flags they may read and are +evaluated lazily after request narrowing. Short-circuiting skips unnecessary +checks, and request-owned state memoizes each flag that is reached. The same +state backs `deps.IsFeatureEnabled`. + `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. @@ -219,5 +224,6 @@ Insiders is a **meta feature flag** — the same shape as `default` or `all` for 2. Add it to `AllowedFeatureFlags` if end users should be able to opt in via `--features`, `X-MCP-Features`, or the `features` URL query parameter. 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`. +4. For tool availability, attach an `inventory.NewFeatureRule` that declares every flag used by its predicate. For behavior inside a handler, use `deps.IsFeatureEnabled(ctx, FeatureFlagX)`. +5. Gate on concrete flags, never on `cfg.InsidersMode`. There is a `TestGitHubPackageDoesNotReadInsidersMode` guard test that fails if `pkg/github` reads `InsidersMode` directly. +6. 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 dadc05744b..f713a44026 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -256,7 +256,7 @@ type StdioServerConfig struct { EnabledTools []string // EnabledFeatures is a list of feature flags that are enabled - // Items with FeatureFlagEnable matching an entry in this list will be available + // Tool feature rules evaluate entries in this list. EnabledFeatures []string // ReadOnly indicates if we should only register read-only tools diff --git a/pkg/context/mcp_info.go b/pkg/context/mcp_info.go index af474b13a0..fc09e6b139 100644 --- a/pkg/context/mcp_info.go +++ b/pkg/context/mcp_info.go @@ -3,6 +3,8 @@ package context import ( "context" "encoding/json" + + "github.com/modelcontextprotocol/go-sdk/mcp" ) type mcpMethodInfoCtx string @@ -22,6 +24,10 @@ type MCPMethodInfo struct { ItemName string // RawArguments contains the unmaterialized tool arguments for tools/call requests. RawArguments json.RawMessage + // ProtocolVersion and ClientCapabilities describe the requesting MCP client + // when stateless HTTP parsing makes them available before registration. + ProtocolVersion string + ClientCapabilities *mcp.ClientCapabilities } // DecodeArguments materializes tool arguments when request middleware needs diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 964bc95a6b..f390c31771 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -574,7 +574,7 @@ func Test_ActionsGetJobLogs(t *testing.T) { // Note: consolidated ActionsGetJobLogs has same tool name "get_job_logs" as the individual tool // but with different descriptions. We skip toolsnap validation here since the individual // tool's toolsnap already exists and is tested in Test_GetJobLogs. - // The consolidated tool has FeatureFlagEnable set, so only one will be active at a time. + // The functional feature rules ensure only one variant is active at a time. assert.Equal(t, "get_job_logs", toolDef.Tool.Name) assert.NotEmpty(t, toolDef.Tool.Description) inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) diff --git a/pkg/github/csv_output_test.go b/pkg/github/csv_output_test.go index 5cc6fe7e54..44dbff6280 100644 --- a/pkg/github/csv_output_test.go +++ b/pkg/github/csv_output_test.go @@ -29,20 +29,18 @@ func TestCSVOutputAppliedToDefaultListTools(t *testing.T) { require.Len(t, available, 2) listing := requireToolByName(t, available, "list_things") - assert.Empty(t, listing.FeatureFlagEnable) - assert.Empty(t, listing.FeatureFlagDisable) + assert.True(t, listing.FeatureRule.IsZero()) getting := requireToolByName(t, available, "get_thing") - assert.Empty(t, getting.FeatureFlagEnable) - assert.Empty(t, getting.FeatureFlagDisable) + assert.True(t, getting.FeatureRule.IsZero()) } } func TestCSVOutputAppliesToFlagGatedListTools(t *testing.T) { enabledOnly := testCSVOutputTool("list_things", `[{"number":1}]`) - enabledOnly.FeatureFlagEnable = FeatureFlagFileBlame + enabledOnly.FeatureRule = featureEnabledRule(FeatureFlagFileBlame) disabledOnly := testCSVOutputTool("list_legacy_things", `[{"number":2}]`) - disabledOnly.FeatureFlagDisable = []string{FeatureFlagFileBlame} + disabledOnly.FeatureRule = featureDisabledRule(FeatureFlagFileBlame) tools := withCSVOutput([]inventory.ServerTool{enabledOnly, disabledOnly}) require.Len(t, tools, 2) diff --git a/pkg/github/dependencies.go b/pkg/github/dependencies.go index 0de3e3b279..333b1d9c07 100644 --- a/pkg/github/dependencies.go +++ b/pkg/github/dependencies.go @@ -6,7 +6,6 @@ import ( "fmt" "log/slog" "net/http" - "os" ghcontext "github.com/github/github-mcp-server/pkg/context" "github.com/github/github-mcp-server/pkg/http/transport" @@ -95,7 +94,7 @@ type ToolDependencies interface { GetContentWindowSize() int // IsFeatureEnabled checks if a feature flag is enabled. - IsFeatureEnabled(ctx context.Context, flagName string) bool + IsFeatureEnabled(ctx context.Context, flag string) bool // Logger returns the structured logger, optionally enriched with // request-scoped data from ctx. Integrators provide their own slog.Handler @@ -204,22 +203,11 @@ func (d BaseDeps) Metrics(ctx context.Context) metrics.Metrics { // GetRequestStateSealer implements RequestStateSealerProvider. func (d BaseDeps) GetRequestStateSealer() RequestStateSealer { return d.StateSealer } -// IsFeatureEnabled checks if a feature flag is enabled. -// Returns false if the feature checker is nil, flag name is empty, or an error occurs. -// This allows tools to conditionally change behavior based on feature flags. -func (d BaseDeps) IsFeatureEnabled(ctx context.Context, flagName string) bool { - if d.featureChecker == nil || flagName == "" { - return false - } - - enabled, err := d.featureChecker(ctx, flagName) - if err != nil { - // Log error but don't fail the tool - treat as disabled - fmt.Fprintf(os.Stderr, "Feature flag check error for %q: %v\n", flagName, err) - return false - } - - return enabled +// IsFeatureEnabled checks if a feature flag is enabled. Request feature state +// is authoritative when present; the dependency checker is a fallback for +// direct handler invocation. Empty names and checker errors resolve false. +func (d BaseDeps) IsFeatureEnabled(ctx context.Context, flag string) bool { + return inventory.ResolveFeature(ctx, d.featureChecker, inventory.FeatureFlag(flag)) } // NewTool creates a ServerTool that retrieves ToolDependencies from context at call time. @@ -495,18 +483,9 @@ func (d *RequestDeps) Metrics(ctx context.Context) metrics.Metrics { return d.obsv.Metrics(ctx) } -// IsFeatureEnabled checks if a feature flag is enabled. -func (d *RequestDeps) IsFeatureEnabled(ctx context.Context, flagName string) bool { - if d.featureChecker == nil || flagName == "" { - return false - } - - enabled, err := d.featureChecker(ctx, flagName) - if err != nil { - // Log error but don't fail the tool - treat as disabled - fmt.Fprintf(os.Stderr, "Feature flag check error for %q: %v\n", flagName, err) - return false - } - - return enabled +// IsFeatureEnabled checks if a feature flag is enabled. Request feature state +// is authoritative when present; the dependency checker is a fallback for +// direct handler invocation. +func (d *RequestDeps) IsFeatureEnabled(ctx context.Context, flag string) bool { + return inventory.ResolveFeature(ctx, d.featureChecker, inventory.FeatureFlag(flag)) } diff --git a/pkg/github/feature_flags.go b/pkg/github/feature_flags.go index a388f30d6b..94f335ebe5 100644 --- a/pkg/github/feature_flags.go +++ b/pkg/github/feature_flags.go @@ -1,6 +1,10 @@ package github -import "slices" +import ( + "slices" + + "github.com/github/github-mcp-server/pkg/inventory" +) // MCPAppsFeatureFlag is the feature flag name for MCP Apps (interactive UI forms). const MCPAppsFeatureFlag = "remote_mcp_ui_apps" @@ -71,6 +75,33 @@ type FeatureFlags struct { LockdownMode bool } +func featureEnabledRule(feature string) inventory.FeatureRule { + flag := inventory.FeatureFlag(feature) + return inventory.NewFeatureRule( + []inventory.FeatureFlag{flag}, + func(featureAsBool inventory.FeatureResolver) bool { + return featureAsBool(flag) + }, + ) +} + +func featureDisabledRule(feature string) inventory.FeatureRule { + flag := inventory.FeatureFlag(feature) + return inventory.NewFeatureRule( + []inventory.FeatureFlag{flag}, + func(featureAsBool inventory.FeatureResolver) bool { + return !featureAsBool(flag) + }, + ) +} + +var ( + issuesGranularFeatureRule = featureEnabledRule(FeatureFlagIssuesGranular) + issuesConsolidatedFeatureRule = featureDisabledRule(FeatureFlagIssuesGranular) + pullRequestsGranularFeatureRule = featureEnabledRule(FeatureFlagPullRequestsGranular) + pullRequestsConsolidatedRule = featureDisabledRule(FeatureFlagPullRequestsGranular) +) + // ResolveFeatureFlags computes the effective set of enabled feature flags by: // 1. Taking the user-supplied flags (from --features or HTTP request // configuration) and @@ -89,9 +120,9 @@ type FeatureFlags struct { // Returns a set (map) for O(1) lookup by the feature checker. func ResolveFeatureFlags(enabledFeatures []string, insidersMode bool) map[string]bool { effective := make(map[string]bool) - for _, f := range enabledFeatures { - if slices.Contains(AllowedFeatureFlags, f) { - effective[f] = true + for _, feature := range enabledFeatures { + if slices.Contains(AllowedFeatureFlags, feature) { + effective[feature] = true } } if insidersMode { diff --git a/pkg/github/feature_flags_benchmark_test.go b/pkg/github/feature_flags_benchmark_test.go new file mode 100644 index 0000000000..79aa17d55d --- /dev/null +++ b/pkg/github/feature_flags_benchmark_test.go @@ -0,0 +1,188 @@ +package github + +import ( + "context" + "fmt" + "sync/atomic" + "testing" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func BenchmarkFeatureInventory(b *testing.B) { + for _, distribution := range featureBenchmarkDistributions() { + b.Run(distribution.name, func(b *testing.B) { + b.Run("build", func(b *testing.B) { + var calls atomic.Int64 + b.ReportAllocs() + for b.Loop() { + _, err := featureBenchmarkBuilder(distribution, &calls).Build() + if err != nil { + b.Fatal(err) + } + } + b.ReportMetric(float64(calls.Load())/float64(b.N), "checks/op") + }) + + builder := featureBenchmarkBuilder(distribution, nil) + b.Run("preconstructed-builder", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + if _, err := builder.Build(); err != nil { + b.Fatal(err) + } + } + }) + + b.Run("tools-list", func(b *testing.B) { + inv, calls := featureBenchmarkInventory(b, distribution) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _ = inv.ForMCPRequest(inventory.MCPMethodToolsList, "").ToolsForRegistration(context.Background()) + } + b.ReportMetric(float64(calls.Load())/float64(b.N), "checks/op") + }) + + b.Run("read-only-tools-list", func(b *testing.B) { + var calls atomic.Int64 + checker := func(_ context.Context, flag string) (bool, error) { + calls.Add(1) + return distribution.enabled["*"] || distribution.enabled[flag], nil + } + tools := AllTools(translations.NullTranslationHelper) + inv, err := inventory.NewBuilder(). + SetTools(tools). + SetResources(AllResources(translations.NullTranslationHelper)). + SetPrompts(AllPrompts(translations.NullTranslationHelper)). + WithToolsets([]string{"all"}). + WithReadOnly(true). + WithFeatureChecker(checker). + Build() + if err != nil { + b.Fatal(err) + } + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _ = inv.ForMCPRequest(inventory.MCPMethodToolsList, "").ToolsForRegistration(context.Background()) + } + b.ReportMetric(float64(calls.Load())/float64(b.N), "checks/op") + }) + + b.Run("unflagged-tool-call", func(b *testing.B) { + inv, calls := featureBenchmarkInventory(b, distribution) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _ = inv.ForMCPRequest(inventory.MCPMethodToolsCall, "get_commit").ToolsForRegistration(context.Background()) + } + b.ReportMetric(float64(calls.Load())/float64(b.N), "checks/op") + }) + + b.Run("gated-tool-call", func(b *testing.B) { + inv, calls := featureBenchmarkInventory(b, distribution) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _ = inv.ForMCPRequest(inventory.MCPMethodToolsCall, "get_file_blame").ToolsForRegistration(context.Background()) + } + b.ReportMetric(float64(calls.Load())/float64(b.N), "checks/op") + }) + + b.Run("ui-tool-call", func(b *testing.B) { + inv, calls := featureBenchmarkInventory(b, distribution) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + _ = inv.ForMCPRequest(inventory.MCPMethodToolsCall, "ui_get").ToolsForRegistration(context.Background()) + } + b.ReportMetric(float64(calls.Load())/float64(b.N), "checks/op") + }) + + b.Run("direct-handler-checks", func(b *testing.B) { + var calls atomic.Int64 + checker := func(_ context.Context, flag string) (bool, error) { + calls.Add(1) + return distribution.enabled["*"] || distribution.enabled[flag], nil + } + b.ReportAllocs() + for b.Loop() { + ctx := inventory.WithFeatureState(context.Background(), checker) + _ = inventory.ResolveFeature(ctx, checker, inventory.FeatureFlag(FeatureFlagCSVOutput)) + _ = inventory.ResolveFeature(ctx, checker, inventory.FeatureFlag(FeatureFlagCSVOutput)) + } + b.ReportMetric(float64(calls.Load())/float64(b.N), "checks/op") + }) + + b.Run("build-list-register", func(b *testing.B) { + var calls atomic.Int64 + b.ReportAllocs() + for b.Loop() { + inv, err := featureBenchmarkBuilder(distribution, &calls).Build() + if err != nil { + b.Fatal(err) + } + inv = inv.ForMCPRequest(inventory.MCPMethodToolsList, "") + server := mcp.NewServer(&mcp.Implementation{Name: "benchmark", Version: "v0"}, nil) + inv.RegisterAll(context.Background(), server, nil) + } + b.ReportMetric(float64(calls.Load())/float64(b.N), "checks/op") + }) + }) + } +} + +type featureBenchmarkDistribution struct { + name string + enabled map[string]bool +} + +func featureBenchmarkDistributions() []featureBenchmarkDistribution { + return []featureBenchmarkDistribution{ + {name: "all-false"}, + { + name: "mixed", + enabled: map[string]bool{ + MCPAppsFeatureFlag: true, + FeatureFlagFileBlame: true, + FeatureFlagIssuesGranular: true, + FeatureFlagIssueDependencies: true, + }, + }, + {name: "all-true", enabled: map[string]bool{"*": true}}, + } +} + +func featureBenchmarkBuilder(distribution featureBenchmarkDistribution, calls *atomic.Int64) *inventory.Builder { + checker := func(_ context.Context, flag string) (bool, error) { + if calls != nil { + calls.Add(1) + } + return distribution.enabled["*"] || distribution.enabled[flag], nil + } + tools := AllTools(translations.NullTranslationHelper) + for i, baseCount := 0, len(tools); len(tools) < 139; i++ { + tool := tools[i%baseCount] + tool.Tool.Name = fmt.Sprintf("%s_remote_%d", tool.Tool.Name, i) + tools = append(tools, tool) + } + return inventory.NewBuilder(). + SetTools(tools). + SetResources(AllResources(translations.NullTranslationHelper)). + SetPrompts(AllPrompts(translations.NullTranslationHelper)). + WithToolsets([]string{"all"}). + WithFeatureChecker(checker) +} + +func featureBenchmarkInventory(b *testing.B, distribution featureBenchmarkDistribution) (*inventory.Inventory, *atomic.Int64) { + b.Helper() + var calls atomic.Int64 + inv, err := featureBenchmarkBuilder(distribution, &calls).Build() + if err != nil { + b.Fatal(err) + } + return inv, &calls +} diff --git a/pkg/github/feature_flags_test.go b/pkg/github/feature_flags_test.go index dafacfa79a..cc3fbf0837 100644 --- a/pkg/github/feature_flags_test.go +++ b/pkg/github/feature_flags_test.go @@ -17,12 +17,12 @@ import ( ) // RemoteMCPEnthusiasticGreeting is a dummy test feature flag . -const RemoteMCPEnthusiasticGreeting = "remote_mcp_enthusiastic_greeting" +const RemoteMCPEnthusiasticGreeting inventory.FeatureFlag = "remote_mcp_enthusiastic_greeting" -func featureCheckerFor(enabledFlags ...string) func(context.Context, string) (bool, error) { +func featureCheckerFor(enabledFlags ...inventory.FeatureFlag) inventory.FeatureFlagChecker { enabled := make(map[string]bool, len(enabledFlags)) for _, flag := range enabledFlags { - enabled[flag] = true + enabled[string(flag)] = true } return func(_ context.Context, flagName string) (bool, error) { return enabled[flagName], nil @@ -47,7 +47,7 @@ func HelloWorldTool(t translations.TranslationHelperFunc) inventory.ServerTool { // Check feature flag to determine greeting style greeting := "Hello, world!" - if deps.IsFeatureEnabled(ctx, RemoteMCPEnthusiasticGreeting) { + if deps.IsFeatureEnabled(ctx, string(RemoteMCPEnthusiasticGreeting)) { greeting += " Welcome to the future of MCP! 🎉" } @@ -91,7 +91,7 @@ func TestHelloWorld_ConditionalBehavior_Featureflag(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - var enabledFlags []string + var enabledFlags []inventory.FeatureFlag if tt.featureFlagEnabled { enabledFlags = append(enabledFlags, RemoteMCPEnthusiasticGreeting) } @@ -237,7 +237,7 @@ func TestResolveFeatureFlags(t *testing.T) { func TestThreadResolutionReasonToolVariants(t *testing.T) { tests := []struct { name string - flags []string + flags []inventory.FeatureFlag host utils.HostType toolName string hasReason bool @@ -248,30 +248,30 @@ func TestThreadResolutionReasonToolVariants(t *testing.T) { }, { name: "consolidated flag on", - flags: []string{FeatureFlagThreadResolutionReason}, + flags: []inventory.FeatureFlag{FeatureFlagThreadResolutionReason}, toolName: "pull_request_review_write", hasReason: true, }, { name: "granular flag off", - flags: []string{FeatureFlagPullRequestsGranular}, + flags: []inventory.FeatureFlag{inventory.FeatureFlag(FeatureFlagPullRequestsGranular)}, toolName: "resolve_review_thread", }, { name: "granular flag on", - flags: []string{FeatureFlagPullRequestsGranular, FeatureFlagThreadResolutionReason}, + flags: []inventory.FeatureFlag{inventory.FeatureFlag(FeatureFlagPullRequestsGranular), FeatureFlagThreadResolutionReason}, toolName: "resolve_review_thread", hasReason: true, }, { name: "consolidated flag on GHES", - flags: []string{FeatureFlagThreadResolutionReason}, + flags: []inventory.FeatureFlag{FeatureFlagThreadResolutionReason}, host: utils.HostTypeGHES, toolName: "pull_request_review_write", }, { name: "granular flag on GHES", - flags: []string{FeatureFlagPullRequestsGranular, FeatureFlagThreadResolutionReason}, + flags: []inventory.FeatureFlag{inventory.FeatureFlag(FeatureFlagPullRequestsGranular), FeatureFlagThreadResolutionReason}, host: utils.HostTypeGHES, toolName: "resolve_review_thread", }, diff --git a/pkg/github/find_duplicate.go b/pkg/github/find_duplicate.go index 65cb6f2002..236f53a489 100644 --- a/pkg/github/find_duplicate.go +++ b/pkg/github/find_duplicate.go @@ -178,6 +178,6 @@ func FindDuplicate(t translations.TranslationHelperFunc) inventory.ServerTool { result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoUserContent) return result, nil, nil }) - st.FeatureFlagEnable = FeatureFlagDuplicateDetection + st.FeatureRule = featureEnabledRule(FeatureFlagDuplicateDetection) return st } diff --git a/pkg/github/find_duplicate_test.go b/pkg/github/find_duplicate_test.go index 9e20d958b4..b2c032989b 100644 --- a/pkg/github/find_duplicate_test.go +++ b/pkg/github/find_duplicate_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" @@ -20,8 +21,8 @@ func Test_FindDuplicate(t *testing.T) { // Verify tool definition once (flag-gated variant snap). serverTool := FindDuplicate(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagDuplicateDetection, tool)) - require.Equal(t, FeatureFlagDuplicateDetection, serverTool.FeatureFlagEnable) + require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+string(FeatureFlagDuplicateDetection), tool)) + require.Equal(t, []inventory.FeatureFlag{FeatureFlagDuplicateDetection}, serverTool.FeatureRule.Features()) assert.Equal(t, "find_duplicate", tool.Name) assert.NotEmpty(t, tool.Description) diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go index 5ef0c0a662..456129a0fe 100644 --- a/pkg/github/granular_tools_test.go +++ b/pkg/github/granular_tools_test.go @@ -21,9 +21,16 @@ import ( ) func granularToolsForToolset(toolsetID inventory.ToolsetID, featureFlag string) []inventory.ServerTool { + flag := inventory.FeatureFlag(featureFlag) var result []inventory.ServerTool for _, tool := range AllTools(translations.NullTranslationHelper) { - if tool.Toolset.ID == toolsetID && tool.FeatureFlagEnable == featureFlag && len(tool.FeatureFlagEnableAll) == 0 { + features := tool.FeatureRule.Features() + usesFeature := false + for _, feature := range features { + usesFeature = usesFeature || feature == flag + } + if tool.Toolset.ID == toolsetID && usesFeature && + tool.FeatureRule.Enabled(func(feature inventory.FeatureFlag) bool { return feature == flag }) { result = append(result, tool) } } @@ -102,7 +109,7 @@ func TestIssuesGranularToolset(t *testing.T) { t.Run("all granular tools have correct feature flag", func(t *testing.T) { for _, tool := range granularToolsForToolset(ToolsetMetadataIssues.ID, FeatureFlagIssuesGranular) { - assert.Equal(t, FeatureFlagIssuesGranular, tool.FeatureFlagEnable, "tool %s", tool.Tool.Name) + assert.Equal(t, []inventory.FeatureFlag{inventory.FeatureFlag(FeatureFlagIssuesGranular)}, tool.FeatureRule.Features(), "tool %s", tool.Tool.Name) } }) } @@ -138,7 +145,7 @@ func TestPullRequestsGranularToolset(t *testing.T) { t.Run("all granular tools have correct feature flag", func(t *testing.T) { for _, tool := range granularToolsForToolset(ToolsetMetadataPullRequests.ID, FeatureFlagPullRequestsGranular) { - assert.Equal(t, FeatureFlagPullRequestsGranular, tool.FeatureFlagEnable, "tool %s", tool.Tool.Name) + assert.Contains(t, tool.FeatureRule.Features(), inventory.FeatureFlag(FeatureFlagPullRequestsGranular), "tool %s", tool.Tool.Name) } }) } diff --git a/pkg/github/issue_dependencies.go b/pkg/github/issue_dependencies.go index 246192736e..dc78c9eccc 100644 --- a/pkg/github/issue_dependencies.go +++ b/pkg/github/issue_dependencies.go @@ -103,7 +103,7 @@ Options are: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }) - st.FeatureFlagEnable = FeatureFlagIssueDependencies + st.FeatureRule = featureEnabledRule(FeatureFlagIssueDependencies) return st } @@ -321,7 +321,7 @@ Options are: result, err := writeIssueDependency(ctx, client, method, blocked, blocking) return result, nil, err }) - st.FeatureFlagEnable = FeatureFlagIssueDependencies + st.FeatureRule = featureEnabledRule(FeatureFlagIssueDependencies) return st } diff --git a/pkg/github/issue_dependencies_test.go b/pkg/github/issue_dependencies_test.go index 6af9c504ed..35baa815ee 100644 --- a/pkg/github/issue_dependencies_test.go +++ b/pkg/github/issue_dependencies_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" @@ -34,8 +35,8 @@ func Test_IssueDependencyRead(t *testing.T) { // Verify tool definition once (flag-gated variant snap) serverTool := IssueDependencyRead(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagIssueDependencies, tool)) - require.Equal(t, FeatureFlagIssueDependencies, serverTool.FeatureFlagEnable) + require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+string(FeatureFlagIssueDependencies), tool)) + require.Equal(t, []inventory.FeatureFlag{FeatureFlagIssueDependencies}, serverTool.FeatureRule.Features()) assert.Equal(t, "issue_dependency_read", tool.Name) assert.NotEmpty(t, tool.Description) @@ -189,8 +190,8 @@ func Test_IssueDependencyWrite(t *testing.T) { // Verify tool definition once (flag-gated variant snap) serverTool := IssueDependencyWrite(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+FeatureFlagIssueDependencies, tool)) - require.Equal(t, FeatureFlagIssueDependencies, serverTool.FeatureFlagEnable) + require.NoError(t, toolsnaps.Test(tool.Name+"_ff_"+string(FeatureFlagIssueDependencies), tool)) + require.Equal(t, []inventory.FeatureFlag{FeatureFlagIssueDependencies}, serverTool.FeatureRule.Features()) assert.Equal(t, "issue_dependency_write", tool.Name) assert.NotEmpty(t, tool.Description) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index fd7ea36873..9450476b19 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1679,7 +1679,7 @@ func SubIssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }) - st.FeatureFlagDisable = []string{FeatureFlagIssuesGranular} + st.FeatureRule = issuesConsolidatedFeatureRule return st } @@ -2700,7 +2700,7 @@ Options are: return utils.NewToolResultError("invalid method, must be either 'create' or 'update'"), nil, nil } }) - st.FeatureFlagDisable = []string{FeatureFlagIssuesGranular} + st.FeatureRule = issuesConsolidatedFeatureRule return st } diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go index 0b1cdd0e7c..c6544495c6 100644 --- a/pkg/github/issues_granular.go +++ b/pkg/github/issues_granular.go @@ -108,7 +108,7 @@ func issueUpdateTool( return utils.NewToolResultText(string(r)), nil, nil }, ) - st.FeatureFlagEnable = FeatureFlagIssuesGranular + st.FeatureRule = issuesGranularFeatureRule return st } @@ -234,7 +234,7 @@ func GranularCreateIssue(t translations.TranslationHelperFunc) inventory.ServerT return utils.NewToolResultText(string(r)), nil, nil }, ) - st.FeatureFlagEnable = FeatureFlagIssuesGranular + st.FeatureRule = issuesGranularFeatureRule return st } @@ -460,7 +460,7 @@ func GranularUpdateIssueAssignees(t translations.TranslationHelperFunc) inventor return utils.NewToolResultText(string(r)), nil, nil }, ) - st.FeatureFlagEnable = FeatureFlagIssuesGranular + st.FeatureRule = issuesGranularFeatureRule return st } @@ -678,7 +678,7 @@ func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.S return utils.NewToolResultText(string(r)), nil, nil }, ) - st.FeatureFlagEnable = FeatureFlagIssuesGranular + st.FeatureRule = issuesGranularFeatureRule return st } @@ -866,7 +866,7 @@ func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.Ser return utils.NewToolResultText(string(r)), nil, nil }, ) - st.FeatureFlagEnable = FeatureFlagIssuesGranular + st.FeatureRule = issuesGranularFeatureRule return st } @@ -1064,7 +1064,7 @@ func GranularUpdateIssueState(t translations.TranslationHelperFunc) inventory.Se return utils.NewToolResultText(string(r)), nil, nil }, ) - st.FeatureFlagEnable = FeatureFlagIssuesGranular + st.FeatureRule = issuesGranularFeatureRule return st } @@ -1138,7 +1138,7 @@ func GranularAddSubIssue(t translations.TranslationHelperFunc) inventory.ServerT return result, nil, err }, ) - st.FeatureFlagEnable = FeatureFlagIssuesGranular + st.FeatureRule = issuesGranularFeatureRule return st } @@ -1207,7 +1207,7 @@ func GranularRemoveSubIssue(t translations.TranslationHelperFunc) inventory.Serv return result, nil, err }, ) - st.FeatureFlagEnable = FeatureFlagIssuesGranular + st.FeatureRule = issuesGranularFeatureRule return st } @@ -1292,7 +1292,7 @@ func GranularReprioritizeSubIssue(t translations.TranslationHelperFunc) inventor return result, nil, err }, ) - st.FeatureFlagEnable = FeatureFlagIssuesGranular + st.FeatureRule = issuesGranularFeatureRule return st } @@ -1580,7 +1580,7 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv return utils.NewToolResultText(string(r)), nil, nil }, ) - st.FeatureFlagEnable = FeatureFlagIssuesGranular + st.FeatureRule = issuesGranularFeatureRule return st } @@ -1662,7 +1662,7 @@ func GranularAddIssueReaction(t translations.TranslationHelperFunc) inventory.Se return utils.NewToolResultText(string(r)), nil, nil }, ) - st.FeatureFlagEnable = FeatureFlagIssuesGranular + st.FeatureRule = issuesGranularFeatureRule return st } @@ -1744,6 +1744,6 @@ func GranularAddIssueCommentReaction(t translations.TranslationHelperFunc) inven return utils.NewToolResultText(string(r)), nil, nil }, ) - st.FeatureFlagEnable = FeatureFlagIssuesGranular + st.FeatureRule = issuesGranularFeatureRule return st } diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index e8b4cd2c13..a894641ddf 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -1833,7 +1833,7 @@ func Test_CreateIssue(t *testing.T) { serverTool := IssueWrite(translations.NullTranslationHelper) tool := serverTool.Tool require.NoError(t, toolsnaps.Test(tool.Name, tool)) - require.Empty(t, serverTool.FeatureFlagEnable) + require.Equal(t, []inventory.FeatureFlag{inventory.FeatureFlag(FeatureFlagIssuesGranular)}, serverTool.FeatureRule.Features()) assert.Equal(t, "issue_write", tool.Name) assert.NotEmpty(t, tool.Description) diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 925e7d55a0..5cee8b3231 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -1169,7 +1169,7 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultText(string(r)), nil, nil }) - st.FeatureFlagDisable = []string{FeatureFlagPullRequestsGranular} + st.FeatureRule = pullRequestsConsolidatedRule return st } @@ -1907,12 +1907,24 @@ Available methods: } }) if withResolutionReason { - st.FeatureFlagEnable = FeatureFlagThreadResolutionReason - st.FeatureFlagDisable = []string{FeatureFlagPullRequestsGranular} + st.FeatureRule = inventory.NewFeatureRule( + []inventory.FeatureFlag{FeatureFlagThreadResolutionReason, inventory.FeatureFlag(FeatureFlagPullRequestsGranular)}, + func(featureAsBool inventory.FeatureResolver) bool { + return featureAsBool(FeatureFlagThreadResolutionReason) && + !featureAsBool(inventory.FeatureFlag(FeatureFlagPullRequestsGranular)) + }, + ) } else { - st.FeatureFlagDisable = []string{FeatureFlagPullRequestsGranular} - if cfg.hostType != utils.HostTypeGHES { - st.FeatureFlagDisable = append(st.FeatureFlagDisable, FeatureFlagThreadResolutionReason) + if cfg.hostType == utils.HostTypeGHES { + st.FeatureRule = pullRequestsConsolidatedRule + } else { + st.FeatureRule = inventory.NewFeatureRule( + []inventory.FeatureFlag{FeatureFlagThreadResolutionReason, inventory.FeatureFlag(FeatureFlagPullRequestsGranular)}, + func(featureAsBool inventory.FeatureResolver) bool { + return !featureAsBool(FeatureFlagThreadResolutionReason) && + !featureAsBool(inventory.FeatureFlag(FeatureFlagPullRequestsGranular)) + }, + ) } } return st @@ -2459,7 +2471,7 @@ func AddCommentToPendingReview(t translations.TranslationHelperFunc) inventory.S }) return result, nil, err }) - st.FeatureFlagDisable = []string{FeatureFlagPullRequestsGranular} + st.FeatureRule = pullRequestsConsolidatedRule return st } diff --git a/pkg/github/pullrequests_granular.go b/pkg/github/pullrequests_granular.go index 3a4b9fc810..a32723b3c7 100644 --- a/pkg/github/pullrequests_granular.go +++ b/pkg/github/pullrequests_granular.go @@ -103,7 +103,7 @@ func prUpdateTool( return utils.NewToolResultText(string(r)), nil, nil }, ) - st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + st.FeatureRule = pullRequestsGranularFeatureRule return st } @@ -272,7 +272,7 @@ func GranularUpdatePullRequestDraftState(t translations.TranslationHelperFunc) i return utils.NewToolResultText("pull request marked as ready for review"), nil, nil }, ) - st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + st.FeatureRule = pullRequestsGranularFeatureRule return st } @@ -351,7 +351,7 @@ func GranularRequestPullRequestReviewers(t translations.TranslationHelperFunc) i return utils.NewToolResultText(string(r)), nil, nil }, ) - st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + st.FeatureRule = pullRequestsGranularFeatureRule return st } @@ -436,7 +436,7 @@ func GranularCreatePullRequestReview(t translations.TranslationHelperFunc) inven return result, nil, err }, ) - st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + st.FeatureRule = pullRequestsGranularFeatureRule return st } @@ -500,7 +500,7 @@ func GranularSubmitPendingPullRequestReview(t translations.TranslationHelperFunc return result, nil, err }, ) - st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + st.FeatureRule = pullRequestsGranularFeatureRule return st } @@ -555,7 +555,7 @@ func GranularDeletePendingPullRequestReview(t translations.TranslationHelperFunc return result, nil, err }, ) - st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + st.FeatureRule = pullRequestsGranularFeatureRule return st } @@ -666,7 +666,7 @@ func GranularAddPullRequestReviewComment(t translations.TranslationHelperFunc) i return result, nil, err }, ) - st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + st.FeatureRule = pullRequestsGranularFeatureRule return st } @@ -748,11 +748,25 @@ func granularResolveReviewThread(t translations.TranslationHelperFunc, withResol return result, nil, err }, ) - st.FeatureFlagEnable = FeatureFlagPullRequestsGranular - if withResolutionReason { - st.FeatureFlagEnableAll = []string{FeatureFlagThreadResolutionReason} - } else if cfg.hostType != utils.HostTypeGHES { - st.FeatureFlagDisable = []string{FeatureFlagThreadResolutionReason} + switch { + case withResolutionReason: + st.FeatureRule = inventory.NewFeatureRule( + []inventory.FeatureFlag{inventory.FeatureFlag(FeatureFlagPullRequestsGranular), FeatureFlagThreadResolutionReason}, + func(featureAsBool inventory.FeatureResolver) bool { + return featureAsBool(inventory.FeatureFlag(FeatureFlagPullRequestsGranular)) && + featureAsBool(FeatureFlagThreadResolutionReason) + }, + ) + case cfg.hostType == utils.HostTypeGHES: + st.FeatureRule = pullRequestsGranularFeatureRule + default: + st.FeatureRule = inventory.NewFeatureRule( + []inventory.FeatureFlag{inventory.FeatureFlag(FeatureFlagPullRequestsGranular), FeatureFlagThreadResolutionReason}, + func(featureAsBool inventory.FeatureResolver) bool { + return featureAsBool(inventory.FeatureFlag(FeatureFlagPullRequestsGranular)) && + !featureAsBool(FeatureFlagThreadResolutionReason) + }, + ) } return st } @@ -797,7 +811,7 @@ func GranularUnresolveReviewThread(t translations.TranslationHelperFunc) invento return result, nil, err }, ) - st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + st.FeatureRule = pullRequestsGranularFeatureRule return st } @@ -879,6 +893,6 @@ func GranularAddPullRequestReviewCommentReaction(t translations.TranslationHelpe return utils.NewToolResultText(string(r)), nil, nil }, ) - st.FeatureFlagEnable = FeatureFlagPullRequestsGranular + st.FeatureRule = pullRequestsGranularFeatureRule return st } diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 8dfa19b4a2..326bea51c6 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -3030,7 +3030,7 @@ func GetFileBlame(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultText(string(payload)), nil, nil }, ) - st.FeatureFlagEnable = FeatureFlagFileBlame + st.FeatureRule = featureEnabledRule(FeatureFlagFileBlame) return st } diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 71b04faa3e..d88c13e7a5 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -5971,7 +5971,7 @@ func Test_GetFileBlame(t *testing.T) { // get_file_blame is gated so it is not advertised unless the feature flag // (or insiders mode) opts it in. - assert.Equal(t, FeatureFlagFileBlame, serverTool.FeatureFlagEnable, "get_file_blame must be gated behind the file_blame feature flag") + assert.Equal(t, []inventory.FeatureFlag{FeatureFlagFileBlame}, serverTool.FeatureRule.Features()) schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") diff --git a/pkg/github/server.go b/pkg/github/server.go index b8f0197889..6e9b5b7566 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -35,7 +35,7 @@ type MCPServerConfig struct { EnabledTools []string // EnabledFeatures is a list of feature flags that are enabled - // Items with FeatureFlagEnable matching an entry in this list will be available + // Tool feature rules evaluate entries in this list. EnabledFeatures []string // ReadOnly indicates if we should only offer read-only tools @@ -113,6 +113,7 @@ func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependenci // 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, // and any middleware that needs to read or modify the context should be before it. ghServer.AddReceivingMiddleware(middleware...) + ghServer.AddReceivingMiddleware(injectFeatureStateMiddleware(inv)) ghServer.AddReceivingMiddleware(InjectDepsMiddleware(deps)) ghServer.AddReceivingMiddleware(addGitHubAPIErrorToContext) @@ -138,6 +139,14 @@ func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependenci return ghServer, nil } +func injectFeatureStateMiddleware(inv *inventory.Inventory) mcp.Middleware { + return func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + return next(inv.WithFeatureState(ctx), method, req) + } + } +} + // ResolvedEnabledToolsets determines which toolsets should be enabled based on config. // Returns nil for "use defaults", empty slice for "none", or explicit list. func ResolvedEnabledToolsets(enabledToolsets []string, enabledTools []string) []string { diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go index 07cb63c85f..bd8b9abd6b 100644 --- a/pkg/github/server_test.go +++ b/pkg/github/server_test.go @@ -62,10 +62,12 @@ func (s stubDeps) GetRawClient(ctx context.Context) (*raw.Client, error) { func (s stubDeps) GetRepoAccessCache(_ context.Context) (*lockdown.RepoAccessCache, error) { return s.repoAccessCache, nil } -func (s stubDeps) GetT() translations.TranslationHelperFunc { return s.t } -func (s stubDeps) GetFlags(_ context.Context) FeatureFlags { return s.flags } -func (s stubDeps) GetContentWindowSize() int { return s.contentWindowSize } -func (s stubDeps) IsFeatureEnabled(_ context.Context, _ string) bool { return false } +func (s stubDeps) GetT() translations.TranslationHelperFunc { return s.t } +func (s stubDeps) GetFlags(_ context.Context) FeatureFlags { return s.flags } +func (s stubDeps) GetContentWindowSize() int { return s.contentWindowSize } +func (s stubDeps) IsFeatureEnabled(_ context.Context, _ string) bool { + return false +} func (s stubDeps) Logger(_ context.Context) *slog.Logger { return s.obsv.Logger() } @@ -192,6 +194,27 @@ func TestNewMCPServer_CreatesSuccessfully(t *testing.T) { // is already tested in pkg/github/*_test.go. } +func TestFeatureStateMiddlewareCachesHandlerChecks(t *testing.T) { + var calls int + checker := func(_ context.Context, flag string) (bool, error) { + calls++ + return flag == "enabled", nil + } + inv, err := NewInventory(translations.NullTranslationHelper). + WithFeatureChecker(checker). + Build() + require.NoError(t, err) + + next := func(ctx context.Context, _ string, _ mcp.Request) (mcp.Result, error) { + assert.True(t, inventory.ResolveFeature(ctx, nil, "enabled")) + assert.True(t, inventory.ResolveFeature(ctx, nil, "enabled")) + return nil, nil + } + _, err = injectFeatureStateMiddleware(inv)(next)(context.Background(), "tools/call", nil) + require.NoError(t, err) + assert.Equal(t, 1, calls) +} + // advertisedServerCapabilities connects an in-memory client to the given server // and returns the capabilities the server advertised during initialization. func advertisedServerCapabilities(t *testing.T, server *mcp.Server) *mcp.ServerCapabilities { diff --git a/pkg/github/tools_validation_test.go b/pkg/github/tools_validation_test.go index cdc12348ae..3eacc8e1c2 100644 --- a/pkg/github/tools_validation_test.go +++ b/pkg/github/tools_validation_test.go @@ -166,34 +166,78 @@ func TestToolReadOnlyHintConsistency(t *testing.T) { } } -// TestNoDuplicateToolNames ensures all tools have unique names +// TestNoDuplicateToolNames ensures duplicate names cannot be enabled together. func TestNoDuplicateToolNames(t *testing.T) { tools := AllTools(stubTranslation) - seen := make(map[string]bool) - featureFlagged := make(map[string]bool) + toolsByName := make(map[string][]inventory.ServerTool) + for _, tool := range tools { + toolsByName[tool.Tool.Name] = append(toolsByName[tool.Tool.Name], tool) + } // get_label is intentionally in both issues and labels toolsets for conformance // with original behavior where it was registered in both - allowedDuplicates := map[string]bool{ - "get_label": true, + for name, variants := range toolsByName { + if name == "get_label" || len(variants) < 2 { + continue + } + assert.False(t, featureDeclarationsOverlap(variants), "tool variants for %q can be enabled together", name) } +} - // First pass: identify tools that have feature flags (mutually exclusive at runtime) - for _, tool := range tools { - if tool.FeatureFlagEnable != "" || len(tool.FeatureFlagDisable) > 0 { - featureFlagged[tool.Tool.Name] = true +func featureDeclarationsOverlap(variants []inventory.ServerTool) bool { + positions := make(map[inventory.FeatureFlag]uint) + for _, variant := range variants { + for _, feature := range variant.FeatureRule.Features() { + if _, ok := positions[feature]; !ok { + positions[feature] = uint(len(positions)) + } } } + if len(positions) > 16 { + return true + } - for _, tool := range tools { - name := tool.Tool.Name - // Allow duplicates for explicitly allowed tools and feature-flagged tools - if !allowedDuplicates[name] && !featureFlagged[name] { - assert.False(t, seen[name], - "Duplicate tool name found: %q", name) + for assignment := range 1 << len(positions) { + enabled := 0 + featureAsBool := func(feature inventory.FeatureFlag) bool { + return assignment&(1< 1 { + return true } - seen[name] = true } + return false +} + +func TestFeatureRulesOverlap(t *testing.T) { + flag := inventory.FeatureFlag("flag") + enabled := inventory.ServerTool{FeatureRule: inventory.NewFeatureRule([]inventory.FeatureFlag{flag}, func(featureAsBool inventory.FeatureResolver) bool { + return featureAsBool(flag) + })} + disabled := inventory.ServerTool{FeatureRule: inventory.NewFeatureRule([]inventory.FeatureFlag{flag}, func(featureAsBool inventory.FeatureResolver) bool { + return !featureAsBool(flag) + })} + otherFlag := inventory.FeatureFlag("other") + otherEnabled := inventory.ServerTool{FeatureRule: inventory.NewFeatureRule([]inventory.FeatureFlag{otherFlag}, func(featureAsBool inventory.FeatureResolver) bool { + return featureAsBool(otherFlag) + })} + ungated := inventory.ServerTool{} + + assert.True(t, featureDeclarationsOverlap([]inventory.ServerTool{enabled, enabled})) + assert.True(t, featureDeclarationsOverlap([]inventory.ServerTool{enabled, otherEnabled})) + assert.True(t, featureDeclarationsOverlap([]inventory.ServerTool{ungated, enabled})) + assert.False(t, featureDeclarationsOverlap([]inventory.ServerTool{enabled, disabled})) +} + +func TestMCPAppsFeatureFlagMatchesInventory(t *testing.T) { + inv, err := NewInventory(stubTranslation).Build() + require.NoError(t, err) + assert.Contains(t, inv.RequiredFeatures(), inventory.FeatureFlag(MCPAppsFeatureFlag)) } // TestNoDuplicateResourceNames ensures all resources have unique names diff --git a/pkg/github/ui_capability_test.go b/pkg/github/ui_capability_test.go index 1c49ee15be..812d6fde89 100644 --- a/pkg/github/ui_capability_test.go +++ b/pkg/github/ui_capability_test.go @@ -5,6 +5,7 @@ import ( "testing" ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/github/github-mcp-server/pkg/inventory" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -95,17 +96,17 @@ func Test_shouldDeferToForm_featureFlags(t *testing.T) { tests := []struct { name string - enabledFlags []string + enabledFlags []inventory.FeatureFlag want bool }{ { name: "MCP Apps enabled defers to form", - enabledFlags: []string{MCPAppsFeatureFlag}, + enabledFlags: []inventory.FeatureFlag{MCPAppsFeatureFlag}, want: true, }, { name: "form deferral disabled executes directly", - enabledFlags: []string{ + enabledFlags: []inventory.FeatureFlag{ MCPAppsFeatureFlag, MCPAppsDisableFormDeferralFeatureFlag, }, @@ -113,7 +114,7 @@ func Test_shouldDeferToForm_featureFlags(t *testing.T) { }, { name: "form deferral opt-out does not enable MCP Apps", - enabledFlags: []string{MCPAppsDisableFormDeferralFeatureFlag}, + enabledFlags: []inventory.FeatureFlag{MCPAppsDisableFormDeferralFeatureFlag}, want: false, }, { diff --git a/pkg/github/ui_tools.go b/pkg/github/ui_tools.go index 62bba06ef6..b8d6cd31a5 100644 --- a/pkg/github/ui_tools.go +++ b/pkg/github/ui_tools.go @@ -98,7 +98,7 @@ func UIGet(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }) - st.FeatureFlagEnable = MCPAppsFeatureFlag + st.FeatureRule = featureEnabledRule(MCPAppsFeatureFlag) return st } diff --git a/pkg/github/ui_tools_test.go b/pkg/github/ui_tools_test.go index 4a4981875b..d400752bdf 100644 --- a/pkg/github/ui_tools_test.go +++ b/pkg/github/ui_tools_test.go @@ -12,6 +12,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/inventory" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v89/github" "github.com/google/jsonschema-go/jsonschema" @@ -105,7 +106,7 @@ func Test_UIGet(t *testing.T) { assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"method", "owner"}) assert.True(t, tool.Annotations.ReadOnlyHint, "ui_get should be read-only") - assert.Equal(t, MCPAppsFeatureFlag, serverTool.FeatureFlagEnable, "ui_get should be gated on the MCP Apps feature flag") + assert.Equal(t, []inventory.FeatureFlag{MCPAppsFeatureFlag}, serverTool.FeatureRule.Features()) // ui_get must be app-only so the host hides it from the agent's tool list // while keeping it callable by the views (MCP Apps 2026-01-26 spec). diff --git a/pkg/http/handler.go b/pkg/http/handler.go index e4a9d198ec..1aad3ed01d 100644 --- a/pkg/http/handler.go +++ b/pkg/http/handler.go @@ -23,6 +23,11 @@ import ( const subscriptionsListenMethod = "subscriptions/listen" +// InventoryFactoryFunc builds the inventory for one HTTP request. All context +// values required by its feature checker must be installed before this runs: +// feature availability is resolved immediately afterward, before MCP receiving +// middleware can run. Handler-only lazy checks still see receiving-middleware +// context. type InventoryFactoryFunc func(r *http.Request) (*inventory.Inventory, error) // GitHubMCPServerFactoryFunc is a function type for creating a new MCP Server instance. @@ -214,6 +219,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if methodInfo, ok := ghcontext.MCPMethod(r.Context()); ok && methodInfo != nil { invToUse = inv.ForMCPRequest(methodInfo.Method, methodInfo.ItemName) } + // Tool registration must know availability before the MCP server exists. + // Remote consumers install user identity in HTTP middleware before the + // inventory factory, so their per-user checker has its full context here. + r = r.WithContext(invToUse.WithFeatureState(r.Context())) ghServer, err := h.githubMcpServerFactory(r, h.deps, invToUse, &github.MCPServerConfig{ Version: h.config.Version, diff --git a/pkg/http/handler_test.go b/pkg/http/handler_test.go index 406f845897..ea37ec2a6a 100644 --- a/pkg/http/handler_test.go +++ b/pkg/http/handler_test.go @@ -60,12 +60,19 @@ func (f allScopesFetcher) FetchTokenScopes(_ context.Context, _ string) ([]strin var _ scopes.FetcherInterface = allScopesFetcher{} -func mockToolWithFeatureFlag(name, toolsetID string, readOnly bool, enableFlag, disableFlag string) inventory.ServerTool { +func mockToolWithFeatureFlag(name, toolsetID string, readOnly bool, enableFlag, disableFlag inventory.FeatureFlag) inventory.ServerTool { tool := mockTool(name, toolsetID, readOnly) - tool.FeatureFlagEnable = enableFlag + features := make([]inventory.FeatureFlag, 0, 2) + if enableFlag != "" { + features = append(features, enableFlag) + } if disableFlag != "" { - tool.FeatureFlagDisable = []string{disableFlag} + features = append(features, disableFlag) } + tool.FeatureRule = inventory.NewFeatureRule(features, func(featureAsBool inventory.FeatureResolver) bool { + return (enableFlag == "" || featureAsBool(enableFlag)) && + (disableFlag == "" || !featureAsBool(disableFlag)) + }) return tool } @@ -763,7 +770,9 @@ func TestStaticInventoryPreservesPerRequestFeatureVariants(t *testing.T) { 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) + assert.True(t, available[0].FeatureRule.Enabled(func(flag inventory.FeatureFlag) bool { + return flag == github.FeatureFlagCSVOutput + })) } func TestStaticInventoryDisablesOnlyDeleteRepository(t *testing.T) { @@ -1037,6 +1046,74 @@ func TestCrossOriginProtection(t *testing.T) { } } +func TestFeatureResolutionUsesOuterHTTPContext(t *testing.T) { + type userContextKey struct{} + const ( + userValue = "remote-user" + featureFlag = inventory.FeatureFlag("remote-feature") + ) + + var checkerCalls int + tool := mockTool("feature_tool", "test", true) + tool.FeatureRule = inventory.NewFeatureRule( + []inventory.FeatureFlag{featureFlag}, + func(featureAsBool inventory.FeatureResolver) bool { + return featureAsBool(featureFlag) + }, + ) + inventoryFactory := func(_ *http.Request) (*inventory.Inventory, error) { + checker := func(ctx context.Context, flag string) (bool, error) { + checkerCalls++ + return flag == string(featureFlag) && ctx.Value(userContextKey{}) == userValue, nil + } + return inventory.NewBuilder(). + SetTools([]inventory.ServerTool{tool}). + WithToolsets([]string{"all"}). + WithFeatureChecker(checker). + Build() + } + + apiHost, err := utils.NewAPIHost("https://api.github.com") + require.NoError(t, err) + handler := NewHTTPMcpHandler( + context.Background(), + &ServerConfig{Version: "test"}, + nil, + translations.NullTranslationHelper, + slog.Default(), + apiHost, + WithInventoryFactory(inventoryFactory), + WithGitHubMCPServerFactory(func(r *http.Request, _ github.ToolDependencies, inv *inventory.Inventory, _ *github.MCPServerConfig) (*mcp.Server, error) { + assert.True(t, inventory.ResolveFeature(r.Context(), nil, featureFlag)) + require.Len(t, inv.AvailableTools(r.Context()), 1) + return mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil), nil + }), + WithScopeFetcher(allScopesFetcher{}), + ) + + router := chi.NewRouter() + router.Use(func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userContextKey{}, userValue))) + }) + }) + handler.RegisterMiddleware(router) + handler.RegisterRoutes(router) + + body := `{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientInfo":{"name":"test","version":"1.0.0"},"io.modelcontextprotocol/clientCapabilities":{}}}}` + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body)) + req.Header.Set(headers.ContentTypeHeader, headers.ContentTypeJSON) + req.Header.Set(headers.AcceptHeader, strings.Join([]string{headers.ContentTypeJSON, headers.ContentTypeEventStream}, ", ")) + req.Header.Set("Mcp-Protocol-Version", "2026-07-28") + req.Header.Set("Mcp-Method", "tools/list") + req.Header.Set(headers.AuthorizationHeader, "ghs_test-token") + + recorder := httptest.NewRecorder() + router.ServeHTTP(recorder, req) + require.Equal(t, http.StatusOK, recorder.Code, "response body: %s", recorder.Body.String()) + assert.Equal(t, 1, checkerCalls) +} + func TestHTTPToolMinimumProtocolVersion(t *testing.T) { apiHost, err := utils.NewAPIHost("https://api.github.com") require.NoError(t, err) diff --git a/pkg/http/middleware/mcp_parse.go b/pkg/http/middleware/mcp_parse.go index 0b56902261..bcc44f01f5 100644 --- a/pkg/http/middleware/mcp_parse.go +++ b/pkg/http/middleware/mcp_parse.go @@ -7,6 +7,7 @@ import ( "net/http" ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/modelcontextprotocol/go-sdk/mcp" ) // mcpJSONRPCRequest represents the structure of an MCP JSON-RPC request. @@ -21,7 +22,11 @@ type mcpJSONRPCRequest struct { // For prompts/get // Name is shared with tools/call // For resources/read - URI string `json:"uri,omitempty"` + URI string `json:"uri,omitempty"` + Meta struct { + ProtocolVersion string `json:"io.modelcontextprotocol/protocolVersion,omitempty"` + ClientCapabilities *mcp.ClientCapabilities `json:"io.modelcontextprotocol/clientCapabilities,omitempty"` + } `json:"_meta"` } `json:"params"` } @@ -101,7 +106,11 @@ func parseMCPMethodInfo(body []byte) (*ghcontext.MCPMethodInfo, error) { return nil, nil } - methodInfo := &ghcontext.MCPMethodInfo{Method: mcpReq.Method} + methodInfo := &ghcontext.MCPMethodInfo{ + Method: mcpReq.Method, + ProtocolVersion: mcpReq.Params.Meta.ProtocolVersion, + ClientCapabilities: mcpReq.Params.Meta.ClientCapabilities, + } switch mcpReq.Method { case "tools/call": methodInfo.ItemName = mcpReq.Params.Name diff --git a/pkg/http/middleware/mcp_parse_test.go b/pkg/http/middleware/mcp_parse_test.go index e067f7808a..8034dc29a6 100644 --- a/pkg/http/middleware/mcp_parse_test.go +++ b/pkg/http/middleware/mcp_parse_test.go @@ -9,22 +9,25 @@ import ( "testing" ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestWithMCPParse(t *testing.T) { tests := []struct { - name string - method string - path string - body string - expectInfo bool - expectedMethod string - expectedItem string - expectedRaw string - expectedArgs map[string]any - expectArgsError bool + name string + method string + path string + body string + expectInfo bool + expectedMethod string + expectedItem string + expectedRaw string + expectedArgs map[string]any + expectedProtocol string + expectedForm bool + expectArgsError bool }{ { name: "health check path is skipped", @@ -76,6 +79,19 @@ func TestWithMCPParse(t *testing.T) { expectInfo: true, expectedMethod: "tools/list", }, + { + name: "tools/list parses client availability", + method: http.MethodPost, + path: "/mcp", + body: `{"jsonrpc":"2.0","method":"tools/list","params":{"_meta":{ + "io.modelcontextprotocol/protocolVersion":"2026-07-28", + "io.modelcontextprotocol/clientCapabilities":{"elicitation":{"form":{}}} + }}}`, + expectInfo: true, + expectedMethod: "tools/list", + expectedProtocol: "2026-07-28", + expectedForm: true, + }, { name: "tools/call parses name", method: http.MethodPost, @@ -158,6 +174,12 @@ func TestWithMCPParse(t *testing.T) { require.NotNil(t, capturedInfo) assert.Equal(t, tt.expectedMethod, capturedInfo.Method) assert.Equal(t, tt.expectedItem, capturedInfo.ItemName) + assert.Equal(t, tt.expectedProtocol, capturedInfo.ProtocolVersion) + if tt.expectedForm { + require.NotNil(t, capturedInfo.ClientCapabilities) + require.NotNil(t, capturedInfo.ClientCapabilities.Elicitation) + assert.Equal(t, &mcp.FormElicitationCapabilities{}, capturedInfo.ClientCapabilities.Elicitation.Form) + } if tt.expectedRaw != "" { assert.JSONEq(t, tt.expectedRaw, string(capturedInfo.RawArguments)) } diff --git a/pkg/inventory/builder.go b/pkg/inventory/builder.go index 9ecaca1f57..60bda764b6 100644 --- a/pkg/inventory/builder.go +++ b/pkg/inventory/builder.go @@ -17,7 +17,7 @@ var ( // mcpAppsFeatureFlag is the feature flag name that controls MCP Apps UI metadata. // This is defined here to avoid importing pkg/github (which imports pkg/inventory). // The value must match github.MCPAppsFeatureFlag. -const mcpAppsFeatureFlag = "remote_mcp_ui_apps" +const mcpAppsFeatureFlag FeatureFlag = "remote_mcp_ui_apps" // ToolFilter is a function that determines if a tool should be included. // Returns true if the tool should be included, false to exclude it. @@ -65,19 +65,19 @@ func NewBuilder() *Builder { // SetTools sets the tools for the inventory. Returns self for chaining. func (b *Builder) SetTools(tools []ServerTool) *Builder { - b.tools = tools + b.tools = slices.Clone(tools) return b } // SetResources sets the resource templates for the inventory. Returns self for chaining. func (b *Builder) SetResources(resources []ServerResourceTemplate) *Builder { - b.resourceTemplates = resources + b.resourceTemplates = slices.Clone(resources) return b } // SetPrompts sets the prompts for the inventory. Returns self for chaining. func (b *Builder) SetPrompts(prompts []ServerPrompt) *Builder { - b.prompts = prompts + b.prompts = slices.Clone(prompts) return b } @@ -125,15 +125,10 @@ func (b *Builder) WithTools(toolNames []string) *Builder { return b } -// WithFeatureChecker sets the feature flag checker function. -// The checker receives a context (for actor extraction) and feature flag name, -// 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. +// WithFeatureChecker sets the feature flag checker function. Inventory items +// declare their feature dependencies and functional availability rules through +// FeatureRule. Checks are deduplicated into request-owned resolution state; +// errors are logged and treated as disabled. // // When the checker is nil, no feature-flag filter is installed; tools, // resources, and prompts pass through feature-flag gating unchanged. The @@ -212,15 +207,7 @@ 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, diff --git a/pkg/inventory/features.go b/pkg/inventory/features.go new file mode 100644 index 0000000000..25665fde82 --- /dev/null +++ b/pkg/inventory/features.go @@ -0,0 +1,269 @@ +package inventory + +import ( + "context" + "fmt" + "os" + "slices" + "sync" +) + +const maxFeatureRuleFlags = 16 + +// FeatureFlag identifies a feature consistently across inventory consumers. +type FeatureFlag string + +// FeatureFlagChecker resolves one feature flag for the current request. Checkers +// must not call ResolveFeature; nested resolution fails the owning check closed. +type FeatureFlagChecker func(ctx context.Context, flag string) (bool, error) + +// FeatureResolver returns the resolved value of a feature flag. +// Implementations absorb resolution errors and fail closed. +type FeatureResolver func(flag FeatureFlag) bool + +// FeaturePredicate determines whether an inventory item is available. Predicates +// must be pure: their result may depend only on calls to the supplied resolver. +type FeaturePredicate func(featureAsBool FeatureResolver) bool + +// FeatureRule declares the feature flags used by an availability predicate. +// The predicate resolves reached flags lazily with normal Go boolean semantics, +// while request state deduplicates repeated checks. +type FeatureRule struct { + features []FeatureFlag + predicate FeaturePredicate +} + +// NewFeatureRule creates an availability rule over the supplied feature flags. +func NewFeatureRule(features []FeatureFlag, predicate FeaturePredicate) FeatureRule { + declared := make([]FeatureFlag, 0, len(features)) + for _, feature := range features { + if feature == "" { + continue + } + if slices.Contains(declared, feature) { + continue + } + declared = append(declared, feature) + } + rule := FeatureRule{ + features: declared, + predicate: predicate, + } + if len(declared) > 0 && predicate == nil { + panic("feature rule declares flags without a predicate") + } + rule.validate() + return rule +} + +func (r FeatureRule) validate() { + if r.predicate == nil { + return + } + if len(r.features) > maxFeatureRuleFlags { + panic(fmt.Sprintf("feature rule declares %d flags; maximum is %d", len(r.features), maxFeatureRuleFlags)) + } + + for assignment := range 1 << len(r.features) { + r.evaluate(func(feature FeatureFlag) bool { + for i, declared := range r.features { + if feature == declared { + return assignment&(1< "new_tool" // - User specifies --tools=old_tool // Expected behavior: @@ -1850,7 +2116,7 @@ func TestWithMCPApps_EnabledPreservesUIMetadata(t *testing.T) { // Feature checker enables MCP Apps - UI meta should be preserved mcpAppsChecker := func(_ context.Context, flag string) (bool, error) { - return flag == mcpAppsFeatureFlag, nil + return flag == string(mcpAppsFeatureFlag), nil } reg := mustBuild(t, NewBuilder(). SetTools([]ServerTool{toolWithUI}). diff --git a/pkg/inventory/resources.go b/pkg/inventory/resources.go index 2dd07ae0fe..62a846120f 100644 --- a/pkg/inventory/resources.go +++ b/pkg/inventory/resources.go @@ -16,12 +16,8 @@ type ServerResourceTemplate struct { HandlerFunc ResourceHandlerFunc // Toolset identifies which toolset this resource belongs to Toolset ToolsetMetadata - // FeatureFlagEnable specifies a feature flag that must be enabled for this resource - // to be available. If set and the flag is not enabled, the resource is omitted. - FeatureFlagEnable string - // FeatureFlagDisable specifies feature flags that, when any is enabled, cause this - // resource to be omitted. Used to disable resources when a feature flag is on. - FeatureFlagDisable []string + // FeatureRule controls whether this resource is available. + FeatureRule FeatureRule } // HasHandler returns true if this resource has a handler function. diff --git a/pkg/inventory/server_tool.go b/pkg/inventory/server_tool.go index 9c458c3d12..2bc2769593 100644 --- a/pkg/inventory/server_tool.go +++ b/pkg/inventory/server_tool.go @@ -94,17 +94,9 @@ type ServerTool struct { // and handlers are only created when needed. HandlerFunc HandlerFunc - // FeatureFlagEnable specifies a feature flag that must be enabled for this tool - // to be available. If set and the flag is not enabled, the tool is omitted. - FeatureFlagEnable string - - // FeatureFlagEnableAll specifies additional feature flags that must all be enabled - // for this tool to be available. - FeatureFlagEnableAll []string - - // FeatureFlagDisable specifies feature flags that, when any is enabled, cause this - // tool to be omitted. Used to disable tools when a feature flag is on. - FeatureFlagDisable []string + // FeatureRule declares and evaluates the feature flags that control whether + // this tool is available. Its zero value leaves the tool available. + FeatureRule FeatureRule // Enabled is an optional function called at build/filter time to determine // if this tool should be available. If nil, the tool is considered enabled diff --git a/pkg/inventory/tool_availability.go b/pkg/inventory/tool_availability.go index 937726a8c7..5f7edcee88 100644 --- a/pkg/inventory/tool_availability.go +++ b/pkg/inventory/tool_availability.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + ghcontext "github.com/github/github-mcp-server/pkg/context" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -26,6 +27,14 @@ type toolAvailability struct { requiredElicitationMode ElicitationMode } +type toolFeatureDecision uint8 + +const ( + evaluateToolFeatureRule toolFeatureDecision = iota + excludeToolBeforeFeatureRule + includeToolWithoutFeatureRule +) + func (st *ServerTool) availability() toolAvailability { return toolAvailability{ minimumProtocolVersion: st.MinimumProtocolVersion, @@ -37,6 +46,40 @@ func (a toolAvailability) unrestricted() bool { return a.minimumProtocolVersion == "" && a.requiredElicitationMode == "" } +func featureDecisionForToolAvailability(ctx context.Context, availability toolAvailability) toolFeatureDecision { + if availability.unrestricted() { + return evaluateToolFeatureRule + } + info, ok := ghcontext.MCPMethod(ctx) + if !ok || info == nil || (info.Method != MCPMethodToolsList && info.Method != MCPMethodToolsCall) { + return evaluateToolFeatureRule + } + + available, known := knownToolAvailability(info.ProtocolVersion, info.ClientCapabilities, availability) + if !known || available { + return evaluateToolFeatureRule + } + if info.Method == MCPMethodToolsCall { + return includeToolWithoutFeatureRule + } + return excludeToolBeforeFeatureRule +} + +func knownToolAvailability(protocolVersion string, capabilities *mcp.ClientCapabilities, availability toolAvailability) (bool, bool) { + protocolKnown := availability.minimumProtocolVersion == "" || protocolVersion != "" + if protocolKnown && !protocolVersionAllowed(protocolVersion, availability.minimumProtocolVersion) { + return false, true + } + capabilitiesKnown := availability.requiredElicitationMode == "" || capabilities != nil + if capabilitiesKnown && !elicitationModeSupported(capabilities, availability.requiredElicitationMode) { + return false, true + } + if !protocolKnown || !capabilitiesKnown { + return false, false + } + return true, true +} + func addToolAvailabilityMiddleware(server *mcp.Server, tools []ServerTool) { availabilityByName := make(map[string]toolAvailability) for _, tool := range tools { diff --git a/pkg/inventory/tool_availability_test.go b/pkg/inventory/tool_availability_test.go index 1f76e3cb26..d52e4ff607 100644 --- a/pkg/inventory/tool_availability_test.go +++ b/pkg/inventory/tool_availability_test.go @@ -5,7 +5,9 @@ import ( "encoding/json" "errors" "testing" + "time" + ghcontext "github.com/github/github-mcp-server/pkg/context" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" @@ -140,6 +142,146 @@ func TestToolAvailability(t *testing.T) { } } +func TestToolCallAvailabilitySkipsFeatureChecksOnlyWhenUnavailable(t *testing.T) { + tests := []struct { + name string + minimumProtocolVersion string + requiredElicitationMode ElicitationMode + methodInfo *ghcontext.MCPMethodInfo + clientCapabilities *mcp.ClientCapabilities + legacyProtocol bool + wantCheckerCalls int + wantHandlerCalls int + wantError string + }{ + { + name: "protocol unavailable", + minimumProtocolVersion: ProtocolVersionMultiRoundTrip, + methodInfo: &ghcontext.MCPMethodInfo{ + Method: MCPMethodToolsCall, + ProtocolVersion: "2025-11-25", + ClientCapabilities: &mcp.ClientCapabilities{}, + }, + legacyProtocol: true, + wantError: `Tool "restricted" requires MCP protocol version 2026-07-28 or later.`, + wantCheckerCalls: 0, + }, + { + name: "elicitation unavailable", + requiredElicitationMode: ElicitationModeForm, + methodInfo: &ghcontext.MCPMethodInfo{ + Method: MCPMethodToolsCall, + ProtocolVersion: ProtocolVersionMultiRoundTrip, + ClientCapabilities: &mcp.ClientCapabilities{ + Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}}, + }, + }, + clientCapabilities: &mcp.ClientCapabilities{ + Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}}, + }, + wantError: `Tool "restricted" requires client support for form elicitation.`, + wantCheckerCalls: 0, + }, + { + name: "eligible", + minimumProtocolVersion: ProtocolVersionMultiRoundTrip, + requiredElicitationMode: ElicitationModeForm, + methodInfo: &ghcontext.MCPMethodInfo{ + Method: MCPMethodToolsCall, + ProtocolVersion: ProtocolVersionMultiRoundTrip, + ClientCapabilities: &mcp.ClientCapabilities{ + Elicitation: &mcp.ElicitationCapabilities{Form: &mcp.FormElicitationCapabilities{}}, + }, + }, + clientCapabilities: &mcp.ClientCapabilities{ + Elicitation: &mcp.ElicitationCapabilities{Form: &mcp.FormElicitationCapabilities{}}, + }, + wantCheckerCalls: 1, + wantHandlerCalls: 1, + }, + { + name: "unknown metadata defers", + minimumProtocolVersion: ProtocolVersionMultiRoundTrip, + requiredElicitationMode: ElicitationModeForm, + methodInfo: &ghcontext.MCPMethodInfo{Method: MCPMethodToolsCall}, + clientCapabilities: &mcp.ClientCapabilities{ + Elicitation: &mcp.ElicitationCapabilities{Form: &mcp.FormElicitationCapabilities{}}, + }, + wantCheckerCalls: 1, + wantHandlerCalls: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + checkerCalls := 0 + handlerCalls := 0 + tool := availabilityTestTool( + "restricted", + tt.minimumProtocolVersion, + tt.requiredElicitationMode, + func() { handlerCalls++ }, + ) + if tt.wantError != "" { + tool.Tool.Meta = map[string]any{"ui": map[string]any{"resourceUri": "ui://restricted"}} + } + tool.FeatureRule = NewFeatureRule([]FeatureFlag{"feature"}, func(featureAsBool FeatureResolver) bool { + return featureAsBool("feature") + }) + inv, err := NewBuilder(). + SetTools([]ServerTool{tool}). + WithToolsets([]string{"all"}). + WithFeatureChecker(func(context.Context, string) (bool, error) { + time.Sleep(time.Millisecond) + checkerCalls++ + return true, nil + }). + Build() + require.NoError(t, err) + + ctx := ghcontext.WithMCPMethodInfo(context.Background(), tt.methodInfo) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + inv.ForMCPRequest(MCPMethodToolsCall, "restricted").RegisterTools(ctx, server, nil) + require.Equal(t, tt.wantCheckerCalls, checkerCalls) + if tt.legacyProtocol { + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) { + if method == "server/discover" { + return nil, errors.New("legacy server does not support discovery") + } + return next(ctx, method, request) + } + }) + } + + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = serverSession.Close() }) + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, &mcp.ClientOptions{ + Capabilities: tt.clientCapabilities, + }) + clientSession, err := client.Connect(context.Background(), clientTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = clientSession.Close() }) + + result, err := clientSession.CallTool(context.Background(), &mcp.CallToolParams{Name: "restricted"}) + require.NoError(t, err) + require.Equal(t, tt.wantCheckerCalls, checkerCalls) + require.Equal(t, tt.wantHandlerCalls, handlerCalls) + if tt.wantError == "" { + assert.False(t, result.IsError) + return + } + require.True(t, result.IsError) + require.Len(t, result.Content, 1) + content, ok := result.Content[0].(*mcp.TextContent) + require.True(t, ok) + assert.Equal(t, tt.wantError, content.Text) + }) + } +} + func availabilityTestTool(name, minimumProtocolVersion string, requiredElicitationMode ElicitationMode, onCall func()) ServerTool { return ServerTool{ Tool: mcp.Tool{ diff --git a/script/print-mcp-diff-configs/main.go b/script/print-mcp-diff-configs/main.go index 421c9fce41..ada18c4063 100644 --- a/script/print-mcp-diff-configs/main.go +++ b/script/print-mcp-diff-configs/main.go @@ -141,7 +141,7 @@ func baseEntries() []baseEntry { }}, } - flags := append([]string(nil), github.AllowedFeatureFlags...) + flags := github.HeaderAllowedFeatureFlags() sort.Strings(flags) for _, f := range flags { entries = append(entries, baseEntry{ @@ -208,7 +208,7 @@ func (s settings) toHeaders() map[string]string { } func firstFeatureFlag() string { - flags := append([]string(nil), github.AllowedFeatureFlags...) + flags := github.HeaderAllowedFeatureFlags() if len(flags) == 0 { return "" } From 51e45e23610b9b8a2e3769a376d75b27e125fbe5 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 3 Sep 2026 15:04:26 +0200 Subject: [PATCH 14/61] feat(governance): add repository ruleset tools with multi-level scope challenge (#2991) * feat(governance): add repository ruleset tools with multi-level scope challenge Reimplements the repository ruleset support from #821 (issue #820) onto the current inventory-based tool architecture, consolidated into two level-aware tools in a new non-default `governance` toolset. - `repository_ruleset_read`: read rulesets, branch rules, and rule suites at repository, organization, or enterprise level. - `create_repository_ruleset`: create a ruleset at any of the three levels. A `level` argument selects the scope, and a DynamicChallenge up-scopes the required OAuth scope accordingly (repo -> read:org/admin:org -> read:enterprise/admin:enterprise), so the default surface only asks for repo scope. Ruleset creation round-trips the request through go-github's RepositoryRuleset unmarshalling and rejects rule types, parameters, conditions, or bypass-actor keys that are silently dropped, preventing typos from creating a weaker-than-intended ruleset. Co-authored-by: Patrick Knight Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e886867-a922-419a-b02c-ac643716aea8 * fix(governance): complete ruleset routing and validation Add organization rule-suite routing and current filters, harden schema validation, and keep scope metadata usable for fixed-scope tokens and library callers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(governance): escape ruleset branch path Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(governance): decode enterprise ruleset lists Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(governance): reject unknown ruleset create fields Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(governance): require explicit ruleset bypass mode Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Patrick Knight Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e886867-a922-419a-b02c-ac643716aea8 --- README.md | 53 + docs/remote-server.md | 1 + .../create_repository_ruleset.snap | 135 ++ .../repository_ruleset_read.snap | 122 ++ pkg/github/rulesets.go | 1060 ++++++++++++ pkg/github/rulesets_test.go | 1441 +++++++++++++++++ pkg/github/tools.go | 9 + pkg/http/oauth/oauth_test.go | 3 + pkg/octicons/icons/law-dark.png | Bin 0 -> 550 bytes pkg/octicons/icons/law-light.png | Bin 0 -> 841 bytes pkg/octicons/icons_data_uris.txt | 2 + pkg/octicons/required_icons.txt | 1 + pkg/scopes/scopes.go | 24 +- pkg/scopes/scopes_test.go | 11 + 14 files changed, 2856 insertions(+), 6 deletions(-) create mode 100644 pkg/github/__toolsnaps__/create_repository_ruleset.snap create mode 100644 pkg/github/__toolsnaps__/repository_ruleset_read.snap create mode 100644 pkg/github/rulesets.go create mode 100644 pkg/github/rulesets_test.go create mode 100644 pkg/octicons/icons/law-dark.png create mode 100644 pkg/octicons/icons/law-light.png diff --git a/README.md b/README.md index 1b9f693a34..9d32100497 100644 --- a/README.md +++ b/README.md @@ -586,6 +586,7 @@ The following sets of tools are available: | comment-discussion | `discussions` | GitHub Discussions related tools | | logo-gist | `gists` | GitHub Gist related tools | | git-branch | `git` | GitHub Git API related tools for low-level Git operations | +| law | `governance` | Repository governance tools for managing rulesets at the repository, organization, and enterprise levels | | issue-opened | `issues` | GitHub Issues related tools | | tag | `labels` | GitHub Labels related tools | | bell | `notifications` | GitHub Notifications related tools | @@ -872,6 +873,58 @@ The following sets of tools are available:
+law Governance + +- **create_repository_ruleset** - Create repository ruleset + - **OAuth Challenge Scopes**: `repo`, `admin:org`, `admin:enterprise` + - `bypass_actors`: The actors that can bypass the rules in this ruleset (object[], optional) + - `conditions`: Conditions for when this ruleset applies, e.g. {"ref_name": {"include": ["refs/heads/main"], "exclude": []}} (object, optional) + - `enforcement`: The enforcement level of the ruleset. 'evaluate' allows admins to test rules before enforcing them (string, required) + - `enterprise`: Enterprise slug. Required when level is 'enterprise'. (string, optional) + - `level`: The level at which the ruleset is configured: + - 'repository': A ruleset on a single repository (requires 'owner' and 'repo'). + - 'organization': A ruleset covering repositories in an organization (requires 'org'). + - 'enterprise': A ruleset covering repositories across an enterprise (requires 'enterprise'). (string, required) + - `name`: The name of the ruleset (string, required) + - `org`: Organization name. Required when level is 'organization'. (string, optional) + - `owner`: Repository owner. Required when level is 'repository'. (string, optional) + - `repo`: Repository name. Required when level is 'repository'. (string, optional) + - `rules`: An array of rules within the ruleset. Each rule is an object with a 'type' (e.g. 'creation', 'deletion', 'non_fast_forward', 'required_signatures', 'pull_request', 'required_status_checks') and, for rules that need configuration, a 'parameters' object (object[], required) + - `target`: The target of the ruleset. Defaults to 'branch'. 'repository' is only valid for 'organization' and 'enterprise' level rulesets. (string, optional) + +- **repository_ruleset_read** - Read repository rulesets + - **OAuth Challenge Scopes**: `repo`, `read:org`, `read:enterprise` + - `actor_name`: The handle for the GitHub user account to filter rule suites on. Used by the 'list_rule_suites' method. (string, optional) + - `branch`: Branch name. Required for the 'get_rules_for_branch' method. (string, optional) + - `enterprise`: Enterprise slug. Required when level is 'enterprise'. (string, optional) + - `evaluate_status`: Filter rule suites by ruleset evaluation mode. Used by the 'list_rule_suites' method. (string, optional) + - `includes_parents`: Include rulesets configured at higher levels that also apply. Defaults to true. Used by the 'get' and 'list' methods at the repository level. (boolean, optional) + - `level`: The level at which the ruleset is configured: + - 'repository': A ruleset on a single repository (requires 'owner' and 'repo'). + - 'organization': A ruleset covering repositories in an organization (requires 'org'). + - 'enterprise': A ruleset covering repositories across an enterprise (requires 'enterprise'). (string, required) + - `method`: Operation to perform: + - 'get': Get a specific ruleset by ID (requires 'ruleset_id'). Supported at every level. + - 'list': List all rulesets. Supported at every level. + - 'get_rules_for_branch': Get all rules that apply to a branch (requires 'branch'). Repository level only. + - 'list_rule_suites': List rule suites, the evaluations of rules against pushes. Repository and organization levels only. + - 'get_rule_suite': Get a specific rule suite by ID (requires 'rule_suite_id'). Repository and organization levels only. (string, required) + - `org`: Organization name. Required when level is 'organization'. (string, optional) + - `owner`: Repository owner. Required when level is 'repository'. (string, optional) + - `page`: Page number for pagination (min 1) (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `ref`: The name of the ref (branch, tag, etc.) to filter rule suites by. Used by the 'list_rule_suites' method. (string, optional) + - `repo`: Repository name. Required when level is 'repository'. (string, optional) + - `repository_name`: Repository name to filter rule suites by. Used by the 'list_rule_suites' method at the organization level. (string, optional) + - `rule_suite_id`: Rule suite ID. Required for the 'get_rule_suite' method. (number, optional) + - `rule_suite_result`: The rule suite result to filter by. Used by the 'list_rule_suites' method. (string, optional) + - `ruleset_id`: Ruleset ID. Required for the 'get' method. (number, optional) + - `time_period`: The time period to filter rule suites by. Used by the 'list_rule_suites' method. (string, optional) + +
+ +
+ issue-opened Issues - **add_issue_comment** - Add comment to issue or pull request diff --git a/docs/remote-server.md b/docs/remote-server.md index d8587a3116..78b5c906ac 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -30,6 +30,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | comment-discussion
`discussions` | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) | | logo-gist
`gists` | GitHub Gist related tools | https://api.githubcopilot.com/mcp/x/gists | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/gists/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%2Freadonly%22%7D) | | git-branch
`git` | GitHub Git API related tools for low-level Git operations | https://api.githubcopilot.com/mcp/x/git | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/git/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%2Freadonly%22%7D) | +| law
`governance` | Repository governance tools for managing rulesets at the repository, organization, and enterprise levels | https://api.githubcopilot.com/mcp/x/governance | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-governance&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgovernance%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/governance/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-governance&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgovernance%2Freadonly%22%7D) | | issue-opened
`issues` | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) | | tag
`labels` | GitHub Labels related tools | https://api.githubcopilot.com/mcp/x/labels | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/labels/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%2Freadonly%22%7D) | | bell
`notifications` | GitHub Notifications related tools | https://api.githubcopilot.com/mcp/x/notifications | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/notifications/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%2Freadonly%22%7D) | diff --git a/pkg/github/__toolsnaps__/create_repository_ruleset.snap b/pkg/github/__toolsnaps__/create_repository_ruleset.snap new file mode 100644 index 0000000000..81f2cadcb2 --- /dev/null +++ b/pkg/github/__toolsnaps__/create_repository_ruleset.snap @@ -0,0 +1,135 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": false, + "title": "Create repository ruleset" + }, + "description": "Create a new ruleset at the repository, organization, or enterprise level", + "inputSchema": { + "additionalProperties": false, + "properties": { + "bypass_actors": { + "description": "The actors that can bypass the rules in this ruleset", + "items": { + "additionalProperties": false, + "properties": { + "actor_id": { + "description": "The ID of the actor that can bypass a ruleset", + "type": "number" + }, + "actor_type": { + "description": "The type of actor that can bypass a ruleset. 'EnterpriseOwner' and 'EnterpriseRole' are only valid for 'enterprise' level rulesets.", + "enum": [ + "Integration", + "OrganizationAdmin", + "RepositoryRole", + "Team", + "DeployKey", + "User", + "EnterpriseOwner", + "EnterpriseRole" + ], + "type": "string" + }, + "bypass_mode": { + "description": "When the specified actor can bypass the ruleset. 'pull_request' only applies to branch rulesets and is not valid for the 'DeployKey' actor type. 'exempt' means rules are not run for that actor and no bypass audit entry is created.", + "enum": [ + "always", + "pull_request", + "exempt" + ], + "type": "string" + } + }, + "required": [ + "actor_type", + "bypass_mode" + ], + "type": "object" + }, + "type": "array" + }, + "conditions": { + "description": "Conditions for when this ruleset applies, e.g. {\"ref_name\": {\"include\": [\"refs/heads/main\"], \"exclude\": []}}", + "type": "object" + }, + "enforcement": { + "description": "The enforcement level of the ruleset. 'evaluate' allows admins to test rules before enforcing them", + "enum": [ + "disabled", + "active", + "evaluate" + ], + "type": "string" + }, + "enterprise": { + "description": "Enterprise slug. Required when level is 'enterprise'.", + "type": "string" + }, + "level": { + "description": "The level at which the ruleset is configured:\n- 'repository': A ruleset on a single repository (requires 'owner' and 'repo').\n- 'organization': A ruleset covering repositories in an organization (requires 'org').\n- 'enterprise': A ruleset covering repositories across an enterprise (requires 'enterprise').", + "enum": [ + "repository", + "organization", + "enterprise" + ], + "type": "string" + }, + "name": { + "description": "The name of the ruleset", + "type": "string" + }, + "org": { + "description": "Organization name. Required when level is 'organization'.", + "type": "string" + }, + "owner": { + "description": "Repository owner. Required when level is 'repository'.", + "type": "string" + }, + "repo": { + "description": "Repository name. Required when level is 'repository'.", + "type": "string" + }, + "rules": { + "description": "An array of rules within the ruleset. Each rule is an object with a 'type' (e.g. 'creation', 'deletion', 'non_fast_forward', 'required_signatures', 'pull_request', 'required_status_checks') and, for rules that need configuration, a 'parameters' object", + "items": { + "additionalProperties": false, + "properties": { + "parameters": { + "description": "Parameters for rule types that require additional configuration", + "type": "object" + }, + "type": { + "description": "The type of rule, e.g. 'creation', 'deletion', 'non_fast_forward', 'required_signatures', 'pull_request', 'required_status_checks'", + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "type": "array" + }, + "target": { + "description": "The target of the ruleset. Defaults to 'branch'. 'repository' is only valid for 'organization' and 'enterprise' level rulesets.", + "enum": [ + "branch", + "tag", + "push", + "repository" + ], + "type": "string" + } + }, + "required": [ + "level", + "name", + "enforcement", + "rules" + ], + "type": "object" + }, + "name": "create_repository_ruleset" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/repository_ruleset_read.snap b/pkg/github/__toolsnaps__/repository_ruleset_read.snap new file mode 100644 index 0000000000..47b4c3742d --- /dev/null +++ b/pkg/github/__toolsnaps__/repository_ruleset_read.snap @@ -0,0 +1,122 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "Read repository rulesets" + }, + "description": "Read rulesets at the repository, organization, or enterprise level, and rule suites at the repository or organization level. Select the level with the 'level' parameter and the operation with the 'method' parameter.", + "inputSchema": { + "properties": { + "actor_name": { + "description": "The handle for the GitHub user account to filter rule suites on. Used by the 'list_rule_suites' method.", + "type": "string" + }, + "branch": { + "description": "Branch name. Required for the 'get_rules_for_branch' method.", + "type": "string" + }, + "enterprise": { + "description": "Enterprise slug. Required when level is 'enterprise'.", + "type": "string" + }, + "evaluate_status": { + "description": "Filter rule suites by ruleset evaluation mode. Used by the 'list_rule_suites' method.", + "enum": [ + "all", + "active", + "evaluate" + ], + "type": "string" + }, + "includes_parents": { + "description": "Include rulesets configured at higher levels that also apply. Defaults to true. Used by the 'get' and 'list' methods at the repository level.", + "type": "boolean" + }, + "level": { + "description": "The level at which the ruleset is configured:\n- 'repository': A ruleset on a single repository (requires 'owner' and 'repo').\n- 'organization': A ruleset covering repositories in an organization (requires 'org').\n- 'enterprise': A ruleset covering repositories across an enterprise (requires 'enterprise').", + "enum": [ + "repository", + "organization", + "enterprise" + ], + "type": "string" + }, + "method": { + "description": "Operation to perform:\n- 'get': Get a specific ruleset by ID (requires 'ruleset_id'). Supported at every level.\n- 'list': List all rulesets. Supported at every level.\n- 'get_rules_for_branch': Get all rules that apply to a branch (requires 'branch'). Repository level only.\n- 'list_rule_suites': List rule suites, the evaluations of rules against pushes. Repository and organization levels only.\n- 'get_rule_suite': Get a specific rule suite by ID (requires 'rule_suite_id'). Repository and organization levels only.", + "enum": [ + "get", + "list", + "get_rules_for_branch", + "list_rule_suites", + "get_rule_suite" + ], + "type": "string" + }, + "org": { + "description": "Organization name. Required when level is 'organization'.", + "type": "string" + }, + "owner": { + "description": "Repository owner. Required when level is '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 name of the ref (branch, tag, etc.) to filter rule suites by. Used by the 'list_rule_suites' method.", + "type": "string" + }, + "repo": { + "description": "Repository name. Required when level is 'repository'.", + "type": "string" + }, + "repository_name": { + "description": "Repository name to filter rule suites by. Used by the 'list_rule_suites' method at the organization level.", + "type": "string" + }, + "rule_suite_id": { + "description": "Rule suite ID. Required for the 'get_rule_suite' method.", + "type": "number" + }, + "rule_suite_result": { + "description": "The rule suite result to filter by. Used by the 'list_rule_suites' method.", + "enum": [ + "pass", + "fail", + "bypass", + "all" + ], + "type": "string" + }, + "ruleset_id": { + "description": "Ruleset ID. Required for the 'get' method.", + "type": "number" + }, + "time_period": { + "description": "The time period to filter rule suites by. Used by the 'list_rule_suites' method.", + "enum": [ + "hour", + "day", + "week", + "month" + ], + "type": "string" + } + }, + "required": [ + "level", + "method" + ], + "type": "object" + }, + "name": "repository_ruleset_read" +} \ No newline at end of file diff --git a/pkg/github/rulesets.go b/pkg/github/rulesets.go new file mode 100644 index 0000000000..ac05282aaa --- /dev/null +++ b/pkg/github/rulesets.go @@ -0,0 +1,1060 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/google/go-github/v89/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// rulesetLevelDescription documents the "level" parameter shared by the +// ruleset read and write tools. +const rulesetLevelDescription = "The level at which the ruleset is configured:\n" + + "- 'repository': A ruleset on a single repository (requires 'owner' and 'repo').\n" + + "- 'organization': A ruleset covering repositories in an organization (requires 'org').\n" + + "- 'enterprise': A ruleset covering repositories across an enterprise (requires 'enterprise')." + +// rulesetReadScopeAccess declares the exhaustive scope challenge policy for +// repository_ruleset_read. The exact scope challenged depends on the "level" +// argument: repository reads need "repo", organization reads need "read:org", +// and enterprise reads need "read:enterprise". A missing or unrecognized +// level returns no challenge so normal handler validation produces the error. +func rulesetReadScopeAccess() inventory.ScopeAccess { + return scopes.DynamicChallenge( + []scopes.Scope{scopes.Repo, scopes.ReadOrg, scopes.ReadEnterprise}, + func([]string) bool { + // Repository-level reads may target public repositories, so the + // tool stays visible even for tokens without any of these scopes. + return true + }, + func(arguments map[string]any, activeScopes []string) []string { + level, ok := arguments["level"].(string) + if !ok { + return nil + } + switch level { + case "repository": + return scopes.ChallengeAll(activeScopes, scopes.Repo) + case "organization": + return scopes.ChallengeAll(activeScopes, scopes.ReadOrg) + case "enterprise": + return scopes.ChallengeAll(activeScopes, scopes.ReadEnterprise) + default: + return nil + } + }, + ) +} + +// rulesetWriteScopeAccess declares the exhaustive scope challenge policy for +// create_repository_ruleset. The exact scope challenged depends on the +// "level" argument: repository writes need "repo", organization writes need +// "admin:org", and enterprise writes need "admin:enterprise". A missing or +// unrecognized level returns no challenge so normal handler validation +// produces the error. +func rulesetWriteScopeAccess() inventory.ScopeAccess { + return scopes.DynamicChallenge( + []scopes.Scope{scopes.Repo, scopes.AdminOrg, scopes.AdminEnterprise}, + func(activeScopes []string) bool { + return scopes.HasAll(activeScopes, scopes.Repo) || + scopes.HasAll(activeScopes, scopes.AdminOrg) || + scopes.HasAll(activeScopes, scopes.AdminEnterprise) + }, + func(arguments map[string]any, activeScopes []string) []string { + level, ok := arguments["level"].(string) + if !ok { + return nil + } + switch level { + case "repository": + return scopes.ChallengeAll(activeScopes, scopes.Repo) + case "organization": + return scopes.ChallengeAll(activeScopes, scopes.AdminOrg) + case "enterprise": + return scopes.ChallengeAll(activeScopes, scopes.AdminEnterprise) + default: + return nil + } + }, + ) +} + +// RepositoryRulesetRead creates a tool for ruleset and rule-suite reads. +func RepositoryRulesetRead(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataGovernance, + mcp.Tool{ + Name: "repository_ruleset_read", + Description: t("TOOL_REPOSITORY_RULESET_READ_DESCRIPTION", "Read rulesets at the repository, organization, or enterprise level, and rule suites at the repository or organization level. Select the level with the 'level' parameter and the operation with the 'method' parameter."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_REPOSITORY_RULESET_READ_USER_TITLE", "Read repository rulesets"), + ReadOnlyHint: true, + }, + InputSchema: WithPagination(&jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "level": { + Type: "string", + Enum: []any{"repository", "organization", "enterprise"}, + Description: rulesetLevelDescription, + }, + "method": { + Type: "string", + Enum: []any{"get", "list", "get_rules_for_branch", "list_rule_suites", "get_rule_suite"}, + Description: "Operation to perform:\n" + + "- 'get': Get a specific ruleset by ID (requires 'ruleset_id'). Supported at every level.\n" + + "- 'list': List all rulesets. Supported at every level.\n" + + "- 'get_rules_for_branch': Get all rules that apply to a branch (requires 'branch'). Repository level only.\n" + + "- 'list_rule_suites': List rule suites, the evaluations of rules against pushes. Repository and organization levels only.\n" + + "- 'get_rule_suite': Get a specific rule suite by ID (requires 'rule_suite_id'). Repository and organization levels only.", + }, + "owner": { + Type: "string", + Description: "Repository owner. Required when level is 'repository'.", + }, + "repo": { + Type: "string", + Description: "Repository name. Required when level is 'repository'.", + }, + "org": { + Type: "string", + Description: "Organization name. Required when level is 'organization'.", + }, + "enterprise": { + Type: "string", + Description: "Enterprise slug. Required when level is 'enterprise'.", + }, + "ruleset_id": { + Type: "number", + Description: "Ruleset ID. Required for the 'get' method.", + }, + "includes_parents": { + Type: "boolean", + Description: "Include rulesets configured at higher levels that also apply. Defaults to true. Used by the 'get' and 'list' methods at the repository level.", + }, + "branch": { + Type: "string", + Description: "Branch name. Required for the 'get_rules_for_branch' method.", + }, + "ref": { + Type: "string", + Description: "The name of the ref (branch, tag, etc.) to filter rule suites by. Used by the 'list_rule_suites' method.", + }, + "time_period": { + Type: "string", + Enum: []any{"hour", "day", "week", "month"}, + Description: "The time period to filter rule suites by. Used by the 'list_rule_suites' method.", + }, + "actor_name": { + Type: "string", + Description: "The handle for the GitHub user account to filter rule suites on. Used by the 'list_rule_suites' method.", + }, + "repository_name": { + Type: "string", + Description: "Repository name to filter rule suites by. Used by the 'list_rule_suites' method at the organization level.", + }, + "rule_suite_result": { + Type: "string", + Enum: []any{"pass", "fail", "bypass", "all"}, + Description: "The rule suite result to filter by. Used by the 'list_rule_suites' method.", + }, + "evaluate_status": { + Type: "string", + Enum: []any{"all", "active", "evaluate"}, + Description: "Filter rule suites by ruleset evaluation mode. Used by the 'list_rule_suites' method.", + }, + "rule_suite_id": { + Type: "number", + Description: "Rule suite ID. Required for the 'get_rule_suite' method.", + }, + }, + Required: []string{"level", "method"}, + }), + }, + rulesetReadScopeAccess(), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + level, err := RequiredParam[string](args, "level") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + method, err := RequiredParam[string](args, "method") + 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) + } + + switch level { + case "repository": + return repositoryRulesetReadRepository(ctx, client, strings.ToLower(method), args) + case "organization": + return repositoryRulesetReadOrganization(ctx, client, strings.ToLower(method), args) + case "enterprise": + return repositoryRulesetReadEnterprise(ctx, client, strings.ToLower(method), args) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown level: %q (expected 'repository', 'organization', or 'enterprise')", level)), nil, nil + } + }, + ) +} + +// repositoryRulesetReadRepository handles repository_ruleset_read calls with level="repository". +func repositoryRulesetReadRepository(ctx context.Context, client *github.Client, method string, 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 + } + + switch method { + case "get": + rulesetID, err := RequiredBigInt(args, "ruleset_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + // GetRuleset always sends includes_parents, so preserve GitHub's true default. + includesParents := true + if _, ok := args["includes_parents"]; ok { + includesParents, err = OptionalParam[bool](args, "includes_parents") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + } + result, err := GetRepositoryRuleset(ctx, client, owner, repo, rulesetID, includesParents) + return result, nil, err + case "list": + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + var includesParents *bool + if _, ok := args["includes_parents"]; ok { + v, err := OptionalParam[bool](args, "includes_parents") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + includesParents = &v + } + result, err := ListRepositoryRulesets(ctx, client, owner, repo, includesParents, pagination) + return result, nil, err + case "get_rules_for_branch": + branch, err := RequiredParam[string](args, "branch") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + result, err := GetRepositoryRulesForBranch(ctx, client, owner, repo, branch, pagination) + return result, nil, err + case "list_rule_suites": + filters, err := ruleSuiteFiltersFromArgs(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + result, err := ListRepositoryRuleSuites(ctx, client, owner, repo, filters, pagination) + return result, nil, err + case "get_rule_suite": + ruleSuiteID, err := RequiredBigInt(args, "rule_suite_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + result, err := GetRepositoryRuleSuite(ctx, client, owner, repo, ruleSuiteID) + return result, nil, err + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %q", method)), nil, nil + } +} + +// repositoryRulesetReadOrganization handles repository_ruleset_read calls with level="organization". +func repositoryRulesetReadOrganization(ctx context.Context, client *github.Client, method string, args map[string]any) (*mcp.CallToolResult, any, error) { + org, err := RequiredParam[string](args, "org") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + switch method { + case "get": + rulesetID, err := RequiredBigInt(args, "ruleset_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + result, err := GetOrganizationRepositoryRuleset(ctx, client, org, rulesetID) + return result, nil, err + case "list": + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + result, err := ListOrganizationRepositoryRulesets(ctx, client, org, pagination) + return result, nil, err + case "list_rule_suites": + filters, err := ruleSuiteFiltersFromArgs(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + result, err := ListOrganizationRuleSuites(ctx, client, org, filters, pagination) + return result, nil, err + case "get_rule_suite": + ruleSuiteID, err := RequiredBigInt(args, "rule_suite_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + result, err := GetOrganizationRuleSuite(ctx, client, org, ruleSuiteID) + return result, nil, err + default: + return utils.NewToolResultError(fmt.Sprintf("method %q is not supported for level \"organization\"; supported methods: get, list, list_rule_suites, get_rule_suite", method)), nil, nil + } +} + +// repositoryRulesetReadEnterprise handles repository_ruleset_read calls with level="enterprise". +func repositoryRulesetReadEnterprise(ctx context.Context, client *github.Client, method string, args map[string]any) (*mcp.CallToolResult, any, error) { + enterprise, err := RequiredParam[string](args, "enterprise") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + switch method { + case "get": + rulesetID, err := RequiredBigInt(args, "ruleset_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + result, err := GetEnterpriseRepositoryRuleset(ctx, client, enterprise, rulesetID) + return result, nil, err + case "list": + pagination, err := OptionalPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + result, err := ListEnterpriseRepositoryRulesets(ctx, client, enterprise, pagination) + return result, nil, err + default: + return utils.NewToolResultError(fmt.Sprintf("method %q is not supported for level \"enterprise\"; supported methods: get, list", method)), nil, nil + } +} + +// GetRepositoryRuleset gets a specific repository ruleset by ID. +func GetRepositoryRuleset(ctx context.Context, client *github.Client, owner, repo string, rulesetID int64, includesParents bool) (*mcp.CallToolResult, error) { + ruleset, resp, err := client.Repositories.GetRuleset(ctx, owner, repo, rulesetID, includesParents) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get repository ruleset", resp, err), nil + } + + return MarshalledTextResult(ruleset), nil +} + +// ListRepositoryRulesets lists all rulesets for a repository. When +// includesParents is nil GitHub's default behaviour (include parents) is used. +func ListRepositoryRulesets(ctx context.Context, client *github.Client, owner, repo string, includesParents *bool, pagination PaginationParams) (*mcp.CallToolResult, error) { + opts := &github.RepositoryListRulesetsOptions{ + ListOptions: github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + }, + IncludesParents: includesParents, + } + + rulesets, resp, err := client.Repositories.GetAllRulesets(ctx, owner, repo, opts) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list repository rulesets", resp, err), nil + } + + return MarshalledTextResult(rulesets), nil +} + +// GetRepositoryRulesForBranch gets all rules that apply to a specific branch. +func GetRepositoryRulesForBranch(ctx context.Context, client *github.Client, owner, repo, branch string, pagination PaginationParams) (*mcp.CallToolResult, error) { + opts := &github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + } + + branchRules, resp, err := client.Repositories.ListRulesForBranch(ctx, owner, repo, url.PathEscape(branch), opts) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get repository rules for branch", resp, err), nil + } + + return MarshalledTextResult(branchRules), nil +} + +// RuleSuiteFilters holds the optional filters for listing rule suites. +type RuleSuiteFilters struct { + Ref string + RepositoryName string + TimePeriod string + ActorName string + RuleSuiteResult string + EvaluateStatus string +} + +func ruleSuiteFiltersFromArgs(args map[string]any) (RuleSuiteFilters, error) { + ref, err := OptionalParam[string](args, "ref") + if err != nil { + return RuleSuiteFilters{}, err + } + repositoryName, err := OptionalParam[string](args, "repository_name") + if err != nil { + return RuleSuiteFilters{}, err + } + timePeriod, err := OptionalParam[string](args, "time_period") + if err != nil { + return RuleSuiteFilters{}, err + } + actorName, err := OptionalParam[string](args, "actor_name") + if err != nil { + return RuleSuiteFilters{}, err + } + ruleSuiteResult, err := OptionalParam[string](args, "rule_suite_result") + if err != nil { + return RuleSuiteFilters{}, err + } + evaluateStatus, err := OptionalParam[string](args, "evaluate_status") + if err != nil { + return RuleSuiteFilters{}, err + } + return RuleSuiteFilters{ + Ref: ref, + RepositoryName: repositoryName, + TimePeriod: timePeriod, + ActorName: actorName, + RuleSuiteResult: ruleSuiteResult, + EvaluateStatus: evaluateStatus, + }, nil +} + +// ListRepositoryRuleSuites lists rule suites (evaluations of rules against +// pushes) for a repository. Rule suites are not supported by go-github, so the +// request is issued directly. +func ListRepositoryRuleSuites(ctx context.Context, client *github.Client, owner, repo string, filters RuleSuiteFilters, pagination PaginationParams) (*mcp.CallToolResult, error) { + apiURL := fmt.Sprintf("repos/%s/%s/rulesets/rule-suites", url.PathEscape(owner), url.PathEscape(repo)) + return listRuleSuites(ctx, client, apiURL, "failed to list repository rule suites", filters, false, pagination) +} + +// ListOrganizationRuleSuites lists rule suites for an organization. +func ListOrganizationRuleSuites(ctx context.Context, client *github.Client, org string, filters RuleSuiteFilters, pagination PaginationParams) (*mcp.CallToolResult, error) { + apiURL := fmt.Sprintf("orgs/%s/rulesets/rule-suites", url.PathEscape(org)) + return listRuleSuites(ctx, client, apiURL, "failed to list organization rule suites", filters, true, pagination) +} + +func listRuleSuites(ctx context.Context, client *github.Client, apiURL, errorMessage string, filters RuleSuiteFilters, includeRepositoryName bool, pagination PaginationParams) (*mcp.CallToolResult, error) { + query := url.Values{} + if filters.Ref != "" { + query.Set("ref", filters.Ref) + } + if includeRepositoryName && filters.RepositoryName != "" { + query.Set("repository_name", filters.RepositoryName) + } + if filters.TimePeriod != "" { + query.Set("time_period", filters.TimePeriod) + } + if filters.ActorName != "" { + query.Set("actor_name", filters.ActorName) + } + if filters.RuleSuiteResult != "" { + query.Set("rule_suite_result", filters.RuleSuiteResult) + } + if filters.EvaluateStatus != "" { + query.Set("evaluate_status", filters.EvaluateStatus) + } + if pagination.Page > 0 { + query.Set("page", strconv.Itoa(pagination.Page)) + } + if pagination.PerPage > 0 { + query.Set("per_page", strconv.Itoa(pagination.PerPage)) + } + if len(query) > 0 { + apiURL += "?" + query.Encode() + } + + req, err := client.NewRequest(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to create request", err), nil + } + + var ruleSuites any + resp, err := client.Do(req, &ruleSuites) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, errorMessage, resp, err), nil + } + + return MarshalledTextResult(ruleSuites), nil +} + +// GetRepositoryRuleSuite gets details of a specific repository rule suite. +func GetRepositoryRuleSuite(ctx context.Context, client *github.Client, owner, repo string, ruleSuiteID int64) (*mcp.CallToolResult, error) { + apiURL := fmt.Sprintf("repos/%s/%s/rulesets/rule-suites/%d", url.PathEscape(owner), url.PathEscape(repo), ruleSuiteID) + return getRuleSuite(ctx, client, apiURL, "failed to get repository rule suite") +} + +// GetOrganizationRuleSuite gets details of a specific organization rule suite. +func GetOrganizationRuleSuite(ctx context.Context, client *github.Client, org string, ruleSuiteID int64) (*mcp.CallToolResult, error) { + apiURL := fmt.Sprintf("orgs/%s/rulesets/rule-suites/%d", url.PathEscape(org), ruleSuiteID) + return getRuleSuite(ctx, client, apiURL, "failed to get organization rule suite") +} + +func getRuleSuite(ctx context.Context, client *github.Client, apiURL, errorMessage string) (*mcp.CallToolResult, error) { + req, err := client.NewRequest(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to create request", err), nil + } + + var ruleSuite any + resp, err := client.Do(req, &ruleSuite) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, errorMessage, resp, err), nil + } + + return MarshalledTextResult(ruleSuite), nil +} + +// GetOrganizationRepositoryRuleset gets a specific organization repository +// ruleset by ID. +func GetOrganizationRepositoryRuleset(ctx context.Context, client *github.Client, org string, rulesetID int64) (*mcp.CallToolResult, error) { + ruleset, resp, err := client.Organizations.GetRepositoryRuleset(ctx, org, rulesetID) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get organization repository ruleset", resp, err), nil + } + + return MarshalledTextResult(ruleset), nil +} + +// ListOrganizationRepositoryRulesets lists all repository rulesets for an +// organization. +func ListOrganizationRepositoryRulesets(ctx context.Context, client *github.Client, org string, pagination PaginationParams) (*mcp.CallToolResult, error) { + opts := &github.ListOptions{ + Page: pagination.Page, + PerPage: pagination.PerPage, + } + + rulesets, resp, err := client.Organizations.ListAllRepositoryRulesets(ctx, org, opts) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list organization repository rulesets", resp, err), nil + } + + return MarshalledTextResult(rulesets), nil +} + +// GetEnterpriseRepositoryRuleset gets a specific enterprise repository +// ruleset by ID. +func GetEnterpriseRepositoryRuleset(ctx context.Context, client *github.Client, enterprise string, rulesetID int64) (*mcp.CallToolResult, error) { + ruleset, resp, err := client.Enterprise.GetRepositoryRuleset(ctx, enterprise, rulesetID) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get enterprise repository ruleset", resp, err), nil + } + + return MarshalledTextResult(ruleset), nil +} + +type enterpriseRepositoryRulesetsResponse struct { + TotalCount int `json:"total_count"` + Rulesets []*github.RepositoryRuleset `json:"rulesets"` +} + +// ListEnterpriseRepositoryRulesets lists all repository rulesets for an +// enterprise. Listing enterprise rulesets is not supported by go-github, so +// the request is issued directly. +func ListEnterpriseRepositoryRulesets(ctx context.Context, client *github.Client, enterprise string, pagination PaginationParams) (*mcp.CallToolResult, error) { + apiURL := fmt.Sprintf("enterprises/%s/rulesets", url.PathEscape(enterprise)) + query := url.Values{} + if pagination.Page > 0 { + query.Set("page", strconv.Itoa(pagination.Page)) + } + if pagination.PerPage > 0 { + query.Set("per_page", strconv.Itoa(pagination.PerPage)) + } + if len(query) > 0 { + apiURL += "?" + query.Encode() + } + + req, err := client.NewRequest(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to create request", err), nil + } + + var result enterpriseRepositoryRulesetsResponse + resp, err := client.Do(req, &result) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list enterprise repository rulesets", resp, err), nil + } + + return MarshalledTextResult(result), nil +} + +// CreateRepositoryRuleset creates a tool to create a new repository ruleset +// at the repository, organization, or enterprise level. The level is +// selected with the "level" parameter. +func CreateRepositoryRuleset(t translations.TranslationHelperFunc) inventory.ServerTool { + properties := rulesetWriteProperties() + properties["level"] = &jsonschema.Schema{ + Type: "string", + Enum: []any{"repository", "organization", "enterprise"}, + Description: rulesetLevelDescription, + } + properties["owner"] = &jsonschema.Schema{Type: "string", Description: "Repository owner. Required when level is 'repository'."} + properties["repo"] = &jsonschema.Schema{Type: "string", Description: "Repository name. Required when level is 'repository'."} + properties["org"] = &jsonschema.Schema{Type: "string", Description: "Organization name. Required when level is 'organization'."} + properties["enterprise"] = &jsonschema.Schema{Type: "string", Description: "Enterprise slug. Required when level is 'enterprise'."} + + return NewTool( + ToolsetMetadataGovernance, + mcp.Tool{ + Name: "create_repository_ruleset", + Description: t("TOOL_CREATE_REPOSITORY_RULESET_DESCRIPTION", "Create a new ruleset at the repository, organization, or enterprise level"), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_CREATE_REPOSITORY_RULESET_USER_TITLE", "Create repository ruleset"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + Properties: properties, + Required: []string{"level", "name", "enforcement", "rules"}, + }, + }, + rulesetWriteScopeAccess(), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + level, err := RequiredParam[string](args, "level") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + ruleset, errResult := buildRepositoryRulesetFromArgs(args) + if errResult != nil { + return errResult, nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + switch level { + case "repository": + 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 + } + created, resp, err := client.Repositories.CreateRuleset(ctx, owner, repo, ruleset) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create repository ruleset", resp, err), nil, nil + } + return MarshalledTextResult(created), nil, nil + case "organization": + org, err := RequiredParam[string](args, "org") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + created, resp, err := client.Organizations.CreateRepositoryRuleset(ctx, org, ruleset) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create organization repository ruleset", resp, err), nil, nil + } + return MarshalledTextResult(created), nil, nil + case "enterprise": + enterprise, err := RequiredParam[string](args, "enterprise") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + created, resp, err := client.Enterprise.CreateRepositoryRuleset(ctx, enterprise, ruleset) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to create enterprise repository ruleset", resp, err), nil, nil + } + return MarshalledTextResult(created), nil, nil + default: + return utils.NewToolResultError(fmt.Sprintf("unknown level: %q (expected 'repository', 'organization', or 'enterprise')", level)), nil, nil + } + }, + ) +} + +// rulesetWriteProperties returns the shared input schema properties for the +// ruleset creation tool. Callers add the level-specific identifier +// properties (owner/repo, org, or enterprise). +func rulesetWriteProperties() map[string]*jsonschema.Schema { + return map[string]*jsonschema.Schema{ + "name": { + Type: "string", + Description: "The name of the ruleset", + }, + "enforcement": { + Type: "string", + Enum: []any{"disabled", "active", "evaluate"}, + Description: "The enforcement level of the ruleset. 'evaluate' allows admins to test rules before enforcing them", + }, + "target": { + Type: "string", + Enum: []any{"branch", "tag", "push", "repository"}, + Description: "The target of the ruleset. Defaults to 'branch'. 'repository' is only valid for 'organization' and 'enterprise' level rulesets.", + }, + "rules": { + Type: "array", + Description: "An array of rules within the ruleset. Each rule is an object with a 'type' (e.g. 'creation', 'deletion', 'non_fast_forward', 'required_signatures', 'pull_request', 'required_status_checks') and, for rules that need configuration, a 'parameters' object", + Items: &jsonschema.Schema{ + Type: "object", + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + Properties: map[string]*jsonschema.Schema{ + "type": { + Type: "string", + Description: "The type of rule, e.g. 'creation', 'deletion', 'non_fast_forward', 'required_signatures', 'pull_request', 'required_status_checks'", + }, + "parameters": { + Type: "object", + Description: "Parameters for rule types that require additional configuration", + }, + }, + Required: []string{"type"}, + }, + }, + "conditions": { + Type: "object", + Description: "Conditions for when this ruleset applies, e.g. {\"ref_name\": {\"include\": [\"refs/heads/main\"], \"exclude\": []}}", + }, + "bypass_actors": { + Type: "array", + Description: "The actors that can bypass the rules in this ruleset", + Items: &jsonschema.Schema{ + Type: "object", + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + Properties: map[string]*jsonschema.Schema{ + "actor_id": { + Type: "number", + Description: "The ID of the actor that can bypass a ruleset", + }, + "actor_type": { + Type: "string", + Enum: []any{"Integration", "OrganizationAdmin", "RepositoryRole", "Team", "DeployKey", "User", "EnterpriseOwner", "EnterpriseRole"}, + Description: "The type of actor that can bypass a ruleset. 'EnterpriseOwner' and 'EnterpriseRole' are only valid for 'enterprise' level rulesets.", + }, + "bypass_mode": { + Type: "string", + Enum: []any{"always", "pull_request", "exempt"}, + Description: "When the specified actor can bypass the ruleset. 'pull_request' only applies to branch rulesets and is not valid for the 'DeployKey' actor type. 'exempt' means rules are not run for that actor and no bypass audit entry is created.", + }, + }, + Required: []string{"actor_type", "bypass_mode"}, + }, + }, + } +} + +// buildRepositoryRulesetFromArgs assembles a github.RepositoryRuleset from the +// shared ruleset creation arguments. It returns a non-nil *mcp.CallToolResult +// describing the problem when the arguments are invalid. +func buildRepositoryRulesetFromArgs(args map[string]any) (github.RepositoryRuleset, *mcp.CallToolResult) { + name, err := RequiredParam[string](args, "name") + if err != nil { + return github.RepositoryRuleset{}, utils.NewToolResultError(err.Error()) + } + enforcement, err := RequiredParam[string](args, "enforcement") + if err != nil { + return github.RepositoryRuleset{}, utils.NewToolResultError(err.Error()) + } + target, err := OptionalParam[string](args, "target") + if err != nil { + return github.RepositoryRuleset{}, utils.NewToolResultError(err.Error()) + } + + rules, ok := args["rules"].([]any) + if !ok { + return github.RepositoryRuleset{}, utils.NewToolResultError("rules parameter must be an array of rule objects") + } + + requestedRuleTypes := make([]string, 0, len(rules)) + requestedRuleParameters := make(map[string]map[string]any, len(rules)) + seenRuleTypes := make(map[string]bool, len(rules)) + for i, rule := range rules { + ruleMap, ok := rule.(map[string]any) + if !ok { + return github.RepositoryRuleset{}, utils.NewToolResultError("each rule must be an object with a 'type' field") + } + for key := range ruleMap { + if key != "type" && key != "parameters" { + return github.RepositoryRuleset{}, utils.NewToolResultError(fmt.Sprintf("rules[%d]: unsupported or unrecognized key: %q", i, key)) + } + } + ruleType, ok := ruleMap["type"].(string) + if !ok || ruleType == "" { + return github.RepositoryRuleset{}, utils.NewToolResultError("each rule must have a non-empty string 'type' field") + } + if seenRuleTypes[ruleType] { + // go-github stores only one rule per type. + return github.RepositoryRuleset{}, utils.NewToolResultError(fmt.Sprintf("duplicate rule type: %q (a ruleset may only have one rule of each type)", ruleType)) + } + seenRuleTypes[ruleType] = true + requestedRuleTypes = append(requestedRuleTypes, ruleType) + if parameters, exists := ruleMap["parameters"]; exists && parameters != nil { + parametersMap, ok := parameters.(map[string]any) + if !ok { + return github.RepositoryRuleset{}, utils.NewToolResultError(fmt.Sprintf("rule %q: parameters must be an object", ruleType)) + } + requestedRuleParameters[ruleType] = parametersMap + } + } + + payload := map[string]any{ + "name": name, + "enforcement": enforcement, + "rules": rules, + } + if target != "" { + payload["target"] = target + } + if conditions, exists := args["conditions"]; exists && conditions != nil { + conditionsMap, ok := conditions.(map[string]any) + if !ok { + return github.RepositoryRuleset{}, utils.NewToolResultError("conditions parameter must be an object") + } + payload["conditions"] = conditionsMap + } + if bypassActors, exists := args["bypass_actors"]; exists && bypassActors != nil { + bypassActorsArr, ok := bypassActors.([]any) + if !ok { + return github.RepositoryRuleset{}, utils.NewToolResultError("bypass_actors parameter must be an array of objects") + } + for i, actor := range bypassActorsArr { + actorMap, ok := actor.(map[string]any) + if !ok { + return github.RepositoryRuleset{}, utils.NewToolResultError(fmt.Sprintf("bypass_actors[%d] must be an object", i)) + } + for key := range actorMap { + if key != "actor_id" && key != "actor_type" && key != "bypass_mode" { + return github.RepositoryRuleset{}, utils.NewToolResultError(fmt.Sprintf("bypass_actors[%d]: unsupported or unrecognized key: %q", i, key)) + } + } + bypassMode, ok := actorMap["bypass_mode"].(string) + if !ok { + return github.RepositoryRuleset{}, utils.NewToolResultError(fmt.Sprintf("bypass_actors[%d].bypass_mode is required and must be a string", i)) + } + switch github.BypassMode(bypassMode) { + case github.BypassModeAlways, github.BypassModePullRequest, github.BypassModeExempt: + default: + return github.RepositoryRuleset{}, utils.NewToolResultError(fmt.Sprintf("bypass_actors[%d].bypass_mode must be one of \"always\", \"pull_request\", or \"exempt\"", i)) + } + } + payload["bypass_actors"] = bypassActorsArr + } + + raw, err := json.Marshal(payload) + if err != nil { + return github.RepositoryRuleset{}, utils.NewToolResultErrorFromErr("failed to build ruleset request", err) + } + var ruleset github.RepositoryRuleset + if err := json.Unmarshal(raw, &ruleset); err != nil { + return github.RepositoryRuleset{}, utils.NewToolResultErrorFromErr("failed to parse ruleset request", err) + } + + // Reject fields silently discarded by go-github's custom ruleset unmarshaler. + appliedRules, errResult := rulesetAppliedRules(ruleset.Rules) + if errResult != nil { + return github.RepositoryRuleset{}, errResult + } + for _, ruleType := range requestedRuleTypes { + appliedParameters, ok := appliedRules[ruleType] + if !ok { + return github.RepositoryRuleset{}, utils.NewToolResultError(fmt.Sprintf("unsupported or unrecognized rule type: %q", ruleType)) + } + if requested := requestedRuleParameters[ruleType]; requested != nil { + if droppedPath := droppedKeyPath(requested, appliedParameters); droppedPath != "" { + return github.RepositoryRuleset{}, utils.NewToolResultError(fmt.Sprintf("rule %q: unsupported or unrecognized parameter: %q", ruleType, droppedPath)) + } + } + } + + if requestedConditions, ok := payload["conditions"].(map[string]any); ok { + appliedConditions, errResult := rulesetAppliedConditions(ruleset.Conditions) + if errResult != nil { + return github.RepositoryRuleset{}, errResult + } + if droppedPath := droppedKeyPath(requestedConditions, appliedConditions); droppedPath != "" { + return github.RepositoryRuleset{}, utils.NewToolResultError(fmt.Sprintf("conditions: unsupported or unrecognized key: %q", droppedPath)) + } + } + + return ruleset, nil +} + +// droppedKeyPath returns the first caller-supplied field omitted by the local +// go-github JSON round-trip. JSON zero values may disappear through omitempty. +func droppedKeyPath(requested, applied map[string]any) string { + for key, requestedValue := range requested { + appliedValue, ok := applied[key] + if !ok { + if isZeroJSONValue(requestedValue) { + continue + } + return key + } + if nested := droppedValuePath(requestedValue, appliedValue); nested != "" { + return key + nested + } + } + return "" +} + +func droppedValuePath(requested, applied any) string { + switch requestedTyped := requested.(type) { + case map[string]any: + appliedMap, ok := applied.(map[string]any) + if !ok { + return "" + } + if nested := droppedKeyPath(requestedTyped, appliedMap); nested != "" { + return "." + nested + } + return "" + case []any: + appliedArr, ok := applied.([]any) + if !ok { + return "" + } + for i, requestedElem := range requestedTyped { + if i >= len(appliedArr) { + if isZeroJSONValue(requestedElem) { + continue + } + return fmt.Sprintf("[%d]", i) + } + if nested := droppedValuePath(requestedElem, appliedArr[i]); nested != "" { + return fmt.Sprintf("[%d]%s", i, nested) + } + } + return "" + default: + return "" + } +} + +func isZeroJSONValue(v any) bool { + switch value := v.(type) { + case nil: + return true + case bool: + return !value + case float64: + return value == 0 + case string: + return value == "" + case []any: + return len(value) == 0 + case map[string]any: + return len(value) == 0 + default: + return false + } +} + +// rulesetAppliedRules marshals the parsed rules back to the API's array form +// and returns, for each rule type that was actually retained, the parameter +// object the corresponding go-github struct recognized. +func rulesetAppliedRules(rules *github.RepositoryRulesetRules) (map[string]map[string]any, *mcp.CallToolResult) { + applied := map[string]map[string]any{} + if rules == nil { + return applied, nil + } + raw, err := json.Marshal(rules) + if err != nil { + return nil, utils.NewToolResultErrorFromErr("failed to validate ruleset rules", err) + } + var ruleObjects []struct { + Type string `json:"type"` + Parameters map[string]any `json:"parameters"` + } + if err := json.Unmarshal(raw, &ruleObjects); err != nil { + return nil, utils.NewToolResultErrorFromErr("failed to validate ruleset rules", err) + } + for _, rule := range ruleObjects { + parameters := rule.Parameters + if parameters == nil { + parameters = map[string]any{} + } + applied[rule.Type] = parameters + } + return applied, nil +} + +// rulesetAppliedConditions marshals the parsed conditions back to the API's +// object form and returns the keys that were actually retained. +func rulesetAppliedConditions(conditions *github.RepositoryRulesetConditions) (map[string]any, *mcp.CallToolResult) { + if conditions == nil { + return map[string]any{}, nil + } + raw, err := json.Marshal(conditions) + if err != nil { + return nil, utils.NewToolResultErrorFromErr("failed to validate ruleset conditions", err) + } + var applied map[string]any + if err := json.Unmarshal(raw, &applied); err != nil { + return nil, utils.NewToolResultErrorFromErr("failed to validate ruleset conditions", err) + } + if applied == nil { + applied = map[string]any{} + } + return applied, nil +} diff --git a/pkg/github/rulesets_test.go b/pkg/github/rulesets_test.go new file mode 100644 index 0000000000..37de10f112 --- /dev/null +++ b/pkg/github/rulesets_test.go @@ -0,0 +1,1441 @@ +package github + +import ( + "context" + "encoding/json" + "io" + "maps" + "net/http" + "net/url" + "testing" + + "github.com/google/go-github/v89/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" +) + +func Test_RepositoryRulesetRead(t *testing.T) { + toolDef := RepositoryRulesetRead(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "repository_ruleset_read", toolDef.Tool.Name) + assert.NotEmpty(t, toolDef.Tool.Description) + assert.True(t, toolDef.Tool.Annotations.ReadOnlyHint) + + schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.ElementsMatch(t, schema.Required, []string{"level", "method"}) + + t.Run("repository level: get defaults includes_parents to true", func(t *testing.T) { + var capturedQuery url.Values + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/{owner}/{repo}/rulesets/{ruleset_id}": func(w http.ResponseWriter, r *http.Request) { + capturedQuery = r.URL.Query() + mockResponse(t, http.StatusOK, &github.RepositoryRuleset{Name: "main protection", Enforcement: "active"})(w, r) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "repository", "method": "get", "owner": "owner", "repo": "repo", "ruleset_id": float64(42)}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Equal(t, "true", capturedQuery.Get("includes_parents")) + + var returned github.RepositoryRuleset + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + assert.Equal(t, "main protection", returned.Name) + }) + + t.Run("repository level: get forwards explicit includes_parents=false", func(t *testing.T) { + var capturedQuery url.Values + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/{owner}/{repo}/rulesets/{ruleset_id}": func(w http.ResponseWriter, r *http.Request) { + capturedQuery = r.URL.Query() + mockResponse(t, http.StatusOK, &github.RepositoryRuleset{Name: "rs"})(w, r) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "repository", "method": "get", "owner": "owner", "repo": "repo", "ruleset_id": float64(42), "includes_parents": false}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Equal(t, "false", capturedQuery.Get("includes_parents")) + }) + + t.Run("repository level: get requires ruleset_id", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "repository", "method": "get", "owner": "owner", "repo": "repo"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "ruleset_id") + }) + + t.Run("repository level: requires owner and repo", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "repository", "method": "list"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "owner") + }) + + t.Run("repository level: list omits includes_parents when not provided", func(t *testing.T) { + var capturedQuery url.Values + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, r *http.Request) { + capturedQuery = r.URL.Query() + mockResponse(t, http.StatusOK, []*github.RepositoryRuleset{{Name: "rs1"}})(w, r) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "repository", "method": "list", "owner": "owner", "repo": "repo", "perPage": float64(50)}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.False(t, capturedQuery.Has("includes_parents"), "includes_parents must not be sent when omitted") + assert.Equal(t, "50", capturedQuery.Get("per_page")) + + var returned []*github.RepositoryRuleset + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + require.Len(t, returned, 1) + assert.Equal(t, "rs1", returned[0].Name) + }) + + t.Run("repository level: list forwards explicit includes_parents=false", func(t *testing.T) { + var capturedQuery url.Values + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, r *http.Request) { + capturedQuery = r.URL.Query() + mockResponse(t, http.StatusOK, []*github.RepositoryRuleset{})(w, r) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "repository", "method": "list", "owner": "owner", "repo": "repo", "includes_parents": false}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Equal(t, "false", capturedQuery.Get("includes_parents")) + }) + + t.Run("repository level: get_rules_for_branch", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/{owner}/{repo}/rules/branches/{branch}": mockResponse(t, http.StatusOK, []map[string]any{{"type": "creation"}}), + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "repository", "method": "get_rules_for_branch", "owner": "owner", "repo": "repo", "branch": "main"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, "Creation") + }) + + t.Run("repository level: get_rules_for_branch requires branch", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "repository", "method": "get_rules_for_branch", "owner": "owner", "repo": "repo"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "branch") + }) + + t.Run("repository level: list_rule_suites forwards filters", func(t *testing.T) { + var capturedQuery url.Values + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/{owner}/{repo}/rulesets/rule-suites": func(w http.ResponseWriter, r *http.Request) { + capturedQuery = r.URL.Query() + mockResponse(t, http.StatusOK, []map[string]any{{"id": 101, "result": "pass"}})(w, r) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "method": "list_rule_suites", + "owner": "owner", + "repo": "repo", + "ref": "refs/heads/main", + "time_period": "week", + "actor_name": "octocat", + "rule_suite_result": "pass", + "evaluate_status": "evaluate", + "perPage": float64(25), + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, "pass") + + assert.Equal(t, "refs/heads/main", capturedQuery.Get("ref")) + assert.Equal(t, "week", capturedQuery.Get("time_period")) + assert.Equal(t, "octocat", capturedQuery.Get("actor_name")) + assert.Equal(t, "pass", capturedQuery.Get("rule_suite_result")) + assert.Equal(t, "evaluate", capturedQuery.Get("evaluate_status")) + assert.Equal(t, "25", capturedQuery.Get("per_page")) + }) + + t.Run("repository level: get_rule_suite", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/{owner}/{repo}/rulesets/rule-suites/{rule_suite_id}": mockResponse(t, http.StatusOK, map[string]any{"id": 101, "result": "fail"}), + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "repository", "method": "get_rule_suite", "owner": "owner", "repo": "repo", "rule_suite_id": float64(101)}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, "fail") + }) + + t.Run("repository level: unknown method", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "repository", "method": "frobnicate", "owner": "owner", "repo": "repo"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "unknown method") + }) + + t.Run("organization level: get", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/{org}/rulesets/{ruleset_id}": mockResponse(t, http.StatusOK, &github.RepositoryRuleset{Name: "org rs", Enforcement: "active"}), + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "organization", "method": "get", "org": "octo", "ruleset_id": float64(7)}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + var returned github.RepositoryRuleset + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + assert.Equal(t, "org rs", returned.Name) + }) + + t.Run("organization level: get requires ruleset_id", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "organization", "method": "get", "org": "octo"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "ruleset_id") + }) + + t.Run("organization level: requires org", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "organization", "method": "list"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "org") + }) + + t.Run("organization level: list", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/{org}/rulesets": mockResponse(t, http.StatusOK, []*github.RepositoryRuleset{{Name: "org rs"}}), + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "organization", "method": "list", "org": "octo"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + var returned []*github.RepositoryRuleset + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + require.Len(t, returned, 1) + assert.Equal(t, "org rs", returned[0].Name) + }) + + t.Run("organization level: list_rule_suites forwards organization filters", func(t *testing.T) { + var capturedQuery url.Values + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/{org}/rulesets/rule-suites": func(w http.ResponseWriter, r *http.Request) { + capturedQuery = r.URL.Query() + mockResponse(t, http.StatusOK, []map[string]any{{"id": 101, "repository_name": "repo"}})(w, r) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "organization", + "method": "list_rule_suites", + "org": "octo", + "repository_name": "repo", + "evaluate_status": "active", + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Equal(t, "repo", capturedQuery.Get("repository_name")) + assert.Equal(t, "active", capturedQuery.Get("evaluate_status")) + }) + + t.Run("organization level: get_rule_suite", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/{org}/rulesets/rule-suites/{rule_suite_id}": mockResponse(t, http.StatusOK, map[string]any{"id": 101, "result": "pass"}), + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "organization", "method": "get_rule_suite", "org": "octo", "rule_suite_id": float64(101)}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, "pass") + }) + + t.Run("organization level: repository-only method is rejected", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "organization", "method": "get_rules_for_branch", "org": "octo"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "not supported for level \"organization\"") + }) + + t.Run("organization level: unknown method", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "organization", "method": "frobnicate", "org": "octo"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "not supported for level") + }) + + t.Run("enterprise level: get", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /enterprises/{enterprise}/rulesets/{ruleset_id}": mockResponse(t, http.StatusOK, &github.RepositoryRuleset{Name: "enterprise rs", Enforcement: "active"}), + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "enterprise", "method": "get", "enterprise": "acme", "ruleset_id": float64(9)}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + var returned github.RepositoryRuleset + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + assert.Equal(t, "enterprise rs", returned.Name) + }) + + t.Run("enterprise level: requires enterprise", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "enterprise", "method": "list"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "enterprise") + }) + + t.Run("enterprise level: list", func(t *testing.T) { + t.Run("success preserves total count and pagination", func(t *testing.T) { + var capturedQuery url.Values + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /enterprises/{enterprise}/rulesets": func(w http.ResponseWriter, r *http.Request) { + capturedQuery = r.URL.Query() + mockResponse(t, http.StatusOK, map[string]any{ + "total_count": 17, + "rulesets": []*github.RepositoryRuleset{{Name: "enterprise rs"}}, + })(w, r) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "enterprise", + "method": "list", + "enterprise": "acme", + "page": float64(2), + "perPage": float64(1), + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Equal(t, "2", capturedQuery.Get("page")) + assert.Equal(t, "1", capturedQuery.Get("per_page")) + + var returned struct { + TotalCount int `json:"total_count"` + Rulesets []*github.RepositoryRuleset `json:"rulesets"` + } + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + assert.Equal(t, 17, returned.TotalCount) + require.Len(t, returned.Rulesets, 1) + assert.Equal(t, "enterprise rs", returned.Rulesets[0].Name) + }) + + t.Run("empty response preserves object shape", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /enterprises/{enterprise}/rulesets": mockResponse(t, http.StatusOK, map[string]any{ + "total_count": 0, + "rulesets": []*github.RepositoryRuleset{}, + }), + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "enterprise", "method": "list", "enterprise": "acme"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + var returned struct { + TotalCount int `json:"total_count"` + Rulesets []*github.RepositoryRuleset `json:"rulesets"` + } + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + assert.Zero(t, returned.TotalCount) + assert.Empty(t, returned.Rulesets) + }) + + t.Run("API error", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /enterprises/{enterprise}/rulesets": mockResponse(t, http.StatusInternalServerError, map[string]string{"message": "Internal Server Error"}), + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "enterprise", "method": "list", "enterprise": "acme"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "failed to list enterprise repository rulesets") + }) + }) + + t.Run("enterprise level: rule suite method is rejected", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "enterprise", "method": "list_rule_suites", "enterprise": "acme"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "not supported for level \"enterprise\"") + }) + + t.Run("unknown level defers to normal validation", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "planet", "method": "list"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "unknown level") + }) + + t.Run("mismatched-case level is rejected rather than silently normalized", func(t *testing.T) { + called := false + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/{org}/rulesets": func(w http.ResponseWriter, r *http.Request) { + called = true + mockResponse(t, http.StatusOK, []*github.RepositoryRuleset{})(w, r) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "Organization", "method": "list", "org": "octo"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "unknown level") + assert.False(t, called, "a mismatched-case level must not reach the organization-level API call") + + assert.Empty(t, toolDef.ScopeAccess.Challenge(map[string]any{"level": "Organization"}, nil)) + }) +} + +func Test_GetRepositoryRulesForBranchEscapesBranch(t *testing.T) { + tests := []struct { + name string + branch string + escapedPath string + }{ + { + name: "slash", + branch: "release/1.0", + escapedPath: "/repos/owner/repo/rules/branches/release%2F1.0", + }, + { + name: "special characters", + branch: "release/#1%ready", + escapedPath: "/repos/owner/repo/rules/branches/release%2F%231%25ready", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandler(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, tt.escapedPath, r.URL.EscapedPath()) + mockResponse(t, http.StatusOK, []map[string]any{{"type": "creation"}})(w, r) + })) + + result, err := GetRepositoryRulesForBranch(t.Context(), client, "owner", "repo", tt.branch, PaginationParams{}) + require.NoError(t, err) + require.False(t, result.IsError) + }) + } +} + +func Test_CreateRepositoryRuleset(t *testing.T) { + toolDef := CreateRepositoryRuleset(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "create_repository_ruleset", toolDef.Tool.Name) + assert.False(t, toolDef.Tool.Annotations.ReadOnlyHint) + + schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok) + assert.ElementsMatch(t, schema.Required, []string{"level", "name", "enforcement", "rules"}) + require.NotNil(t, schema.AdditionalProperties) + require.NotNil(t, schema.AdditionalProperties.Not) + + propertyNames := make([]string, 0, len(schema.Properties)) + for name := range schema.Properties { + propertyNames = append(propertyNames, name) + } + assert.ElementsMatch(t, []string{ + "level", + "owner", + "repo", + "org", + "enterprise", + "name", + "enforcement", + "target", + "rules", + "conditions", + "bypass_actors", + }, propertyNames) + + resolvedSchema, err := schema.Resolve(nil) + require.NoError(t, err) + bypassActorSchema := schema.Properties["bypass_actors"].Items + require.NotNil(t, bypassActorSchema) + assert.ElementsMatch(t, []string{"actor_type", "bypass_mode"}, bypassActorSchema.Required) + assert.ElementsMatch(t, []any{"always", "pull_request", "exempt"}, bypassActorSchema.Properties["bypass_mode"].Enum) + + validArgs := map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "org": "org", + "enterprise": "enterprise", + "name": "main protection", + "enforcement": "active", + "target": "branch", + "rules": []any{map[string]any{"type": "creation"}}, + "conditions": map[string]any{"ref_name": map[string]any{"include": []any{"refs/heads/main"}}}, + "bypass_actors": []any{ + map[string]any{"actor_type": "OrganizationAdmin", "bypass_mode": "always"}, + }, + } + require.NoError(t, resolvedSchema.Validate(validArgs)) + + t.Run("bypass actor requires an explicit documented mode", func(t *testing.T) { + args := maps.Clone(validArgs) + args["bypass_actors"] = []any{map[string]any{"actor_type": "OrganizationAdmin"}} + require.Error(t, resolvedSchema.Validate(args)) + + args["bypass_actors"] = []any{map[string]any{"actor_type": "OrganizationAdmin", "bypass_mode": "never"}} + require.Error(t, resolvedSchema.Validate(args)) + }) + + for _, test := range []struct { + field string + typo string + }{ + {field: "target", typo: "targte"}, + {field: "conditions", typo: "conditons"}, + } { + t.Run("rejects misspelled "+test.field, func(t *testing.T) { + args := maps.Clone(validArgs) + args[test.typo] = args[test.field] + delete(args, test.field) + require.Error(t, resolvedSchema.Validate(args)) + }) + } + + t.Run("repository level", func(t *testing.T) { + var capturedBody github.RepositoryRuleset + var capturedRaw []byte + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + capturedRaw = body + _ = json.Unmarshal(body, &capturedBody) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(body) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "main protection", + "enforcement": "active", + "target": "branch", + "rules": []any{ + map[string]any{"type": "creation"}, + map[string]any{"type": "deletion"}, + map[string]any{ + "type": "pull_request", + "parameters": map[string]any{ + "required_approving_review_count": float64(2), + }, + }, + }, + "conditions": map[string]any{ + "ref_name": map[string]any{ + "include": []any{"refs/heads/main"}, + "exclude": []any{}, + }, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + assert.Equal(t, "main protection", capturedBody.Name) + assert.Equal(t, github.RulesetEnforcement("active"), capturedBody.Enforcement) + require.NotNil(t, capturedBody.Rules) + + var outbound struct { + Rules []struct { + Type string `json:"type"` + Parameters map[string]any `json:"parameters"` + } `json:"rules"` + Conditions struct { + RefName struct { + Include []string `json:"include"` + } `json:"ref_name"` + } `json:"conditions"` + } + require.NoError(t, json.Unmarshal(capturedRaw, &outbound)) + + sentTypes := make([]string, 0, len(outbound.Rules)) + var pullRequestParams map[string]any + for _, rule := range outbound.Rules { + sentTypes = append(sentTypes, rule.Type) + if rule.Type == "pull_request" { + pullRequestParams = rule.Parameters + } + } + assert.ElementsMatch(t, []string{"creation", "deletion", "pull_request"}, sentTypes) + require.NotNil(t, pullRequestParams) + assert.EqualValues(t, 2, pullRequestParams["required_approving_review_count"]) + assert.Equal(t, []string{"refs/heads/main"}, outbound.Conditions.RefName.Include) + }) + + t.Run("repository level requires owner and repo", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "name": "x", + "enforcement": "active", + "rules": []any{map[string]any{"type": "creation"}}, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "owner") + }) + + t.Run("unsupported rule type", func(t *testing.T) { + called := false + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusCreated) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "x", + "enforcement": "active", + "rules": []any{ + map[string]any{"type": "creation"}, + map[string]any{"type": "totally_made_up_rule"}, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "totally_made_up_rule") + assert.False(t, called, "request must not be sent when a rule type is unsupported") + }) + + t.Run("unrecognized rule key", func(t *testing.T) { + called := false + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusCreated) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "x", + "enforcement": "active", + "rules": []any{ + map[string]any{ + "type": "pull_request", + "configuration": map[string]any{"required_approving_review_count": float64(2)}, + }, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "configuration") + assert.False(t, called) + }) + + t.Run("invalid rules", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "x", + "enforcement": "active", + "rules": "not-an-array", + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "rules parameter must be an array") + }) + + t.Run("duplicate rule type", func(t *testing.T) { + called := false + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusCreated) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "x", + "enforcement": "active", + "rules": []any{ + map[string]any{"type": "creation"}, + map[string]any{"type": "creation"}, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "duplicate rule type") + assert.False(t, called, "request must not be sent when rules contain a duplicate type") + }) + + t.Run("unrecognized rule parameter is rejected even though the rule type is valid", func(t *testing.T) { + called := false + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusCreated) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "x", + "enforcement": "active", + "rules": []any{ + map[string]any{ + "type": "pull_request", + "parameters": map[string]any{ + "require_code_owners_review": true, // typo: should be require_code_owner_review + }, + }, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "require_code_owners_review") + assert.False(t, called, "request must not be sent when a rule parameter is unrecognized") + }) + + t.Run("zero-valued parameters are not flagged as unrecognized", func(t *testing.T) { + var capturedBody []byte + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, r *http.Request) { + capturedBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(capturedBody) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "x", + "enforcement": "active", + "rules": []any{ + map[string]any{ + "type": "pull_request", + "parameters": map[string]any{ + "required_approving_review_count": float64(0), + "allowed_merge_methods": []any{}, + }, + }, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + if result.IsError { + t.Fatalf("unexpected error: %s", getErrorResult(t, result).Text) + } + assert.NotEmpty(t, capturedBody) + }) + + t.Run("unrecognized key inside a rule parameter array element is rejected", func(t *testing.T) { + called := false + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusCreated) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "x", + "enforcement": "active", + "rules": []any{ + map[string]any{ + "type": "required_status_checks", + "parameters": map[string]any{ + "required_status_checks": []any{ + map[string]any{ + "context": "ci", + "integration_ids": float64(42), // typo: should be integration_id + }, + }, + "strict_required_status_checks_policy": true, + }, + }, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "required_status_checks[0].integration_ids") + assert.False(t, called, "request must not be sent when a nested array element key is unrecognized") + }) + + t.Run("valid rule parameter array elements round-trip and are not misflagged", func(t *testing.T) { + var capturedBody []byte + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, r *http.Request) { + capturedBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(capturedBody) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "x", + "enforcement": "active", + "rules": []any{ + map[string]any{ + "type": "required_status_checks", + "parameters": map[string]any{ + "required_status_checks": []any{ + map[string]any{ + "context": "ci", + "integration_id": float64(42), + }, + }, + "strict_required_status_checks_policy": true, + }, + }, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + if result.IsError { + t.Fatalf("unexpected error: %s", getErrorResult(t, result).Text) + } + assert.NotEmpty(t, capturedBody) + }) + + for _, bypassMode := range []string{"always", "pull_request", "exempt"} { + t.Run("bypass_actors serializes "+bypassMode+" exactly", func(t *testing.T) { + var capturedRaw []byte + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, r *http.Request) { + capturedRaw, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(capturedRaw) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "main protection", + "enforcement": "active", + "rules": []any{map[string]any{"type": "creation"}}, + "bypass_actors": []any{ + map[string]any{"actor_type": "OrganizationAdmin", "bypass_mode": bypassMode}, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + var outbound struct { + BypassActors []struct { + BypassMode string `json:"bypass_mode"` + } `json:"bypass_actors"` + } + require.NoError(t, json.Unmarshal(capturedRaw, &outbound)) + require.Len(t, outbound.BypassActors, 1) + assert.Equal(t, bypassMode, outbound.BypassActors[0].BypassMode) + }) + } + + t.Run("bypass_actors without bypass_mode is rejected before request", func(t *testing.T) { + called := false + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusCreated) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "main protection", + "enforcement": "active", + "rules": []any{map[string]any{"type": "creation"}}, + "bypass_actors": []any{ + map[string]any{"actor_type": "OrganizationAdmin"}, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "bypass_actors[0].bypass_mode") + assert.False(t, called) + }) + + t.Run("bypass_actors accepts exempt bypass mode and enterprise actor types", func(t *testing.T) { + var capturedBody github.RepositoryRuleset + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /enterprises/{enterprise}/rulesets": func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &capturedBody) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(body) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "enterprise", + "enterprise": "acme", + "name": "enterprise protection", + "enforcement": "active", + "rules": []any{map[string]any{"type": "creation"}}, + "bypass_actors": []any{ + map[string]any{"actor_type": "EnterpriseOwner", "bypass_mode": "exempt"}, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + require.Len(t, capturedBody.BypassActors, 1) + assert.Equal(t, github.BypassActorType("EnterpriseOwner"), *capturedBody.BypassActors[0].ActorType) + assert.Equal(t, github.BypassMode("exempt"), *capturedBody.BypassActors[0].BypassMode) + }) + + t.Run("unrecognized bypass_actors key is rejected", func(t *testing.T) { + called := false + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusCreated) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "x", + "enforcement": "active", + "rules": []any{map[string]any{"type": "creation"}}, + "bypass_actors": []any{ + map[string]any{"actor_type": "Team", "actor_id": float64(1), "bypass_modes": "pull_request"}, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "bypass_modes") + assert.False(t, called, "request must not be sent when a bypass_actors key is unrecognized") + }) + + t.Run("unrecognized top-level condition key is rejected", func(t *testing.T) { + called := false + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusCreated) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "x", + "enforcement": "active", + "rules": []any{map[string]any{"type": "creation"}}, + "conditions": map[string]any{ + "ref_names": map[string]any{ // typo: should be ref_name + "include": []any{"refs/heads/main"}, + "exclude": []any{}, + }, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "ref_names") + assert.False(t, called, "request must not be sent when a condition key is unrecognized") + }) + + t.Run("unrecognized nested condition key is rejected", func(t *testing.T) { + called := false + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusCreated) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "x", + "enforcement": "active", + "rules": []any{map[string]any{"type": "creation"}}, + "conditions": map[string]any{ + "ref_name": map[string]any{ + "includes": []any{"refs/heads/main"}, // typo: should be include + }, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "ref_name.includes") + assert.False(t, called, "request must not be sent when a nested condition key is unrecognized") + }) + + t.Run("valid conditions round-trip and are not misflagged", func(t *testing.T) { + var capturedBody []byte + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /repos/{owner}/{repo}/rulesets": func(w http.ResponseWriter, r *http.Request) { + capturedBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(capturedBody) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "name": "x", + "enforcement": "active", + "rules": []any{map[string]any{"type": "creation"}}, + "conditions": map[string]any{ + "ref_name": map[string]any{ + "include": []any{"refs/heads/main"}, + "exclude": []any{}, + }, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + if result.IsError { + t.Fatalf("unexpected error: %s", getErrorResult(t, result).Text) + } + assert.NotEmpty(t, capturedBody) + }) + + t.Run("organization level", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /orgs/{org}/rulesets": func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(body) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "organization", + "org": "octo", + "name": "org protection", + "enforcement": "active", + "rules": []any{map[string]any{"type": "creation"}}, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + var returned github.RepositoryRuleset + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + assert.Equal(t, "org protection", returned.Name) + }) + + t.Run("organization level requires org", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "organization", + "name": "org protection", + "enforcement": "active", + "rules": []any{map[string]any{"type": "creation"}}, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "org") + }) + + t.Run("enterprise level", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /enterprises/{enterprise}/rulesets": func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write(body) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "enterprise", + "enterprise": "acme", + "name": "enterprise protection", + "enforcement": "active", + "rules": []any{map[string]any{"type": "creation"}}, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + var returned github.RepositoryRuleset + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + assert.Equal(t, "enterprise protection", returned.Name) + }) + + t.Run("enterprise level requires enterprise", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "enterprise", + "name": "enterprise protection", + "enforcement": "active", + "rules": []any{map[string]any{"type": "creation"}}, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "enterprise") + }) + + t.Run("unknown level", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "planet", + "name": "x", + "enforcement": "active", + "rules": []any{map[string]any{"type": "creation"}}, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "unknown level") + }) + + t.Run("mismatched-case level is rejected rather than silently normalized", func(t *testing.T) { + called := false + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "POST /orgs/{org}/rulesets": func(w http.ResponseWriter, _ *http.Request) { + called = true + w.WriteHeader(http.StatusCreated) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "Organization", + "org": "octo", + "name": "org protection", + "enforcement": "active", + "rules": []any{map[string]any{"type": "creation"}}, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "unknown level") + assert.False(t, called, "a mismatched-case level must not reach the organization-level create call") + + assert.Empty(t, toolDef.ScopeAccess.Challenge(map[string]any{"level": "Organization"}, nil)) + }) +} + +func Test_RulesetScopeChallenges(t *testing.T) { + tests := []struct { + name string + tool inventory.ServerTool + arguments map[string]any + allowed []string + disallowed []string + }{ + { + name: "read repository level", + tool: RepositoryRulesetRead(translations.NullTranslationHelper), + arguments: map[string]any{"level": "repository", "method": "get"}, + allowed: []string{"repo"}, + disallowed: []string{"read:org"}, + }, + { + name: "read organization level", + tool: RepositoryRulesetRead(translations.NullTranslationHelper), + arguments: map[string]any{"level": "organization", "method": "list"}, + allowed: []string{"read:org"}, + disallowed: []string{"repo"}, + }, + { + name: "read enterprise level", + tool: RepositoryRulesetRead(translations.NullTranslationHelper), + arguments: map[string]any{"level": "enterprise", "method": "get"}, + allowed: []string{"read:enterprise"}, + disallowed: []string{"repo", "read:org"}, + }, + { + name: "read missing level defers to validation", + tool: RepositoryRulesetRead(translations.NullTranslationHelper), + arguments: map[string]any{"method": "get"}, + allowed: nil, + disallowed: nil, + }, + { + name: "read unknown level defers to validation", + tool: RepositoryRulesetRead(translations.NullTranslationHelper), + arguments: map[string]any{"level": "planet", "method": "get"}, + allowed: nil, + disallowed: nil, + }, + { + name: "write repository level", + tool: CreateRepositoryRuleset(translations.NullTranslationHelper), + arguments: map[string]any{"level": "repository"}, + allowed: []string{"repo"}, + disallowed: []string{"admin:org"}, + }, + { + name: "write organization level", + tool: CreateRepositoryRuleset(translations.NullTranslationHelper), + arguments: map[string]any{"level": "organization"}, + allowed: []string{"admin:org"}, + disallowed: []string{"repo"}, + }, + { + name: "write enterprise level", + tool: CreateRepositoryRuleset(translations.NullTranslationHelper), + arguments: map[string]any{"level": "enterprise"}, + allowed: []string{"admin:enterprise"}, + disallowed: []string{"repo", "admin:org"}, + }, + { + name: "write missing level defers to validation", + tool: CreateRepositoryRuleset(translations.NullTranslationHelper), + arguments: map[string]any{}, + allowed: nil, + disallowed: nil, + }, + { + name: "write malformed level defers to validation", + tool: CreateRepositoryRuleset(translations.NullTranslationHelper), + arguments: map[string]any{"level": 123}, + allowed: nil, + disallowed: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + require.NotNil(t, tt.tool.ScopeAccess.Challenge) + assert.True(t, tt.tool.ScopeAccess.Dynamic) + assert.Empty(t, tt.tool.ScopeAccess.Challenge(tt.arguments, tt.allowed)) + if tt.disallowed == nil { + assert.Empty(t, tt.tool.ScopeAccess.Challenge(tt.arguments, nil)) + } else { + assert.NotEmpty(t, tt.tool.ScopeAccess.Challenge(tt.arguments, tt.disallowed)) + } + }) + } +} + +func Test_RulesetScopeVisibility(t *testing.T) { + readAccess := RepositoryRulesetRead(translations.NullTranslationHelper).ScopeAccess + writeAccess := CreateRepositoryRuleset(translations.NullTranslationHelper).ScopeAccess + + assert.True(t, readAccess.Visible(nil)) + assert.False(t, writeAccess.Visible(nil)) + assert.True(t, writeAccess.Visible([]string{"repo"})) + assert.True(t, writeAccess.Visible([]string{"admin:org"})) + assert.True(t, writeAccess.Visible([]string{"admin:enterprise"})) + assert.False(t, writeAccess.Visible([]string{"read:org", "read:enterprise"})) +} + +func Test_RulesetScopeMetadataIsExhaustive(t *testing.T) { + tests := []struct { + tool inventory.ServerTool + maxScopes []string + }{ + {tool: RepositoryRulesetRead(translations.NullTranslationHelper), maxScopes: []string{"repo", "read:org", "read:enterprise"}}, + {tool: CreateRepositoryRuleset(translations.NullTranslationHelper), maxScopes: []string{"repo", "admin:org", "admin:enterprise"}}, + } + + for _, tt := range tests { + t.Run(tt.tool.Tool.Name, func(t *testing.T) { + assert.True(t, tt.tool.ScopeAccess.Dynamic) + assert.Equal(t, tt.maxScopes, tt.tool.ScopeAccess.Scopes) + assert.NotNil(t, tt.tool.ScopeAccess.Challenge) + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index ca46deadd2..a6ffbecfee 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -73,6 +73,11 @@ var ( Description: "GitHub Organization related tools", Icon: "organization", } + ToolsetMetadataGovernance = inventory.ToolsetMetadata{ + ID: "governance", + Description: "Repository governance tools for managing rulesets at the repository, organization, and enterprise levels", + Icon: "law", + } ToolsetMetadataActions = inventory.ToolsetMetadata{ ID: "actions", Description: "GitHub Actions workflows and CI/CD operations", @@ -265,6 +270,10 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent // Organization tools SearchOrgs(t), + // Governance tools (rulesets) + RepositoryRulesetRead(t), + CreateRepositoryRuleset(t), + // Pull request tools PullRequestRead(t), ListPullRequests(t), diff --git a/pkg/http/oauth/oauth_test.go b/pkg/http/oauth/oauth_test.go index 39c7e953b4..d2176880de 100644 --- a/pkg/http/oauth/oauth_test.go +++ b/pkg/http/oauth/oauth_test.go @@ -653,6 +653,9 @@ func TestSupportedScopes(t *testing.T) { "repo", "delete_repo", "read:org", + "admin:org", + "read:enterprise", + "admin:enterprise", "read:user", "user:email", "read:packages", diff --git a/pkg/octicons/icons/law-dark.png b/pkg/octicons/icons/law-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..3c7127574873c01e6c225a4743485ad0d63e356c GIT binary patch literal 550 zcmV+>0@?kEP)q*CT@h}L+?9wIQc91Dnzn_<)p-YFUL7AKHl?n2Xi^e5^xB$s?MpO-H4X@MBUeCgFWg4^?R;&QN5~GQ-)jy zb^=>~Rp4DpX(hLM6CkBj&*evA@*40lrSzfC*1gf~77apaFy-&i^F2^c1KV@y{Cm*G oQcCH5A?NrbhsDc6;{OzW0sdf(YKjW*u>b%707*qoM6N<$f`5AW&;S4c literal 0 HcmV?d00001 diff --git a/pkg/octicons/icons/law-light.png b/pkg/octicons/icons/law-light.png new file mode 100644 index 0000000000000000000000000000000000000000..3121c5b6547e0440a7eb2fe8fa3ed132600e174c GIT binary patch literal 841 zcmV-P1GfB$P)Th+xnZTCgp>w4w(oJxDLv`~lW%ch`*TtS+rWS}^$AY@C^= z2a|0Qvt84k#P4Z%-sk)MJTtsJ122`Fck~_L?t|eqei@8P3{T`Pg;c+cg1BZ1n~t`~{ddT5R`{u~#endV8A~SsBsOj|F%Y!yR#2U|40{aLMMc55o6Zl?& z@O-hDb3OOj$ugOY4;*b>`Q`q0-0Wc$Zcg}@1r_^L zv6!1Bdb+OVe;w8*4Sm2;o+kd`x!J>oLSeKD0EkAT4FVqld0>&5=kKW(UX?xeKF|o{ zQT9fo(T2&RD!`uKxD!}{N;l{r!oGAm{T$c0%R=}B$Y{tuV2M4yamOGH9G(df%GzQfeb)feD&LkCRF7FprzrVpFm2XNKZY0 zgwg}t@UFD}3?M3fz{=+K#HK1BAnZl?D;T?TcET3|rv=uxbtKo+3U7(6dP`xQpnY&I z08szI55R~~=>;&&@l>n8R-nIJE{{!`t?=;~;BFYwU2}TOr@;N7^2&FUHP7=Zhph{_u@_{?Dm_Q&+{*dLofn`0xo$qSm6&GPkZ5F8yBft^Z9O!@7M)$P;!9R7m?p+x=H(O5aRiOGAkeXhrJ{_CUdl{G&@Z#%lQ6L*- T*E9Y<00000NkvXXu0mjfWvYxK literal 0 HcmV?d00001 diff --git a/pkg/octicons/icons_data_uris.txt b/pkg/octicons/icons_data_uris.txt index 1083af68b8..b5899f7589 100644 --- a/pkg/octicons/icons_data_uris.txt +++ b/pkg/octicons/icons_data_uris.txt @@ -30,6 +30,8 @@ git-pull-request-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYA git-pull-request-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC issue-opened-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII= issue-opened-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC +law-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAB20lEQVRIieWVz4uNYRTHP8+9t9ya7qiZNUWpmQ0LkaLGRcOOQpaUWbBipxE3439gacNiyg4LG12xRoNSmqZJidkxKTPl+lg4V++89+eblXxX7znne77fc56etwf+K6i31VtFeioFPSYL8gcbqMeAXRFujdyViBdSSo+LmraFx9V5B2NeHR8kNqWud2leU2fVSvCaajO+K1Fb69K3rk5lDcbUhnonCO/Va+pEbpA/BpncpHo9egyNhjrWbZObQVpVN3epdxhEflT9Gr1z2VopQ6oCF4C3QA043/dcN2IGGI3ei6HVMcVMTHBQfaout8++3wZqWV2KWj00OodTF9TXalJPBPHkEAangns84lfqGzVlSUeCdDbikrqoPhvC4HlsUI74XGgdzpIeqSvZs1MvB3FvLwN1d3AuZXKb1E/qw3Zih9pSG7nJauoX9W4fg3vdbpx6Q/2pTlSAaX7fpv3qGWAZGAEOAC3gKL0xDfwAZtUXwDdgG1AHEnAItarOqZ/diJb6RN3XZ4M96gP1e653Ub2qVlOGXAZ2AluAVeBdSmklt3oTIKVUz+VHYvIa8DGl9KHP1r3R7RYNQmkw5e/Q8z1QTwPbc+n8e9DGUkrpfiHn+COHxctC4v8UfgH+YI1qigrwsQAAAABJRU5ErkJggg== +law-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC/klEQVRIieWVz2sUZxjHP887UzcqRmixelC6SSZhN9HdtQUvKq5SouKhFBo8qAextJU2Jj14sCKlgiiIYNAUWrAtvbSiByk9SC/Z/AHWbHfXjOusLq1CWjD422ycmacHk20Ss13TnsTvaXje5/v9PDO8PAMvlZx46ksnnuifi8eeG0LjIHNy1O1u6UhskVCSE90fPOXwNYAazZYKv138T4BYLPaaL5F+YHudGc7aWvnYdd3bNQFObPUGJPwFmDfjvILqF8uXvXo8k8n4Tjw5AOANZzem02n75sjofkQ+ByIzfOOo6fTcy4M2QMQaz1UC+wgQRdgNXBP4XoLwfLGYcz332ckymYwPHG1d+eYFguA9hV1AK8q3QDlijecADEChUBj13OxhFW5O+JcFFftUsZibJXq6ruV/HdYnDX3AUp4m/uG52cOFQmG0CgCIRqMNAh8BeWCRmefvqRdelT32PtAI5FH2RqPRhsmjKsCe37gTWIJKN+ggwr50Ov0c17jLQvgEyGjIPmCJPb9xxzMAwXQDOc8dGlTMSeCNWyOj79SLdzquvgs0IXKydDU7AAyB9DJxgQxAW3z124omVOUEoKXhoZ9ASqFITz2AhNID3PCutP4MICp9wMrm9sSmKiAg7EX5Kxi7c3bCFwKnBda3dSTX1Apvia16S2GdoH1wLgBQ/8EPwIhR6QUwzfFUq8BWoL9cLo9Nmq3w8RngbhjSXXN6zKfA/aDyyneTNc/zKoh8BWxra1sVsw10AgZhbUs8ud2IlkNhYRDIOiAANtf+PnQCvon4B5z21CUleCBqNaFsBCS0rE22//jOGXvB4tdRPhT4UVUQBYQQJYPowVr5oZFtRvUQSg+qDYIBFJASogf9R3e/mbKLuiynvZhAdAWBuRfY/vCNfP7PqYFTV8XUeiKRWPjwCU2i1qLQMreuFy79XvOt/01OPDkwCXlemfot/08117XTnupCtXlG97T/wT91ue5dGTo3W07tVaD6GZCaXqs+HZvRexmYFfDi629RIBtl1zP+PwAAAABJRU5ErkJggg== logo-gist-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACYAAAAYCAYAAACWTY9zAAAABmJLR0QA/wD/AP+gvaeTAAACoElEQVRIie2WX2hXZRjHP8/4hW5UzoGFja2wdiF00S7EotAgIcKbMBoIu7HuuhANC3aRCTIQgmBBsEZ3s8hoIwomrLuB4E0YhqWFrjHY0NY2rGHh6uPFecXj2flt+/2mXoRfOHDO+z5/Ps973vO8B+7r/yi1U+1XL6gL6j/qZfWU+r7aca9YIgf1LtAL/AuMAOeA68AjQCfwLDAeEU8l+wpwCDgfEV/fFTq120zfq49XsWlVu3PPu5LPX2vI+4L6RrXJJnVanVEfqyHow+rnas8awH5Vx6pN7k2Vv1dvgnq1HFgD8Eq6//IOJ21QN6rr6w1wRp2t0/c3tb8w1qmeVK95S3+oJ9K2aVF7037+L9ldzF1dABWgHRivqypoBppyUB3AGHAFOAZMAY3Ao8DzwDqytzQBfAIcBa4CH+Rinr0J9mCaLFuRfcADheHvIqJaIftSzOci4soyBQ2k+O8A0xExUDSokPUyqwToAx4qjL1O9RVuA2ZWgFqVGoAFslUrUzvQkq7XVhHvZ6BVfUttWtF6BbBJ4ImyyYiYj4i5iJgDVtNI+4BvgI+BP9Vx9Vv1iNpWK9hPwCa1FK4WRcRCRLxKVmgXMEh2xPUAP6pbawEbTfd71gqWA5yIiKGIOJxAdwIbgDdrARsG5oC31eY7BVcAPQ38DZQdeQ2lYBExDxwBWoGv1JYaci6S+0OpJnUHsB74pTA1C2xRiy2JCkBEfJT+Kg4CF9QTZHtvkeyL3Qy8XJLzErBdfSYiflD3A9sSwO9kPfBpoBuYAT4t+H8BfAgMqceB+YgYpSj1JXVYnfd2Takj6oH861ZfVCfVwfS8Wx1Tr+Z8Z9XP1CdL8kWKOZ5sZ0qKX+LUlA7hyipslxzUyb/YnJeL0ahW66f3taJuAAWd129KkzycAAAAAElFTkSuQmCC logo-gist-light data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAACYAAAAYCAYAAACWTY9zAAAABmJLR0QA/wD/AP+gvaeTAAAD2ElEQVRIie2WS2xUVRjHf9/tHSgjRCA+C5am3gFmhnIHJypEY4aVujAYfCUGF8SFRhONgiRsiInxFRIiGhcSxQVEhYhhRQyycuMjUWaG0mnLLQ9BUORRiK20nTl/F1DSDJe2VOLC+F/dc873/c/vfDfnAf/rPyIb2Qgy4SI5njNjKTAb8BG9GBHSbmfeZwcrxQP/KlgqnVsj9CZQM9jlYL+JIYxbgEXAYrBDUaUYABQKBf/Yb2dXy6yzp1Lceb3BfIAgE66Q9C7wcw1v+aHK3iP1gXPnhrOcr6XD7eO/nytgvG2oD5g6kclbs+H9npgbdZQ2XwHWlM8n6a+uB51ucA2PRF17j8eZdHeXfgW2Drfd0KQflRj43OT2TQQKwHN8KjgBXAl2Q9/QMpndZti6rqtAxSmKfjgPPD1RqLHkyexhAM9p+/X2bm5rm9HS0tI4kWQL0uFeYE5UKc281uQgHR6W+Lqns/T85b5MuAjHWxgFYBjqDLCnP+mvnHHhQuNALbEK00Nc3FQDGCP/1Nqoo7TdB5qBQxNZFTDdjORwozWdSyF9i8dJnN4R3nHPc1PkvFsxd58/ODh5aKjRk1c7guwjQ29gnJez9cMeDa5Whou7cipwPm7WVCZc6ZwlRvb5ct90dZVjF+IZKxG+fC3pKZdPjrKgTQBBOnxNcKKns7ipPsAHDENx2RIbzTRtZF+tgSe4WoWlOxCnxoAalzygz1z8OVSdbM2Dvps56LuZJh4b081UwZgVpHMvNOXzyTHjR5EPHJXREjd4uFjsHf4O5od/jmWW9NnYP6R7gA+T/dUP7kyHvxi0Y/aTvOonPe3tR8cL5gEdwM3z5+di4a5F5XK5L6qUH63htZj0JLAFVENaa7WGfakFd6XH6+Vj7EY8VUXLgQ3/FA7g0pV2BNgBEKQXLgb7Tq72LLB6PB5edZJ9BZzFeLUll5t+PcDqFVXK3wMXJGuqHzPDiwU7XCz2mul1YFZiQF9ms9lrOGhVpe7pFKdUuu0BoNGM7rqhMxKt+Xw+UZ/jAxzoKL8fZMI5Eq8MOL8rSIfbwDokqoamGna7TA/WJ5vsoIx7U9lc7sD+YjGVWfiSZHcbdGP84SDhwQKJFcApBt3HdRZfGGw491d1R5AJt+LojTpLuy+DAUQdpVWtmYW7GmQvClaAbrRLtXDohGFFYHM1YXsu1wtbg7RFTquAZ5DXI/Q4sAwxzQDBWUw78dy6qNJ+bCRVVCm9F6RzkvSywTZMp4GbRi1/Uz6fbG5rm1EoFPxRA4G4i7opn0/OmzdvWlx8nGbPXjIlm81O6F33vwD+Bhvyhr7wtSBQAAAAAElFTkSuQmCC mark-github-dark data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAB8ElEQVRIibWVu09UQRSHv9k1IgW7AY0RBWKsTGx9ND4qS5F/wEYLDb0xAY0UxkdrZ2NHYWfsaYyVT4iJwZpNjJooLBQEYz6LvZsdxmH3LtFfN3PO+X5zzzwu/GeFbkF1BJgCJoHjwFgRagDLwAvgeQjhR1+u6qA6q67ZW6vqjDpYFn5YfV0CnOqDOtELPqY2dgFvq6Ee6daWd1HyvPqyBPSV+jQav1H3tbkhMpgF7hXDLaAeQthUzwFXgE/AF0BgFDgBPAshLKhV4CcwVNTPhBAexKsfcfuGbqhdT1imA1+j+lV1GKBSxKeAWpRfTca94HuB+BTVgcuxwWRS8zCEsFbWIISwBcwl0x2m+jnZuKNl4RHjQMJYjoPNJFju0vxt8itiNKHTomqSO7wLeA3YE01VYoPvSf7Jfg2AU8n4W2zwPglO78Igrekw1enMDb1fXKCuUivq7Uz99Tiprq6rvwuzhSLpo3pLvZABn1Vv2nrkUjWLPdlWMFcEF9WD6tuo4EnG4HEG3Nad3KcOqEtRe/bb+ic8Uo9l8i/tAF9UB3bq54StJ3dTvaGOqofM3IuiRalW1PH8bnUKx4tVxLqYyTuf5Czl4JV0IoSwApwB7gLr7enMWtpzG7TeodNFbXmpNfWq6YloxYbUa2q9L+i/1h8/EAGdUrF9ZQAAAABJRU5ErkJggg== diff --git a/pkg/octicons/required_icons.txt b/pkg/octicons/required_icons.txt index 15dc444956..ebc5d99048 100644 --- a/pkg/octicons/required_icons.txt +++ b/pkg/octicons/required_icons.txt @@ -29,6 +29,7 @@ git-commit git-merge git-pull-request issue-opened +law logo-gist mark-github organization diff --git a/pkg/scopes/scopes.go b/pkg/scopes/scopes.go index d845cc6dc8..2294ac01ef 100644 --- a/pkg/scopes/scopes.go +++ b/pkg/scopes/scopes.go @@ -31,6 +31,14 @@ const ( // AdminOrg grants full control of organizations and teams AdminOrg Scope = "admin:org" + // ReadEnterprise grants read-only access to enterprise profile data, including + // enterprise-level custom properties + ReadEnterprise Scope = "read:enterprise" + + // AdminEnterprise grants full control of enterprises, including enterprise-level + // rulesets and custom properties + AdminEnterprise Scope = "admin:enterprise" + // Gist grants write access to gists Gist Scope = "gist" @@ -77,6 +85,9 @@ var oauthScopeDefinitions = []oauthScopeDefinition{ {scope: Repo, byDefault: true}, {scope: DeleteRepo}, {scope: ReadOrg, byDefault: true}, + {scope: AdminOrg}, + {scope: ReadEnterprise}, + {scope: AdminEnterprise}, {scope: ReadUser, byDefault: true}, {scope: UserEmail, byDefault: true}, {scope: ReadPackages, byDefault: true}, @@ -113,12 +124,13 @@ func oauthScopes(defaultOnly bool) []string { // A parent scope implicitly grants access to all child scopes. // For example, "repo" grants access to "public_repo" and "security_events". var ScopeHierarchy = map[Scope][]Scope{ - Repo: {PublicRepo, SecurityEvents}, - AdminOrg: {WriteOrg, ReadOrg}, - WriteOrg: {ReadOrg}, - Project: {ReadProject}, - WritePackages: {ReadPackages}, - User: {ReadUser, UserEmail}, + Repo: {PublicRepo, SecurityEvents}, + AdminOrg: {WriteOrg, ReadOrg}, + AdminEnterprise: {ReadEnterprise}, + WriteOrg: {ReadOrg}, + Project: {ReadProject}, + WritePackages: {ReadPackages}, + User: {ReadUser, UserEmail}, } // RequireAll creates scope checks for a tool that always needs the given scopes. diff --git a/pkg/scopes/scopes_test.go b/pkg/scopes/scopes_test.go index 62bfc6178e..e9deda9953 100644 --- a/pkg/scopes/scopes_test.go +++ b/pkg/scopes/scopes_test.go @@ -17,6 +17,12 @@ func TestOAuthScopeCatalog(t *testing.T) { assert.NotContains(t, defaults, string(Workflow)) assert.Contains(t, supported, string(Codespace)) assert.NotContains(t, defaults, string(Codespace)) + assert.Contains(t, supported, string(AdminOrg)) + assert.NotContains(t, defaults, string(AdminOrg)) + assert.Contains(t, supported, string(ReadEnterprise)) + assert.NotContains(t, defaults, string(ReadEnterprise)) + assert.Contains(t, supported, string(AdminEnterprise)) + assert.NotContains(t, defaults, string(AdminEnterprise)) } func TestScopeChecks(t *testing.T) { @@ -25,8 +31,12 @@ func TestScopeChecks(t *testing.T) { assert.False(t, HasAll([]string{"repo"}, Repo, Workflow)) assert.True(t, HasAll([]string{"admin:org"}, ReadOrg)) assert.True(t, HasAllScopeNames([]string{"admin:org"}, []string{"read:org"})) + assert.True(t, HasAll([]string{"admin:enterprise"}, ReadEnterprise)) + assert.True(t, HasAllScopeNames([]string{"admin:enterprise"}, []string{"read:enterprise"})) + assert.False(t, HasAll([]string{"read:enterprise"}, AdminEnterprise)) assert.False(t, HasAllScopeNames([]string{"repo"}, []string{"repo", "workflow"})) assert.Nil(t, ChallengeAll([]string{"repo", "workflow"}, Repo, Workflow)) + assert.Nil(t, ChallengeAll([]string{"admin:enterprise"}, ReadEnterprise)) assert.Equal(t, []string{"repo", "workflow"}, ChallengeAll([]string{"repo"}, Repo, Workflow)) } @@ -53,6 +63,7 @@ func TestScopeHierarchy(t *testing.T) { assert.Contains(t, ScopeHierarchy[Repo], SecurityEvents) assert.Contains(t, ScopeHierarchy[AdminOrg], WriteOrg) assert.Contains(t, ScopeHierarchy[AdminOrg], ReadOrg) + assert.Contains(t, ScopeHierarchy[AdminEnterprise], ReadEnterprise) assert.Contains(t, ScopeHierarchy[WriteOrg], ReadOrg) assert.Contains(t, ScopeHierarchy[Project], ReadProject) assert.Contains(t, ScopeHierarchy[WritePackages], ReadPackages) From c30039fedc8d6c4b23a306050a1d1a5c77d76e2c Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 3 Sep 2026 15:13:37 +0200 Subject: [PATCH 15/61] feat(governance): add custom properties tools (#2992) * feat(governance): add custom properties tools with multi-level scope challenge Add custom properties support to the non-default governance toolset, completing the second half of the rulesets + custom properties work requested in #820. Rather than porting the original six single-level tools verbatim, this consolidates them into two level-parameterized tools: - custom_properties_read (level: repository | organization | enterprise) - custom_properties_write (level: repository | organization | enterprise) The `level` argument dispatches to the correct GitHub API. Repository level reads and writes property VALUES (property_name + value), while organization and enterprise levels read and write property DEFINITIONS/schema (value_type, required, allowed_values, default_value, description, values_editable_by). This distinction is documented in the tool and field descriptions. Both tools reuse the shared governanceReadScopeAccess/governanceWriteScopeAccess helpers (renamed from the ruleset-specific names) so rulesets and custom properties present one consistent, exhaustive scope-challenge policy that up-scopes based on the requested level. Co-authored-by: Patrick Knight Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e886867-a922-419a-b02c-ac643716aea8 * fix(governance): validate custom property values Co-authored-by: Patrick Knight Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e886867-a922-419a-b02c-ac643716aea8 * fix(governance): reject unknown custom property fields Co-authored-by: Patrick Knight Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e886867-a922-419a-b02c-ac643716aea8 * fix(governance): validate custom properties by level Co-authored-by: Patrick Knight Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e886867-a922-419a-b02c-ac643716aea8 * fix(governance): preserve custom property item type Co-authored-by: Patrick Knight Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e886867-a922-419a-b02c-ac643716aea8 * fix(governance): preserve partial property updates Co-authored-by: Patrick Knight Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e886867-a922-419a-b02c-ac643716aea8 * chore: regenerate license files Auto-generated by license-check workflow * chore: rerun checks after license regeneration Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1e886867-a922-419a-b02c-ac643716aea8 --------- Co-authored-by: Patrick Knight Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] Copilot-Session: 1e886867-a922-419a-b02c-ac643716aea8 --- README.md | 25 +- docs/remote-server.md | 2 +- .../__toolsnaps__/custom_properties_read.snap | 42 ++ .../custom_properties_write.snap | 174 ++++++ pkg/github/custom_properties.go | 492 ++++++++++++++++ pkg/github/custom_properties_test.go | 550 ++++++++++++++++++ pkg/github/tools.go | 6 +- third-party-licenses.darwin.md | 12 +- third-party-licenses.linux.md | 98 +++- third-party-licenses.windows.md | 92 ++- 10 files changed, 1473 insertions(+), 20 deletions(-) create mode 100644 pkg/github/__toolsnaps__/custom_properties_read.snap create mode 100644 pkg/github/__toolsnaps__/custom_properties_write.snap create mode 100644 pkg/github/custom_properties.go create mode 100644 pkg/github/custom_properties_test.go diff --git a/README.md b/README.md index 9d32100497..5b90f64a58 100644 --- a/README.md +++ b/README.md @@ -586,7 +586,7 @@ The following sets of tools are available: | comment-discussion | `discussions` | GitHub Discussions related tools | | logo-gist | `gists` | GitHub Gist related tools | | git-branch | `git` | GitHub Git API related tools for low-level Git operations | -| law | `governance` | Repository governance tools for managing rulesets at the repository, organization, and enterprise levels | +| law | `governance` | Repository governance tools for managing rulesets and custom properties at the repository, organization, and enterprise levels | | issue-opened | `issues` | GitHub Issues related tools | | tag | `labels` | GitHub Labels related tools | | bell | `notifications` | GitHub Notifications related tools | @@ -892,6 +892,29 @@ The following sets of tools are available: - `rules`: An array of rules within the ruleset. Each rule is an object with a 'type' (e.g. 'creation', 'deletion', 'non_fast_forward', 'required_signatures', 'pull_request', 'required_status_checks') and, for rules that need configuration, a 'parameters' object (object[], required) - `target`: The target of the ruleset. Defaults to 'branch'. 'repository' is only valid for 'organization' and 'enterprise' level rulesets. (string, optional) +- **custom_properties_read** - Read custom properties + - **OAuth Challenge Scopes**: `repo`, `read:org`, `read:enterprise` + - `enterprise`: Enterprise slug. Required when level is 'enterprise'. (string, optional) + - `level`: The level at which custom properties are managed: + - 'repository': The custom property VALUES assigned to a repository (requires 'owner' and 'repo'). + - 'organization': The custom property DEFINITIONS (schema) for an organization (requires 'org'). + - 'enterprise': The custom property DEFINITIONS (schema) for an enterprise (requires 'enterprise'). (string, required) + - `org`: Organization name. Required when level is 'organization'. (string, optional) + - `owner`: Repository owner. Required when level is 'repository'. (string, optional) + - `repo`: Repository name. Required when level is 'repository'. (string, optional) + +- **custom_properties_write** - Set custom properties + - **OAuth Challenge Scopes**: `repo`, `admin:org`, `admin:enterprise` + - `enterprise`: Enterprise slug. Required when level is 'enterprise'. (string, optional) + - `level`: The level at which custom properties are managed: + - 'repository': The custom property VALUES assigned to a repository (requires 'owner' and 'repo'). + - 'organization': The custom property DEFINITIONS (schema) for an organization (requires 'org'). + - 'enterprise': The custom property DEFINITIONS (schema) for an enterprise (requires 'enterprise'). (string, required) + - `org`: Organization name. Required when level is 'organization'. (string, optional) + - `owner`: Repository owner. Required when level is 'repository'. (string, optional) + - `properties`: The custom properties to create or update. At the repository level each item assigns a value ('property_name' and 'value'); at the organization and enterprise levels each item defines the schema ('property_name' and 'value_type', plus optional definition fields). (object[], required) + - `repo`: Repository name. Required when level is 'repository'. (string, optional) + - **repository_ruleset_read** - Read repository rulesets - **OAuth Challenge Scopes**: `repo`, `read:org`, `read:enterprise` - `actor_name`: The handle for the GitHub user account to filter rule suites on. Used by the 'list_rule_suites' method. (string, optional) diff --git a/docs/remote-server.md b/docs/remote-server.md index 78b5c906ac..be91009148 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -30,7 +30,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | comment-discussion
`discussions` | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) | | logo-gist
`gists` | GitHub Gist related tools | https://api.githubcopilot.com/mcp/x/gists | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/gists/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%2Freadonly%22%7D) | | git-branch
`git` | GitHub Git API related tools for low-level Git operations | https://api.githubcopilot.com/mcp/x/git | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/git/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%2Freadonly%22%7D) | -| law
`governance` | Repository governance tools for managing rulesets at the repository, organization, and enterprise levels | https://api.githubcopilot.com/mcp/x/governance | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-governance&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgovernance%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/governance/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-governance&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgovernance%2Freadonly%22%7D) | +| law
`governance` | Repository governance tools for managing rulesets and custom properties at the repository, organization, and enterprise levels | https://api.githubcopilot.com/mcp/x/governance | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-governance&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgovernance%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/governance/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-governance&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgovernance%2Freadonly%22%7D) | | issue-opened
`issues` | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) | | tag
`labels` | GitHub Labels related tools | https://api.githubcopilot.com/mcp/x/labels | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/labels/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-labels&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Flabels%2Freadonly%22%7D) | | bell
`notifications` | GitHub Notifications related tools | https://api.githubcopilot.com/mcp/x/notifications | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/notifications/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-notifications&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fnotifications%2Freadonly%22%7D) | diff --git a/pkg/github/__toolsnaps__/custom_properties_read.snap b/pkg/github/__toolsnaps__/custom_properties_read.snap new file mode 100644 index 0000000000..9a7ff10e9b --- /dev/null +++ b/pkg/github/__toolsnaps__/custom_properties_read.snap @@ -0,0 +1,42 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "Read custom properties" + }, + "description": "Read custom properties at the repository, organization, or enterprise level. At the repository level this returns the property values assigned to a repository; at the organization and enterprise levels it returns the property definitions (schema). Select the level with the 'level' parameter.", + "inputSchema": { + "properties": { + "enterprise": { + "description": "Enterprise slug. Required when level is 'enterprise'.", + "type": "string" + }, + "level": { + "description": "The level at which custom properties are managed:\n- 'repository': The custom property VALUES assigned to a repository (requires 'owner' and 'repo').\n- 'organization': The custom property DEFINITIONS (schema) for an organization (requires 'org').\n- 'enterprise': The custom property DEFINITIONS (schema) for an enterprise (requires 'enterprise').", + "enum": [ + "repository", + "organization", + "enterprise" + ], + "type": "string" + }, + "org": { + "description": "Organization name. Required when level is 'organization'.", + "type": "string" + }, + "owner": { + "description": "Repository owner. Required when level is 'repository'.", + "type": "string" + }, + "repo": { + "description": "Repository name. Required when level is 'repository'.", + "type": "string" + } + }, + "required": [ + "level" + ], + "type": "object" + }, + "name": "custom_properties_read" +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/custom_properties_write.snap b/pkg/github/__toolsnaps__/custom_properties_write.snap new file mode 100644 index 0000000000..9c9df1f6ca --- /dev/null +++ b/pkg/github/__toolsnaps__/custom_properties_write.snap @@ -0,0 +1,174 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": false, + "title": "Set custom properties" + }, + "description": "Create or update custom properties at the repository, organization, or enterprise level. At the repository level this sets the property values on a repository (the properties must already be defined for the organization). Organization and enterprise definition writes preserve omitted writable fields by reading current definitions immediately before updating; concurrent definition updates remain last-write-wins. Select the level with the 'level' parameter.", + "inputSchema": { + "properties": { + "enterprise": { + "description": "Enterprise slug. Required when level is 'enterprise'.", + "type": "string" + }, + "level": { + "description": "The level at which custom properties are managed:\n- 'repository': The custom property VALUES assigned to a repository (requires 'owner' and 'repo').\n- 'organization': The custom property DEFINITIONS (schema) for an organization (requires 'org').\n- 'enterprise': The custom property DEFINITIONS (schema) for an enterprise (requires 'enterprise').", + "enum": [ + "repository", + "organization", + "enterprise" + ], + "type": "string" + }, + "org": { + "description": "Organization name. Required when level is 'organization'.", + "type": "string" + }, + "owner": { + "description": "Repository owner. Required when level is 'repository'.", + "type": "string" + }, + "properties": { + "description": "The custom properties to create or update. At the repository level each item assigns a value ('property_name' and 'value'); at the organization and enterprise levels each item defines the schema ('property_name' and 'value_type', plus optional definition fields).", + "items": { + "oneOf": [ + { + "additionalProperties": false, + "description": "A repository-level custom property value.", + "properties": { + "property_name": { + "description": "The name of the custom property.", + "type": "string" + }, + "value": { + "description": "Repository level only: the value to assign. A string, an array of strings, or null to clear the value.", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "property_name", + "value" + ], + "type": "object" + }, + { + "additionalProperties": false, + "description": "An organization- or enterprise-level custom property definition.", + "properties": { + "allowed_values": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "description": "The ordered list of allowed values for single_select and multi_select properties. Omit when updating to preserve the current list; use null or an empty array to clear it." + }, + "default_value": { + "description": "The value applied when a repository does not set the property. Omit when updating to preserve the current value; use null to clear it.", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "A short description of the property. Omit when updating to preserve the current description; use null to clear it." + }, + "property_name": { + "description": "The name of the custom property.", + "type": "string" + }, + "require_explicit_values": { + "description": "Whether repositories must explicitly set a value for the property. Omit when updating to preserve the current setting.", + "type": "boolean" + }, + "required": { + "description": "Whether the property must be set on every repository. Omit when updating to preserve the current setting.", + "type": "boolean" + }, + "value_type": { + "description": "The data type of the property. Required for new definitions; omit when updating to preserve the current type.", + "enum": [ + "string", + "single_select", + "multi_select", + "true_false", + "url" + ], + "type": "string" + }, + "values_editable_by": { + "anyOf": [ + { + "enum": [ + "org_actors", + "org_and_repo_actors" + ], + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Who can edit the values of the property. Omit when updating to preserve the current setting; use null to restore the default." + } + }, + "required": [ + "property_name" + ], + "type": "object" + } + ], + "type": "object" + }, + "type": "array" + }, + "repo": { + "description": "Repository name. Required when level is 'repository'.", + "type": "string" + } + }, + "required": [ + "level", + "properties" + ], + "type": "object" + }, + "name": "custom_properties_write" +} \ No newline at end of file diff --git a/pkg/github/custom_properties.go b/pkg/github/custom_properties.go new file mode 100644 index 0000000000..0e71aa8ecc --- /dev/null +++ b/pkg/github/custom_properties.go @@ -0,0 +1,492 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + + ghErrors "github.com/github/github-mcp-server/pkg/errors" + "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/go-github/v89/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const customPropertiesLevelDescription = "The level at which custom properties are managed:\n" + + "- 'repository': The custom property VALUES assigned to a repository (requires 'owner' and 'repo').\n" + + "- 'organization': The custom property DEFINITIONS (schema) for an organization (requires 'org').\n" + + "- 'enterprise': The custom property DEFINITIONS (schema) for an enterprise (requires 'enterprise')." + +// CustomPropertiesRead creates the custom properties read tool. +func CustomPropertiesRead(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataGovernance, + mcp.Tool{ + Name: "custom_properties_read", + Description: t("TOOL_CUSTOM_PROPERTIES_READ_DESCRIPTION", "Read custom properties at the repository, organization, or enterprise level. At the repository level this returns the property values assigned to a repository; at the organization and enterprise levels it returns the property definitions (schema). Select the level with the 'level' parameter."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_CUSTOM_PROPERTIES_READ_USER_TITLE", "Read custom properties"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "level": { + Type: "string", + Enum: []any{"repository", "organization", "enterprise"}, + Description: customPropertiesLevelDescription, + }, + "owner": { + Type: "string", + Description: "Repository owner. Required when level is 'repository'.", + }, + "repo": { + Type: "string", + Description: "Repository name. Required when level is 'repository'.", + }, + "org": { + Type: "string", + Description: "Organization name. Required when level is 'organization'.", + }, + "enterprise": { + Type: "string", + Description: "Enterprise slug. Required when level is 'enterprise'.", + }, + }, + Required: []string{"level"}, + }, + }, + rulesetReadScopeAccess(), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + level, err := RequiredParam[string](args, "level") + 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) + } + + switch level { + case "repository": + return customPropertiesReadRepository(ctx, client, args) + case "organization": + return customPropertiesReadOrganization(ctx, client, args) + case "enterprise": + return customPropertiesReadEnterprise(ctx, client, args) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown level: %q (expected 'repository', 'organization', or 'enterprise')", level)), nil, nil + } + }, + ) +} + +func customPropertiesReadRepository(ctx context.Context, client *github.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + properties, resp, err := client.Repositories.GetAllCustomPropertyValues(ctx, owner, repo) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get repository custom property values", resp, err), nil, nil + } + + return MarshalledTextResult(properties), nil, nil +} + +func customPropertiesReadOrganization(ctx context.Context, client *github.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + org, err := RequiredParam[string](args, "org") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + properties, errResult := getCustomPropertyDefinitions(ctx, client, fmt.Sprintf("orgs/%s/properties/schema", url.PathEscape(org)), "failed to get organization custom properties") + if errResult != nil { + return errResult, nil, nil + } + + return MarshalledTextResult(properties), nil, nil +} + +func customPropertiesReadEnterprise(ctx context.Context, client *github.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + enterprise, err := RequiredParam[string](args, "enterprise") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + properties, errResult := getCustomPropertyDefinitions(ctx, client, fmt.Sprintf("enterprises/%s/properties/schema", url.PathEscape(enterprise)), "failed to get enterprise custom properties") + if errResult != nil { + return errResult, nil, nil + } + + return MarshalledTextResult(properties), nil, nil +} + +// CustomPropertiesWrite creates the custom properties write tool. +func CustomPropertiesWrite(t translations.TranslationHelperFunc) inventory.ServerTool { + return NewTool( + ToolsetMetadataGovernance, + mcp.Tool{ + Name: "custom_properties_write", + Description: t("TOOL_CUSTOM_PROPERTIES_WRITE_DESCRIPTION", "Create or update custom properties at the repository, organization, or enterprise level. At the repository level this sets the property values on a repository (the properties must already be defined for the organization). Organization and enterprise definition writes preserve omitted writable fields by reading current definitions immediately before updating; concurrent definition updates remain last-write-wins. Select the level with the 'level' parameter."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_CUSTOM_PROPERTIES_WRITE_USER_TITLE", "Set custom properties"), + ReadOnlyHint: false, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "level": { + Type: "string", + Enum: []any{"repository", "organization", "enterprise"}, + Description: customPropertiesLevelDescription, + }, + "owner": { + Type: "string", + Description: "Repository owner. Required when level is 'repository'.", + }, + "repo": { + Type: "string", + Description: "Repository name. Required when level is 'repository'.", + }, + "org": { + Type: "string", + Description: "Organization name. Required when level is 'organization'.", + }, + "enterprise": { + Type: "string", + Description: "Enterprise slug. Required when level is 'enterprise'.", + }, + "properties": { + Type: "array", + Description: "The custom properties to create or update. At the repository level each item assigns a value ('property_name' and 'value'); at the organization and enterprise levels each item defines the schema ('property_name' and 'value_type', plus optional definition fields).", + Items: &jsonschema.Schema{ + Type: "object", + OneOf: []*jsonschema.Schema{ + customPropertyValueSchema(), + customPropertyDefinitionSchema(), + }, + }, + }, + }, + Required: []string{"level", "properties"}, + }, + }, + rulesetWriteScopeAccess(), + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + level, err := RequiredParam[string](args, "level") + 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) + } + + switch level { + case "repository": + return customPropertiesWriteRepository(ctx, client, args) + case "organization": + return customPropertiesWriteOrganization(ctx, client, args) + case "enterprise": + return customPropertiesWriteEnterprise(ctx, client, args) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown level: %q (expected 'repository', 'organization', or 'enterprise')", level)), nil, nil + } + }, + ) +} + +func customPropertiesWriteRepository(ctx context.Context, client *github.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + values, errResult := parseCustomProperties[*github.CustomPropertyValue](args, customPropertyValueSchema()) + if errResult != nil { + return errResult, nil, nil + } + + resp, err := client.Repositories.CreateOrUpdateCustomProperties(ctx, owner, repo, values) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update repository custom property values", resp, err), nil, nil + } + + return utils.NewToolResultText("Repository custom property values updated successfully"), nil, nil +} + +func customPropertiesWriteOrganization(ctx context.Context, client *github.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + org, err := RequiredParam[string](args, "org") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + properties, errResult := parseCustomProperties[map[string]any](args, customPropertyDefinitionSchema()) + if errResult != nil { + return errResult, nil, nil + } + + return customPropertiesWriteDefinitions(ctx, client, fmt.Sprintf("orgs/%s/properties/schema", url.PathEscape(org)), "organization", properties) +} + +func customPropertiesWriteEnterprise(ctx context.Context, client *github.Client, args map[string]any) (*mcp.CallToolResult, any, error) { + enterprise, err := RequiredParam[string](args, "enterprise") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + properties, errResult := parseCustomProperties[map[string]any](args, customPropertyDefinitionSchema()) + if errResult != nil { + return errResult, nil, nil + } + + return customPropertiesWriteDefinitions(ctx, client, fmt.Sprintf("enterprises/%s/properties/schema", url.PathEscape(enterprise)), "enterprise", properties) +} + +func customPropertyValueSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "object", + Description: "A repository-level custom property value.", + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + Properties: map[string]*jsonschema.Schema{ + "property_name": { + Type: "string", + Description: "The name of the custom property.", + }, + "value": { + Description: "Repository level only: the value to assign. A string, an array of strings, or null to clear the value.", + OneOf: []*jsonschema.Schema{ + {Type: "string"}, + { + Type: "array", + Items: &jsonschema.Schema{Type: "string"}, + }, + {Type: "null"}, + }, + }, + }, + Required: []string{"property_name", "value"}, + } +} + +func customPropertyDefinitionSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "object", + Description: "An organization- or enterprise-level custom property definition.", + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + Properties: map[string]*jsonschema.Schema{ + "property_name": { + Type: "string", + Description: "The name of the custom property.", + }, + "value_type": { + Type: "string", + Enum: []any{"string", "single_select", "multi_select", "true_false", "url"}, + Description: "The data type of the property. Required for new definitions; omit when updating to preserve the current type.", + }, + "required": { + Type: "boolean", + Description: "Whether the property must be set on every repository. Omit when updating to preserve the current setting.", + }, + "default_value": { + Description: "The value applied when a repository does not set the property. Omit when updating to preserve the current value; use null to clear it.", + OneOf: []*jsonschema.Schema{ + {Type: "string"}, + { + Type: "array", + Items: &jsonschema.Schema{Type: "string"}, + }, + {Type: "null"}, + }, + }, + "description": { + Description: "A short description of the property. Omit when updating to preserve the current description; use null to clear it.", + AnyOf: []*jsonschema.Schema{ + {Type: "string"}, + {Type: "null"}, + }, + }, + "allowed_values": { + Description: "The ordered list of allowed values for single_select and multi_select properties. Omit when updating to preserve the current list; use null or an empty array to clear it.", + AnyOf: []*jsonschema.Schema{ + { + Type: "array", + Items: &jsonschema.Schema{Type: "string"}, + }, + {Type: "null"}, + }, + }, + "values_editable_by": { + Description: "Who can edit the values of the property. Omit when updating to preserve the current setting; use null to restore the default.", + AnyOf: []*jsonschema.Schema{ + { + Type: "string", + Enum: []any{"org_actors", "org_and_repo_actors"}, + }, + {Type: "null"}, + }, + }, + "require_explicit_values": { + Type: "boolean", + Description: "Whether repositories must explicitly set a value for the property. Omit when updating to preserve the current setting.", + }, + }, + Required: []string{"property_name"}, + } +} + +var customPropertyDefinitionFields = []string{ + "value_type", + "required", + "default_value", + "description", + "allowed_values", + "values_editable_by", + "require_explicit_values", +} + +func customPropertiesWriteDefinitions(ctx context.Context, client *github.Client, apiURL, sourceType string, requested []map[string]any) (*mcp.CallToolResult, any, error) { + if errResult := validateUniqueCustomPropertyNames(requested); errResult != nil { + return errResult, nil, nil + } + + current, errResult := getCustomPropertyDefinitions(ctx, client, apiURL, fmt.Sprintf("failed to get %s custom properties before updating", sourceType)) + if errResult != nil { + return errResult, nil, nil + } + merged, errResult := mergeCustomPropertyDefinitions(current, requested, sourceType) + if errResult != nil { + return errResult, nil, nil + } + + req, err := client.NewRequest(ctx, http.MethodPatch, apiURL, map[string]any{"properties": merged}) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to create custom properties update request", err), nil, nil + } + + var updated []map[string]any + resp, err := client.Do(req, &updated) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, fmt.Sprintf("failed to update %s custom properties", sourceType), resp, err), nil, nil + } + return MarshalledTextResult(updated), nil, nil +} + +func getCustomPropertyDefinitions(ctx context.Context, client *github.Client, apiURL, errorMessage string) ([]map[string]any, *mcp.CallToolResult) { + req, err := client.NewRequest(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return nil, utils.NewToolResultErrorFromErr("failed to create custom properties request", err) + } + + var properties []map[string]any + resp, err := client.Do(req, &properties) + if resp != nil { + defer func() { _ = resp.Body.Close() }() + } + if err != nil { + return nil, ghErrors.NewGitHubAPIErrorResponse(ctx, errorMessage, resp, err) + } + return properties, nil +} + +func validateUniqueCustomPropertyNames(properties []map[string]any) *mcp.CallToolResult { + seen := make(map[string]struct{}, len(properties)) + for i, property := range properties { + name := property["property_name"].(string) + if _, ok := seen[name]; ok { + return utils.NewToolResultError(fmt.Sprintf("properties[%d].property_name duplicates %q", i, name)) + } + seen[name] = struct{}{} + } + return nil +} + +func mergeCustomPropertyDefinitions(current, requested []map[string]any, sourceType string) ([]map[string]any, *mcp.CallToolResult) { + currentByName := make(map[string]map[string]any, len(current)) + for _, property := range current { + name, _ := property["property_name"].(string) + if name != "" { + currentByName[name] = property + } + } + + merged := make([]map[string]any, 0, len(requested)) + for i, update := range requested { + name := update["property_name"].(string) + existing, exists := currentByName[name] + if !exists { + if _, ok := update["value_type"]; !ok { + return nil, utils.NewToolResultError(fmt.Sprintf("properties[%d].value_type is required for new property %q", i, name)) + } + merged = append(merged, update) + continue + } + if existingSource, _ := existing["source_type"].(string); existingSource != "" && existingSource != sourceType { + return nil, utils.NewToolResultError(fmt.Sprintf("property %q is inherited from %s and cannot be updated at the %s level", name, existingSource, sourceType)) + } + + property := map[string]any{"property_name": name} + for _, field := range customPropertyDefinitionFields { + if value, ok := existing[field]; ok { + property[field] = value + } + if value, ok := update[field]; ok { + property[field] = value + } + } + merged = append(merged, property) + } + return merged, nil +} + +func parseCustomProperties[T any](args map[string]any, itemSchema *jsonschema.Schema) ([]T, *mcp.CallToolResult) { + raw, ok := args["properties"] + if !ok || raw == nil { + return nil, utils.NewToolResultError("properties parameter is required") + } + arr, ok := raw.([]any) + if !ok { + return nil, utils.NewToolResultError("properties parameter must be an array") + } + resolved, err := itemSchema.Resolve(nil) + if err != nil { + return nil, utils.NewToolResultErrorFromErr("failed to resolve properties schema", err) + } + for i, property := range arr { + if err := resolved.Validate(property); err != nil { + return nil, utils.NewToolResultErrorFromErr(fmt.Sprintf("properties[%d] is invalid", i), err) + } + } + + encoded, err := json.Marshal(arr) + if err != nil { + return nil, utils.NewToolResultErrorFromErr("failed to encode properties", err) + } + var out []T + if err := json.Unmarshal(encoded, &out); err != nil { + return nil, utils.NewToolResultErrorFromErr("failed to parse properties", err) + } + return out, nil +} diff --git a/pkg/github/custom_properties_test.go b/pkg/github/custom_properties_test.go new file mode 100644 index 0000000000..be04872038 --- /dev/null +++ b/pkg/github/custom_properties_test.go @@ -0,0 +1,550 @@ +package github + +import ( + "context" + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/google/go-github/v89/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" +) + +func Test_CustomPropertiesRead(t *testing.T) { + toolDef := CustomPropertiesRead(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "custom_properties_read", toolDef.Tool.Name) + assert.NotEmpty(t, toolDef.Tool.Description) + assert.True(t, toolDef.Tool.Annotations.ReadOnlyHint) + + schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.ElementsMatch(t, schema.Required, []string{"level"}) + + t.Run("repository level: returns property values", func(t *testing.T) { + mockValues := []*github.CustomPropertyValue{{PropertyName: "environment", Value: "production"}} + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/{owner}/{repo}/properties/values": mockResponse(t, http.StatusOK, mockValues), + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "repository", "owner": "owner", "repo": "repo"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + var returned []*github.CustomPropertyValue + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + require.Len(t, returned, 1) + assert.Equal(t, "environment", returned[0].PropertyName) + }) + + t.Run("repository level: requires owner and repo", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "repository", "owner": "owner"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "repo") + }) + + t.Run("organization level: returns property definitions", func(t *testing.T) { + mockProps := []map[string]any{{ + "property_name": "environment", + "value_type": "single_select", + "require_explicit_values": true, + }} + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/{org}/properties/schema": mockResponse(t, http.StatusOK, mockProps), + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "organization", "org": "octo"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + var returned []map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + require.Len(t, returned, 1) + assert.Equal(t, "environment", returned[0]["property_name"]) + assert.Equal(t, true, returned[0]["require_explicit_values"]) + }) + + t.Run("organization level: requires org", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "organization"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "org") + }) + + t.Run("enterprise level: returns property definitions", func(t *testing.T) { + mockProps := []*github.CustomProperty{{PropertyName: github.Ptr("compliance"), ValueType: "true_false"}} + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /enterprises/{enterprise}/properties/schema": mockResponse(t, http.StatusOK, mockProps), + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "enterprise", "enterprise": "acme"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + var returned []*github.CustomProperty + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + require.Len(t, returned, 1) + assert.Equal(t, "compliance", returned[0].GetPropertyName()) + }) + + t.Run("unknown level returns an error", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "team"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "unknown level") + }) +} + +func Test_CustomPropertiesWrite(t *testing.T) { + toolDef := CustomPropertiesWrite(translations.NullTranslationHelper) + require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + + assert.Equal(t, "custom_properties_write", toolDef.Tool.Name) + assert.NotEmpty(t, toolDef.Tool.Description) + assert.False(t, toolDef.Tool.Annotations.ReadOnlyHint) + + schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.ElementsMatch(t, schema.Required, []string{"level", "properties"}) + + t.Run("property items use level-specific schemas", func(t *testing.T) { + itemSchema := schema.Properties["properties"].Items + assert.Equal(t, "object", itemSchema.Type) + require.Len(t, itemSchema.OneOf, 2) + valueItemSchema := itemSchema.OneOf[0] + definitionItemSchema := itemSchema.OneOf[1] + require.NotNil(t, valueItemSchema.AdditionalProperties.Not) + require.NotNil(t, definitionItemSchema.AdditionalProperties.Not) + assert.ElementsMatch(t, []string{"property_name", "value"}, valueItemSchema.Required) + assert.ElementsMatch(t, []string{"property_name"}, definitionItemSchema.Required) + assert.Equal(t, "boolean", definitionItemSchema.Properties["require_explicit_values"].Type) + + resolvedValue, err := valueItemSchema.Resolve(nil) + require.NoError(t, err) + resolvedDefinition, err := definitionItemSchema.Resolve(nil) + require.NoError(t, err) + + valueSchema := valueItemSchema.Properties["value"] + require.Len(t, valueSchema.OneOf, 3) + assert.Equal(t, "string", valueSchema.OneOf[0].Type) + assert.Equal(t, "array", valueSchema.OneOf[1].Type) + assert.Equal(t, "string", valueSchema.OneOf[1].Items.Type) + assert.Equal(t, "null", valueSchema.OneOf[2].Type) + + defaultValueSchema := definitionItemSchema.Properties["default_value"] + require.Len(t, defaultValueSchema.OneOf, 3) + assert.Equal(t, "string", defaultValueSchema.OneOf[0].Type) + assert.Equal(t, "array", defaultValueSchema.OneOf[1].Type) + assert.Equal(t, "string", defaultValueSchema.OneOf[1].Items.Type) + assert.Equal(t, "null", defaultValueSchema.OneOf[2].Type) + + tests := []struct { + name string + definition bool + property map[string]any + shouldPass bool + }{ + {name: "repository string", property: map[string]any{"property_name": "environment", "value": "production"}, shouldPass: true}, + {name: "repository string array", property: map[string]any{"property_name": "environment", "value": []any{"production", "staging"}}, shouldPass: true}, + {name: "repository null", property: map[string]any{"property_name": "environment", "value": nil}, shouldPass: true}, + {name: "repository number", property: map[string]any{"property_name": "environment", "value": 1}}, + {name: "repository boolean", property: map[string]any{"property_name": "environment", "value": true}}, + {name: "repository object", property: map[string]any{"property_name": "environment", "value": map[string]any{"name": "production"}}}, + {name: "repository definition field", property: map[string]any{"property_name": "environment", "value": "production", "required": true}}, + { + name: "definition all fields", + definition: true, + property: map[string]any{ + "property_name": "environment", + "value_type": "single_select", + "required": true, + "default_value": "production", + "description": "Deployment environment", + "allowed_values": []any{"production", "staging"}, + "values_editable_by": "org_and_repo_actors", + "require_explicit_values": true, + }, + shouldPass: true, + }, + {name: "partial definition", definition: true, property: map[string]any{"property_name": "environment", "required": false}, shouldPass: true}, + {name: "definition array default", definition: true, property: map[string]any{"property_name": "compliance", "value_type": "multi_select", "default_value": []any{"soc2", "fedramp"}}, shouldPass: true}, + {name: "definition explicit nulls", definition: true, property: map[string]any{"property_name": "environment", "default_value": nil, "description": nil, "allowed_values": nil, "values_editable_by": nil}, shouldPass: true}, + {name: "definition number default", definition: true, property: map[string]any{"property_name": "environment", "value_type": "string", "default_value": 1}}, + {name: "definition boolean default", definition: true, property: map[string]any{"property_name": "environment", "value_type": "string", "default_value": true}}, + {name: "definition object default", definition: true, property: map[string]any{"property_name": "environment", "value_type": "string", "default_value": map[string]any{"name": "production"}}}, + {name: "definition repository field", definition: true, property: map[string]any{"property_name": "environment", "value_type": "string", "value": "production"}}, + {name: "definition misspelled field", definition: true, property: map[string]any{"property_name": "environment", "value_type": "string", "require": true}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resolved := resolvedValue + if tt.definition { + resolved = resolvedDefinition + } + err := resolved.Validate(tt.property) + if tt.shouldPass { + require.NoError(t, err) + } else { + require.Error(t, err) + } + }) + } + }) + + t.Run("repository level: sets property values with one request", func(t *testing.T) { + requests := 0 + var captured struct { + Properties []*github.CustomPropertyValue `json:"properties"` + } + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "PATCH /repos/{owner}/{repo}/properties/values": func(w http.ResponseWriter, r *http.Request) { + requests++ + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &captured) + w.WriteHeader(http.StatusNoContent) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "repository", + "owner": "owner", + "repo": "repo", + "properties": []any{ + map[string]any{"property_name": "environment", "value": "production"}, + map[string]any{"property_name": "deprecated", "value": nil}, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, "updated successfully") + assert.Equal(t, 1, requests) + + require.Len(t, captured.Properties, 2) + assert.Equal(t, "environment", captured.Properties[0].PropertyName) + assert.Equal(t, "production", captured.Properties[0].Value) + assert.Equal(t, "deprecated", captured.Properties[1].PropertyName) + assert.Nil(t, captured.Properties[1].Value) + }) + + t.Run("repository level: requires properties", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{"level": "repository", "owner": "owner", "repo": "repo"}) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "properties parameter is required") + }) + + t.Run("organization level: merges mixed create and update in two requests", func(t *testing.T) { + requests := 0 + var captured struct { + Properties []map[string]any `json:"properties"` + } + current := []map[string]any{ + { + "property_name": "environment", + "source_type": "organization", + "url": "https://api.github.com/orgs/octo/properties/schema/environment", + "value_type": "single_select", + "required": true, + "default_value": "production", + "description": "Deployment environment", + "allowed_values": []string{"production", "staging"}, + "values_editable_by": "org_and_repo_actors", + "require_explicit_values": true, + }, + { + "property_name": "legacy", + "source_type": "organization", + "value_type": "string", + "required": false, + "default_value": nil, + "description": "", + "allowed_values": []string{}, + "values_editable_by": nil, + "require_explicit_values": false, + }, + { + "property_name": "untouched", + "source_type": "organization", + "value_type": "string", + }, + } + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/{org}/properties/schema": func(w http.ResponseWriter, r *http.Request) { + requests++ + mockResponse(t, http.StatusOK, current)(w, r) + }, + "PATCH /orgs/{org}/properties/schema": func(w http.ResponseWriter, r *http.Request) { + requests++ + body, _ := io.ReadAll(r.Body) + require.NoError(t, json.Unmarshal(body, &captured)) + mockResponse(t, http.StatusOK, captured.Properties)(w, r) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "organization", + "org": "octo", + "properties": []any{ + map[string]any{ + "property_name": "environment", + "required": false, + }, + map[string]any{ + "property_name": "legacy", + "description": nil, + "allowed_values": []any{}, + "require_explicit_values": false, + }, + map[string]any{ + "property_name": "service", + "value_type": "string", + "required": false, + "default_value": nil, + "description": "", + "allowed_values": nil, + "values_editable_by": nil, + "require_explicit_values": false, + }, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Equal(t, 2, requests) + require.Len(t, captured.Properties, 3) + + environment := captured.Properties[0] + assert.Equal(t, "environment", environment["property_name"]) + assert.Equal(t, "single_select", environment["value_type"]) + assert.Equal(t, false, environment["required"]) + assert.Equal(t, "production", environment["default_value"]) + assert.Equal(t, "Deployment environment", environment["description"]) + assert.Equal(t, []any{"production", "staging"}, environment["allowed_values"]) + assert.Equal(t, "org_and_repo_actors", environment["values_editable_by"]) + assert.Equal(t, true, environment["require_explicit_values"]) + assert.NotContains(t, environment, "source_type") + assert.NotContains(t, environment, "url") + + legacy := captured.Properties[1] + assert.Equal(t, "string", legacy["value_type"]) + assert.Equal(t, false, legacy["required"]) + assert.Contains(t, legacy, "default_value") + assert.Nil(t, legacy["default_value"]) + assert.Contains(t, legacy, "description") + assert.Nil(t, legacy["description"]) + assert.Contains(t, legacy, "allowed_values") + assert.Empty(t, legacy["allowed_values"]) + assert.Contains(t, legacy, "values_editable_by") + assert.Nil(t, legacy["values_editable_by"]) + assert.Equal(t, false, legacy["require_explicit_values"]) + + service := captured.Properties[2] + assert.Equal(t, "string", service["value_type"]) + assert.Equal(t, false, service["required"]) + assert.Contains(t, service, "default_value") + assert.Nil(t, service["default_value"]) + assert.Equal(t, "", service["description"]) + assert.Contains(t, service, "allowed_values") + assert.Nil(t, service["allowed_values"]) + assert.Contains(t, service, "values_editable_by") + assert.Nil(t, service["values_editable_by"]) + assert.Equal(t, false, service["require_explicit_values"]) + }) + + t.Run("enterprise level: merges an existing definition", func(t *testing.T) { + requests := 0 + var captured struct { + Properties []map[string]any `json:"properties"` + } + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /enterprises/{enterprise}/properties/schema": func(w http.ResponseWriter, r *http.Request) { + requests++ + mockResponse(t, http.StatusOK, []map[string]any{{ + "property_name": "compliance", + "source_type": "enterprise", + "value_type": "multi_select", + "required": true, + "default_value": []string{"soc2"}, + "description": "Compliance frameworks", + "allowed_values": []string{"soc2", "fedramp"}, + "values_editable_by": "org_actors", + "require_explicit_values": true, + }})(w, r) + }, + "PATCH /enterprises/{enterprise}/properties/schema": func(w http.ResponseWriter, r *http.Request) { + requests++ + body, _ := io.ReadAll(r.Body) + require.NoError(t, json.Unmarshal(body, &captured)) + mockResponse(t, http.StatusOK, captured.Properties)(w, r) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "enterprise", + "enterprise": "acme", + "properties": []any{ + map[string]any{ + "property_name": "compliance", + "default_value": []any{"soc2", "fedramp"}, + }, + }, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Equal(t, 2, requests) + require.Len(t, captured.Properties, 1) + assert.Equal(t, "multi_select", captured.Properties[0]["value_type"]) + assert.Equal(t, []any{"soc2", "fedramp"}, captured.Properties[0]["default_value"]) + assert.Equal(t, true, captured.Properties[0]["require_explicit_values"]) + }) + + t.Run("rejects invalid definition writes before patch", func(t *testing.T) { + tests := []struct { + name string + args map[string]any + current []map[string]any + getStatus int + expectedError string + expectedRequests int + }{ + { + name: "organization definition without value_type", + args: map[string]any{ + "level": "organization", + "org": "octo", + "properties": []any{map[string]any{"property_name": "environment"}}, + }, + expectedError: "value_type is required for new property", + expectedRequests: 1, + }, + { + name: "duplicate property names", + args: map[string]any{ + "level": "organization", + "org": "octo", + "properties": []any{ + map[string]any{"property_name": "environment"}, + map[string]any{"property_name": "environment"}, + }, + }, + expectedError: "duplicates", + }, + { + name: "inherited enterprise definition", + args: map[string]any{ + "level": "organization", + "org": "octo", + "properties": []any{map[string]any{"property_name": "environment"}}, + }, + current: []map[string]any{{ + "property_name": "environment", + "source_type": "enterprise", + "value_type": "string", + }}, + expectedError: "inherited from enterprise", + expectedRequests: 1, + }, + { + name: "GET failure", + args: map[string]any{ + "level": "organization", + "org": "octo", + "properties": []any{map[string]any{"property_name": "environment"}}, + }, + getStatus: http.StatusInternalServerError, + expectedError: "failed to get organization custom properties before updating", + expectedRequests: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + requests := 0 + status := tt.getStatus + if status == 0 { + status = http.StatusOK + } + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/{org}/properties/schema": func(w http.ResponseWriter, r *http.Request) { + requests++ + mockResponse(t, status, tt.current)(w, r) + }, + "PATCH /orgs/{org}/properties/schema": func(w http.ResponseWriter, _ *http.Request) { + requests++ + w.WriteHeader(http.StatusOK) + }, + })) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(tt.args) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, tt.expectedError) + assert.Equal(t, tt.expectedRequests, requests) + }) + } + }) + + t.Run("unknown level returns an error", func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{})) + deps := BaseDeps{Client: client} + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "level": "team", + "properties": []any{map[string]any{"property_name": "x"}}, + }) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "unknown level") + }) +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index a6ffbecfee..6764edfc26 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -75,7 +75,7 @@ var ( } ToolsetMetadataGovernance = inventory.ToolsetMetadata{ ID: "governance", - Description: "Repository governance tools for managing rulesets at the repository, organization, and enterprise levels", + Description: "Repository governance tools for managing rulesets and custom properties at the repository, organization, and enterprise levels", Icon: "law", } ToolsetMetadataActions = inventory.ToolsetMetadata{ @@ -270,9 +270,11 @@ func AllTools(t translations.TranslationHelperFunc, opts ...ToolOption) []invent // Organization tools SearchOrgs(t), - // Governance tools (rulesets) + // Governance tools RepositoryRulesetRead(t), CreateRepositoryRuleset(t), + CustomPropertiesRead(t), + CustomPropertiesWrite(t), // Pull request tools PullRequestRead(t), diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index 9e8cd0b794..f94ecc3ed4 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -42,11 +42,11 @@ The following packages are included for the amd64, arm64 architectures. - [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE)) - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.5/LICENSE)) - - [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.55.0:LICENSE)) - - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.36.0:LICENSE)) - - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.20.0:LICENSE)) - - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.45.0:LICENSE)) - - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.37.0:LICENSE)) - - [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.15.0:LICENSE)) + - [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](Unknown)) + - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](Unknown)) + - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](Unknown)) + - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](Unknown)) + - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](Unknown)) + - [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](Unknown)) [github/github-mcp-server]: https://github.com/github/github-mcp-server diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index f5b267f28d..d8f98770b3 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -4,13 +4,56 @@ The following open source dependencies are used to build the [github/github-mcp- ## Table of Contents -- [386, amd64, arm64](#386-amd64-arm64) +- [amd64](#amd64) +- [386](#386) +- [arm64](#arm64) --- -## 386, amd64, arm64 +## amd64 -The following packages are included for the 386, amd64, arm64 architectures. +The following packages are included for the amd64 architectures. + + - [github.com/aymerick/douceur](https://pkg.go.dev/github.com/aymerick/douceur) ([MIT](https://github.com/aymerick/douceur/blob/v0.2.0/LICENSE)) + - [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.9.0/LICENSE)) + - [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) + - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.3.2/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/v89/github](https://pkg.go.dev/github.com/google/go-github/v89/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/34349a88bac3/LICENSE)) + - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.2.0/LICENSE)) + - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.3/LICENSE)) + - [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) + - [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v2.5.0/v2/LICENSE)) + - [github.com/lithammer/fuzzysearch/fuzzy](https://pkg.go.dev/github.com/lithammer/fuzzysearch/fuzzy) ([MIT](https://github.com/lithammer/fuzzysearch/blob/v1.1.8/LICENSE)) + - [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.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.7.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)) + - [github.com/segmentio/asm](https://pkg.go.dev/github.com/segmentio/asm) ([MIT](https://github.com/segmentio/asm/blob/v1.1.3/LICENSE)) + - [github.com/segmentio/encoding](https://pkg.go.dev/github.com/segmentio/encoding) ([MIT](https://github.com/segmentio/encoding/blob/v0.5.4/LICENSE)) + - [github.com/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/48295856cce7/LICENSE)) + - [github.com/shurcooL/graphql](https://pkg.go.dev/github.com/shurcooL/graphql) ([MIT](https://github.com/shurcooL/graphql/blob/ed46e5a46466/LICENSE)) + - [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/5f936abd7ae8/LICENSE)) + - [github.com/spf13/afero](https://pkg.go.dev/github.com/spf13/afero) ([Apache-2.0](https://github.com/spf13/afero/blob/v1.15.0/LICENSE.txt)) + - [github.com/spf13/cast](https://pkg.go.dev/github.com/spf13/cast) ([MIT](https://github.com/spf13/cast/blob/v1.10.0/LICENSE)) + - [github.com/spf13/cobra](https://pkg.go.dev/github.com/spf13/cobra) ([Apache-2.0](https://github.com/spf13/cobra/blob/v1.10.2/LICENSE.txt)) + - [github.com/spf13/pflag](https://pkg.go.dev/github.com/spf13/pflag) ([BSD-3-Clause](https://github.com/spf13/pflag/blob/v1.0.10/LICENSE)) + - [github.com/spf13/viper](https://pkg.go.dev/github.com/spf13/viper) ([MIT](https://github.com/spf13/viper/blob/v1.21.0/LICENSE)) + - [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE)) + - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) + - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.5/LICENSE)) + - [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](Unknown)) + - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](Unknown)) + - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](Unknown)) + - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](Unknown)) + - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](Unknown)) + - [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](Unknown)) + +## 386 + +The following packages are included for the 386 architectures. - [github.com/aymerick/douceur](https://pkg.go.dev/github.com/aymerick/douceur) ([MIT](https://github.com/aymerick/douceur/blob/v0.2.0/LICENSE)) - [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.9.0/LICENSE)) @@ -43,10 +86,51 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.5/LICENSE)) - [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.55.0:LICENSE)) - - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.36.0:LICENSE)) - - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.20.0:LICENSE)) - - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.45.0:LICENSE)) - - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.37.0:LICENSE)) + - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](Unknown)) + - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](Unknown)) + - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](Unknown)) + - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](Unknown)) - [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.15.0:LICENSE)) +## arm64 + +The following packages are included for the arm64 architectures. + + - [github.com/aymerick/douceur](https://pkg.go.dev/github.com/aymerick/douceur) ([MIT](https://github.com/aymerick/douceur/blob/v0.2.0/LICENSE)) + - [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.9.0/LICENSE)) + - [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) + - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.3.2/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/v89/github](https://pkg.go.dev/github.com/google/go-github/v89/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/34349a88bac3/LICENSE)) + - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.2.0/LICENSE)) + - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.3/LICENSE)) + - [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) + - [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v2.5.0/v2/LICENSE)) + - [github.com/lithammer/fuzzysearch/fuzzy](https://pkg.go.dev/github.com/lithammer/fuzzysearch/fuzzy) ([MIT](https://github.com/lithammer/fuzzysearch/blob/v1.1.8/LICENSE)) + - [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.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.7.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)) + - [github.com/segmentio/asm](https://pkg.go.dev/github.com/segmentio/asm) ([MIT](https://github.com/segmentio/asm/blob/v1.1.3/LICENSE)) + - [github.com/segmentio/encoding](https://pkg.go.dev/github.com/segmentio/encoding) ([MIT](https://github.com/segmentio/encoding/blob/v0.5.4/LICENSE)) + - [github.com/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/48295856cce7/LICENSE)) + - [github.com/shurcooL/graphql](https://pkg.go.dev/github.com/shurcooL/graphql) ([MIT](https://github.com/shurcooL/graphql/blob/ed46e5a46466/LICENSE)) + - [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/5f936abd7ae8/LICENSE)) + - [github.com/spf13/afero](https://pkg.go.dev/github.com/spf13/afero) ([Apache-2.0](https://github.com/spf13/afero/blob/v1.15.0/LICENSE.txt)) + - [github.com/spf13/cast](https://pkg.go.dev/github.com/spf13/cast) ([MIT](https://github.com/spf13/cast/blob/v1.10.0/LICENSE)) + - [github.com/spf13/cobra](https://pkg.go.dev/github.com/spf13/cobra) ([Apache-2.0](https://github.com/spf13/cobra/blob/v1.10.2/LICENSE.txt)) + - [github.com/spf13/pflag](https://pkg.go.dev/github.com/spf13/pflag) ([BSD-3-Clause](https://github.com/spf13/pflag/blob/v1.0.10/LICENSE)) + - [github.com/spf13/viper](https://pkg.go.dev/github.com/spf13/viper) ([MIT](https://github.com/spf13/viper/blob/v1.21.0/LICENSE)) + - [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE)) + - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) + - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.5/LICENSE)) + - [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.55.0:LICENSE)) + - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](Unknown)) + - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](Unknown)) + - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](Unknown)) + - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](Unknown)) + - [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](Unknown)) + [github/github-mcp-server]: https://github.com/github/github-mcp-server diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index 7e834bc36f..dca13b7c2a 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -4,13 +4,99 @@ The following open source dependencies are used to build the [github/github-mcp- ## Table of Contents -- [386, amd64, arm64](#386-amd64-arm64) +- [arm64](#arm64) +- [386](#386) +- [amd64](#amd64) --- -## 386, amd64, arm64 +## arm64 -The following packages are included for the 386, amd64, arm64 architectures. +The following packages are included for the arm64 architectures. + + - [github.com/aymerick/douceur](https://pkg.go.dev/github.com/aymerick/douceur) ([MIT](https://github.com/aymerick/douceur/blob/v0.2.0/LICENSE)) + - [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.9.0/LICENSE)) + - [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) + - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.3.2/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/v89/github](https://pkg.go.dev/github.com/google/go-github/v89/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/34349a88bac3/LICENSE)) + - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.2.0/LICENSE)) + - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.3/LICENSE)) + - [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) + - [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)) + - [github.com/lithammer/fuzzysearch/fuzzy](https://pkg.go.dev/github.com/lithammer/fuzzysearch/fuzzy) ([MIT](https://github.com/lithammer/fuzzysearch/blob/v1.1.8/LICENSE)) + - [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.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.7.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)) + - [github.com/segmentio/asm](https://pkg.go.dev/github.com/segmentio/asm) ([MIT](https://github.com/segmentio/asm/blob/v1.1.3/LICENSE)) + - [github.com/segmentio/encoding](https://pkg.go.dev/github.com/segmentio/encoding) ([MIT](https://github.com/segmentio/encoding/blob/v0.5.4/LICENSE)) + - [github.com/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/48295856cce7/LICENSE)) + - [github.com/shurcooL/graphql](https://pkg.go.dev/github.com/shurcooL/graphql) ([MIT](https://github.com/shurcooL/graphql/blob/ed46e5a46466/LICENSE)) + - [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/5f936abd7ae8/LICENSE)) + - [github.com/spf13/afero](https://pkg.go.dev/github.com/spf13/afero) ([Apache-2.0](https://github.com/spf13/afero/blob/v1.15.0/LICENSE.txt)) + - [github.com/spf13/cast](https://pkg.go.dev/github.com/spf13/cast) ([MIT](https://github.com/spf13/cast/blob/v1.10.0/LICENSE)) + - [github.com/spf13/cobra](https://pkg.go.dev/github.com/spf13/cobra) ([Apache-2.0](https://github.com/spf13/cobra/blob/v1.10.2/LICENSE.txt)) + - [github.com/spf13/pflag](https://pkg.go.dev/github.com/spf13/pflag) ([BSD-3-Clause](https://github.com/spf13/pflag/blob/v1.0.10/LICENSE)) + - [github.com/spf13/viper](https://pkg.go.dev/github.com/spf13/viper) ([MIT](https://github.com/spf13/viper/blob/v1.21.0/LICENSE)) + - [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE)) + - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) + - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.5/LICENSE)) + - [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.55.0:LICENSE)) + - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.36.0:LICENSE)) + - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.20.0:LICENSE)) + - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](Unknown)) + - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.37.0:LICENSE)) + - [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](Unknown)) + +## 386 + +The following packages are included for the 386 architectures. + + - [github.com/aymerick/douceur](https://pkg.go.dev/github.com/aymerick/douceur) ([MIT](https://github.com/aymerick/douceur/blob/v0.2.0/LICENSE)) + - [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.9.0/LICENSE)) + - [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) + - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.3.2/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/v89/github](https://pkg.go.dev/github.com/google/go-github/v89/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/34349a88bac3/LICENSE)) + - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.2.0/LICENSE)) + - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.3/LICENSE)) + - [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) + - [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)) + - [github.com/lithammer/fuzzysearch/fuzzy](https://pkg.go.dev/github.com/lithammer/fuzzysearch/fuzzy) ([MIT](https://github.com/lithammer/fuzzysearch/blob/v1.1.8/LICENSE)) + - [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) + - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.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.7.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)) + - [github.com/segmentio/asm](https://pkg.go.dev/github.com/segmentio/asm) ([MIT](https://github.com/segmentio/asm/blob/v1.1.3/LICENSE)) + - [github.com/segmentio/encoding](https://pkg.go.dev/github.com/segmentio/encoding) ([MIT](https://github.com/segmentio/encoding/blob/v0.5.4/LICENSE)) + - [github.com/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/48295856cce7/LICENSE)) + - [github.com/shurcooL/graphql](https://pkg.go.dev/github.com/shurcooL/graphql) ([MIT](https://github.com/shurcooL/graphql/blob/ed46e5a46466/LICENSE)) + - [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/5f936abd7ae8/LICENSE)) + - [github.com/spf13/afero](https://pkg.go.dev/github.com/spf13/afero) ([Apache-2.0](https://github.com/spf13/afero/blob/v1.15.0/LICENSE.txt)) + - [github.com/spf13/cast](https://pkg.go.dev/github.com/spf13/cast) ([MIT](https://github.com/spf13/cast/blob/v1.10.0/LICENSE)) + - [github.com/spf13/cobra](https://pkg.go.dev/github.com/spf13/cobra) ([Apache-2.0](https://github.com/spf13/cobra/blob/v1.10.2/LICENSE.txt)) + - [github.com/spf13/pflag](https://pkg.go.dev/github.com/spf13/pflag) ([BSD-3-Clause](https://github.com/spf13/pflag/blob/v1.0.10/LICENSE)) + - [github.com/spf13/viper](https://pkg.go.dev/github.com/spf13/viper) ([MIT](https://github.com/spf13/viper/blob/v1.21.0/LICENSE)) + - [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE)) + - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) + - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.5/LICENSE)) + - [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](Unknown)) + - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](Unknown)) + - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.20.0:LICENSE)) + - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](Unknown)) + - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](Unknown)) + - [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](Unknown)) + +## amd64 + +The following packages are included for the amd64 architectures. - [github.com/aymerick/douceur](https://pkg.go.dev/github.com/aymerick/douceur) ([MIT](https://github.com/aymerick/douceur/blob/v0.2.0/LICENSE)) - [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.9.0/LICENSE)) From f2cbc12e58b838fc6e75944143c707d43a90f380 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 3 Sep 2026 15:14:58 +0200 Subject: [PATCH 16/61] fix(sanitize): preserve plain-text title characters (#3216) Add an entity-aware plain-text sanitizer for titles, release names, headlines, and validation text while keeping encoded markup inert and Markdown body handling unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/errors/error.go | 3 +- pkg/errors/error_test.go | 7 +- pkg/github/discussions.go | 4 +- pkg/github/issues.go | 4 +- pkg/github/minimal_types.go | 22 ++--- pkg/github/repositories.go | 2 +- pkg/github/sanitize_coverage_test.go | 27 +++++- pkg/sanitize/sanitize.go | 118 ++++++++++++++++++++++- pkg/sanitize/sanitize_test.go | 136 +++++++++++++++++++++++++++ 9 files changed, 295 insertions(+), 28 deletions(-) diff --git a/pkg/errors/error.go b/pkg/errors/error.go index 13b607b405..cc3e542cc9 100644 --- a/pkg/errors/error.go +++ b/pkg/errors/error.go @@ -256,8 +256,7 @@ func formatGitHubValidationDetail(validationErr github.Error) string { } func sanitizeGitHubValidationText(value string) string { - // Tool errors are plain text; keep quoted branch patterns readable. - sanitized := strings.ReplaceAll(sanitize.Sanitize(value), "'", "'") + sanitized := sanitize.PlainText(value) return strings.Join(strings.Fields(sanitized), " ") } diff --git a/pkg/errors/error_test.go b/pkg/errors/error_test.go index 9938c12df9..bdf7cb16bf 100644 --- a/pkg/errors/error_test.go +++ b/pkg/errors/error_test.go @@ -703,13 +703,13 @@ func TestNewGitHubAPIErrorResponse_ValidationMessages(t *testing.T) { originalErr := &github.ErrorResponse{ Response: response, - Message: "Validation Failed\u202e", + Message: "Validation Failed\u202e for AT&T", Errors: []github.Error{ { Resource: "GitRef", Field: "ref", Code: "custom", - Message: "ref name does not match the required pattern 'feature/*'\u202e", + Message: `ref name does not match the required pattern 'feature/*' or "release/*"` + "\u202e", }, }, DocumentationURL: "https://docs.github.test/private?token=secret-doc-token", @@ -724,7 +724,8 @@ func TestNewGitHubAPIErrorResponse_ValidationMessages(t *testing.T) { ) text := requireErrorText(t, result) - assert.Equal(t, "failed to create branch: Validation Failed\nGitRef.ref (custom): ref name does not match the required pattern 'feature/*'", text) + assert.Equal(t, `failed to create branch: Validation Failed for AT&T +GitRef.ref (custom): ref name does not match the required pattern 'feature/*' or "release/*"`, text) assert.NotContains(t, text, "create ref") assert.NotContains(t, text, "https://") assert.NotContains(t, text, "secret-") diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index 9ea31b2ebf..8a601da86a 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -100,7 +100,7 @@ type WithCategoryNoOrder struct { func fragmentToDiscussion(fragment NodeFragment) *github.Discussion { return &github.Discussion{ Number: github.Ptr(int(fragment.Number)), - Title: github.Ptr(sanitize.Sanitize(string(fragment.Title))), + Title: github.Ptr(sanitize.PlainText(string(fragment.Title))), HTMLURL: github.Ptr(string(fragment.URL)), CreatedAt: &github.Timestamp{Time: fragment.CreatedAt.Time}, UpdatedAt: &github.Timestamp{Time: fragment.UpdatedAt.Time}, @@ -361,7 +361,7 @@ func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool { // like ListDiscussions and GetDiscussionComments). response := map[string]any{ "number": int(d.Number), - "title": sanitize.Sanitize(string(d.Title)), + "title": sanitize.PlainText(string(d.Title)), "body": sanitize.Sanitize(string(d.Body)), "url": string(d.URL), "closed": bool(d.Closed), diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 9450476b19..4532b04076 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1194,7 +1194,7 @@ func GetIssueParent(ctx context.Context, client *githubv4.Client, deps ToolDepen return MarshalledTextResult(map[string]any{ "parent": map[string]any{ "number": int(parent.Number), - "title": sanitize.Sanitize(string(parent.Title)), + "title": sanitize.PlainText(string(parent.Title)), "state": string(parent.State), "url": string(parent.URL), "repository": string(parent.Repository.NameWithOwner), @@ -1995,7 +1995,7 @@ func sanitizeIssueTitleAndBody(issue *github.Issue) { return } if issue.Title != nil { - issue.Title = github.Ptr(sanitize.Sanitize(*issue.Title)) + issue.Title = github.Ptr(sanitize.PlainText(*issue.Title)) } if issue.Body != nil { issue.Body = github.Ptr(sanitize.Sanitize(*issue.Body)) diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index f8cf9f307c..ce0213f3ef 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -622,7 +622,7 @@ type MinimalPullRequestRef struct { func newMinimalPullRequestRef(number int, title, state, url, repository string) MinimalPullRequestRef { return MinimalPullRequestRef{ Number: number, - Title: sanitize.Sanitize(title), + Title: sanitize.PlainText(title), State: state, URL: url, Repository: repository, @@ -646,7 +646,7 @@ type MinimalIssueRef struct { func newMinimalIssueRef(number int, title, state, url, repository string) MinimalIssueRef { return MinimalIssueRef{ Number: number, - Title: sanitize.Sanitize(title), + Title: sanitize.PlainText(title), State: state, URL: url, Repository: repository, @@ -814,7 +814,7 @@ func convertToMinimalPullRequestReview(review *github.PullRequestReview) Minimal func convertToMinimalIssue(issue *github.Issue) MinimalIssue { m := MinimalIssue{ Number: issue.GetNumber(), - Title: sanitize.Sanitize(issue.GetTitle()), + Title: sanitize.PlainText(issue.GetTitle()), Body: sanitize.Sanitize(issue.GetBody()), State: issue.GetState(), StateReason: issue.GetStateReason(), @@ -925,7 +925,7 @@ func fragmentToMinimalIssue(fragment IssueFragment) MinimalIssue { func fragmentWithoutFieldValuesToMinimalIssue(fragment issueFragmentWithoutFieldValues) MinimalIssue { m := MinimalIssue{ Number: int(fragment.Number), - Title: sanitize.Sanitize(string(fragment.Title)), + Title: sanitize.PlainText(string(fragment.Title)), Body: sanitize.Sanitize(string(fragment.Body)), State: string(fragment.State), Comments: int(fragment.Comments.TotalCount), @@ -1084,7 +1084,7 @@ func convertToMinimalFileContentResponse(resp *github.RepositoryContentResponse) func convertToMinimalPullRequest(pr *github.PullRequest) MinimalPullRequest { m := MinimalPullRequest{ Number: pr.GetNumber(), - Title: sanitize.Sanitize(pr.GetTitle()), + Title: sanitize.PlainText(pr.GetTitle()), Body: sanitize.Sanitize(pr.GetBody()), State: pr.GetState(), Draft: pr.GetDraft(), @@ -1279,7 +1279,7 @@ func convertIssueToMinimalProjectItemContent(issue *github.Issue) *MinimalProjec ID: issue.GetID(), NodeID: issue.GetNodeID(), Number: issue.GetNumber(), - Title: sanitize.Sanitize(issue.GetTitle()), + Title: sanitize.PlainText(issue.GetTitle()), State: issue.GetState(), StateReason: issue.GetStateReason(), HTMLURL: issue.GetHTMLURL(), @@ -1316,7 +1316,7 @@ func convertPullRequestToMinimalProjectItemContent(pr *github.PullRequest) *Mini ID: pr.GetID(), NodeID: pr.GetNodeID(), Number: pr.GetNumber(), - Title: sanitize.Sanitize(pr.GetTitle()), + Title: sanitize.PlainText(pr.GetTitle()), State: pr.GetState(), HTMLURL: pr.GetHTMLURL(), Repository: pullRequestRepositoryFullName(pr), @@ -1353,7 +1353,7 @@ func convertDraftIssueToMinimalProjectItemContent(draftIssue *github.ProjectV2Dr m := &MinimalProjectItemContent{ ID: draftIssue.GetID(), NodeID: draftIssue.GetNodeID(), - Title: sanitize.Sanitize(draftIssue.GetTitle()), + Title: sanitize.PlainText(draftIssue.GetTitle()), CreatedAt: formatProjectTimestamp(draftIssue.CreatedAt), UpdatedAt: formatProjectTimestamp(draftIssue.UpdatedAt), } @@ -1612,7 +1612,7 @@ func minimalProjectPullRequestRefFromPullRequest(pr *github.PullRequest) minimal } return minimalProjectPullRequestRef{ Number: pr.GetNumber(), - Title: sanitize.Sanitize(pr.GetTitle()), + Title: sanitize.PlainText(pr.GetTitle()), State: pr.GetState(), HTMLURL: pr.GetHTMLURL(), Repository: pullRequestRepositoryFullName(pr), @@ -1634,7 +1634,7 @@ func minimalProjectPullRequestRefFromMap(value map[string]any) minimalProjectPul return minimalProjectPullRequestRef{ Number: intFromAny(value["number"]), - Title: sanitize.Sanitize(stringFromMap(value, "title")), + Title: sanitize.PlainText(stringFromMap(value, "title")), State: stringFromMap(value, "state"), HTMLURL: htmlURL, Repository: repository, @@ -2038,7 +2038,7 @@ func convertToMinimalRelease(release *github.RepositoryRelease) MinimalRelease { m := MinimalRelease{ ID: release.GetID(), TagName: release.GetTagName(), - Name: sanitize.Sanitize(release.GetName()), + Name: sanitize.PlainText(release.GetName()), Body: sanitize.Sanitize(release.GetBody()), HTMLURL: release.GetHTMLURL(), Prerelease: release.GetPrerelease(), diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 326bea51c6..234089cf53 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -2981,7 +2981,7 @@ func GetFileBlame(t translations.TranslationHelperFunc) inventory.ServerTool { SHA: sha, // Sanitized after truncation so the headline is cut at the author's real // first line break rather than one introduced by sanitization. - MessageHeadline: sanitize.Sanitize(headline), + MessageHeadline: sanitize.PlainText(headline), CommittedDate: r.Commit.CommittedDate.Format("2006-01-02T15:04:05Z"), Author: BlameAuthor{ Name: string(r.Commit.Author.Name), diff --git a/pkg/github/sanitize_coverage_test.go b/pkg/github/sanitize_coverage_test.go index 59747e9780..1b423334f4 100644 --- a/pkg/github/sanitize_coverage_test.go +++ b/pkg/github/sanitize_coverage_test.go @@ -13,19 +13,19 @@ import ( ) // maliciousText contains an HTML payload plus invisible/hidden-instruction characters, -// mirroring the classes of untrusted content pkg/sanitize.Sanitize is meant to strip: +// mirroring the classes of untrusted content the shared sanitizers are meant to strip: // disallowed HTML tags and zero-width/BiDi control characters that can hide instructions // from a human reviewer while still being interpreted by a model. const maliciousText = "Hello\u200BWorld" -// sanitizedText is what maliciousText becomes after sanitize.Sanitize: the after", + expected: "beforeafter", + }, + { + name: "raw formatting element", + input: "bold and italic", + expected: "bold and italic", + }, + { + name: "named entity encoded element remains inert", + input: "<script>alert(1)</script>", + expected: "<script>alert(1)</script>", + }, + { + name: "decimal entity encoded element remains inert", + input: "<script>alert(1)</script>", + expected: "<script>alert(1)</script>", + }, + { + name: "hexadecimal entity encoded element remains inert", + input: "<script>alert(1)</script>", + expected: "<script>alert(1)</script>", + }, + { + name: "double encoded element remains inert", + input: "&lt;script&gt;", + expected: "&lt;script&gt;", + }, + { + name: "triply encoded element remains inert", + input: "&amp;lt;script&amp;gt;", + expected: "&amp;lt;script&amp;gt;", + }, + { + name: "double encoded punctuation remains encoded once", + input: "can&#39;t", + expected: "can&#39;t", + }, + { + name: "malformed element is stripped", + input: "before bold", + expected: "githubmcpplaintexttoken1xbold", + }, + { + name: "literal angle brackets are neutralized", + input: "1 < 2 > 0", + expected: "1 < 2 > 0", + }, + { + name: "literal invisible and bidi characters", + input: "Hello\u200B\u202EWorld", + expected: "HelloWorld", + }, + { + name: "encoded invisible and bidi characters", + input: "Hello​‮World", + expected: "HelloWorld", + }, + { + name: "nul characters are normalized", + input: "Hello\x00�World", + expected: "Hello��World", + }, + { + name: "malformed utf8 is normalized", + input: "Hello\xffWorld", + expected: "Hello\uFFFDWorld", + }, + { + name: "code fence metadata", + input: "```steal secrets\ncode\n```", + expected: "```\ncode\n```", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := PlainText(tt.input) + require.Equal(t, tt.expected, result) + require.Equal(t, result, PlainText(result)) + }) + } +} + // TestSanitizeFiltersInvisibleCharactersAfterEntityDecoding covers the core // regression from issue #3101: invisible/bidi characters encoded as HTML // character entities are decoded by FilterHTMLTags, so the invisible-character @@ -665,6 +782,25 @@ func TestSanitizeIsIdempotent(t *testing.T) { } } +func TestPlainTextIsIdempotent(t *testing.T) { + for _, in := range invariantCorpus { + once := PlainText(in) + require.Equal(t, once, PlainText(once), "PlainText not idempotent on %q", in) + } +} + +func FuzzPlainTextIsIdempotent(f *testing.F) { + for _, seed := range invariantCorpus { + f.Add(seed) + } + f.Fuzz(func(t *testing.T, in string) { + once := PlainText(in) + if twice := PlainText(once); twice != once { + t.Fatalf("PlainText not idempotent on %q: first %q, second %q", in, once, twice) + } + }) +} + // TestSanitizeDoesNotAllocateForCleanASCII pins the allocation contract from // issue #3117: ordinary clean text passes through without being copied. func TestSanitizeDoesNotAllocateForCleanASCII(t *testing.T) { From 8b9df57e31eb07bdd9e147c2a88803086e6c64b8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:18:33 +0200 Subject: [PATCH 17/61] build(deps): bump fast-uri (#3208) Bumps the npm_and_yarn group with 1 update in the /ui directory: [fast-uri](https://github.com/fastify/fast-uri). Updates `fast-uri` from 3.1.5 to 3.1.7 - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.5...v3.1.7) --- updated-dependencies: - dependency-name: fast-uri dependency-version: 3.1.7 dependency-type: indirect dependency-group: npm_and_yarn ... 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 d3a08adc3e..da56a09be5 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -2744,9 +2744,9 @@ "peer": true }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "funding": [ { "type": "github", From 49bdd190f2bb3a2af6e7a88875412082dfa63911 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:20:45 +0200 Subject: [PATCH 18/61] build(deps): bump the npm_and_yarn group across 1 directory with 2 updates (#3217) Bumps the npm_and_yarn group with 2 updates in the /ui directory: [browserslist](https://github.com/browserslist/browserslist) and [qs](https://github.com/ljharb/qs). Updates `browserslist` from 4.28.2 to 4.28.8 - [Release notes](https://github.com/browserslist/browserslist/releases) - [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md) - [Commits](https://github.com/browserslist/browserslist/compare/4.28.2...4.28.8) Updates `qs` from 6.15.2 to 6.16.0 - [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md) - [Commits](https://github.com/ljharb/qs/compare/v6.15.2...v6.16.0) --- updated-dependencies: - dependency-name: browserslist dependency-version: 4.28.8 dependency-type: indirect dependency-group: npm_and_yarn - dependency-name: qs dependency-version: 6.16.0 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- ui/package-lock.json | 77 ++++++++++++++++++++++---------------------- 1 file changed, 39 insertions(+), 38 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index da56a09be5..c19577b0d6 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -2076,9 +2076,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.37", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", - "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", "license": "Apache-2.0", "peer": true, "bin": { @@ -2141,9 +2141,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "funding": [ { "type": "opencollective", @@ -2161,11 +2161,11 @@ "license": "MIT", "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -2226,9 +2226,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001799", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", - "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "funding": [ { "type": "opencollective", @@ -2546,9 +2546,9 @@ "peer": true }, "node_modules/electron-to-chromium": { - "version": "1.5.372", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.372.tgz", - "integrity": "sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==", + "version": "1.5.420", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", + "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==", "license": "ISC", "peer": true }, @@ -2583,9 +2583,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", "license": "MIT", "peer": true, "dependencies": { @@ -2938,9 +2938,9 @@ } }, "node_modules/hasown": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "peer": true, "dependencies": { @@ -4621,9 +4621,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.47", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.47.tgz", - "integrity": "sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "license": "MIT", "peer": true, "engines": { @@ -4837,13 +4837,14 @@ } }, "node_modules/qs": { - "version": "6.15.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", - "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "peer": true, "dependencies": { - "side-channel": "^1.1.0" + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" }, "engines": { "node": ">=0.6" @@ -5202,15 +5203,15 @@ } }, "node_modules/side-channel": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", - "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", "license": "MIT", "peer": true, "dependencies": { "es-errors": "^1.3.0", - "object-inspect": "^1.13.3", - "side-channel-list": "^1.0.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" }, @@ -5665,9 +5666,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "funding": [ { "type": "opencollective", From a014a187c125d883968baf88b4ba1fb1c70052b1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:21:43 +0200 Subject: [PATCH 19/61] build(deps): bump github/codeql-action from 4.37.4 to 4.37.9 (#3192) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.37.4 to 4.37.9. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v4.37.4...v4.37.9) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.37.9 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/code-scanning.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/code-scanning.yml b/.github/workflows/code-scanning.yml index 48ffe98664..ca08306237 100644 --- a/.github/workflows/code-scanning.yml +++ b/.github/workflows/code-scanning.yml @@ -44,7 +44,7 @@ jobs: uses: actions/checkout@v7 - name: Initialize CodeQL - uses: github/codeql-action/init@v4.37.4 + uses: github/codeql-action/init@v4.37.9 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -61,13 +61,13 @@ jobs: threat-models: [ ] - name: Setup proxy for registries id: proxy - uses: github/codeql-action/start-proxy@v4.37.4 + uses: github/codeql-action/start-proxy@v4.37.9 with: registries_credentials: ${{ secrets.GITHUB_REGISTRIES_PROXY }} language: ${{ matrix.language }} - name: Configure - uses: github/codeql-action/resolve-environment@v4.37.4 + uses: github/codeql-action/resolve-environment@v4.37.9 id: resolve-environment with: language: ${{ matrix.language }} @@ -91,10 +91,10 @@ jobs: uses: ./.github/actions/build-ui - name: Autobuild - uses: github/codeql-action/autobuild@v4.37.4 + uses: github/codeql-action/autobuild@v4.37.9 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.37.4 + uses: github/codeql-action/analyze@v4.37.9 env: CODEQL_PROXY_HOST: ${{ steps.proxy.outputs.proxy_host }} CODEQL_PROXY_PORT: ${{ steps.proxy.outputs.proxy_port }} From 59847fb6204b276dcdbe10eaf14ebe3a73176948 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D0=B8=D1=8F?= Date: Thu, 3 Sep 2026 16:22:33 +0300 Subject: [PATCH 20/61] feat: add Agent Plugins 1.0 package (#3169) * feat: add Agent Plugins 1.0 package * fix: align Agent Plugin package identity --- agent-plugin/mcp.json | 9 +++++++++ agent-plugin/plugin.json | 13 +++++++++++++ 2 files changed, 22 insertions(+) create mode 100644 agent-plugin/mcp.json create mode 100644 agent-plugin/plugin.json diff --git a/agent-plugin/mcp.json b/agent-plugin/mcp.json new file mode 100644 index 0000000000..86e6bd64a1 --- /dev/null +++ b/agent-plugin/mcp.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json", + "mcpServers": { + "github": { + "type": "streamable-http", + "url": "https://api.githubcopilot.com/mcp/" + } + } +} diff --git a/agent-plugin/plugin.json b/agent-plugin/plugin.json new file mode 100644 index 0000000000..a7f1f6919c --- /dev/null +++ b/agent-plugin/plugin.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", + "name": "github", + "description": "Work with GitHub repositories, issues, pull requests, reviews, and code search through MCP.", + "author": { + "name": "GitHub", + "url": "https://github.com/" + }, + "homepage": "https://github.com/github/github-mcp-server", + "repository": "https://github.com/github/github-mcp-server", + "license": "MIT", + "keywords": ["code-search", "github", "issues", "mcp", "pull-requests"] +} From 25f11e629c912ab5a810486cefa15d433452e296 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 3 Sep 2026 15:24:00 +0200 Subject: [PATCH 21/61] fix(sanitize): preserve Markdown body fidelity on read surfaces (#3177) * fix: preserve Markdown content in GitHub responses Route body-bearing response fields through the fidelity-preserving content path while retaining strict title handling and remove only unconditional invisible characters. Cover direct and converter read-modify-write surfaces for issues, releases, comments, discussions, projects, and commits. Refs #2202, #3165 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 69c5ab30-9815-4c07-8385-11a206e68f66 * chore: regenerate license files Auto-generated by license-check workflow --------- Co-authored-by: github-actions[bot] Copilot-Session: 69c5ab30-9815-4c07-8385-11a206e68f66 --- pkg/github/discussions.go | 2 +- pkg/github/discussions_test.go | 2 +- pkg/github/find_duplicate_test.go | 1 - pkg/github/issues.go | 20 +++++- pkg/github/issues_test.go | 37 ++++++----- pkg/github/minimal_types.go | 10 +-- pkg/github/projects.go | 2 +- pkg/github/repositories.go | 2 +- pkg/github/repositories_test.go | 9 ++- pkg/github/sanitize_coverage_test.go | 85 +++++++++++------------- pkg/sanitize/sanitize.go | 4 ++ pkg/sanitize/sanitize_test.go | 15 +++++ third-party-licenses.darwin.md | 12 ++-- third-party-licenses.linux.md | 98 ++-------------------------- third-party-licenses.windows.md | 92 +------------------------- 15 files changed, 129 insertions(+), 262 deletions(-) diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index 8a601da86a..4b40569c39 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -362,7 +362,7 @@ func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool { response := map[string]any{ "number": int(d.Number), "title": sanitize.PlainText(string(d.Title)), - "body": sanitize.Sanitize(string(d.Body)), + "body": sanitize.Content(string(d.Body)), "url": string(d.URL), "closed": bool(d.Closed), "isAnswered": bool(d.IsAnswered), diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index a41a903d4e..111372a9ef 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -571,7 +571,7 @@ func Test_GetDiscussion(t *testing.T) { expected: map[string]any{ "number": float64(1), "title": sanitizedText, - "body": sanitizedText, + "body": sanitizedContentText, "url": "https://github.com/owner/repo/discussions/1", "closed": false, "isAnswered": false, diff --git a/pkg/github/find_duplicate_test.go b/pkg/github/find_duplicate_test.go index b2c032989b..f7299116ba 100644 --- a/pkg/github/find_duplicate_test.go +++ b/pkg/github/find_duplicate_test.go @@ -166,7 +166,6 @@ func Test_FindDuplicate_SanitizesIssueTitle(t *testing.T) { require.NoError(t, json.Unmarshal([]byte(text.Text), &candidates)) require.Len(t, candidates, 1) assert.Equal(t, sanitizedText, candidates[0].Issue.Title) - assert.NotContains(t, text.Text, "Hello\u200BWorld" -// sanitizedText is what maliciousText becomes after sanitization: the HelloWorld" -// Test_MinimalConverters_SanitizeUserAuthoredText is a table-driven regression test asserting -// that every convertToMinimal* helper which surfaces untrusted, user-authored prose (issue and -// PR titles/bodies, comments, reviews, review comments, releases, commit messages) applies the -// appropriate shared sanitizer consistently. This guards against the inconsistent coverage described in -// https://github.com/github/github-mcp-server/issues/3106. +// Test_MinimalConverters_SanitizeUserAuthoredText covers the converter fields that expose +// untrusted titles, bodies, comments, reviews, releases, and commit messages. func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { tests := []struct { - name string - got func() string + name string + content bool + got func() string }{ { name: "issue title (REST)", @@ -41,7 +35,8 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { }, }, { - name: "issue body (REST)", + name: "issue body (REST)", + content: true, got: func() string { return convertToMinimalIssue(&github.Issue{ Body: github.Ptr(maliciousText), @@ -49,7 +44,8 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { }, }, { - name: "issue comment body", + name: "issue comment body", + content: true, got: func() string { return convertToMinimalIssueComment(&github.IssueComment{ Body: github.Ptr(maliciousText), @@ -65,7 +61,8 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { }, }, { - name: "pull request body", + name: "pull request body", + content: true, got: func() string { return convertToMinimalPullRequest(&github.PullRequest{ Body: github.Ptr(maliciousText), @@ -73,7 +70,8 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { }, }, { - name: "pull request review body", + name: "pull request review body", + content: true, got: func() string { return convertToMinimalPullRequestReview(&github.PullRequestReview{ Body: github.Ptr(maliciousText), @@ -81,7 +79,8 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { }, }, { - name: "pull request review comment body (GraphQL)", + name: "pull request review comment body (GraphQL)", + content: true, got: func() string { return convertToMinimalReviewComment(reviewCommentNode{ Body: githubv4.String(maliciousText), @@ -98,7 +97,8 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { }, }, { - name: "release body", + name: "release body", + content: true, got: func() string { return convertToMinimalRelease(&github.RepositoryRelease{ Body: github.Ptr(maliciousText), @@ -106,7 +106,8 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { }, }, { - name: "commit message (get_commit / list_commits)", + name: "commit message (get_commit / list_commits)", + content: true, got: func() string { commit := convertToMinimalCommit(&github.RepositoryCommit{ Commit: &github.Commit{Message: github.Ptr(maliciousText)}, @@ -116,7 +117,8 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { }, }, { - name: "commit message (search_commits)", + name: "commit message (search_commits)", + content: true, got: func() string { item := convertCommitResultToMinimalCommit(&github.CommitResult{ Commit: &github.Commit{Message: github.Ptr(maliciousText)}, @@ -126,7 +128,8 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { }, }, { - name: "pull request commit message (list_pull_request_commits)", + name: "pull request commit message (list_pull_request_commits)", + content: true, got: func() string { commits := convertToMinimalPullRequestCommits([]*github.RepositoryCommit{ {Commit: &github.Commit{Message: github.Ptr(maliciousText)}}, @@ -136,7 +139,8 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { }, }, { - name: "file commit message (create/update/delete file)", + name: "file commit message (create/update/delete file)", + content: true, got: func() string { resp := convertToMinimalFileContentResponse(&github.RepositoryContentResponse{ Commit: github.Commit{Message: github.Ptr(maliciousText)}, @@ -146,7 +150,8 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { }, }, { - name: "workflow run head commit message", + name: "workflow run head commit message", + content: true, got: func() string { run := convertToMinimalWorkflowRun(&github.WorkflowRun{ HeadCommit: &github.HeadCommit{Message: github.Ptr(maliciousText)}, @@ -196,7 +201,8 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { }, }, { - name: "project status update body (projects_get / projects_list)", + name: "project status update body (projects_get / projects_list)", + content: true, got: func() string { return convertToMinimalStatusUpdate(statusUpdateNode{ Body: githubv4.NewString(githubv4.String(maliciousText)), @@ -228,7 +234,11 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, sanitizedText, tt.got()) + expected := sanitizedText + if tt.content { + expected = sanitizedContentText + } + assert.Equal(t, expected, tt.got()) }) } } @@ -254,24 +264,7 @@ func Test_SearchIssueResult_SanitizesTitleAndBody(t *testing.T) { require.NoError(t, json.Unmarshal(out, &decoded)) assert.Equal(t, sanitizedText, decoded.Title) - assert.Equal(t, sanitizedText, decoded.Body) -} - -func Test_TitleSanitizationPreservesToolOutputText(t *testing.T) { - issue := convertToMinimalIssue(&github.Issue{ - Title: github.Ptr(`can't "quote" AT&T`), - Body: github.Ptr(`"quoted"`), - }) - - result := MarshalledTextResult(map[string]string{ - "body": issue.Body, - "title": issue.Title, - }) - - assert.Equal(t, - `{"body":"\u003cb\u003e\u0026#34;quoted\u0026#34;\u003c/b\u003e","title":"can't \"quote\" AT\u0026T"}`, - getTextResult(t, result).Text, - ) + assert.Equal(t, sanitizedContentText, decoded.Body) } // Test_SanitizeIssueTitleAndBody exercises the shared helper directly, including its nil-safety, @@ -297,7 +290,7 @@ func Test_SanitizeIssueTitleAndBody(t *testing.T) { require.NotNil(t, issue.Title) require.NotNil(t, issue.Body) assert.Equal(t, sanitizedText, *issue.Title) - assert.Equal(t, sanitizedText, *issue.Body) + assert.Equal(t, sanitizedContentText, *issue.Body) }) } @@ -336,6 +329,6 @@ func Test_Discussion_SanitizesUserAuthoredText(t *testing.T) { t.Run("discussion comment body (newMinimalDiscussionComment, used by get_discussion_comments)", func(t *testing.T) { comment := newMinimalDiscussionComment("id", maliciousText, false) - assert.Equal(t, sanitizedText, comment.Body) + assert.Equal(t, sanitizedContentText, comment.Body) }) } diff --git a/pkg/sanitize/sanitize.go b/pkg/sanitize/sanitize.go index c1778c6d77..6321b8513d 100644 --- a/pkg/sanitize/sanitize.go +++ b/pkg/sanitize/sanitize.go @@ -43,6 +43,10 @@ func Sanitize(input string) string { return FilterCodeFenceMetadata(FilterInvisibleCharacters(normalized)) } +func Content(input string) string { + return FilterInvisibleCharacters(input) +} + // PlainText sanitizes user-authored text that must not contain HTML. func PlainText(input string) string { filtered := FilterCodeFenceMetadata(FilterInvisibleCharacters(input)) diff --git a/pkg/sanitize/sanitize_test.go b/pkg/sanitize/sanitize_test.go index 6eb46f0a8e..8ab772c799 100644 --- a/pkg/sanitize/sanitize_test.go +++ b/pkg/sanitize/sanitize_test.go @@ -858,3 +858,18 @@ func TestSanitizeStillStripsMaliciousContent(t *testing.T) { } var sink string + +func TestContentPreservesMarkdownAndCode(t *testing.T) { + content := "普通 prose with $5 and $x^2$, :rocket:, ✈️, 👩‍💻.\n\n" + + "[link](https://example.com/a?b=c) ![badge](https://example.com/b.svg)\n\n" + + "
Details
cell
\n\n" + + "```uncommon-language\nx & y\n```\n\n" + + " inline `code` and footnote[^1]\n\n[^1]: note" + + require.Equal(t, content, Content(content)) +} + +func TestContentRemovesOnlyUnconditionalInvisibleCharacters(t *testing.T) { + require.Equal(t, "left right", Content("left\u200B right")) + require.Equal(t, "✈️ and 👩‍💻", Content("✈️ and 👩‍💻")) +} diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index f94ecc3ed4..9e8cd0b794 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -42,11 +42,11 @@ The following packages are included for the amd64, arm64 architectures. - [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE)) - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.5/LICENSE)) - - [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](Unknown)) - - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](Unknown)) - - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](Unknown)) - - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](Unknown)) - - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](Unknown)) - - [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](Unknown)) + - [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.55.0:LICENSE)) + - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.36.0:LICENSE)) + - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.20.0:LICENSE)) + - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.45.0:LICENSE)) + - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.37.0:LICENSE)) + - [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.15.0:LICENSE)) [github/github-mcp-server]: https://github.com/github/github-mcp-server diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index d8f98770b3..f5b267f28d 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -4,56 +4,13 @@ The following open source dependencies are used to build the [github/github-mcp- ## Table of Contents -- [amd64](#amd64) -- [386](#386) -- [arm64](#arm64) +- [386, amd64, arm64](#386-amd64-arm64) --- -## amd64 +## 386, amd64, arm64 -The following packages are included for the amd64 architectures. - - - [github.com/aymerick/douceur](https://pkg.go.dev/github.com/aymerick/douceur) ([MIT](https://github.com/aymerick/douceur/blob/v0.2.0/LICENSE)) - - [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.9.0/LICENSE)) - - [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.3.2/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/v89/github](https://pkg.go.dev/github.com/google/go-github/v89/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/34349a88bac3/LICENSE)) - - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.2.0/LICENSE)) - - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.3/LICENSE)) - - [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) - - [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v2.5.0/v2/LICENSE)) - - [github.com/lithammer/fuzzysearch/fuzzy](https://pkg.go.dev/github.com/lithammer/fuzzysearch/fuzzy) ([MIT](https://github.com/lithammer/fuzzysearch/blob/v1.1.8/LICENSE)) - - [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.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.7.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)) - - [github.com/segmentio/asm](https://pkg.go.dev/github.com/segmentio/asm) ([MIT](https://github.com/segmentio/asm/blob/v1.1.3/LICENSE)) - - [github.com/segmentio/encoding](https://pkg.go.dev/github.com/segmentio/encoding) ([MIT](https://github.com/segmentio/encoding/blob/v0.5.4/LICENSE)) - - [github.com/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/48295856cce7/LICENSE)) - - [github.com/shurcooL/graphql](https://pkg.go.dev/github.com/shurcooL/graphql) ([MIT](https://github.com/shurcooL/graphql/blob/ed46e5a46466/LICENSE)) - - [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/5f936abd7ae8/LICENSE)) - - [github.com/spf13/afero](https://pkg.go.dev/github.com/spf13/afero) ([Apache-2.0](https://github.com/spf13/afero/blob/v1.15.0/LICENSE.txt)) - - [github.com/spf13/cast](https://pkg.go.dev/github.com/spf13/cast) ([MIT](https://github.com/spf13/cast/blob/v1.10.0/LICENSE)) - - [github.com/spf13/cobra](https://pkg.go.dev/github.com/spf13/cobra) ([Apache-2.0](https://github.com/spf13/cobra/blob/v1.10.2/LICENSE.txt)) - - [github.com/spf13/pflag](https://pkg.go.dev/github.com/spf13/pflag) ([BSD-3-Clause](https://github.com/spf13/pflag/blob/v1.0.10/LICENSE)) - - [github.com/spf13/viper](https://pkg.go.dev/github.com/spf13/viper) ([MIT](https://github.com/spf13/viper/blob/v1.21.0/LICENSE)) - - [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE)) - - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) - - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.5/LICENSE)) - - [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](Unknown)) - - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](Unknown)) - - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](Unknown)) - - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](Unknown)) - - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](Unknown)) - - [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](Unknown)) - -## 386 - -The following packages are included for the 386 architectures. +The following packages are included for the 386, amd64, arm64 architectures. - [github.com/aymerick/douceur](https://pkg.go.dev/github.com/aymerick/douceur) ([MIT](https://github.com/aymerick/douceur/blob/v0.2.0/LICENSE)) - [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.9.0/LICENSE)) @@ -86,51 +43,10 @@ The following packages are included for the 386 architectures. - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.5/LICENSE)) - [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.55.0:LICENSE)) - - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](Unknown)) - - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](Unknown)) - - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](Unknown)) - - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](Unknown)) + - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.36.0:LICENSE)) + - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.20.0:LICENSE)) + - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.45.0:LICENSE)) + - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.37.0:LICENSE)) - [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](https://cs.opensource.google/go/x/time/+/v0.15.0:LICENSE)) -## arm64 - -The following packages are included for the arm64 architectures. - - - [github.com/aymerick/douceur](https://pkg.go.dev/github.com/aymerick/douceur) ([MIT](https://github.com/aymerick/douceur/blob/v0.2.0/LICENSE)) - - [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.9.0/LICENSE)) - - [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.3.2/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/v89/github](https://pkg.go.dev/github.com/google/go-github/v89/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/34349a88bac3/LICENSE)) - - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.2.0/LICENSE)) - - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.3/LICENSE)) - - [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) - - [github.com/josephburnett/jd/v2](https://pkg.go.dev/github.com/josephburnett/jd/v2) ([MIT](https://github.com/josephburnett/jd/blob/v2.5.0/v2/LICENSE)) - - [github.com/lithammer/fuzzysearch/fuzzy](https://pkg.go.dev/github.com/lithammer/fuzzysearch/fuzzy) ([MIT](https://github.com/lithammer/fuzzysearch/blob/v1.1.8/LICENSE)) - - [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.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.7.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)) - - [github.com/segmentio/asm](https://pkg.go.dev/github.com/segmentio/asm) ([MIT](https://github.com/segmentio/asm/blob/v1.1.3/LICENSE)) - - [github.com/segmentio/encoding](https://pkg.go.dev/github.com/segmentio/encoding) ([MIT](https://github.com/segmentio/encoding/blob/v0.5.4/LICENSE)) - - [github.com/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/48295856cce7/LICENSE)) - - [github.com/shurcooL/graphql](https://pkg.go.dev/github.com/shurcooL/graphql) ([MIT](https://github.com/shurcooL/graphql/blob/ed46e5a46466/LICENSE)) - - [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/5f936abd7ae8/LICENSE)) - - [github.com/spf13/afero](https://pkg.go.dev/github.com/spf13/afero) ([Apache-2.0](https://github.com/spf13/afero/blob/v1.15.0/LICENSE.txt)) - - [github.com/spf13/cast](https://pkg.go.dev/github.com/spf13/cast) ([MIT](https://github.com/spf13/cast/blob/v1.10.0/LICENSE)) - - [github.com/spf13/cobra](https://pkg.go.dev/github.com/spf13/cobra) ([Apache-2.0](https://github.com/spf13/cobra/blob/v1.10.2/LICENSE.txt)) - - [github.com/spf13/pflag](https://pkg.go.dev/github.com/spf13/pflag) ([BSD-3-Clause](https://github.com/spf13/pflag/blob/v1.0.10/LICENSE)) - - [github.com/spf13/viper](https://pkg.go.dev/github.com/spf13/viper) ([MIT](https://github.com/spf13/viper/blob/v1.21.0/LICENSE)) - - [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE)) - - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) - - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.5/LICENSE)) - - [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.55.0:LICENSE)) - - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](Unknown)) - - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](Unknown)) - - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](Unknown)) - - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](Unknown)) - - [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](Unknown)) - [github/github-mcp-server]: https://github.com/github/github-mcp-server diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index dca13b7c2a..7e834bc36f 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -4,99 +4,13 @@ The following open source dependencies are used to build the [github/github-mcp- ## Table of Contents -- [arm64](#arm64) -- [386](#386) -- [amd64](#amd64) +- [386, amd64, arm64](#386-amd64-arm64) --- -## arm64 +## 386, amd64, arm64 -The following packages are included for the arm64 architectures. - - - [github.com/aymerick/douceur](https://pkg.go.dev/github.com/aymerick/douceur) ([MIT](https://github.com/aymerick/douceur/blob/v0.2.0/LICENSE)) - - [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.9.0/LICENSE)) - - [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.3.2/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/v89/github](https://pkg.go.dev/github.com/google/go-github/v89/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/34349a88bac3/LICENSE)) - - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.2.0/LICENSE)) - - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.3/LICENSE)) - - [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) - - [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)) - - [github.com/lithammer/fuzzysearch/fuzzy](https://pkg.go.dev/github.com/lithammer/fuzzysearch/fuzzy) ([MIT](https://github.com/lithammer/fuzzysearch/blob/v1.1.8/LICENSE)) - - [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.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.7.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)) - - [github.com/segmentio/asm](https://pkg.go.dev/github.com/segmentio/asm) ([MIT](https://github.com/segmentio/asm/blob/v1.1.3/LICENSE)) - - [github.com/segmentio/encoding](https://pkg.go.dev/github.com/segmentio/encoding) ([MIT](https://github.com/segmentio/encoding/blob/v0.5.4/LICENSE)) - - [github.com/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/48295856cce7/LICENSE)) - - [github.com/shurcooL/graphql](https://pkg.go.dev/github.com/shurcooL/graphql) ([MIT](https://github.com/shurcooL/graphql/blob/ed46e5a46466/LICENSE)) - - [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/5f936abd7ae8/LICENSE)) - - [github.com/spf13/afero](https://pkg.go.dev/github.com/spf13/afero) ([Apache-2.0](https://github.com/spf13/afero/blob/v1.15.0/LICENSE.txt)) - - [github.com/spf13/cast](https://pkg.go.dev/github.com/spf13/cast) ([MIT](https://github.com/spf13/cast/blob/v1.10.0/LICENSE)) - - [github.com/spf13/cobra](https://pkg.go.dev/github.com/spf13/cobra) ([Apache-2.0](https://github.com/spf13/cobra/blob/v1.10.2/LICENSE.txt)) - - [github.com/spf13/pflag](https://pkg.go.dev/github.com/spf13/pflag) ([BSD-3-Clause](https://github.com/spf13/pflag/blob/v1.0.10/LICENSE)) - - [github.com/spf13/viper](https://pkg.go.dev/github.com/spf13/viper) ([MIT](https://github.com/spf13/viper/blob/v1.21.0/LICENSE)) - - [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE)) - - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) - - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.5/LICENSE)) - - [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](https://cs.opensource.google/go/x/net/+/v0.55.0:LICENSE)) - - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](https://cs.opensource.google/go/x/oauth2/+/v0.36.0:LICENSE)) - - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.20.0:LICENSE)) - - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](Unknown)) - - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.37.0:LICENSE)) - - [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](Unknown)) - -## 386 - -The following packages are included for the 386 architectures. - - - [github.com/aymerick/douceur](https://pkg.go.dev/github.com/aymerick/douceur) ([MIT](https://github.com/aymerick/douceur/blob/v0.2.0/LICENSE)) - - [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.9.0/LICENSE)) - - [github.com/github/github-mcp-server](https://pkg.go.dev/github.com/github/github-mcp-server) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.3.2/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/v89/github](https://pkg.go.dev/github.com/google/go-github/v89/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/34349a88bac3/LICENSE)) - - [github.com/google/go-querystring/query](https://pkg.go.dev/github.com/google/go-querystring/query) ([BSD-3-Clause](https://github.com/google/go-querystring/blob/v1.2.0/LICENSE)) - - [github.com/google/jsonschema-go/jsonschema](https://pkg.go.dev/github.com/google/jsonschema-go/jsonschema) ([MIT](https://github.com/google/jsonschema-go/blob/v0.4.3/LICENSE)) - - [github.com/gorilla/css/scanner](https://pkg.go.dev/github.com/gorilla/css/scanner) ([BSD-3-Clause](https://github.com/gorilla/css/blob/v1.0.1/LICENSE)) - - [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)) - - [github.com/lithammer/fuzzysearch/fuzzy](https://pkg.go.dev/github.com/lithammer/fuzzysearch/fuzzy) ([MIT](https://github.com/lithammer/fuzzysearch/blob/v1.1.8/LICENSE)) - - [github.com/microcosm-cc/bluemonday](https://pkg.go.dev/github.com/microcosm-cc/bluemonday) ([BSD-3-Clause](https://github.com/microcosm-cc/bluemonday/blob/v1.0.27/LICENSE.md)) - - [github.com/modelcontextprotocol/go-sdk](https://pkg.go.dev/github.com/modelcontextprotocol/go-sdk) ([Apache-2.0](https://github.com/modelcontextprotocol/go-sdk/blob/v1.7.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.7.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)) - - [github.com/segmentio/asm](https://pkg.go.dev/github.com/segmentio/asm) ([MIT](https://github.com/segmentio/asm/blob/v1.1.3/LICENSE)) - - [github.com/segmentio/encoding](https://pkg.go.dev/github.com/segmentio/encoding) ([MIT](https://github.com/segmentio/encoding/blob/v0.5.4/LICENSE)) - - [github.com/shurcooL/githubv4](https://pkg.go.dev/github.com/shurcooL/githubv4) ([MIT](https://github.com/shurcooL/githubv4/blob/48295856cce7/LICENSE)) - - [github.com/shurcooL/graphql](https://pkg.go.dev/github.com/shurcooL/graphql) ([MIT](https://github.com/shurcooL/graphql/blob/ed46e5a46466/LICENSE)) - - [github.com/sourcegraph/conc](https://pkg.go.dev/github.com/sourcegraph/conc) ([MIT](https://github.com/sourcegraph/conc/blob/5f936abd7ae8/LICENSE)) - - [github.com/spf13/afero](https://pkg.go.dev/github.com/spf13/afero) ([Apache-2.0](https://github.com/spf13/afero/blob/v1.15.0/LICENSE.txt)) - - [github.com/spf13/cast](https://pkg.go.dev/github.com/spf13/cast) ([MIT](https://github.com/spf13/cast/blob/v1.10.0/LICENSE)) - - [github.com/spf13/cobra](https://pkg.go.dev/github.com/spf13/cobra) ([Apache-2.0](https://github.com/spf13/cobra/blob/v1.10.2/LICENSE.txt)) - - [github.com/spf13/pflag](https://pkg.go.dev/github.com/spf13/pflag) ([BSD-3-Clause](https://github.com/spf13/pflag/blob/v1.0.10/LICENSE)) - - [github.com/spf13/viper](https://pkg.go.dev/github.com/spf13/viper) ([MIT](https://github.com/spf13/viper/blob/v1.21.0/LICENSE)) - - [github.com/subosito/gotenv](https://pkg.go.dev/github.com/subosito/gotenv) ([MIT](https://github.com/subosito/gotenv/blob/v1.6.0/LICENSE)) - - [github.com/yosida95/uritemplate/v3](https://pkg.go.dev/github.com/yosida95/uritemplate/v3) ([BSD-3-Clause](https://github.com/yosida95/uritemplate/blob/v3.0.2/LICENSE)) - - [go.yaml.in/yaml/v3](https://pkg.go.dev/go.yaml.in/yaml/v3) ([MIT](https://github.com/yaml/go-yaml/blob/v3.0.5/LICENSE)) - - [golang.org/x/net](https://pkg.go.dev/golang.org/x/net) ([BSD-3-Clause](Unknown)) - - [golang.org/x/oauth2](https://pkg.go.dev/golang.org/x/oauth2) ([BSD-3-Clause](Unknown)) - - [golang.org/x/sync/errgroup](https://pkg.go.dev/golang.org/x/sync/errgroup) ([BSD-3-Clause](https://cs.opensource.google/go/x/sync/+/v0.20.0:LICENSE)) - - [golang.org/x/sys](https://pkg.go.dev/golang.org/x/sys) ([BSD-3-Clause](Unknown)) - - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](Unknown)) - - [golang.org/x/time/rate](https://pkg.go.dev/golang.org/x/time/rate) ([BSD-3-Clause](Unknown)) - -## amd64 - -The following packages are included for the amd64 architectures. +The following packages are included for the 386, amd64, arm64 architectures. - [github.com/aymerick/douceur](https://pkg.go.dev/github.com/aymerick/douceur) ([MIT](https://github.com/aymerick/douceur/blob/v0.2.0/LICENSE)) - [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.9.0/LICENSE)) From cf3a4bbf4aa389f23939baf297376adb6afcdb08 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:26:51 +0200 Subject: [PATCH 22/61] build(deps): bump actions/stale from 10 to 11 (#3003) Bumps [actions/stale](https://github.com/actions/stale) from 10 to 11. - [Release notes](https://github.com/actions/stale/releases) - [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md) - [Commits](https://github.com/actions/stale/compare/v10...v11) --- updated-dependencies: - dependency-name: actions/stale dependency-version: '11' 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/close-inactive-issues.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/close-inactive-issues.yml b/.github/workflows/close-inactive-issues.yml index 84d91d1e48..beb701bc37 100644 --- a/.github/workflows/close-inactive-issues.yml +++ b/.github/workflows/close-inactive-issues.yml @@ -14,7 +14,7 @@ jobs: issues: write pull-requests: write steps: - - uses: actions/stale@v10 + - uses: actions/stale@v11 with: days-before-issue-stale: ${{ env.PR_DAYS_BEFORE_STALE }} days-before-issue-close: ${{ env.PR_DAYS_BEFORE_CLOSE }} From 9205304fedf10540c33ae41fcf6352fa97dcc9be Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 3 Sep 2026 15:56:57 +0200 Subject: [PATCH 23/61] fix: restore sanitizer content boundaries (#3219) Preserve Markdown and HTML in body and commit-message fields while stripping invisible controls. Apply plain-text sanitization consistently to raw release and sub-issue titles and restore blame headline handling. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/github/issues.go | 2 +- pkg/github/issues_test.go | 32 ++++++++++++++++---------------- pkg/github/minimal_types.go | 14 +++++++------- pkg/github/repositories.go | 16 +++++++++++++++- pkg/github/repositories_test.go | 18 +++++++++--------- 5 files changed, 48 insertions(+), 34 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 39682c0db7..cc8bc599a1 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -2013,7 +2013,7 @@ func sanitizeSubIssueTitleAndBody(issue *github.SubIssue) { return } if issue.Title != nil { - issue.Title = github.Ptr(sanitize.Sanitize(*issue.Title)) + issue.Title = github.Ptr(sanitize.PlainText(*issue.Title)) } if issue.Body != nil { issue.Body = github.Ptr(sanitize.Content(*issue.Body)) diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 321fa09ffc..83bedc5b54 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -5774,8 +5774,8 @@ func Test_AddSubIssue(t *testing.T) { // Setup mock issue for success case (matches GitHub API response format) mockIssue := &github.Issue{ Number: github.Ptr(42), - Title: github.Ptr("\u200B"), - Body: github.Ptr("This is **Markdown**\u200B"), + Title: github.Ptr("can't \"quote\" AT&T\u200B"), + Body: github.Ptr("This is **Markdown**\u200B"), State: github.Ptr("open"), HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), User: &github.User{ @@ -5970,8 +5970,8 @@ func Test_AddSubIssue(t *testing.T) { err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) require.NoError(t, err) assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) - assert.Empty(t, *returnedIssue.Title) - assert.Equal(t, "This is **Markdown**", *returnedIssue.Body) + assert.Equal(t, "can't \"quote\" AT&T", *returnedIssue.Title) + assert.Equal(t, "This is **Markdown**", *returnedIssue.Body) assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login) @@ -5999,8 +5999,8 @@ func Test_GetSubIssues(t *testing.T) { mockSubIssues := []*github.Issue{ { Number: github.Ptr(123), - Title: github.Ptr("\u200B"), - Body: github.Ptr("This is **Markdown**\u200B"), + Title: github.Ptr("can't \"quote\" AT&T\u200B"), + Body: github.Ptr("This is **Markdown**\u200B"), State: github.Ptr("open"), HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), User: &github.User{ @@ -6200,8 +6200,8 @@ func Test_GetSubIssues(t *testing.T) { if i < len(tc.expectedSubIssues) { assert.Equal(t, *tc.expectedSubIssues[i].Number, *subIssue.Number) if i == 0 { - assert.Empty(t, *subIssue.Title) - assert.Equal(t, "This is **Markdown**", *subIssue.Body) + assert.Equal(t, "can't \"quote\" AT&T", *subIssue.Title) + assert.Equal(t, "This is **Markdown**", *subIssue.Body) } else { assert.Equal(t, *tc.expectedSubIssues[i].Title, *subIssue.Title) } @@ -6657,8 +6657,8 @@ func Test_RemoveSubIssue(t *testing.T) { // Setup mock issue for success case (matches GitHub API response format - the updated parent issue) mockIssue := &github.Issue{ Number: github.Ptr(42), - Title: github.Ptr("\u200B"), - Body: github.Ptr("This is **Markdown**\u200B"), + Title: github.Ptr("can't \"quote\" AT&T\u200B"), + Body: github.Ptr("This is **Markdown**\u200B"), State: github.Ptr("open"), HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), User: &github.User{ @@ -6836,8 +6836,8 @@ func Test_RemoveSubIssue(t *testing.T) { err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) require.NoError(t, err) assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) - assert.Empty(t, *returnedIssue.Title) - assert.Equal(t, "This is **Markdown**", *returnedIssue.Body) + assert.Equal(t, "can't \"quote\" AT&T", *returnedIssue.Title) + assert.Equal(t, "This is **Markdown**", *returnedIssue.Body) assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login) @@ -6865,8 +6865,8 @@ func Test_ReprioritizeSubIssue(t *testing.T) { // Setup mock issue for success case (matches GitHub API response format - the updated parent issue) mockIssue := &github.Issue{ Number: github.Ptr(42), - Title: github.Ptr("\u200B"), - Body: github.Ptr("This is **Markdown**\u200B"), + Title: github.Ptr("can't \"quote\" AT&T\u200B"), + Body: github.Ptr("This is **Markdown**\u200B"), State: github.Ptr("open"), HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), User: &github.User{ @@ -7096,8 +7096,8 @@ func Test_ReprioritizeSubIssue(t *testing.T) { err = json.Unmarshal([]byte(textContent.Text), &returnedIssue) require.NoError(t, err) assert.Equal(t, *tc.expectedIssue.Number, *returnedIssue.Number) - assert.Empty(t, *returnedIssue.Title) - assert.Equal(t, "This is **Markdown**", *returnedIssue.Body) + assert.Equal(t, "can't \"quote\" AT&T", *returnedIssue.Title) + assert.Equal(t, "This is **Markdown**", *returnedIssue.Body) assert.Equal(t, *tc.expectedIssue.State, *returnedIssue.State) assert.Equal(t, *tc.expectedIssue.HTMLURL, *returnedIssue.HTMLURL) assert.Equal(t, *tc.expectedIssue.User.Login, *returnedIssue.User.Login) diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 3e72e3631f..2eba9a1628 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -204,7 +204,7 @@ type MinimalDiscussionComment struct { func newMinimalDiscussionComment(id string, body string, isAnswer bool) MinimalDiscussionComment { return MinimalDiscussionComment{ ID: id, - Body: sanitize.Sanitize(body), + Body: sanitize.Content(body), IsAnswer: isAnswer, } } @@ -1015,7 +1015,7 @@ func convertToMinimalIssuesResponseWithoutFieldValues(fragment issueQueryFragmen func convertToMinimalIssueComment(comment *github.IssueComment) MinimalIssueComment { m := MinimalIssueComment{ ID: comment.GetID(), - Body: sanitize.Sanitize(comment.GetBody()), + Body: sanitize.Content(comment.GetBody()), HTMLURL: comment.GetHTMLURL(), User: convertToMinimalUser(comment.GetUser()), AuthorAssociation: comment.GetAuthorAssociation(), @@ -1064,7 +1064,7 @@ func convertToMinimalFileContentResponse(resp *github.RepositoryContentResponse) m.Commit = &MinimalFileCommit{ SHA: resp.Commit.GetSHA(), - Message: sanitize.Sanitize(resp.Commit.GetMessage()), + Message: sanitize.Content(resp.Commit.GetMessage()), HTMLURL: resp.Commit.GetHTMLURL(), } @@ -1794,7 +1794,7 @@ func newMinimalCommitFromCore(sha, htmlURL string, commit *github.Commit, author if commit != nil { minimalCommit.Commit = &MinimalCommitInfo{ - Message: sanitize.Sanitize(commit.GetMessage()), + Message: sanitize.Content(commit.GetMessage()), } if commit.Author != nil { @@ -2000,7 +2000,7 @@ func convertToMinimalPullRequestCommits(commits []*github.RepositoryCommit) []Mi } if commit.Commit != nil { - minimalCommit.Message = sanitize.Sanitize(commit.Commit.GetMessage()) + minimalCommit.Message = sanitize.Content(commit.Commit.GetMessage()) minimalCommit.Author = convertToMinimalCommitAuthor(commit.Commit.Author) } @@ -2095,7 +2095,7 @@ func convertToMinimalWorkflowRun(workflowRun *github.WorkflowRun) MinimalWorkflo if headCommit := workflowRun.GetHeadCommit(); headCommit != nil && headCommit.GetMessage() != "" { minimalRun.HeadCommit = &MinimalWorkflowRunHeadCommit{ - Message: sanitize.Sanitize(headCommit.GetMessage()), + Message: sanitize.Content(headCommit.GetMessage()), } } @@ -2280,7 +2280,7 @@ func convertToMinimalReviewThread(thread reviewThreadNode) MinimalReviewThread { func convertToMinimalReviewComment(c reviewCommentNode) MinimalReviewComment { m := MinimalReviewComment{ - Body: sanitize.Sanitize(string(c.Body)), + Body: sanitize.Content(string(c.Body)), Path: string(c.Path), Author: string(c.Author.Login), HTMLURL: c.URL.String(), diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index f98050c8a4..8e2dd25172 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -2233,6 +2233,7 @@ func GetLatestRelease(t translations.TranslationHelperFunc) inventory.ServerTool return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get latest release", resp, body), nil, nil } + sanitizeReleaseNameAndBody(release) r, err := json.Marshal(release) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) @@ -2319,6 +2320,7 @@ func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get release by tag", resp, body), nil, nil } + sanitizeReleaseNameAndBody(release) r, err := json.Marshal(release) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) @@ -2338,6 +2340,18 @@ func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool ) } +func sanitizeReleaseNameAndBody(release *github.RepositoryRelease) { + if release == nil { + return + } + if release.Name != nil { + release.Name = github.Ptr(sanitize.PlainText(*release.Name)) + } + if release.Body != nil { + release.Body = github.Ptr(sanitize.Content(*release.Body)) + } +} + // ListStarredRepositories creates a tool to list starred repositories for the authenticated user or a specified user. func ListStarredRepositories(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( @@ -2981,7 +2995,7 @@ func GetFileBlame(t translations.TranslationHelperFunc) inventory.ServerTool { SHA: sha, // Sanitized after truncation so the headline is cut at the author's real // first line break rather than one introduced by sanitization. - MessageHeadline: sanitize.Content(headline), + MessageHeadline: sanitize.PlainText(headline), CommittedDate: r.Commit.CommittedDate.Format("2006-01-02T15:04:05Z"), Author: BlameAuthor{ Name: string(r.Commit.Author.Name), diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 601702306a..8194895afa 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -4854,8 +4854,8 @@ func Test_GetLatestRelease(t *testing.T) { mockRelease := &github.RepositoryRelease{ ID: 1, TagName: "v1.0.0", - Name: github.Ptr("First Release"), - Body: github.Ptr("
Notes
\u200B"), + Name: github.Ptr("can't \"quote\" AT&T\u200B"), + Body: github.Ptr("
Notes
\u200B"), } tests := []struct { @@ -4923,8 +4923,8 @@ func Test_GetLatestRelease(t *testing.T) { err = json.Unmarshal([]byte(textContent.Text), &returnedRelease) require.NoError(t, err) assert.Equal(t, tc.expectedResult.TagName, returnedRelease.TagName) - assert.Equal(t, "First Release", *returnedRelease.Name) - assert.Equal(t, "
Notes
", *returnedRelease.Body) + assert.Equal(t, "can't \"quote\" AT&T", *returnedRelease.Name) + assert.Equal(t, "
Notes
", *returnedRelease.Body) }) } } @@ -4947,8 +4947,8 @@ func Test_GetReleaseByTag(t *testing.T) { mockRelease := &github.RepositoryRelease{ ID: 1, TagName: "v1.0.0", - Name: github.Ptr("Release v1.0.0"), - Body: github.Ptr("
Notes
\u200B"), + Name: github.Ptr("can't \"quote\" AT&T\u200B"), + Body: github.Ptr("
Notes
\u200B"), Assets: []*github.ReleaseAsset{ { ID: github.Ptr(int64(1)), @@ -5088,9 +5088,9 @@ func Test_GetReleaseByTag(t *testing.T) { assert.Equal(t, tc.expectedResult.ID, returnedRelease.ID) assert.Equal(t, tc.expectedResult.TagName, returnedRelease.TagName) - assert.Equal(t, *tc.expectedResult.Name, *returnedRelease.Name) + assert.Equal(t, "can't \"quote\" AT&T", *returnedRelease.Name) if tc.expectedResult.Body != nil { - assert.Equal(t, "
Notes
", *returnedRelease.Body) + assert.Equal(t, "
Notes
", *returnedRelease.Body) } if len(tc.expectedResult.Assets) > 0 { require.Len(t, returnedRelease.Assets, len(tc.expectedResult.Assets)) @@ -6203,7 +6203,7 @@ func Test_GetFileBlame(t *testing.T) { var br BlameResult require.NoError(t, json.Unmarshal([]byte(result), &br)) require.Contains(t, br.Commits, "badc0ffee0000") - assert.Equal(t, sanitizedContentText, br.Commits["badc0ffee0000"].MessageHeadline) + assert.Equal(t, sanitizedText, br.Commits["badc0ffee0000"].MessageHeadline) assert.NotContains(t, result, "can't \"quote\" AT&T\u200B"), - Body: github.Ptr("This is **Markdown**\u200B"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), + Number: new(42), + Title: new("can't \"quote\" AT&T\u200B"), + Body: new("This is **Markdown**\u200B"), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/issues/42"), User: &github.User{ - Login: github.Ptr("testuser"), + Login: new("testuser"), }, Labels: []*github.Label{ { Name: "enhancement", Color: "84b6eb", - Description: github.Ptr("New feature or request"), + Description: new("New feature or request"), }, }, } @@ -5998,33 +5998,33 @@ func Test_GetSubIssues(t *testing.T) { // Setup mock sub-issues for success case mockSubIssues := []*github.Issue{ { - Number: github.Ptr(123), - Title: github.Ptr("can't \"quote\" AT&T\u200B"), - Body: github.Ptr("This is **Markdown**\u200B"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), + Number: new(123), + Title: new("can't \"quote\" AT&T\u200B"), + Body: new("This is **Markdown**\u200B"), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/issues/123"), User: &github.User{ - Login: github.Ptr("user1"), + Login: new("user1"), }, Labels: []*github.Label{ { Name: "bug", Color: "d73a4a", - Description: github.Ptr("Something isn't working"), + Description: new("Something isn't working"), }, }, }, { - Number: github.Ptr(124), - Title: github.Ptr("Sub-issue 2"), - Body: github.Ptr("This is the second sub-issue"), - State: github.Ptr("closed"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/124"), + Number: new(124), + Title: new("Sub-issue 2"), + Body: new("This is the second sub-issue"), + State: new("closed"), + HTMLURL: new("https://github.com/owner/repo/issues/124"), User: &github.User{ - Login: github.Ptr("user2"), + Login: new("user2"), }, Assignees: []*github.User{ - {Login: github.Ptr("assignee1")}, + {Login: new("assignee1")}, }, }, } @@ -6321,17 +6321,17 @@ func TestAddIssueCommentHandler(t *testing.T) { serverTool := AddIssueComment(translations.NullTranslationHelper) mockComment := &github.IssueComment{ - ID: github.Ptr(int64(456)), - Body: github.Ptr("This is a comment"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42#issuecomment-456"), + ID: new(int64(456)), + Body: new("This is a comment"), + HTMLURL: new("https://github.com/owner/repo/issues/42#issuecomment-456"), } mockReaction := &github.Reaction{ - ID: github.Ptr(int64(789)), - Content: github.Ptr("heart"), + ID: new(int64(789)), + Content: new("heart"), } mockIssueComment := &github.IssueComment{ - ID: github.Ptr(int64(999)), - IssueURL: github.Ptr("https://api.github.com/repos/owner/repo/issues/42"), + ID: new(int64(999)), + IssueURL: new("https://api.github.com/repos/owner/repo/issues/42"), } commentCreatedAfterReactionFailure := &atomic.Bool{} @@ -6387,8 +6387,8 @@ func TestAddIssueCommentHandler(t *testing.T) { name: "issue comment reaction requires matching issue_number", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposIssuesCommentByOwnerByRepoByCommentID: mockResponse(t, http.StatusOK, &github.IssueComment{ - ID: github.Ptr(int64(999)), - IssueURL: github.Ptr("https://api.github.com/repos/owner/repo/issues/43"), + ID: new(int64(999)), + IssueURL: new("https://api.github.com/repos/owner/repo/issues/43"), }), }), requestArgs: map[string]any{ @@ -6717,9 +6717,9 @@ func TestUpdateIssueCommentHandler(t *testing.T) { t.Parallel() updatedComment := &github.IssueComment{ - ID: github.Ptr(int64(456)), - Body: github.Ptr("Updated comment"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42#issuecomment-456"), + ID: new(int64(456)), + Body: new("Updated comment"), + HTMLURL: new("https://github.com/owner/repo/issues/42#issuecomment-456"), } tests := []struct { @@ -6847,19 +6847,19 @@ func Test_RemoveSubIssue(t *testing.T) { // Setup mock issue for success case (matches GitHub API response format - the updated parent issue) mockIssue := &github.Issue{ - Number: github.Ptr(42), - Title: github.Ptr("can't \"quote\" AT&T\u200B"), - Body: github.Ptr("This is **Markdown**\u200B"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), + Number: new(42), + Title: new("can't \"quote\" AT&T\u200B"), + Body: new("This is **Markdown**\u200B"), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/issues/42"), User: &github.User{ - Login: github.Ptr("testuser"), + Login: new("testuser"), }, Labels: []*github.Label{ { Name: "enhancement", Color: "84b6eb", - Description: github.Ptr("New feature or request"), + Description: new("New feature or request"), }, }, } @@ -7055,19 +7055,19 @@ func Test_ReprioritizeSubIssue(t *testing.T) { // Setup mock issue for success case (matches GitHub API response format - the updated parent issue) mockIssue := &github.Issue{ - Number: github.Ptr(42), - Title: github.Ptr("can't \"quote\" AT&T\u200B"), - Body: github.Ptr("This is **Markdown**\u200B"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/42"), + Number: new(42), + Title: new("can't \"quote\" AT&T\u200B"), + Body: new("This is **Markdown**\u200B"), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/issues/42"), User: &github.User{ - Login: github.Ptr("testuser"), + Login: new("testuser"), }, Labels: []*github.Label{ { Name: "enhancement", Color: "84b6eb", - Description: github.Ptr("New feature or request"), + Description: new("New feature or request"), }, }, } @@ -7310,16 +7310,16 @@ func Test_ListIssueTypes(t *testing.T) { // Setup mock issue types for success case mockIssueTypes := []*github.IssueType{ { - ID: github.Ptr(int64(1)), - Name: github.Ptr("bug"), - Description: github.Ptr("Something isn't working"), - Color: github.Ptr("d73a4a"), + ID: new(int64(1)), + Name: new("bug"), + Description: new("Something isn't working"), + Color: new("d73a4a"), }, { - ID: github.Ptr(int64(2)), - Name: github.Ptr("feature"), - Description: github.Ptr("New feature or enhancement"), - Color: github.Ptr("a2eeef"), + ID: new(int64(2)), + Name: new("feature"), + Description: new("New feature or enhancement"), + Color: new("a2eeef"), }, } diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 2b06b279d5..2bb9efba35 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -1211,19 +1211,19 @@ func convertToMinimalProject(fullProject *github.ProjectV2) *MinimalProject { } return &MinimalProject{ - ID: github.Ptr(fullProject.GetID()), - NodeID: github.Ptr(fullProject.GetNodeID()), + ID: new(fullProject.GetID()), + NodeID: new(fullProject.GetNodeID()), Owner: convertToMinimalUser(fullProject.GetOwner()), Creator: convertToMinimalUser(fullProject.GetCreator()), - Title: github.Ptr(fullProject.GetTitle()), - Description: github.Ptr(fullProject.GetDescription()), - Public: github.Ptr(fullProject.GetPublic()), - ClosedAt: github.Ptr(fullProject.GetClosedAt()), - CreatedAt: github.Ptr(fullProject.GetCreatedAt()), - UpdatedAt: github.Ptr(fullProject.GetUpdatedAt()), - DeletedAt: github.Ptr(fullProject.GetDeletedAt()), - Number: github.Ptr(fullProject.GetNumber()), - ShortDescription: github.Ptr(fullProject.GetShortDescription()), + Title: new(fullProject.GetTitle()), + Description: new(fullProject.GetDescription()), + Public: new(fullProject.GetPublic()), + ClosedAt: new(fullProject.GetClosedAt()), + CreatedAt: new(fullProject.GetCreatedAt()), + UpdatedAt: new(fullProject.GetUpdatedAt()), + DeletedAt: new(fullProject.GetDeletedAt()), + Number: new(fullProject.GetNumber()), + ShortDescription: new(fullProject.GetShortDescription()), DeletedBy: convertToMinimalUser(fullProject.GetDeletedBy()), } } diff --git a/pkg/github/notifications_test.go b/pkg/github/notifications_test.go index c7fc296cb0..09ee191914 100644 --- a/pkg/github/notifications_test.go +++ b/pkg/github/notifications_test.go @@ -35,8 +35,8 @@ func Test_ListNotifications(t *testing.T) { // All fields are optional, so Required should be empty assert.Empty(t, schema.Required) mockNotification := &github.Notification{ - ID: github.Ptr("123"), - Reason: github.Ptr("mention"), + ID: new("123"), + Reason: new("mention"), } tests := []struct { @@ -156,8 +156,8 @@ func Test_ManageNotificationSubscription(t *testing.T) { assert.Contains(t, schema.Properties, "action") assert.Equal(t, []string{"notificationID", "action"}, schema.Required) - mockSub := &github.Subscription{Ignored: github.Ptr(true)} - mockSubWatch := &github.Subscription{Ignored: github.Ptr(false), Subscribed: github.Ptr(true)} + mockSub := &github.Subscription{Ignored: new(true)} + mockSubWatch := &github.Subscription{Ignored: new(false), Subscribed: new(true)} tests := []struct { name string @@ -179,7 +179,7 @@ func Test_ManageNotificationSubscription(t *testing.T) { "action": "ignore", }, expectError: false, - expectIgnored: github.Ptr(true), + expectIgnored: new(true), }, { name: "watch subscription", @@ -191,7 +191,7 @@ func Test_ManageNotificationSubscription(t *testing.T) { "action": "watch", }, expectError: false, - expectIgnored: github.Ptr(false), + expectIgnored: new(false), }, { name: "delete subscription", @@ -296,8 +296,8 @@ func Test_ManageRepositoryNotificationSubscription(t *testing.T) { assert.Contains(t, schema.Properties, "action") assert.Equal(t, []string{"owner", "repo", "action"}, schema.Required) - mockSub := &github.Subscription{Ignored: github.Ptr(true)} - mockWatchSub := &github.Subscription{Ignored: github.Ptr(false), Subscribed: github.Ptr(true)} + mockSub := &github.Subscription{Ignored: new(true)} + mockWatchSub := &github.Subscription{Ignored: new(false), Subscribed: new(true)} tests := []struct { name string @@ -321,7 +321,7 @@ func Test_ManageRepositoryNotificationSubscription(t *testing.T) { "action": "ignore", }, expectError: false, - expectIgnored: github.Ptr(true), + expectIgnored: new(true), }, { name: "watch subscription", @@ -334,8 +334,8 @@ func Test_ManageRepositoryNotificationSubscription(t *testing.T) { "action": "watch", }, expectError: false, - expectIgnored: github.Ptr(false), - expectSubscribed: github.Ptr(true), + expectIgnored: new(false), + expectSubscribed: new(true), }, { name: "delete subscription", @@ -679,7 +679,7 @@ func Test_GetNotificationDetails(t *testing.T) { assert.Contains(t, schema.Properties, "notificationID") assert.Equal(t, []string{"notificationID"}, schema.Required) - mockThread := &github.Notification{ID: github.Ptr("123"), Reason: github.Ptr("mention")} + mockThread := &github.Notification{ID: new("123"), Reason: new("mention")} tests := []struct { name string diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 256f5af9d8..6305569c3a 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -1488,7 +1488,7 @@ func Test_BuildIssueFieldUpdate(t *testing.T) { func Test_ProjectItemIssueID_RejectsNonIssueItems(t *testing.T) { for _, contentType := range []string{"PullRequest", "DraftIssue"} { t.Run(contentType, func(t *testing.T) { - item := &gogithub.ProjectV2Item{ContentType: gogithub.Ptr(gogithub.ProjectV2ItemContentType(contentType))} + item := &gogithub.ProjectV2Item{ContentType: new(gogithub.ProjectV2ItemContentType(contentType))} _, err := projectItemIssueID(item) var structured *ghErrors.StructuredResolutionError require.ErrorAs(t, err, &structured) diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 42d99ab9bb..98354ac70b 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -777,17 +777,17 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo } newPR := &github.CreatePullRequest{ - Title: github.Ptr(title), + Title: new(title), Head: head, Base: base, } if body != "" { - newPR.Body = github.Ptr(body) + newPR.Body = new(body) } - newPR.Draft = github.Ptr(draft) - newPR.MaintainerCanModify = github.Ptr(maintainerCanModify) + newPR.Draft = new(draft) + newPR.MaintainerCanModify = new(maintainerCanModify) client, err := deps.GetClient(ctx) if err != nil { @@ -969,35 +969,35 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo if title, ok, err := OptionalParamOK[string](args, "title"); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } else if ok { - update.Title = github.Ptr(title) + update.Title = new(title) restUpdateNeeded = true } if body, ok, err := OptionalParamOK[string](args, "body"); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } else if ok { - update.Body = github.Ptr(body) + update.Body = new(body) restUpdateNeeded = true } if state, ok, err := OptionalParamOK[string](args, "state"); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } else if ok { - update.State = github.Ptr(state) + update.State = new(state) restUpdateNeeded = true } if base, ok, err := OptionalParamOK[string](args, "base"); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } else if ok { - update.Base = &github.PullRequestBranch{Ref: github.Ptr(base)} + update.Base = &github.PullRequestBranch{Ref: new(base)} restUpdateNeeded = true } if maintainerCanModify, ok, err := OptionalParamOK[bool](args, "maintainer_can_modify"); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } else if ok { - update.MaintainerCanModify = github.Ptr(maintainerCanModify) + update.MaintainerCanModify = new(maintainerCanModify) restUpdateNeeded = true } @@ -1732,7 +1732,7 @@ func UpdatePullRequestBranch(t translations.TranslationHelperFunc) inventory.Ser } opts := &github.PullRequestBranchUpdateOptions{} if expectedHeadSHA != "" { - opts.ExpectedHeadSHA = github.Ptr(expectedHeadSHA) + opts.ExpectedHeadSHA = new(expectedHeadSHA) } client, err := deps.GetClient(ctx) diff --git a/pkg/github/pullrequests_sha_pinning_test.go b/pkg/github/pullrequests_sha_pinning_test.go index b1a6df3025..02f8b758a3 100644 --- a/pkg/github/pullrequests_sha_pinning_test.go +++ b/pkg/github/pullrequests_sha_pinning_test.go @@ -17,9 +17,9 @@ func Test_MergePullRequestSHAPinning(t *testing.T) { const pinnedSHA = "0123456789abcdef0123456789abcdef01234567" mockMergeResult := &github.PullRequestMergeResult{ - SHA: github.Ptr("merge-result-sha"), - Merged: github.Ptr(true), - Message: github.Ptr("Pull Request successfully merged"), + SHA: new("merge-result-sha"), + Merged: new(true), + Message: new("Pull Request successfully merged"), } t.Run("expected head SHA is sent as REST sha", func(t *testing.T) { diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index d06fe72806..a6bb796839 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -36,20 +36,20 @@ func Test_GetPullRequest(t *testing.T) { // Setup mock PR for success case mockPR := &github.PullRequest{ - Number: github.Ptr(42), - Title: github.Ptr("Test PR"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), + Number: new(42), + Title: new("Test PR"), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/pull/42"), Head: &github.PullRequestBranch{ - SHA: github.Ptr("abcd1234"), - Ref: github.Ptr("feature-branch"), + SHA: new("abcd1234"), + Ref: new("feature-branch"), }, Base: &github.PullRequestBranch{ - Ref: github.Ptr("main"), + Ref: new("main"), }, - Body: github.Ptr("This is a test PR"), + Body: new("This is a test PR"), User: &github.User{ - Login: github.Ptr("testuser"), + Login: new("testuser"), }, } @@ -203,32 +203,32 @@ func Test_UpdatePullRequest(t *testing.T) { // Setup mock PR for success case mockUpdatedPR := &github.PullRequest{ - Number: github.Ptr(42), - Title: github.Ptr("Updated Test PR Title"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), - Body: github.Ptr("Updated test PR body."), - MaintainerCanModify: github.Ptr(false), - Draft: github.Ptr(false), + Number: new(42), + Title: new("Updated Test PR Title"), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/pull/42"), + Body: new("Updated test PR body."), + MaintainerCanModify: new(false), + Draft: new(false), Base: &github.PullRequestBranch{ - Ref: github.Ptr("develop"), + Ref: new("develop"), }, } mockClosedPR := &github.PullRequest{ - Number: github.Ptr(42), - Title: github.Ptr("Test PR"), - State: github.Ptr("closed"), // State updated + Number: new(42), + Title: new("Test PR"), + State: new("closed"), // State updated } // Mock PR for when there are no updates but we still need a response mockPRWithReviewers := &github.PullRequest{ - Number: github.Ptr(42), - Title: github.Ptr("Test PR"), - State: github.Ptr("open"), + Number: new(42), + Title: new("Test PR"), + State: new("open"), RequestedReviewers: []*github.User{ - {Login: github.Ptr("reviewer1")}, - {Login: github.Ptr("reviewer2")}, + {Login: new("reviewer1")}, + {Login: new("reviewer2")}, }, } @@ -430,15 +430,15 @@ func Test_UpdatePullRequest(t *testing.T) { func Test_UpdatePullRequest_Draft(t *testing.T) { // Setup mock PR for success case mockUpdatedPR := &github.PullRequest{ - Number: github.Ptr(42), - Title: github.Ptr("Test PR Title"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), - Body: github.Ptr("Test PR body."), - MaintainerCanModify: github.Ptr(false), - Draft: github.Ptr(false), // Updated to ready for review + Number: new(42), + Title: new("Test PR Title"), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/pull/42"), + Body: new("Test PR body."), + MaintainerCanModify: new(false), + Draft: new(false), // Updated to ready for review Base: &github.PullRequestBranch{ - Ref: github.Ptr("main"), + Ref: new("main"), }, } @@ -635,16 +635,16 @@ func Test_ListPullRequests(t *testing.T) { // Setup mock PRs for success case mockPRs := []*github.PullRequest{ { - Number: github.Ptr(42), - Title: github.Ptr("First PR"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), + Number: new(42), + Title: new("First PR"), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/pull/42"), }, { - Number: github.Ptr(43), - Title: github.Ptr("Second PR"), - State: github.Ptr("closed"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/43"), + Number: new(43), + Title: new("Second PR"), + State: new("closed"), + HTMLURL: new("https://github.com/owner/repo/pull/43"), }, } @@ -765,9 +765,9 @@ func Test_MergePullRequest(t *testing.T) { // Setup mock merge result for success case mockMergeResult := &github.PullRequestMergeResult{ - Merged: github.Ptr(true), - Message: github.Ptr("Pull Request successfully merged"), - SHA: github.Ptr("abcd1234efgh5678"), + Merged: new(true), + Message: new("Pull Request successfully merged"), + SHA: new("abcd1234efgh5678"), } tests := []struct { @@ -879,29 +879,29 @@ func Test_SearchPullRequests(t *testing.T) { assert.ElementsMatch(t, schema.Required, []string{"query"}) mockSearchResult := &github.IssuesSearchResult{ - Total: github.Ptr(2), - IncompleteResults: github.Ptr(false), + Total: new(2), + IncompleteResults: new(false), Issues: []*github.Issue{ { - Number: github.Ptr(42), - Title: github.Ptr("Test PR 1"), - Body: github.Ptr("Updated tests."), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/1"), - Comments: github.Ptr(5), + Number: new(42), + Title: new("Test PR 1"), + Body: new("Updated tests."), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/pull/1"), + Comments: new(5), User: &github.User{ - Login: github.Ptr("user1"), + Login: new("user1"), }, }, { - Number: github.Ptr(43), - Title: github.Ptr("Test PR 2"), - Body: github.Ptr("Updated build scripts."), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/2"), - Comments: github.Ptr(3), + Number: new(43), + Title: new("Test PR 2"), + Body: new("Updated build scripts."), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/pull/2"), + Comments: new(3), User: &github.User{ - Login: github.Ptr("user2"), + Login: new("user2"), }, }, }, @@ -1168,20 +1168,20 @@ func Test_GetPullRequestFiles(t *testing.T) { // Setup mock PR files for success case mockFiles := []*github.CommitFile{ { - Filename: github.Ptr("file1.go"), - Status: github.Ptr("modified"), - Additions: github.Ptr(10), - Deletions: github.Ptr(5), - Changes: github.Ptr(15), - Patch: github.Ptr("@@ -1,5 +1,10 @@"), + Filename: new("file1.go"), + Status: new("modified"), + Additions: new(10), + Deletions: new(5), + Changes: new(15), + Patch: new("@@ -1,5 +1,10 @@"), }, { - Filename: github.Ptr("file2.go"), - Status: github.Ptr("added"), - Additions: github.Ptr(20), - Deletions: github.Ptr(0), - Changes: github.Ptr(20), - Patch: github.Ptr("@@ -0,0 +1,20 @@"), + Filename: new("file2.go"), + Status: new("added"), + Additions: new(20), + Deletions: new(0), + Changes: new(20), + Patch: new("@@ -0,0 +1,20 @@"), }, } @@ -1261,8 +1261,8 @@ func Test_GetPullRequestFiles(t *testing.T) { name: "lockdown enabled - author lacks push access", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, &github.PullRequest{ - Number: github.Ptr(42), - User: &github.User{Login: github.Ptr("reader")}, + Number: new(42), + User: &github.User{Login: new("reader")}, }), }), requestArgs: map[string]any{ @@ -1280,8 +1280,8 @@ func Test_GetPullRequestFiles(t *testing.T) { name: "lockdown enabled - author has push access", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, &github.PullRequest{ - Number: github.Ptr(42), - User: &github.User{Login: github.Ptr("writer")}, + Number: new(42), + User: &github.User{Login: new("writer")}, }), GetReposPullsFilesByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockFiles), }), @@ -1391,39 +1391,39 @@ func Test_GetPullRequestCommits(t *testing.T) { authorDate := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC) mockCommits := []*github.RepositoryCommit{ { - SHA: github.Ptr("abc123def456"), - HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"), + SHA: new("abc123def456"), + HTMLURL: new("https://github.com/owner/repo/commit/abc123def456"), Commit: &github.Commit{ - Message: github.Ptr("feat: add commit listing"), + Message: new("feat: add commit listing"), Author: &github.CommitAuthor{ - Name: github.Ptr("Test User"), - Email: github.Ptr("test@example.com"), + Name: new("Test User"), + Email: new("test@example.com"), Date: &github.Timestamp{Time: authorDate}, }, Committer: &github.CommitAuthor{ - Name: github.Ptr("Merge Bot"), - Email: github.Ptr("merge@example.com"), + Name: new("Merge Bot"), + Email: new("merge@example.com"), Date: &github.Timestamp{Time: authorDate.Add(30 * time.Minute)}, }, }, Author: &github.User{ - Login: github.Ptr("test-user"), - ID: github.Ptr(int64(12345)), - HTMLURL: github.Ptr("https://github.com/test-user"), - AvatarURL: github.Ptr("https://github.com/test-user.png"), + Login: new("test-user"), + ID: new(int64(12345)), + HTMLURL: new("https://github.com/test-user"), + AvatarURL: new("https://github.com/test-user.png"), }, Committer: &github.User{ - Login: github.Ptr("merge-bot"), - ID: github.Ptr(int64(67890)), - HTMLURL: github.Ptr("https://github.com/merge-bot"), - AvatarURL: github.Ptr("https://github.com/merge-bot.png"), + Login: new("merge-bot"), + ID: new(int64(67890)), + HTMLURL: new("https://github.com/merge-bot"), + AvatarURL: new("https://github.com/merge-bot.png"), }, }, { - SHA: github.Ptr("def456abc789"), - HTMLURL: github.Ptr("https://github.com/owner/repo/commit/def456abc789"), + SHA: new("def456abc789"), + HTMLURL: new("https://github.com/owner/repo/commit/def456abc789"), Commit: &github.Commit{ - Message: github.Ptr("fix: handle pagination"), + Message: new("fix: handle pagination"), }, }, } @@ -1504,8 +1504,8 @@ func Test_GetPullRequestCommits(t *testing.T) { name: "lockdown enabled - author lacks push access", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, &github.PullRequest{ - Number: github.Ptr(42), - User: &github.User{Login: github.Ptr("reader")}, + Number: new(42), + User: &github.User{Login: new("reader")}, }), }), requestArgs: map[string]any{ @@ -1523,8 +1523,8 @@ func Test_GetPullRequestCommits(t *testing.T) { name: "lockdown enabled - author has push access", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, &github.PullRequest{ - Number: github.Ptr(42), - User: &github.User{Login: github.Ptr("writer")}, + Number: new(42), + User: &github.User{Login: new("writer")}, }), GetReposPullsCommitsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockCommits), }), @@ -1543,8 +1543,8 @@ func Test_GetPullRequestCommits(t *testing.T) { name: "lockdown enabled - trusted bot author lacks push access", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, &github.PullRequest{ - Number: github.Ptr(42), - User: &github.User{Login: github.Ptr("github-actions[bot]")}, + Number: new(42), + User: &github.User{Login: new("github-actions[bot]")}, }), GetReposPullsCommitsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockCommits), }), @@ -1653,54 +1653,54 @@ func Test_GetPullRequestStatus(t *testing.T) { // Setup mock PR for successful PR fetch mockPR := &github.PullRequest{ - Number: github.Ptr(42), - Title: github.Ptr("Test PR"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), + Number: new(42), + Title: new("Test PR"), + HTMLURL: new("https://github.com/owner/repo/pull/42"), Head: &github.PullRequestBranch{ - SHA: github.Ptr("abcd1234"), - Ref: github.Ptr("feature-branch"), + SHA: new("abcd1234"), + Ref: new("feature-branch"), }, } statusCreatedAt := &github.Timestamp{Time: time.Date(2026, time.August, 11, 9, 30, 0, 0, time.UTC)} statusUpdatedAt := &github.Timestamp{Time: time.Date(2026, time.August, 11, 9, 35, 0, 0, time.UTC)} mockStatus := &github.CombinedStatus{ - Name: github.Ptr("abcd1234"), - State: github.Ptr("success"), - SHA: github.Ptr("abcd1234"), - TotalCount: github.Ptr(2), - CommitURL: github.Ptr("https://api.github.com/repos/owner/repo/commits/abcd1234"), - RepositoryURL: github.Ptr( + Name: new("abcd1234"), + State: new("success"), + SHA: new("abcd1234"), + TotalCount: new(2), + CommitURL: new("https://api.github.com/repos/owner/repo/commits/abcd1234"), + RepositoryURL: new( "https://api.github.com/repos/owner/repo", ), Statuses: []*github.RepoStatus{ { - ID: github.Ptr(int64(101)), - NodeID: github.Ptr("SC_kwDOStatus101"), - URL: github.Ptr("https://api.github.com/repos/owner/repo/statuses/abcd1234"), - State: github.Ptr("success"), - Context: github.Ptr("continuous-integration/travis-ci"), - Description: github.Ptr("Build succeeded"), - TargetURL: github.Ptr("https://travis-ci.org/owner/repo/builds/123"), - AvatarURL: github.Ptr("https://avatars.githubusercontent.com/in/123"), + ID: new(int64(101)), + NodeID: new("SC_kwDOStatus101"), + URL: new("https://api.github.com/repos/owner/repo/statuses/abcd1234"), + State: new("success"), + Context: new("continuous-integration/travis-ci"), + Description: new("Build succeeded"), + TargetURL: new("https://travis-ci.org/owner/repo/builds/123"), + AvatarURL: new("https://avatars.githubusercontent.com/in/123"), Creator: &github.User{ - Login: github.Ptr("ci-bot"), + Login: new("ci-bot"), }, CreatedAt: statusCreatedAt, UpdatedAt: statusUpdatedAt, }, { - State: github.Ptr("success"), - Context: github.Ptr("codecov/patch"), - Description: github.Ptr("Coverage increased"), - TargetURL: github.Ptr("https://codecov.io/gh/owner/repo/pull/42"), + State: new("success"), + Context: new("codecov/patch"), + Description: new("Coverage increased"), + TargetURL: new("https://codecov.io/gh/owner/repo/pull/42"), }, }, } emptyStatus := &github.CombinedStatus{ - State: github.Ptr("pending"), - SHA: github.Ptr("abcd1234"), - TotalCount: github.Ptr(0), + State: new("pending"), + SHA: new("abcd1234"), + TotalCount: new(0), Statuses: []*github.RepoStatus{nil}, } @@ -1881,32 +1881,32 @@ func Test_GetPullRequestCheckRuns(t *testing.T) { // Setup mock PR for successful PR fetch mockPR := &github.PullRequest{ - Number: github.Ptr(42), - Title: github.Ptr("Test PR"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), + Number: new(42), + Title: new("Test PR"), + HTMLURL: new("https://github.com/owner/repo/pull/42"), Head: &github.PullRequestBranch{ - SHA: github.Ptr("abcd1234"), - Ref: github.Ptr("feature-branch"), + SHA: new("abcd1234"), + Ref: new("feature-branch"), }, } // Setup mock check runs for success case mockCheckRuns := &github.ListCheckRunsResults{ - Total: github.Ptr(2), + Total: new(2), CheckRuns: []*github.CheckRun{ { - ID: github.Ptr(int64(1)), - Name: github.Ptr("build"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("success"), - HTMLURL: github.Ptr("https://github.com/owner/repo/runs/1"), + ID: new(int64(1)), + Name: new("build"), + Status: new("completed"), + Conclusion: new("success"), + HTMLURL: new("https://github.com/owner/repo/runs/1"), }, { - ID: github.Ptr(int64(2)), - Name: github.Ptr("test"), - Status: github.Ptr("completed"), - Conclusion: github.Ptr("success"), - HTMLURL: github.Ptr("https://github.com/owner/repo/runs/2"), + ID: new(int64(2)), + Name: new("test"), + Status: new("completed"), + Conclusion: new("success"), + HTMLURL: new("https://github.com/owner/repo/runs/2"), }, }, } @@ -2036,8 +2036,8 @@ func Test_UpdatePullRequestBranch(t *testing.T) { // Setup mock update result for success case mockUpdateResult := &github.PullRequestBranchUpdateResponse{ - Message: github.Ptr("Branch was updated successfully"), - URL: github.Ptr("https://api.github.com/repos/owner/repo/pulls/42"), + Message: new("Branch was updated successfully"), + URL: new("https://api.github.com/repos/owner/repo/pulls/42"), } tests := []struct { @@ -2579,25 +2579,25 @@ func Test_GetPullRequestReviews(t *testing.T) { // Setup mock PR reviews for success case mockReviews := []*github.PullRequestReview{ { - ID: github.Ptr(int64(201)), - State: github.Ptr("APPROVED"), - Body: github.Ptr("LGTM"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#pullrequestreview-201"), + ID: new(int64(201)), + State: new("APPROVED"), + Body: new("LGTM"), + HTMLURL: new("https://github.com/owner/repo/pull/42#pullrequestreview-201"), User: &github.User{ - Login: github.Ptr("approver"), + Login: new("approver"), }, - CommitID: github.Ptr("abcdef123456"), + CommitID: new("abcdef123456"), SubmittedAt: &github.Timestamp{Time: time.Now().Add(-24 * time.Hour)}, }, { - ID: github.Ptr(int64(202)), - State: github.Ptr("CHANGES_REQUESTED"), - Body: github.Ptr("Please address the following issues"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#pullrequestreview-202"), + ID: new(int64(202)), + State: new("CHANGES_REQUESTED"), + Body: new("Please address the following issues"), + HTMLURL: new("https://github.com/owner/repo/pull/42#pullrequestreview-202"), User: &github.User{ - Login: github.Ptr("reviewer"), + Login: new("reviewer"), }, - CommitID: github.Ptr("abcdef123456"), + CommitID: new("abcdef123456"), SubmittedAt: &github.Timestamp{Time: time.Now().Add(-12 * time.Hour)}, }, } @@ -2674,16 +2674,16 @@ func Test_GetPullRequestReviews(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposPullsReviewsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, []*github.PullRequestReview{ { - ID: github.Ptr(int64(2030)), - State: github.Ptr("APPROVED"), - Body: github.Ptr("Maintainer review"), - User: &github.User{Login: github.Ptr("maintainer")}, + ID: new(int64(2030)), + State: new("APPROVED"), + Body: new("Maintainer review"), + User: &github.User{Login: new("maintainer")}, }, { - ID: github.Ptr(int64(2031)), - State: github.Ptr("COMMENTED"), - Body: github.Ptr("External reviewer"), - User: &github.User{Login: github.Ptr("testuser")}, + ID: new(int64(2031)), + State: new("COMMENTED"), + Body: new("External reviewer"), + User: &github.User{Login: new("testuser")}, }, }), }), @@ -2696,10 +2696,10 @@ func Test_GetPullRequestReviews(t *testing.T) { expectError: false, expectedReviews: []*github.PullRequestReview{ { - ID: github.Ptr(int64(2030)), - State: github.Ptr("APPROVED"), - Body: github.Ptr("Maintainer review"), - User: &github.User{Login: github.Ptr("maintainer")}, + ID: new(int64(2030)), + State: new("APPROVED"), + Body: new("Maintainer review"), + User: &github.User{Login: new("maintainer")}, }, }, lockdownEnabled: true, @@ -2709,15 +2709,15 @@ func Test_GetPullRequestReviews(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposPullsReviewsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, []*github.PullRequestReview{ { - ID: github.Ptr(int64(2040)), - State: github.Ptr("APPROVED"), - Body: github.Ptr("Ghost review"), - User: &github.User{Login: github.Ptr("")}, + ID: new(int64(2040)), + State: new("APPROVED"), + Body: new("Ghost review"), + User: &github.User{Login: new("")}, }, { - ID: github.Ptr(int64(2041)), - State: github.Ptr("COMMENTED"), - Body: github.Ptr("Another ghost review"), + ID: new(int64(2041)), + State: new("COMMENTED"), + Body: new("Another ghost review"), }, }), }), @@ -2814,23 +2814,23 @@ func Test_CreatePullRequest(t *testing.T) { // Setup mock PR for success case mockPR := &github.PullRequest{ - Number: github.Ptr(42), - Title: github.Ptr("Test PR"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), + Number: new(42), + Title: new("Test PR"), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/pull/42"), Head: &github.PullRequestBranch{ - SHA: github.Ptr("abcd1234"), - Ref: github.Ptr("feature-branch"), + SHA: new("abcd1234"), + Ref: new("feature-branch"), }, Base: &github.PullRequestBranch{ - SHA: github.Ptr("efgh5678"), - Ref: github.Ptr("main"), + SHA: new("efgh5678"), + Ref: new("main"), }, - Body: github.Ptr("This is a test PR"), - Draft: github.Ptr(false), - MaintainerCanModify: github.Ptr(true), + Body: new("This is a test PR"), + Draft: new(false), + MaintainerCanModify: new(true), User: &github.User{ - Login: github.Ptr("testuser"), + Login: new("testuser"), }, } @@ -2949,12 +2949,12 @@ func Test_CreatePullRequest_MCPAppsFeature_UIGate(t *testing.T) { t.Parallel() mockPR := &github.PullRequest{ - Number: github.Ptr(42), - Title: github.Ptr("Test PR"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), - Head: &github.PullRequestBranch{SHA: github.Ptr("abc"), Ref: github.Ptr("feature")}, - Base: &github.PullRequestBranch{SHA: github.Ptr("def"), Ref: github.Ptr("main")}, - User: &github.User{Login: github.Ptr("testuser")}, + Number: new(42), + Title: new("Test PR"), + HTMLURL: new("https://github.com/owner/repo/pull/42"), + Head: &github.PullRequestBranch{SHA: new("abc"), Ref: new("feature")}, + Base: &github.PullRequestBranch{SHA: new("def"), Ref: new("main")}, + User: &github.User{Login: new("testuser")}, } serverTool := CreatePullRequest(translations.NullTranslationHelper) @@ -3050,12 +3050,12 @@ func Test_UpdatePullRequest_MCPAppsFeature_UIGate(t *testing.T) { t.Parallel() mockPR := &github.PullRequest{ - Number: github.Ptr(42), - Title: github.Ptr("Updated"), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"), - Head: &github.PullRequestBranch{SHA: github.Ptr("abc"), Ref: github.Ptr("feature")}, - Base: &github.PullRequestBranch{SHA: github.Ptr("def"), Ref: github.Ptr("main")}, - User: &github.User{Login: github.Ptr("testuser")}, + Number: new(42), + Title: new("Updated"), + HTMLURL: new("https://github.com/owner/repo/pull/42"), + Head: &github.PullRequestBranch{SHA: new("abc"), Ref: new("feature")}, + Base: &github.PullRequestBranch{SHA: new("def"), Ref: new("main")}, + User: &github.User{Login: new("testuser")}, } serverTool := UpdatePullRequest(translations.NullTranslationHelper) @@ -4186,8 +4186,8 @@ index 5d6e7b2..8a4f5c3 100644 // author, then the raw diff; branch on the Accept header to serve both. prOrDiffHandler := func(authorLogin string) http.HandlerFunc { mockPR := &github.PullRequest{ - Number: github.Ptr(42), - User: &github.User{Login: github.Ptr(authorLogin)}, + Number: new(42), + User: &github.User{Login: new(authorLogin)}, } return func(w http.ResponseWriter, r *http.Request) { if strings.Contains(r.Header.Get("Accept"), "diff") { @@ -4418,19 +4418,19 @@ func TestAddReplyToPullRequestComment(t *testing.T) { // Setup mock reply comment for success case mockReplyComment := &github.PullRequestComment{ - ID: github.Ptr(int64(456)), - Body: github.Ptr("This is a reply to the comment"), - InReplyTo: github.Ptr(int64(123)), - HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42#discussion_r456"), + ID: new(int64(456)), + Body: new("This is a reply to the comment"), + InReplyTo: new(int64(123)), + HTMLURL: new("https://github.com/owner/repo/pull/42#discussion_r456"), User: &github.User{ - Login: github.Ptr("responder"), + Login: new("responder"), }, CreatedAt: &github.Timestamp{Time: time.Now()}, UpdatedAt: &github.Timestamp{Time: time.Now()}, } mockReaction := &github.Reaction{ - ID: github.Ptr(int64(789)), - Content: github.Ptr("rocket"), + ID: new(int64(789)), + Content: new("rocket"), } replyCreatedAfterReactionFailure := &atomic.Bool{} diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 21647059b7..4b7e84cb9a 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -496,9 +496,9 @@ SHA MUST be provided for existing file updates. // Create the file options opts := &github.RepositoryContentFileOptions{ - Message: github.Ptr(message), + Message: new(message), Content: contentBytes, - Branch: github.Ptr(branch), + Branch: new(branch), } // If SHA is provided, set it (for updates) @@ -507,7 +507,7 @@ SHA MUST be provided for existing file updates. return utils.NewToolResultError(err.Error()), nil, nil } if sha != "" { - opts.SHA = github.Ptr(sha) + opts.SHA = new(sha) } allowSymlinkWrite, err := OptionalParam[bool](args, "allow_symlink_write") @@ -699,10 +699,10 @@ func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool } repo := &github.Repository{ - Name: github.Ptr(name), - Description: github.Ptr(description), - Private: github.Ptr(private), - AutoInit: github.Ptr(autoInit), + Name: new(name), + Description: new(description), + Private: new(private), + AutoInit: new(autoInit), } client, err := deps.GetClient(ctx) @@ -768,7 +768,7 @@ func DeleteRepository(t translations.TranslationHelperFunc) inventory.ServerTool Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_DELETE_REPOSITORY_USER_TITLE", "Delete repository"), ReadOnlyHint: false, - DestructiveHint: github.Ptr(true), + DestructiveHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -1310,7 +1310,7 @@ func DeleteFile(t translations.TranslationHelperFunc) inventory.ServerTool { Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_DELETE_FILE_USER_TITLE", "Delete file"), ReadOnlyHint: false, - DestructiveHint: github.Ptr(true), + DestructiveHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -1400,9 +1400,9 @@ func DeleteFile(t translations.TranslationHelperFunc) inventory.ServerTool { // Create a tree entry for the file deletion by setting SHA to nil treeEntries := []*github.TreeEntry{ { - Path: github.Ptr(path), - Mode: github.Ptr("100644"), // Regular file mode - Type: github.Ptr("blob"), + Path: new(path), + Mode: new("100644"), // Regular file mode + Type: new("blob"), SHA: nil, // Setting SHA to nil deletes the file }, } @@ -1428,7 +1428,7 @@ func DeleteFile(t translations.TranslationHelperFunc) inventory.ServerTool { // Create a new commit with the new tree commit := github.Commit{ - Message: github.Ptr(message), + Message: new(message), Tree: newTree, Parents: []*github.Commit{{SHA: baseCommit.SHA}}, } @@ -1454,7 +1454,7 @@ func DeleteFile(t translations.TranslationHelperFunc) inventory.ServerTool { ref.Object.SHA = newCommit.SHA _, resp, err = client.Git.UpdateRef(ctx, owner, repo, *ref.Ref, github.UpdateRef{ SHA: *newCommit.SHA, - Force: github.Ptr(false), + Force: new(false), }) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, @@ -1708,10 +1708,10 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { } entries = append(entries, &github.TreeEntry{ - Path: github.Ptr(filePath), - Mode: github.Ptr("100644"), - Type: github.Ptr("blob"), - Content: github.Ptr(content), + Path: new(filePath), + Mode: new("100644"), + Type: new("blob"), + Content: new(content), }) } @@ -1803,7 +1803,7 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { // Create a new commit (baseCommit always has a value now) commit := github.Commit{ - Message: github.Ptr(message), + Message: new(message), Tree: newTree, Parents: []*github.Commit{{SHA: baseCommit.SHA}}, } @@ -1823,7 +1823,7 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { ref.Object.SHA = newCommit.SHA updatedRef, resp, err := client.Git.UpdateRef(ctx, owner, repo, *ref.Ref, github.UpdateRef{ SHA: *newCommit.SHA, - Force: github.Ptr(false), + Force: new(false), }) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, @@ -2345,10 +2345,10 @@ func sanitizeReleaseNameAndBody(release *github.RepositoryRelease) { return } if release.Name != nil { - release.Name = github.Ptr(sanitize.PlainText(*release.Name)) + release.Name = new(sanitize.PlainText(*release.Name)) } if release.Body != nil { - release.Body = github.Ptr(sanitize.Content(*release.Body)) + release.Body = new(sanitize.Content(*release.Body)) } } diff --git a/pkg/github/repositories_helper.go b/pkg/github/repositories_helper.go index 0b46b839f1..519385fc8c 100644 --- a/pkg/github/repositories_helper.go +++ b/pkg/github/repositories_helper.go @@ -34,9 +34,9 @@ func initializeRepository(ctx context.Context, client *github.Client, owner, rep defaultBranch := repository.GetDefaultBranch() fileOpts := &github.RepositoryContentFileOptions{ - Message: github.Ptr("Initial commit"), + Message: new("Initial commit"), Content: []byte(""), - Branch: github.Ptr(defaultBranch), + Branch: new(defaultBranch), } // Create an initial empty commit to create the default branch diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 4719b23164..0c930ae8c0 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -113,19 +113,19 @@ func Test_GetFileContents(t *testing.T) { // Setup mock directory content for success case mockDirContent := []*github.RepositoryContent{ { - Type: github.Ptr("file"), - Name: github.Ptr("README.md"), - Path: github.Ptr("README.md"), - SHA: github.Ptr(gitBlobSHA(mockRawContent)), - Size: github.Ptr(42), - HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/README.md"), + Type: new("file"), + Name: new("README.md"), + Path: new("README.md"), + SHA: new(gitBlobSHA(mockRawContent)), + Size: new(42), + HTMLURL: new("https://github.com/owner/repo/blob/main/README.md"), }, { - Type: github.Ptr("dir"), - Name: github.Ptr("src"), - Path: github.Ptr("src"), - SHA: github.Ptr("def456"), - HTMLURL: github.Ptr("https://github.com/owner/repo/tree/main/src"), + Type: new("dir"), + Name: new("src"), + Path: new("src"), + SHA: new("def456"), + HTMLURL: new("https://github.com/owner/repo/tree/main/src"), }, } @@ -147,12 +147,12 @@ func Test_GetFileContents(t *testing.T) { GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) fileContent := &github.RepositoryContent{ - Name: github.Ptr("README.md"), - Path: github.Ptr("README.md"), - SHA: github.Ptr(gitBlobSHA(mockRawContent)), - Type: github.Ptr("file"), - Content: github.Ptr(string(mockRawContent)), - Size: github.Ptr(len(mockRawContent)), + Name: new("README.md"), + Path: new("README.md"), + SHA: new(gitBlobSHA(mockRawContent)), + Type: new("file"), + Content: new(string(mockRawContent)), + Size: new(len(mockRawContent)), } contentBytes, _ := json.Marshal(fileContent) _, _ = w.Write(contentBytes) @@ -183,13 +183,13 @@ func Test_GetFileContents(t *testing.T) { pngContent := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01") encodedContent := base64.StdEncoding.EncodeToString(pngContent) fileContent := &github.RepositoryContent{ - Name: github.Ptr("test.png"), - Path: github.Ptr("test.png"), - SHA: github.Ptr(gitBlobSHA(pngContent)), - Type: github.Ptr("file"), - Content: github.Ptr(encodedContent), - Size: github.Ptr(len(pngContent)), - Encoding: github.Ptr("base64"), + Name: new("test.png"), + Path: new("test.png"), + SHA: new(gitBlobSHA(pngContent)), + Type: new("file"), + Content: new(encodedContent), + Size: new(len(pngContent)), + Encoding: new("base64"), } contentBytes, _ := json.Marshal(fileContent) _, _ = w.Write(contentBytes) @@ -219,13 +219,13 @@ func Test_GetFileContents(t *testing.T) { pdfContent := []byte("%PDF-1.4 fake pdf content") encodedContent := base64.StdEncoding.EncodeToString(pdfContent) fileContent := &github.RepositoryContent{ - Name: github.Ptr("document.pdf"), - Path: github.Ptr("document.pdf"), - SHA: github.Ptr(gitBlobSHA(pdfContent)), - Type: github.Ptr("file"), - Content: github.Ptr(encodedContent), - Size: github.Ptr(len(pdfContent)), - Encoding: github.Ptr("base64"), + Name: new("document.pdf"), + Path: new("document.pdf"), + SHA: new(gitBlobSHA(pdfContent)), + Type: new("file"), + Content: new(encodedContent), + Size: new(len(pdfContent)), + Encoding: new("base64"), } contentBytes, _ := json.Marshal(fileContent) _, _ = w.Write(contentBytes) @@ -274,13 +274,13 @@ func Test_GetFileContents(t *testing.T) { // Base64 encode the content as GitHub API does encodedContent := base64.StdEncoding.EncodeToString(mockRawContent) fileContent := &github.RepositoryContent{ - Name: github.Ptr("README.md"), - Path: github.Ptr("README.md"), - SHA: github.Ptr(gitBlobSHA(mockRawContent)), - Type: github.Ptr("file"), - Content: github.Ptr(encodedContent), - Size: github.Ptr(len(mockRawContent)), - Encoding: github.Ptr("base64"), + Name: new("README.md"), + Path: new("README.md"), + SHA: new(gitBlobSHA(mockRawContent)), + Type: new("file"), + Content: new(encodedContent), + Size: new(len(mockRawContent)), + Encoding: new("base64"), } contentBytes, _ := json.Marshal(fileContent) _, _ = w.Write(contentBytes) @@ -358,13 +358,13 @@ func Test_GetFileContents(t *testing.T) { // Base64 encode the content as GitHub API does encodedContent := base64.StdEncoding.EncodeToString(mockRawContent) fileContent := &github.RepositoryContent{ - Name: github.Ptr("README.md"), - Path: github.Ptr("README.md"), - SHA: github.Ptr(gitBlobSHA(mockRawContent)), - Type: github.Ptr("file"), - Content: github.Ptr(encodedContent), - Size: github.Ptr(len(mockRawContent)), - Encoding: github.Ptr("base64"), + Name: new("README.md"), + Path: new("README.md"), + SHA: new(gitBlobSHA(mockRawContent)), + Type: new("file"), + Content: new(encodedContent), + Size: new(len(mockRawContent)), + Encoding: new("base64"), } contentBytes, _ := json.Marshal(fileContent) _, _ = w.Write(contentBytes) @@ -391,22 +391,22 @@ func Test_GetFileContents(t *testing.T) { GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, "{\"name\": \"repo\", \"default_branch\": \"main\"}"), "GET /repos/owner/repo/git/trees/refs/heads/main": mockResponse(t, http.StatusOK, &github.Tree{ Entries: []*github.TreeEntry{{ - Path: github.Ptr("large-file.bin"), - Mode: github.Ptr("100644"), - Type: github.Ptr("blob"), - SHA: github.Ptr(strings.Repeat("a", 40)), + Path: new("large-file.bin"), + Mode: new("100644"), + Type: new("blob"), + SHA: new(strings.Repeat("a", 40)), }}, }), GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) // File larger than 1MB - Contents API returns metadata but no content fileContent := &github.RepositoryContent{ - Name: github.Ptr("large-file.bin"), - Path: github.Ptr("large-file.bin"), - SHA: github.Ptr(strings.Repeat("a", 40)), - Type: github.Ptr("file"), - Size: github.Ptr(2 * 1024 * 1024), // 2MB - DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/large-file.bin"), + Name: new("large-file.bin"), + Path: new("large-file.bin"), + SHA: new(strings.Repeat("a", 40)), + Type: new("file"), + Size: new(2 * 1024 * 1024), // 2MB + DownloadURL: new("https://raw.githubusercontent.com/owner/repo/main/large-file.bin"), } contentBytes, _ := json.Marshal(fileContent) _, _ = w.Write(contentBytes) @@ -433,13 +433,13 @@ func Test_GetFileContents(t *testing.T) { GetReposContentsByOwnerByRepoByPath: func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) fileContent := &github.RepositoryContent{ - Name: github.Ptr(".gitkeep"), - Path: github.Ptr(".gitkeep"), - SHA: github.Ptr(gitBlobSHA(nil)), - Type: github.Ptr("file"), + Name: new(".gitkeep"), + Path: new(".gitkeep"), + SHA: new(gitBlobSHA(nil)), + Type: new("file"), Content: nil, - Size: github.Ptr(0), - Encoding: github.Ptr("base64"), + Size: new(0), + Encoding: new("base64"), } contentBytes, _ := json.Marshal(fileContent) _, _ = w.Write(contentBytes) @@ -593,8 +593,8 @@ func Test_GetFileContents_SymlinkDisclosure(t *testing.T) { content := []byte("ordinary content") result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ contents: &github.RepositoryContent{ - Type: github.Ptr("file"), Path: github.Ptr("README.md"), SHA: github.Ptr(gitBlobSHA(content)), - Content: github.Ptr(string(content)), Size: github.Ptr(len(content)), + Type: new("file"), Path: new("README.md"), SHA: new(gitBlobSHA(content)), + Content: new(string(content)), Size: new(len(content)), }, }, args("README.md")) require.False(t, result.IsError) @@ -615,9 +615,9 @@ func Test_GetFileContents_SymlinkDisclosure(t *testing.T) { linkSHA := gitBlobSHA([]byte(target)) result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ contents: &github.RepositoryContent{ - Type: github.Ptr("file"), Path: github.Ptr("docs/link"), SHA: github.Ptr(linkSHA), - Content: github.Ptr(base64.StdEncoding.EncodeToString(tc.content)), - Encoding: github.Ptr("base64"), Size: github.Ptr(len(tc.content)), + Type: new("file"), Path: new("docs/link"), SHA: new(linkSHA), + Content: new(base64.StdEncoding.EncodeToString(tc.content)), + Encoding: new("base64"), Size: new(len(tc.content)), }, blobs: map[string][]byte{linkSHA: []byte(target)}, }, args("docs/link")) @@ -644,8 +644,8 @@ func Test_GetFileContents_SymlinkDisclosure(t *testing.T) { target := "../../outside" result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ contents: &github.RepositoryContent{ - Type: github.Ptr("symlink"), Path: github.Ptr("docs/link"), - SHA: github.Ptr(gitBlobSHA([]byte(target))), Target: github.Ptr(target), + Type: new("symlink"), Path: new("docs/link"), + SHA: new(gitBlobSHA([]byte(target))), Target: new(target), }, }, args("docs/link")) require.False(t, result.IsError) @@ -662,9 +662,9 @@ func Test_GetFileContents_SymlinkDisclosure(t *testing.T) { target, content := "target.txt", []byte("resolved") result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ contents: &github.RepositoryContent{ - Type: github.Ptr("symlink"), Path: github.Ptr("docs/link"), - SHA: github.Ptr(gitBlobSHA([]byte(target))), Target: github.Ptr(target), - Content: github.Ptr(string(content)), Size: github.Ptr(len(content)), + Type: new("symlink"), Path: new("docs/link"), + SHA: new(gitBlobSHA([]byte(target))), Target: new(target), + Content: new(string(content)), Size: new(len(content)), }, }, args("docs/link")) require.False(t, result.IsError) @@ -676,8 +676,8 @@ func Test_GetFileContents_SymlinkDisclosure(t *testing.T) { t.Run("submodule is explicit", func(t *testing.T) { result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ contents: &github.RepositoryContent{ - Type: github.Ptr("submodule"), Path: github.Ptr("vendor/module"), - SHA: github.Ptr(strings.Repeat("d", 40)), SubmoduleGitURL: github.Ptr("https://example.com/module.git"), + Type: new("submodule"), Path: new("vendor/module"), + SHA: new(strings.Repeat("d", 40)), SubmoduleGitURL: new("https://example.com/module.git"), }, }, args("vendor/module")) require.False(t, result.IsError) @@ -691,8 +691,8 @@ func Test_GetFileContents_SymlinkDisclosure(t *testing.T) { content := []byte("resolved") result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ contents: &github.RepositoryContent{ - Type: github.Ptr("file"), Path: github.Ptr("bad"), SHA: github.Ptr("bad-sha"), - Content: github.Ptr(string(content)), Size: github.Ptr(len(content)), + Type: new("file"), Path: new("bad"), SHA: new("bad-sha"), + Content: new(string(content)), Size: new(len(content)), }, }, args("bad")) assert.Equal(t, 1, requests) @@ -702,8 +702,8 @@ func Test_GetFileContents_SymlinkDisclosure(t *testing.T) { pathSHA := gitBlobSHA(pathBlob) result, requests = runRepositoryReadFixture(t, repositoryReadFixture{ contents: &github.RepositoryContent{ - Type: github.Ptr("file"), Path: github.Ptr("bad"), SHA: github.Ptr(pathSHA), - Content: github.Ptr(string(content)), Size: github.Ptr(len(content)), + Type: new("file"), Path: new("bad"), SHA: new(pathSHA), + Content: new(string(content)), Size: new(len(content)), }, blobs: map[string][]byte{pathSHA: pathBlob}, }, args("bad")) @@ -723,10 +723,10 @@ func Test_GetFileContents_ContentlessRequestCounts(t *testing.T) { t.Run("normal large file", func(t *testing.T) { result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ contents: &github.RepositoryContent{ - Type: github.Ptr("file"), Path: github.Ptr("large.bin"), SHA: github.Ptr(fileSHA), Size: github.Ptr(largeSize), + Type: new("file"), Path: new("large.bin"), SHA: new(fileSHA), Size: new(largeSize), }, trees: map[string]*github.Tree{commitSHA: {Entries: []*github.TreeEntry{ - {Path: github.Ptr("large.bin"), Mode: github.Ptr("100644"), Type: github.Ptr("blob"), SHA: github.Ptr(fileSHA)}, + {Path: new("large.bin"), Mode: new("100644"), Type: new("blob"), SHA: new(fileSHA)}, }}}, }, args("large.bin")) require.False(t, result.IsError) @@ -740,15 +740,15 @@ func Test_GetFileContents_ContentlessRequestCounts(t *testing.T) { linkSHA := gitBlobSHA([]byte(target)) result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ contents: &github.RepositoryContent{ - Type: github.Ptr("file"), Path: github.Ptr("docs/link"), SHA: github.Ptr(linkSHA), Size: github.Ptr(largeSize), + Type: new("file"), Path: new("docs/link"), SHA: new(linkSHA), Size: new(largeSize), }, blobs: map[string][]byte{linkSHA: []byte(target)}, trees: map[string]*github.Tree{ commitSHA: {Entries: []*github.TreeEntry{ - {Path: github.Ptr("docs"), Mode: github.Ptr("040000"), Type: github.Ptr("tree"), SHA: github.Ptr("docs-tree")}, + {Path: new("docs"), Mode: new("040000"), Type: new("tree"), SHA: new("docs-tree")}, }}, "docs-tree": {Entries: []*github.TreeEntry{ - {Path: github.Ptr("link"), Mode: github.Ptr(gitSymlinkMode), Type: github.Ptr("blob"), SHA: github.Ptr(linkSHA)}, + {Path: new("link"), Mode: new(gitSymlinkMode), Type: new("blob"), SHA: new(linkSHA)}, }}, }, inspect: func(r *http.Request) { @@ -767,9 +767,9 @@ func Test_GetFileContents_ContentlessRequestCounts(t *testing.T) { t.Run("truncated tree fails closed", func(t *testing.T) { result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ contents: &github.RepositoryContent{ - Type: github.Ptr("file"), Path: github.Ptr("large.bin"), SHA: github.Ptr(fileSHA), Size: github.Ptr(largeSize), + Type: new("file"), Path: new("large.bin"), SHA: new(fileSHA), Size: new(largeSize), }, - trees: map[string]*github.Tree{commitSHA: {Truncated: github.Ptr(true)}}, + trees: map[string]*github.Tree{commitSHA: {Truncated: new(true)}}, }, args("large.bin")) assert.Equal(t, 2, requests) assert.Contains(t, getErrorResult(t, result).Text, "truncated") @@ -778,7 +778,7 @@ func Test_GetFileContents_ContentlessRequestCounts(t *testing.T) { t.Run("directory remains one request", func(t *testing.T) { result, requests := runRepositoryReadFixture(t, repositoryReadFixture{ contents: []*github.RepositoryContent{{ - Type: github.Ptr("file"), Path: github.Ptr("docs/readme"), SHA: github.Ptr(fileSHA), + Type: new("file"), Path: new("docs/readme"), SHA: new(fileSHA), }}, }, args("docs")) require.False(t, result.IsError) @@ -789,21 +789,21 @@ func Test_GetFileContents_ContentlessRequestCounts(t *testing.T) { func Test_GetFileContents_DirectoryFieldFiltering(t *testing.T) { mockDirContent := []*github.RepositoryContent{ { - Type: github.Ptr("file"), - Name: github.Ptr("README.md"), - Path: github.Ptr("README.md"), - SHA: github.Ptr("abc123"), - Size: github.Ptr(42), - URL: github.Ptr("https://api.github.com/repos/owner/repo/contents/README.md"), - HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/README.md"), - DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/README.md"), + Type: new("file"), + Name: new("README.md"), + Path: new("README.md"), + SHA: new("abc123"), + Size: new(42), + URL: new("https://api.github.com/repos/owner/repo/contents/README.md"), + HTMLURL: new("https://github.com/owner/repo/blob/main/README.md"), + DownloadURL: new("https://raw.githubusercontent.com/owner/repo/main/README.md"), }, { - Type: github.Ptr("dir"), - Name: github.Ptr("src"), - Path: github.Ptr("src"), - SHA: github.Ptr("def456"), - HTMLURL: github.Ptr("https://github.com/owner/repo/tree/main/src"), + Type: new("dir"), + Name: new("src"), + Path: new("src"), + SHA: new("def456"), + HTMLURL: new("https://github.com/owner/repo/tree/main/src"), }, } @@ -852,14 +852,14 @@ func Test_GetFileContents_DirectoryFieldFiltering(t *testing.T) { func Test_GetFileContents_DirectoryFieldsTelemetry(t *testing.T) { mockDirContent := []*github.RepositoryContent{ { - Type: github.Ptr("file"), - Name: github.Ptr("README.md"), - Path: github.Ptr("README.md"), - SHA: github.Ptr("abc123"), - Size: github.Ptr(42), - URL: github.Ptr("https://api.github.com/repos/owner/repo/contents/README.md"), - HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/README.md"), - DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/README.md"), + Type: new("file"), + Name: new("README.md"), + Path: new("README.md"), + SHA: new("abc123"), + Size: new(42), + URL: new("https://api.github.com/repos/owner/repo/contents/README.md"), + HTMLURL: new("https://github.com/owner/repo/blob/main/README.md"), + DownloadURL: new("https://raw.githubusercontent.com/owner/repo/main/README.md"), }, } @@ -919,13 +919,13 @@ func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { w.WriteHeader(http.StatusOK) encodedContent := base64.StdEncoding.EncodeToString(mockRawContent) fileContent := &github.RepositoryContent{ - Name: github.Ptr("README.md"), - Path: github.Ptr("README.md"), - SHA: github.Ptr(gitBlobSHA(mockRawContent)), - Type: github.Ptr("file"), - Content: github.Ptr(encodedContent), - Size: github.Ptr(len(mockRawContent)), - Encoding: github.Ptr("base64"), + Name: new("README.md"), + Path: new("README.md"), + SHA: new(gitBlobSHA(mockRawContent)), + Type: new("file"), + Content: new(encodedContent), + Size: new(len(mockRawContent)), + Encoding: new("base64"), } contentBytes, _ := json.Marshal(fileContent) _, _ = w.Write(contentBytes) @@ -1012,13 +1012,13 @@ func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { w.WriteHeader(http.StatusOK) encodedContent := base64.StdEncoding.EncodeToString(mockRawContent) fileContent := &github.RepositoryContent{ - Name: github.Ptr("README.md"), - Path: github.Ptr("README.md"), - SHA: github.Ptr(gitBlobSHA(mockRawContent)), - Type: github.Ptr("file"), - Content: github.Ptr(encodedContent), - Size: github.Ptr(len(mockRawContent)), - Encoding: github.Ptr("base64"), + Name: new("README.md"), + Path: new("README.md"), + SHA: new(gitBlobSHA(mockRawContent)), + Type: new("file"), + Content: new(encodedContent), + Size: new(len(mockRawContent)), + Encoding: new("base64"), } contentBytes, _ := json.Marshal(fileContent) _, _ = w.Write(contentBytes) @@ -1048,8 +1048,8 @@ func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { GetReposGitRefByOwnerByRepoByRef: mockResponse(t, http.StatusOK, "{\"ref\": \"refs/heads/main\", \"object\": {\"sha\": \"\"}}"), GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, map[string]any{"private": false}), GetReposContentsByOwnerByRepoByPath: mockResponse(t, http.StatusOK, &github.RepositoryContent{ - Type: github.Ptr("file"), Path: github.Ptr("link"), SHA: github.Ptr(linkSHA), - Content: github.Ptr(string(content)), Size: github.Ptr(len(content)), + Type: new("file"), Path: new("link"), SHA: new(linkSHA), + Content: new(string(content)), Size: new(len(content)), }), GetReposGitBlobsByOwnerByRepoByFileSHA: func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write(target) @@ -1081,9 +1081,9 @@ func Test_GetCommit_IFC_FeatureFlag(t *testing.T) { serverTool := GetCommit(translations.NullTranslationHelper) mockCommit := &github.RepositoryCommit{ - SHA: github.Ptr("abc123def456"), - Commit: &github.Commit{Message: github.Ptr("First commit")}, - HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"), + SHA: new("abc123def456"), + Commit: &github.Commit{Message: new("First commit")}, + HTMLURL: new("https://github.com/owner/repo/commit/abc123def456"), } makeMockClient := func(isPrivate bool) *http.Client { @@ -1207,16 +1207,16 @@ func Test_ForkRepository(t *testing.T) { // Setup mock forked repo for success case mockForkedRepo := &github.Repository{ - ID: github.Ptr(int64(123456)), - Name: github.Ptr("repo"), - FullName: github.Ptr("new-owner/repo"), + ID: new(int64(123456)), + Name: new("repo"), + FullName: new("new-owner/repo"), Owner: &github.User{ - Login: github.Ptr("new-owner"), + Login: new("new-owner"), }, - HTMLURL: github.Ptr("https://github.com/new-owner/repo"), - DefaultBranch: github.Ptr("main"), - Fork: github.Ptr(true), - ForksCount: github.Ptr(0), + HTMLURL: new("https://github.com/new-owner/repo"), + DefaultBranch: new("main"), + Fork: new(true), + ForksCount: new(0), } tests := []struct { @@ -1310,22 +1310,22 @@ func Test_CreateBranch(t *testing.T) { // Setup mock repository for default branch test mockRepo := &github.Repository{ - DefaultBranch: github.Ptr("main"), + DefaultBranch: new("main"), } // Setup mock reference for from_branch tests mockSourceRef := &github.Reference{ - Ref: github.Ptr("refs/heads/main"), + Ref: new("refs/heads/main"), Object: &github.GitObject{ - SHA: github.Ptr("abc123def456"), + SHA: new("abc123def456"), }, } // Setup mock created reference mockCreatedRef := &github.Reference{ - Ref: github.Ptr("refs/heads/new-feature"), + Ref: new("refs/heads/new-feature"), Object: &github.GitObject{ - SHA: github.Ptr("abc123def456"), + SHA: new("abc123def456"), }, } @@ -1521,32 +1521,32 @@ func Test_GetCommit(t *testing.T) { assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "sha"}) mockCommit := &github.RepositoryCommit{ - SHA: github.Ptr("abc123def456"), + SHA: new("abc123def456"), Commit: &github.Commit{ - Message: github.Ptr("First commit"), + Message: new("First commit"), Author: &github.CommitAuthor{ - Name: github.Ptr("Test User"), - Email: github.Ptr("test@example.com"), + Name: new("Test User"), + Email: new("test@example.com"), Date: &github.Timestamp{Time: time.Now().Add(-48 * time.Hour)}, }, }, Author: &github.User{ - Login: github.Ptr("testuser"), + Login: new("testuser"), }, - HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"), + HTMLURL: new("https://github.com/owner/repo/commit/abc123def456"), Stats: &github.CommitStats{ - Additions: github.Ptr(10), - Deletions: github.Ptr(2), - Total: github.Ptr(12), + Additions: new(10), + Deletions: new(2), + Total: new(12), }, Files: []*github.CommitFile{ { - Filename: github.Ptr("file1.go"), - Status: github.Ptr("modified"), - Additions: github.Ptr(10), - Deletions: github.Ptr(2), - Changes: github.Ptr(12), - Patch: github.Ptr("@@ -1,2 +1,10 @@"), + Filename: new("file1.go"), + Status: new("modified"), + Additions: new(10), + Deletions: new(2), + Changes: new(12), + Patch: new("@@ -1,2 +1,10 @@"), }, }, } @@ -1635,24 +1635,24 @@ func Test_GetCommit(t *testing.T) { func Test_GetCommit_Detail(t *testing.T) { mockCommit := &github.RepositoryCommit{ - SHA: github.Ptr("abc123def456"), - HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"), + SHA: new("abc123def456"), + HTMLURL: new("https://github.com/owner/repo/commit/abc123def456"), Commit: &github.Commit{ - Message: github.Ptr("First commit"), + Message: new("First commit"), }, Stats: &github.CommitStats{ - Additions: github.Ptr(10), - Deletions: github.Ptr(2), - Total: github.Ptr(12), + Additions: new(10), + Deletions: new(2), + Total: new(12), }, Files: []*github.CommitFile{ { - Filename: github.Ptr("file1.go"), - Status: github.Ptr("modified"), - Additions: github.Ptr(10), - Deletions: github.Ptr(2), - Changes: github.Ptr(12), - Patch: github.Ptr("@@ -1,2 +1,10 @@\n+new line"), + Filename: new("file1.go"), + Status: new("modified"), + Additions: new(10), + Deletions: new(2), + Changes: new(12), + Patch: new("@@ -1,2 +1,10 @@\n+new line"), }, }, } @@ -1773,73 +1773,73 @@ func Test_ListCommits(t *testing.T) { // Setup mock commits for success case mockCommits := []*github.RepositoryCommit{ { - SHA: github.Ptr("abc123def456"), + SHA: new("abc123def456"), Commit: &github.Commit{ - Message: github.Ptr("First commit"), + Message: new("First commit"), Author: &github.CommitAuthor{ - Name: github.Ptr("Test User"), - Email: github.Ptr("test@example.com"), + Name: new("Test User"), + Email: new("test@example.com"), Date: &github.Timestamp{Time: time.Now().Add(-48 * time.Hour)}, }, }, Author: &github.User{ - Login: github.Ptr("testuser"), - ID: github.Ptr(int64(12345)), - HTMLURL: github.Ptr("https://github.com/testuser"), - AvatarURL: github.Ptr("https://github.com/testuser.png"), + Login: new("testuser"), + ID: new(int64(12345)), + HTMLURL: new("https://github.com/testuser"), + AvatarURL: new("https://github.com/testuser.png"), }, - HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"), + HTMLURL: new("https://github.com/owner/repo/commit/abc123def456"), Stats: &github.CommitStats{ - Additions: github.Ptr(10), - Deletions: github.Ptr(5), - Total: github.Ptr(15), + Additions: new(10), + Deletions: new(5), + Total: new(15), }, Files: []*github.CommitFile{ { - Filename: github.Ptr("src/main.go"), - Status: github.Ptr("modified"), - Additions: github.Ptr(8), - Deletions: github.Ptr(3), - Changes: github.Ptr(11), + Filename: new("src/main.go"), + Status: new("modified"), + Additions: new(8), + Deletions: new(3), + Changes: new(11), }, { - Filename: github.Ptr("README.md"), - Status: github.Ptr("added"), - Additions: github.Ptr(2), - Deletions: github.Ptr(2), - Changes: github.Ptr(4), + Filename: new("README.md"), + Status: new("added"), + Additions: new(2), + Deletions: new(2), + Changes: new(4), }, }, }, { - SHA: github.Ptr("def456abc789"), + SHA: new("def456abc789"), Commit: &github.Commit{ - Message: github.Ptr("Second commit"), + Message: new("Second commit"), Author: &github.CommitAuthor{ - Name: github.Ptr("Another User"), - Email: github.Ptr("another@example.com"), + Name: new("Another User"), + Email: new("another@example.com"), Date: &github.Timestamp{Time: time.Now().Add(-24 * time.Hour)}, }, }, Author: &github.User{ - Login: github.Ptr("anotheruser"), - ID: github.Ptr(int64(67890)), - HTMLURL: github.Ptr("https://github.com/anotheruser"), - AvatarURL: github.Ptr("https://github.com/anotheruser.png"), + Login: new("anotheruser"), + ID: new(int64(67890)), + HTMLURL: new("https://github.com/anotheruser"), + AvatarURL: new("https://github.com/anotheruser.png"), }, - HTMLURL: github.Ptr("https://github.com/owner/repo/commit/def456abc789"), + HTMLURL: new("https://github.com/owner/repo/commit/def456abc789"), Stats: &github.CommitStats{ - Additions: github.Ptr(20), - Deletions: github.Ptr(10), - Total: github.Ptr(30), + Additions: new(20), + Deletions: new(10), + Total: new(30), }, Files: []*github.CommitFile{ { - Filename: github.Ptr("src/utils.go"), - Status: github.Ptr("added"), - Additions: github.Ptr(20), - Deletions: github.Ptr(10), - Changes: github.Ptr(30), + Filename: new("src/utils.go"), + Status: new("added"), + Additions: new(20), + Deletions: new(10), + Changes: new(30), }, }, }, @@ -2077,22 +2077,22 @@ func Test_CreateOrUpdateFile(t *testing.T) { // Setup mock file content response mockFileResponse := &github.RepositoryContentResponse{ Content: &github.RepositoryContent{ - Name: github.Ptr("example.md"), - Path: github.Ptr("docs/example.md"), - SHA: github.Ptr("abc123def456"), - Size: github.Ptr(42), - HTMLURL: github.Ptr("https://github.com/owner/repo/blob/main/docs/example.md"), - DownloadURL: github.Ptr("https://raw.githubusercontent.com/owner/repo/main/docs/example.md"), + Name: new("example.md"), + Path: new("docs/example.md"), + SHA: new("abc123def456"), + Size: new(42), + HTMLURL: new("https://github.com/owner/repo/blob/main/docs/example.md"), + DownloadURL: new("https://raw.githubusercontent.com/owner/repo/main/docs/example.md"), }, Commit: github.Commit{ - SHA: github.Ptr("def456abc789"), - Message: github.Ptr("Add example file"), + SHA: new("def456abc789"), + Message: new("Add example file"), Author: &github.CommitAuthor{ - Name: github.Ptr("Test User"), - Email: github.Ptr("test@example.com"), + Name: new("Test User"), + Email: new("test@example.com"), Date: &github.Timestamp{Time: time.Now()}, }, - HTMLURL: github.Ptr("https://github.com/owner/repo/commit/def456abc789"), + HTMLURL: new("https://github.com/owner/repo/commit/def456abc789"), }, } symlinkTarget := []byte("other.md") @@ -2105,10 +2105,10 @@ func Test_CreateOrUpdateFile(t *testing.T) { tree = &github.Tree{ Entries: []*github.TreeEntry{ { - Path: github.Ptr("docs"), - Mode: github.Ptr("040000"), - Type: github.Ptr("tree"), - SHA: github.Ptr("docs-tree"), + Path: new("docs"), + Mode: new("040000"), + Type: new("tree"), + SHA: new("docs-tree"), }, }, } @@ -2120,10 +2120,10 @@ func Test_CreateOrUpdateFile(t *testing.T) { tree = &github.Tree{ Entries: []*github.TreeEntry{ { - Path: github.Ptr("example.md"), - Mode: github.Ptr(mode), - Type: github.Ptr("blob"), - SHA: github.Ptr(entrySHA), + Path: new("example.md"), + Mode: new(mode), + Type: new("blob"), + SHA: new(entrySHA), }, }, } @@ -2180,12 +2180,12 @@ func Test_CreateOrUpdateFile(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposGitTreesByOwnerByRepoByTree: mockPathTree("100644"), "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("abc123def456"), - Type: github.Ptr("file"), + SHA: new("abc123def456"), + Type: new("file"), }), "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("abc123def456"), - Type: github.Ptr("file"), + SHA: new("abc123def456"), + Type: new("file"), }), PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]any{ "message": "Update example file", @@ -2246,12 +2246,12 @@ func Test_CreateOrUpdateFile(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposGitTreesByOwnerByRepoByTree: mockPathTree("100644"), "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("abc123def456"), - Type: github.Ptr("file"), + SHA: new("abc123def456"), + Type: new("file"), }), "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("abc123def456"), - Type: github.Ptr("file"), + SHA: new("abc123def456"), + Type: new("file"), }), PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]any{ "message": "Update example file", @@ -2291,12 +2291,12 @@ func Test_CreateOrUpdateFile(t *testing.T) { _, _ = w.Write(symlinkTarget) }, "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("abc123def456"), - Type: github.Ptr("file"), + SHA: new("abc123def456"), + Type: new("file"), }), "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("abc123def456"), - Type: github.Ptr("file"), + SHA: new("abc123def456"), + Type: new("file"), }), }), requestArgs: map[string]any{ @@ -2331,12 +2331,12 @@ func Test_CreateOrUpdateFile(t *testing.T) { _, _ = w.Write(symlinkTarget) }, "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("abc123def456"), - Type: github.Ptr("file"), + SHA: new("abc123def456"), + Type: new("file"), }), "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("abc123def456"), - Type: github.Ptr("file"), + SHA: new("abc123def456"), + Type: new("file"), }), }), requestArgs: map[string]any{ @@ -2356,12 +2356,12 @@ func Test_CreateOrUpdateFile(t *testing.T) { name: "allows intentional symbolic link update with explicit opt-in", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("abc123def456"), - Type: github.Ptr("file"), + SHA: new("abc123def456"), + Type: new("file"), }), "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("abc123def456"), - Type: github.Ptr("file"), + SHA: new("abc123def456"), + Type: new("file"), }), PutReposContentsByOwnerByRepoByPath: expectRequestBody(t, map[string]any{ "message": "Change link target", @@ -2398,14 +2398,14 @@ func Test_CreateOrUpdateFile(t *testing.T) { name: "rejects explicit symbolic link response without tree inspection", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("abc123def456"), - Type: github.Ptr("symlink"), - Target: github.Ptr("../../outside"), + SHA: new("abc123def456"), + Type: new("symlink"), + Target: new("../../outside"), }), "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("abc123def456"), - Type: github.Ptr("symlink"), - Target: github.Ptr("../../outside"), + SHA: new("abc123def456"), + Type: new("symlink"), + Target: new("../../outside"), }), }), requestArgs: map[string]any{ @@ -2430,15 +2430,15 @@ func Test_CreateOrUpdateFile(t *testing.T) { name: "fails closed when git tree is truncated", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposGitTreesByOwnerByRepoByTree: mockResponse(t, http.StatusOK, &github.Tree{ - Truncated: github.Ptr(true), + Truncated: new(true), }), "GET /repos/owner/repo/contents/docs/example.md": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("abc123def456"), - Type: github.Ptr("file"), + SHA: new("abc123def456"), + Type: new("file"), }), "GET /repos/{owner}/{repo}/contents/{path:.*}": mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("abc123def456"), - Type: github.Ptr("file"), + SHA: new("abc123def456"), + Type: new("file"), }), }), requestArgs: map[string]any{ @@ -2460,16 +2460,16 @@ func Test_CreateOrUpdateFile(t *testing.T) { "GET /repos/owner/repo/contents/docs/example.md": expectQueryParams(t, map[string]string{ "ref": "main", }).andThen(mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr(symlinkSHA), - Type: github.Ptr("symlink"), - Target: github.Ptr(string(symlinkTarget)), + SHA: new(symlinkSHA), + Type: new("symlink"), + Target: new(string(symlinkTarget)), })), "GET /repos/{owner}/{repo}/contents/{path:.*}": expectQueryParams(t, map[string]string{ "ref": "main", }).andThen(mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr(symlinkSHA), - Type: github.Ptr("symlink"), - Target: github.Ptr(string(symlinkTarget)), + SHA: new(symlinkSHA), + Type: new("symlink"), + Target: new(string(symlinkTarget)), })), }), requestArgs: map[string]any{ @@ -2558,14 +2558,14 @@ func Test_CreateOrUpdateFile(t *testing.T) { "GET /repos/owner/repo/contents/docs/example.md": expectQueryParams(t, map[string]string{ "ref": "release/#candidate", }).andThen(mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("existing123"), - Type: github.Ptr("file"), + SHA: new("existing123"), + Type: new("file"), })), "GET /repos/{owner}/{repo}/contents/{path:.*}": expectQueryParams(t, map[string]string{ "ref": "release/#candidate", }).andThen(mockResponse(t, http.StatusOK, &github.RepositoryContent{ - SHA: github.Ptr("existing123"), - Type: github.Ptr("file"), + SHA: new("existing123"), + Type: new("file"), })), }), requestArgs: map[string]any{ @@ -2749,13 +2749,13 @@ func Test_CreateRepository(t *testing.T) { // Setup mock repository response mockRepo := &github.Repository{ - Name: github.Ptr("test-repo"), - Description: github.Ptr("Test repository"), - Private: github.Ptr(true), - HTMLURL: github.Ptr("https://github.com/testuser/test-repo"), + Name: new("test-repo"), + Description: new("Test repository"), + Private: new(true), + HTMLURL: new("https://github.com/testuser/test-repo"), CreatedAt: &github.Timestamp{Time: time.Now()}, Owner: &github.User{ - Login: github.Ptr("testuser"), + Login: new("testuser"), }, } @@ -2939,35 +2939,35 @@ func Test_PushFiles(t *testing.T) { // Setup mock objects mockRef := &github.Reference{ - Ref: github.Ptr("refs/heads/main"), + Ref: new("refs/heads/main"), Object: &github.GitObject{ - SHA: github.Ptr("abc123"), - URL: github.Ptr("https://api.github.com/repos/owner/repo/git/trees/abc123"), + SHA: new("abc123"), + URL: new("https://api.github.com/repos/owner/repo/git/trees/abc123"), }, } mockCommit := &github.Commit{ - SHA: github.Ptr("abc123"), + SHA: new("abc123"), Tree: &github.Tree{ - SHA: github.Ptr("def456"), + SHA: new("def456"), }, } mockTree := &github.Tree{ - SHA: github.Ptr("ghi789"), + SHA: new("ghi789"), } mockNewCommit := &github.Commit{ - SHA: github.Ptr("jkl012"), - Message: github.Ptr("Update multiple files"), - HTMLURL: github.Ptr("https://github.com/owner/repo/commit/jkl012"), + SHA: new("jkl012"), + Message: new("Update multiple files"), + HTMLURL: new("https://github.com/owner/repo/commit/jkl012"), } mockUpdatedRef := &github.Reference{ - Ref: github.Ptr("refs/heads/main"), + Ref: new("refs/heads/main"), Object: &github.GitObject{ - SHA: github.Ptr("jkl012"), - URL: github.Ptr("https://api.github.com/repos/owner/repo/git/trees/jkl012"), + SHA: new("jkl012"), + URL: new("https://api.github.com/repos/owner/repo/git/trees/jkl012"), }, } @@ -3250,7 +3250,7 @@ func Test_PushFiles(t *testing.T) { WithRequestMatch( GetReposByOwnerByRepo, &github.Repository{ - DefaultBranch: github.Ptr("main"), + DefaultBranch: new("main"), }, ), // Create initial file using Contents API @@ -3264,7 +3264,7 @@ func Test_PushFiles(t *testing.T) { require.Equal(t, "main", body["branch"]) w.WriteHeader(http.StatusCreated) response := &github.RepositoryContentResponse{ - Commit: github.Commit{SHA: github.Ptr("abc123")}, + Commit: github.Commit{SHA: new("abc123")}, } b, _ := json.Marshal(response) _, _ = w.Write(b) @@ -3328,8 +3328,8 @@ func Test_PushFiles(t *testing.T) { // Second call: returns the updated reference after first file creation w.WriteHeader(http.StatusOK) b, _ := json.Marshal(&github.Reference{ - Ref: github.Ptr("refs/heads/main"), - Object: &github.GitObject{SHA: github.Ptr("init456")}, + Ref: new("refs/heads/main"), + Object: &github.GitObject{SHA: new("init456")}, }) _, _ = w.Write(b) } @@ -3340,7 +3340,7 @@ func Test_PushFiles(t *testing.T) { WithRequestMatch( GetReposByOwnerByRepo, &github.Repository{ - DefaultBranch: github.Ptr("main"), + DefaultBranch: new("main"), }, ), // Create initial empty README.md file using Contents API to initialize repo @@ -3358,12 +3358,12 @@ func Test_PushFiles(t *testing.T) { w.WriteHeader(http.StatusCreated) response := &github.RepositoryContentResponse{ Content: &github.RepositoryContent{ - SHA: github.Ptr("readme123"), + SHA: new("readme123"), }, Commit: github.Commit{ - SHA: github.Ptr("init456"), + SHA: new("init456"), Tree: &github.Tree{ - SHA: github.Ptr("tree456"), + SHA: new("tree456"), }, }, } @@ -3377,9 +3377,9 @@ func Test_PushFiles(t *testing.T) { http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) response := &github.Commit{ - SHA: github.Ptr("init456"), + SHA: new("init456"), Tree: &github.Tree{ - SHA: github.Ptr("tree456"), + SHA: new("tree456"), }, } b, _ := json.Marshal(response) @@ -3479,7 +3479,7 @@ func Test_PushFiles(t *testing.T) { WithRequestMatch( GetReposByOwnerByRepo, &github.Repository{ - DefaultBranch: github.Ptr("main"), + DefaultBranch: new("main"), }, ), // Fail to create initial file using Contents API @@ -3532,15 +3532,15 @@ func Test_PushFiles(t *testing.T) { WithRequestMatch( GetReposByOwnerByRepo, &github.Repository{ - DefaultBranch: github.Ptr("main"), + DefaultBranch: new("main"), }, ), // Create initial file using Contents API WithRequestMatch( PutReposContentsByOwnerByRepoByPath, &github.RepositoryContentResponse{ - Content: &github.RepositoryContent{SHA: github.Ptr("readme123")}, - Commit: github.Commit{SHA: github.Ptr("init456")}, + Content: &github.RepositoryContent{SHA: new("readme123")}, + Commit: github.Commit{SHA: new("init456")}, }, ), ), @@ -3578,15 +3578,15 @@ func Test_PushFiles(t *testing.T) { WithRequestMatch( GetReposByOwnerByRepo, &github.Repository{ - DefaultBranch: github.Ptr("main"), + DefaultBranch: new("main"), }, ), // Create initial file using Contents API WithRequestMatch( PutReposContentsByOwnerByRepoByPath, &github.RepositoryContentResponse{ - Content: &github.RepositoryContent{SHA: github.Ptr("readme123")}, - Commit: github.Commit{SHA: github.Ptr("init456")}, + Content: &github.RepositoryContent{SHA: new("readme123")}, + Commit: github.Commit{SHA: new("init456")}, }, ), // Fail to get commit @@ -4122,12 +4122,12 @@ func Test_ListBranches(t *testing.T) { // Setup mock branches for success case mockBranches := []*github.Branch{ { - Name: github.Ptr("main"), - Commit: &github.RepositoryCommit{SHA: github.Ptr("abc123")}, + Name: new("main"), + Commit: &github.RepositoryCommit{SHA: new("abc123")}, }, { - Name: github.Ptr("develop"), - Commit: &github.RepositoryCommit{SHA: github.Ptr("def456")}, + Name: new("develop"), + Commit: &github.RepositoryCommit{SHA: new("def456")}, }, } @@ -4241,27 +4241,27 @@ func Test_DeleteFile(t *testing.T) { // Setup mock objects for Git Data API mockRef := &github.Reference{ - Ref: github.Ptr("refs/heads/main"), + Ref: new("refs/heads/main"), Object: &github.GitObject{ - SHA: github.Ptr("abc123"), + SHA: new("abc123"), }, } mockCommit := &github.Commit{ - SHA: github.Ptr("abc123"), + SHA: new("abc123"), Tree: &github.Tree{ - SHA: github.Ptr("def456"), + SHA: new("def456"), }, } mockTree := &github.Tree{ - SHA: github.Ptr("ghi789"), + SHA: new("ghi789"), } mockNewCommit := &github.Commit{ - SHA: github.Ptr("jkl012"), - Message: github.Ptr("Delete example file"), - HTMLURL: github.Ptr("https://github.com/owner/repo/commit/jkl012"), + SHA: new("jkl012"), + Message: new("Delete example file"), + HTMLURL: new("https://github.com/owner/repo/commit/jkl012"), } tests := []struct { @@ -4321,9 +4321,9 @@ func Test_DeleteFile(t *testing.T) { "force": false, }).andThen( mockResponse(t, http.StatusOK, &github.Reference{ - Ref: github.Ptr("refs/heads/main"), + Ref: new("refs/heads/main"), Object: &github.GitObject{ - SHA: github.Ptr("jkl012"), + SHA: new("jkl012"), }, }), ), @@ -4422,22 +4422,22 @@ func Test_ListTags(t *testing.T) { // Setup mock tags for success case mockTags := []*github.RepositoryTag{ { - Name: github.Ptr("v1.0.0"), + Name: new("v1.0.0"), Commit: &github.Commit{ - SHA: github.Ptr("v1.0.0-tag-sha"), - URL: github.Ptr("https://api.github.com/repos/owner/repo/commits/abc123"), + SHA: new("v1.0.0-tag-sha"), + URL: new("https://api.github.com/repos/owner/repo/commits/abc123"), }, - ZipballURL: github.Ptr("https://github.com/owner/repo/zipball/v1.0.0"), - TarballURL: github.Ptr("https://github.com/owner/repo/tarball/v1.0.0"), + ZipballURL: new("https://github.com/owner/repo/zipball/v1.0.0"), + TarballURL: new("https://github.com/owner/repo/tarball/v1.0.0"), }, { - Name: github.Ptr("v0.9.0"), + Name: new("v0.9.0"), Commit: &github.Commit{ - SHA: github.Ptr("v0.9.0-tag-sha"), - URL: github.Ptr("https://api.github.com/repos/owner/repo/commits/def456"), + SHA: new("v0.9.0-tag-sha"), + URL: new("https://api.github.com/repos/owner/repo/commits/def456"), }, - ZipballURL: github.Ptr("https://github.com/owner/repo/zipball/v0.9.0"), - TarballURL: github.Ptr("https://github.com/owner/repo/tarball/v0.9.0"), + ZipballURL: new("https://github.com/owner/repo/zipball/v0.9.0"), + TarballURL: new("https://github.com/owner/repo/tarball/v0.9.0"), }, } @@ -4551,28 +4551,28 @@ func Test_GetTag(t *testing.T) { assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "tag"}) mockAnnotatedTagRef := &github.Reference{ - Ref: github.Ptr("refs/tags/v1.0.0"), + Ref: new("refs/tags/v1.0.0"), Object: &github.GitObject{ - Type: github.Ptr("tag"), - SHA: github.Ptr("v1.0.0-tag-sha"), + Type: new("tag"), + SHA: new("v1.0.0-tag-sha"), }, } mockLightweightTagRef := &github.Reference{ - Ref: github.Ptr("refs/tags/v1.0.1"), + Ref: new("refs/tags/v1.0.1"), Object: &github.GitObject{ - Type: github.Ptr("commit"), - SHA: github.Ptr("abc123"), + Type: new("commit"), + SHA: new("abc123"), }, } mockTagObj := &github.Tag{ - SHA: github.Ptr("v1.0.0-tag-sha"), - Tag: github.Ptr("v1.0.0"), - Message: github.Ptr("Release v1.0.0"), + SHA: new("v1.0.0-tag-sha"), + Tag: new("v1.0.0"), + Message: new("Release v1.0.0"), Object: &github.GitObject{ - Type: github.Ptr("commit"), - SHA: github.Ptr("abc123"), + Type: new("commit"), + SHA: new("abc123"), }, } @@ -4756,12 +4756,12 @@ func Test_ListReleases(t *testing.T) { { ID: 1, TagName: "v1.0.0", - Name: github.Ptr("First Release"), + Name: new("First Release"), }, { ID: 2, TagName: "v0.9.0", - Name: github.Ptr("Beta Release"), + Name: new("Beta Release"), }, } @@ -4854,8 +4854,8 @@ func Test_GetLatestRelease(t *testing.T) { mockRelease := &github.RepositoryRelease{ ID: 1, TagName: "v1.0.0", - Name: github.Ptr("can't \"quote\" AT&T\u200B"), - Body: github.Ptr("
Notes
\u200B"), + Name: new("can't \"quote\" AT&T\u200B"), + Body: new("
Notes
\u200B"), } tests := []struct { @@ -4947,12 +4947,12 @@ func Test_GetReleaseByTag(t *testing.T) { mockRelease := &github.RepositoryRelease{ ID: 1, TagName: "v1.0.0", - Name: github.Ptr("can't \"quote\" AT&T\u200B"), - Body: github.Ptr("
Notes
\u200B"), + Name: new("can't \"quote\" AT&T\u200B"), + Body: new("
Notes
\u200B"), Assets: []*github.ReleaseAsset{ { - ID: github.Ptr(int64(1)), - Name: github.Ptr("release-v1.0.0.tar.gz"), + ID: new(int64(1)), + Name: new("release-v1.0.0.tar.gz"), }, }, } @@ -5115,7 +5115,7 @@ func Test_GetReleaseByTag_IFC_FeatureFlag(t *testing.T) { return &github.RepositoryRelease{ ID: 1, TagName: "v1.0.0", - Name: github.Ptr("v1.0.0"), + Name: new("v1.0.0"), Draft: draft, } } @@ -5278,10 +5278,10 @@ func Test_filterPaths(t *testing.T) { { name: "file name", tree: []*github.TreeEntry{ - {Path: github.Ptr("folder/foo.txt"), Type: github.Ptr("blob")}, - {Path: github.Ptr("bar.txt"), Type: github.Ptr("blob")}, - {Path: github.Ptr("nested/folder/foo.txt"), Type: github.Ptr("blob")}, - {Path: github.Ptr("nested/folder/baz.txt"), Type: github.Ptr("blob")}, + {Path: new("folder/foo.txt"), Type: new("blob")}, + {Path: new("bar.txt"), Type: new("blob")}, + {Path: new("nested/folder/foo.txt"), Type: new("blob")}, + {Path: new("nested/folder/baz.txt"), Type: new("blob")}, }, path: "foo.txt", maxResults: -1, @@ -5290,10 +5290,10 @@ func Test_filterPaths(t *testing.T) { { name: "dir name", tree: []*github.TreeEntry{ - {Path: github.Ptr("folder"), Type: github.Ptr("tree")}, - {Path: github.Ptr("bar.txt"), Type: github.Ptr("blob")}, - {Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")}, - {Path: github.Ptr("nested/folder/baz.txt"), Type: github.Ptr("blob")}, + {Path: new("folder"), Type: new("tree")}, + {Path: new("bar.txt"), Type: new("blob")}, + {Path: new("nested/folder"), Type: new("tree")}, + {Path: new("nested/folder/baz.txt"), Type: new("blob")}, }, path: "folder/", maxResults: -1, @@ -5302,8 +5302,8 @@ func Test_filterPaths(t *testing.T) { { name: "dir and file match", tree: []*github.TreeEntry{ - {Path: github.Ptr("name"), Type: github.Ptr("tree")}, - {Path: github.Ptr("name"), Type: github.Ptr("blob")}, + {Path: new("name"), Type: new("tree")}, + {Path: new("name"), Type: new("blob")}, }, path: "name", // No trailing slash can match both files and directories maxResults: -1, @@ -5312,8 +5312,8 @@ func Test_filterPaths(t *testing.T) { { name: "dir only match", tree: []*github.TreeEntry{ - {Path: github.Ptr("name"), Type: github.Ptr("tree")}, - {Path: github.Ptr("name"), Type: github.Ptr("blob")}, + {Path: new("name"), Type: new("tree")}, + {Path: new("name"), Type: new("blob")}, }, path: "name/", // Trialing slash ensures only directories are matched maxResults: -1, @@ -5322,9 +5322,9 @@ func Test_filterPaths(t *testing.T) { { name: "max results limit 2", tree: []*github.TreeEntry{ - {Path: github.Ptr("folder"), Type: github.Ptr("tree")}, - {Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")}, - {Path: github.Ptr("nested/nested/folder"), Type: github.Ptr("tree")}, + {Path: new("folder"), Type: new("tree")}, + {Path: new("nested/folder"), Type: new("tree")}, + {Path: new("nested/nested/folder"), Type: new("tree")}, }, path: "folder/", maxResults: 2, @@ -5333,9 +5333,9 @@ func Test_filterPaths(t *testing.T) { { name: "max results limit 1", tree: []*github.TreeEntry{ - {Path: github.Ptr("folder"), Type: github.Ptr("tree")}, - {Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")}, - {Path: github.Ptr("nested/nested/folder"), Type: github.Ptr("tree")}, + {Path: new("folder"), Type: new("tree")}, + {Path: new("nested/folder"), Type: new("tree")}, + {Path: new("nested/nested/folder"), Type: new("tree")}, }, path: "folder/", maxResults: 1, @@ -5344,9 +5344,9 @@ func Test_filterPaths(t *testing.T) { { name: "max results limit 0", tree: []*github.TreeEntry{ - {Path: github.Ptr("folder"), Type: github.Ptr("tree")}, - {Path: github.Ptr("nested/folder"), Type: github.Ptr("tree")}, - {Path: github.Ptr("nested/nested/folder"), Type: github.Ptr("tree")}, + {Path: new("folder"), Type: new("tree")}, + {Path: new("nested/folder"), Type: new("tree")}, + {Path: new("nested/nested/folder"), Type: new("tree")}, }, path: "folder/", maxResults: 0, @@ -5648,39 +5648,39 @@ func Test_ListStarredRepositories(t *testing.T) { { StarredAt: &github.Timestamp{Time: starredAt}, Repository: &github.Repository{ - ID: github.Ptr(int64(12345)), - Name: github.Ptr("awesome-repo"), - FullName: github.Ptr("owner/awesome-repo"), - Description: github.Ptr("An awesome repository"), - HTMLURL: github.Ptr("https://github.com/owner/awesome-repo"), - Language: github.Ptr("Go"), - StargazersCount: github.Ptr(100), - ForksCount: github.Ptr(25), - OpenIssuesCount: github.Ptr(5), + ID: new(int64(12345)), + Name: new("awesome-repo"), + FullName: new("owner/awesome-repo"), + Description: new("An awesome repository"), + HTMLURL: new("https://github.com/owner/awesome-repo"), + Language: new("Go"), + StargazersCount: new(100), + ForksCount: new(25), + OpenIssuesCount: new(5), UpdatedAt: &github.Timestamp{Time: updatedAt}, - Private: github.Ptr(false), - Fork: github.Ptr(false), - Archived: github.Ptr(false), - DefaultBranch: github.Ptr("main"), + Private: new(false), + Fork: new(false), + Archived: new(false), + DefaultBranch: new("main"), }, }, { StarredAt: &github.Timestamp{Time: starredAt.Add(-12 * time.Hour)}, Repository: &github.Repository{ - ID: github.Ptr(int64(67890)), - Name: github.Ptr("cool-project"), - FullName: github.Ptr("user/cool-project"), - Description: github.Ptr("A very cool project"), - HTMLURL: github.Ptr("https://github.com/user/cool-project"), - Language: github.Ptr("Python"), - StargazersCount: github.Ptr(500), - ForksCount: github.Ptr(75), - OpenIssuesCount: github.Ptr(10), + ID: new(int64(67890)), + Name: new("cool-project"), + FullName: new("user/cool-project"), + Description: new("A very cool project"), + HTMLURL: new("https://github.com/user/cool-project"), + Language: new("Python"), + StargazersCount: new(500), + ForksCount: new(75), + OpenIssuesCount: new(10), UpdatedAt: &github.Timestamp{Time: updatedAt.Add(-1 * time.Hour)}, - Private: github.Ptr(false), - Fork: github.Ptr(true), - Archived: github.Ptr(false), - DefaultBranch: github.Ptr("master"), + Private: new(false), + Fork: new(true), + Archived: new(false), + DefaultBranch: new("master"), }, }, } @@ -6700,14 +6700,14 @@ func Test_ListRepositoryCollaborators(t *testing.T) { mockCollaborators := []*github.User{ { - Login: github.Ptr("user1"), - ID: github.Ptr(int64(101)), - RoleName: github.Ptr("admin"), + Login: new("user1"), + ID: new(int64(101)), + RoleName: new("admin"), }, { - Login: github.Ptr("user2"), - ID: github.Ptr(int64(102)), - RoleName: github.Ptr("write"), + Login: new("user2"), + ID: new(int64(102)), + RoleName: new("write"), }, } diff --git a/pkg/github/sanitize_coverage_test.go b/pkg/github/sanitize_coverage_test.go index b35081719b..f8f612cfe2 100644 --- a/pkg/github/sanitize_coverage_test.go +++ b/pkg/github/sanitize_coverage_test.go @@ -30,7 +30,7 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { name: "issue title (REST)", got: func() string { return convertToMinimalIssue(&github.Issue{ - Title: github.Ptr(maliciousText), + Title: new(maliciousText), }).Title }, }, @@ -39,7 +39,7 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { content: true, got: func() string { return convertToMinimalIssue(&github.Issue{ - Body: github.Ptr(maliciousText), + Body: new(maliciousText), }).Body }, }, @@ -48,7 +48,7 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { content: true, got: func() string { return convertToMinimalIssueComment(&github.IssueComment{ - Body: github.Ptr(maliciousText), + Body: new(maliciousText), }).Body }, }, @@ -56,7 +56,7 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { name: "pull request title", got: func() string { return convertToMinimalPullRequest(&github.PullRequest{ - Title: github.Ptr(maliciousText), + Title: new(maliciousText), }).Title }, }, @@ -65,7 +65,7 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { content: true, got: func() string { return convertToMinimalPullRequest(&github.PullRequest{ - Body: github.Ptr(maliciousText), + Body: new(maliciousText), }).Body }, }, @@ -74,7 +74,7 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { content: true, got: func() string { return convertToMinimalPullRequestReview(&github.PullRequestReview{ - Body: github.Ptr(maliciousText), + Body: new(maliciousText), }).Body }, }, @@ -92,7 +92,7 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { name: "release name", got: func() string { return convertToMinimalRelease(&github.RepositoryRelease{ - Name: github.Ptr(maliciousText), + Name: new(maliciousText), }).Name }, }, @@ -101,7 +101,7 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { content: true, got: func() string { return convertToMinimalRelease(&github.RepositoryRelease{ - Body: github.Ptr(maliciousText), + Body: new(maliciousText), }).Body }, }, @@ -110,7 +110,7 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { content: true, got: func() string { commit := convertToMinimalCommit(&github.RepositoryCommit{ - Commit: &github.Commit{Message: github.Ptr(maliciousText)}, + Commit: &github.Commit{Message: new(maliciousText)}, }, commitDetailNone) require.NotNil(t, commit.Commit) return commit.Commit.Message @@ -121,7 +121,7 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { content: true, got: func() string { item := convertCommitResultToMinimalCommit(&github.CommitResult{ - Commit: &github.Commit{Message: github.Ptr(maliciousText)}, + Commit: &github.Commit{Message: new(maliciousText)}, }) require.NotNil(t, item.Commit) return item.Commit.Message @@ -132,7 +132,7 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { content: true, got: func() string { commits := convertToMinimalPullRequestCommits([]*github.RepositoryCommit{ - {Commit: &github.Commit{Message: github.Ptr(maliciousText)}}, + {Commit: &github.Commit{Message: new(maliciousText)}}, }) require.Len(t, commits, 1) return commits[0].Message @@ -143,7 +143,7 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { content: true, got: func() string { resp := convertToMinimalFileContentResponse(&github.RepositoryContentResponse{ - Commit: github.Commit{Message: github.Ptr(maliciousText)}, + Commit: github.Commit{Message: new(maliciousText)}, }) require.NotNil(t, resp.Commit) return resp.Commit.Message @@ -154,7 +154,7 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { content: true, got: func() string { run := convertToMinimalWorkflowRun(&github.WorkflowRun{ - HeadCommit: &github.HeadCommit{Message: github.Ptr(maliciousText)}, + HeadCommit: &github.HeadCommit{Message: new(maliciousText)}, }) require.NotNil(t, run.HeadCommit) return run.HeadCommit.Message @@ -164,7 +164,7 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { name: "project item content title (issue)", got: func() string { return convertIssueToMinimalProjectItemContent(&github.Issue{ - Title: github.Ptr(maliciousText), + Title: new(maliciousText), }).Title }, }, @@ -172,7 +172,7 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { name: "project item content title (pull request)", got: func() string { return convertPullRequestToMinimalProjectItemContent(&github.PullRequest{ - Title: github.Ptr(maliciousText), + Title: new(maliciousText), }).Title }, }, @@ -180,7 +180,7 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { name: "project item content title (draft issue)", got: func() string { return convertDraftIssueToMinimalProjectItemContent(&github.ProjectV2DraftIssue{ - Title: github.Ptr(maliciousText), + Title: new(maliciousText), }).Title }, }, @@ -188,7 +188,7 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { name: "project pull request ref title (from *github.PullRequest)", got: func() string { return minimalProjectPullRequestRefFromPullRequest(&github.PullRequest{ - Title: github.Ptr(maliciousText), + Title: new(maliciousText), }).Title }, }, @@ -226,7 +226,7 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { name: "issue dependency ref title (issue_dependency_read / issue_dependency_write)", got: func() string { return issueToDependencyRef(&github.Issue{ - Title: github.Ptr(maliciousText), + Title: new(maliciousText), }).Title }, }, @@ -249,8 +249,8 @@ func Test_MinimalConverters_SanitizeUserAuthoredText(t *testing.T) { func Test_SearchIssueResult_SanitizesTitleAndBody(t *testing.T) { result := SearchIssueResult{ Issue: &github.Issue{ - Title: github.Ptr(maliciousText), - Body: github.Ptr(maliciousText), + Title: new(maliciousText), + Body: new(maliciousText), }, } @@ -283,8 +283,8 @@ func Test_SanitizeIssueTitleAndBody(t *testing.T) { t.Run("sanitizes in place", func(t *testing.T) { issue := &github.Issue{ - Title: github.Ptr(maliciousText), - Body: github.Ptr(maliciousText), + Title: new(maliciousText), + Body: new(maliciousText), } sanitizeIssueTitleAndBody(issue) require.NotNil(t, issue.Title) @@ -302,7 +302,7 @@ func Test_MinimalConverters_PreserveCodeFidelity(t *testing.T) { t.Run("commit file patch (get_commit)", func(t *testing.T) { commit := convertToMinimalCommit(&github.RepositoryCommit{ - Files: []*github.CommitFile{{Filename: github.Ptr("a.go"), Patch: github.Ptr(patch)}}, + Files: []*github.CommitFile{{Filename: new("a.go"), Patch: new(patch)}}, }, commitDetailFullPatch) require.Len(t, commit.Files, 1) assert.Equal(t, patch, commit.Files[0].Patch) @@ -310,7 +310,7 @@ func Test_MinimalConverters_PreserveCodeFidelity(t *testing.T) { t.Run("pull request file patch (get_pull_request_files)", func(t *testing.T) { files := convertToMinimalPRFiles([]*github.CommitFile{ - {Filename: github.Ptr("a.go"), Patch: github.Ptr(patch)}, + {Filename: new("a.go"), Patch: new(patch)}, }) require.Len(t, files, 1) assert.Equal(t, patch, files[0].Patch) diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index 8139ce76aa..f372de1bd7 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -35,24 +35,24 @@ func Test_SearchRepositories(t *testing.T) { // Setup mock search results mockSearchResult := &github.RepositoriesSearchResult{ - Total: github.Ptr(2), - IncompleteResults: github.Ptr(false), + Total: new(2), + IncompleteResults: new(false), Repositories: []*github.Repository{ { - ID: github.Ptr(int64(12345)), - Name: github.Ptr("repo-1"), - FullName: github.Ptr("owner/repo-1"), - HTMLURL: github.Ptr("https://github.com/owner/repo-1"), - Description: github.Ptr("Test repository 1"), - StargazersCount: github.Ptr(100), + ID: new(int64(12345)), + Name: new("repo-1"), + FullName: new("owner/repo-1"), + HTMLURL: new("https://github.com/owner/repo-1"), + Description: new("Test repository 1"), + StargazersCount: new(100), }, { - ID: github.Ptr(int64(67890)), - Name: github.Ptr("repo-2"), - FullName: github.Ptr("owner/repo-2"), - HTMLURL: github.Ptr("https://github.com/owner/repo-2"), - Description: github.Ptr("Test repository 2"), - StargazersCount: github.Ptr(50), + ID: new(int64(67890)), + Name: new("repo-2"), + FullName: new("owner/repo-2"), + HTMLURL: new("https://github.com/owner/repo-2"), + Description: new("Test repository 2"), + StargazersCount: new(50), }, }, } @@ -181,18 +181,18 @@ func Test_SearchRepositories_IFC_InsidersMode(t *testing.T) { makeRepo := func(r repoFixture) *github.Repository { return &github.Repository{ - ID: github.Ptr(int64(1)), - Name: github.Ptr(r.name), - FullName: github.Ptr(r.owner + "/" + r.name), - Private: github.Ptr(r.isPrivate), - Owner: &github.User{Login: github.Ptr(r.owner)}, + ID: new(int64(1)), + Name: new(r.name), + FullName: new(r.owner + "/" + r.name), + Private: new(r.isPrivate), + Owner: &github.User{Login: new(r.owner)}, } } makeMockClient := func(repos []repoFixture) *http.Client { searchResult := &github.RepositoriesSearchResult{ - Total: github.Ptr(len(repos)), - IncompleteResults: github.Ptr(false), + Total: new(len(repos)), + IncompleteResults: new(false), } for _, r := range repos { searchResult.Repositories = append(searchResult.Repositories, makeRepo(r)) @@ -280,16 +280,16 @@ func Test_SearchRepositories_IFC_InsidersMode(t *testing.T) { func Test_SearchRepositories_FullOutput(t *testing.T) { mockSearchResult := &github.RepositoriesSearchResult{ - Total: github.Ptr(1), - IncompleteResults: github.Ptr(false), + Total: new(1), + IncompleteResults: new(false), Repositories: []*github.Repository{ { - ID: github.Ptr(int64(12345)), - Name: github.Ptr("test-repo"), - FullName: github.Ptr("owner/test-repo"), - HTMLURL: github.Ptr("https://github.com/owner/test-repo"), - Description: github.Ptr("Test repository"), - StargazersCount: github.Ptr(100), + ID: new(int64(12345)), + Name: new("test-repo"), + FullName: new("owner/test-repo"), + HTMLURL: new("https://github.com/owner/test-repo"), + Description: new("Test repository"), + StargazersCount: new(100), }, }, } @@ -359,30 +359,30 @@ func Test_SearchCode(t *testing.T) { // Setup mock search results mockSearchResult := &github.CodeSearchResult{ - Total: github.Ptr(2), - IncompleteResults: github.Ptr(false), + Total: new(2), + IncompleteResults: new(false), CodeResults: []*github.CodeResult{ { - Name: github.Ptr("file1.go"), - Path: github.Ptr("path/to/file1.go"), - SHA: github.Ptr("abc123def456"), + Name: new("file1.go"), + Path: new("path/to/file1.go"), + SHA: new("abc123def456"), Repository: &github.Repository{ - Name: github.Ptr("repo"), - FullName: github.Ptr("owner/repo"), + Name: new("repo"), + FullName: new("owner/repo"), }, TextMatches: []*github.TextMatch{ { - Fragment: github.Ptr("func main() { fmt.Println(\"hello\") }"), + Fragment: new("func main() { fmt.Println(\"hello\") }"), }, }, }, { - Name: github.Ptr("file2.go"), - Path: github.Ptr("path/to/file2.go"), - SHA: github.Ptr("def456abc123"), + Name: new("file2.go"), + Path: new("path/to/file2.go"), + SHA: new("def456abc123"), Repository: &github.Repository{ - Name: github.Ptr("repo"), - FullName: github.Ptr("owner/repo"), + Name: new("repo"), + FullName: new("owner/repo"), }, }, }, @@ -512,19 +512,19 @@ func Test_SearchCode(t *testing.T) { func Test_SearchCode_FieldFiltering(t *testing.T) { mockSearchResult := &github.CodeSearchResult{ - Total: github.Ptr(1), - IncompleteResults: github.Ptr(false), + Total: new(1), + IncompleteResults: new(false), CodeResults: []*github.CodeResult{ { - Name: github.Ptr("file1.go"), - Path: github.Ptr("path/to/file1.go"), - SHA: github.Ptr("abc123def456"), + Name: new("file1.go"), + Path: new("path/to/file1.go"), + SHA: new("abc123def456"), Repository: &github.Repository{ - Name: github.Ptr("repo"), - FullName: github.Ptr("owner/repo"), + Name: new("repo"), + FullName: new("owner/repo"), }, TextMatches: []*github.TextMatch{ - {Fragment: github.Ptr("func main() {}")}, + {Fragment: new("func main() {}")}, }, }, }, @@ -569,19 +569,19 @@ func Test_SearchCode_FieldFiltering(t *testing.T) { func Test_SearchCode_FieldsTelemetry(t *testing.T) { mockSearchResult := &github.CodeSearchResult{ - Total: github.Ptr(1), - IncompleteResults: github.Ptr(false), + Total: new(1), + IncompleteResults: new(false), CodeResults: []*github.CodeResult{ { - Name: github.Ptr("file1.go"), - Path: github.Ptr("path/to/file1.go"), - SHA: github.Ptr("abc123def456"), + Name: new("file1.go"), + Path: new("path/to/file1.go"), + SHA: new("abc123def456"), Repository: &github.Repository{ - Name: github.Ptr("repo"), - FullName: github.Ptr("owner/repo"), + Name: new("repo"), + FullName: new("owner/repo"), }, TextMatches: []*github.TextMatch{ - {Fragment: github.Ptr("func main() {}")}, + {Fragment: new("func main() {}")}, }, }, }, @@ -656,21 +656,21 @@ func Test_SearchUsers(t *testing.T) { // Setup mock search results mockSearchResult := &github.UsersSearchResult{ - Total: github.Ptr(2), - IncompleteResults: github.Ptr(false), + Total: new(2), + IncompleteResults: new(false), Users: []*github.User{ { - Login: github.Ptr("user1"), - ID: github.Ptr(int64(1001)), - HTMLURL: github.Ptr("https://github.com/user1"), - AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/1001"), + Login: new("user1"), + ID: new(int64(1001)), + HTMLURL: new("https://github.com/user1"), + AvatarURL: new("https://avatars.githubusercontent.com/u/1001"), }, { - Login: github.Ptr("user2"), - ID: github.Ptr(int64(1002)), - HTMLURL: github.Ptr("https://github.com/user2"), - AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/1002"), - Type: github.Ptr("User"), + Login: new("user2"), + ID: new(int64(1002)), + HTMLURL: new("https://github.com/user2"), + AvatarURL: new("https://avatars.githubusercontent.com/u/1002"), + Type: new("User"), }, }, } @@ -843,20 +843,20 @@ func Test_SearchOrgs(t *testing.T) { // Setup mock search results mockSearchResult := &github.UsersSearchResult{ - Total: github.Ptr(int(2)), - IncompleteResults: github.Ptr(false), + Total: new(int(2)), + IncompleteResults: new(false), Users: []*github.User{ { - Login: github.Ptr("org-1"), - ID: github.Ptr(int64(111)), - HTMLURL: github.Ptr("https://github.com/org-1"), - AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/111?v=4"), + Login: new("org-1"), + ID: new(int64(111)), + HTMLURL: new("https://github.com/org-1"), + AvatarURL: new("https://avatars.githubusercontent.com/u/111?v=4"), }, { - Login: github.Ptr("org-2"), - ID: github.Ptr(int64(222)), - HTMLURL: github.Ptr("https://github.com/org-2"), - AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/222?v=4"), + Login: new("org-2"), + ID: new(int64(222)), + HTMLURL: new("https://github.com/org-2"), + AvatarURL: new("https://avatars.githubusercontent.com/u/222?v=4"), }, }, } @@ -1001,41 +1001,41 @@ func Test_SearchCommits(t *testing.T) { now := time.Now().Truncate(time.Second) mockSearchResult := &github.CommitsSearchResult{ - Total: github.Ptr(2), - IncompleteResults: github.Ptr(false), + Total: new(2), + IncompleteResults: new(false), Commits: []*github.CommitResult{ { - SHA: github.Ptr("abc123commit"), - HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123commit"), + SHA: new("abc123commit"), + HTMLURL: new("https://github.com/owner/repo/commit/abc123commit"), Commit: &github.Commit{ - Message: github.Ptr("Initial commit"), + Message: new("Initial commit"), Author: &github.CommitAuthor{ - Name: github.Ptr("Author Name"), - Email: github.Ptr("author@example.com"), + Name: new("Author Name"), + Email: new("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"), + Login: new("author"), + ID: new(int64(1)), + HTMLURL: new("https://github.com/author"), }, Repository: &github.Repository{ - FullName: github.Ptr("owner/repo"), - HTMLURL: github.Ptr("https://github.com/owner/repo"), - Private: github.Ptr(false), + FullName: new("owner/repo"), + HTMLURL: new("https://github.com/owner/repo"), + Private: new(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"), + SHA: new("def456commit"), + HTMLURL: new("https://github.com/owner/repo/commit/def456commit"), Commit: &github.Commit{ - Message: github.Ptr("Unlinked author"), + Message: new("Unlinked author"), }, Repository: &github.Repository{ - FullName: github.Ptr("owner/repo"), + FullName: new("owner/repo"), }, }, }, diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go index 49ec7bbff2..47b8aa26bd 100644 --- a/pkg/github/search_utils.go +++ b/pkg/github/search_utils.go @@ -135,7 +135,7 @@ func prepareSearchArgs(args map[string]any, targetType string, mode searchMode) // field.: qualifiers require the advanced search API. if strings.Contains(query, "field.") { - opts.AdvancedSearch = github.Ptr(true) + opts.AdvancedSearch = new(true) } // Lexical is the API default, so it leaves search_type unset. diff --git a/pkg/github/secret_scanning_test.go b/pkg/github/secret_scanning_test.go index 58b59296b8..6018962d6e 100644 --- a/pkg/github/secret_scanning_test.go +++ b/pkg/github/secret_scanning_test.go @@ -32,9 +32,9 @@ func Test_GetSecretScanningAlert(t *testing.T) { // Setup mock alert for success case mockAlert := &github.SecretScanningAlert{ - Number: github.Ptr(42), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/private-repo/security/secret-scanning/42"), + Number: new(42), + State: new("open"), + HTMLURL: new("https://github.com/owner/private-repo/security/secret-scanning/42"), } tests := []struct { @@ -139,18 +139,18 @@ func Test_ListSecretScanningAlerts(t *testing.T) { // Setup mock alerts for success case resolvedAlert := github.SecretScanningAlert{ - Number: github.Ptr(2), - HTMLURL: github.Ptr("https://github.com/owner/private-repo/security/secret-scanning/2"), - State: github.Ptr("resolved"), - Resolution: github.Ptr("false_positive"), - SecretType: github.Ptr("adafruit_io_key"), + Number: new(2), + HTMLURL: new("https://github.com/owner/private-repo/security/secret-scanning/2"), + State: new("resolved"), + Resolution: new("false_positive"), + SecretType: new("adafruit_io_key"), } openAlert := github.SecretScanningAlert{ - Number: github.Ptr(2), - HTMLURL: github.Ptr("https://github.com/owner/private-repo/security/secret-scanning/3"), - State: github.Ptr("open"), - Resolution: github.Ptr("false_positive"), - SecretType: github.Ptr("adafruit_io_key"), + Number: new(2), + HTMLURL: new("https://github.com/owner/private-repo/security/secret-scanning/3"), + State: new("open"), + Resolution: new("false_positive"), + SecretType: new("adafruit_io_key"), } tests := []struct { diff --git a/pkg/github/security_advisories_test.go b/pkg/github/security_advisories_test.go index fc8e660a6f..27dcf7ec0a 100644 --- a/pkg/github/security_advisories_test.go +++ b/pkg/github/security_advisories_test.go @@ -33,10 +33,10 @@ func Test_ListGlobalSecurityAdvisories(t *testing.T) { // Setup mock advisory for success case mockAdvisory := &github.GlobalSecurityAdvisory{ SecurityAdvisory: github.SecurityAdvisory{ - GHSAID: github.Ptr("GHSA-xxxx-xxxx-xxxx"), - Summary: github.Ptr("Test advisory"), - Description: github.Ptr("This is a test advisory."), - Severity: github.Ptr("high"), + GHSAID: new("GHSA-xxxx-xxxx-xxxx"), + Summary: new("Test advisory"), + Description: new("This is a test advisory."), + Severity: new("high"), }, } @@ -146,10 +146,10 @@ func Test_GetGlobalSecurityAdvisory(t *testing.T) { // Setup mock advisory for success case mockAdvisory := &github.GlobalSecurityAdvisory{ SecurityAdvisory: github.SecurityAdvisory{ - GHSAID: github.Ptr("GHSA-xxxx-xxxx-xxxx"), - Summary: github.Ptr("Test advisory"), - Description: github.Ptr("This is a test advisory."), - Severity: github.Ptr("high"), + GHSAID: new("GHSA-xxxx-xxxx-xxxx"), + Summary: new("Test advisory"), + Description: new("This is a test advisory."), + Severity: new("high"), }, } @@ -256,16 +256,16 @@ func Test_ListRepositorySecurityAdvisories(t *testing.T) { // Setup mock advisories for success cases adv1 := &github.SecurityAdvisory{ - GHSAID: github.Ptr("GHSA-1111-1111-1111"), - Summary: github.Ptr("Repo advisory one"), - Description: github.Ptr("First repo advisory."), - Severity: github.Ptr("high"), + GHSAID: new("GHSA-1111-1111-1111"), + Summary: new("Repo advisory one"), + Description: new("First repo advisory."), + Severity: new("high"), } adv2 := &github.SecurityAdvisory{ - GHSAID: github.Ptr("GHSA-2222-2222-2222"), - Summary: github.Ptr("Repo advisory two"), - Description: github.Ptr("Second repo advisory."), - Severity: github.Ptr("medium"), + GHSAID: new("GHSA-2222-2222-2222"), + Summary: new("Repo advisory two"), + Description: new("Second repo advisory."), + Severity: new("medium"), } tests := []struct { @@ -384,14 +384,14 @@ func Test_ListRepositorySecurityAdvisories_IFC_FeatureFlag(t *testing.T) { toolDef := ListRepositorySecurityAdvisories(translations.NullTranslationHelper) publishedAdvisory := &github.SecurityAdvisory{ - GHSAID: github.Ptr("GHSA-1111-1111-1111"), - Summary: github.Ptr("Published advisory"), - State: github.Ptr("published"), + GHSAID: new("GHSA-1111-1111-1111"), + Summary: new("Published advisory"), + State: new("published"), } draftAdvisory := &github.SecurityAdvisory{ - GHSAID: github.Ptr("GHSA-2222-2222-2222"), - Summary: github.Ptr("Draft advisory"), - State: github.Ptr("draft"), + GHSAID: new("GHSA-2222-2222-2222"), + Summary: new("Draft advisory"), + State: new("draft"), } makeMockClient := func(isPrivate bool, advisories []*github.SecurityAdvisory) *http.Client { @@ -515,16 +515,16 @@ func Test_ListOrgRepositorySecurityAdvisories(t *testing.T) { assert.ElementsMatch(t, schema.Required, []string{"org"}) adv1 := &github.SecurityAdvisory{ - GHSAID: github.Ptr("GHSA-aaaa-bbbb-cccc"), - Summary: github.Ptr("Org repo advisory 1"), - Description: github.Ptr("First advisory"), - Severity: github.Ptr("low"), + GHSAID: new("GHSA-aaaa-bbbb-cccc"), + Summary: new("Org repo advisory 1"), + Description: new("First advisory"), + Severity: new("low"), } adv2 := &github.SecurityAdvisory{ - GHSAID: github.Ptr("GHSA-dddd-eeee-ffff"), - Summary: github.Ptr("Org repo advisory 2"), - Description: github.Ptr("Second advisory"), - Severity: github.Ptr("critical"), + GHSAID: new("GHSA-dddd-eeee-ffff"), + Summary: new("Org repo advisory 2"), + Description: new("Second advisory"), + Severity: new("critical"), } tests := []struct { diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go index 54d8bc890f..f629ace35d 100644 --- a/pkg/github/server_test.go +++ b/pkg/github/server_test.go @@ -123,7 +123,7 @@ func mockRESTPermissionServer(t *testing.T, defaultPerm string, overrides map[st } } resp := gogithub.RepositoryPermissionLevel{ - Permission: gogithub.Ptr(perm), + Permission: new(perm), } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) diff --git a/pkg/github/ui_tools_test.go b/pkg/github/ui_tools_test.go index 5f6f1a61fd..4e6aba185f 100644 --- a/pkg/github/ui_tools_test.go +++ b/pkg/github/ui_tools_test.go @@ -116,34 +116,34 @@ func Test_UIGet(t *testing.T) { // Setup mock data mockAssignees := []*github.User{ - {Login: github.Ptr("user1"), AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/1")}, - {Login: github.Ptr("user2"), AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/2")}, + {Login: new("user1"), AvatarURL: new("https://avatars.githubusercontent.com/u/1")}, + {Login: new("user2"), AvatarURL: new("https://avatars.githubusercontent.com/u/2")}, } mockBranches := []*github.Branch{ - {Name: github.Ptr("main"), Protected: github.Ptr(true)}, - {Name: github.Ptr("feature"), Protected: github.Ptr(false)}, + {Name: new("main"), Protected: new(true)}, + {Name: new("feature"), Protected: new(false)}, } dueDate := time.Date(2026, 1, 31, 0, 0, 0, 0, time.UTC) mockMilestones := []*github.Milestone{ - {Number: github.Ptr(1), Title: github.Ptr("with due date"), DueOn: &github.Timestamp{Time: dueDate}}, - {Number: github.Ptr(2), Title: github.Ptr("no due date")}, + {Number: new(1), Title: new("with due date"), DueOn: &github.Timestamp{Time: dueDate}}, + {Number: new(2), Title: new("no due date")}, } mockIssueTypes := []*github.IssueType{ - {Name: github.Ptr("Bug")}, - {Name: github.Ptr("Feature")}, + {Name: new("Bug")}, + {Name: new("Feature")}, } mockReviewers := []*github.User{ - {Login: github.Ptr("octocat"), AvatarURL: github.Ptr("https://avatars.githubusercontent.com/u/583231")}, - {Login: github.Ptr("dependabot[bot]"), AvatarURL: github.Ptr("https://avatars.githubusercontent.com/in/29110")}, - {Login: github.Ptr("github-actions"), Type: github.Ptr("Bot")}, + {Login: new("octocat"), AvatarURL: new("https://avatars.githubusercontent.com/u/583231")}, + {Login: new("dependabot[bot]"), AvatarURL: new("https://avatars.githubusercontent.com/in/29110")}, + {Login: new("github-actions"), Type: new("Bot")}, } mockReviewerTeams := []*github.Team{ - {Slug: github.Ptr("docs"), Name: github.Ptr("Docs")}, + {Slug: new("docs"), Name: new("Docs")}, } tests := []struct { @@ -385,7 +385,7 @@ func Test_UIGet(t *testing.T) { { name: "branches pagination stops at the page cap", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - "GET /repos/owner/repo/branches": alwaysNextPageHandler(t, []*github.Branch{{Name: github.Ptr("feature")}}), + "GET /repos/owner/repo/branches": alwaysNextPageHandler(t, []*github.Branch{{Name: new("feature")}}), }), requestArgs: map[string]any{ "method": "branches", @@ -427,7 +427,7 @@ func Test_UIGet(t *testing.T) { { name: "reviewers pagination stops at the page cap", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - "GET /repos/owner/repo/collaborators": alwaysNextPageHandler(t, []*github.User{{Login: github.Ptr("octocat")}}), + "GET /repos/owner/repo/collaborators": alwaysNextPageHandler(t, []*github.User{{Login: new("octocat")}}), "GET /repos/owner/repo/teams": mockResponse(t, http.StatusOK, mockReviewerTeams), }), requestArgs: map[string]any{ diff --git a/pkg/lockdown/lockdown_test.go b/pkg/lockdown/lockdown_test.go index 22fab53b03..d724a46c4b 100644 --- a/pkg/lockdown/lockdown_test.go +++ b/pkg/lockdown/lockdown_test.go @@ -89,7 +89,7 @@ func newMockGQLClient(viewerLogin string, isPrivate bool) (*githubv4.Client, *co func newMockRESTServer(t *testing.T, permission string) *gogithub.Client { t.Helper() restServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - resp := gogithub.RepositoryPermissionLevel{Permission: gogithub.Ptr(permission)} + resp := gogithub.RepositoryPermissionLevel{Permission: new(permission)} w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) })) diff --git a/ui/package-lock.json b/ui/package-lock.json index 72de424ec9..31562fa821 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -9,13 +9,15 @@ "version": "1.0.0", "dependencies": { "@github/markdown-toolbar-element": "^2.2.3", - "@modelcontextprotocol/ext-apps": "^1.7.5", + "@modelcontextprotocol/client": "^2.0.0", + "@modelcontextprotocol/ext-apps": "^2.0.3", "@primer/octicons-react": "^19.38.0", "@primer/react": "^36.27.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", - "remark-gfm": "^4.0.1" + "remark-gfm": "^4.0.1", + "zod": "^4.2.0" }, "devDependencies": { "@types/node": "^26.6.4", @@ -379,19 +381,6 @@ "integrity": "sha512-wUk72T/pA3OG+YCdVaDcwHwpT2uyVmHlu6CjZyeRfoECWNQovFZAMqp8JVHjweeLxp2lM2ASqa8lLrkCskrB2g==", "license": "MIT" }, - "node_modules/@hono/node-server": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.3.tgz", - "integrity": "sha512-TA//nWMqPhbfdfneACk6t5a9eqbS9lABEPyKn0/xZTah3H3U2XaVg85rJFl0/Fyit0I552YDHgXGVSf3GwqbUw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "hono": "^4" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -454,10 +443,40 @@ "integrity": "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==", "license": "BSD-3-Clause" }, + "node_modules/@modelcontextprotocol/client": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.2.0.tgz", + "integrity": "sha512-LxCou/CSYQ6dwEnjhLZY0KnEuc8V4UJ3IEQCl/uR2yHobSQIEdJKUlKLRYRF5i5FqRGHy1eK7une3aAWDHLtig==", + "license": "MIT", + "dependencies": { + "@modelcontextprotocol/core": "2.2.0", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "jose": "^6.1.3", + "pkce-challenge": "^5.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.2.0.tgz", + "integrity": "sha512-iLhmprRmWI8EcosOA3wVvww22z02NkgqhV4fBH6f/odQBsi7xnJc0HGmi21yWO+/iKw2yzqVE127Zhna5/+JXw==", + "license": "MIT", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@modelcontextprotocol/ext-apps": { - "version": "1.7.5", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.7.5.tgz", - "integrity": "sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-2.0.3.tgz", + "integrity": "sha512-Tn+4+cyhO4f4cQSDxNyLiIfEE+qlTTuIbWBJB8JTinEXOhDYgCT4wDTQplxD9MBYZH5pCYGC59GaxiwIT4bu8w==", "license": "MIT", "workspaces": [ "examples/*" @@ -469,12 +488,17 @@ "node": ">=20" }, "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.29.0", + "@modelcontextprotocol/client": "^2.0.0", + "@modelcontextprotocol/core": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.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" + "zod": "^4.2.0" }, "peerDependenciesMeta": { + "@modelcontextprotocol/server": { + "optional": true + }, "react": { "optional": true }, @@ -483,47 +507,6 @@ } } }, - "node_modules/@modelcontextprotocol/sdk": { - "version": "1.31.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.31.0.tgz", - "integrity": "sha512-UvTMgnNlnIBO/22ob2RcVGDlcvOslQs8T59+FTGdA0L27a39fdGF/EDETNtDVK4DZGpwomlsYpRdA8UXcVL/pw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@hono/node-server": "^1.19.9 || ^2.0.5", - "ajv": "^8.17.1", - "ajv-formats": "^3.0.1", - "content-type": "^1.0.5", - "cors": "^2.8.5", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "express": "^5.2.1", - "express-rate-limit": "^8.2.1", - "hono": "^4.11.4", - "jose": "^6.1.3", - "json-schema-typed": "^8.0.2", - "pkce-challenge": "^5.0.0", - "raw-body": "^3.0.0", - "zod": "^3.25 || ^4.0", - "zod-to-json-schema": "^3.25.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@cfworker/json-schema": "^4.1.1", - "zod": "^3.25 || ^4.0" - }, - "peerDependenciesMeta": { - "@cfworker/json-schema": { - "optional": true - }, - "zod": { - "optional": false - } - } - }, "node_modules/@oddbird/popover-polyfill": { "version": "0.3.8", "resolved": "https://registry.npmjs.org/@oddbird/popover-polyfill/-/popover-polyfill-0.3.8.tgz", @@ -2274,55 +2257,6 @@ } } }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "peer": true, - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ajv": { - "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": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "node_modules/bail": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", @@ -2346,45 +2280,6 @@ "node": ">=6.0.0" } }, - "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", - "license": "MIT", - "peer": true, - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^2.0.0", - "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", - "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/body-parser/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -2432,47 +2327,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/bytes": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", - "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/call-bind-apply-helpers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "es-errors": "^1.3.0", - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/call-bound": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "get-intrinsic": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/camelize": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", @@ -2579,30 +2433,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/content-disposition": { - "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": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/content-type": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", - "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -2610,50 +2440,11 @@ "license": "MIT", "peer": true }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.6.0" - } - }, - "node_modules/cors": { - "version": "2.8.6", - "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", - "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", - "license": "MIT", - "peer": true, - "dependencies": { - "object-assign": "^4", - "vary": "^1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "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", @@ -2730,16 +2521,6 @@ "node": ">=0.10.0" } }, - "node_modules/depd": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", - "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -2781,28 +2562,6 @@ "node": ">=0.3.1" } }, - "node_modules/dunder-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.1", - "es-errors": "^1.3.0", - "gopd": "^1.2.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/ee-first": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", - "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", - "license": "MIT", - "peer": true - }, "node_modules/electron-to-chromium": { "version": "1.5.444", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.444.tgz", @@ -2810,49 +2569,6 @@ "license": "ISC", "peer": true }, - "node_modules/encodeurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", - "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/es-define-property": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/es-object-atoms": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", - "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", - "license": "MIT", - "peer": true, - "dependencies": { - "es-errors": "^1.3.0" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -2863,13 +2579,6 @@ "node": ">=6" } }, - "node_modules/escape-html": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", - "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", - "license": "MIT", - "peer": true - }, "node_modules/escape-string-regexp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", @@ -2892,22 +2601,11 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/etag": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", - "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, "node_modules/eventsource": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", "license": "MIT", - "peer": true, "dependencies": { "eventsource-parser": "^3.0.1" }, @@ -2920,105 +2618,16 @@ "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=18.0.0" } }, - "node_modules/express": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", - "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", - "license": "MIT", - "peer": true, - "dependencies": { - "accepts": "^2.0.0", - "body-parser": "^2.2.1", - "content-disposition": "^1.0.0", - "content-type": "^1.0.5", - "cookie": "^0.7.1", - "cookie-signature": "^1.2.1", - "debug": "^4.4.0", - "depd": "^2.0.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "finalhandler": "^2.1.0", - "fresh": "^2.0.0", - "http-errors": "^2.0.0", - "merge-descriptors": "^2.0.0", - "mime-types": "^3.0.0", - "on-finished": "^2.4.1", - "once": "^1.4.0", - "parseurl": "^1.3.3", - "proxy-addr": "^2.0.7", - "qs": "^6.14.0", - "range-parser": "^1.2.1", - "router": "^2.2.0", - "send": "^1.1.0", - "serve-static": "^2.2.0", - "statuses": "^2.0.1", - "type-is": "^2.0.1", - "vary": "^1.1.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/express-rate-limit": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", - "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.4.3", - "ip-address": "^10.2.0" - }, - "engines": { - "node": ">= 16" - }, - "funding": { - "url": "https://github.com/sponsors/express-rate-limit" - }, - "peerDependencies": { - "express": ">= 4.11" - } - }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "license": "MIT" }, - "node_modules/fast-deep-equal": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "license": "MIT", - "peer": true - }, - "node_modules/fast-uri": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", - "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause", - "peer": true - }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -3050,54 +2659,12 @@ "node": ">=8" } }, - "node_modules/finalhandler": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", - "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.4.0", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "on-finished": "^2.4.1", - "parseurl": "^1.3.3", - "statuses": "^2.0.1" - }, - "engines": { - "node": ">= 18.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/focus-visible": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/focus-visible/-/focus-visible-5.2.1.tgz", "integrity": "sha512-8Bx950VD1bWTQJEH/AM6SpEk+SU55aVnp4Ujhuuxy3eMEBCRwBnTBnVXr9YAPvZL3/CNjCa8u4IWfNmEO53whA==", "license": "W3C" }, - "node_modules/forwarded": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", - "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/fresh": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", - "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3113,16 +2680,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/fzy.js": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/fzy.js/-/fzy.js-0.4.1.tgz", @@ -3139,58 +2696,6 @@ "node": ">=6.9.0" } }, - "node_modules/get-intrinsic": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bind-apply-helpers": "^1.0.2", - "es-define-property": "^1.0.1", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.1.1", - "function-bind": "^1.1.2", - "get-proto": "^1.0.1", - "gopd": "^1.2.0", - "has-symbols": "^1.1.0", - "hasown": "^2.0.2", - "math-intrinsics": "^1.1.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/get-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "license": "MIT", - "peer": true, - "dependencies": { - "dunder-proto": "^1.0.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/gopd": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -3201,32 +2706,6 @@ "node": ">=4" } }, - "node_modules/has-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "license": "MIT", - "peer": true, - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -3293,16 +2772,6 @@ "license": "MIT", "peer": true }, - "node_modules/hono": { - "version": "4.13.12", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.12.tgz", - "integrity": "sha512-6E2QDAc9Ick9Sq77ZrGS/dk2WUYni91aufTw6LJKpV7w8kW5/GxVUc650FOADOmlwg3K+f7Pun6XlV+pYmW6gw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=16.9.0" - } - }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -3313,77 +2782,12 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/http-errors": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", - "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "depd": "~2.0.0", - "inherits": "~2.0.4", - "setprototypeof": "~1.2.0", - "statuses": "~2.0.2", - "toidentifier": "~1.0.1" - }, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/iconv-lite": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", - "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC", - "peer": true - }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", "license": "MIT" }, - "node_modules/ip-address": { - "version": "10.7.3", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.3.tgz", - "integrity": "sha512-A1kdq/tSb5QjvKvAMgIoEvDBIgL7qaqVP/jkvSwYYRZ9iEzvPpopxp2wQfu3SuZRHtpHNxMn8Fs0bS+gf5Xmwg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 12" - } - }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/is-alphabetical": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", @@ -3473,26 +2877,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT", - "peer": true - }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/jose": { "version": "6.2.12", "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/panva" } @@ -3516,20 +2911,6 @@ "node": ">=6" } }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT", - "peer": true - }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause", - "peer": true - }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -3867,16 +3248,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/math-intrinsics": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - } - }, "node_modules/mdast-util-definitions": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-5.1.2.tgz", @@ -4221,33 +3592,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/media-typer": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", - "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/merge-descriptors": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", - "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/micromark": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz", @@ -4859,33 +4203,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/mime-db": { - "version": "1.54.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", - "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", - "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", - "license": "MIT", - "peer": true, - "dependencies": { - "mime-db": "^1.54.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/mri": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", @@ -4920,37 +4237,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/negotiator": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", - "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", - "license": "MIT", - "peer": true, - "dependencies": { - "content-type": "^2.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/negotiator/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/node-releases": { "version": "2.0.57", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", @@ -4970,42 +4256,6 @@ "node": ">=0.10.0" } }, - "node_modules/object-inspect": { - "version": "1.13.4", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", - "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/on-finished": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", - "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", - "license": "MIT", - "peer": true, - "dependencies": { - "ee-first": "1.1.1" - }, - "engines": { - "node": ">= 0.8" - } - }, - "node_modules/once": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", - "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "license": "ISC", - "peer": true, - "dependencies": { - "wrappy": "1" - } - }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -5031,37 +4281,15 @@ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", "license": "MIT" }, - "node_modules/parseurl": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", - "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/path-key": { "version": "3.1.1", "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" } }, - "node_modules/path-to-regexp": { - "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": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -5085,7 +4313,6 @@ "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.20.0" } @@ -5153,71 +4380,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/proxy-addr": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", - "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "forwarded": "0.2.0", - "ipaddr.js": "1.9.1" - }, - "engines": { - "node": ">= 0.10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/qs": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", - "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", - "license": "BSD-3-Clause", - "peer": true, - "dependencies": { - "es-define-property": "^1.0.1", - "side-channel": "^1.1.1" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/range-parser": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", - "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.6" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/raw-body": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", - "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", - "license": "MIT", - "peer": true, - "dependencies": { - "bytes": "~3.1.2", - "http-errors": "~2.0.1", - "iconv-lite": "~0.7.0", - "unpipe": "~1.0.0" - }, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -5358,16 +4520,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/rolldown": { "version": "1.2.12", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz", @@ -5402,23 +4554,6 @@ "@rolldown/binding-win32-x64-msvc": "1.2.12" } }, - "node_modules/router": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", - "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.4.0", - "depd": "^2.0.0", - "is-promise": "^4.0.0", - "parseurl": "^1.3.3", - "path-to-regexp": "^8.0.0" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -5431,13 +4566,6 @@ "node": ">=6" } }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "license": "MIT", - "peer": true - }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -5457,60 +4585,6 @@ "semver": "bin/semver.js" } }, - "node_modules/send": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", - "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "debug": "^4.4.3", - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "etag": "^1.8.1", - "fresh": "^2.0.0", - "http-errors": "^2.0.1", - "mime-types": "^3.0.2", - "ms": "^2.1.3", - "on-finished": "^2.4.1", - "range-parser": "^1.2.1", - "statuses": "^2.0.2" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/serve-static": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", - "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", - "license": "MIT", - "peer": true, - "dependencies": { - "encodeurl": "^2.0.0", - "escape-html": "^1.0.3", - "parseurl": "^1.3.3", - "send": "^1.2.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/setprototypeof": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", - "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "license": "ISC", - "peer": true - }, "node_modules/shallowequal": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", @@ -5523,7 +4597,6 @@ "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" }, @@ -5536,87 +4609,10 @@ "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" } }, - "node_modules/side-channel": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", - "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "es-errors": "^1.3.0", - "object-inspect": "^1.13.4", - "side-channel-list": "^1.0.1", - "side-channel-map": "^1.0.1", - "side-channel-weakmap": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-list": { - "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.4" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-map": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", - "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/side-channel-weakmap": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", - "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", - "license": "MIT", - "peer": true, - "dependencies": { - "call-bound": "^1.0.2", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.5", - "object-inspect": "^1.13.3", - "side-channel-map": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/source-map-js": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", @@ -5637,16 +4633,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/statuses": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", - "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -5791,16 +4777,6 @@ "node": ">=8.0" } }, - "node_modules/toidentifier": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", - "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.6" - } - }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -5821,39 +4797,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/type-is": { - "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": "^2.0.0", - "media-typer": "^1.1.0", - "mime-types": "^3.0.0" - }, - "engines": { - "node": ">= 18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, - "node_modules/type-is/node_modules/content-type": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", - "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", @@ -5993,16 +4936,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/unpipe": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", - "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/update-browserslist-db": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", @@ -6052,16 +4985,6 @@ "node": ">=8" } }, - "node_modules/vary": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", - "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">= 0.8" - } - }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -6195,7 +5118,6 @@ "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" }, @@ -6206,13 +5128,6 @@ "node": ">= 8" } }, - "node_modules/wrappy": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", - "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "license": "ISC", - "peer": true - }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -6225,21 +5140,10 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", "integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-to-json-schema": { - "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.28 || ^4" - } - }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/ui/package.json b/ui/package.json index bd8097d0e5..efbd84bb16 100644 --- a/ui/package.json +++ b/ui/package.json @@ -15,13 +15,15 @@ }, "dependencies": { "@github/markdown-toolbar-element": "^2.2.3", - "@modelcontextprotocol/ext-apps": "^1.7.5", + "@modelcontextprotocol/client": "^2.0.0", + "@modelcontextprotocol/ext-apps": "^2.0.3", "@primer/octicons-react": "^19.38.0", "@primer/react": "^36.27.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^10.1.0", - "remark-gfm": "^4.0.1" + "remark-gfm": "^4.0.1", + "zod": "^4.2.0" }, "devDependencies": { "@types/node": "^26.6.4", diff --git a/ui/src/hooks/useMcpApp.ts b/ui/src/hooks/useMcpApp.ts index e4146a234f..f45276abf0 100644 --- a/ui/src/hooks/useMcpApp.ts +++ b/ui/src/hooks/useMcpApp.ts @@ -5,7 +5,7 @@ import type { McpUiHostContext, McpUiUpdateModelContextRequest, } from "@modelcontextprotocol/ext-apps"; -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { CallToolResult } from "@modelcontextprotocol/client"; import { useState, useCallback, useEffect } from "react"; interface UseMcpAppOptions { diff --git a/ui/src/lib/toolResult.ts b/ui/src/lib/toolResult.ts index e985537f49..cb39015754 100644 --- a/ui/src/lib/toolResult.ts +++ b/ui/src/lib/toolResult.ts @@ -1,4 +1,4 @@ -import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { CallToolResult } from "@modelcontextprotocol/client"; /** * Returns the parsed JSON payload of a *completed* tool result, or `null` when From 36e5c48f2e376f8f4ffa30fd718c38deedb7ae27 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Fri, 2 Oct 2026 13:22:57 +0200 Subject: [PATCH 39/61] ci: migrate cosign to v3 and dual-publish image signatures (#3382) * ci: migrate cosign to v3 with legacy signature compatibility Keep legacy image payloads, .sig tags, and Rekor v1 service defaults. Document exact cosign v2/v3 image verification and unchanged release archive attestations. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * ci: dual-publish legacy and native cosign image signatures Sign the same immutable digest in separate fail-fast steps. Retain legacy signatures and publish native bundles with v3 service defaults. Remove the verification documentation introduced by this PR as requested; keep compatibility evidence and rollout plans in the PR description. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/docker-publish.yml | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index db5e847a5a..a810a716ca 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -48,7 +48,7 @@ jobs: if: github.event_name != 'pull_request' uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 #v4.1.2 with: - cosign-release: "v2.6.5" + cosign-release: "v3.1.3" # Set up BuildKit Docker container builder to be able to build # multi-platform images and export cache @@ -122,16 +122,20 @@ jobs: oauth_client_secret=${{ secrets.OAUTH_CLIENT_SECRET }} # Sign the resulting Docker image digest except on PRs. - # This will only write to the public Rekor transparency log when the Docker - # repository is public to avoid leaking data. If you would like to publish - # transparency data even for private images, pass --force to cosign below. + # Publish both v2-compatible .sig tags and native Sigstore bundles. + # --yes also consents to publishing signing metadata to the public log. # https://github.com/sigstore/cosign - - name: Sign the published Docker image + - name: Sign the published Docker image (legacy) if: ${{ github.event_name != 'pull_request' }} env: # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable - TAGS: ${{ steps.meta.outputs.tags }} DIGEST: ${{ steps.build-and-push.outputs.digest }} # This step uses the identity token to provision an ephemeral certificate # against the sigstore community Fulcio instance. - run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} + run: cosign sign --yes --new-bundle-format=false --use-signing-config=false --registry-referrers-mode=legacy "${REGISTRY}/${IMAGE_NAME}@${DIGEST}" + + - name: Sign the published Docker image (native bundle) + if: ${{ github.event_name != 'pull_request' }} + env: + DIGEST: ${{ steps.build-and-push.outputs.digest }} + run: cosign sign --yes "${REGISTRY}/${IMAGE_NAME}@${DIGEST}" From f10e4e1f923d46b86f2e80e849aa74084c847184 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Fri, 2 Oct 2026 16:31:11 +0200 Subject: [PATCH 40/61] build(ui): migrate to React 19 and Primer React 38 (#3383) * build(ui): migrate to React 19 and Primer React 38 Replace removed Box and sx APIs with native styles and CSS Modules, load Primer CSS tokens, use the CSS-based theme provider and Banner, and scope custom-element JSX types to the React runtime. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(ui): use CSS token for reviewer label backgrounds Replace leftover canvas.inset theme paths with the CSS custom property in PR creation and editing. Keep the reviewed native gaps unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CONTRIBUTING.md | 8 + ui/package-lock.json | 2234 +++----------------------- ui/package.json | 11 +- ui/src/apps/get-me/App.tsx | 131 +- ui/src/apps/issue-write/App.tsx | 526 +++--- ui/src/apps/pr-edit/App.tsx | 382 +++-- ui/src/apps/pr-write/App.tsx | 400 +++-- ui/src/components/AppProvider.tsx | 10 +- ui/src/components/FeedbackFooter.tsx | 18 +- ui/src/components/MarkdownEditor.tsx | 227 ++- ui/src/styles.module.css | 148 ++ 11 files changed, 1434 insertions(+), 2661 deletions(-) create mode 100644 ui/src/styles.module.css diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7ec4f136a8..24f93af022 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,6 +40,14 @@ Type checking uses the native TypeScript 7 compiler through `tsc`; Vite handles transpilation and bundling separately. No TypeScript compiler API integration, typescript-eslint, or ts-node is required. +### MCP Apps UI + +The `ui/` views use React 19 and Primer React 38. With Node.js 20.19+ or 22.12+, run `cd ui && npm ci && npm run typecheck && npm run build && npm audit` before `script/test`. The build writes self-contained HTML to `pkg/github/ui_dist/`, which the Go server embeds; these generated files are not committed. + +Primer 38 no longer exports `Box` or accepts `sx`/styled-system props. Use semantic HTML, native `style` props for dynamic/layout styles, and CSS Modules for nested selectors. `AppProvider` loads Primer's primitive tokens and light/dark themes, so use CSS variables rather than JavaScript theme values. Custom element typings must augment `react/jsx-runtime` rather than the global `JSX` namespace. + +For UI dependency upgrades, compare all four views (`get-me`, `issue-write`, `pr-write`, and `pr-edit`) in light/dark themes and at narrow widths in an MCP Apps host, including menus, Markdown editing/preview, and completed-result views. Include before/after screenshots in the pull request. + ## Submitting a pull request 1. [Fork][fork] and clone the repository diff --git a/ui/package-lock.json b/ui/package-lock.json index 31562fa821..4211483e2b 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -12,17 +12,18 @@ "@modelcontextprotocol/client": "^2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.3", "@primer/octicons-react": "^19.38.0", - "@primer/react": "^36.27.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "@primer/primitives": "^11.10.0", + "@primer/react": "^38.40.1", + "react": "^19.3.0", + "react-dom": "^19.3.0", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "zod": "^4.2.0" }, "devDependencies": { "@types/node": "^26.6.4", - "@types/react": "^18.3.31", - "@types/react-dom": "^18.3.7", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", "typescript": "^7.0.2", "vite": "^8.3.2", @@ -32,237 +33,6 @@ "node": "^26.0.0" } }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", - "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/parser": "^7.29.8", - "@babel/types": "^7.29.8", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", - "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", - "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", - "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/types": "^7.29.8" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", - "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/runtime": { "version": "7.29.7", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", @@ -272,165 +42,24 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.8.tgz", - "integrity": "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.8", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.8", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.8", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.8", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", - "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "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/@github/combobox-nav": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/@github/combobox-nav/-/combobox-nav-2.3.1.tgz", - "integrity": "sha512-gwxPzLw8XKecy1nP63i9lOBritS3bWmxl02UX6G0TwMQZbMem1BCS1tEZgYd3mkrkiDrUMWaX+DbFCuDFo3K+A==", - "license": "MIT" - }, "node_modules/@github/markdown-toolbar-element": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@github/markdown-toolbar-element/-/markdown-toolbar-element-2.2.3.tgz", "integrity": "sha512-AlquKGee+IWiAMYVB0xyHFZRMnu4n3X4HTvJHu79GiVJ1ojTukCWyxMlF5NMsecoLcBKsuBhx3QPv2vkE/zQ0A==", "license": "MIT" }, - "node_modules/@github/paste-markdown": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/@github/paste-markdown/-/paste-markdown-1.5.3.tgz", - "integrity": "sha512-PzZ1b3PaqBzYqbT4fwKEhiORf38h2OcGp2+JdXNNM7inZ7egaSmfmhyNkQILpqWfS0AYtRS3CDq6z03eZ8yOMQ==", + "node_modules/@github/mini-throttle": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@github/mini-throttle/-/mini-throttle-2.1.1.tgz", + "integrity": "sha512-KtOPaB+FiKJ6jcKm9UKyaM5fPURHGf+xcp+b4Mzoi81hOc6M1sIGpMZMAVbNzfa2lW5+RPGKq888Px0j76OZ/A==", "license": "MIT" }, "node_modules/@github/relative-time-element": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/@github/relative-time-element/-/relative-time-element-4.5.1.tgz", - "integrity": "sha512-uxCxCwe9vdwUDmRmM84tN0UERlj8MosLV44+r/VDj7DZUVUSTP4vyWlE9mRK6vHelOmT8DS3RMlaMrLlg1h1PQ==", - "license": "MIT" - }, - "node_modules/@github/tab-container-element": { - "version": "4.9.0", - "resolved": "https://registry.npmjs.org/@github/tab-container-element/-/tab-container-element-4.9.0.tgz", - "integrity": "sha512-wUk72T/pA3OG+YCdVaDcwHwpT2uyVmHlu6CjZyeRfoECWNQovFZAMqp8JVHjweeLxp2lM2ASqa8lLrkCskrB2g==", + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/@github/relative-time-element/-/relative-time-element-5.3.1.tgz", + "integrity": "sha512-f++QCIXxGH7rMbpTASRfLyCyPydd4HqwOmATBb/sAUCq72HEZN0MX9znmJSp7NKYxuQxF8j0Mv3ez9P32c70vw==", "license": "MIT" }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "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" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "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" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "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" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", - "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", - "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" - } - }, "node_modules/@lit-labs/react": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@lit-labs/react/-/react-1.2.1.tgz", @@ -439,922 +68,171 @@ }, "node_modules/@lit-labs/ssr-dom-shim": { "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz", - "integrity": "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==", - "license": "BSD-3-Clause" - }, - "node_modules/@modelcontextprotocol/client": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.2.0.tgz", - "integrity": "sha512-LxCou/CSYQ6dwEnjhLZY0KnEuc8V4UJ3IEQCl/uR2yHobSQIEdJKUlKLRYRF5i5FqRGHy1eK7une3aAWDHLtig==", - "license": "MIT", - "dependencies": { - "@modelcontextprotocol/core": "2.2.0", - "cross-spawn": "^7.0.5", - "eventsource": "^3.0.2", - "eventsource-parser": "^3.0.0", - "jose": "^6.1.3", - "pkce-challenge": "^5.0.0", - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@modelcontextprotocol/core": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.2.0.tgz", - "integrity": "sha512-iLhmprRmWI8EcosOA3wVvww22z02NkgqhV4fBH6f/odQBsi7xnJc0HGmi21yWO+/iKw2yzqVE127Zhna5/+JXw==", - "license": "MIT", - "dependencies": { - "zod": "^4.2.0" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/@modelcontextprotocol/ext-apps": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-2.0.3.tgz", - "integrity": "sha512-Tn+4+cyhO4f4cQSDxNyLiIfEE+qlTTuIbWBJB8JTinEXOhDYgCT4wDTQplxD9MBYZH5pCYGC59GaxiwIT4bu8w==", - "license": "MIT", - "workspaces": [ - "examples/*" - ], - "dependencies": { - "@standard-schema/spec": "^1.1.0" - }, - "engines": { - "node": ">=20" - }, - "peerDependencies": { - "@modelcontextprotocol/client": "^2.0.0", - "@modelcontextprotocol/core": "^2.0.0", - "@modelcontextprotocol/server": "^2.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", - "zod": "^4.2.0" - }, - "peerDependenciesMeta": { - "@modelcontextprotocol/server": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, - "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.152.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", - "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/oxc-project" - } - }, - "node_modules/@primer/behaviors": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/@primer/behaviors/-/behaviors-1.11.0.tgz", - "integrity": "sha512-kDHtaw9kfCjPVT61eM731ftckhGyWiPX+OKmUccLN9sMFt9E6FXmsPAbny7O0RBxeW1Xj/NHNdbMEjYEg2dBYg==", - "license": "MIT" - }, - "node_modules/@primer/live-region-element": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/@primer/live-region-element/-/live-region-element-0.7.2.tgz", - "integrity": "sha512-wdxCHfcJzE1IPPjZNFR4RTwRcSWb7TN0fRdMH5HcxphLEnuZBWy0TAxk3xPA+/6lwiN3uEJ+ZWV4UF/glXh43A==", - "license": "MIT", - "dependencies": { - "@lit-labs/ssr-dom-shim": "^1.2.0" - } - }, - "node_modules/@primer/octicons-react": { - "version": "19.38.0", - "resolved": "https://registry.npmjs.org/@primer/octicons-react/-/octicons-react-19.38.0.tgz", - "integrity": "sha512-ZzWVrX6kluBaCepsDj/jAxagBOMaRX18LEf723IKpr4E/jdkTdCOtlsN+60Oj7IV9YEVrlUpo9Oky0Tn1heSCA==", - "license": "MIT", - "peerDependencies": { - "react": ">=16.3" - } - }, - "node_modules/@primer/primitives": { - "version": "7.17.1", - "resolved": "https://registry.npmjs.org/@primer/primitives/-/primitives-7.17.1.tgz", - "integrity": "sha512-SiPzEb+up1nDpV2NGwNiY8m6sGnF3OUqRb0has5s6T40vq6Li/g3cYVgl+oolEa4DUoNygEPs09jwJt24f/3zg==", - "license": "MIT" - }, - "node_modules/@primer/react": { - "version": "36.27.0", - "resolved": "https://registry.npmjs.org/@primer/react/-/react-36.27.0.tgz", - "integrity": "sha512-dVyp0f9zbbQYQZ6ztfMET43vVaWhvSz+qWirBzpRjDxvCk8vCQsvWrVGUU/PR0kAxxDHf6hqeLG7vcDL229NLA==", - "license": "MIT", - "dependencies": { - "@github/combobox-nav": "^2.1.5", - "@github/markdown-toolbar-element": "^2.1.0", - "@github/paste-markdown": "^1.4.0", - "@github/relative-time-element": "^4.4.1", - "@github/tab-container-element": "^4.8.0", - "@lit-labs/react": "1.2.1", - "@oddbird/popover-polyfill": "^0.3.1", - "@primer/behaviors": "^1.7.0", - "@primer/live-region-element": "^0.7.0", - "@primer/octicons-react": "^19.9.0", - "@primer/primitives": "^7.16.0", - "@styled-system/css": "^5.1.5", - "@styled-system/props": "^5.1.5", - "@styled-system/theme-get": "^5.1.2", - "@types/react-is": "^18.2.1", - "@types/styled-system": "^5.1.12", - "@types/styled-system__css": "^5.0.16", - "@types/styled-system__theme-get": "^5.0.1", - "clsx": "^1.2.1", - "color2k": "^2.0.3", - "deepmerge": "^4.2.2", - "focus-visible": "^5.2.0", - "fzy.js": "^0.4.1", - "history": "^5.0.0", - "lodash.isempty": "^4.4.0", - "lodash.isobject": "^3.0.2", - "react-intersection-observer": "^9.4.3", - "react-is": "^18.2.0", - "react-markdown": "8.0.7", - "styled-system": "^5.1.5" - }, - "engines": { - "node": ">=12", - "npm": ">=7" - }, - "peerDependencies": { - "@types/react": "^18.0.0", - "@types/react-dom": "^18.0.0", - "@types/styled-components": "^5.1.11", - "react": "^18.0.0", - "react-dom": "^18.0.0", - "styled-components": "5.x" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "@types/react-dom": { - "optional": true - }, - "@types/styled-components": { - "optional": true - } - } - }, - "node_modules/@primer/react/node_modules/@types/hast": { - "version": "2.3.10", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz", - "integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==", - "license": "MIT", - "dependencies": { - "@types/unist": "^2" - } - }, - "node_modules/@primer/react/node_modules/@types/mdast": { - "version": "3.0.15", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", - "integrity": "sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^2" - } - }, - "node_modules/@primer/react/node_modules/@types/unist": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", - "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", - "license": "MIT" - }, - "node_modules/@primer/react/node_modules/hast-util-whitespace": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-2.0.1.tgz", - "integrity": "sha512-nAxA0v8+vXSBDt3AnRUNjyRIQ0rD+ntpbAp4LnPkumc5M9yUbSMa4XDU9Q6etY4f1Wp4bNgvc1yjiZtsTTrSng==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/@primer/react/node_modules/inline-style-parser": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz", - "integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==", - "license": "MIT" - }, - "node_modules/@primer/react/node_modules/mdast-util-from-markdown": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-1.3.1.tgz", - "integrity": "sha512-4xTO/M8c82qBcnQc1tgpNtubGUW/Y1tBQ1B0i5CtSoelOLKFYlElIr3bvgREYYO5iRqbMY1YuqZng0GVOI8Qww==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^3.0.0", - "@types/unist": "^2.0.0", - "decode-named-character-reference": "^1.0.0", - "mdast-util-to-string": "^3.1.0", - "micromark": "^3.0.0", - "micromark-util-decode-numeric-character-reference": "^1.0.0", - "micromark-util-decode-string": "^1.0.0", - "micromark-util-normalize-identifier": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0", - "unist-util-stringify-position": "^3.0.0", - "uvu": "^0.5.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/@primer/react/node_modules/mdast-util-to-hast": { - "version": "12.3.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-12.3.0.tgz", - "integrity": "sha512-pits93r8PhnIoU4Vy9bjW39M2jJ6/tdHyja9rrot9uujkN7UTU9SDnE6WNJz/IGyQk3XHX6yNNtrBH6cQzm8Hw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^2.0.0", - "@types/mdast": "^3.0.0", - "mdast-util-definitions": "^5.0.0", - "micromark-util-sanitize-uri": "^1.1.0", - "trim-lines": "^3.0.0", - "unist-util-generated": "^2.0.0", - "unist-util-position": "^4.0.0", - "unist-util-visit": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/@primer/react/node_modules/mdast-util-to-string": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-3.2.0.tgz", - "integrity": "sha512-V4Zn/ncyN1QNSqSBxTrMOLpjr+IKdHl2v3KVLoWmDPscP4r9GcCi71gjgvUV1SFSKh92AjAG4peFuBl2/YgCJg==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/@primer/react/node_modules/micromark": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/micromark/-/micromark-3.2.0.tgz", - "integrity": "sha512-uD66tJj54JLYq0De10AhWycZWGQNUvDI55xPgk2sQM5kn1JYlhbCMTtEeT27+vAhW2FBQxLlOmS3pmA7/2z4aA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "@types/debug": "^4.0.0", - "debug": "^4.0.0", - "decode-named-character-reference": "^1.0.0", - "micromark-core-commonmark": "^1.0.1", - "micromark-factory-space": "^1.0.0", - "micromark-util-character": "^1.0.0", - "micromark-util-chunked": "^1.0.0", - "micromark-util-combine-extensions": "^1.0.0", - "micromark-util-decode-numeric-character-reference": "^1.0.0", - "micromark-util-encode": "^1.0.0", - "micromark-util-normalize-identifier": "^1.0.0", - "micromark-util-resolve-all": "^1.0.0", - "micromark-util-sanitize-uri": "^1.0.0", - "micromark-util-subtokenize": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.1", - "uvu": "^0.5.0" - } - }, - "node_modules/@primer/react/node_modules/micromark-core-commonmark": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-1.1.0.tgz", - "integrity": "sha512-BgHO1aRbolh2hcrzL2d1La37V0Aoz73ymF8rAcKnohLy93titmv62E0gP8Hrx9PKcKrqCZ1BbLGbP3bEhoXYlw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-factory-destination": "^1.0.0", - "micromark-factory-label": "^1.0.0", - "micromark-factory-space": "^1.0.0", - "micromark-factory-title": "^1.0.0", - "micromark-factory-whitespace": "^1.0.0", - "micromark-util-character": "^1.0.0", - "micromark-util-chunked": "^1.0.0", - "micromark-util-classify-character": "^1.0.0", - "micromark-util-html-tag-name": "^1.0.0", - "micromark-util-normalize-identifier": "^1.0.0", - "micromark-util-resolve-all": "^1.0.0", - "micromark-util-subtokenize": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.1", - "uvu": "^0.5.0" - } - }, - "node_modules/@primer/react/node_modules/micromark-factory-destination": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-1.1.0.tgz", - "integrity": "sha512-XaNDROBgx9SgSChd69pjiGKbV+nfHGDPVYFs5dOoDd7ZnMAE+Cuu91BCpsY8RT2NP9vo/B8pds2VQNCLiu0zhg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/@primer/react/node_modules/micromark-factory-label": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-1.1.0.tgz", - "integrity": "sha512-OLtyez4vZo/1NjxGhcpDSbHQ+m0IIGnT8BoPamh+7jVlzLJBH98zzuCoUeMxvM6WsNeh8wx8cKvqLiPHEACn0w==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0", - "uvu": "^0.5.0" - } - }, - "node_modules/@primer/react/node_modules/micromark-factory-space": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-1.1.0.tgz", - "integrity": "sha512-cRzEj7c0OL4Mw2v6nwzttyOZe8XY/Z8G0rzmWQZTBi/jjwyw/U4uqKtUORXQrR5bAZZnbTI/feRV/R7hc4jQYQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/@primer/react/node_modules/micromark-factory-title": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-1.1.0.tgz", - "integrity": "sha512-J7n9R3vMmgjDOCY8NPw55jiyaQnH5kBdV2/UXCtZIpnHH3P6nHUKaH7XXEYuWwx/xUJcawa8plLBEjMPU24HzQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^1.0.0", - "micromark-util-character": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/@primer/react/node_modules/micromark-factory-whitespace": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-1.1.0.tgz", - "integrity": "sha512-v2WlmiymVSp5oMg+1Q0N1Lxmt6pMhIHD457whWM7/GUlEks1hI9xj5w3zbc4uuMKXGisksZk8DzP2UyGbGqNsQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-factory-space": "^1.0.0", - "micromark-util-character": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/@primer/react/node_modules/micromark-util-character": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-1.2.0.tgz", - "integrity": "sha512-lXraTwcX3yH/vMDaFWCQJP1uIszLVebzUa3ZHdrgxr7KEU/9mL4mVgCpGbyhvNLNlauROiNUq7WN5u7ndbY6xg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/@primer/react/node_modules/micromark-util-chunked": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-1.1.0.tgz", - "integrity": "sha512-Ye01HXpkZPNcV6FiyoW2fGZDUw4Yc7vT0E9Sad83+bEDiCJ1uXu0S3mr8WLpsz3HaG3x2q0HM6CTuPdcZcluFQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^1.0.0" - } - }, - "node_modules/@primer/react/node_modules/micromark-util-classify-character": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-1.1.0.tgz", - "integrity": "sha512-SL0wLxtKSnklKSUplok1WQFoGhUdWYKggKUiqhX+Swala+BtptGCu5iPRc+xvzJ4PXE/hwM3FNXsfEVgoZsWbw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/@primer/react/node_modules/micromark-util-combine-extensions": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-1.1.0.tgz", - "integrity": "sha512-Q20sp4mfNf9yEqDL50WwuWZHUrCO4fEyeDCnMGmG5Pr0Cz15Uo7KBs6jq+dq0EgX4DPwwrh9m0X+zPV1ypFvUA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-chunked": "^1.0.0", - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/@primer/react/node_modules/micromark-util-decode-numeric-character-reference": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-1.1.0.tgz", - "integrity": "sha512-m9V0ExGv0jB1OT21mrWcuf4QhP46pH1KkfWy9ZEezqHKAxkj4mPCy3nIH1rkbdMlChLHX531eOrymlwyZIf2iw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^1.0.0" - } - }, - "node_modules/@primer/react/node_modules/micromark-util-decode-string": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-1.1.0.tgz", - "integrity": "sha512-YphLGCK8gM1tG1bd54azwyrQRjCFcmgj2S2GoJDNnh4vYtnL38JS8M4gpxzOPNyHdNEpheyWXCTnnTDY3N+NVQ==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "decode-named-character-reference": "^1.0.0", - "micromark-util-character": "^1.0.0", - "micromark-util-decode-numeric-character-reference": "^1.0.0", - "micromark-util-symbol": "^1.0.0" - } - }, - "node_modules/@primer/react/node_modules/micromark-util-encode": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-1.1.0.tgz", - "integrity": "sha512-EuEzTWSTAj9PA5GOAs992GzNh2dGQO52UvAbtSOMvXTxv3Criqb6IOzJUBCmEqrrXSblJIJBbFFv6zPxpreiJw==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/@primer/react/node_modules/micromark-util-html-tag-name": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-1.2.0.tgz", - "integrity": "sha512-VTQzcuQgFUD7yYztuQFKXT49KghjtETQ+Wv/zUjGSGBioZnkA4P1XXZPT1FHeJA6RwRXSF47yvJ1tsJdoxwO+Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/@primer/react/node_modules/micromark-util-normalize-identifier": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-1.1.0.tgz", - "integrity": "sha512-N+w5vhqrBihhjdpM8+5Xsxy71QWqGn7HYNUvch71iV2PM7+E3uWGox1Qp90loa1ephtCxG2ftRV/Conitc6P2Q==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^1.0.0" - } - }, - "node_modules/@primer/react/node_modules/micromark-util-resolve-all": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-1.1.0.tgz", - "integrity": "sha512-b/G6BTMSg+bX+xVCshPTPyAu2tmA0E4X98NSR7eIbeC6ycCqCeE7wjfDIgzEbkzdEVJXRtOG4FbEm/uGbCRouA==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-types": "^1.0.0" - } - }, - "node_modules/@primer/react/node_modules/micromark-util-sanitize-uri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-1.2.0.tgz", - "integrity": "sha512-QO4GXv0XZfWey4pYFndLUKEAktKkG5kZTdUNaTAkzbuJxn2tNBOr+QtxR2XpWaMhbImT2dPzyLrPXLlPhph34A==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^1.0.0", - "micromark-util-encode": "^1.0.0", - "micromark-util-symbol": "^1.0.0" - } - }, - "node_modules/@primer/react/node_modules/micromark-util-subtokenize": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-1.1.0.tgz", - "integrity": "sha512-kUQHyzRoxvZO2PuLzMt2P/dwVsTiivCK8icYTeR+3WgbuPqfHgPPy7nFKbeqRivBvn/3N3GBiNC+JRTMSxEC7A==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-chunked": "^1.0.0", - "micromark-util-symbol": "^1.0.0", - "micromark-util-types": "^1.0.0", - "uvu": "^0.5.0" - } - }, - "node_modules/@primer/react/node_modules/micromark-util-symbol": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-1.1.0.tgz", - "integrity": "sha512-uEjpEYY6KMs1g7QfJ2eX1SQEV+ZT4rUD3UcF6l57acZvLNK7PBZL+ty82Z1qhK1/yXIY4bdx04FKMgR0g4IAag==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/@primer/react/node_modules/micromark-util-types": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-1.1.0.tgz", - "integrity": "sha512-ukRBgie8TIAcacscVHSiddHjO4k/q3pnedmzMQ4iwDcK0FtFCohKOlFbaOL/mPgfnPsL3C1ZyxJa4sbWrBl3jg==", - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/@primer/react/node_modules/property-information": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", - "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/@primer/react/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" - }, - "node_modules/@primer/react/node_modules/react-markdown": { - "version": "8.0.7", - "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-8.0.7.tgz", - "integrity": "sha512-bvWbzG4MtOU62XqBx3Xx+zB2raaFFsq4mYiAzfjXJMEz2sixgeAfraA3tvzULF02ZdOMUOKTBFFaZJDDrq+BJQ==", - "license": "MIT", - "dependencies": { - "@types/hast": "^2.0.0", - "@types/prop-types": "^15.0.0", - "@types/unist": "^2.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-whitespace": "^2.0.0", - "prop-types": "^15.0.0", - "property-information": "^6.0.0", - "react-is": "^18.0.0", - "remark-parse": "^10.0.0", - "remark-rehype": "^10.0.0", - "space-separated-tokens": "^2.0.0", - "style-to-object": "^0.4.0", - "unified": "^10.0.0", - "unist-util-visit": "^4.0.0", - "vfile": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - }, - "peerDependencies": { - "@types/react": ">=16", - "react": ">=16" - } - }, - "node_modules/@primer/react/node_modules/remark-parse": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-10.0.2.tgz", - "integrity": "sha512-3ydxgHa/ZQzG8LvC7jTXccARYDcRld3VfcgIIFs7bI6vbRSxJJmzgLEIIoYKyrfhaY+ujuWaf/PJiMZXoiCXgw==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^3.0.0", - "mdast-util-from-markdown": "^1.0.0", - "unified": "^10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/@primer/react/node_modules/remark-rehype": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-10.1.0.tgz", - "integrity": "sha512-EFmR5zppdBp0WQeDVZ/b66CWJipB2q2VLNFMabzDSGR66Z2fQii83G5gTBbgGEnEEA0QRussvrFHxk1HWGJskw==", - "license": "MIT", - "dependencies": { - "@types/hast": "^2.0.0", - "@types/mdast": "^3.0.0", - "mdast-util-to-hast": "^12.1.0", - "unified": "^10.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/@primer/react/node_modules/style-to-object": { - "version": "0.4.4", - "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" - } - }, - "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" - } + "resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.6.0.tgz", + "integrity": "sha512-VHb0ALPMTlgKjM6yIxxoQNnpKyUKLD04VzeQdsiXkMqkvYlAHxq9glGLmgbb889/1GsohSOAjvQYoiBppXFqrQ==", + "license": "BSD-3-Clause" }, - "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/@modelcontextprotocol/client": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/client/-/client-2.2.0.tgz", + "integrity": "sha512-LxCou/CSYQ6dwEnjhLZY0KnEuc8V4UJ3IEQCl/uR2yHobSQIEdJKUlKLRYRF5i5FqRGHy1eK7une3aAWDHLtig==", "license": "MIT", "dependencies": { - "@types/unist": "^2.0.0" + "@modelcontextprotocol/core": "2.2.0", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "jose": "^6.1.3", + "pkce-challenge": "^5.0.0", + "zod": "^4.2.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": ">=20" } }, - "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/@modelcontextprotocol/core": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.2.0.tgz", + "integrity": "sha512-iLhmprRmWI8EcosOA3wVvww22z02NkgqhV4fBH6f/odQBsi7xnJc0HGmi21yWO+/iKw2yzqVE127Zhna5/+JXw==", "license": "MIT", "dependencies": { - "@types/unist": "^2.0.0" + "zod": "^4.2.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": ">=20" } }, - "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/@modelcontextprotocol/ext-apps": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-2.0.3.tgz", + "integrity": "sha512-Tn+4+cyhO4f4cQSDxNyLiIfEE+qlTTuIbWBJB8JTinEXOhDYgCT4wDTQplxD9MBYZH5pCYGC59GaxiwIT4bu8w==", "license": "MIT", + "workspaces": [ + "examples/*" + ], "dependencies": { - "@types/unist": "^2.0.0" + "@standard-schema/spec": "^1.1.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@modelcontextprotocol/client": "^2.0.0", + "@modelcontextprotocol/core": "^2.0.0", + "@modelcontextprotocol/server": "^2.0.0", + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", + "zod": "^4.2.0" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/server": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } } }, - "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/@oddbird/popover-polyfill": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/@oddbird/popover-polyfill/-/popover-polyfill-0.5.2.tgz", + "integrity": "sha512-iFrvar5SOMtKFOSjYvs4z9UlLqDdJbMx0mgISLcPedv+g0ac5sgeETLGtipHCVIae6HJPclNEH5aCyD1RZaEHw==", + "license": "BSD-3-Clause" + }, + "node_modules/@oxc-project/types": { + "version": "0.152.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.152.0.tgz", + "integrity": "sha512-oM/5rLBm2tPkg0iBgkH/FOeR3PCDpY19GTgAZjMFM8h9WI9VW7cLgzp6nwtarYKmovavIQZ+Fe/RKX/8C8O/Rw==", + "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" + "url": "https://github.com/sponsors/oxc-project" } }, - "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/@primer/behaviors": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@primer/behaviors/-/behaviors-1.11.0.tgz", + "integrity": "sha512-kDHtaw9kfCjPVT61eM731ftckhGyWiPX+OKmUccLN9sMFt9E6FXmsPAbny7O0RBxeW1Xj/NHNdbMEjYEg2dBYg==", + "license": "MIT" + }, + "node_modules/@primer/live-region-element": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@primer/live-region-element/-/live-region-element-0.8.1.tgz", + "integrity": "sha512-g7bNs9R2A2LfETMOWYsvtT0b5Q/QLM1Fz1vrTcWa5r2LbEhrPm7cZV5pEOleFKiGybrYWmdXteV2sBAQLXuxvg==", "license": "MIT", "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-is": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "@lit-labs/ssr-dom-shim": "^1.6.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/@primer/octicons-react": { + "version": "19.38.0", + "resolved": "https://registry.npmjs.org/@primer/octicons-react/-/octicons-react-19.38.0.tgz", + "integrity": "sha512-ZzWVrX6kluBaCepsDj/jAxagBOMaRX18LEf723IKpr4E/jdkTdCOtlsN+60Oj7IV9YEVrlUpo9Oky0Tn1heSCA==", "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" + "peerDependencies": { + "react": ">=16.3" } }, - "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/@primer/primitives": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/@primer/primitives/-/primitives-11.10.0.tgz", + "integrity": "sha512-ihZxIst4Izry284StKFjpbYvX6R/IBFTLu+qiuAmR93PsKF2hdjoabhSYwKI8iCxOUx94jVP55bI3NoY7STjYQ==", + "license": "MIT", + "workspaces": [ + ".", + "docs/storybook" + ] + }, + "node_modules/@primer/react": { + "version": "38.40.1", + "resolved": "https://registry.npmjs.org/@primer/react/-/react-38.40.1.tgz", + "integrity": "sha512-yTd+QTtWXdqJbWQLCnLibwh280j2TKw1+tREEXtJR6lRYiqrE1dnu5ifCsKhsqIYvxqaKZBNWg4KRqiFG6opZA==", "license": "MIT", "dependencies": { - "@types/unist": "^2.0.0", - "unist-util-stringify-position": "^3.0.0" + "@github/mini-throttle": "^2.1.1", + "@github/relative-time-element": "^5.0.0", + "@lit-labs/react": "1.2.1", + "@oddbird/popover-polyfill": "^0.5.2", + "@primer/behaviors": "^1.10.3", + "@primer/live-region-element": "^0.8.0", + "@primer/octicons-react": "^19.28.1", + "@primer/primitives": "10.x || 11.x", + "@tanstack/react-virtual": "^3.13.18", + "clsx": "^2.1.1", + "color2k": "^2.0.3", + "deepmerge": "^4.3.1", + "focus-visible": "^5.2.1", + "history": "^5.0.0", + "hsluv": "1.0.1", + "lodash.isempty": "^4.4.0", + "lodash.isobject": "^3.0.2", + "react-intersection-observer": "^10.0.3" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" + "engines": { + "node": ">=12", + "npm": ">=7" + }, + "peerDependencies": { + "@types/react": "18.x || 19.x", + "@types/react-dom": "18.x || 19.x", + "@types/react-is": "18.x || 19.x", + "react": "18.x || 19.x", + "react-dom": "18.x || 19.x", + "react-is": "18.x || 19.x" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + }, + "@types/react-is": { + "optional": true + } } }, "node_modules/@rolldown/binding-android-arm-eabi": { @@ -1625,137 +503,31 @@ "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", - "integrity": "sha512-jtwH2C/U6ssuGSvwTN3ri/IyjdHb8W9X/g8Y0JLcrH02G+BW3OS8kZdHphF1/YyRklnrKrBT2ngwGUK6aqqV3A==", - "license": "MIT", - "dependencies": { - "@styled-system/core": "^5.1.2" - } - }, - "node_modules/@styled-system/border": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/@styled-system/border/-/border-5.1.5.tgz", - "integrity": "sha512-JvddhNrnhGigtzWRCVuAHepniyVi6hBlimxWDVAdcTuk7aRn9BYJUwfHslURtwYFsF5FoEs8Zmr1oZq2M1AP0A==", - "license": "MIT", - "dependencies": { - "@styled-system/core": "^5.1.2" - } - }, - "node_modules/@styled-system/color": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/color/-/color-5.1.2.tgz", - "integrity": "sha512-1kCkeKDZkt4GYkuFNKc7vJQMcOmTl3bJY3YBUs7fCNM6mMYJeT1pViQ2LwBSBJytj3AB0o4IdLBoepgSgGl5MA==", - "license": "MIT", - "dependencies": { - "@styled-system/core": "^5.1.2" - } - }, - "node_modules/@styled-system/core": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/core/-/core-5.1.2.tgz", - "integrity": "sha512-XclBDdNIy7OPOsN4HBsawG2eiWfCcuFt6gxKn1x4QfMIgeO6TOlA2pZZ5GWZtIhCUqEPTgIBta6JXsGyCkLBYw==", - "license": "MIT", - "dependencies": { - "object-assign": "^4.1.1" - } - }, - "node_modules/@styled-system/css": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/@styled-system/css/-/css-5.1.5.tgz", - "integrity": "sha512-XkORZdS5kypzcBotAMPBoeckDs9aSZVkvrAlq5K3xP8IMAUek+x2O4NtwoSgkYkWWzVBu6DGdFZLR790QWGG+A==", - "license": "MIT" - }, - "node_modules/@styled-system/flexbox": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/flexbox/-/flexbox-5.1.2.tgz", - "integrity": "sha512-6hHV52+eUk654Y1J2v77B8iLeBNtc+SA3R4necsu2VVinSD7+XY5PCCEzBFaWs42dtOEDIa2lMrgL0YBC01mDQ==", - "license": "MIT", - "dependencies": { - "@styled-system/core": "^5.1.2" - } - }, - "node_modules/@styled-system/grid": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/grid/-/grid-5.1.2.tgz", - "integrity": "sha512-K3YiV1KyHHzgdNuNlaw8oW2ktMuGga99o1e/NAfTEi5Zsa7JXxzwEnVSDSBdJC+z6R8WYTCYRQC6bkVFcvdTeg==", - "license": "MIT", - "dependencies": { - "@styled-system/core": "^5.1.2" - } - }, - "node_modules/@styled-system/layout": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/layout/-/layout-5.1.2.tgz", - "integrity": "sha512-wUhkMBqSeacPFhoE9S6UF3fsMEKFv91gF4AdDWp0Aym1yeMPpqz9l9qS/6vjSsDPF7zOb5cOKC3tcKKOMuDCPw==", - "license": "MIT", - "dependencies": { - "@styled-system/core": "^5.1.2" - } - }, - "node_modules/@styled-system/position": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/position/-/position-5.1.2.tgz", - "integrity": "sha512-60IZfMXEOOZe3l1mCu6sj/2NAyUmES2kR9Kzp7s2D3P4qKsZWxD1Se1+wJvevb+1TP+ZMkGPEYYXRyU8M1aF5A==", - "license": "MIT", - "dependencies": { - "@styled-system/core": "^5.1.2" - } - }, - "node_modules/@styled-system/props": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/@styled-system/props/-/props-5.1.5.tgz", - "integrity": "sha512-FXhbzq2KueZpGaHxaDm8dowIEWqIMcgsKs6tBl6Y6S0njG9vC8dBMI6WSLDnzMoSqIX3nSKHmOmpzpoihdDewg==", - "license": "MIT", - "dependencies": { - "styled-system": "^5.1.5" - } - }, - "node_modules/@styled-system/shadow": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/shadow/-/shadow-5.1.2.tgz", - "integrity": "sha512-wqniqYb7XuZM7K7C0d1Euxc4eGtqEe/lvM0WjuAFsQVImiq6KGT7s7is+0bNI8O4Dwg27jyu4Lfqo/oIQXNzAg==", - "license": "MIT", - "dependencies": { - "@styled-system/core": "^5.1.2" - } - }, - "node_modules/@styled-system/space": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/space/-/space-5.1.2.tgz", - "integrity": "sha512-+zzYpR8uvfhcAbaPXhH8QgDAV//flxqxSjHiS9cDFQQUSznXMQmxJegbhcdEF7/eNnJgHeIXv1jmny78kipgBA==", - "license": "MIT", - "dependencies": { - "@styled-system/core": "^5.1.2" - } - }, - "node_modules/@styled-system/theme-get": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/theme-get/-/theme-get-5.1.2.tgz", - "integrity": "sha512-afAYdRqrKfNIbVgmn/2Qet1HabxmpRnzhFwttbGr6F/mJ4RDS/Cmn+KHwHvNXangQsWw/5TfjpWV+rgcqqIcJQ==", - "license": "MIT", - "dependencies": { - "@styled-system/core": "^5.1.2" - } - }, - "node_modules/@styled-system/typography": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@styled-system/typography/-/typography-5.1.2.tgz", - "integrity": "sha512-BxbVUnN8N7hJ4aaPOd7wEsudeT7CxarR+2hns8XCX1zp0DFfbWw4xYa/olA0oQaqx7F1hzDg+eRaGzAJbF+jOg==", + "node_modules/@tanstack/react-virtual": { + "version": "3.14.13", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.13.tgz", + "integrity": "sha512-JbDTAwtzZ99aOeCrAfW5EsE5KSq5RWh6Af2dtFwyLIIk48Ja7vm6n4axu/43T3vfjFnEGJalxJ1wyYjSQD6bSg==", "license": "MIT", "dependencies": { - "@styled-system/core": "^5.1.2" + "@tanstack/virtual-core": "3.17.11" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/@styled-system/variant": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/@styled-system/variant/-/variant-5.1.5.tgz", - "integrity": "sha512-Yn8hXAFoWIro8+Q5J8YJd/mP85Teiut3fsGVR9CAxwgNfIAiqlYxsk5iHU7VHJks/0KjL4ATSjmbtCDC/4l1qw==", + "node_modules/@tanstack/virtual-core": { + "version": "3.17.11", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.11.tgz", + "integrity": "sha512-+ILjvtHup6Y2hzQ6YzwMgX1Q+oQpxEGOXCEsCNaPoIP0VxMbizIBTmYTDtkerkIQS8/CbP1BRuyt8V/8BCsy1g==", "license": "MIT", - "dependencies": { - "@styled-system/core": "^5.1.2", - "@styled-system/css": "^5.1.5" + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" } }, "node_modules/@types/debug": { @@ -1816,65 +588,25 @@ "undici-types": "~8.9.0" } }, - "node_modules/@types/prop-types": { - "version": "15.7.15", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", - "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", - "license": "MIT" - }, "node_modules/@types/react": { - "version": "18.3.31", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", - "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", "license": "MIT", "dependencies": { - "@types/prop-types": "*", "csstype": "^3.2.2" } }, "node_modules/@types/react-dom": { - "version": "18.3.7", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", - "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", "devOptional": true, "license": "MIT", "peerDependencies": { - "@types/react": "^18.0.0" - } - }, - "node_modules/@types/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/@types/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-zts4lhQn5ia0cF/y2+3V6Riu0MAfez9/LJYavdM8TvcVl+S91A/7VWxyBT8hbRuWspmuCaiGI0F41OJYGrKhRA==", - "license": "MIT", - "dependencies": { - "@types/react": "^18" - } - }, - "node_modules/@types/styled-system": { - "version": "5.1.25", - "resolved": "https://registry.npmjs.org/@types/styled-system/-/styled-system-5.1.25.tgz", - "integrity": "sha512-B1oyjE4oeAbVnkigcB0WqU2gPFuTwLV/KkLa/uJZWFB9JWVKq1Fs0QwodZXZ9Sq6cb9ngY4kDqRY/dictIchjA==", - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/styled-system__css": { - "version": "5.0.22", - "resolved": "https://registry.npmjs.org/@types/styled-system__css/-/styled-system__css-5.0.22.tgz", - "integrity": "sha512-1oOWbdcL1SE2t6hTC3LlwrVHK3Z1Py4KYFehl6NL2XcLxS/L0ELEmN6APNWIYqUywPdeaKlQkRpV5dn0trLjGA==", - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" + "@types/react": "^19.3.0" } }, - "node_modules/@types/styled-system__theme-get": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/styled-system__theme-get/-/styled-system__theme-get-5.0.4.tgz", - "integrity": "sha512-dbzwxQ+8x6Bo3EKZMo9M3Knzo77ukwoC/isKW+GAuF5TenXlPkvgzx4t4+Lp0+fKs2M4owSef0KO3gtGW3Hpkw==", - "license": "MIT" - }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -2267,19 +999,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/baseline-browser-mapping": { - "version": "2.11.27", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz", - "integrity": "sha512-ElY12DaROGuan+lMmZ8Cvo/ZUbXPe7Enc/9VU/b1T3Kp4dwytRcNdR8DoSJN5SNJT/CuvcCA0DHDVmMOCePdRQ==", - "license": "Apache-2.0", - "peer": true, - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/braces": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", @@ -2290,73 +1009,8 @@ "fill-range": "^7.1.1" }, "engines": { - "node": ">=8" - } - }, - "node_modules/browserslist": { - "version": "4.29.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", - "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "baseline-browser-mapping": "^2.11.26", - "caniuse-lite": "^1.0.30001813", - "electron-to-chromium": "^1.5.439", - "node-releases": "^2.0.57", - "update-browserslist-db": "^1.3.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/camelize": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", - "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", - "license": "MIT", - "peer": true, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001814", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001814.tgz", - "integrity": "sha512-/Uaf1lAzr59XcMpW0o96WoEfr+VXK2OX4U9AgFoiSHsVJ4HppnIFUjtYzsyDH2+tgANaQb2/oxYGwCPapN1FpA==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0", - "peer": true + "node": ">=8" + } }, "node_modules/ccount": { "version": "2.0.1", @@ -2409,9 +1063,9 @@ } }, "node_modules/clsx": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "license": "MIT", "engines": { "node": ">=6" @@ -2433,13 +1087,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/convert-source-map": { - "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", - "peer": true - }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2454,28 +1101,6 @@ "node": ">= 8" } }, - "node_modules/css-color-keywords": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", - "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", - "license": "ISC", - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/css-to-react-native": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", - "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "camelize": "^1.0.0", - "css-color-keywords": "^1.0.0", - "postcss-value-parser": "^4.0.2" - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2553,32 +1178,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/diff": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", - "integrity": "sha512-vtcDfH3TOjP8UekytvnHH1o1P4FcUdt4eQ1Y+Abap1tk/OB2MWQvcwS2ClCd1zuIhc3JKOx6p3kod8Vfys3E+A==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.444", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.444.tgz", - "integrity": "sha512-5ss/uJfoDYDHT0lfJzT6FbcskIzROIOPf0BbbFkGcvDzoJU7i//9GDrwwIHQVmIsrAGiF3ihpADBRIsrEFt1rQ==", - "license": "ISC", - "peer": true - }, - "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" - } - }, "node_modules/escape-string-regexp": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", @@ -2680,32 +1279,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/fzy.js": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/fzy.js/-/fzy.js-0.4.1.tgz", - "integrity": "sha512-4sPVXf+9oGhzg2tYzgWe4hgAY0wEbkqeuKVEgdnqX8S8VcLosQsDjb0jV+f5uoQlf8INWId1w0IGoufAoik1TA==", - "license": "MIT" - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "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" - } - }, - "node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=4" - } - }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -2755,22 +1328,11 @@ "@babel/runtime": "^7.7.6" } }, - "node_modules/hoist-non-react-statics": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", - "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", - "license": "BSD-3-Clause", - "peer": true, - "dependencies": { - "react-is": "^16.7.0" - } - }, - "node_modules/hoist-non-react-statics/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT", - "peer": true + "node_modules/hsluv": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hsluv/-/hsluv-1.0.1.tgz", + "integrity": "sha512-zCaFTiDqBLQjCCFBu0qg7z9ASYPd+Bxx2GDCVZJsnehjK80S+jByqhuFz0pCd2Aw3FSKr18AWbRlwnKR0YdizQ==", + "license": "MIT" }, "node_modules/html-url-attributes": { "version": "3.0.1", @@ -2812,29 +1374,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/is-buffer": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", - "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/is-decimal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", @@ -2892,47 +1431,6 @@ "url": "https://github.com/sponsors/panva" } }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "license": "MIT" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "license": "MIT", - "peer": true, - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "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" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -3216,28 +1714,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/loose-envify": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", - "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", - "license": "MIT", - "dependencies": { - "js-tokens": "^3.0.0 || ^4.0.0" - }, - "bin": { - "loose-envify": "cli.js" - } - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "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" - } - }, "node_modules/markdown-table": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", @@ -3248,78 +1724,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/mdast-util-definitions": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-5.1.2.tgz", - "integrity": "sha512-8SVPMuHqlPME/z3gqVwWY4zVXn8lqKv/pAhC57FuJ40ImXyBpmO5ukh98zB2v7Blql2FiHjHv9LVztSIqjY+MA==", - "license": "MIT", - "dependencies": { - "@types/mdast": "^3.0.0", - "@types/unist": "^2.0.0", - "unist-util-visit": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/mdast-util-definitions/node_modules/@types/mdast": { - "version": "3.0.15", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", - "integrity": "sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^2" - } - }, - "node_modules/mdast-util-definitions/node_modules/@types/unist": { - "version": "2.0.11", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", - "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", - "license": "MIT" - }, - "node_modules/mdast-util-definitions/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/mdast-util-definitions/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/mdast-util-definitions/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/mdast-util-find-and-replace": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", @@ -4203,15 +2607,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/mri": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", - "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4237,25 +2632,6 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/node-releases": { - "version": "2.0.57", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.57.tgz", - "integrity": "sha512-kQK9LGGFiHtrWiNhZtA7Qbw17AQz+dmsEKODRIVTXA9+e5MS/2gZEBhYJt13GrAz5/IOZKddH/0Z3TP/Zgo+yw==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=18" - } - }, - "node_modules/object-assign": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", - "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/parse-entities": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", @@ -4294,12 +2670,14 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, "license": "ISC" }, "node_modules/picomatch": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -4346,30 +2724,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-value-parser": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", - "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "license": "MIT", - "peer": true - }, - "node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/prop-types/node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "license": "MIT" - }, "node_modules/property-information": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", @@ -4381,34 +2735,30 @@ } }, "node_modules/react": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", - "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", + "integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==", "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - }, "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", - "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==", "license": "MIT", "dependencies": { - "loose-envify": "^1.1.0", - "scheduler": "^0.23.2" + "scheduler": "^0.28.0" }, "peerDependencies": { - "react": "^18.3.1" + "react": "^19.3.0" } }, "node_modules/react-intersection-observer": { - "version": "9.16.0", - "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.16.0.tgz", - "integrity": "sha512-w9nJSEp+DrW9KmQmeWHQyfaP6b03v+TdXynaoA964Wxt7mdR3An11z4NNCQgL4gKSK7y1ver2Fq+JKH6CWEzUA==", + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-10.1.0.tgz", + "integrity": "sha512-V8HDu3+Llg6OEhOxx8LnUSS0t4VS+1Xk9ZatkI8Jct/H0CwKnqFTCu8NT3q7ghJTghTdIrEMPSWr2dkKPG+gdQ==", "license": "MIT", "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0", @@ -4554,43 +2904,11 @@ "@rolldown/binding-win32-x64-msvc": "1.2.12" } }, - "node_modules/sade": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", - "integrity": "sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==", - "license": "MIT", - "dependencies": { - "mri": "^1.1.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/scheduler": { - "version": "0.23.2", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", - "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", - "license": "MIT", - "dependencies": { - "loose-envify": "^1.1.0" - } - }, - "node_modules/semver": { - "version": "6.3.1", - "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" - } - }, - "node_modules/shallowequal": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", - "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", - "license": "MIT", - "peer": true + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" }, "node_modules/shebang-command": { "version": "2.0.0", @@ -4665,88 +2983,6 @@ "inline-style-parser": "0.2.7" } }, - "node_modules/styled-components": { - "version": "5.3.11", - "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-5.3.11.tgz", - "integrity": "sha512-uuzIIfnVkagcVHv9nE0VPlHPSCmXIUGKfJ42LNjxCCTDTL5sgnJ8Z7GZBq0EnLYGln77tPpEpExt2+qa+cZqSw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-module-imports": "^7.0.0", - "@babel/traverse": "^7.4.5", - "@emotion/is-prop-valid": "^1.1.0", - "@emotion/stylis": "^0.8.4", - "@emotion/unitless": "^0.7.4", - "babel-plugin-styled-components": ">= 1.12.0", - "css-to-react-native": "^3.0.0", - "hoist-non-react-statics": "^3.0.0", - "shallowequal": "^1.1.0", - "supports-color": "^5.5.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/styled-components" - }, - "peerDependencies": { - "react": ">= 16.8.0", - "react-dom": ">= 16.8.0", - "react-is": ">= 16.8.0" - } - }, - "node_modules/styled-components/node_modules/babel-plugin-styled-components": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/babel-plugin-styled-components/-/babel-plugin-styled-components-2.3.0.tgz", - "integrity": "sha512-nP/y6PbBqS/qtKROnJCgpGo8hYUzlBAVXN1QAjSBANL6vZiQXPQN7FYW/nUwoxY7nZhBEGm9T5tjL9gbzwulDw==", - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.25.9", - "@babel/helper-module-imports": "^7.25.9", - "@babel/plugin-syntax-jsx": "^7.25.9", - "picomatch": "^4.0.2" - }, - "peerDependencies": { - "@babel/core": "^7.0.0", - "styled-components": ">= 2" - } - }, - "node_modules/styled-system": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/styled-system/-/styled-system-5.1.5.tgz", - "integrity": "sha512-7VoD0o2R3RKzOzPK0jYrVnS8iJdfkKsQJNiLRDjikOpQVqQHns/DXWaPZOH4tIKkhAT7I6wIsy9FWTWh2X3q+A==", - "license": "MIT", - "dependencies": { - "@styled-system/background": "^5.1.2", - "@styled-system/border": "^5.1.5", - "@styled-system/color": "^5.1.2", - "@styled-system/core": "^5.1.2", - "@styled-system/flexbox": "^5.1.2", - "@styled-system/grid": "^5.1.2", - "@styled-system/layout": "^5.1.2", - "@styled-system/position": "^5.1.2", - "@styled-system/shadow": "^5.1.2", - "@styled-system/space": "^5.1.2", - "@styled-system/typography": "^5.1.2", - "@styled-system/variant": "^5.1.5", - "object-assign": "^4.1.1" - } - }, - "node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "license": "MIT", - "peer": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -4858,16 +3094,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/unist-util-generated": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unist-util-generated/-/unist-util-generated-2.0.1.tgz", - "integrity": "sha512-qF72kLmPxAw0oN2fwpWIqbXAVyEqUzDHMsbtPvOudIlUzXYFIeQIuxXQCRCFh22B7cixvU0MG7m3MW8FTq/S+A==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/unist-util-is": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", @@ -4936,55 +3162,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/update-browserslist-db": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz", - "integrity": "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "peer": true, - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/uvu": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/uvu/-/uvu-0.5.6.tgz", - "integrity": "sha512-+g8ENReyr8YsOc6fv/NVJs2vFdHBnBNdfE49rshrTzDWOlUx4Gq7KOS2GD8eqhy2j+Ejq29+SbKH8yjkAqXqoA==", - "license": "MIT", - "dependencies": { - "dequal": "^2.0.0", - "diff": "^5.0.0", - "kleur": "^4.0.3", - "sade": "^1.7.3" - }, - "bin": { - "uvu": "bin.js" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -5128,13 +3305,6 @@ "node": ">= 8" } }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "license": "ISC", - "peer": true - }, "node_modules/zod": { "version": "4.6.5", "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz", diff --git a/ui/package.json b/ui/package.json index efbd84bb16..9507f13600 100644 --- a/ui/package.json +++ b/ui/package.json @@ -18,17 +18,18 @@ "@modelcontextprotocol/client": "^2.0.0", "@modelcontextprotocol/ext-apps": "^2.0.3", "@primer/octicons-react": "^19.38.0", - "@primer/react": "^36.27.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "@primer/primitives": "^11.10.0", + "@primer/react": "^38.40.1", + "react": "^19.3.0", + "react-dom": "^19.3.0", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", "zod": "^4.2.0" }, "devDependencies": { "@types/node": "^26.6.4", - "@types/react": "^18.3.31", - "@types/react-dom": "^18.3.7", + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.1", "typescript": "^7.0.2", "vite": "^8.3.2", diff --git a/ui/src/apps/get-me/App.tsx b/ui/src/apps/get-me/App.tsx index c181fcab90..27ba66a86d 100644 --- a/ui/src/apps/get-me/App.tsx +++ b/ui/src/apps/get-me/App.tsx @@ -1,7 +1,7 @@ import { StrictMode, useState } from "react"; import type React from "react"; import { createRoot } from "react-dom/client"; -import { Avatar, Box, Text, Link, Heading, Spinner } from "@primer/react"; +import { Avatar, Text, Link, Heading, Spinner } from "@primer/react"; import { OrganizationIcon, LocationIcon, @@ -35,30 +35,30 @@ function AvatarWithFallback({ src, login, size }: { src?: string; login: string; if (!src || imgError) { return ( - - + ); } return ( - setImgError(true)} + style={{ marginRight: 16 }} /> ); } @@ -79,43 +79,62 @@ function UserCard({ }); return ( - {/* Header with avatar and name */} - +
- - +
+ {d.name || user.login} - @{user.login} - - + @{user.login} +
+
{/* Info grid */} - +
{d.company && ( <> - +
{d.company} )} {d.location && ( <> - +
{d.location} )} {d.blog && ( <> - +
- +
{d.email} )} - +
{/* Stats */} - - - +
+
+ {d.public_repos ?? 0} - Repos - - - + Repos +
+
+ {d.followers ?? 0} - Followers - - - + Followers +
+
+ {d.following ?? 0} - Following - - - + Following +
+
+ ); } @@ -165,25 +194,25 @@ function GetMeApp() { const content = (() => { if (error) { - return Error: {error.message}; + return Error: {error.message}; } if (!toolResult) { return ( - +
- Loading user data... - + Loading user data... +
); } const textContent = toolResult.content?.find((c: { type: string }) => c.type === "text"); if (!textContent || !("text" in textContent)) { - return No user data in response; + return No user data in response; } try { const userData = JSON.parse(textContent.text as string) as UserData; return void openLink(url)} />; } catch { - return Failed to parse user data; + return Failed to parse user data; } })(); diff --git a/ui/src/apps/issue-write/App.tsx b/ui/src/apps/issue-write/App.tsx index ee48ea5d33..7c14a29ffc 100644 --- a/ui/src/apps/issue-write/App.tsx +++ b/ui/src/apps/issue-write/App.tsx @@ -1,11 +1,10 @@ import { StrictMode, useState, useCallback, useEffect, useMemo, useRef } from "react"; import { createRoot } from "react-dom/client"; import { - Box, Text, TextInput, Button, - Flash, + Banner, Spinner, FormControl, CounterLabel, @@ -13,6 +12,7 @@ import { ActionList, Label, } from "@primer/react"; +import styles from "../../styles.module.css"; import { IssueOpenedIcon, CheckCircleIcon, @@ -296,46 +296,65 @@ function SuccessView({ const issueUrl = issue.html_url || issue.url || issue.URL || "#"; return ( - - - +
- - +
+ {isUpdate ? "Issue updated successfully" : "Issue created successfully"} -
- - + +
- +
- - +
+
{issue.title || submittedTitle} {issue.number && ( - + #{issue.number} )} - + {owner}/{repo} {submittedLabels.length > 0 && ( - +
{submittedLabels.map((label) => (
)} -
- - +
+
+ ); } @@ -1162,9 +1194,9 @@ function CreateIssueApp() { const selectedOptionName = fieldValue.cleared ? undefined : fieldValue.optionName; const selectedOption = field.options.find((option) => option.name === selectedOptionName); return ( - +
- + {selectedOption ? selectedOption.name : "Select option"} @@ -1179,15 +1211,15 @@ function CreateIssueApp() { onSelect={() => updateIssueFieldValue(field.name, { optionName: option.name })} > - @@ -1198,18 +1230,26 @@ function CreateIssueApp() { - +
); } return ( updateIssueFieldValue(field.name, { value: e.target.value })} + onChange={(e) => + updateIssueFieldValue(field.name, { value: e.target.value }) + } block contrast - sx={{ flex: 1 }} + style={{ flex: 1 }} /> ); }; @@ -1217,17 +1257,22 @@ function CreateIssueApp() { const body_node = (() => { if (appError) { return ( - - Connection error: {appError.message} - + ); } if (!app) { return ( - +
- +
); } @@ -1246,52 +1291,71 @@ function CreateIssueApp() { } return ( - - {/* Repository picker */} - - + {/* Repository picker */} +
+
span:last-child": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }} - > + size="small" + leadingVisual={selectedRepo?.isPrivate ? LockIcon : RepoIcon} + style={{ maxWidth: "100%", overflow: "hidden" }} + className={styles.truncateLastChild} + > {selectedRepo ? selectedRepo.fullName : "Select repository"} - +
setRepoFilter(e.target.value)} - sx={{ width: "100%" }} - size="small" - autoFocus - /> - + placeholder="Search repositories..." + value={repoFilter} + onChange={(e) => setRepoFilter(e.target.value)} + size="small" + autoFocus + style={{ width: "100%" }} + /> +
{repoSearchLoading ? ( - +
- +
) : repoSearchResults.length > 0 ? ( repoSearchResults.map((r) => ( ) : ( - - +
+ Type to search repositories... - +
)}
- - +
+
{/* Error banner */} {error && ( - - {error} - + )} {/* Title */} - - + + Title {/* Description */} - +
+ as="label" + style={{ + fontWeight: 500, + fontSize: 14, + display: "block", + marginBottom: 8, + }} + > Description - +
{/* Metadata section */} - +
{/* Labels dropdown */} Labels {selectedLabels.length > 0 && ( - {selectedLabels.length} + {selectedLabels.length} )} - +
- +
{labelsLoading ? ( @@ -1420,14 +1510,14 @@ function CreateIssueApp() { }} > - +
{label.text} @@ -1442,11 +1532,18 @@ function CreateIssueApp() { Assignees {selectedAssignees.length > 0 && ( - {selectedAssignees.length} + {selectedAssignees.length} )} - +
- +
{assigneesLoading ? ( @@ -1569,18 +1666,23 @@ function CreateIssueApp() {
- +
{/* Fields section */} {availableIssueFields.length > 0 && ( - - +
+ Fields - +
{availableIssueFields.map((field) => { const fieldValue = fieldValues[field.name]; const hasFieldValue = @@ -1590,56 +1692,95 @@ function CreateIssueApp() { (fieldValue.value !== undefined && fieldValue.value !== "")); return ( - - +
+ {field.name} {field.description && ( - + {field.description} )} - +
{renderIssueFieldInput(field)} {hasFieldValue && ( )} - - +
+
); })} -
- +
+
)} {/* Selected labels display */} {selectedLabels.length > 0 && ( - +
{selectedLabels.map((label) => ( ))} - +
)} {/* Selected metadata display */} {(selectedAssignees.length > 0 || selectedMilestone) && ( - +
{selectedAssignees.length > 0 && ( Assigned to: {selectedAssignees.map((a) => a.text).join(", ")} @@ -1648,36 +1789,51 @@ function CreateIssueApp() { {selectedMilestone && ( Milestone: {selectedMilestone.text} )} - +
)} {/* State and submit actions */} - +
{isUpdateMode && ( - +
{currentState === "open" ? ( <> - - + size="small" + style={{ + marginLeft: "-1px", + borderTopLeftRadius: 0, + borderBottomLeftRadius: 0, + }} + > {selectedStateReason.label} @@ -1695,18 +1851,18 @@ function CreateIssueApp() { - +
{stateReason === "duplicate" && ( - - Duplicate of + + Duplicate of setDuplicateOf(e.target.value)} - size="small" - sx={{ width: 140 }} - /> + type="number" + placeholder="Issue number" + value={duplicateOf} + onChange={(e) => setDuplicateOf(e.target.value)} + size="small" + style={{ width: 140 }} + /> )} @@ -1719,7 +1875,7 @@ function CreateIssueApp() { Reopen issue )} -
+
)} -
-
+
+ ); })(); diff --git a/ui/src/apps/pr-edit/App.tsx b/ui/src/apps/pr-edit/App.tsx index 5b4995affc..791217f33a 100644 --- a/ui/src/apps/pr-edit/App.tsx +++ b/ui/src/apps/pr-edit/App.tsx @@ -1,11 +1,10 @@ import { StrictMode, useState, useCallback, useEffect, useMemo } from "react"; import { createRoot } from "react-dom/client"; import { - Box, Text, TextInput, Button, - Flash, + Banner, Spinner, FormControl, ActionMenu, @@ -15,6 +14,7 @@ import { CounterLabel, Label, } from "@primer/react"; +import styles from "../../styles.module.css"; import { GitPullRequestIcon, CheckCircleIcon, @@ -169,46 +169,65 @@ function SuccessView({ const prUrl = pr.html_url || pr.url || pr.URL || "#"; return ( - - - +
- - +
+ Pull request updated successfully -
- - + + + ); } @@ -516,9 +541,16 @@ function EditPRApp() { if (!app && !appError) { return ( - +
- +
); } @@ -526,7 +558,7 @@ function EditPRApp() { if (appError) { return ( - {appError.message} + ); } @@ -534,9 +566,16 @@ function EditPRApp() { if (toolInput === null) { return ( - +
- +
); } @@ -544,56 +583,78 @@ function EditPRApp() { if (!owner || !repo || !pullNumber) { return ( - Pull request owner, repo, and pullNumber are required. + ); } return ( - - - +
- - - #{pullNumber} · {owner}/{repo} +
+
+ #{pullNumber} · {owner}/{repo} {title && ( - + {title} )} - - +
+ - {error && {error}} + {error && } {isLoadingPR && !initialValues ? ( - +
- Loading pull request... - + Loading pull request... +
) : ( <> - - Title + + Title setTitle(e.target.value)} @@ -603,22 +664,42 @@ function EditPRApp() { /> - - +
+ Description - - - - base +
+ +
+ base - span": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}> + {baseBranch || "Select base"} - +
- +
{branchesLoading ? ( Loading... @@ -647,11 +728,27 @@ function EditPRApp() {
- - - - - state +
+ +
+
+ state - - - +
+ + +
+ +
+ reviewers - + {selectedReviewers.length === 0 ? ( "No reviewers" ) : ( <> Reviewers - {selectedReviewers.length} + {selectedReviewers.length} )} - +
- +
{reviewersLoading ? ( Loading... @@ -734,28 +857,51 @@ function EditPRApp() {
{selectedReviewers.length > 0 && ( - +
{selectedReviewers.map((reviewer) => ( ))} - +
)} -
- - - +
+ +
+ - +
)} -
+
); } diff --git a/ui/src/apps/pr-write/App.tsx b/ui/src/apps/pr-write/App.tsx index 6975434bea..a7014a544e 100644 --- a/ui/src/apps/pr-write/App.tsx +++ b/ui/src/apps/pr-write/App.tsx @@ -1,11 +1,10 @@ import { StrictMode, useState, useCallback, useEffect, useMemo } from "react"; import { createRoot } from "react-dom/client"; import { - Box, Text, TextInput, Button, - Flash, + Banner, Spinner, FormControl, ActionMenu, @@ -15,6 +14,7 @@ import { CounterLabel, Label, } from "@primer/react"; +import styles from "../../styles.module.css"; import { GitPullRequestIcon, CheckCircleIcon, @@ -82,46 +82,65 @@ function SuccessView({ const prUrl = pr.html_url || pr.url || pr.URL || "#"; return ( - - - +
- - +
+ Pull request created successfully -
- - + + + ); } @@ -462,9 +487,16 @@ function CreatePRApp() { if (!app && !appError) { return ( - +
- +
); } @@ -472,59 +504,78 @@ function CreatePRApp() { if (appError) { return ( - {appError.message} + ); } return ( - {/* Repository picker */} - - +
span:last-child": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }} + style={{ maxWidth: "100%", overflow: "hidden" }} + className={styles.truncateLastChild} > {selectedRepo ? selectedRepo.fullName : "Select repository"} - +
setRepoFilter(e.target.value)} - sx={{ width: "100%" }} - size="small" - autoFocus - /> - + placeholder="Search repositories..." + value={repoFilter} + onChange={(e) => setRepoFilter(e.target.value)} + size="small" + autoFocus + style={{ width: "100%" }} + /> +
{repoSearchLoading ? ( - +
- +
) : repoSearchResults.length > 0 ? ( repoSearchResults.map((r) => ( ) : ( - - Type to search repositories... - +
+ Type to search repositories... +
)}
- - +
+ {/* Branch selectors */} - - - base +
+
+ base - span": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}> + {baseBranch || "Select base"} - +
- +
{branchesLoading ? ( Loading... @@ -604,19 +685,39 @@ function CreatePRApp() {
- - - ← +
- - compare + ← + +
+ compare - span": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}> + {headBranch || "Select head"} - +
- +
{branchesLoading ? ( Loading... @@ -644,15 +745,15 @@ function CreatePRApp() {
- - +
+
{/* Error banner */} - {error && {error}} + {error && } {/* Title */} - - Title + + Title setTitle(e.target.value)} @@ -663,29 +764,55 @@ function CreatePRApp() { {/* Description */} - - +
+ Description - +
{/* Reviewers */} - - reviewers +
+ reviewers - + {selectedReviewers.length === 0 ? ( "No reviewers" ) : ( <> Reviewers - {selectedReviewers.length} + {selectedReviewers.length} )} - +
- +
{reviewersLoading ? ( Loading... @@ -737,29 +864,52 @@ function CreatePRApp() {
{selectedReviewers.length > 0 && ( - +
{selectedReviewers.map((reviewer) => ( ))} - +
)} -
+
{/* Options and Submit */} - - +
+ @@ -810,8 +966,8 @@ function CreatePRApp() { - - +
+
); } diff --git a/ui/src/components/AppProvider.tsx b/ui/src/components/AppProvider.tsx index e27bf96a02..9258ff99d6 100644 --- a/ui/src/components/AppProvider.tsx +++ b/ui/src/components/AppProvider.tsx @@ -1,4 +1,8 @@ -import { ThemeProvider, BaseStyles, Box } from "@primer/react"; +import { BaseStyles } from "@primer/react"; +import { ThemeProvider } from "@primer/react/next"; +import "@primer/primitives/dist/css/primitives.css"; +import "@primer/primitives/dist/css/functional/themes/light.css"; +import "@primer/primitives/dist/css/functional/themes/dark.css"; import type { ReactNode, CSSProperties } from "react"; import { useEffect, useMemo } from "react"; import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps"; @@ -44,10 +48,10 @@ export function AppProvider({ children, hostContext }: AppProviderProps) { return ( - +
{children} - +
); diff --git a/ui/src/components/FeedbackFooter.tsx b/ui/src/components/FeedbackFooter.tsx index 10fbdf44e6..c8f4c38403 100644 --- a/ui/src/components/FeedbackFooter.tsx +++ b/ui/src/components/FeedbackFooter.tsx @@ -1,17 +1,19 @@ -import { Box, Text } from "@primer/react"; +import { Text } from "@primer/react"; export function FeedbackFooter() { return ( - - +
+ Help us improve MCP Apps support in the GitHub MCP Server
github.com/github/github-mcp-server/issues/new?template=insiders-feedback.md
- +
); } diff --git a/ui/src/components/MarkdownEditor.tsx b/ui/src/components/MarkdownEditor.tsx index 5ba25932d0..fde12dae9a 100644 --- a/ui/src/components/MarkdownEditor.tsx +++ b/ui/src/components/MarkdownEditor.tsx @@ -6,7 +6,8 @@ * @see https://github.com/github/markdown-toolbar-element */ import { useId, useRef, useState, useEffect } from "react"; -import { Box, Text, Button, IconButton, useTheme } from "@primer/react"; +import { Text, Button, IconButton, useTheme } from "@primer/react"; +import styles from "../styles.module.css"; import { BoldIcon, ItalicIcon, @@ -25,7 +26,7 @@ import remarkGfm from "remark-gfm"; import "@github/markdown-toolbar-element"; // Declare types for the web component elements -declare global { +declare module "react/jsx-runtime" { namespace JSX { interface IntrinsicElements { "markdown-toolbar": React.DetailedHTMLProps< @@ -168,41 +169,48 @@ export function MarkdownEditor({ }; return ( - {/* Header with tabs and toolbar */} - {/* Write/Preview tabs */} - +
@@ -210,24 +218,32 @@ export function MarkdownEditor({ size="small" variant="invisible" onClick={() => setViewMode("preview")} - sx={{ - fontWeight: viewMode === "preview" ? "semibold" : "normal", - color: viewMode === "preview" ? "fg.default" : "fg.muted", - bg: viewMode === "preview" ? "actionListItem.default.hoverBg" : "transparent", - borderRadius: 2, - "&:hover": { - color: "fg.default", - }, + style={{ + fontWeight: viewMode === "preview" ? 500 : 400, + backgroundColor: + viewMode === "preview" + ? "var(--control-transparent-bgColor-hover)" + : "transparent", + borderRadius: 6, }} + className={styles.editorTab} + data-active={viewMode === "preview"} > Preview - +
{/* Toolbar - uses GitHub's official markdown-toolbar-element */} {viewMode === "write" && ( - +
- @@ -302,10 +319,10 @@ export function MarkdownEditor({ variant="invisible" /> - +
)} -
+ {/* Content area */} {viewMode === "write" ? ( @@ -333,115 +350,51 @@ export function MarkdownEditor({ }} /> ) : ( - :first-child": { mt: 0 }, - // GitHub Flavored Markdown styles - "& h1, & h2, & h3, & h4, & h5, & h6": { - mt: 3, - mb: 2, - fontWeight: "semibold", - lineHeight: 1.25, - }, - "& h1": { fontSize: 4, borderBottom: "1px solid", borderColor: "border.default", pb: 2 }, - "& h2": { fontSize: 3, borderBottom: "1px solid", borderColor: "border.default", pb: 2 }, - "& h3": { fontSize: 2 }, - "& p": { my: 2 }, - "& ul, & ol": { pl: 4, my: 2 }, - "& li": { my: 1 }, - "& code": { - bg: "neutral.muted", - px: 1, - py: "2px", - borderRadius: 1, - fontFamily: "mono", - fontSize: "85%", - }, - "& pre": { - bg: "neutral.muted", - p: 3, - borderRadius: 2, - overflow: "auto", - my: 2, - }, - "& pre code": { - bg: "transparent", - p: 0, - }, - "& blockquote": { - borderLeft: "4px solid", - borderColor: "border.default", - pl: 3, - ml: 0, - mr: 0, - my: 2, - color: "fg.muted", - bg: "canvas.subtle", - }, - "& a": { - color: "accent.fg", - textDecoration: "none", - "&:hover": { textDecoration: "underline" }, - }, - "& table": { - borderCollapse: "collapse", - width: "100%", - my: 2, - }, - "& th, & td": { - border: "1px solid", - borderColor: "border.default", - p: 2, - }, - "& th": { - bg: "canvas.subtle", - fontWeight: "semibold", - }, - "& input[type='checkbox']": { - mr: 2, - }, - "& hr": { - border: "none", - borderTop: "1px solid", - borderColor: "border.default", - my: 3, - }, - }}> + color: "var(--fgColor-default)", + }} + className={styles.markdownPreview} + > {value ? ( {value} ) : ( - + Nothing to preview )} - + )} {/* Footer */} - - + Markdown is supported - -
+ + ); } diff --git a/ui/src/styles.module.css b/ui/src/styles.module.css new file mode 100644 index 0000000000..cfe72d499b --- /dev/null +++ b/ui/src/styles.module.css @@ -0,0 +1,148 @@ +.truncateLastChild > span:last-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.truncateChildren > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.editorTab { + color: var(--fgColor-muted); +} + +.editorTab[data-active="true"], +.editorTab:hover { + color: var(--fgColor-default); +} + +.markdownPreview > :first-child { + margin-top: 0; +} + +.markdownPreview h1, +.markdownPreview h2, +.markdownPreview h3, +.markdownPreview h4, +.markdownPreview h5, +.markdownPreview h6 { + margin-top: 16px; + margin-bottom: 8px; + font-weight: 500; + line-height: 1.25; +} + +.markdownPreview h1 { + font-size: 24px; + border-bottom: 1px solid; + border-color: var(--borderColor-default); + padding-bottom: 8px; +} + +.markdownPreview h2 { + font-size: 20px; + border-bottom: 1px solid; + border-color: var(--borderColor-default); + padding-bottom: 8px; +} + +.markdownPreview h3 { + font-size: 16px; +} + +.markdownPreview p { + margin-top: 8px; + margin-bottom: 8px; +} + +.markdownPreview ul, +.markdownPreview ol { + padding-left: 24px; + margin-top: 8px; + margin-bottom: 8px; +} + +.markdownPreview li { + margin-top: 4px; + margin-bottom: 4px; +} + +.markdownPreview code { + background-color: var(--bgColor-neutral-muted); + padding-left: 4px; + padding-right: 4px; + padding-top: 2px; + padding-bottom: 2px; + border-radius: 3px; + font-family: + ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + font-size: 85%; +} + +.markdownPreview pre { + background-color: var(--bgColor-neutral-muted); + padding: 16px; + border-radius: 6px; + overflow: auto; + margin-top: 8px; + margin-bottom: 8px; +} + +.markdownPreview pre code { + background-color: transparent; + padding: 0; +} + +.markdownPreview blockquote { + border-left: 4px solid; + border-color: var(--borderColor-default); + padding-left: 16px; + margin-left: 0; + margin-right: 0; + margin-top: 8px; + margin-bottom: 8px; + color: var(--fgColor-muted); + background-color: var(--bgColor-muted); +} + +.markdownPreview a { + color: var(--fgColor-accent); + text-decoration: none; +} +.markdownPreview a:hover { + text-decoration: underline; +} + +.markdownPreview table { + border-collapse: collapse; + width: 100%; + margin-top: 8px; + margin-bottom: 8px; +} + +.markdownPreview th, +.markdownPreview td { + border: 1px solid; + border-color: var(--borderColor-default); + padding: 8px; +} + +.markdownPreview th { + background-color: var(--bgColor-muted); + font-weight: 500; +} + +.markdownPreview input[type="checkbox"] { + margin-right: 8px; +} + +.markdownPreview hr { + border: none; + border-top: 1px solid; + border-color: var(--borderColor-default); + margin-top: 16px; + margin-bottom: 16px; +} From 71ef8266e48110974b13aef50b4df6ff9914ff68 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Sat, 3 Oct 2026 15:18:15 +0200 Subject: [PATCH 41/61] perf: cache encoded tools/list schemas (#3407) * perf: cache encoded tools/list schemas Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test: verify encoded schema cache lifecycle Document immutable shared schema definitions and verify bounded reuse across HTTP inventories and repeated server registration. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/github/encoded_schemas_test.go | 269 +++++++++++++++++++++++ pkg/http/handler_test.go | 36 ++++ pkg/inventory/encoded_schemas.go | 88 ++++++++ pkg/inventory/encoded_schemas_test.go | 300 ++++++++++++++++++++++++++ pkg/inventory/registry.go | 11 +- pkg/inventory/server_tool.go | 5 + 6 files changed, 708 insertions(+), 1 deletion(-) create mode 100644 pkg/github/encoded_schemas_test.go create mode 100644 pkg/inventory/encoded_schemas.go create mode 100644 pkg/inventory/encoded_schemas_test.go diff --git a/pkg/github/encoded_schemas_test.go b/pkg/github/encoded_schemas_test.go new file mode 100644 index 0000000000..09e9011ae0 --- /dev/null +++ b/pkg/github/encoded_schemas_test.go @@ -0,0 +1,269 @@ +package github + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "slices" + "testing" + + ghcontext "github.com/github/github-mcp-server/pkg/context" + "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/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The baseline uses the public single-tool registration path, which does not +// install the tools/list encoder. Both paths select the same inventory first. +func schemaListServer(ctx context.Context, inv *inventory.Inventory, optimized bool, pageSize int) *mcp.Server { + server := mcp.NewServer(&mcp.Implementation{Name: "schema-list"}, &mcp.ServerOptions{PageSize: pageSize}) + if optimized { + inv.RegisterTools(ctx, server, nil) + } else { + for _, tool := range inv.ToolsForRegistration(ctx) { + tool.RegisterFunc(server, nil) + } + } + return server +} + +func schemaListContext(protocol string, ui bool) context.Context { + return ghcontext.WithUISupport(ghcontext.WithMCPMethodInfo(context.Background(), &ghcontext.MCPMethodInfo{ + Method: inventory.MCPMethodToolsList, + ProtocolVersion: protocol, + ClientCapabilities: &mcp.ClientCapabilities{}, + }), ui) +} + +type schemaListWire struct { + Result *mcp.ListToolsResult `json:"result"` + Error json.RawMessage `json:"error"` +} + +func schemaListPages(ctx context.Context, t *testing.T, inv *inventory.Inventory, optimized bool, transport, protocol string, pageSize int) ([][]byte, []string) { + t.Helper() + var request func(string) []byte + switch transport { + case "stdio": + server := schemaListServer(ctx, inv, optimized, pageSize) + serverConn, clientConn := net.Pipe() + ss, err := server.Connect(ctx, &mcp.IOTransport{Reader: serverConn, Writer: serverConn}, nil) + require.NoError(t, err) + defer func() { _ = ss.Close() }() + defer func() { _ = clientConn.Close() }() + reader := bufio.NewReader(clientConn) + exchange := func(body string) []byte { + _, err := io.WriteString(clientConn, body+"\n") + require.NoError(t, err) + response, err := reader.ReadBytes('\n') + require.NoError(t, err) + return response + } + init := exchange(fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":%q,"capabilities":{},"clientInfo":{"name":"test","version":"1"}}}`, protocol)) + require.NotContains(t, string(init), `"error"`) + _, err = io.WriteString(clientConn, `{"jsonrpc":"2.0","method":"notifications/initialized"}`+"\n") + require.NoError(t, err) + request = exchange + case "http": + handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { + return schemaListServer(ctx, inv, optimized, pageSize) + }, &mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true}) + request = func(body string) []byte { + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Mcp-Protocol-Version", protocol) + req.Header.Set("Mcp-Method", "tools/list") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + require.Contains(t, []int{http.StatusOK, http.StatusBadRequest}, rec.Code, rec.Body.String()) + return rec.Body.Bytes() + } + default: + t.Fatalf("unknown transport: %s", transport) + } + var pages [][]byte + names := make([]string, 0) + listRequest := func(id int, cursor string) string { + return fmt.Sprintf(`{"jsonrpc":"2.0","id":%d,"method":"tools/list","params":{"cursor":%q,"_meta":{"io.modelcontextprotocol/protocolVersion":%q,"io.modelcontextprotocol/clientInfo":{"name":"test","version":"1"},"io.modelcontextprotocol/clientCapabilities":{}}}}`, id, cursor, protocol) + } + cursor := "" + for { + response := request(listRequest(2, cursor)) + pages = append(pages, response) + var wire schemaListWire + require.NoError(t, json.Unmarshal(response, &wire)) + require.Empty(t, wire.Error) + require.NotNil(t, wire.Result) + for _, tool := range wire.Result.Tools { + names = append(names, tool.Name) + } + if wire.Result.NextCursor == "" { + break + } + require.NotEqual(t, cursor, wire.Result.NextCursor) + cursor = wire.Result.NextCursor + } + bad := request(listRequest(3, "not-a-valid-cursor")) + var wire schemaListWire + require.NoError(t, json.Unmarshal(bad, &wire)) + require.NotEmpty(t, wire.Error) + pages = append(pages, bad) + return pages, names +} + +func TestEncodedSchemasWireParity(t *testing.T) { + type selection struct { + name string + toolsets []string + features []string + exclude []string + explicit []string + scopes []string + protocol string + ui bool + } + prototype, err := NewInventory(translations.NullTranslationHelper).WithToolsets([]string{"all"}).Build() + require.NoError(t, err) + var selections []selection + for _, toolset := range append([]inventory.ToolsetID{"all", "default"}, prototype.ToolsetIDs()...) { + for _, enabled := range []bool{false, true} { + features := []string(nil) + if enabled { + features = AllowedFeatureFlags + } + selections = append(selections, selection{ + name: fmt.Sprintf("%s/all-flags=%v", toolset, enabled), toolsets: []string{string(toolset)}, features: features, + }) + } + } + for _, feature := range AllowedFeatureFlags { + selections = append(selections, selection{name: "flag/" + feature, toolsets: []string{"all"}, features: []string{feature}}) + } + selections = append(selections, + selection{name: "insiders", toolsets: []string{"all"}, features: InsidersFeatureFlags}, + selection{name: "none", toolsets: []string{}}, + selection{name: "explicit-and-excluded", toolsets: []string{"repos"}, explicit: []string{"get_me"}, exclude: []string{"get_commit"}}, + selection{name: "scopes", toolsets: []string{"all"}, scopes: []string{"read:user"}}, + selection{name: "legacy-no-ui", toolsets: []string{"all"}, features: AllowedFeatureFlags, protocol: "2025-11-25"}, + selection{name: "apps", toolsets: []string{"all"}, features: AllowedFeatureFlags, ui: true}, + ) + for _, host := range []utils.HostType{utils.HostTypeDotcom, utils.HostTypeGHEC, utils.HostTypeGHES} { + // Reuse the same static definitions across request-time variants, just as + // both the local HTTP and remote inventory factories do. + definitions := AllTools(translations.NullTranslationHelper, WithHost(host)) + for _, readOnly := range []bool{false, true} { + for _, sel := range selections { + t.Run(fmt.Sprintf("host=%d/readonly=%v/%s", host, readOnly, sel.name), func(t *testing.T) { + protocol := sel.protocol + if protocol == "" { + protocol = inventory.ProtocolVersionMultiRoundTrip + } + ctx := schemaListContext(protocol, sel.ui) + flags := ResolveFeatureFlags(sel.features, false) + builder := inventory.NewBuilder().SetTools(definitions). + WithToolsets(sel.toolsets).WithReadOnly(readOnly). + WithTools(sel.explicit).WithExcludeTools(sel.exclude). + WithFeatureChecker(func(_ context.Context, stringFlag string) (bool, error) { + return flags[stringFlag], nil + }) + if sel.scopes != nil { + builder.WithFilter(CreateToolScopeFilter(sel.scopes)) + } + inv, err := builder.Build() + require.NoError(t, err) + wantNames := make([]string, 0) + for _, tool := range inv.ToolsForRegistration(ctx) { + wantNames = append(wantNames, tool.Tool.Name) + } + slices.Sort(wantNames) + wantNames = slices.Compact(wantNames) + for _, transport := range []string{"stdio", "http"} { + t.Run(transport, func(t *testing.T) { + before, baselineNames := schemaListPages(ctx, t, inv, false, transport, protocol, 7) + after, encodedNames := schemaListPages(ctx, t, inv, true, transport, protocol, 7) + assert.Equal(t, before, after, "complete JSON-RPC wire responses, including cursors and errors") + assert.Equal(t, wantNames, baselineNames) + assert.Equal(t, wantNames, encodedNames) + }) + } + }) + } + } + } +} + +func schemaListClient(tb testing.TB, server *mcp.Server) *mcp.ClientSession { + tb.Helper() + st, ct := mcp.NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + require.NoError(tb, err) + client := mcp.NewClient(&mcp.Implementation{Name: "benchmark"}, nil) + cs, err := client.Connect(context.Background(), ct, nil) + require.NoError(tb, err) + tb.Cleanup(func() { + _ = cs.Close() + _ = ss.Close() + }) + return cs +} + +func BenchmarkEncodedSchemas(b *testing.B) { + ctx := schemaListContext(inventory.ProtocolVersionMultiRoundTrip, false) + inv, err := NewInventory(translations.NullTranslationHelper). + WithToolsets([]string{"all"}). + WithFeatureChecker(func(context.Context, string) (bool, error) { return true, nil }). + Build() + require.NoError(b, err) + for _, optimized := range []bool{false, true} { + name := map[bool]string{false: "baseline", true: "encoded"}[optimized] + b.Run(name, func(b *testing.B) { + b.Run("ListTools", func(b *testing.B) { + cs := schemaListClient(b, schemaListServer(ctx, inv, optimized, 1000)) + result, err := cs.ListTools(ctx, nil) + require.NoError(b, err) + payload, err := json.Marshal(result) + require.NoError(b, err) + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + if _, err := cs.ListTools(ctx, nil); err != nil { + b.Fatal(err) + } + } + b.ReportMetric(float64(len(payload)), "payload-B") + b.ReportMetric(float64(len(result.Tools)), "tools") + }) + b.Run("ConstructionAndList", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + st, ct := mcp.NewInMemoryTransports() + server := schemaListServer(ctx, inv, optimized, 1000) + ss, err := server.Connect(ctx, st, nil) + if err != nil { + b.Fatal(err) + } + cs, err := mcp.NewClient(&mcp.Implementation{Name: "benchmark"}, nil).Connect(ctx, ct, nil) + if err != nil { + b.Fatal(err) + } + _, err = cs.ListTools(ctx, nil) + _ = cs.Close() + _ = ss.Close() + if err != nil { + b.Fatal(err) + } + } + }) + }) + } +} diff --git a/pkg/http/handler_test.go b/pkg/http/handler_test.go index c9fa1de095..9edf886af5 100644 --- a/pkg/http/handler_test.go +++ b/pkg/http/handler_test.go @@ -21,6 +21,7 @@ import ( "github.com/github/github-mcp-server/pkg/translations" "github.com/github/github-mcp-server/pkg/utils" "github.com/go-chi/chi/v5" + "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" @@ -60,6 +61,41 @@ func (f allScopesFetcher) FetchTokenScopes(_ context.Context, _ string) ([]strin var _ scopes.FetcherInterface = allScopesFetcher{} +func TestDefaultInventoryFactoryReusesSchemaDefinitions(t *testing.T) { + t.Parallel() + for _, host := range []string{"https://github.com", "https://example.ghe.com", "https://github.example.com"} { + t.Run(host, func(t *testing.T) { + factory, err := NewDefaultInventoryFactory(&ServerConfig{Host: host}, translations.NullTranslationHelper, nil, allScopesFetcher{}) + require.NoError(t, err) + request := httptest.NewRequest(http.MethodPost, "/", nil) + request = request.WithContext(ghcontext.WithToolsets(request.Context(), []string{"all"})) + first, err := factory(request) + require.NoError(t, err) + definitions := first.AllTools() + require.NotEmpty(t, definitions) + for range 10 { + next, err := factory(request) + require.NoError(t, err) + tools := next.AllTools() + require.Len(t, tools, len(definitions)) + for i, tool := range tools { + assert.Equal(t, definitions[i].Tool.Name, tool.Tool.Name) + if _, ok := definitions[i].Tool.InputSchema.(*jsonschema.Schema); ok { + assert.Same(t, definitions[i].Tool.InputSchema, tool.Tool.InputSchema) + } else { + assert.Equal(t, definitions[i].Tool.InputSchema, tool.Tool.InputSchema) + } + if _, ok := definitions[i].Tool.OutputSchema.(*jsonschema.Schema); ok { + assert.Same(t, definitions[i].Tool.OutputSchema, tool.Tool.OutputSchema) + } else { + assert.Equal(t, definitions[i].Tool.OutputSchema, tool.Tool.OutputSchema) + } + } + } + }) + } +} + func mockToolWithFeatureFlag(name, toolsetID string, readOnly bool, enableFlag, disableFlag inventory.FeatureFlag) inventory.ServerTool { tool := mockTool(name, toolsetID, readOnly) features := make([]inventory.FeatureFlag, 0, 2) diff --git a/pkg/inventory/encoded_schemas.go b/pkg/inventory/encoded_schemas.go new file mode 100644 index 0000000000..12c7961ef0 --- /dev/null +++ b/pkg/inventory/encoded_schemas.go @@ -0,0 +1,88 @@ +package inventory + +import ( + "context" + "encoding/json" + "fmt" + "sync" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type listedToolSchemas struct { + source any + registered *mcp.Tool +} + +type encodedSchemaKey struct { + schema *jsonschema.Schema + // Input schemas receive header annotations at registration; output schemas do not. + input bool +} + +var encodedSchemas sync.Map // encodedSchemaKey -> func() (json.RawMessage, error) + +func encodeListedSchema(source, registered, listed any, input bool) (any, error) { + schema, ok := source.(*jsonschema.Schema) + if !ok || schema == nil { + return listed, nil + } + finalSchema, ok := registered.(*jsonschema.Schema) + if !ok || finalSchema == nil { + return listed, nil + } + if listedSchema, ok := listed.(*jsonschema.Schema); !ok || listedSchema != finalSchema { + // Another middleware or a replacement tool may provide a different schema. + return listed, nil + } + + key := encodedSchemaKey{schema: schema, input: input} + encode, ok := encodedSchemas.Load(key) + if !ok { + // Key by the source definition: header annotation clones the input schema + // on each HTTP registration, but its encoded representation is unchanged. + encode, _ = encodedSchemas.LoadOrStore(key, sync.OnceValues(func() (json.RawMessage, error) { + data, err := json.Marshal(finalSchema) + return json.RawMessage(data), err + })) + } + return encode.(func() (json.RawMessage, error))() +} + +func encodedToolSchemasMiddleware(schemas map[string]listedToolSchemas) mcp.Middleware { + return func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) { + result, err := next(ctx, method, request) + if err != nil || method != MCPMethodToolsList { + return result, err + } + list, ok := result.(*mcp.ListToolsResult) + if !ok || list == nil { + return result, nil + } + patched := *list + if list.Tools != nil { + patched.Tools = make([]*mcp.Tool, len(list.Tools)) + } + for i, tool := range list.Tools { + definition, ok := schemas[tool.Name] + if !ok { + patched.Tools[i] = tool + continue + } + toolCopy := *tool + toolCopy.InputSchema, err = encodeListedSchema(definition.source, definition.registered.InputSchema, tool.InputSchema, true) + if err != nil { + return nil, fmt.Errorf("encode input schema for %q: %w", tool.Name, err) + } + toolCopy.OutputSchema, err = encodeListedSchema(definition.registered.OutputSchema, definition.registered.OutputSchema, tool.OutputSchema, false) + if err != nil { + return nil, fmt.Errorf("encode output schema for %q: %w", tool.Name, err) + } + patched.Tools[i] = &toolCopy + } + return &patched, nil + } + } +} diff --git a/pkg/inventory/encoded_schemas_test.go b/pkg/inventory/encoded_schemas_test.go new file mode 100644 index 0000000000..c3442878bc --- /dev/null +++ b/pkg/inventory/encoded_schemas_test.go @@ -0,0 +1,300 @@ +package inventory + +import ( + "context" + "encoding/json" + "errors" + "sync" + "sync/atomic" + "testing" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func listThroughEncoder(t *testing.T, definitions map[string]listedToolSchemas, list *mcp.ListToolsResult) *mcp.ListToolsResult { + t.Helper() + handler := encodedToolSchemasMiddleware(definitions)(func(context.Context, string, mcp.Request) (mcp.Result, error) { + return list, nil + }) + result, err := handler(context.Background(), MCPMethodToolsList, nil) + require.NoError(t, err) + return result.(*mcp.ListToolsResult) +} + +func TestEncodedSchemasVariantIsolation(t *testing.T) { + t.Parallel() + for _, property := range []string{"dotcom", "enterprise", "feature_enabled"} { + t.Run(property, func(t *testing.T) { + t.Parallel() + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{property: {Type: "string"}, "owner": {Type: "string"}}, + } + tool := &mcp.Tool{Name: "same_name", InputSchema: schema, OutputSchema: schema} + AnnotateHeaderParams(tool) + before := &mcp.ListToolsResult{Tools: []*mcp.Tool{tool}, NextCursor: "unchanged"} + want, err := json.Marshal(before) + require.NoError(t, err) + after := listThroughEncoder(t, map[string]listedToolSchemas{ + tool.Name: {source: schema, registered: tool}, + }, before) + got, err := json.Marshal(after) + require.NoError(t, err) + assert.Equal(t, string(want), string(got)) + assert.IsType(t, json.RawMessage{}, after.Tools[0].InputSchema) + assert.IsType(t, json.RawMessage{}, after.Tools[0].OutputSchema) + assert.NotSame(t, before, after) + assert.NotSame(t, tool, after.Tools[0]) + assert.Same(t, schema, tool.OutputSchema) + assert.IsType(t, &jsonschema.Schema{}, tool.InputSchema) + assert.Contains(t, string(got), property) + assert.NotContains(t, string(after.Tools[0].OutputSchema.(json.RawMessage)), "x-mcp-header") + assert.Contains(t, string(after.Tools[0].InputSchema.(json.RawMessage)), "x-mcp-header") + }) + } +} + +type encodingProbe struct { + calls atomic.Int64 + err error +} + +func (p *encodingProbe) MarshalJSON() ([]byte, error) { + p.calls.Add(1) + return []byte(`true`), p.err +} + +func TestEncodedSchemasConcurrentRegistration(t *testing.T) { + t.Parallel() + probe := &encodingProbe{} + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{"owner": {Type: "string"}}, + Extra: map[string]any{"x-probe": probe}, + } + original, err := json.Marshal(schema) + require.NoError(t, err) + initialCalls := probe.calls.Load() + const workers = 32 + results := make(chan json.RawMessage, workers) + var wg sync.WaitGroup + for range workers { + wg.Go(func() { + tool := &mcp.Tool{Name: "concurrent", InputSchema: schema} + AnnotateHeaderParams(tool) + before := &mcp.ListToolsResult{Tools: []*mcp.Tool{tool}} + handler := encodedToolSchemasMiddleware(map[string]listedToolSchemas{ + tool.Name: {source: schema, registered: tool}, + })(func(context.Context, string, mcp.Request) (mcp.Result, error) { + return before, nil + }) + for range 10 { + result, err := handler(context.Background(), MCPMethodToolsList, nil) + if !assert.NoError(t, err) { + return + } + after := result.(*mcp.ListToolsResult) + assert.Same(t, tool, before.Tools[0]) + assert.IsType(t, &jsonschema.Schema{}, tool.InputSchema) + data := after.Tools[0].InputSchema.(json.RawMessage) + assert.True(t, json.Valid(data)) + } + result, err := handler(context.Background(), MCPMethodToolsList, nil) + if assert.NoError(t, err) { + results <- result.(*mcp.ListToolsResult).Tools[0].InputSchema.(json.RawMessage) + } + }) + } + wg.Wait() + close(results) + assert.Equal(t, int64(1), probe.calls.Load()-initialCalls, "all registration clones share one encoding") + var first json.RawMessage + for data := range results { + if first == nil { + first = data + } + assert.Equal(t, first, data) + } + after, err := json.Marshal(schema) + require.NoError(t, err) + assert.Equal(t, original, after, "source schema must remain unchanged") +} + +func TestEncodedSchemasRepeatedInventoryRegistration(t *testing.T) { + t.Parallel() + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{"owner": {Type: "string"}}, + } + definition := ServerTool{ + Tool: mcp.Tool{Name: "repeated", InputSchema: schema, OutputSchema: schema}, + HandlerFunc: func(any) mcp.ToolHandler { + return func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{}, nil + } + }, + } + original, err := json.Marshal(schema) + require.NoError(t, err) + var first []byte + for range 20 { + inv, err := NewBuilder().SetTools([]ServerTool{definition}).WithToolsets([]string{"all"}).Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "repeated"}, nil) + inv.ForMCPRequest(MCPMethodToolsList, "").RegisterTools(context.Background(), server, nil) + st, ct := mcp.NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + require.NoError(t, err) + cs, err := mcp.NewClient(&mcp.Implementation{Name: "test"}, nil).Connect(context.Background(), ct, nil) + require.NoError(t, err) + list, err := cs.ListTools(context.Background(), nil) + _ = cs.Close() + _ = ss.Close() + require.NoError(t, err) + data, err := json.Marshal(list) + require.NoError(t, err) + if first == nil { + first = data + } + assert.Equal(t, first, data) + } + var entries int + encodedSchemas.Range(func(key, _ any) bool { + if key.(encodedSchemaKey).schema == schema { + entries++ + } + return true + }) + assert.Equal(t, 2, entries, "new inventories and annotation clones must reuse the two source-schema keys") + after, err := json.Marshal(schema) + require.NoError(t, err) + assert.Equal(t, original, after) +} + +func TestEncodedSchemasPreserveReplacementsAndErrors(t *testing.T) { + t.Parallel() + source := &jsonschema.Schema{Type: "object"} + registered := &mcp.Tool{Name: "tool", InputSchema: source} + replacement := &mcp.Tool{Name: "tool", InputSchema: &jsonschema.Schema{Type: "object", Description: "replacement"}} + raw := &mcp.Tool{Name: "raw", InputSchema: json.RawMessage(`{"type":"object","properties":{}}`)} + before := &mcp.ListToolsResult{Tools: []*mcp.Tool{replacement, raw}} + after := listThroughEncoder(t, map[string]listedToolSchemas{ + "tool": {source: source, registered: registered}, + "raw": {source: raw.InputSchema, registered: raw}, + }, before) + assert.Same(t, replacement.InputSchema, after.Tools[0].InputSchema) + assert.Equal(t, raw.InputSchema, after.Tools[1].InputSchema) + + broken := &jsonschema.Schema{Type: "object", Extra: map[string]any{ + "x-probe": &encodingProbe{err: errors.New("encoding failed")}, + }} + tool := &mcp.Tool{Name: "broken", InputSchema: broken} + handler := encodedToolSchemasMiddleware(map[string]listedToolSchemas{ + "broken": {source: broken, registered: tool}, + })(func(context.Context, string, mcp.Request) (mcp.Result, error) { + return &mcp.ListToolsResult{Tools: []*mcp.Tool{tool}}, nil + }) + result, err := handler(context.Background(), MCPMethodToolsList, nil) + assert.Nil(t, result) + require.ErrorContains(t, err, `encode input schema for "broken"`) + + wantErr := errors.New("downstream error") + handler = encodedToolSchemasMiddleware(nil)(func(context.Context, string, mcp.Request) (mcp.Result, error) { + return nil, wantErr + }) + _, err = handler(context.Background(), MCPMethodToolsList, nil) + assert.ErrorIs(t, err, wantErr) +} + +func TestEncodedSchemasSharedResultIsImmutable(t *testing.T) { + t.Parallel() + schema := &jsonschema.Schema{Type: "object", Description: "shared"} + tool := &mcp.Tool{Name: "shared", InputSchema: schema} + list := &mcp.ListToolsResult{Tools: []*mcp.Tool{tool}, NextCursor: "cursor"} + handler := encodedToolSchemasMiddleware(map[string]listedToolSchemas{ + tool.Name: {source: schema, registered: tool}, + })(func(context.Context, string, mcp.Request) (mcp.Result, error) { + return list, nil + }) + var wg sync.WaitGroup + for range 32 { + wg.Go(func() { + for range 10 { + result, err := handler(context.Background(), MCPMethodToolsList, nil) + if !assert.NoError(t, err) { + return + } + patched := result.(*mcp.ListToolsResult) + assert.Equal(t, "cursor", patched.NextCursor) + assert.Equal(t, "shared", patched.Tools[0].Name) + assert.Equal(t, json.RawMessage(`{"type":"object","description":"shared"}`), patched.Tools[0].InputSchema) + patched.NextCursor = "caller cursor" + patched.Tools[0].Name = "caller tool" + patched.Tools[0].InputSchema = json.RawMessage(`{}`) + patched.Tools[0] = nil + } + }) + } + wg.Wait() + assert.Same(t, tool, list.Tools[0]) + assert.Same(t, schema, tool.InputSchema) + assert.Equal(t, "shared", tool.Name) + assert.Equal(t, "cursor", list.NextCursor) +} + +func TestEncodedSchemasLeaveSDKValidationIntact(t *testing.T) { + t.Parallel() + type values struct { + Count int `json:"count"` + } + input := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{"count": {Type: "integer"}}, + Required: []string{"count"}, + } + output := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{"count": {Type: "integer", Minimum: new(float64)}}, + Required: []string{"count"}, + } + for _, optimized := range []bool{false, true} { + t.Run(map[bool]string{false: "baseline", true: "encoded"}[optimized], func(t *testing.T) { + var calls int + server := mcp.NewServer(&mcp.Implementation{Name: "validation"}, &mcp.ServerOptions{SchemaCache: mcp.NewSchemaCache()}) + tool := &mcp.Tool{Name: "validate", InputSchema: input, OutputSchema: output} + mcp.AddTool(server, tool, func(_ context.Context, _ *mcp.CallToolRequest, args values) (*mcp.CallToolResult, values, error) { + calls++ + return nil, args, nil + }) + if optimized { + server.AddReceivingMiddleware(encodedToolSchemasMiddleware(map[string]listedToolSchemas{ + tool.Name: {source: input, registered: tool}, + })) + } + st, ct := mcp.NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + require.NoError(t, err) + defer func() { _ = ss.Close() }() + cs, err := mcp.NewClient(&mcp.Implementation{Name: "test"}, nil).Connect(context.Background(), ct, nil) + require.NoError(t, err) + defer func() { _ = cs.Close() }() + _, err = cs.ListTools(context.Background(), nil) + require.NoError(t, err) + invalid, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: tool.Name, Arguments: map[string]any{"count": "wrong"}}) + require.NoError(t, err) + assert.True(t, invalid.IsError) + assert.Zero(t, calls, "input validation rejects before calling the handler") + _, err = cs.CallTool(context.Background(), &mcp.CallToolParams{Name: tool.Name, Arguments: values{Count: -1}}) + require.Error(t, err, "output validation must still reject a negative result") + result, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: tool.Name, Arguments: values{Count: 1}}) + require.NoError(t, err) + assert.False(t, result.IsError) + assert.Equal(t, 2, calls) + assert.Same(t, input, tool.InputSchema) + assert.Same(t, output, tool.OutputSchema) + }) + } +} diff --git a/pkg/inventory/registry.go b/pkg/inventory/registry.go index 02dc5b0fd7..c613664936 100644 --- a/pkg/inventory/registry.go +++ b/pkg/inventory/registry.go @@ -240,12 +240,21 @@ func shouldStripMCPAppsMetadata(ctx context.Context) bool { // the client did not advertise the io.modelcontextprotocol/ui extension. The // strip happens here (rather than at Build() time) so the per-request // context, which carries the client capability, is in scope. +// +// Schema definitions must remain immutable and be reused across registrations. +// Their encodings are retained process-wide, keyed by source schema identity. func (r *Inventory) RegisterTools(ctx context.Context, s *mcp.Server, deps any, middleware ...ToolHandlerMiddleware) { tools := r.ToolsForRegistration(ctx) addToolAvailabilityMiddleware(s, tools) + schemas := make(map[string]listedToolSchemas, len(tools)) for _, tool := range tools { - tool.RegisterFunc(s, deps, middleware...) + registered := tool.register(s, deps, middleware...) + schemas[registered.Name] = listedToolSchemas{ + source: tool.Tool.InputSchema, + registered: registered, + } } + s.AddReceivingMiddleware(encodedToolSchemasMiddleware(schemas)) } // RegisterResourceTemplates registers all available resource templates with the server. diff --git a/pkg/inventory/server_tool.go b/pkg/inventory/server_tool.go index 9dd708685f..ec8ee44240 100644 --- a/pkg/inventory/server_tool.go +++ b/pkg/inventory/server_tool.go @@ -142,6 +142,10 @@ func (st *ServerTool) Handler(deps any) mcp.ToolHandler { // A shallow copy of the tool is made to avoid mutating the original ServerTool. // Panics if the tool has no handler - all tools should have handlers. func (st *ServerTool) RegisterFunc(s *mcp.Server, deps any, middleware ...ToolHandlerMiddleware) { + st.register(s, deps, middleware...) +} + +func (st *ServerTool) register(s *mcp.Server, deps any, middleware ...ToolHandlerMiddleware) *mcp.Tool { handler := st.Handler(deps) // This will panic if HandlerFunc is nil for _, m := range slices.Backward(middleware) { handler = m(handler) @@ -158,6 +162,7 @@ func (st *ServerTool) RegisterFunc(s *mcp.Server, deps any, middleware ...ToolHa // No-op for tools without these params. AnnotateHeaderParams(&toolCopy) s.AddTool(&toolCopy, handler) + return &toolCopy } // HeaderParams maps owner/repo input properties to the MCP-Param-* headers a From bf0f67fa572e2926c30506fe89c859b360c1d110 Mon Sep 17 00:00:00 2001 From: GitHub Security Bot <88103841+github-security-bot@users.noreply.github.com> Date: Tue, 6 Oct 2026 09:44:23 -0400 Subject: [PATCH 42/61] Pin GitHub Actions to commit SHAs (#3259) Co-authored-by: Michael Recachinas Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/dependabot.yml | 2 ++ .github/workflows/ai-issue-assessment.yml | 2 +- .github/workflows/close-inactive-issues.yml | 2 +- .github/workflows/code-scanning.yml | 16 ++++++++-------- .github/workflows/docker-publish.yml | 4 ++-- .github/workflows/docs-check.yml | 4 ++-- .github/workflows/go.yml | 4 ++-- .github/workflows/goreleaser.yml | 6 +++--- .github/workflows/license-check.yml | 8 ++++---- .github/workflows/lint.yml | 6 +++--- .github/workflows/mcp-diff.yml | 8 ++++---- .github/workflows/moderator.yml | 4 ++-- .github/workflows/registry-releaser.yml | 4 ++-- 13 files changed, 36 insertions(+), 34 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index dbb35ef4d5..c04aa0c593 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -21,3 +21,5 @@ updates: directory: "/" schedule: interval: "weekly" + cooldown: + default-days: 7 diff --git a/.github/workflows/ai-issue-assessment.yml b/.github/workflows/ai-issue-assessment.yml index 1d4e0c35c7..20962cc3be 100644 --- a/.github/workflows/ai-issue-assessment.yml +++ b/.github/workflows/ai-issue-assessment.yml @@ -14,7 +14,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Run AI assessment uses: github/ai-assessment-comment-labeler@e3bedc38cfffa9179fe4cee8f7ecc93bffb3fee7 # v1.0.1 diff --git a/.github/workflows/close-inactive-issues.yml b/.github/workflows/close-inactive-issues.yml index beb701bc37..57124ebbe2 100644 --- a/.github/workflows/close-inactive-issues.yml +++ b/.github/workflows/close-inactive-issues.yml @@ -14,7 +14,7 @@ jobs: issues: write pull-requests: write steps: - - uses: actions/stale@v11 + - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11 with: days-before-issue-stale: ${{ env.PR_DAYS_BEFORE_STALE }} days-before-issue-close: ${{ env.PR_DAYS_BEFORE_CLOSE }} diff --git a/.github/workflows/code-scanning.yml b/.github/workflows/code-scanning.yml index 18d8d69ac8..26d61a2faa 100644 --- a/.github/workflows/code-scanning.yml +++ b/.github/workflows/code-scanning.yml @@ -41,10 +41,10 @@ jobs: runner: '["ubuntu-22.04"]' steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Initialize CodeQL - uses: github/codeql-action/init@v4.38.2 + uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} @@ -61,18 +61,18 @@ jobs: threat-models: [ ] - name: Setup proxy for registries id: proxy - uses: github/codeql-action/start-proxy@v4.38.2 + uses: github/codeql-action/start-proxy@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 with: registries_credentials: ${{ secrets.GITHUB_REGISTRIES_PROXY }} language: ${{ matrix.language }} - name: Configure - uses: github/codeql-action/resolve-environment@v4.38.2 + uses: github/codeql-action/resolve-environment@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 id: resolve-environment with: language: ${{ matrix.language }} - name: Setup Go - uses: actions/setup-go@v7 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 if: matrix.language == 'go' && fromJSON(steps.resolve-environment.outputs.environment).configuration.go.version with: go-version: ${{ fromJSON(steps.resolve-environment.outputs.environment).configuration.go.version }} @@ -80,7 +80,7 @@ jobs: - name: Set up Node.js (for JavaScript CodeQL) if: matrix.language == 'javascript' - uses: actions/setup-node@v7 + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7 with: node-version-file: ui/package.json cache: "npm" @@ -91,10 +91,10 @@ jobs: uses: ./.github/actions/build-ui - name: Autobuild - uses: github/codeql-action/autobuild@v4.38.2 + uses: github/codeql-action/autobuild@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4.38.2 + uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 env: CODEQL_PROXY_HOST: ${{ steps.proxy.outputs.proxy_host }} CODEQL_PROXY_PORT: ${{ steps.proxy.outputs.proxy_port }} diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index a810a716ca..d443bc36f8 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -40,7 +40,7 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 # Install the cosign tool except on PR # https://github.com/sigstore/cosign-installer @@ -87,7 +87,7 @@ jobs: type=raw,value=latest,enable=${{ github.ref_type == 'tag' && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref, '-') }} - name: Go Build Cache for Docker - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: go-build-cache key: ${{ runner.os }}-go-build-cache-${{ hashFiles('**/go.sum') }} diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml index 0514a1afe4..b7b681a591 100644 --- a/.github/workflows/docs-check.yml +++ b/.github/workflows/docs-check.yml @@ -14,13 +14,13 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Build UI uses: ./.github/actions/build-ui - name: Set up Go - uses: actions/setup-go@v7 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version-file: 'go.mod' diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 165c8e3815..1475aca261 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -23,13 +23,13 @@ jobs: git config --global core.eol lf - name: Check out code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Build UI uses: ./.github/actions/build-ui - name: Set up Go - uses: actions/setup-go@v7 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version-file: "go.mod" diff --git a/.github/workflows/goreleaser.yml b/.github/workflows/goreleaser.yml index 12e680a049..c0de29ba51 100644 --- a/.github/workflows/goreleaser.yml +++ b/.github/workflows/goreleaser.yml @@ -14,13 +14,13 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Build UI uses: ./.github/actions/build-ui - name: Set up Go - uses: actions/setup-go@v7 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version-file: "go.mod" @@ -42,7 +42,7 @@ jobs: OAUTH_CLIENT_SECRET: ${{ secrets.OAUTH_CLIENT_SECRET }} - name: Generate signed build provenance attestations for workflow artifacts - uses: actions/attest-build-provenance@v4 + uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4 with: subject-path: | dist/*.tar.gz diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml index 80016c4a37..2accf3bcf7 100644 --- a/.github/workflows/license-check.yml +++ b/.github/workflows/license-check.yml @@ -24,7 +24,7 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 # Check out the actual PR branch so we can push changes back if needed - name: Check out PR branch @@ -36,7 +36,7 @@ jobs: uses: ./.github/actions/build-ui - name: Set up Go - uses: actions/setup-go@v7 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version-file: "go.mod" @@ -70,7 +70,7 @@ jobs: - name: Check if already commented if: steps.changes.outcome == 'failure' && steps.push.outcome == 'failure' id: check_comment - uses: actions/github-script@v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | const { data: comments } = await github.rest.issues.listComments({ @@ -88,7 +88,7 @@ jobs: - name: Comment with instructions if cannot push if: steps.changes.outcome == 'failure' && steps.push.outcome == 'failure' && steps.check_comment.outputs.already_commented == 'false' - uses: actions/github-script@v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9 with: script: | await github.rest.issues.createComment({ diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 568ea1ca18..94f771cbb0 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -13,14 +13,14 @@ jobs: name: lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Build UI uses: ./.github/actions/build-ui - - uses: actions/setup-go@v7 + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version-file: 'go.mod' - name: golangci-lint - uses: golangci/golangci-lint-action@v9 + uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9 with: # sync with script/lint version: v2.14.0 diff --git a/.github/workflows/mcp-diff.yml b/.github/workflows/mcp-diff.yml index 653d71093a..3e635244f9 100644 --- a/.github/workflows/mcp-diff.yml +++ b/.github/workflows/mcp-diff.yml @@ -15,12 +15,12 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v7 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version-file: go.mod @@ -85,12 +85,12 @@ jobs: steps: - name: Check out code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: fetch-depth: 0 - name: Set up Go - uses: actions/setup-go@v7 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version-file: go.mod diff --git a/.github/workflows/moderator.yml b/.github/workflows/moderator.yml index e0726d0146..b96c65f174 100644 --- a/.github/workflows/moderator.yml +++ b/.github/workflows/moderator.yml @@ -16,8 +16,8 @@ jobs: models: read contents: read steps: - - uses: actions/checkout@v7 - - uses: github/ai-moderator@v1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + - uses: github/ai-moderator@81159c370785e295c97461ade67d7c33576e9319 # v1 with: token: ${{ secrets.GITHUB_TOKEN }} spam-label: 'spam' diff --git a/.github/workflows/registry-releaser.yml b/.github/workflows/registry-releaser.yml index 7ab683f721..a1db9759cf 100644 --- a/.github/workflows/registry-releaser.yml +++ b/.github/workflows/registry-releaser.yml @@ -14,10 +14,10 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v7 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - name: Setup Go - uses: actions/setup-go@v7 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7 with: go-version: "stable" From 18fb782b7f6c23547b6b967d87e9d433afe78c28 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 7 Oct 2026 00:11:16 +0200 Subject: [PATCH 43/61] feat(inventory): add typed MCP tool registration foundation (#3371) * feat(inventory): add typed MCP tool registration foundation Use the MCP SDK's generic registration for concrete input/output types, with protocol-gated output schemas and structured results. Keep raw handlers compatible and apply shared middleware on both paths. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * test: cover legacy typed CSV results Exercise the typed array and object paths through an initialized 2025-11-25 MCP session. Verify CSV text is returned once without structured output or the SDK JSON fallback. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(inventory): normalize legacy typed arguments Add a raw JSON input normalizer that runs before SDK schema validation so migrated tools can retain legacy input coercions without widening their published schemas. Test case-folded issue states, numeric string IDs, validation failures and replacement registrations. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * test(inventory): preserve typed input optionality Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(inventory): stabilize typed schema compatibility Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(inventory): cover protocol isolation across clients Exercise simultaneous legacy and modern sessions against one typed tool, plus stateless HTTP requests with modern, legacy, and absent protocol headers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(inventory): harden typed schema registration contracts Cache immutable schema variants and enum overrides, preserve runtime input compatibility and request-era output behavior, and normalize dynamic scope challenges before dispatch. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(inventory): cache warm schemas and guard typed preflight Avoid repeated canonical schema serialization and header annotation for cache-owned immutable pointers while retaining content-based lookup for caller-owned schemas. Run typed authorization and availability middleware before preflight and preserve tool-result error semantics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(inventory): preserve schema metadata isolation Advertise the SDK-compatible object schema for typed any input and deep-clone mutable JSON Schema metadata before caching or deriving runtime variants. Add legacy/modern registration and mutation isolation coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(inventory): detach numeric schema bounds and dedupe default traversal Two Copilot findings on 431b7a21: - CloneSchemas only deep-copies subschema nodes, so pointer-valued numeric validation keywords (Minimum, Maximum, MinLength, MinItems, etc.) remained aliased with the caller-owned schema after CloneSchema/CachedSchema. A caller mutating one of those pointers after caching could race with concurrent cached-schema serialization or silently change the cached validation bounds. Clone every *int/*float64 keyword explicitly alongside the existing collection/metadata cloning. - CloneSchemaWithoutDefaults's removeSchemaDefaults visited every map/pointer child twice: once via two literal child-collection loops, and again via schemaChildren, which already covers the same fields. This doubled traversal at every nested level, making default removal exponential for deeply nested object/array schemas. Traverse only the single comprehensive schemaChildren list. Add regression coverage across CloneSchema, CachedSchema, schema inference option cloning, and CloneSchemaWithoutDefaults: every *int/*float64 keyword is isolated from caller mutation (including under concurrent marshaling), and a 48-level deep chain is used for every schema-child field kind to confirm linear, non-exponential default removal. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(inventory): gate typed output on supported protocol range Select modern typed output for SDK-supported ISO-date protocol versions from 2026-07-28 onward. Missing, malformed, and unsupported future versions retain legacy behavior. Cover a later supported version through the selector list seam and verify the public path against the SDK supported set. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(inventory): unwrap pointer inputs before schema inference Match SDK input inference by unwrapping one pointer level before the cached input key and inference. Preserve nullable output pointer schemas. Cover both public constructors with actual modern and legacy discovery and calls, including structured null output. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(inventory): reuse encoded schema cache for typed tools Key output encodings by their source definitions while preserving the SDK-registered schema guard. Keep header routing metadata available for legacy and unknown protocol clients. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] --- CONTRIBUTING.md | 1 + docs/typed-tool-schemas.md | 61 + pkg/context/mcp_info.go | 6 + pkg/github/actions.go | 8 +- pkg/github/comment_minimize.go | 12 +- pkg/github/copilot.go | 2 +- pkg/github/csv_output.go | 20 +- pkg/github/csv_output_test.go | 116 ++ pkg/github/dependencies.go | 34 +- pkg/github/dependencies_test.go | 92 + pkg/github/discussions.go | 2 +- pkg/github/issues.go | 12 +- pkg/github/issues_granular.go | 106 +- pkg/github/labels.go | 2 +- pkg/github/notifications.go | 4 +- pkg/github/params.go | 16 +- pkg/github/projects.go | 2 +- pkg/github/pullrequests.go | 2 +- pkg/github/pullrequests_granular.go | 64 +- pkg/github/repositories.go | 4 +- pkg/github/server.go | 17 +- pkg/github/server_test.go | 63 + pkg/github/workflow_enums.go | 50 + pkg/http/handler_test.go | 139 ++ pkg/http/headers/headers.go | 2 + pkg/http/middleware/mcp_parse.go | 4 + pkg/http/middleware/mcp_parse_test.go | 14 + pkg/http/middleware/scope_challenge.go | 24 +- pkg/http/middleware/scope_challenge_test.go | 63 + pkg/inventory/encoded_schemas.go | 11 +- pkg/inventory/input_error.go | 12 + pkg/inventory/registry.go | 36 +- pkg/inventory/server_tool.go | 377 +++- pkg/inventory/typed_output.go | 392 ++++ pkg/inventory/typed_output_test.go | 1984 +++++++++++++++++++ pkg/inventory/typed_schema.go | 444 +++++ pkg/scopes/map.go | 32 +- 37 files changed, 4059 insertions(+), 171 deletions(-) create mode 100644 docs/typed-tool-schemas.md create mode 100644 pkg/github/workflow_enums.go create mode 100644 pkg/inventory/input_error.go create mode 100644 pkg/inventory/typed_output.go create mode 100644 pkg/inventory/typed_output_test.go create mode 100644 pkg/inventory/typed_schema.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 24f93af022..0c97c238c5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,6 +60,7 @@ For UI dependency upgrades, compare all four views (`get-me`, `issue-write`, `pr - Update readme documentation: `script/generate-docs` - If renaming a tool, add a deprecation alias (see [Tool Renaming Guide](docs/tool-renaming.md)) - For toolset and icon configuration, see [Toolsets and Icons Guide](docs/toolsets-and-icons.md) + - For typed tool registration and compatibility schemas, see [Typed Tool Schemas](docs/typed-tool-schemas.md) 6. Push to your fork and [submit a pull request][pr] targeting the `main` branch 7. Pat yourself on the back and wait for your pull request to be reviewed and merged. diff --git a/docs/typed-tool-schemas.md b/docs/typed-tool-schemas.md new file mode 100644 index 0000000000..f783dd5720 --- /dev/null +++ b/docs/typed-tool-schemas.md @@ -0,0 +1,61 @@ +# Typed tool schemas + +Typed tool registrations use concrete Go input and output types with the MCP +Go SDK's `mcp.AddTool` path. The SDK infers schemas when the tool definition +does not provide them, validates arguments before the handler runs, and +validates typed output. Keep business rules that JSON Schema cannot express +in the handler or a preflight callback. + +Input inference unwraps one pointer level, matching the SDK's object input +contract. Output pointers retain nullable schemas and may return JSON null. + +```go +tool := github.NewToolWithSchemaOptions[workflowInput, workflowOutput]( + toolset, + mcp.Tool{Name: "list_workflow_runs"}, + scopeAccess, + inventory.TypedSchemaOptions{ + InputEnums: []inventory.SchemaEnum{ + {Path: "status", Values: github.WorkflowStatusValues()}, + }, + }, + listWorkflowRuns, +) +``` + +`SchemaEnum.Path` addresses inferred properties with dot-separated names and +uses `items` to descend into array elements. `EnumSchema` creates a standalone +string enum schema, while `WithEnum` returns a cloned explicit schema with an +enum applied at a path. Inferred and explicit schema helpers cache immutable +schemas; do not mutate the returned pointers. + +When compatibility requires a broader runtime input contract than the one +advertised to clients, provide `ValidationInputSchema`. The tool's declared +`InputSchema` remains visible while the SDK validates calls against the +runtime-only schema. Use `Preflight` for checks that need raw arguments or +request dependencies before typed decoding; it may return a derived context +for the handler. Input normalizers are only for compatibility transformations, +not a replacement for schema validation. + +The SDK applies defaults from the runtime input schema before decoding. If an +omitted field must remain omitted, build the runtime schema with +`inventory.CloneSchemaWithoutDefaults` before applying validation-only +changes. Use `inventory.CloneSchema` when deriving other runtime-only schema +variants so numeric bound pointers and nested metadata are detached as well as +subschemas. Default removal visits each child once per parent. The advertised +schema can retain its defaults; the constructor caches the runtime schema +without mutating either caller-owned schema. + +The output schema and `structuredContent` are exposed only for a negotiated, +SDK-supported protocol version `2026-07-28` or later. Unknown versions are +treated as legacy, including unsupported future dates; older, absent, and +malformed versions also retain the legacy text result. Normal `Inventory.RegisterTools` and +`ServerTool.RegisterFunc` registrations select behavior per request. Use +`RegisterToolsForProtocolEra` or `RegisterFuncForProtocolEra` only when the +protocol era is already known before server construction, such as a stateless +request-scoped server. + +If a handler intentionally returns non-JSON text or content blocks, set +`PreserveHandlerContent` in `TypedSchemaOptions`, or call +`inventory.PreserveToolHandlerContent(ctx)` from the handler middleware for +request-dependent output such as CSV. diff --git a/pkg/context/mcp_info.go b/pkg/context/mcp_info.go index fc09e6b139..f589bafaed 100644 --- a/pkg/context/mcp_info.go +++ b/pkg/context/mcp_info.go @@ -24,6 +24,12 @@ type MCPMethodInfo struct { ItemName string // RawArguments contains the unmaterialized tool arguments for tools/call requests. RawArguments json.RawMessage + // NormalizedArguments contains canonical arguments prepared by HTTP scope + // middleware so tool adapters do not rerun non-idempotent normalizers. + NormalizedArguments json.RawMessage + // ArgumentsNormalized distinguishes an intentionally empty normalized value + // from a request that has not been normalized. + ArgumentsNormalized bool // ProtocolVersion and ClientCapabilities describe the requesting MCP client // when stateless HTTP parsing makes them available before registration. ProtocolVersion string diff --git a/pkg/github/actions.go b/pkg/github/actions.go index f40d8a31d3..970e9fa31a 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -311,13 +311,13 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an "page": { Type: "number", Description: "Page number for pagination (default: 1)", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, "perPage": { Type: "number", Description: "Results per page for pagination (default: 30, max: 100)", - Minimum: jsonschema.Ptr(1.0), - Maximum: jsonschema.Ptr(100.0), + Minimum: new(1.0), + Maximum: new(100.0), }, }, Required: []string{"method", "owner", "repo"}, @@ -536,7 +536,7 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ACTIONS_RUN_TRIGGER_USER_TITLE", "Trigger GitHub Actions workflow actions"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(true), + DestructiveHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", diff --git a/pkg/github/comment_minimize.go b/pkg/github/comment_minimize.go index 43eb0134d7..e8dc88620b 100644 --- a/pkg/github/comment_minimize.go +++ b/pkg/github/comment_minimize.go @@ -57,7 +57,7 @@ var issueCommentVisibilityTarget = commentVisibilityTarget{ "comment_id": { Type: "number", Description: "The numeric ID of the issue or pull request conversation comment", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, } }, @@ -84,7 +84,7 @@ var pullRequestReviewCommentVisibilityTarget = commentVisibilityTarget{ "comment_id": { Type: "number", Description: "The numeric pull request review comment ID. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...).", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, } }, @@ -111,12 +111,12 @@ var pullRequestReviewVisibilityTarget = commentVisibilityTarget{ "pullNumber": { Type: "number", Description: "The pull request number", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, "review_id": { Type: "number", Description: "The numeric ID of the pull request review", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, } }, @@ -175,8 +175,8 @@ func commentVisibilityTool(t translations.TranslationHelperFunc, target commentV Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_"+strings.ToUpper(name)+"_USER_TITLE", titleAction+" "+target.title), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(false), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(false), + OpenWorldHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", diff --git a/pkg/github/copilot.go b/pkg/github/copilot.go index f507a16537..8e9af5aa68 100644 --- a/pkg/github/copilot.go +++ b/pkg/github/copilot.go @@ -563,7 +563,7 @@ func AssignCopilotToIssueWithIntent(t translations.TranslationHelperFunc) invent Type: "string", Description: "One concise sentence explaining what specifically about the issue led to choosing Copilot. " + "State the concrete signal (e.g. 'Well-scoped task with clear acceptance criteria').", - MaxLength: jsonschema.Ptr(280), + MaxLength: new(280), }, "confidence": { Type: "string", diff --git a/pkg/github/csv_output.go b/pkg/github/csv_output.go index 6acb8b2fdb..8bb368385c 100644 --- a/pkg/github/csv_output.go +++ b/pkg/github/csv_output.go @@ -42,16 +42,14 @@ type csvOutputDocument struct { 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. +// withCSVOutput adds dependency-aware middleware to every default-toolset +// list_* tool so that it converts JSON text to CSV when the feature is enabled. func withCSVOutput(tools []inventory.ServerTool) []inventory.ServerTool { for i := range tools { if !isCSVOutputTool(tools[i]) { continue } - tools[i].HandlerFunc = wrapHandlerWithCSVOutput(tools[i].HandlerFunc) + tools[i].AddHandlerMiddleware(csvOutputMiddleware) } return tools } @@ -68,18 +66,18 @@ func isCSVOutputTool(tool inventory.ServerTool) bool { return strings.HasPrefix(tool.Tool.Name, "list_") } -func wrapHandlerWithCSVOutput(next inventory.HandlerFunc) inventory.HandlerFunc { - return func(deps any) mcp.ToolHandler { - handler := next(deps) - csvDeps, _ := deps.(ToolDependencies) +func csvOutputMiddleware(deps any) inventory.ToolHandlerMiddleware { + csvDeps, _ := deps.(ToolDependencies) + return func(next mcp.ToolHandler) mcp.ToolHandler { return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - result, err := handler(ctx, req) + result, err := next(ctx, req) if err != nil || result == nil || result.IsError { return result, err } if csvDeps == nil || !csvDeps.IsFeatureEnabled(ctx, FeatureFlagCSVOutput) { - return result, nil + return result, err } + inventory.PreserveToolHandlerContent(ctx) return convertJSONTextResultToCSV(result), nil } } diff --git a/pkg/github/csv_output_test.go b/pkg/github/csv_output_test.go index 44dbff6280..ec7222199a 100644 --- a/pkg/github/csv_output_test.go +++ b/pkg/github/csv_output_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/csv" "encoding/json" + "errors" "strings" "testing" @@ -126,6 +127,114 @@ func TestCSVOutputPreservesOriginalJSONWhenFlagOff(t *testing.T) { assert.JSONEq(t, jsonResponse, text.Text) } +func TestCSVOutputMiddlewareAppliesToTypedToolRegistration(t *testing.T) { + type row struct { + Number int `json:"number"` + } + tools := withCSVOutput([]inventory.ServerTool{ + inventory.NewServerToolWithContextHandler( + mcp.Tool{Name: "list_typed_things"}, + ToolsetMetadataRepos, + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, []row, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: `[{"number":1}]`}}, + }, []row{{Number: 1}}, nil + }, + ), + }) + deps := newCSVOutputTestDeps(true) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + tools[0].RegisterFunc(server, deps) + + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = serverSession.Close() }) + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil) + clientSession, err := client.Connect(context.Background(), clientTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = clientSession.Close() }) + + result, err := clientSession.CallTool(context.Background(), &mcp.CallToolParams{Name: "list_typed_things"}) + require.NoError(t, err) + require.False(t, result.IsError) + assert.JSONEq(t, `[{"number":1}]`, mustMarshalCSVJSON(t, result.StructuredContent)) + assert.Contains(t, textResult(t, result), "number\n1") +} + +func TestCSVOutputTypedArrayPreservesLegacyProtocolResult(t *testing.T) { + type row struct { + Number int `json:"number"` + } + type object struct { + Number int `json:"number"` + } + tools := withCSVOutput([]inventory.ServerTool{ + inventory.NewServerToolWithContextHandler( + mcp.Tool{Name: "list_typed_things"}, + ToolsetMetadataRepos, + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, []row, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: `[{"number":1}]`}}, + }, []row{{Number: 1}}, nil + }, + ), + inventory.NewServerToolWithContextHandler( + mcp.Tool{Name: "get_typed_thing"}, + ToolsetMetadataRepos, + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, object, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: `{"number":1}`}}, + }, object{Number: 1}, nil + }, + ), + }) + deps := newCSVOutputTestDeps(true) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) { + if method == "server/discover" { + return nil, errors.New("legacy client uses initialize") + } + return next(ctx, method, request) + } + }) + for _, tool := range tools { + tool.RegisterFunc(server, deps) + } + + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = serverSession.Close() }) + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil) + clientSession, err := client.Connect(context.Background(), clientTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = clientSession.Close() }) + require.Equal(t, "2025-11-25", clientSession.InitializeResult().ProtocolVersion) + + list, err := clientSession.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 2) + for _, tool := range list.Tools { + assert.Nil(t, tool.OutputSchema, "legacy tools/list must hide typed output schemas") + } + + listResult, err := clientSession.CallTool(context.Background(), &mcp.CallToolParams{Name: "list_typed_things"}) + require.NoError(t, err) + require.False(t, listResult.IsError) + assert.Nil(t, listResult.StructuredContent) + require.Len(t, listResult.Content, 1, "CSV text must not be duplicated by the SDK's JSON array fallback") + assert.Equal(t, "number\n1\n", textResult(t, listResult)) + + objectResult, err := clientSession.CallTool(context.Background(), &mcp.CallToolParams{Name: "get_typed_thing"}) + require.NoError(t, err) + require.False(t, objectResult.IsError) + assert.Nil(t, objectResult.StructuredContent) + require.Len(t, objectResult.Content, 1) + assert.Equal(t, `{"number":1}`, textResult(t, objectResult)) +} + func TestCSVOutputVariantMovesMetadataToPreamble(t *testing.T) { csvText, err := jsonTextToCSV(`{ "issues": [ @@ -152,6 +261,13 @@ func TestCSVOutputVariantMovesMetadataToPreamble(t *testing.T) { assert.NotContains(t, row, "totalCount") } +func mustMarshalCSVJSON(t *testing.T, value any) string { + t.Helper() + data, err := json.Marshal(value) + require.NoError(t, err) + return string(data) +} + func TestJSONTextToCSVFlattensPrimaryRows(t *testing.T) { csvText, err := jsonTextToCSV(`{ "discussions": [ diff --git a/pkg/github/dependencies.go b/pkg/github/dependencies.go index 21bbd99553..b8a4b80eef 100644 --- a/pkg/github/dependencies.go +++ b/pkg/github/dependencies.go @@ -214,6 +214,14 @@ func (d BaseDeps) IsFeatureEnabled(ctx context.Context, flag string) bool { // This avoids creating closures at registration time, which is important for performance // in servers that create a new server instance per request (like the remote server). // +// Use concrete input and output structs for migrated tools; registration then +// delegates schema inference and validation to mcp.AddTool. For example, define +// `type SearchInput struct { Query string `json:"query"` }` and a concrete +// `SearchOutput`, then use `NewTool[SearchInput, SearchOutput](...)`. Keep Out +// as any for tools that must retain the raw registration path. A final optional +// inventory.InputNormalizer argument can canonicalize legacy wire values +// (such as case-insensitive enums) before strict SDK schema validation. +// // The handler function receives deps extracted from context via MustDepsFromContext. // Ensure ContextWithDeps is called to inject deps before any tool handlers are invoked. // @@ -223,11 +231,33 @@ func NewTool[In, Out any]( tool mcp.Tool, scopeAccess inventory.ScopeAccess, handler func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error), + inputNormalizers ...inventory.InputNormalizer, +) inventory.ServerTool { + return NewToolWithSchemaOptions( + toolset, + tool, + scopeAccess, + inventory.TypedSchemaOptions{}, + handler, + inputNormalizers..., + ) +} + +// NewToolWithSchemaOptions is like NewTool, with options for schema inference, +// runtime-only input validation schemas, and pre-decode compatibility checks. +// The original tool schema remains the schema advertised to clients. +func NewToolWithSchemaOptions[In, Out any]( + toolset inventory.ToolsetMetadata, + tool mcp.Tool, + scopeAccess inventory.ScopeAccess, + schemaOptions inventory.TypedSchemaOptions, + handler func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error), + inputNormalizers ...inventory.InputNormalizer, ) inventory.ServerTool { - st := inventory.NewServerToolWithContextHandler(tool, toolset, func(ctx context.Context, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error) { + st := inventory.NewServerToolWithContextHandlerAndSchemaOptions(tool, toolset, func(ctx context.Context, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error) { deps := MustDepsFromContext(ctx) return handler(ctx, deps, req, args) - }) + }, schemaOptions, inputNormalizers...) st.ScopeAccess = scopeAccess return st } diff --git a/pkg/github/dependencies_test.go b/pkg/github/dependencies_test.go index 223fc8aca4..fc8d70ab04 100644 --- a/pkg/github/dependencies_test.go +++ b/pkg/github/dependencies_test.go @@ -2,7 +2,9 @@ package github_test import ( "context" + "encoding/json" "errors" + "fmt" "log/slog" "net/http" "net/http/httptest" @@ -14,9 +16,11 @@ import ( ghcontext "github.com/github/github-mcp-server/pkg/context" "github.com/github/github-mcp-server/pkg/github" "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/observability" "github.com/github/github-mcp-server/pkg/observability/metrics" "github.com/github/github-mcp-server/pkg/translations" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -27,6 +31,94 @@ func testExporters() observability.Exporters { return obs } +func TestNewToolWithSchemaOptionsPointerInputAndNullableOutput(t *testing.T) { + type input struct { + Query string `json:"query"` + } + type output struct { + Query string `json:"query"` + } + for _, legacy := range []bool{false, true} { + t.Run(fmt.Sprintf("legacy=%t", legacy), func(t *testing.T) { + options := &mcp.ServerOptions{} + if legacy { + options.SupportedProtocolVersions = []string{"2025-11-25"} + } + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "1"}, options) + if legacy { + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + if method == "server/discover" { + return nil, errors.New("legacy initialize required") + } + return next(ctx, method, req) + } + }) + } + deps := github.NewRequestDeps(newRequestDepsAPIHostResolver(t, "https://api.github.com"), + "test", false, nil, translations.NullTranslationHelper, 0, nil, testExporters()) + server.AddReceivingMiddleware(github.InjectDepsMiddleware(deps)) + tool := github.NewToolWithSchemaOptions( + inventory.ToolsetMetadata{ID: "test"}, + mcp.Tool{Name: "pointer_tool"}, + inventory.ScopeAccess{}, + inventory.TypedSchemaOptions{}, + func(_ context.Context, gotDeps github.ToolDependencies, _ *mcp.CallToolRequest, args *input) (*mcp.CallToolResult, *output, error) { + require.Same(t, deps, gotDeps) + require.NotNil(t, args) + result := &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: args.Query}}} + if args.Query == "null" { + return result, nil, nil + } + return result, &output{Query: args.Query}, nil + }, + ) + require.NotPanics(t, func() { tool.RegisterFunc(server, deps) }) + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + require.NoError(t, err) + defer serverSession.Close() + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "1"}, nil) + session, err := client.Connect(context.Background(), clientTransport, nil) + require.NoError(t, err) + defer session.Close() + if legacy { + assert.Equal(t, "2025-11-25", session.InitializeResult().ProtocolVersion) + } + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + inputJSON, err := json.Marshal(list.Tools[0].InputSchema) + require.NoError(t, err) + assert.Contains(t, string(inputJSON), `"type":"object"`) + for _, query := range []string{"value", "null"} { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: tool.Tool.Name, Arguments: map[string]any{"query": query}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + if legacy { + assert.Nil(t, list.Tools[0].OutputSchema) + assert.Nil(t, result.StructuredContent) + require.Len(t, result.Content, 1) + assert.Equal(t, query, result.Content[0].(*mcp.TextContent).Text) + } else { + schemaJSON, err := json.Marshal(list.Tools[0].OutputSchema) + require.NoError(t, err) + assert.Contains(t, string(schemaJSON), `"null"`) + valueJSON, err := json.Marshal(result.StructuredContent) + require.NoError(t, err) + if query == "null" { + assert.JSONEq(t, `null`, string(valueJSON)) + } else { + assert.JSONEq(t, `{"query":"value"}`, string(valueJSON)) + } + } + } + }) + } +} + type requestDepsAPIHostResolver struct { endpoint *url.URL } diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index a650a76d14..2912ee559b 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -597,7 +597,7 @@ Supports adding top-level comments, replying to existing comments, updating comm Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_DISCUSSION_COMMENT_WRITE_USER_TITLE", "Manage discussion comments"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(true), + DestructiveHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 80483d7905..4d7e72b58a 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1390,12 +1390,12 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool "comment_id": { Type: "integer", Description: "The numeric ID of the issue or pull request comment to react to. Use this for reactions to comments; omit it to react to the issue or pull request itself. Cannot be combined with body.", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, "body": { Type: "string", Description: "Comment content. Required unless reaction is provided.", - MinLength: jsonschema.Ptr(1), + MinLength: new(1), }, "reaction": { Type: "string", @@ -1577,12 +1577,12 @@ func UpdateIssueComment(t translations.TranslationHelperFunc) inventory.ServerTo "comment_id": { Type: "integer", Description: "The numeric ID of the issue or pull request conversation comment to update. Do not use a pull request review comment ID.", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, "body": { Type: "string", Description: "New comment content", - MinLength: jsonschema.Ptr(1), + MinLength: new(1), }, }, Required: []string{"owner", "repo", "comment_id", "body"}, @@ -2537,7 +2537,7 @@ Options are: "parent_issue_number": { Type: "number", Description: "Issue number of the parent issue. Only used when method is 'create' and cannot be combined with issue_fields. The new issue is created and attached to this parent in the same operation.", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, "parent_owner": { Type: "string", @@ -2575,7 +2575,7 @@ Options are: }, "type": { AnyOf: []*jsonschema.Schema{ - {Type: "string", MinLength: jsonschema.Ptr(1)}, + {Type: "string", MinLength: new(1)}, {Type: "null"}, }, Description: "Type of this issue. For updates, pass null to remove the current type. Only use if issue types are enabled for this repository. Use list_issue_types to get valid type values for this repository or its owner organization. If the repository doesn't support issue types, omit this parameter.", diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go index cd32b9c59f..eabf89702b 100644 --- a/pkg/github/issues_granular.go +++ b/pkg/github/issues_granular.go @@ -43,7 +43,7 @@ func issueUpdateTool( "issue_number": { Type: "number", Description: "The issue number to update", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, } maps.Copy(props, extraProps) @@ -58,8 +58,8 @@ func issueUpdateTool( Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_"+strings.ToUpper(name)+"_USER_TITLE", title), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(false), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(false), + OpenWorldHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -122,8 +122,8 @@ func GranularCreateIssue(t translations.TranslationHelperFunc) inventory.ServerT Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_CREATE_ISSUE_USER_TITLE", "Create Issue"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(false), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(false), + OpenWorldHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -147,7 +147,7 @@ func GranularCreateIssue(t translations.TranslationHelperFunc) inventory.ServerT "parent_issue_number": { Type: "number", Description: "Issue number of the parent issue. The new issue is created and attached to this parent in the same operation.", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, "parent_owner": { Type: "string", @@ -288,8 +288,8 @@ func GranularUpdateIssueAssignees(t translations.TranslationHelperFunc) inventor Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_UPDATE_ISSUE_ASSIGNEES_USER_TITLE", "Update Issue Assignees"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(false), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(false), + OpenWorldHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -305,7 +305,7 @@ func GranularUpdateIssueAssignees(t translations.TranslationHelperFunc) inventor "issue_number": { Type: "number", Description: "The issue number to update", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, "assignees": { Type: "array", @@ -324,7 +324,7 @@ func GranularUpdateIssueAssignees(t translations.TranslationHelperFunc) inventor Type: "string", Description: "One concise sentence explaining what specifically about the issue led you to choose this assignee. " + "State the concrete signal (e.g. 'Authored the file the crash originates in').", - MaxLength: jsonschema.Ptr(280), + MaxLength: new(280), }, "confidence": { Type: "string", @@ -506,8 +506,8 @@ func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.S Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_UPDATE_ISSUE_LABELS_USER_TITLE", "Update Issue Labels"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(false), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(false), + OpenWorldHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -523,7 +523,7 @@ func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.S "issue_number": { Type: "number", Description: "The issue number to update", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, "labels": { Type: "array", @@ -542,7 +542,7 @@ func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.S 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), + MaxLength: new(280), }, "confidence": { Type: "string", @@ -692,7 +692,7 @@ func GranularUpdateIssueMilestone(t translations.TranslationHelperFunc) inventor "milestone": { Type: "integer", Description: "The milestone number to set on the issue", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, }, []string{"milestone"}, @@ -731,8 +731,8 @@ func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.Ser Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_UPDATE_ISSUE_TYPE_USER_TITLE", "Update Issue Type"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(false), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(false), + OpenWorldHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -748,11 +748,11 @@ func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.Ser "issue_number": { Type: "number", Description: "The issue number to update", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, "issue_type": { AnyOf: []*jsonschema.Schema{ - {Type: "string", MinLength: jsonschema.Ptr(1)}, + {Type: "string", MinLength: new(1)}, {Type: "null"}, }, Description: "The issue type to set, or null to remove the current type", @@ -761,7 +761,7 @@ func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.Ser Type: "string", Description: "One concise sentence explaining what specifically about the issue led you to choose this type. " + "State the concrete signal (e.g. 'Reports a crash when saving' → bug, 'Asks for dark mode support' → feature).", - MaxLength: jsonschema.Ptr(280), + MaxLength: new(280), }, "confidence": { Type: "string", @@ -897,8 +897,8 @@ func GranularUpdateIssueState(t translations.TranslationHelperFunc) inventory.Se Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_UPDATE_ISSUE_STATE_USER_TITLE", "Update Issue State"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(false), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(false), + OpenWorldHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -914,7 +914,7 @@ func GranularUpdateIssueState(t translations.TranslationHelperFunc) inventory.Se "issue_number": { Type: "number", Description: "The issue number to update", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, "state": { Type: "string", @@ -930,7 +930,7 @@ func GranularUpdateIssueState(t translations.TranslationHelperFunc) inventory.Se Type: "string", Description: "One concise sentence explaining what specifically about the issue led you to choose this state. " + "State the concrete signal (e.g. 'The reported crash is fixed in v2.1' → completed).", - MaxLength: jsonschema.Ptr(280), + MaxLength: new(280), }, "confidence": { Type: "string", @@ -945,7 +945,7 @@ func GranularUpdateIssueState(t translations.TranslationHelperFunc) inventory.Se "duplicate_of": { Type: "number", Description: "The issue number of the canonical issue this issue duplicates. Only valid when state_reason is 'duplicate'. Required when is_suggestion is true and state_reason is 'duplicate'. The issue number is resolved to a database ID before being sent to the API.", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, }, Required: []string{"owner", "repo", "issue_number", "state"}, @@ -1078,8 +1078,8 @@ func GranularAddSubIssue(t translations.TranslationHelperFunc) inventory.ServerT Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ADD_SUB_ISSUE_USER_TITLE", "Add Sub-Issue"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(false), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(false), + OpenWorldHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -1095,7 +1095,7 @@ func GranularAddSubIssue(t translations.TranslationHelperFunc) inventory.ServerT "issue_number": { Type: "number", Description: "The parent issue number", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, "sub_issue_id": { Type: "number", @@ -1152,8 +1152,8 @@ func GranularRemoveSubIssue(t translations.TranslationHelperFunc) inventory.Serv Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_REMOVE_SUB_ISSUE_USER_TITLE", "Remove Sub-Issue"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(true), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(true), + OpenWorldHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -1169,7 +1169,7 @@ func GranularRemoveSubIssue(t translations.TranslationHelperFunc) inventory.Serv "issue_number": { Type: "number", Description: "The parent issue number", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, "sub_issue_id": { Type: "number", @@ -1221,8 +1221,8 @@ func GranularReprioritizeSubIssue(t translations.TranslationHelperFunc) inventor Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_REPRIORITIZE_SUB_ISSUE_USER_TITLE", "Reprioritize Sub-Issue"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(false), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(false), + OpenWorldHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -1238,7 +1238,7 @@ func GranularReprioritizeSubIssue(t translations.TranslationHelperFunc) inventor "issue_number": { Type: "number", Description: "The parent issue number", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, "sub_issue_id": { Type: "number", @@ -1347,8 +1347,8 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_SET_ISSUE_FIELDS_USER_TITLE", "Set Issue Fields"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(false), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(false), + OpenWorldHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -1364,12 +1364,12 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv "issue_number": { Type: "number", Description: "The issue number to update", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, "fields": { Type: "array", Description: "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.", - MinItems: jsonschema.Ptr(1), + MinItems: new(1), Items: &jsonschema.Schema{ Type: "object", Properties: map[string]*jsonschema.Schema{ @@ -1401,7 +1401,7 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv 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), + MaxLength: new(280), }, "confidence": { Type: "string", @@ -1594,8 +1594,8 @@ func GranularAddIssueReaction(t translations.TranslationHelperFunc) inventory.Se Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ADD_ISSUE_REACTION_USER_TITLE", "Add Reaction to Issue or Pull Request"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(false), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(false), + OpenWorldHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -1611,7 +1611,7 @@ func GranularAddIssueReaction(t translations.TranslationHelperFunc) inventory.Se "issue_number": { Type: "number", Description: "The issue number", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, "content": { Type: "string", @@ -1676,8 +1676,8 @@ func GranularRemoveIssueReaction(t translations.TranslationHelperFunc) inventory Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_REMOVE_ISSUE_REACTION_USER_TITLE", "Remove Reaction from Issue or Pull Request"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(true), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(true), + OpenWorldHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -1693,12 +1693,12 @@ func GranularRemoveIssueReaction(t translations.TranslationHelperFunc) inventory "issue_number": { Type: "number", Description: "The issue number", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, "reaction_id": { Type: "number", Description: "The reaction ID to remove", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, }, Required: []string{"owner", "repo", "issue_number", "reaction_id"}, @@ -1753,8 +1753,8 @@ func GranularAddIssueCommentReaction(t translations.TranslationHelperFunc) inven Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ADD_ISSUE_COMMENT_REACTION_USER_TITLE", "Add Reaction to Issue or Pull Request Comment"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(false), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(false), + OpenWorldHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -1770,7 +1770,7 @@ func GranularAddIssueCommentReaction(t translations.TranslationHelperFunc) inven "comment_id": { Type: "number", Description: "The issue or pull request comment ID", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, "content": { Type: "string", @@ -1835,8 +1835,8 @@ func GranularRemoveIssueCommentReaction(t translations.TranslationHelperFunc) in Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_REMOVE_ISSUE_COMMENT_REACTION_USER_TITLE", "Remove Reaction from Issue or Pull Request Comment"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(true), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(true), + OpenWorldHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -1852,12 +1852,12 @@ func GranularRemoveIssueCommentReaction(t translations.TranslationHelperFunc) in "comment_id": { Type: "number", Description: "The issue or pull request comment ID", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, "reaction_id": { Type: "number", Description: "The reaction ID to remove", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, }, Required: []string{"owner", "repo", "comment_id", "reaction_id"}, diff --git a/pkg/github/labels.go b/pkg/github/labels.go index 4be66a7c7d..c6fecfc21f 100644 --- a/pkg/github/labels.go +++ b/pkg/github/labels.go @@ -236,7 +236,7 @@ func LabelWrite(t translations.TranslationHelperFunc) inventory.ServerTool { Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_LABEL_WRITE_TITLE", "Write operations on repository labels"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(true), + DestructiveHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", diff --git a/pkg/github/notifications.go b/pkg/github/notifications.go index 117e96c806..77cf9a68b0 100644 --- a/pkg/github/notifications.go +++ b/pkg/github/notifications.go @@ -415,7 +415,7 @@ func ManageNotificationSubscription(t translations.TranslationHelperFunc) invent Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_MANAGE_NOTIFICATION_SUBSCRIPTION_USER_TITLE", "Manage notification subscription"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(true), + DestructiveHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -512,7 +512,7 @@ func ManageRepositoryNotificationSubscription(t translations.TranslationHelperFu Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_MANAGE_REPOSITORY_NOTIFICATION_SUBSCRIPTION_USER_TITLE", "Manage repository notification subscription"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(true), + DestructiveHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", diff --git a/pkg/github/params.go b/pkg/github/params.go index f4da2d6814..416020abb7 100644 --- a/pkg/github/params.go +++ b/pkg/github/params.go @@ -348,14 +348,14 @@ func WithPagination(schema *jsonschema.Schema) *jsonschema.Schema { schema.Properties["page"] = &jsonschema.Schema{ Type: "number", Description: "Page number for pagination (min 1)", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), } schema.Properties["perPage"] = &jsonschema.Schema{ Type: "number", Description: "Results per page for pagination (min 1, max 100)", - Minimum: jsonschema.Ptr(1.0), - Maximum: jsonschema.Ptr(100.0), + Minimum: new(1.0), + Maximum: new(100.0), } return schema @@ -367,14 +367,14 @@ func WithUnifiedPagination(schema *jsonschema.Schema) *jsonschema.Schema { schema.Properties["page"] = &jsonschema.Schema{ Type: "number", Description: "Page number for pagination (min 1)", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), } schema.Properties["perPage"] = &jsonschema.Schema{ Type: "number", Description: "Results per page for pagination (min 1, max 100)", - Minimum: jsonschema.Ptr(1.0), - Maximum: jsonschema.Ptr(100.0), + Minimum: new(1.0), + Maximum: new(100.0), } schema.Properties["after"] = &jsonschema.Schema{ @@ -390,8 +390,8 @@ func WithCursorPagination(schema *jsonschema.Schema) *jsonschema.Schema { schema.Properties["perPage"] = &jsonschema.Schema{ Type: "number", Description: "Results per page for pagination (min 1, max 100)", - Minimum: jsonschema.Ptr(1.0), - Maximum: jsonschema.Ptr(100.0), + Minimum: new(1.0), + Maximum: new(100.0), } schema.Properties["after"] = &jsonschema.Schema{ diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 4cb6a875cd..1f55182160 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -735,7 +735,7 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_PROJECTS_WRITE_USER_TITLE", "Manage GitHub Projects"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(true), + DestructiveHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 98354ac70b..d5e02fee71 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -1193,7 +1193,7 @@ func AddReplyToPullRequestComment(t translations.TranslationHelperFunc) inventor "commentId": { Type: "number", Description: "The numeric ID of the pull request review comment to reply or react to. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...).", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, "body": { Type: "string", diff --git a/pkg/github/pullrequests_granular.go b/pkg/github/pullrequests_granular.go index cf7fa93c1d..c1276a6b86 100644 --- a/pkg/github/pullrequests_granular.go +++ b/pkg/github/pullrequests_granular.go @@ -38,7 +38,7 @@ func prUpdateTool( "pullNumber": { Type: "number", Description: "The pull request number", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, } maps.Copy(props, extraProps) @@ -53,8 +53,8 @@ func prUpdateTool( Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_"+strings.ToUpper(name)+"_USER_TITLE", title), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(false), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(false), + OpenWorldHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -181,15 +181,15 @@ func GranularUpdatePullRequestDraftState(t translations.TranslationHelperFunc) i Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_UPDATE_PULL_REQUEST_DRAFT_STATE_USER_TITLE", "Update Pull Request Draft State"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(false), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(false), + OpenWorldHint: new(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"}, - "pullNumber": {Type: "number", Description: "The pull request number", Minimum: jsonschema.Ptr(1.0)}, + "pullNumber": {Type: "number", Description: "The pull request number", Minimum: new(1.0)}, "draft": {Type: "boolean", Description: "Set to true to convert to draft, false to mark as ready for review"}, }, Required: []string{"owner", "repo", "pullNumber", "draft"}, @@ -286,15 +286,15 @@ func GranularRequestPullRequestReviewers(t translations.TranslationHelperFunc) i Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_REQUEST_PULL_REQUEST_REVIEWERS_USER_TITLE", "Request Pull Request Reviewers"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(false), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(false), + OpenWorldHint: new(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"}, - "pullNumber": {Type: "number", Description: "The pull request number", Minimum: jsonschema.Ptr(1.0)}, + "pullNumber": {Type: "number", Description: "The pull request number", Minimum: new(1.0)}, "reviewers": { Type: "array", Description: "GitHub usernames or ORG/team-slug team reviewers to request reviews from", @@ -381,15 +381,15 @@ func GranularCreatePullRequestReview(t translations.TranslationHelperFunc) inven Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_CREATE_PULL_REQUEST_REVIEW_USER_TITLE", "Create Pull Request Review"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(false), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(false), + OpenWorldHint: new(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"}, - "pullNumber": {Type: "number", Description: "The pull request number", Minimum: jsonschema.Ptr(1.0)}, + "pullNumber": {Type: "number", Description: "The pull request number", Minimum: new(1.0)}, "body": {Type: "string", Description: "The review body text (optional)"}, "event": {Type: "string", Description: "The review action to perform. If omitted, creates a pending review.", Enum: []any{"APPROVE", "REQUEST_CHANGES", "COMMENT"}}, "commitID": {Type: "string", Description: "The SHA of the commit to review (optional, defaults to latest)"}, @@ -450,15 +450,15 @@ func GranularSubmitPendingPullRequestReview(t translations.TranslationHelperFunc Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_SUBMIT_PENDING_PULL_REQUEST_REVIEW_USER_TITLE", "Submit Pending Pull Request Review"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(false), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(false), + OpenWorldHint: new(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"}, - "pullNumber": {Type: "number", Description: "The pull request number", Minimum: jsonschema.Ptr(1.0)}, + "pullNumber": {Type: "number", Description: "The pull request number", Minimum: new(1.0)}, "event": {Type: "string", Description: "The review action to perform", Enum: []any{"APPROVE", "REQUEST_CHANGES", "COMMENT"}}, "body": {Type: "string", Description: "The review body text (optional)"}, }, @@ -514,15 +514,15 @@ func GranularDeletePendingPullRequestReview(t translations.TranslationHelperFunc Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_DELETE_PENDING_PULL_REQUEST_REVIEW_USER_TITLE", "Delete Pending Pull Request Review"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(true), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(true), + OpenWorldHint: new(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"}, - "pullNumber": {Type: "number", Description: "The pull request number", Minimum: jsonschema.Ptr(1.0)}, + "pullNumber": {Type: "number", Description: "The pull request number", Minimum: new(1.0)}, }, Required: []string{"owner", "repo", "pullNumber"}, }, @@ -569,15 +569,15 @@ func GranularAddPullRequestReviewComment(t translations.TranslationHelperFunc) i Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ADD_PULL_REQUEST_REVIEW_COMMENT_USER_TITLE", "Add Pull Request Review Comment"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(false), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(false), + OpenWorldHint: new(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"}, - "pullNumber": {Type: "number", Description: "The pull request number", Minimum: jsonschema.Ptr(1.0)}, + "pullNumber": {Type: "number", Description: "The pull request number", Minimum: new(1.0)}, "path": {Type: "string", Description: "The relative path of the file to comment on"}, "body": {Type: "string", Description: "The comment body"}, "subjectType": {Type: "string", Description: "The subject type of the comment", Enum: []any{"FILE", "LINE"}}, @@ -709,8 +709,8 @@ func granularResolveReviewThread(t translations.TranslationHelperFunc, withResol Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_RESOLVE_REVIEW_THREAD_USER_TITLE", "Resolve Review Thread"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(false), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(false), + OpenWorldHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -781,8 +781,8 @@ func GranularUnresolveReviewThread(t translations.TranslationHelperFunc) invento Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_UNRESOLVE_REVIEW_THREAD_USER_TITLE", "Unresolve Review Thread"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(false), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(false), + OpenWorldHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -825,8 +825,8 @@ func GranularAddPullRequestReviewCommentReaction(t translations.TranslationHelpe Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ADD_PULL_REQUEST_REVIEW_COMMENT_REACTION_USER_TITLE", "Add Pull Request Review Comment Reaction"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(false), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(false), + OpenWorldHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -842,7 +842,7 @@ func GranularAddPullRequestReviewCommentReaction(t translations.TranslationHelpe "comment_id": { Type: "number", Description: "The numeric pull request review comment ID. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...).", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, "content": { Type: "string", @@ -907,8 +907,8 @@ func GranularRemovePullRequestReviewCommentReaction(t translations.TranslationHe Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_REMOVE_PULL_REQUEST_REVIEW_COMMENT_REACTION_USER_TITLE", "Remove Pull Request Review Comment Reaction"), ReadOnlyHint: false, - DestructiveHint: jsonschema.Ptr(true), - OpenWorldHint: jsonschema.Ptr(true), + DestructiveHint: new(true), + OpenWorldHint: new(true), }, InputSchema: &jsonschema.Schema{ Type: "object", @@ -924,12 +924,12 @@ func GranularRemovePullRequestReviewCommentReaction(t translations.TranslationHe "comment_id": { Type: "number", Description: "The numeric pull request review comment ID. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...).", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, "reaction_id": { Type: "number", Description: "The reaction ID to remove", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, }, Required: []string{"owner", "repo", "comment_id", "reaction_id"}, diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 4b7e84cb9a..4a005e6b3c 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -2784,12 +2784,12 @@ func GetFileBlame(t translations.TranslationHelperFunc) inventory.ServerTool { "start_line": { Type: "number", Description: "Optional 1-based starting line of the window of interest. Only ranges overlapping [start_line, end_line] are returned, clamped to the window.", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, "end_line": { Type: "number", Description: "Optional 1-based ending line of the window of interest. Must be >= start_line when both are provided.", - Minimum: jsonschema.Ptr(1.0), + Minimum: new(1.0), }, }, Required: []string{"owner", "repo", "path"}, diff --git a/pkg/github/server.go b/pkg/github/server.go index 4ce1f965b1..37d571cd01 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -110,13 +110,6 @@ func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependenci 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, - // and any middleware that needs to read or modify the context should be before it. - ghServer.AddReceivingMiddleware(middleware...) - ghServer.AddReceivingMiddleware(injectFeatureStateMiddleware(inv)) - ghServer.AddReceivingMiddleware(InjectDepsMiddleware(deps)) - ghServer.AddReceivingMiddleware(addGitHubAPIErrorToContext) - if unrecognized := inv.UnrecognizedToolsets(); len(unrecognized) > 0 { cfg.Logger.Warn("Warning: unrecognized toolsets ignored", "toolsets", strings.Join(unrecognized, ", ")) } @@ -124,6 +117,16 @@ func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependenci // Register GitHub tools/resources/prompts from the inventory. inv.RegisterAll(ctx, ghServer, deps, cfg.ToolHandlerMiddleware...) + // Add request middleware after registering tools so it wraps the protocol + // adapter. Preflight and legacy typed calls then receive the same request + // context as modern typed calls. + // Middleware order matters: the error context wrapper is applied last so it + // runs first and can observe errors from every inner middleware. + ghServer.AddReceivingMiddleware(middleware...) + ghServer.AddReceivingMiddleware(injectFeatureStateMiddleware(inv)) + ghServer.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + ghServer.AddReceivingMiddleware(addGitHubAPIErrorToContext) + // Register MCP App UI resources whenever the embedded UI assets are // available. The resources are static HTML referenced by tools' _meta.ui // block (the inventory strips that block for clients that do not support diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go index f629ace35d..366ecc8a0d 100644 --- a/pkg/github/server_test.go +++ b/pkg/github/server_test.go @@ -194,6 +194,69 @@ func TestNewMCPServer_CreatesSuccessfully(t *testing.T) { // is already tested in pkg/github/*_test.go. } +func TestNewMCPServerTypedLegacyPreflightAndHandlerReceiveDependencies(t *testing.T) { + type output struct { + OK bool `json:"ok"` + } + deps := stubDeps{obsv: stubExporters()} + tool := NewToolWithSchemaOptions[struct{}, output]( + inventory.ToolsetMetadata{ID: inventory.ToolsetID("test"), Description: "Test"}, + mcp.Tool{Name: "typed_dependency_tool"}, + inventory.ScopeAccess{}, + inventory.TypedSchemaOptions{ + Preflight: func(ctx context.Context, _ *mcp.CallToolRequest) (context.Context, *mcp.CallToolResult, error) { + if _, ok := DepsFromContext(ctx); !ok { + return ctx, nil, errors.New("dependencies missing in preflight") + } + return ctx, nil, nil + }, + }, + func(ctx context.Context, handlerDeps ToolDependencies, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, output, error) { + if _, ok := DepsFromContext(ctx); !ok || handlerDeps == nil { + return nil, output{}, errors.New("dependencies missing in handler") + } + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "legacy text"}}, + }, output{OK: true}, nil + }, + ) + inv, err := inventory.NewBuilder(). + SetTools([]inventory.ServerTool{tool}). + WithToolsets([]string{"all"}). + Build() + require.NoError(t, err) + cfg := MCPServerConfig{ + Version: "test", + Translator: translations.NullTranslationHelper, + Logger: slog.New(slog.DiscardHandler), + } + server, err := NewMCPServer(context.Background(), &cfg, deps, inv) + require.NoError(t, err) + + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = serverSession.Close() }) + client := mcp.NewClient(&mcp.Implementation{Name: "legacy-test", Version: "1"}, nil) + clientSession, err := client.Connect(context.Background(), clientTransport, &mcp.ClientSessionOptions{ + ProtocolVersion: "2025-11-25", + }) + require.NoError(t, err) + t.Cleanup(func() { _ = clientSession.Close() }) + + list, err := clientSession.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + assert.Nil(t, list.Tools[0].OutputSchema) + + result, err := clientSession.CallTool(context.Background(), &mcp.CallToolParams{Name: "typed_dependency_tool"}) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Nil(t, result.StructuredContent) + require.Len(t, result.Content, 1) + assert.Equal(t, "legacy text", result.Content[0].(*mcp.TextContent).Text) +} + func TestFeatureStateMiddlewareCachesHandlerChecks(t *testing.T) { var calls int checker := func(_ context.Context, flag string) (bool, error) { diff --git a/pkg/github/workflow_enums.go b/pkg/github/workflow_enums.go new file mode 100644 index 0000000000..ec68a14747 --- /dev/null +++ b/pkg/github/workflow_enums.go @@ -0,0 +1,50 @@ +package github + +import "slices" + +// WorkflowStatus is a GitHub Actions workflow-run status. +type WorkflowStatus string + +var workflowStatusValues = []string{ + "queued", + "in_progress", + "completed", + "requested", + "waiting", + "pending", +} + +// Values returns the supported workflow statuses. +func (WorkflowStatus) Values() []string { + return slices.Clone(workflowStatusValues) +} + +// WorkflowStatusValues returns the supported workflow statuses. +func WorkflowStatusValues() []string { + return (WorkflowStatus("")).Values() +} + +// WorkflowConclusion is a GitHub Actions workflow-run conclusion. +type WorkflowConclusion string + +var workflowConclusionValues = []string{ + "success", + "failure", + "neutral", + "cancelled", + "skipped", + "timed_out", + "action_required", + "stale", + "startup_failure", +} + +// Values returns the supported workflow conclusions. +func (WorkflowConclusion) Values() []string { + return slices.Clone(workflowConclusionValues) +} + +// WorkflowConclusionValues returns the supported workflow conclusions. +func WorkflowConclusionValues() []string { + return (WorkflowConclusion("")).Values() +} diff --git a/pkg/http/handler_test.go b/pkg/http/handler_test.go index 9edf886af5..050e53dee7 100644 --- a/pkg/http/handler_test.go +++ b/pkg/http/handler_test.go @@ -1261,6 +1261,145 @@ func TestHTTPToolMinimumProtocolVersion(t *testing.T) { } } +func TestHTTPStatelessTypedOutputProtocolHeaders(t *testing.T) { + type output struct { + Values []string `json:"values"` + } + tool := inventory.NewServerToolWithContextHandler( + mcp.Tool{Name: "typed_http_tool"}, + inventory.ToolsetMetadata{ID: inventory.ToolsetID("test"), Description: "Test tool"}, + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, output, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "legacy text"}}, + }, output{Values: []string{"one", "two"}}, nil + }, + ) + inv, err := inventory.NewBuilder(). + SetTools([]inventory.ServerTool{tool}). + WithToolsets([]string{"all"}). + Build() + require.NoError(t, err) + + apiHost, err := utils.NewAPIHost("https://api.github.com") + require.NoError(t, err) + handler := NewHTTPMcpHandler( + context.Background(), + &ServerConfig{Version: "test"}, + nil, + translations.NullTranslationHelper, + slog.Default(), + apiHost, + WithInventoryFactory(func(*http.Request) (*inventory.Inventory, error) { + return inv, nil + }), + WithGitHubMCPServerFactory(func(_ *http.Request, _ github.ToolDependencies, inv *inventory.Inventory, _ *github.MCPServerConfig) (*mcp.Server, error) { + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil) + inv.RegisterTools(context.Background(), server, nil) + return server, nil + }), + WithScopeFetcher(allScopesFetcher{}), + ) + + for _, tc := range []struct { + name string + headerVersion string + metaVersion string + wantModern bool + }{ + {name: "modern header and metadata", headerVersion: inventory.ProtocolVersionMultiRoundTrip, metaVersion: inventory.ProtocolVersionMultiRoundTrip, wantModern: true}, + {name: "legacy header and metadata", headerVersion: "2025-11-25", metaVersion: "2025-11-25"}, + {name: "absent protocol version"}, + } { + t.Run(tc.name, func(t *testing.T) { + post := func(method string, id int) map[string]json.RawMessage { + t.Helper() + params := map[string]any{"name": "typed_http_tool", "arguments": map[string]any{}} + if tc.metaVersion != "" { + params["_meta"] = map[string]any{ + mcp.MetaKeyProtocolVersion: tc.metaVersion, + mcp.MetaKeyClientInfo: map[string]any{"name": "test", "version": "1.0.0"}, + mcp.MetaKeyClientCapabilities: map[string]any{}, + } + } + requestBody, err := json.Marshal(map[string]any{ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + }) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(string(requestBody))) + req.Header.Set(headers.ContentTypeHeader, headers.ContentTypeJSON) + req.Header.Set(headers.AcceptHeader, strings.Join([]string{headers.ContentTypeJSON, headers.ContentTypeEventStream}, ", ")) + req.Header.Set(headers.MCPMethodHeader, method) + if method == "tools/call" { + req.Header.Set(headers.MCPNameHeader, "typed_http_tool") + } + if tc.headerVersion != "" { + req.Header.Set(headers.MCPProtocolVersionHeader, tc.headerVersion) + } + + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, req) + require.Equal(t, http.StatusOK, recorder.Code, "response body: %s", recorder.Body.String()) + + responseBody := recorder.Body.String() + for line := range strings.SplitSeq(responseBody, "\n") { + if data, ok := strings.CutPrefix(line, "data: "); ok { + responseBody = data + break + } + } + var response struct { + Result json.RawMessage `json:"result"` + Error json.RawMessage `json:"error"` + } + require.NoError(t, json.Unmarshal([]byte(responseBody), &response), "response body: %s", responseBody) + require.Empty(t, response.Error, "JSON-RPC error: %s", response.Error) + + var result map[string]json.RawMessage + require.NoError(t, json.Unmarshal(response.Result, &result)) + return result + } + + listResult := post("tools/list", 1) + var listed []struct { + Name string `json:"name"` + OutputSchema json.RawMessage `json:"outputSchema"` + } + require.NoError(t, json.Unmarshal(listResult["tools"], &listed)) + require.Len(t, listed, 1) + require.Equal(t, "typed_http_tool", listed[0].Name) + if tc.wantModern { + assert.JSONEq(t, `{"type":"object","properties":{"values":{"type":["null","array"],"items":{"type":"string"}}},"required":["values"],"additionalProperties":false}`, string(listed[0].OutputSchema)) + } else { + assert.Empty(t, listed[0].OutputSchema, "legacy and unknown protocol versions must not advertise output schemas") + } + + callResult := post("tools/call", 2) + var called struct { + Content []struct { + Type string `json:"type"` + Text string `json:"text"` + } `json:"content"` + StructuredContent json.RawMessage `json:"structuredContent"` + } + callResultJSON, err := json.Marshal(callResult) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(callResultJSON, &called)) + require.Len(t, called.Content, 1, "SDK fallback serialization must not duplicate the existing text") + assert.Equal(t, "text", called.Content[0].Type) + if tc.wantModern { + assert.JSONEq(t, `{"values":["one","two"]}`, string(called.StructuredContent)) + assert.Equal(t, `{"values":["one","two"]}`, called.Content[0].Text) + } else { + assert.Equal(t, "legacy text", called.Content[0].Text) + assert.Empty(t, called.StructuredContent, "legacy and unknown protocol versions must not expose structured content") + } + }) + } +} + func TestSubscriptionsListenIsRejected(t *testing.T) { apiHost, err := utils.NewAPIHost("https://api.githubcopilot.com") require.NoError(t, err) diff --git a/pkg/http/headers/headers.go b/pkg/http/headers/headers.go index 94981081a7..732dcb889c 100644 --- a/pkg/http/headers/headers.go +++ b/pkg/http/headers/headers.go @@ -39,6 +39,8 @@ const ( // MCP-specific headers. + // MCPProtocolVersionHeader carries the protocol version for HTTP MCP requests. + MCPProtocolVersionHeader = "MCP-Protocol-Version" // MCPMethodHeader mirrors the JSON-RPC method for request routing. MCPMethodHeader = "Mcp-Method" // MCPNameHeader identifies the requested MCP primitive. diff --git a/pkg/http/middleware/mcp_parse.go b/pkg/http/middleware/mcp_parse.go index bcc44f01f5..0a91de6bf1 100644 --- a/pkg/http/middleware/mcp_parse.go +++ b/pkg/http/middleware/mcp_parse.go @@ -7,6 +7,7 @@ import ( "net/http" ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/github/github-mcp-server/pkg/http/headers" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -87,6 +88,9 @@ func WithMCPParse() func(http.Handler) http.Handler { next.ServeHTTP(w, r) return } + if methodInfo.ProtocolVersion == "" { + methodInfo.ProtocolVersion = r.Header.Get(headers.MCPProtocolVersionHeader) + } // Store the parsed info in context ctx = ghcontext.WithMCPMethodInfo(ctx, methodInfo) diff --git a/pkg/http/middleware/mcp_parse_test.go b/pkg/http/middleware/mcp_parse_test.go index 8034dc29a6..c8de0d1173 100644 --- a/pkg/http/middleware/mcp_parse_test.go +++ b/pkg/http/middleware/mcp_parse_test.go @@ -20,6 +20,7 @@ func TestWithMCPParse(t *testing.T) { method string path string body string + headerVersion string expectInfo bool expectedMethod string expectedItem string @@ -92,6 +93,16 @@ func TestWithMCPParse(t *testing.T) { expectedProtocol: "2026-07-28", expectedForm: true, }, + { + name: "tools/list falls back to protocol version header", + method: http.MethodPost, + path: "/mcp", + body: `{"jsonrpc":"2.0","method":"tools/list"}`, + headerVersion: "2026-07-28", + expectInfo: true, + expectedMethod: "tools/list", + expectedProtocol: "2026-07-28", + }, { name: "tools/call parses name", method: http.MethodPost, @@ -165,6 +176,9 @@ func TestWithMCPParse(t *testing.T) { handler := middleware(nextHandler) req := httptest.NewRequest(tt.method, tt.path, strings.NewReader(tt.body)) + if tt.headerVersion != "" { + req.Header.Set("MCP-Protocol-Version", tt.headerVersion) + } rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) diff --git a/pkg/http/middleware/scope_challenge.go b/pkg/http/middleware/scope_challenge.go index 7e4bee6dc5..d99dfba35d 100644 --- a/pkg/http/middleware/scope_challenge.go +++ b/pkg/http/middleware/scope_challenge.go @@ -2,6 +2,7 @@ package middleware import ( "bytes" + "encoding/json" "fmt" "io" "net/http" @@ -102,8 +103,27 @@ func WithScopeChallenge(oauthCfg *oauth.Config, scopeFetcher scopes.FetcherInter return } - arguments, err := methodInfo.DecodeArguments() - if err != nil { + rawArguments := methodInfo.RawArguments + if len(rawArguments) == 0 { + rawArguments = []byte(`{}`) + } + argumentsJSON, didNormalize, normalizeErr := scopeAccess.NormalizeArguments(rawArguments) + if normalizeErr != nil { + // Preserve normal tool validation for arguments the normalizer rejects. + next.ServeHTTP(w, r) + return + } + if !didNormalize { + argumentsJSON = rawArguments + } else { + normalizedInfo := *methodInfo + normalizedInfo.NormalizedArguments = argumentsJSON + normalizedInfo.ArgumentsNormalized = true + ctx = ghcontext.WithMCPMethodInfo(ctx, &normalizedInfo) + r = r.WithContext(ctx) + } + arguments := make(map[string]any) + if err := json.Unmarshal(argumentsJSON, &arguments); err != nil { // Preserve normal MCP handler validation for invalid arguments. next.ServeHTTP(w, r) return diff --git a/pkg/http/middleware/scope_challenge_test.go b/pkg/http/middleware/scope_challenge_test.go index 2beeecf49d..6fe6dfb685 100644 --- a/pkg/http/middleware/scope_challenge_test.go +++ b/pkg/http/middleware/scope_challenge_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" @@ -153,6 +154,68 @@ func TestWithScopeChallengeResolvesScopesFromParsedArguments(t *testing.T) { } } +func TestWithScopeChallengeNormalizesArgumentsBeforeDynamicPolicy(t *testing.T) { + normalizerCalls := 0 + var challengedArguments map[string]any + setScopeTestMap(t, inventory.ScopeAccess{ + Scopes: []string{"repo", "workflow"}, + Dynamic: true, + ArgumentNormalizer: func(raw json.RawMessage) (json.RawMessage, error) { + normalizerCalls++ + var arguments map[string]json.RawMessage + if err := json.Unmarshal(raw, &arguments); err != nil { + return nil, err + } + arguments["state"] = json.RawMessage(`"OPEN"`) + return json.Marshal(arguments) + }, + Challenge: func(arguments map[string]any, _ []string) []string { + challengedArguments = arguments + if arguments["state"] == "OPEN" { + return nil + } + return []string{"workflow"} + }, + }) + + for _, parsed := range []bool{true, false} { + t.Run(fmt.Sprintf("parsed=%t", parsed), func(t *testing.T) { + normalizerCalls = 0 + nextCalled := false + var normalizedInfo *ghcontext.MCPMethodInfo + next := http.HandlerFunc(func(_ http.ResponseWriter, request *http.Request) { + nextCalled = true + normalizedInfo, _ = ghcontext.MCPMethod(request.Context()) + }) + request := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader( + `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"write_file","arguments":{"state":"open"}}}`, + )) + ctx := scopeChallengeContext(request.Context()) + methodInfo := &ghcontext.MCPMethodInfo{ + Method: "tools/call", + ItemName: "write_file", + RawArguments: json.RawMessage(`{"state":"open"}`), + } + if parsed { + ctx = ghcontext.WithMCPMethodInfo(ctx, methodInfo) + } + request = request.WithContext(ctx) + + response := httptest.NewRecorder() + WithScopeChallenge(&oauth.Config{}, &mockScopeFetcher{})(next).ServeHTTP(response, request) + + require.Equal(t, http.StatusOK, response.Code) + require.True(t, nextCalled) + assert.Equal(t, 1, normalizerCalls) + assert.Equal(t, "OPEN", challengedArguments["state"]) + require.NotNil(t, normalizedInfo) + assert.True(t, normalizedInfo.ArgumentsNormalized) + assert.JSONEq(t, `{"state":"OPEN"}`, string(normalizedInfo.NormalizedArguments)) + assert.False(t, methodInfo.ArgumentsNormalized, "normalization must not mutate the original parsed metadata") + }) + } +} + func TestWithScopeChallengeResolvesScopesFromFallbackBody(t *testing.T) { setDynamicScopeTestMap(t) diff --git a/pkg/inventory/encoded_schemas.go b/pkg/inventory/encoded_schemas.go index 12c7961ef0..16a13df96e 100644 --- a/pkg/inventory/encoded_schemas.go +++ b/pkg/inventory/encoded_schemas.go @@ -11,8 +11,9 @@ import ( ) type listedToolSchemas struct { - source any - registered *mcp.Tool + source any + outputSource any + registered *mcp.Tool } type encodedSchemaKey struct { @@ -76,7 +77,11 @@ func encodedToolSchemasMiddleware(schemas map[string]listedToolSchemas) mcp.Midd if err != nil { return nil, fmt.Errorf("encode input schema for %q: %w", tool.Name, err) } - toolCopy.OutputSchema, err = encodeListedSchema(definition.registered.OutputSchema, definition.registered.OutputSchema, tool.OutputSchema, false) + outputSource := definition.outputSource + if outputSource == nil { + outputSource = definition.registered.OutputSchema + } + toolCopy.OutputSchema, err = encodeListedSchema(outputSource, definition.registered.OutputSchema, tool.OutputSchema, false) if err != nil { return nil, fmt.Errorf("encode output schema for %q: %w", tool.Name, err) } diff --git a/pkg/inventory/input_error.go b/pkg/inventory/input_error.go new file mode 100644 index 0000000000..29fdd5b6b5 --- /dev/null +++ b/pkg/inventory/input_error.go @@ -0,0 +1,12 @@ +package inventory + +// ToolInputError represents an argument validation error whose message should +// be shown to the tool caller without additional wrapper text. +type ToolInputError struct { + Message string +} + +// Error implements error. +func (err *ToolInputError) Error() string { + return err.Message +} diff --git a/pkg/inventory/registry.go b/pkg/inventory/registry.go index c613664936..2bc87e9bb8 100644 --- a/pkg/inventory/registry.go +++ b/pkg/inventory/registry.go @@ -244,14 +244,44 @@ func shouldStripMCPAppsMetadata(ctx context.Context) bool { // Schema definitions must remain immutable and be reused across registrations. // Their encodings are retained process-wide, keyed by source schema identity. func (r *Inventory) RegisterTools(ctx context.Context, s *mcp.Server, deps any, middleware ...ToolHandlerMiddleware) { + r.registerTools(ctx, s, deps, ProtocolEraDynamic, middleware...) +} + +// RegisterToolsForProtocolEra registers a preselected protocol-compatible +// variant. Remote stateless servers should use this to select schemas before +// registering their request-scoped server. +func (r *Inventory) RegisterToolsForProtocolEra(ctx context.Context, s *mcp.Server, deps any, era ProtocolEra, middleware ...ToolHandlerMiddleware) { + r.registerTools(ctx, s, deps, era, middleware...) +} + +func (r *Inventory) registerTools(ctx context.Context, s *mcp.Server, deps any, era ProtocolEra, middleware ...ToolHandlerMiddleware) { tools := r.ToolsForRegistration(ctx) addToolAvailabilityMiddleware(s, tools) schemas := make(map[string]listedToolSchemas, len(tools)) + registrations := make(map[string]*typedToolRegistration, len(tools)) for _, tool := range tools { - registered := tool.register(s, deps, middleware...) + toolCopy := tool.Tool + if len(toolCopy.Icons) == 0 { + toolCopy.Icons = tool.Toolset.Icons() + } + AnnotateHeaderParams(&toolCopy) + registration := tool.typedRegistration(&toolCopy) + registration.fixedEra = era + registrations[tool.Tool.Name] = registration + } + if len(tools) > 0 { + s.AddReceivingMiddleware(typedOutputMiddleware(&typedToolRegistrationSet{byName: registrations})) + } + for _, tool := range tools { + registered := tool.register(s, deps, era, registrations[tool.Tool.Name], middleware...) + sourceSchema := tool.Tool.InputSchema + if sourceSchema == nil { + sourceSchema = registered.InputSchema + } schemas[registered.Name] = listedToolSchemas{ - source: tool.Tool.InputSchema, - registered: registered, + source: sourceSchema, + outputSource: tool.Tool.OutputSchema, + registered: registered, } } s.AddReceivingMiddleware(encodedToolSchemasMiddleware(schemas)) diff --git a/pkg/inventory/server_tool.go b/pkg/inventory/server_tool.go index ec8ee44240..3546c250b9 100644 --- a/pkg/inventory/server_tool.go +++ b/pkg/inventory/server_tool.go @@ -4,8 +4,10 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "maps" + "reflect" "slices" "github.com/github/github-mcp-server/pkg/octicons" @@ -45,12 +47,55 @@ type ScopeAccess struct { // Dynamic reports whether Challenge depends on tool arguments. Dynamic // policies must declare their exhaustive upper bound in Scopes. Dynamic bool + + // ArgumentNormalizer canonicalizes raw arguments before evaluating a + // dynamic scope challenge. Inventory populates this from the tool's + // InputNormalizer when building the HTTP scope map. + ArgumentNormalizer InputNormalizer +} + +func (r *typedToolRegistration) runtimeTool(era ProtocolEra) *mcp.Tool { + if era == ProtocolEraLegacy { + return r.legacyRuntimeTool + } + return r.modernRuntimeTool } // ToolHandlerMiddleware wraps an MCP tool handler. Middleware is applied from // right to left, so the first middleware passed to RegisterFunc executes first. type ToolHandlerMiddleware func(next mcp.ToolHandler) mcp.ToolHandler +// ToolHandlerMiddlewareProvider creates tool-specific middleware using the +// dependencies supplied when the tool is registered. +type ToolHandlerMiddlewareProvider func(deps any) ToolHandlerMiddleware + +// InputNormalizer transforms raw tool arguments before the SDK validates and +// decodes them. Use it only for compatibility normalization; the registered +// input schema remains the contract clients see and the SDK validates. +type InputNormalizer func(json.RawMessage) (json.RawMessage, error) + +// ToolCallPreflight inspects raw call arguments before SDK schema validation. +// Return nil, nil to continue; a result or error short-circuits the call. +// Returning a non-nil context passes request-scoped preparation to the handler. +type ToolCallPreflight func(context.Context, *mcp.CallToolRequest) (context.Context, *mcp.CallToolResult, error) + +// TypedSchemaOptions customizes schemas inferred for a typed tool. +// +// Input and Output are passed to jsonschema.For when the corresponding schema +// is not provided on the tool. ValidationInputSchema, when set, is used by the +// SDK to validate calls while the tool's declared InputSchema remains visible +// to clients. InputEnums and OutputEnums are applied to inferred schemas at +// registration time. +type TypedSchemaOptions struct { + Input *jsonschema.ForOptions + Output *jsonschema.ForOptions + ValidationInputSchema *jsonschema.Schema + InputEnums []SchemaEnum + OutputEnums []SchemaEnum + Preflight ToolCallPreflight + PreserveHandlerContent bool +} + // ToolsetID is a unique identifier for a toolset. // Using a distinct type provides compile-time type safety. type ToolsetID string @@ -116,6 +161,12 @@ type ServerTool struct { // ScopeAccess controls fixed-token visibility and per-call OAuth challenges. ScopeAccess ScopeAccess + + registerTyped func(*mcp.Server, *typedToolRegistration, ProtocolEra, ...ToolHandlerMiddleware) + prepareTyped func(*mcp.Tool) *typedToolRegistration + handlerMiddlewareProviders []ToolHandlerMiddlewareProvider + inputNormalizer InputNormalizer + schemaOptions TypedSchemaOptions } // IsReadOnly returns true if this tool is marked as read-only via annotations. @@ -128,13 +179,35 @@ func (st *ServerTool) HasHandler() bool { return st.HandlerFunc != nil } +// GetInputNormalizer returns the compatibility normalizer configured for this +// tool, if any. +func (st *ServerTool) GetInputNormalizer() InputNormalizer { + return st.inputNormalizer +} + // Handler returns a tool handler by calling HandlerFunc with the given dependencies. // Panics if HandlerFunc is nil - all tools should have handlers. func (st *ServerTool) Handler(deps any) mcp.ToolHandler { if st.HandlerFunc == nil { panic("HandlerFunc is nil for tool: " + st.Tool.Name) } - return st.HandlerFunc(deps) + return applyToolHandlerMiddleware(st.HandlerFunc(deps), st.handlerMiddlewares(deps)...) +} + +// AddHandlerMiddleware adds dependency-aware middleware used on both typed and +// raw registration paths. +func (st *ServerTool) AddHandlerMiddleware(provider ToolHandlerMiddlewareProvider) { + if provider != nil { + st.handlerMiddlewareProviders = append(st.handlerMiddlewareProviders, provider) + } +} + +func (st *ServerTool) handlerMiddlewares(deps any) []ToolHandlerMiddleware { + middleware := make([]ToolHandlerMiddleware, 0, len(st.handlerMiddlewareProviders)) + for _, provider := range st.handlerMiddlewareProviders { + middleware = append(middleware, provider(deps)) + } + return middleware } // RegisterFunc registers the tool with the server using the provided dependencies. @@ -142,27 +215,62 @@ func (st *ServerTool) Handler(deps any) mcp.ToolHandler { // A shallow copy of the tool is made to avoid mutating the original ServerTool. // Panics if the tool has no handler - all tools should have handlers. func (st *ServerTool) RegisterFunc(s *mcp.Server, deps any, middleware ...ToolHandlerMiddleware) { - st.register(s, deps, middleware...) + st.RegisterFuncForProtocolEra(s, deps, ProtocolEraDynamic, middleware...) +} + +// RegisterFuncForProtocolEra registers a preselected legacy or modern tool +// variant. Use this when the protocol era is known before server construction. +func (st *ServerTool) RegisterFuncForProtocolEra(s *mcp.Server, deps any, era ProtocolEra, middleware ...ToolHandlerMiddleware) { + st.registerFunc(s, deps, true, era, nil, middleware...) +} + +func (st *ServerTool) register(s *mcp.Server, deps any, era ProtocolEra, registration *typedToolRegistration, middleware ...ToolHandlerMiddleware) *mcp.Tool { + return st.registerFunc(s, deps, false, era, registration, middleware...) } -func (st *ServerTool) register(s *mcp.Server, deps any, middleware ...ToolHandlerMiddleware) *mcp.Tool { - handler := st.Handler(deps) // This will panic if HandlerFunc is nil - for _, m := range slices.Backward(middleware) { - handler = m(handler) +func (st *ServerTool) registerFunc(s *mcp.Server, deps any, addTypedOutputMiddleware bool, era ProtocolEra, registration *typedToolRegistration, middleware ...ToolHandlerMiddleware) *mcp.Tool { + if st.HandlerFunc == nil { + panic("HandlerFunc is nil for tool: " + st.Tool.Name) } - handler = st.wrapAvailabilityCheck(handler) // Make a shallow copy of the tool to avoid mutating the original toolCopy := st.Tool - // Apply icons from toolset metadata if tool doesn't have icons set - if len(toolCopy.Icons) == 0 { - toolCopy.Icons = st.Toolset.Icons() + if registration == nil { + // Apply icons from toolset metadata if tool doesn't have icons set + if len(toolCopy.Icons) == 0 { + toolCopy.Icons = st.Toolset.Icons() + } + // Project owner/repo routing params to standard MCP-Param-* headers (SEP-2243). + AnnotateHeaderParams(&toolCopy) + registration = st.typedRegistration(&toolCopy) + } + registration.fixedEra = era + if addTypedOutputMiddleware { + s.AddReceivingMiddleware(typedOutputMiddleware(&typedToolRegistrationSet{ + byName: map[string]*typedToolRegistration{st.Tool.Name: registration}, + })) + } + allMiddleware := make([]ToolHandlerMiddleware, 0, len(middleware)+len(st.handlerMiddlewareProviders)+1) + allMiddleware = append(allMiddleware, func(next mcp.ToolHandler) mcp.ToolHandler { + return st.wrapAvailabilityCheck(next) + }) + allMiddleware = append(allMiddleware, middleware...) + allMiddleware = append(allMiddleware, st.handlerMiddlewares(deps)...) + if st.registerTyped != nil { + registration.handlerMiddleware = allMiddleware + st.registerTyped(s, registration, era) + return registration.runtimeTool(era) } - // Project owner/repo routing params to standard MCP-Param-* headers (SEP-2243) - // so a remote proxy can route requests without re-parsing the JSON-RPC body. - // No-op for tools without these params. - AnnotateHeaderParams(&toolCopy) - s.AddTool(&toolCopy, handler) - return &toolCopy + + handler := applyToolHandlerMiddleware(st.HandlerFunc(deps), allMiddleware...) + s.AddTool(registration.runtimeTool(era), handler) + return registration.runtimeTool(era) +} + +func applyToolHandlerMiddleware(handler mcp.ToolHandler, middleware ...ToolHandlerMiddleware) mcp.ToolHandler { + for i := range slices.Backward(middleware) { + handler = middleware[i](handler) + } + return handler } // HeaderParams maps owner/repo input properties to the MCP-Param-* headers a @@ -183,6 +291,26 @@ func AnnotateHeaderParams(tool *mcp.Tool) { if !ok || schema == nil { return } + if _, owned := ownedSchemaPointers.Load(schema); owned { + entryValue, found := annotatedSchemaCache.Load(schema) + if !found { + entryValue, _ = annotatedSchemaCache.LoadOrStore(schema, &inferredSchemaEntry{}) + } + entry := entryValue.(*inferredSchemaEntry) + entry.once.Do(func() { + annotatedTool := mcp.Tool{InputSchema: schema} + annotateHeaderParams(&annotatedTool) + entry.schema = annotatedTool.InputSchema.(*jsonschema.Schema) + ownedSchemaPointers.Store(entry.schema, struct{}{}) + }) + tool.InputSchema = entry.schema + return + } + annotateHeaderParams(tool) +} + +func annotateHeaderParams(tool *mcp.Tool) { + schema := tool.InputSchema.(*jsonschema.Schema) // Collect params that actually need an annotation, so a tool without // owner/repo (or already annotated) is left untouched and unCloned. @@ -219,19 +347,66 @@ func AnnotateHeaderParams(tool *mcp.Tool) { // 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. // +// When Out is concrete, registration uses mcp.AddTool so the SDK infers missing +// schemas and validates typed input and output. Out=any retains the raw handler +// registration path. Optional InputNormalizer callbacks can canonicalize raw +// JSON before SDK validation without weakening the advertised schema. Direct +// Handler calls apply the same normalization before decoding. +// // The handler function is stored directly without wrapping in a deps closure. // Dependencies should be injected into context before calling tool handlers. -func NewServerToolWithContextHandler[In any, Out any](tool mcp.Tool, toolset ToolsetMetadata, handler mcp.ToolHandlerFor[In, Out]) ServerTool { - return ServerTool{ - Tool: tool, - Toolset: toolset, +func NewServerToolWithContextHandler[In any, Out any](tool mcp.Tool, toolset ToolsetMetadata, handler mcp.ToolHandlerFor[In, Out], inputNormalizers ...InputNormalizer) ServerTool { + return NewServerToolWithContextHandlerAndSchemaOptions( + tool, + toolset, + handler, + TypedSchemaOptions{}, + inputNormalizers..., + ) +} + +// NewServerToolWithContextHandlerAndSchemaOptions is like +// NewServerToolWithContextHandler, with additional schema inference options. +// Inferred input and output schemas are cached per ServerTool. +func NewServerToolWithContextHandlerAndSchemaOptions[In any, Out any]( + tool mcp.Tool, + toolset ToolsetMetadata, + handler mcp.ToolHandlerFor[In, Out], + schemaOptions TypedSchemaOptions, + inputNormalizers ...InputNormalizer, +) ServerTool { + schemaOptions = cloneTypedSchemaOptions(schemaOptions) + cacheExplicitToolSchemas(&tool) + inputNormalizer := combineInputNormalizers(inputNormalizers) + serverTool := ServerTool{ + Tool: tool, + Toolset: toolset, + inputNormalizer: inputNormalizer, + schemaOptions: schemaOptions, // HandlerFunc ignores deps - deps are retrieved from context at call time HandlerFunc: func(_ any) mcp.ToolHandler { return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + if schemaOptions.Preflight != nil && !preflightAlreadyRun(ctx, req.Params.Name) { + preflightContext, result, err := schemaOptions.Preflight(ctx, req) + if result != nil || err != nil { + return result, err + } + if preflightContext != nil { + ctx = preflightContext + } + ctx = markPreflightRun(ctx, req.Params.Name) + } rawArguments := req.Params.Arguments if len(rawArguments) == 0 { rawArguments = json.RawMessage(`{}`) } + if inputNormalizer != nil && !inputAlreadyNormalized(ctx, req.Params.Name) { + normalized, err := inputNormalizer(rawArguments) + if err != nil { + return invalidArgumentsResult(fmt.Errorf("normalize tool arguments: %w", err)), nil + } + rawArguments = normalized + } if bytes.Equal(bytes.TrimSpace(rawArguments), []byte("null")) { return invalidArgumentsResult(fmt.Errorf("arguments must be a JSON object")), nil @@ -246,9 +421,170 @@ func NewServerToolWithContextHandler[In any, Out any](tool mcp.Tool, toolset Too } }, } + if reflect.TypeFor[Out]() != reflect.TypeFor[any]() { + serverTool.prepareTyped = func(tool *mcp.Tool) *typedToolRegistration { + modernTool := *tool + cacheExplicitToolSchemas(&modernTool) + if modernTool.InputSchema == nil && reflect.TypeFor[In]() != reflect.TypeFor[any]() { + inferred, err := CachedInputSchemaFor[In](schemaOptions.Input, schemaOptions.InputEnums...) + if err != nil { + panic(fmt.Sprintf("failed to generate input schema for tool %q: %v", tool.Name, err)) + } + modernTool.InputSchema = inferred + } else if modernTool.InputSchema == nil { + inferred, err := cachedObjectInputSchema() + if err != nil { + panic(fmt.Sprintf("failed to prepare default input schema for tool %q: %v", tool.Name, err)) + } + modernTool.InputSchema = inferred + } + if modernTool.OutputSchema == nil { + inferred, err := CachedSchemaFor[Out](schemaOptions.Output, schemaOptions.OutputEnums...) + if err != nil { + panic(fmt.Sprintf("failed to generate output schema for tool %q: %v", tool.Name, err)) + } + modernTool.OutputSchema = inferred + } + legacyTool := modernTool + legacyTool.OutputSchema = nil + modernRuntimeTool := modernTool + legacyRuntimeTool := legacyTool + if schemaOptions.ValidationInputSchema != nil { + modernRuntimeTool.InputSchema = schemaOptions.ValidationInputSchema + legacyRuntimeTool.InputSchema = schemaOptions.ValidationInputSchema + } + return &typedToolRegistration{ + name: tool.Name, + modernTool: &modernTool, + legacyTool: &legacyTool, + modernRuntimeTool: &modernRuntimeTool, + legacyRuntimeTool: &legacyRuntimeTool, + hasTypedOutput: true, + inputNormalizer: inputNormalizer, + preflight: schemaOptions.Preflight, + preserveContent: schemaOptions.PreserveHandlerContent, + } + } + serverTool.registerTyped = func(server *mcp.Server, registration *typedToolRegistration, era ProtocolEra, middleware ...ToolHandlerMiddleware) { + switch era { + case ProtocolEraLegacy: + mcp.AddTool[In, any](server, registration.legacyRuntimeTool, wrapTypedHandler(handler, middleware...)) + default: + mcp.AddTool[In, any](server, registration.modernRuntimeTool, wrapTypedHandler(handler, middleware...)) + } + } + } + return serverTool +} + +func (st *ServerTool) typedRegistration(tool *mcp.Tool) *typedToolRegistration { + if st.prepareTyped != nil { + return st.prepareTyped(tool) + } + modernTool := *tool + cacheExplicitToolSchemas(&modernTool) + legacyTool := modernTool + return &typedToolRegistration{ + name: tool.Name, + modernTool: &modernTool, + legacyTool: &legacyTool, + modernRuntimeTool: &modernTool, + legacyRuntimeTool: &legacyTool, + inputNormalizer: st.inputNormalizer, + } +} + +func cacheExplicitToolSchemas(tool *mcp.Tool) { + if schema, ok := tool.InputSchema.(*jsonschema.Schema); ok { + cached, err := CachedSchema(schema) + if err != nil { + panic(fmt.Sprintf("failed to cache input schema for tool %q: %v", tool.Name, err)) + } + tool.InputSchema = cached + } + if schema, ok := tool.OutputSchema.(*jsonschema.Schema); ok { + cached, err := CachedSchema(schema) + if err != nil { + panic(fmt.Sprintf("failed to cache output schema for tool %q: %v", tool.Name, err)) + } + tool.OutputSchema = cached + } +} + +func cloneTypedSchemaOptions(options TypedSchemaOptions) TypedSchemaOptions { + options.Input = cloneJSONSchemaForOptions(options.Input) + options.Output = cloneJSONSchemaForOptions(options.Output) + if options.ValidationInputSchema != nil { + cached, err := CachedSchema(options.ValidationInputSchema) + if err != nil { + panic(fmt.Sprintf("failed to cache validation input schema: %v", err)) + } + options.ValidationInputSchema = cached + } + options.InputEnums = slices.Clone(options.InputEnums) + for i := range options.InputEnums { + options.InputEnums[i].Values = slices.Clone(options.InputEnums[i].Values) + } + options.OutputEnums = slices.Clone(options.OutputEnums) + for i := range options.OutputEnums { + options.OutputEnums[i].Values = slices.Clone(options.OutputEnums[i].Values) + } + return options +} + +func cloneJSONSchemaForOptions(options *jsonschema.ForOptions) *jsonschema.ForOptions { + if options == nil { + return nil + } + optionsCopy := *options + if options.TypeSchemas != nil { + optionsCopy.TypeSchemas = make(map[reflect.Type]*jsonschema.Schema, len(options.TypeSchemas)) + for typ, schema := range options.TypeSchemas { + if schema != nil { + optionsCopy.TypeSchemas[typ] = CloneSchema(schema) + } else { + optionsCopy.TypeSchemas[typ] = nil + } + } + } + return &optionsCopy +} + +func combineInputNormalizers(normalizers []InputNormalizer) InputNormalizer { + var active []InputNormalizer + for _, normalizer := range normalizers { + if normalizer != nil { + active = append(active, normalizer) + } + } + switch len(active) { + case 0: + return nil + case 1: + return active[0] + default: + return func(arguments json.RawMessage) (json.RawMessage, error) { + for _, normalizer := range active { + normalized, err := normalizer(arguments) + if err != nil { + return nil, err + } + arguments = normalized + } + return arguments, nil + } + } } func invalidArgumentsResult(err error) *mcp.CallToolResult { + if inputError, ok := errors.AsType[*ToolInputError](err); ok { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{Text: inputError.Message}, + }, + IsError: true, + } + } return &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{Text: fmt.Sprintf("invalid arguments: %s", err)}, @@ -265,6 +601,7 @@ func invalidArgumentsResult(err error) *mcp.CallToolResult { // The handler function is stored directly without wrapping in a deps closure. // Dependencies should be injected into context before calling tool handlers. func NewServerTool(tool mcp.Tool, toolset ToolsetMetadata, handler mcp.ToolHandler) ServerTool { + cacheExplicitToolSchemas(&tool) return ServerTool{ Tool: tool, Toolset: toolset, diff --git a/pkg/inventory/typed_output.go b/pkg/inventory/typed_output.go new file mode 100644 index 0000000000..159dd262a9 --- /dev/null +++ b/pkg/inventory/typed_output.go @@ -0,0 +1,392 @@ +package inventory + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + + ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type inputNormalizationContextKey struct{} +type preflightCompleteKey struct{} +type typedCallStateKey struct{} +type typedRegistrationSetContextKey struct{} + +type typedToolRegistrationSet struct { + byName map[string]*typedToolRegistration +} + +type typedCallState struct { + toolName string + handlerCalled bool + hasOutput bool + contentLengthBeforeSDK int + preserveContent bool + structuredOutput any +} + +type typedToolRegistration struct { + name string + modernTool *mcp.Tool + legacyTool *mcp.Tool + modernRuntimeTool *mcp.Tool + legacyRuntimeTool *mcp.Tool + hasTypedOutput bool + inputNormalizer InputNormalizer + preflight ToolCallPreflight + fixedEra ProtocolEra + preserveContent bool + handlerMiddleware []ToolHandlerMiddleware +} + +func wrapTypedHandler[In, Out any](handler mcp.ToolHandlerFor[In, Out], middleware ...ToolHandlerMiddleware) mcp.ToolHandlerFor[In, any] { + return func(ctx context.Context, req *mcp.CallToolRequest, input In) (*mcp.CallToolResult, any, error) { + var output any + handlerCalled := false + next := func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + handlerCalled = true + result, typedOutput, err := handler(ctx, req, input) + if err == nil && (result == nil || (!result.IsError && result.InputRequests == nil)) { + output = typedOutput + } + return result, err + } + next = applyToolHandlerMiddleware(next, middleware...) + + result, err := next(ctx, req) + if err != nil { + if rpcErr, ok := errors.AsType[*jsonrpc.Error](err); ok { + return nil, output, rpcErr + } + return nil, output, err + } + if result == nil { + result = &mcp.CallToolResult{} + } + if state, ok := ctx.Value(typedCallStateKey{}).(*typedCallState); ok { + state.handlerCalled = handlerCalled + state.hasOutput = handlerCalled && !result.IsError && result.InputRequests == nil + state.contentLengthBeforeSDK = len(result.Content) + } + if result.IsError || result.InputRequests != nil || !handlerCalled { + output = nil + } + return result, output, nil + } +} + +// typedOutputMiddleware selects immutable advertised schema variants and +// handles request-era compatibility without mutating registered tools. +func typedOutputMiddleware(registrationSet *typedToolRegistrationSet) mcp.Middleware { + if registrationSet == nil { + registrationSet = &typedToolRegistrationSet{byName: map[string]*typedToolRegistration{}} + } + return func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) { + switch req := request.(type) { + case *mcp.ListToolsRequest: + result, err := next(ctx, method, request) + if err != nil { + return nil, err + } + list, ok := result.(*mcp.ListToolsResult) + if !ok { + return result, nil + } + var requestVersion string + if req.Params != nil { + requestVersion = requestMetaProtocolVersion(req.Params.Meta) + } + era := requestEra(ctx, requestVersion) + for i, tool := range list.Tools { + registration := registrationSet.byName[tool.Name] + if registration == nil { + continue + } + selectedEra := era + if registration.fixedEra != ProtocolEraDynamic { + selectedEra = registration.fixedEra + } + if selectedEra == ProtocolEraModern { + list.Tools[i] = registration.modernTool + } else { + list.Tools[i] = registration.legacyTool + } + } + return list, nil + case *mcp.CallToolRequest: + if req.Params == nil { + return next(ctx, method, request) + } + registration := registrationSet.byName[req.Params.Name] + if registration == nil { + return next(ctx, method, request) + } + ctx, ownsRequest := claimTypedRegistrationSet(ctx, registrationSet) + if !ownsRequest { + return next(ctx, method, request) + } + state := &typedCallState{toolName: req.Params.Name} + ctx = context.WithValue(ctx, typedCallStateKey{}, state) + requestVersion := requestMetaProtocolVersion(req.Params.Meta) + dispatch := func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + request = req + + if registration != nil && registration.preflight != nil { + preflightContext, preflightResult, preflightErr := registration.preflight(ctx, req) + if preflightErr != nil || preflightResult != nil { + if preflightErr != nil { + if rpcErr, ok := errors.AsType[*jsonrpc.Error](preflightErr); ok { + return nil, rpcErr + } + if preflightResult == nil { + preflightResult = &mcp.CallToolResult{} + } + preflightResult.SetError(preflightErr) + } + era := requestEra(ctx, requestVersion) + if registration.fixedEra != ProtocolEraDynamic { + era = registration.fixedEra + } + if era == ProtocolEraModern { + if err := validateExplicitStructuredOutput(outputSchema(registration), preflightResult); err != nil { + return nil, err + } + } + return preflightResult, nil + } + if preflightContext != nil { + ctx = preflightContext + } + ctx = context.WithValue(ctx, typedCallStateKey{}, state) + ctx = context.WithValue(ctx, preflightCompleteKey{}, req.Params.Name) + } + + era := requestEra(ctx, requestVersion) + if registration != nil && registration.fixedEra != ProtocolEraDynamic { + era = registration.fixedEra + } + if registration != nil && registration.inputNormalizer != nil && !inputAlreadyNormalized(ctx, req.Params.Name) { + arguments := req.Params.Arguments + if len(arguments) == 0 { + arguments = json.RawMessage(`{}`) + } + var normalized json.RawMessage + if methodInfo, ok := ghcontext.MCPMethod(ctx); ok && + methodInfo.ArgumentsNormalized && methodInfo.ItemName == req.Params.Name { + normalized = methodInfo.NormalizedArguments + } else { + var normalizeErr error + normalized, normalizeErr = registration.inputNormalizer(arguments) + if normalizeErr != nil { + return invalidArgumentsResult(fmt.Errorf("normalize tool arguments: %w", normalizeErr)), nil + } + } + requestCopy := *req + paramsCopy := *req.Params + paramsCopy.Arguments = normalized + requestCopy.Params = ¶msCopy + request = &requestCopy + ctx = context.WithValue(ctx, inputNormalizationContextKey{}, req.Params.Name) + } + + result, err := next(ctx, method, request) + if err != nil { + return nil, err + } + callResult, ok := result.(*mcp.CallToolResult) + if !ok { + return nil, fmt.Errorf("unexpected tools/call result type %T", result) + } + if registration != nil && registration.hasTypedOutput && state.hasOutput { + state.structuredOutput = callResult.StructuredContent + resultCopy := *callResult + if err := removeSDKOutputFallback(&resultCopy, state); err != nil { + return nil, err + } + callResult = &resultCopy + if era != ProtocolEraModern { + legacyResult := *callResult + legacyResult.StructuredContent = nil + callResult = &legacyResult + } + } else if registration != nil && registration.hasTypedOutput && !state.handlerCalled && era == ProtocolEraModern { + if err := validateExplicitStructuredOutput(outputSchema(registration), callResult); err != nil { + return nil, err + } + } + if registration.hasTypedOutput && era != ProtocolEraModern && callResult.StructuredContent != nil { + legacyResult := *callResult + legacyResult.StructuredContent = nil + callResult = &legacyResult + } + return callResult, nil + } + callResult, err := applyToolHandlerMiddleware(dispatch, registration.handlerMiddleware...)(ctx, req) + if err != nil { + return nil, err + } + era := requestEra(ctx, requestVersion) + if registration.fixedEra != ProtocolEraDynamic { + era = registration.fixedEra + } + if registration.hasTypedOutput && callResult != nil { + if era != ProtocolEraModern { + resultCopy := *callResult + resultCopy.StructuredContent = nil + callResult = &resultCopy + } else { + if state.hasOutput && state.preserveContent && !callResult.IsError { + resultCopy := *callResult + resultCopy.StructuredContent = state.structuredOutput + callResult = &resultCopy + } + if !state.handlerCalled { + if err := validateExplicitStructuredOutput(outputSchema(registration), callResult); err != nil { + return nil, err + } + } + if !registration.preserveContent && !state.preserveContent { + if err := applyModernStructuredText(callResult); err != nil { + return nil, err + } + } + } + } + return callResult, nil + default: + return next(ctx, method, request) + } + } + } +} + +func claimTypedRegistrationSet(ctx context.Context, registrationSet *typedToolRegistrationSet) (context.Context, bool) { + if active, ok := ctx.Value(typedRegistrationSetContextKey{}).(*typedToolRegistrationSet); ok { + return ctx, active == registrationSet + } + return context.WithValue(ctx, typedRegistrationSetContextKey{}, registrationSet), true +} + +func outputSchema(registration *typedToolRegistration) *jsonschema.Schema { + if registration == nil || registration.modernTool == nil { + return nil + } + if schema, ok := registration.modernTool.OutputSchema.(*jsonschema.Schema); ok { + return schema + } + if raw, ok := registration.modernTool.OutputSchema.(json.RawMessage); ok { + var schema jsonschema.Schema + if json.Unmarshal(raw, &schema) == nil { + return &schema + } + } + return nil +} + +func validateExplicitStructuredOutput(schema *jsonschema.Schema, result *mcp.CallToolResult) error { + if schema == nil || result == nil || result.IsError || result.InputRequests != nil || result.StructuredContent == nil { + return nil + } + encoded, err := json.Marshal(result.StructuredContent) + if err != nil { + return fmt.Errorf("marshal explicit structured tool output: %w", err) + } + var value any + if err := json.Unmarshal(encoded, &value); err != nil { + return fmt.Errorf("decode explicit structured tool output: %w", err) + } + resolved, err := schema.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true}) + if err != nil { + return fmt.Errorf("resolve tool output schema: %w", err) + } + if err := resolved.Validate(value); err != nil { + return fmt.Errorf("validating tool output: %w", err) + } + return nil +} + +func applyModernStructuredText(result *mcp.CallToolResult) error { + if result == nil || result.IsError || result.InputRequests != nil || result.StructuredContent == nil { + return nil + } + encoded, err := json.Marshal(result.StructuredContent) + if err != nil { + return fmt.Errorf("marshal structured tool output for text content: %w", err) + } + if len(result.Content) > 0 { + for _, content := range result.Content { + if _, ok := content.(*mcp.TextContent); !ok { + return nil + } + } + } + result.Content = []mcp.Content{&mcp.TextContent{Text: string(encoded)}} + return nil +} + +func removeSDKOutputFallback(result *mcp.CallToolResult, state *typedCallState) error { + if result.StructuredContent == nil || len(result.Content) <= state.contentLengthBeforeSDK { + return nil + } + encoded, err := json.Marshal(result.StructuredContent) + if err != nil { + return fmt.Errorf("marshal typed tool output while removing fallback content: %w", err) + } + removeMatchingLastTextContent(result, encoded) + return nil +} + +func removeMatchingLastTextContent(result *mcp.CallToolResult, encoded []byte) { + if len(result.Content) == 0 { + return + } + last, ok := result.Content[len(result.Content)-1].(*mcp.TextContent) + if !ok || !bytes.Equal(bytes.TrimSpace([]byte(last.Text)), bytes.TrimSpace(encoded)) { + return + } + result.Content = result.Content[:len(result.Content)-1] +} + +func requestEra(ctx context.Context, requestVersion string) ProtocolEra { + if requestVersion != "" { + return ProtocolEraForVersion(requestVersion) + } + if info, ok := ghcontext.MCPMethod(ctx); ok && info != nil { + return ProtocolEraForVersion(info.ProtocolVersion) + } + return ProtocolEraLegacy +} + +func requestMetaProtocolVersion(meta mcp.Meta) string { + version, _ := meta[mcp.MetaKeyProtocolVersion].(string) + return version +} + +func preflightAlreadyRun(ctx context.Context, toolName string) bool { + name, ok := ctx.Value(preflightCompleteKey{}).(string) + return ok && name == toolName +} + +func markPreflightRun(ctx context.Context, toolName string) context.Context { + return context.WithValue(ctx, preflightCompleteKey{}, toolName) +} + +func inputAlreadyNormalized(ctx context.Context, toolName string) bool { + name, ok := ctx.Value(inputNormalizationContextKey{}).(string) + return ok && name == toolName +} + +// PreserveToolHandlerContent marks a typed call's handler content as +// intentionally non-JSON (for example CSV or resource blocks). +func PreserveToolHandlerContent(ctx context.Context) { + if state, ok := ctx.Value(typedCallStateKey{}).(*typedCallState); ok { + state.preserveContent = true + } +} diff --git a/pkg/inventory/typed_output_test.go b/pkg/inventory/typed_output_test.go new file mode 100644 index 0000000000..135605c5b0 --- /dev/null +++ b/pkg/inventory/typed_output_test.go @@ -0,0 +1,1984 @@ +package inventory + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "reflect" + "slices" + "strconv" + "strings" + "sync" + "testing" + + ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type typedTestInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Query string `json:"query" jsonschema:"The search query"` +} + +type typedTestOutput struct { + Query string `json:"query"` +} + +func TestTypedToolRegistrationInfersSchemasAndValidates(t *testing.T) { + handlerCalls := 0 + tool := NewServerToolWithContextHandler( + mcp.Tool{Name: "typed_tool"}, + testToolsetMetadata("test"), + func(_ context.Context, _ *mcp.CallToolRequest, input typedTestInput) (*mcp.CallToolResult, typedTestOutput, error) { + handlerCalls++ + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "old text result"}}, + }, typedTestOutput{Query: input.Query}, nil + }, + ) + require.Nil(t, tool.Tool.InputSchema) + require.Nil(t, tool.Tool.OutputSchema) + + inv, err := NewBuilder().SetTools([]ServerTool{tool}).WithToolsets([]string{"all"}).Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + inv.RegisterTools(context.Background(), server, nil) + session := connectTypedTestClient(t, server, "") + + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + assert.NotNil(t, list.Tools[0].InputSchema) + assert.NotNil(t, list.Tools[0].OutputSchema) + var inferredSchema map[string]any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, list.Tools[0].InputSchema)), &inferredSchema)) + properties := inferredSchema["properties"].(map[string]any) + assert.Equal(t, "owner", properties["owner"].(map[string]any)["x-mcp-header"]) + assert.Equal(t, "repo", properties["repo"].(map[string]any)["x-mcp-header"]) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "typed_tool", + Arguments: map[string]any{"owner": "octo", "repo": "hello", "query": "is:open"}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Equal(t, 1, handlerCalls) + require.NotNil(t, result.StructuredContent) + assert.JSONEq(t, `{"query":"is:open"}`, mustMarshalJSON(t, result.StructuredContent)) + require.Len(t, result.Content, 1) + assert.JSONEq(t, `{"query":"is:open"}`, result.Content[0].(*mcp.TextContent).Text) + + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "typed_tool", + Arguments: map[string]any{"owner": "octo", "repo": "hello", "query": 42}, + }) + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Equal(t, 1, handlerCalls, "SDK validation must reject invalid arguments before calling the handler") +} + +func TestTypedToolLegacySchemaRetainsHeaderRoutingMetadata(t *testing.T) { + tool := NewServerToolWithContextHandler( + mcp.Tool{Name: "legacy_routing_tool"}, + testToolsetMetadata("test"), + func(_ context.Context, _ *mcp.CallToolRequest, input typedTestInput) (*mcp.CallToolResult, typedTestOutput, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "legacy text result"}}, + }, typedTestOutput{Query: input.Query}, nil + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + tool.RegisterFunc(server, nil) + session := connectTypedTestClient(t, server, "2025-11-25") + + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + assert.Nil(t, list.Tools[0].OutputSchema) + var inputSchema map[string]any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, list.Tools[0].InputSchema)), &inputSchema)) + properties := inputSchema["properties"].(map[string]any) + assert.Equal(t, "owner", properties["owner"].(map[string]any)["x-mcp-header"]) + assert.Equal(t, "repo", properties["repo"].(map[string]any)["x-mcp-header"]) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "legacy_routing_tool", + Arguments: map[string]any{"owner": "octo", "repo": "hello", "query": "is:open"}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Nil(t, result.StructuredContent) + require.Len(t, result.Content, 1) + assert.Equal(t, "legacy text result", result.Content[0].(*mcp.TextContent).Text) +} + +func TestTypedToolInferredInputSchemaUsesStablePointer(t *testing.T) { + type input struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + } + first, err := CachedInputSchemaFor[input](nil) + require.NoError(t, err) + second, err := CachedInputSchemaFor[input](nil) + require.NoError(t, err) + assert.Same(t, first, second, "schema inference must be cached across tool/server instances") + inferredSchema := first + assert.Equal(t, "owner", inferredSchema.Properties["owner"].Extra["x-mcp-header"]) + assert.Equal(t, "repo", inferredSchema.Properties["repo"].Extra["x-mcp-header"]) +} + +func TestTypedSchemasAreCachedAcrossFreshRegistrations(t *testing.T) { + type input struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + } + type output struct { + State string `json:"state"` + } + var firstInput, firstOutput *jsonschema.Schema + for range 2 { + tool := NewServerToolWithContextHandler( + mcp.Tool{Name: "cached_typed_tool"}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, input) (*mcp.CallToolResult, output, error) { + return nil, output{}, nil + }, + ) + registration := tool.typedRegistration(&tool.Tool) + modernInput := registration.modernTool.InputSchema.(*jsonschema.Schema) + legacyInput := registration.legacyTool.InputSchema.(*jsonschema.Schema) + assert.Same(t, modernInput, legacyInput, "both eras must share the pre-annotated input schema") + assert.Same(t, modernInput, registration.modernRuntimeTool.InputSchema) + assert.Same(t, modernInput, registration.legacyRuntimeTool.InputSchema) + assert.Nil(t, registration.legacyTool.OutputSchema) + assert.Nil(t, registration.legacyRuntimeTool.OutputSchema) + if firstInput == nil { + firstInput = modernInput + firstOutput = registration.modernTool.OutputSchema.(*jsonschema.Schema) + continue + } + assert.Same(t, firstInput, modernInput) + assert.Same(t, firstOutput, registration.modernTool.OutputSchema) + } +} + +func TestCachedExplicitSchemaWarmPathsPreserveOwnership(t *testing.T) { + original := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string", Description: "original owner"}, + }, + } + cached, err := CachedSchema(original) + require.NoError(t, err) + require.NotSame(t, original, cached) + original.Properties["owner"].Description = "changed owner" + assert.Equal(t, "original owner", cached.Properties["owner"].Description) + changed, err := CachedSchema(original) + require.NoError(t, err) + assert.NotSame(t, cached, changed, "caller-owned pointers must retain content-based lookup") + + tool := mcp.Tool{InputSchema: cached} + AnnotateHeaderParams(&tool) + annotated := tool.InputSchema.(*jsonschema.Schema) + assert.NotSame(t, cached, annotated) + assert.Empty(t, cached.Properties["owner"].Extra, "header preparation must not mutate the advertised schema") + assert.Equal(t, "owner", annotated.Properties["owner"].Extra["x-mcp-header"]) + + var cacheErr error + allocations := testing.AllocsPerRun(10, func() { + warm := mcp.Tool{InputSchema: cached} + AnnotateHeaderParams(&warm) + var prepared *jsonschema.Schema + prepared, cacheErr = CachedSchema(warm.InputSchema.(*jsonschema.Schema)) + if prepared != annotated { + panic("warm registration changed the cached schema pointer") + } + }) + require.NoError(t, cacheErr) + assert.Zero(t, allocations, "cache-owned warm schemas must avoid canonical serialization and annotation cloning") +} + +func TestProtocolEraForSupportedVersions(t *testing.T) { + supported := []string{"2027-01-01", ProtocolVersionMultiRoundTrip, "2025-11-25", "2027-02-30", "garbage"} + for _, tc := range []struct { + name string + version string + want ProtocolEra + }{ + {name: "exact threshold", version: ProtocolVersionMultiRoundTrip, want: ProtocolEraModern}, + {name: "supported later", version: "2027-01-01", want: ProtocolEraModern}, + {name: "older", version: "2025-11-25", want: ProtocolEraLegacy}, + {name: "unsupported future", version: "2099-01-01", want: ProtocolEraLegacy}, + {name: "invalid date even if listed", version: "2027-02-30", want: ProtocolEraLegacy}, + {name: "garbage even if listed", version: "garbage", want: ProtocolEraLegacy}, + {name: "absent", want: ProtocolEraLegacy}, + } { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, protocolEraForSupportedVersion(tc.version, supported)) + }) + } + assert.Equal(t, ProtocolEraLegacy, protocolEraForSupportedVersion(ProtocolVersionMultiRoundTrip, nil), + "a valid modern date must still be supported") + + for _, version := range mcp.SupportedProtocolVersions() { + want := ProtocolEraLegacy + if version >= ProtocolVersionMultiRoundTrip { + want = ProtocolEraModern + } + assert.Equal(t, want, ProtocolEraForVersion(version), "SDK-supported version %q", version) + } + for _, version := range []string{"", "garbage", "2027-02-30", "2099-01-01"} { + assert.Equal(t, ProtocolEraLegacy, ProtocolEraForVersion(version), "unknown version %q", version) + } +} + +func TestTypedPointerInputUsesObjectSchemaInBothEras(t *testing.T) { + type input struct { + Query string `json:"query"` + } + valueSchema, err := CachedInputSchemaFor[input](nil) + require.NoError(t, err) + pointerSchema, err := CachedInputSchemaFor[*input](nil) + require.NoError(t, err) + assert.Same(t, valueSchema, pointerSchema, "SDK inference unwraps one input pointer before caching") + assert.Equal(t, "object", pointerSchema.Type) + assert.Empty(t, pointerSchema.Types) + + outputSchema, err := CachedSchemaFor[*input](nil) + require.NoError(t, err) + assert.Contains(t, outputSchema.Types, "null", "output pointers must retain nullability") + + tool := NewServerToolWithContextHandler( + mcp.Tool{Name: "pointer_input"}, + testToolsetMetadata("test"), + func(_ context.Context, _ *mcp.CallToolRequest, args *input) (*mcp.CallToolResult, typedTestOutput, error) { + require.NotNil(t, args) + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: args.Query}}}, + typedTestOutput{Query: args.Query}, nil + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "1"}, nil) + require.NotPanics(t, func() { tool.RegisterFunc(server, nil) }) + for _, version := range []string{"", "2025-11-25"} { + session := connectTypedTestClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + assert.Contains(t, mustMarshalJSON(t, list.Tools[0].InputSchema), `"type":"object"`) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: tool.Tool.Name, Arguments: map[string]any{"query": "pointer input decoded"}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + if version == "" { + assert.JSONEq(t, `{"query":"pointer input decoded"}`, mustMarshalJSON(t, result.StructuredContent)) + } else { + assert.Nil(t, result.StructuredContent) + require.Len(t, result.Content, 1) + assert.Equal(t, "pointer input decoded", result.Content[0].(*mcp.TextContent).Text) + } + } +} + +func TestCachedSchemaDeepCopiesMutableMetadata(t *testing.T) { + constant := any(map[string]any{"nested": []string{"const"}}) + nullConstant := any(nil) + original := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "state": { + Type: "string", + Enum: []any{map[string]any{"values": []string{"open"}}}, + Extra: map[string]any{ + "nested": map[string]any{"values": []string{"property"}}, + }, + }, + }, + Enum: []any{map[string]any{"values": []string{"root"}}}, + Const: &constant, + Examples: []any{map[string]any{"values": []string{"example"}}}, + DependencyStrings: map[string][]string{ + "state": {"repo"}, + }, + PropertyOrder: []string{"state"}, + Extra: map[string]any{ + "nested": map[string]any{"values": []string{"extra"}}, + }, + } + cached, err := CachedSchema(original) + require.NoError(t, err) + + original.Enum[0].(map[string]any)["values"].([]string)[0] = "mutated-root" + original.Properties["state"].Enum[0].(map[string]any)["values"].([]string)[0] = "mutated-property" + original.Properties["state"].Extra["nested"].(map[string]any)["values"].([]string)[0] = "mutated-property-extra" + (*original.Const).(map[string]any)["nested"].([]string)[0] = "mutated-const" + original.Examples[0].(map[string]any)["values"].([]string)[0] = "mutated-example" + original.DependencyStrings["state"][0] = "mutated-dependency" + original.PropertyOrder[0] = "mutated-order" + original.Extra["nested"].(map[string]any)["values"].([]string)[0] = "mutated-extra" + + assert.Equal(t, "root", cached.Enum[0].(map[string]any)["values"].([]string)[0]) + assert.Equal(t, "open", cached.Properties["state"].Enum[0].(map[string]any)["values"].([]string)[0]) + assert.Equal(t, "property", cached.Properties["state"].Extra["nested"].(map[string]any)["values"].([]string)[0]) + assert.Equal(t, "const", (*cached.Const).(map[string]any)["nested"].([]string)[0]) + assert.Equal(t, "example", cached.Examples[0].(map[string]any)["values"].([]string)[0]) + assert.Equal(t, []string{"repo"}, cached.DependencyStrings["state"]) + assert.Equal(t, []string{"state"}, cached.PropertyOrder) + assert.Equal(t, "extra", cached.Extra["nested"].(map[string]any)["values"].([]string)[0]) + nullSchema, err := CachedSchema(&jsonschema.Schema{Const: &nullConstant}) + require.NoError(t, err) + require.NotNil(t, nullSchema.Const) + assert.Nil(t, *nullSchema.Const) + + var workers sync.WaitGroup + workers.Add(2) + go func() { + defer workers.Done() + for range 100 { + original.Properties["state"].Extra["nested"].(map[string]any)["values"].([]string)[0] = "concurrent mutation" + } + }() + go func() { + defer workers.Done() + for range 100 { + if _, err := json.Marshal(cached); err != nil { + t.Errorf("marshal cached schema: %v", err) + return + } + } + }() + workers.Wait() +} + +func TestSchemaClonesDetachEveryNumericKeyword(t *testing.T) { + for name, clone := range map[string]func(*jsonschema.Schema) *jsonschema.Schema{ + "direct": CloneSchema, + "cache": func(schema *jsonschema.Schema) *jsonschema.Schema { + cached, err := CachedSchema(schema) + require.NoError(t, err) + return cached + }, + "inference options": func(schema *jsonschema.Schema) *jsonschema.Schema { + options := cloneJSONSchemaForOptions(&jsonschema.ForOptions{ + TypeSchemas: map[reflect.Type]*jsonschema.Schema{reflect.TypeFor[int](): schema}, + }) + return options.TypeSchemas[reflect.TypeFor[int]()] + }, + "without defaults": CloneSchemaWithoutDefaults, + } { + t.Run(name, func(t *testing.T) { + bounds := &jsonschema.Schema{} + fields := reflect.ValueOf(bounds).Elem() + var numericFields []int + for i := range fields.NumField() { + field := fields.Field(i) + if field.Type() != reflect.TypeFor[*int]() && field.Type() != reflect.TypeFor[*float64]() { + continue + } + value := reflect.New(field.Type().Elem()) + if value.Elem().Kind() == reflect.Int { + value.Elem().SetInt(11) + } else { + value.Elem().SetFloat(11) + } + field.Set(value) + numericFields = append(numericFields, i) + } + require.NotEmpty(t, numericFields) + original := &jsonschema.Schema{ + Type: "object", + Comment: "numeric keyword isolation " + name, + Properties: map[string]*jsonschema.Schema{"bounds": bounds}, + } + cloned := clone(original) + clonedFields := reflect.ValueOf(cloned.Properties["bounds"]).Elem() + for _, i := range numericFields { + keyword := fields.Type().Field(i).Name + assert.NotEqual(t, fields.Field(i).Pointer(), clonedFields.Field(i).Pointer(), keyword) + assert.Equal(t, fields.Field(i).Elem().Interface(), clonedFields.Field(i).Elem().Interface(), keyword) + } + + // Mutating caller-owned bounds must not race with cached-schema reads. + var workers sync.WaitGroup + workers.Add(2) + go func() { + defer workers.Done() + for range 100 { + for _, i := range numericFields { + value := fields.Field(i).Elem() + if value.Kind() == reflect.Int { + value.SetInt(22) + } else { + value.SetFloat(22) + } + } + } + }() + go func() { + defer workers.Done() + for range 100 { + if _, err := json.Marshal(cloned); err != nil { + t.Errorf("marshal detached schema: %v", err) + return + } + } + }() + workers.Wait() + for _, i := range numericFields { + value := clonedFields.Field(i).Elem() + if value.Kind() == reflect.Int { + assert.Equal(t, int64(11), value.Int()) + } else { + assert.Equal(t, float64(11), value.Float()) + } + } + assert.Nil(t, cloned.Minimum, "absent bounds must remain absent") + }) + } +} + +func TestCloneSchemaWithoutDefaultsTraversesAllChildKinds(t *testing.T) { + schemaType := reflect.TypeFor[jsonschema.Schema]() + for i := range schemaType.NumField() { + field := schemaType.Field(i) + if field.Type != reflect.TypeFor[*jsonschema.Schema]() && + field.Type != reflect.TypeFor[[]*jsonschema.Schema]() && + field.Type != reflect.TypeFor[map[string]*jsonschema.Schema]() { + continue + } + t.Run(field.Name, func(t *testing.T) { + original := &jsonschema.Schema{Default: json.RawMessage(`1`)} + // A single deep chain makes repeated per-level traversal exponential. + const depth = 48 + for range depth { + parent := &jsonschema.Schema{Default: json.RawMessage(`1`)} + target := reflect.ValueOf(parent).Elem().Field(i) + switch field.Type { + case reflect.TypeFor[*jsonschema.Schema](): + target.Set(reflect.ValueOf(original)) + case reflect.TypeFor[[]*jsonschema.Schema](): + target.Set(reflect.ValueOf([]*jsonschema.Schema{original})) + case reflect.TypeFor[map[string]*jsonschema.Schema](): + target.Set(reflect.ValueOf(map[string]*jsonschema.Schema{"child": original})) + } + original = parent + } + cloned := CloneSchemaWithoutDefaults(original) + for level := 0; level <= depth; level++ { + require.NotSame(t, original, cloned) + assert.Equal(t, json.RawMessage(`1`), original.Default) + assert.Nil(t, cloned.Default) + var originalChild, clonedChild *jsonschema.Schema + for _, child := range schemaChildren(original) { + if child != nil { + originalChild = child + } + } + for _, child := range schemaChildren(cloned) { + if child != nil { + clonedChild = child + } + } + original, cloned = originalChild, clonedChild + } + assert.Nil(t, original) + assert.Nil(t, cloned) + }) + } +} + +func TestSchemaInferenceOptionsDeepCopyOverrides(t *testing.T) { + type input struct { + State string `json:"state"` + } + original := &jsonschema.Schema{ + Type: "string", + Enum: []any{map[string]any{"values": []string{"open"}}}, + Extra: map[string]any{ + "nested": map[string]any{"values": []string{"option"}}, + }, + } + options := cloneJSONSchemaForOptions(&jsonschema.ForOptions{ + TypeSchemas: map[reflect.Type]*jsonschema.Schema{ + reflect.TypeFor[input](): original, + }, + }) + original.Enum[0].(map[string]any)["values"].([]string)[0] = "mutated" + original.Extra["nested"].(map[string]any)["values"].([]string)[0] = "mutated" + + cloned := options.TypeSchemas[reflect.TypeFor[input]()] + assert.Equal(t, "open", cloned.Enum[0].(map[string]any)["values"].([]string)[0]) + assert.Equal(t, "option", cloned.Extra["nested"].(map[string]any)["values"].([]string)[0]) +} + +func TestRawExplicitSchemasReuseAnnotatedPointersAcrossRegistrations(t *testing.T) { + original := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string"}, + }, + } + tool := NewServerTool( + mcp.Tool{Name: "cached_raw_tool", InputSchema: original}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{}, nil + }, + ) + var first *jsonschema.Schema + for range 2 { + toolCopy := tool.Tool + AnnotateHeaderParams(&toolCopy) + registration := tool.typedRegistration(&toolCopy) + schema := registration.modernRuntimeTool.InputSchema.(*jsonschema.Schema) + if first == nil { + first = schema + } else { + assert.Same(t, first, schema) + } + } + assert.Empty(t, original.Properties["owner"].Extra) + assert.Empty(t, tool.Tool.InputSchema.(*jsonschema.Schema).Properties["owner"].Extra, + "constructor caching must not change the public definition's schema metadata") +} + +func TestTypedAnyInputPreservesSDKDefaultObjectSchemaInBothEras(t *testing.T) { + tool := NewServerToolWithContextHandler( + mcp.Tool{Name: "typed_any_input"}, + testToolsetMetadata("test"), + func(_ context.Context, _ *mcp.CallToolRequest, input any) (*mcp.CallToolResult, typedTestOutput, error) { + values, ok := input.(map[string]any) + require.True(t, ok) + return nil, typedTestOutput{Query: values["query"].(string)}, nil + }, + ) + require.Nil(t, tool.Tool.InputSchema, "the caller-owned static definition remains untouched") + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "1"}, nil) + tool.RegisterFunc(server, nil) + modern := connectTypedTestClient(t, server, "") + legacy := connectTypedTestClient(t, server, "2025-11-25") + + for _, session := range []*mcp.ClientSession{modern, legacy} { + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + assert.JSONEq(t, `{"type":"object"}`, mustMarshalJSON(t, list.Tools[0].InputSchema)) + } + list, err := modern.ListTools(context.Background(), nil) + require.NoError(t, err) + assert.NotNil(t, list.Tools[0].OutputSchema) + list, err = legacy.ListTools(context.Background(), nil) + require.NoError(t, err) + assert.Nil(t, list.Tools[0].OutputSchema) + + for _, tc := range []struct { + name string + session *mcp.ClientSession + modern bool + }{ + {name: "modern call", session: modern, modern: true}, + {name: "legacy call", session: legacy}, + } { + t.Run(tc.name, func(t *testing.T) { + result, err := tc.session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "typed_any_input", + Arguments: map[string]any{"query": "accepted"}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + if tc.modern { + assert.JSONEq(t, `{"query":"accepted"}`, mustMarshalJSON(t, result.StructuredContent)) + } else { + assert.Nil(t, result.StructuredContent) + } + }) + } +} + +func TestTypedToolInputSchemasPreserveObjectOptionality(t *testing.T) { + type optionalPointerInput struct { + Owner string `json:"owner"` + Filter *string `json:"filter,omitempty"` + Limit int `json:"limit,omitempty"` + } + + const emptyInputSchema = `{"type":"object"}` + const optionalPointerInputSchema = `{ + "type":"object", + "properties":{ + "owner":{"type":"string"}, + "filter":{"type":"string"}, + "limit":{"type":"integer"} + }, + "required":["owner"] + }` + + legacyEmptyTool := NewServerTool( + mcp.Tool{Name: "legacy_empty_input", InputSchema: json.RawMessage(emptyInputSchema)}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return nil, nil + }, + ) + typedEmptyTool := NewServerToolWithContextHandler( + mcp.Tool{Name: "typed_empty_input"}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, struct{}, error) { + return nil, struct{}{}, nil + }, + ) + legacyOptionalTool := NewServerTool( + mcp.Tool{Name: "legacy_optional_input", InputSchema: json.RawMessage(optionalPointerInputSchema)}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return nil, nil + }, + ) + typedOptionalTool := NewServerToolWithContextHandler( + mcp.Tool{Name: "typed_optional_input"}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, optionalPointerInput) (*mcp.CallToolResult, struct{}, error) { + return nil, struct{}{}, nil + }, + ) + + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + legacyEmptyTool.RegisterFunc(server, nil) + typedEmptyTool.RegisterFunc(server, nil) + legacyOptionalTool.RegisterFunc(server, nil) + typedOptionalTool.RegisterFunc(server, nil) + session := connectTypedTestClient(t, server, "") + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + + schemasByName := make(map[string]any, len(list.Tools)) + for _, tool := range list.Tools { + schemasByName[tool.Name] = tool.InputSchema + } + + for _, tc := range []struct { + legacyName string + typedName string + wantSchema string + wantProperties []string + wantRequired []string + }{ + {legacyName: "legacy_empty_input", typedName: "typed_empty_input", wantSchema: emptyInputSchema}, + { + legacyName: "legacy_optional_input", + typedName: "typed_optional_input", + wantSchema: optionalPointerInputSchema, + wantProperties: []string{"filter", "limit", "owner"}, + wantRequired: []string{"owner"}, + }, + } { + t.Run(tc.typedName, func(t *testing.T) { + legacySchema, ok := schemasByName[tc.legacyName] + require.True(t, ok) + typedSchema, ok := schemasByName[tc.typedName] + require.True(t, ok) + + legacyJSON := mustMarshalJSON(t, legacySchema) + typedJSON := mustMarshalJSON(t, typedSchema) + + legacyShape := readInputSchemaShape(t, legacyJSON) + typedShape := readInputSchemaShape(t, typedJSON) + expectedShape := readInputSchemaShape(t, tc.wantSchema) + assert.Equal(t, expectedShape, legacyShape, "fixture must represent the pre-migration input contract") + assert.Equal(t, legacyShape, typedShape, "typed registration must preserve the pre-migration input contract") + + assert.Equal(t, []string{"object"}, typedShape.rootTypes, "typed input roots must remain objects") + assert.False(t, typedShape.hasAnyOf, "input roots must not become nullable or optional unions") + assert.Equal(t, tc.wantProperties, typedShape.properties, "typed registration must preserve the input properties") + assert.ElementsMatch(t, tc.wantRequired, typedShape.required, "typed registration must preserve required properties") + }) + } +} + +type inputSchemaShape struct { + rootTypes []string + properties []string + required []string + hasAnyOf bool +} + +func readInputSchemaShape(t *testing.T, schemaJSON string) inputSchemaShape { + t.Helper() + var root map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(schemaJSON), &root)) + var schemaValue any + require.NoError(t, json.Unmarshal([]byte(schemaJSON), &schemaValue)) + + shape := inputSchemaShape{} + rawType, ok := root["type"] + require.True(t, ok, "schema must declare a root type") + if len(rawType) > 0 && rawType[0] == '[' { + require.NoError(t, json.Unmarshal(rawType, &shape.rootTypes)) + } else { + var rootType string + require.NoError(t, json.Unmarshal(rawType, &rootType)) + shape.rootTypes = []string{rootType} + } + shape.hasAnyOf = schemaContainsAnyOf(schemaValue) + + var properties map[string]json.RawMessage + if rawProperties, ok := root["properties"]; ok { + require.NoError(t, json.Unmarshal(rawProperties, &properties)) + for name := range properties { + shape.properties = append(shape.properties, name) + } + } + slices.Sort(shape.properties) + + if rawRequired, ok := root["required"]; ok { + require.NoError(t, json.Unmarshal(rawRequired, &shape.required)) + } + return shape +} + +func schemaContainsAnyOf(value any) bool { + switch value := value.(type) { + case map[string]any: + if _, ok := value["anyOf"]; ok { + return true + } + for _, child := range value { + if schemaContainsAnyOf(child) { + return true + } + } + case []any: + return slices.ContainsFunc(value, schemaContainsAnyOf) + } + return false +} + +func TestTypedToolRegistrationAppliesExplicitSchemas(t *testing.T) { + type input struct { + Mode string `json:"mode,omitempty"` + } + type output struct { + Mode string `json:"mode,omitempty"` + Count int `json:"count"` + } + + tool := NewServerToolWithContextHandler( + mcp.Tool{ + Name: "explicit_schema_tool", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{"mode":{"type":"string","enum":["wide","narrow"],"default":"wide"}}, + "required":["mode"] + }`), + OutputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "mode":{"type":"string","enum":["wide","narrow"],"default":"wide"}, + "count":{"type":"integer","minimum":2} + }, + "required":["mode","count"] + }`), + }, + testToolsetMetadata("test"), + func(_ context.Context, _ *mcp.CallToolRequest, args input) (*mcp.CallToolResult, output, error) { + count := 3 + if args.Mode == "narrow" { + count = 1 + } + return nil, output{Count: count, Mode: args.Mode}, nil + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + tool.RegisterFunc(server, nil) + session := connectTypedTestClient(t, server, "") + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + assert.Contains(t, mustMarshalJSON(t, list.Tools[0].InputSchema), `"default":"wide"`) + assert.Contains(t, mustMarshalJSON(t, list.Tools[0].OutputSchema), `"minimum":2`) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "explicit_schema_tool", + Arguments: map[string]any{"mode": "wide"}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.JSONEq(t, `{"mode":"wide","count":3}`, mustMarshalJSON(t, result.StructuredContent)) + + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "explicit_schema_tool", + Arguments: map[string]any{"mode": "invalid"}, + }) + require.NoError(t, err) + assert.True(t, result.IsError) + + _, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "explicit_schema_tool", + Arguments: map[string]any{"mode": "narrow"}, + }) + require.ErrorContains(t, err, "validating tool output") +} + +func TestTypedSchemaOptionsKeepAdvertisedSchemaAndRuntimeValidationSeparate(t *testing.T) { + type input struct { + State string `json:"state"` + } + type output struct { + State string `json:"state"` + } + publicSchema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "state": {Type: "string", Enum: []any{"open"}}, + }, + Required: []string{"state"}, + } + validationSchema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "state": {Type: "string"}, + }, + Required: []string{"state"}, + } + tool := NewServerToolWithContextHandlerAndSchemaOptions( + mcp.Tool{Name: "validation_override_tool", InputSchema: publicSchema}, + testToolsetMetadata("test"), + func(_ context.Context, _ *mcp.CallToolRequest, args input) (*mcp.CallToolResult, output, error) { + return nil, output(args), nil + }, + TypedSchemaOptions{ValidationInputSchema: validationSchema}, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + tool.RegisterFunc(server, nil) + session := connectTypedTestClient(t, server, "") + + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + assert.Contains(t, mustMarshalJSON(t, list.Tools[0].InputSchema), `"enum":["open"]`) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "validation_override_tool", + Arguments: map[string]any{"state": "legacy-value"}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.JSONEq(t, `{"state":"legacy-value"}`, mustMarshalJSON(t, result.StructuredContent)) +} + +func TestValidationInputSchemaIsCachedAndDoesNotInjectDefaults(t *testing.T) { + type paginationInput struct { + Page int `json:"page"` + PerPage int `json:"per_page"` + } + type paginationOutput struct { + Page int `json:"page"` + PerPage int `json:"per_page"` + } + publicSchema := &jsonschema.Schema{} + require.NoError(t, json.Unmarshal([]byte(`{ + "type":"object", + "properties":{ + "page":{"type":"integer","minimum":1,"default":1}, + "per_page":{"type":"integer","minimum":1,"default":30} + } + }`), publicSchema)) + validationSchema := CloneSchemaWithoutDefaults(publicSchema) + minimum := float64(0) + validationSchema.Properties["page"].Minimum = &minimum + validationSchema.Properties["per_page"].Minimum = &minimum + + handlerCalls := 0 + newTool := func(name string) ServerTool { + return NewServerToolWithContextHandlerAndSchemaOptions( + mcp.Tool{Name: name, InputSchema: publicSchema}, + testToolsetMetadata("test"), + func(_ context.Context, _ *mcp.CallToolRequest, args paginationInput) (*mcp.CallToolResult, paginationOutput, error) { + handlerCalls++ + return nil, paginationOutput(args), nil + }, + TypedSchemaOptions{ValidationInputSchema: validationSchema}, + ) + } + tool := newTool("pagination_tool") + registration := tool.typedRegistration(&tool.Tool) + runtimeSchema := registration.modernRuntimeTool.InputSchema.(*jsonschema.Schema) + otherTool := newTool("other_pagination_tool") + otherRegistration := otherTool.typedRegistration(&otherTool.Tool) + assert.Same(t, runtimeSchema, otherRegistration.modernRuntimeTool.InputSchema, + "equal runtime schemas must reuse a process-cached immutable pointer") + assert.Contains(t, mustMarshalJSON(t, registration.modernTool.InputSchema), `"minimum":1`) + assert.Contains(t, mustMarshalJSON(t, registration.modernTool.InputSchema), `"default":30`) + assert.Contains(t, mustMarshalJSON(t, runtimeSchema), `"minimum":0`) + assert.NotContains(t, mustMarshalJSON(t, runtimeSchema), `"default"`) + + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + tool.RegisterFunc(server, nil) + session := connectTypedTestClient(t, server, "") + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "pagination_tool", + Arguments: map[string]any{}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.JSONEq(t, `{"page":0,"per_page":0}`, mustMarshalJSON(t, result.StructuredContent), + "JSON Schema defaults are validation metadata and must not populate omitted query parameters") + assert.Equal(t, 1, handlerCalls) + + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "pagination_tool", + Arguments: map[string]any{"page": -1}, + }) + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Equal(t, 1, handlerCalls, "runtime validation must continue enforcing the relaxed schema's minimum") +} + +func TestTypedSchemaEnumsAndPreflightRunBeforeSDKValidation(t *testing.T) { + type input struct { + State string `json:"state"` + } + type output struct { + State string `json:"state"` + } + type preparedKey struct{} + handlerCalls := 0 + preflightCalls := 0 + tool := NewServerToolWithContextHandlerAndSchemaOptions( + mcp.Tool{Name: "preflight_schema_tool"}, + testToolsetMetadata("test"), + func(ctx context.Context, _ *mcp.CallToolRequest, args input) (*mcp.CallToolResult, output, error) { + handlerCalls++ + require.Equal(t, "prepared", ctx.Value(preparedKey{})) + return nil, output(args), nil + }, + TypedSchemaOptions{ + OutputEnums: []SchemaEnum{{Path: "state", Values: []string{"open", "closed"}}}, + Preflight: func(ctx context.Context, req *mcp.CallToolRequest) (context.Context, *mcp.CallToolResult, error) { + preflightCalls++ + if bytes.Contains(req.Params.Arguments, []byte(`"state":42`)) { + return ctx, &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "preflight rejected raw arguments"}}, + IsError: true, + }, nil + } + return context.WithValue(ctx, preparedKey{}, "prepared"), nil, nil + }, + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + tool.RegisterFunc(server, nil) + session := connectTypedTestClient(t, server, "") + + invalid, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "preflight_schema_tool", + Arguments: map[string]any{"state": 42}, + }) + require.NoError(t, err) + require.True(t, invalid.IsError) + require.Len(t, invalid.Content, 1) + assert.Equal(t, "preflight rejected raw arguments", invalid.Content[0].(*mcp.TextContent).Text) + assert.Nil(t, invalid.StructuredContent) + assert.Zero(t, handlerCalls) + + valid, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "preflight_schema_tool", + Arguments: map[string]any{"state": "open"}, + }) + require.NoError(t, err) + require.False(t, valid.IsError) + assert.JSONEq(t, `{"state":"open"}`, mustMarshalJSON(t, valid.StructuredContent)) + assert.Equal(t, 2, preflightCalls) + assert.Equal(t, 1, handlerCalls) + + _, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "preflight_schema_tool", + Arguments: map[string]any{"state": "unknown"}, + }) + require.ErrorContains(t, err, "validating tool output") + assert.Equal(t, 2, handlerCalls, "the SDK must reject output enum violations before returning structured success") +} + +func TestTypedPreflightRunsAfterAvailabilityAndAuthorization(t *testing.T) { + for _, blockedBy := range []string{"availability", "authorization"} { + t.Run(blockedBy, func(t *testing.T) { + preflightCalls := 0 + handlerCalls := 0 + tool := NewServerToolWithContextHandlerAndSchemaOptions( + mcp.Tool{Name: "guarded_preflight"}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, typedTestInput) (*mcp.CallToolResult, typedTestOutput, error) { + handlerCalls++ + return nil, typedTestOutput{}, nil + }, + TypedSchemaOptions{ + Preflight: func(ctx context.Context, _ *mcp.CallToolRequest) (context.Context, *mcp.CallToolResult, error) { + preflightCalls++ + return ctx, &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "preflight ran"}}}, nil + }, + }, + ) + var middleware []ToolHandlerMiddleware + if blockedBy == "availability" { + tool.MinimumProtocolVersion = "2027-01-01" + } else { + middleware = append(middleware, func(_ mcp.ToolHandler) mcp.ToolHandler { + return func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: "unauthorized"}}}, nil + } + }) + } + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "1"}, nil) + tool.RegisterFunc(server, nil, middleware...) + session := connectTypedTestClient(t, server, "") + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tool.Tool.Name}) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Zero(t, preflightCalls) + assert.Zero(t, handlerCalls) + assert.NotContains(t, mustMarshalJSON(t, result), "preflight ran") + if blockedBy == "authorization" { + assert.Equal(t, "unauthorized", result.Content[0].(*mcp.TextContent).Text) + } + }) + } +} + +func TestTypedPreflightErrorsPreserveToolAndProtocolErrorSemantics(t *testing.T) { + for _, protocolError := range []bool{false, true} { + t.Run(fmt.Sprintf("protocol=%t", protocolError), func(t *testing.T) { + tool := NewServerToolWithContextHandlerAndSchemaOptions( + mcp.Tool{Name: "preflight_error"}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, typedTestOutput, error) { + t.Fatal("preflight must short-circuit the handler") + return nil, typedTestOutput{}, nil + }, + TypedSchemaOptions{ + Preflight: func(ctx context.Context, _ *mcp.CallToolRequest) (context.Context, *mcp.CallToolResult, error) { + if protocolError { + return ctx, nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "protocol rejected"} + } + return ctx, nil, errors.New("preflight failed") + }, + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "1"}, nil) + tool.RegisterFunc(server, nil) + session := connectTypedTestClient(t, server, "") + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tool.Tool.Name}) + if protocolError { + require.ErrorContains(t, err, "protocol rejected") + assert.Nil(t, result) + } else { + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, result.Content[0].(*mcp.TextContent).Text, "preflight failed") + assert.Nil(t, result.StructuredContent) + } + }) + } +} + +func TestTypedInputNormalizerPreservesStrictSchemaAndLegacyValues(t *testing.T) { + type listIssuesInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + State string `json:"state"` + IssueNumber int64 `json:"issue_number"` + } + type listIssuesOutput struct { + State string `json:"state"` + IssueNumber int64 `json:"issue_number"` + } + + handlerCalls := 0 + var got listIssuesInput + tool := NewServerToolWithContextHandler( + mcp.Tool{ + Name: "list_issues", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{ + "owner":{"type":"string","minLength":1}, + "repo":{"type":"string","minLength":1}, + "state":{"type":"string","enum":["OPEN","CLOSED"]}, + "issue_number":{"type":"integer"} + }, + "required":["owner","repo"] + }`), + }, + testToolsetMetadata("issues"), + func(_ context.Context, _ *mcp.CallToolRequest, input listIssuesInput) (*mcp.CallToolResult, listIssuesOutput, error) { + handlerCalls++ + got = input + return nil, listIssuesOutput{State: input.State, IssueNumber: input.IssueNumber}, nil + }, + normalizeListIssuesWireInput, + ) + directResult, err := tool.Handler(nil)(context.Background(), &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{ + Name: "list_issues", + Arguments: json.RawMessage(`{"owner":"octo","repo":"hello","state":"open","issue_number":"42"}`), + }, + }) + require.NoError(t, err) + assert.Nil(t, directResult) + assert.Equal(t, "OPEN", got.State) + assert.Equal(t, int64(42), got.IssueNumber) + handlerCalls = 0 + + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + tool.RegisterFunc(server, nil) + session := connectTypedTestClient(t, server, "") + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + var inputSchema map[string]any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, list.Tools[0].InputSchema)), &inputSchema)) + properties := inputSchema["properties"].(map[string]any) + assert.Equal(t, "integer", properties["issue_number"].(map[string]any)["type"]) + assert.Equal(t, []any{"OPEN", "CLOSED"}, properties["state"].(map[string]any)["enum"]) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "list_issues", + Arguments: map[string]any{ + "owner": "octo", + "repo": "hello", + "state": "open", + "issue_number": "42", + }, + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Equal(t, 1, handlerCalls) + assert.Equal(t, "OPEN", got.State) + assert.Equal(t, int64(42), got.IssueNumber) + assert.JSONEq(t, `{"state":"OPEN","issue_number":42}`, mustMarshalJSON(t, result.StructuredContent)) + + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "list_issues", + Arguments: map[string]any{"owner": "octo", "repo": "hello", "state": "unsupported"}, + }) + require.NoError(t, err) + assert.True(t, result.IsError, "normalization must not bypass SDK enum validation") + assert.Equal(t, 1, handlerCalls) + + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "list_issues", + Arguments: map[string]any{"owner": "octo", "repo": "hello", "issue_number": "not-a-number"}, + }) + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Equal(t, 1, handlerCalls) +} + +func TestInvalidArgumentsPreservesToolInputErrorMessage(t *testing.T) { + result := invalidArgumentsResult(fmt.Errorf( + "normalize tool arguments: %w", + &ToolInputError{Message: "missing required parameter: method"}, + )) + require.True(t, result.IsError) + require.Len(t, result.Content, 1) + assert.Equal(t, "missing required parameter: method", result.Content[0].(*mcp.TextContent).Text) +} + +func TestTypedInputNormalizerReusesScopeNormalizedArguments(t *testing.T) { + normalizerCalls := 0 + tool := &mcp.Tool{Name: "normalized_scope_tool"} + registration := &typedToolRegistration{ + name: tool.Name, + modernTool: tool, + legacyTool: tool, + modernRuntimeTool: tool, + legacyRuntimeTool: tool, + hasTypedOutput: true, + inputNormalizer: func(_ json.RawMessage) (json.RawMessage, error) { + normalizerCalls++ + return json.RawMessage(`{"state":"CHANGED"}`), nil + }, + } + wrapped := typedOutputMiddleware(&typedToolRegistrationSet{byName: map[string]*typedToolRegistration{ + tool.Name: registration, + }})(func(_ context.Context, _ string, request mcp.Request) (mcp.Result, error) { + call := request.(*mcp.CallToolRequest) + assert.JSONEq(t, `{"state":"OPEN"}`, string(call.Params.Arguments)) + return &mcp.CallToolResult{}, nil + }) + ctx := ghcontext.WithMCPMethodInfo(context.Background(), &ghcontext.MCPMethodInfo{ + Method: MCPMethodToolsCall, + ItemName: tool.Name, + ArgumentsNormalized: true, + NormalizedArguments: json.RawMessage(`{"state":"OPEN"}`), + }) + result, err := wrapped(ctx, MCPMethodToolsCall, &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{ + Name: tool.Name, + Arguments: json.RawMessage(`{"state":"open"}`), + }, + }) + require.NoError(t, err) + require.NotNil(t, result) + assert.Zero(t, normalizerCalls, "scope and typed adapters must not run a non-idempotent normalizer twice") +} + +func normalizeListIssuesWireInput(arguments json.RawMessage) (json.RawMessage, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(arguments, &fields); err != nil { + return nil, err + } + if rawState, ok := fields["state"]; ok { + var state string + if err := json.Unmarshal(rawState, &state); err != nil { + return nil, err + } + normalizedState, err := json.Marshal(strings.ToUpper(state)) + if err != nil { + return nil, err + } + fields["state"] = normalizedState + } + if rawID, ok := fields["issue_number"]; ok && len(rawID) > 0 && rawID[0] == '"' { + var id string + if err := json.Unmarshal(rawID, &id); err != nil { + return nil, err + } + parsedID, err := strconv.ParseInt(id, 10, 64) + if err != nil { + return nil, err + } + fields["issue_number"] = json.RawMessage(strconv.FormatInt(parsedID, 10)) + } + return json.Marshal(fields) +} + +func TestTypedInputNormalizerUsesLastRegisteredDefinition(t *testing.T) { + type input struct { + State string `json:"state"` + } + type output struct { + State string `json:"state"` + } + + first := NewServerToolWithContextHandler( + mcp.Tool{Name: "duplicate_tool"}, + testToolsetMetadata("test"), + func(_ context.Context, _ *mcp.CallToolRequest, args input) (*mcp.CallToolResult, output, error) { + return nil, output{State: "first:" + args.State}, nil + }, + func(_ json.RawMessage) (json.RawMessage, error) { + return json.RawMessage(`{"state":"OPEN"}`), nil + }, + ) + last := NewServerToolWithContextHandler( + mcp.Tool{ + Name: "duplicate_tool", + InputSchema: json.RawMessage(`{ + "type":"object", + "properties":{"state":{"type":"string","enum":["open"]}}, + "required":["state"] + }`), + }, + testToolsetMetadata("test"), + func(_ context.Context, _ *mcp.CallToolRequest, args input) (*mcp.CallToolResult, output, error) { + return nil, output{State: "last:" + args.State}, nil + }, + ) + + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + first.RegisterFunc(server, nil) + last.RegisterFunc(server, nil) + session := connectTypedTestClient(t, server, "") + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "duplicate_tool", + Arguments: map[string]any{"state": "open"}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.JSONEq(t, `{"state":"last:open"}`, mustMarshalJSON(t, result.StructuredContent)) +} + +func TestTypedToolMiddlewareShortCircuitAndHandlerErrors(t *testing.T) { + handlerCalls := 0 + shortCircuit := NewServerToolWithContextHandler( + mcp.Tool{Name: "short_circuit_tool"}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, typedTestOutput, error) { + handlerCalls++ + return nil, typedTestOutput{Query: "should not run"}, nil + }, + ) + handlerError := NewServerToolWithContextHandler( + mcp.Tool{Name: "handler_error_tool"}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, typedTestOutput, error) { + return nil, typedTestOutput{}, errors.New("typed handler failed") + }, + ) + type enumOutput struct { + Method string `json:"method"` + } + errorResult := NewServerToolWithContextHandler( + mcp.Tool{ + Name: "typed_error_result_tool", + OutputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": {Type: "string", Enum: []any{"list_projects"}}, + }, + Required: []string{"method"}, + }, + }, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, *enumOutput, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "project list failed"}}, + IsError: true, + }, nil, nil + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + shortCircuit.RegisterFunc(server, nil, func(mcp.ToolHandler) mcp.ToolHandler { + return func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "authorization required"}}, + IsError: true, + }, nil + } + }) + handlerError.RegisterFunc(server, nil) + errorResult.RegisterFunc(server, nil) + session := connectTypedTestClient(t, server, "") + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "short_circuit_tool"}) + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Nil(t, result.StructuredContent) + require.Len(t, result.Content, 1) + assert.Equal(t, "authorization required", result.Content[0].(*mcp.TextContent).Text) + assert.Zero(t, handlerCalls, "middleware short-circuit must not invoke the typed handler") + + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{Name: "handler_error_tool"}) + require.NoError(t, err) + require.True(t, result.IsError) + require.Len(t, result.Content, 1) + assert.Equal(t, "typed handler failed", result.Content[0].(*mcp.TextContent).Text) + + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{Name: "typed_error_result_tool"}) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Nil(t, result.StructuredContent) + require.Len(t, result.Content, 1) + assert.Equal(t, "project list failed", result.Content[0].(*mcp.TextContent).Text) +} + +func TestTypedPointerOutputAndUntypedGenericCompatibility(t *testing.T) { + type pointerOutput struct { + Value *string `json:"value,omitempty"` + } + type pointerInput struct { + Include bool `json:"include"` + } + value := "present" + pointerTool := NewServerToolWithContextHandler( + mcp.Tool{Name: "pointer_tool"}, + testToolsetMetadata("test"), + func(_ context.Context, _ *mcp.CallToolRequest, input pointerInput) (*mcp.CallToolResult, *pointerOutput, error) { + output := &pointerOutput{} + if input.Include { + output.Value = &value + } + return nil, output, nil + }, + ) + untypedCalls := 0 + untypedTool := NewServerToolWithContextHandler[map[string]any, any]( + mcp.Tool{ + Name: "untyped_generic_tool", + InputSchema: &jsonschema.Schema{Type: "object"}, + }, + testToolsetMetadata("test"), + func(_ context.Context, _ *mcp.CallToolRequest, input map[string]any) (*mcp.CallToolResult, any, error) { + untypedCalls++ + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: input["extra"].(string)}}, + }, nil, nil + }, + ) + require.Nil(t, untypedTool.registerTyped, "any output must retain the raw handler path") + + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + pointerTool.RegisterFunc(server, nil) + untypedTool.RegisterFunc(server, nil) + session := connectTypedTestClient(t, server, "") + + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + toolsByName := make(map[string]*mcp.Tool, len(list.Tools)) + for _, tool := range list.Tools { + toolsByName[tool.Name] = tool + } + assert.NotNil(t, toolsByName["pointer_tool"].OutputSchema) + assert.Nil(t, toolsByName["untyped_generic_tool"].OutputSchema) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "pointer_tool", + Arguments: map[string]any{"include": true}, + }) + require.NoError(t, err) + assert.JSONEq(t, `{"value":"present"}`, mustMarshalJSON(t, result.StructuredContent)) + + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "pointer_tool", + Arguments: map[string]any{"include": false}, + }) + require.NoError(t, err) + assert.JSONEq(t, `{}`, mustMarshalJSON(t, result.StructuredContent), "omitempty pointer fields must remain omitted") + + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "untyped_generic_tool", + Arguments: map[string]any{"extra": "accepted"}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + require.Len(t, result.Content, 1) + assert.Equal(t, "accepted", result.Content[0].(*mcp.TextContent).Text) + assert.Equal(t, 1, untypedCalls) +} + +func TestTypedToolOutputProtocolGatePreservesTextAndSharedTool(t *testing.T) { + tool := NewServerToolWithContextHandler( + mcp.Tool{Name: "typed_array_tool"}, + testToolsetMetadata("test"), + func(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, []string, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "legacy array text"}}, + }, []string{"one", "two"}, nil + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + tool.RegisterFunc(server, nil) + + modern := connectTypedTestClient(t, server, "") + legacy := connectTypedTestClient(t, server, "2025-11-25") + assert.Equal(t, ProtocolVersionMultiRoundTrip, modern.InitializeResult().ProtocolVersion) + assert.Equal(t, "2025-11-25", legacy.InitializeResult().ProtocolVersion) + + legacyList, err := legacy.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, legacyList.Tools, 1) + assert.Nil(t, legacyList.Tools[0].OutputSchema) + + modernList, err := modern.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, modernList.Tools, 1) + assert.NotNil(t, modernList.Tools[0].OutputSchema) + assert.JSONEq(t, `{"type":["null","array"],"items":{"type":"string"}}`, mustMarshalJSON(t, modernList.Tools[0].OutputSchema)) + + legacyResult, err := legacy.CallTool(context.Background(), &mcp.CallToolParams{Name: "typed_array_tool"}) + require.NoError(t, err) + assert.Nil(t, legacyResult.StructuredContent) + require.Len(t, legacyResult.Content, 1, "the SDK's array fallback must not duplicate the old text") + assert.Equal(t, "legacy array text", legacyResult.Content[0].(*mcp.TextContent).Text) + + modernResult, err := modern.CallTool(context.Background(), &mcp.CallToolParams{Name: "typed_array_tool"}) + require.NoError(t, err) + assert.JSONEq(t, `["one","two"]`, mustMarshalJSON(t, modernResult.StructuredContent)) + require.Len(t, modernResult.Content, 1) + assert.Equal(t, `["one","two"]`, modernResult.Content[0].(*mcp.TextContent).Text) + + legacyList, err = legacy.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, legacyList.Tools, 1) + assert.Nil(t, legacyList.Tools[0].OutputSchema, "modern discovery must not leak schema to an active legacy session") + + legacyResult, err = legacy.CallTool(context.Background(), &mcp.CallToolParams{Name: "typed_array_tool"}) + require.NoError(t, err) + assert.Nil(t, legacyResult.StructuredContent) + require.Len(t, legacyResult.Content, 1) + assert.Equal(t, "legacy array text", legacyResult.Content[0].(*mcp.TextContent).Text) + + modernResult, err = modern.CallTool(context.Background(), &mcp.CallToolParams{Name: "typed_array_tool"}) + require.NoError(t, err) + assert.JSONEq(t, `["one","two"]`, mustMarshalJSON(t, modernResult.StructuredContent)) + require.Len(t, modernResult.Content, 1) + assert.Equal(t, `["one","two"]`, modernResult.Content[0].(*mcp.TextContent).Text) + + type concurrentCall struct { + list *mcp.ListToolsResult + result *mcp.CallToolResult + err error + } + type clientCalls struct { + modern bool + calls []concurrentCall + } + const iterations = 5 + start := make(chan struct{}) + calls := make(chan clientCalls, 2) + var workers sync.WaitGroup + runClient := func(session *mcp.ClientSession, isModern bool) { + defer workers.Done() + <-start + run := clientCalls{modern: isModern, calls: make([]concurrentCall, 0, iterations)} + for range iterations { + list, listErr := session.ListTools(context.Background(), nil) + if listErr != nil { + run.calls = append(run.calls, concurrentCall{err: listErr}) + break + } + result, callErr := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "typed_array_tool"}) + run.calls = append(run.calls, concurrentCall{list: list, result: result, err: callErr}) + if callErr != nil { + break + } + } + calls <- run + } + workers.Add(2) + go runClient(modern, true) + go runClient(legacy, false) + close(start) + go func() { + workers.Wait() + close(calls) + }() + for run := range calls { + require.Len(t, run.calls, iterations) + for _, call := range run.calls { + require.NoError(t, call.err) + require.Len(t, call.list.Tools, 1) + if run.modern { + assert.NotNil(t, call.list.Tools[0].OutputSchema) + assert.JSONEq(t, `["one","two"]`, mustMarshalJSON(t, call.result.StructuredContent)) + require.Len(t, call.result.Content, 1) + assert.Equal(t, `["one","two"]`, call.result.Content[0].(*mcp.TextContent).Text) + } else { + assert.Nil(t, call.list.Tools[0].OutputSchema) + assert.Nil(t, call.result.StructuredContent) + require.Len(t, call.result.Content, 1) + assert.Equal(t, "legacy array text", call.result.Content[0].(*mcp.TextContent).Text) + } + } + } + + require.Nil(t, tool.Tool.OutputSchema, "registration must not mutate the shared definition") +} + +func TestTypedScalarOutputProtocolGatePreservesHandlerText(t *testing.T) { + tool := NewServerToolWithContextHandler( + mcp.Tool{Name: "typed_scalar_tool"}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, string, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "custom legacy text"}}, + }, "structured scalar", nil + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + tool.RegisterFunc(server, nil) + legacy := connectTypedTestClient(t, server, "2025-11-25") + + list, err := legacy.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + assert.Nil(t, list.Tools[0].OutputSchema) + + result, err := legacy.CallTool(context.Background(), &mcp.CallToolParams{Name: "typed_scalar_tool"}) + require.NoError(t, err) + assert.Nil(t, result.StructuredContent) + require.Len(t, result.Content, 1, "SDK scalar fallback must not replace or duplicate handler text") + assert.Equal(t, "custom legacy text", result.Content[0].(*mcp.TextContent).Text) +} + +func TestTypedLegacyOutputGateRemovesFallbackWithEmptyHandlerContent(t *testing.T) { + tool := NewServerToolWithContextHandler( + mcp.Tool{Name: "typed_empty_content_tool"}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, []string, error) { + return &mcp.CallToolResult{Content: []mcp.Content{}}, []string{"structured"}, nil + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + tool.RegisterFunc(server, nil) + legacy := connectTypedTestClient(t, server, "2025-11-25") + + result, err := legacy.CallTool(context.Background(), &mcp.CallToolParams{Name: "typed_empty_content_tool"}) + require.NoError(t, err) + assert.Nil(t, result.StructuredContent) + assert.Empty(t, result.Content, "the SDK fallback is not part of the legacy handler result") +} + +func TestTypedOutputMiddlewareShortCircuitPreservesErrorResult(t *testing.T) { + type output struct { + Required string `json:"required"` + } + handlerCalls := 0 + tool := NewServerToolWithContextHandler( + mcp.Tool{Name: "typed_short_circuit_tool"}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, output, error) { + handlerCalls++ + return nil, output{Required: "unexpected"}, nil + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + tool.RegisterFunc(server, nil, func(_ mcp.ToolHandler) mcp.ToolHandler { + return func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "blocked"}}, + IsError: true, + }, nil + } + }) + session := connectTypedTestClient(t, server, "") + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "typed_short_circuit_tool"}) + require.NoError(t, err) + require.True(t, result.IsError) + require.Len(t, result.Content, 1) + assert.Equal(t, "blocked", result.Content[0].(*mcp.TextContent).Text) + assert.Nil(t, result.StructuredContent) + assert.Zero(t, handlerCalls) +} + +func TestTypedOutputGateUsesCurrentRequestVersionNotInitializeRequest(t *testing.T) { + tool := NewServerToolWithContextHandler( + mcp.Tool{Name: "typed_initialize_version_tool"}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, []string, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "legacy initialize text"}}, + }, []string{"one"}, nil + }, + ) + server := mcp.NewServer( + &mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, + &mcp.ServerOptions{SupportedProtocolVersions: []string{"2025-11-25"}}, + ) + tool.RegisterFunc(server, nil) + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = serverSession.Close() }) + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil) + transport := protocolVersionTransport{ + Transport: clientTransport, + protocolVersion: ProtocolVersionMultiRoundTrip, + } + session, err := client.Connect(context.Background(), transport, &mcp.ClientSessionOptions{ + ProtocolVersion: "2025-11-25", + }) + require.NoError(t, err) + t.Cleanup(func() { _ = session.Close() }) + require.Equal(t, "2025-11-25", session.InitializeResult().ProtocolVersion) + + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + assert.Nil(t, list.Tools[0].OutputSchema) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "typed_initialize_version_tool"}) + require.NoError(t, err) + assert.Nil(t, result.StructuredContent) + require.Len(t, result.Content, 1) + assert.Equal(t, "legacy initialize text", result.Content[0].(*mcp.TextContent).Text) +} + +func TestTypedNullableOutputPreservesProtocolSemantics(t *testing.T) { + type output struct { + Value *string `json:"value"` + } + tool := NewServerToolWithContextHandler( + mcp.Tool{Name: "typed_nullable_tool"}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, output, error) { + return &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "legacy null text"}}, + }, output{}, nil + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + tool.RegisterFunc(server, nil) + modern := connectTypedTestClient(t, server, "") + legacy := connectTypedTestClient(t, server, "2025-11-25") + + modernResult, err := modern.CallTool(context.Background(), &mcp.CallToolParams{Name: "typed_nullable_tool"}) + require.NoError(t, err) + assert.JSONEq(t, `{"value":null}`, mustMarshalJSON(t, modernResult.StructuredContent)) + require.Len(t, modernResult.Content, 1) + assert.JSONEq(t, `{"value":null}`, modernResult.Content[0].(*mcp.TextContent).Text) + + legacyResult, err := legacy.CallTool(context.Background(), &mcp.CallToolParams{Name: "typed_nullable_tool"}) + require.NoError(t, err) + assert.Nil(t, legacyResult.StructuredContent) + require.Len(t, legacyResult.Content, 1) + assert.Equal(t, "legacy null text", legacyResult.Content[0].(*mcp.TextContent).Text) +} + +func TestTypedToolSupportsExplicitRootUnionOutputSchema(t *testing.T) { + tool := NewServerToolWithContextHandler( + mcp.Tool{ + Name: "union_output_tool", + OutputSchema: json.RawMessage(`{ + "oneOf":[ + {"type":"string"}, + {"type":"array","items":{"type":"string"}} + ] + }`), + }, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, json.RawMessage, error) { + return nil, json.RawMessage(`["one","two"]`), nil + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + tool.RegisterFunc(server, nil) + session := connectTypedTestClient(t, server, "") + + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + assert.Contains(t, mustMarshalJSON(t, list.Tools[0].OutputSchema), `"oneOf"`) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "union_output_tool"}) + require.NoError(t, err) + require.False(t, result.IsError) + assert.JSONEq(t, `["one","two"]`, mustMarshalJSON(t, result.StructuredContent)) +} + +func TestTypedOutputProtocolGateUsesStatelessRequestVersion(t *testing.T) { + inputSchema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string", Extra: map[string]any{"x-mcp-header": "owner"}}, + }, + } + modernTool := &mcp.Tool{ + Name: "typed_tool", + InputSchema: inputSchema, + OutputSchema: json.RawMessage(`{"type":"object"}`), + } + legacyTool := &mcp.Tool{Name: "typed_tool", InputSchema: inputSchema} + registration := &typedToolRegistration{ + name: "typed_tool", + modernTool: modernTool, + legacyTool: legacyTool, + modernRuntimeTool: modernTool, + legacyRuntimeTool: legacyTool, + hasTypedOutput: true, + } + wrapped := typedOutputMiddleware(&typedToolRegistrationSet{byName: map[string]*typedToolRegistration{ + "typed_tool": registration, + }})(func(_ context.Context, method string, _ mcp.Request) (mcp.Result, error) { + require.Equal(t, MCPMethodToolsList, method) + return &mcp.ListToolsResult{Tools: []*mcp.Tool{modernTool}}, nil + }) + + for _, tc := range []struct { + name string + protocolVersion string + wantTool *mcp.Tool + }{ + {name: "stateless modern", protocolVersion: ProtocolVersionMultiRoundTrip, wantTool: modernTool}, + {name: "stateless legacy", protocolVersion: "2025-11-25", wantTool: legacyTool}, + {name: "unknown version defaults to legacy", protocolVersion: "2027-01-01", wantTool: legacyTool}, + {name: "absent version defaults to legacy", wantTool: legacyTool}, + } { + t.Run(tc.name, func(t *testing.T) { + meta := mcp.Meta{} + if tc.protocolVersion != "" { + meta[mcp.MetaKeyProtocolVersion] = tc.protocolVersion + } + listResult, err := wrapped(context.Background(), MCPMethodToolsList, &mcp.ListToolsRequest{ + Params: &mcp.ListToolsParams{Meta: meta}, + }) + require.NoError(t, err) + list := listResult.(*mcp.ListToolsResult) + require.Len(t, list.Tools, 1) + assert.Same(t, tc.wantTool, list.Tools[0]) + assert.Contains(t, mustMarshalJSON(t, list.Tools[0].InputSchema), `"x-mcp-header":"owner"`, + "legacy and unknown-version variants must retain routing metadata") + }) + } + + inputRequired := &mcp.CallToolResult{ + StructuredContent: map[string]any{"awaiting": true}, + InputRequests: mcp.InputRequestMap{"input": &mcp.ElicitParams{Mode: "form"}}, + } + rawMiddleware := typedOutputMiddleware(nil)(func(context.Context, string, mcp.Request) (mcp.Result, error) { + return inputRequired, nil + }) + result, err := rawMiddleware(context.Background(), MCPMethodToolsCall, &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{Name: "raw_tool"}, + }) + require.NoError(t, err) + assert.Same(t, inputRequired, result, "non-typed multi-round-trip structured content must pass through") + + typedRegistration := &typedToolRegistration{ + name: "typed_tool", + modernTool: modernTool, + legacyTool: legacyTool, + modernRuntimeTool: modernTool, + legacyRuntimeTool: legacyTool, + hasTypedOutput: true, + } + typedMiddleware := typedOutputMiddleware(&typedToolRegistrationSet{byName: map[string]*typedToolRegistration{ + "typed_tool": typedRegistration, + }})(func(context.Context, string, mcp.Request) (mcp.Result, error) { + return inputRequired, nil + }) + for _, tc := range []struct { + name string + protocolVersion string + wantStructured bool + }{ + {name: "modern", protocolVersion: ProtocolVersionMultiRoundTrip, wantStructured: true}, + {name: "legacy", protocolVersion: "2025-11-25"}, + } { + t.Run("typed awaiting form "+tc.name, func(t *testing.T) { + meta := mcp.Meta{} + if tc.protocolVersion != "" { + meta[mcp.MetaKeyProtocolVersion] = tc.protocolVersion + } + result, err := typedMiddleware(context.Background(), MCPMethodToolsCall, &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{Name: "typed_tool", Meta: meta}, + }) + require.NoError(t, err) + callResult := result.(*mcp.CallToolResult) + if tc.wantStructured { + assert.Same(t, inputRequired, callResult) + assert.Equal(t, map[string]any{"awaiting": true}, callResult.StructuredContent) + } else { + assert.NotSame(t, inputRequired, callResult) + assert.Nil(t, callResult.StructuredContent, "legacy typed tools must not expose structured output") + } + assert.Equal(t, inputRequired.InputRequests, callResult.InputRequests) + }) + } +} + +func TestTypedOutputGateLeavesRawToolResultsAlone(t *testing.T) { + typedOverride := NewServerToolWithContextHandler( + mcp.Tool{Name: "raw_tool"}, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, map[string]any, error) { + return nil, map[string]any{"typed": true}, nil + }, + ) + rawTool := NewServerTool( + mcp.Tool{ + Name: "raw_tool", + InputSchema: &jsonschema.Schema{Type: "object"}, + OutputSchema: json.RawMessage(`{"type":"object"}`), + }, + testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return &mcp.CallToolResult{ + StructuredContent: map[string]any{"state": "awaiting"}, + }, nil + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + typedOverride.RegisterFunc(server, nil) + rawTool.RegisterFunc(server, nil) + session := connectTypedTestClient(t, server, "2025-11-25") + + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + assert.JSONEq(t, `{"type":"object"}`, mustMarshalJSON(t, list.Tools[0].OutputSchema), "raw tool schemas must remain unchanged") + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "raw_tool"}) + require.NoError(t, err) + require.NotNil(t, result.StructuredContent, "raw structured content without typed output must remain untouched") +} + +func connectTypedTestClient(t *testing.T, server *mcp.Server, protocolVersion string) *mcp.ClientSession { + t.Helper() + serverTransport, clientTransport := mcp.NewInMemoryTransports() + if protocolVersion != "" { + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) { + if method == "server/discover" { + return nil, errors.New("legacy client uses initialize") + } + return next(ctx, method, request) + } + }) + } + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = serverSession.Close() }) + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, &mcp.ClientOptions{}) + var transport mcp.Transport = clientTransport + if protocolVersion != "" { + transport = protocolVersionTransport{Transport: clientTransport, protocolVersion: protocolVersion} + } + clientSession, err := client.Connect(context.Background(), transport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = clientSession.Close() }) + if protocolVersion != "" { + require.Equal(t, protocolVersion, clientSession.InitializeResult().ProtocolVersion) + } + return clientSession +} + +func mustMarshalJSON(t *testing.T, value any) string { + t.Helper() + encoded, err := json.Marshal(value) + require.NoError(t, err) + return string(encoded) +} + +type protocolVersionTransport struct { + mcp.Transport + protocolVersion string +} + +func (t protocolVersionTransport) Connect(ctx context.Context) (mcp.Connection, error) { + connection, err := t.Transport.Connect(ctx) + if err != nil { + return nil, err + } + return protocolVersionConnection{Connection: connection, protocolVersion: t.protocolVersion}, nil +} + +type protocolVersionConnection struct { + mcp.Connection + protocolVersion string +} + +func (c protocolVersionConnection) Write(ctx context.Context, message jsonrpc.Message) error { + request, ok := message.(*jsonrpc.Request) + if !ok || request.Method != "initialize" { + return c.Connection.Write(ctx, message) + } + var params map[string]json.RawMessage + if err := json.Unmarshal(request.Params, ¶ms); err != nil { + return err + } + version, err := json.Marshal(c.protocolVersion) + if err != nil { + return err + } + params["protocolVersion"] = version + encoded, err := json.Marshal(params) + if err != nil { + return err + } + requestCopy := *request + requestCopy.Params = encoded + return c.Connection.Write(ctx, &requestCopy) +} diff --git a/pkg/inventory/typed_schema.go b/pkg/inventory/typed_schema.go new file mode 100644 index 0000000000..664db38f11 --- /dev/null +++ b/pkg/inventory/typed_schema.go @@ -0,0 +1,444 @@ +package inventory + +import ( + "encoding/json" + "fmt" + "maps" + "reflect" + "slices" + "sort" + "strings" + "sync" + "time" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// ProtocolEra selects the wire-compatible registration variant. +type ProtocolEra uint8 + +const ( + // ProtocolEraDynamic selects a variant at request time. + ProtocolEraDynamic ProtocolEra = iota + // ProtocolEraLegacy registers without an output schema or generated output. + ProtocolEraLegacy + // ProtocolEraModern registers the 2026-07-28 output-schema variant. + ProtocolEraModern +) + +// ProtocolEraForVersion selects modern behavior for SDK-supported protocol +// versions from 2026-07-28 onward. Unknown versions remain legacy. +func ProtocolEraForVersion(version string) ProtocolEra { + return protocolEraForSupportedVersion(version, mcp.SupportedProtocolVersions()) +} + +func protocolEraForSupportedVersion(version string, supported []string) ProtocolEra { + if _, err := time.Parse(time.DateOnly, version); err != nil { + return ProtocolEraLegacy + } + if version < ProtocolVersionMultiRoundTrip || !slices.Contains(supported, version) { + return ProtocolEraLegacy + } + return ProtocolEraModern +} + +// SchemaEnum adds an enum to a property of an inferred schema. Path is a +// dot-separated JSON Schema path; use "items" to descend into array items. +type SchemaEnum struct { + Path string + Values []string +} + +// EnumSchema returns a string schema constrained to values. +func EnumSchema(values ...string) *jsonschema.Schema { + enum := make([]any, len(values)) + for i, value := range values { + enum[i] = value + } + return &jsonschema.Schema{Type: "string", Enum: enum} +} + +// WithEnum clones schema and adds a string enum at the requested schema path. +// Array item schemas are addressed with the "items" path segment. +func WithEnum(schema *jsonschema.Schema, path string, values ...string) (*jsonschema.Schema, error) { + if schema == nil { + return nil, fmt.Errorf("schema is nil") + } + segments := strings.Split(path, ".") + if path == "" { + return nil, fmt.Errorf("schema path is empty") + } + clonedSchema := CloneSchema(schema) + current := clonedSchema + for _, segment := range segments { + switch segment { + case "items": + if current.Items == nil { + return nil, fmt.Errorf("schema path %q has no items schema", path) + } + current = current.Items + default: + if current.Properties == nil || current.Properties[segment] == nil { + return nil, fmt.Errorf("schema path %q has no property %q", path, segment) + } + current = current.Properties[segment] + } + } + current.Type = "string" + current.Types = nil + current.Enum = make([]any, len(values)) + for i, value := range values { + current.Enum[i] = value + } + return clonedSchema, nil +} + +type inferredSchemaKey struct { + goType reflect.Type + options string + enums string + inputSchema bool +} + +type inferredSchemaEntry struct { + once sync.Once + schema *jsonschema.Schema + err error +} + +var inferredSchemaCache sync.Map +var explicitSchemaCache sync.Map +var ownedSchemaPointers sync.Map +var annotatedSchemaCache sync.Map +var defaultObjectInputSchemaEntry inferredSchemaEntry + +func cachedObjectInputSchema() (*jsonschema.Schema, error) { + defaultObjectInputSchemaEntry.once.Do(func() { + defaultObjectInputSchemaEntry.schema, defaultObjectInputSchemaEntry.err = CachedSchema(&jsonschema.Schema{Type: "object"}) + }) + return defaultObjectInputSchemaEntry.schema, defaultObjectInputSchemaEntry.err +} + +// CloneSchemaWithoutDefaults returns a deep schema copy with default keywords +// removed. The MCP SDK applies input-schema defaults before decoding arguments, +// so use this for runtime validation schemas when omitted arguments must stay +// omitted. The original advertised schema is unchanged. +func CloneSchemaWithoutDefaults(schema *jsonschema.Schema) *jsonschema.Schema { + clonedSchema := CloneSchema(schema) + removeSchemaDefaults(clonedSchema) + return clonedSchema +} + +// CloneSchema deep-copies schema nodes, numeric bounds, and mutable metadata. +func CloneSchema(schema *jsonschema.Schema) *jsonschema.Schema { + clonedSchema := schema.CloneSchemas() + cloneSchemaMetadata(clonedSchema) + return clonedSchema +} + +type schemaValueVisit struct { + typeOf reflect.Type + ptr uintptr +} + +func cloneSchemaMetadata(schema *jsonschema.Schema) { + if schema == nil { + return + } + schema.MultipleOf = cloneSchemaPointer(schema.MultipleOf) + schema.Minimum = cloneSchemaPointer(schema.Minimum) + schema.Maximum = cloneSchemaPointer(schema.Maximum) + schema.ExclusiveMinimum = cloneSchemaPointer(schema.ExclusiveMinimum) + schema.ExclusiveMaximum = cloneSchemaPointer(schema.ExclusiveMaximum) + schema.MinLength = cloneSchemaPointer(schema.MinLength) + schema.MaxLength = cloneSchemaPointer(schema.MaxLength) + schema.MinItems = cloneSchemaPointer(schema.MinItems) + schema.MaxItems = cloneSchemaPointer(schema.MaxItems) + schema.MinContains = cloneSchemaPointer(schema.MinContains) + schema.MaxContains = cloneSchemaPointer(schema.MaxContains) + schema.MinProperties = cloneSchemaPointer(schema.MinProperties) + schema.MaxProperties = cloneSchemaPointer(schema.MaxProperties) + schema.Types = slices.Clone(schema.Types) + schema.Enum = cloneSchemaValue(reflect.ValueOf(schema.Enum), make(map[schemaValueVisit]reflect.Value)).Interface().([]any) + schema.Default = slices.Clone(schema.Default) + if schema.Const != nil { + value := any(nil) + if *schema.Const != nil { + value = cloneSchemaValue(reflect.ValueOf(*schema.Const), make(map[schemaValueVisit]reflect.Value)).Interface() + } + schema.Const = &value + } + schema.Examples = cloneSchemaValue(reflect.ValueOf(schema.Examples), make(map[schemaValueVisit]reflect.Value)).Interface().([]any) + schema.Required = slices.Clone(schema.Required) + if schema.Vocabulary != nil { + schema.Vocabulary = maps.Clone(schema.Vocabulary) + } + if schema.DependencyStrings != nil { + dependencies := make(map[string][]string, len(schema.DependencyStrings)) + for key, values := range schema.DependencyStrings { + dependencies[key] = slices.Clone(values) + } + schema.DependencyStrings = dependencies + } + if schema.DependentRequired != nil { + dependencies := make(map[string][]string, len(schema.DependentRequired)) + for key, values := range schema.DependentRequired { + dependencies[key] = slices.Clone(values) + } + schema.DependentRequired = dependencies + } + schema.PropertyOrder = slices.Clone(schema.PropertyOrder) + if schema.Extra != nil { + schema.Extra = cloneSchemaValue(reflect.ValueOf(schema.Extra), make(map[schemaValueVisit]reflect.Value)).Interface().(map[string]any) + } + for _, child := range schemaChildren(schema) { + cloneSchemaMetadata(child) + } +} + +func cloneSchemaPointer[T int | float64](value *T) *T { + if value == nil { + return nil + } + cloned := *value + return &cloned +} + +// cloneSchemaValue preserves the concrete metadata types while detaching +// nested mutable collections from schemas retained by callers. +func cloneSchemaValue(value reflect.Value, visited map[schemaValueVisit]reflect.Value) reflect.Value { + if !value.IsValid() { + return value + } + switch value.Kind() { + case reflect.Interface: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + cloned := cloneSchemaValue(value.Elem(), visited) + result := reflect.New(value.Type()).Elem() + result.Set(cloned) + return result + case reflect.Pointer, reflect.Map, reflect.Slice: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + visit := schemaValueVisit{typeOf: value.Type(), ptr: value.Pointer()} + if cloned, ok := visited[visit]; ok { + return cloned + } + switch value.Kind() { + case reflect.Pointer: + result := reflect.New(value.Type().Elem()) + visited[visit] = result + result.Elem().Set(cloneSchemaValue(value.Elem(), visited)) + return result + case reflect.Map: + result := reflect.MakeMapWithSize(value.Type(), value.Len()) + visited[visit] = result + iter := value.MapRange() + for iter.Next() { + result.SetMapIndex(iter.Key(), cloneSchemaValue(iter.Value(), visited)) + } + return result + default: + result := reflect.MakeSlice(value.Type(), value.Len(), value.Len()) + visited[visit] = result + for i := range value.Len() { + result.Index(i).Set(cloneSchemaValue(value.Index(i), visited)) + } + return result + } + case reflect.Array: + result := reflect.New(value.Type()).Elem() + for i := range value.Len() { + result.Index(i).Set(cloneSchemaValue(value.Index(i), visited)) + } + return result + case reflect.Struct: + if !value.CanInterface() { + return value + } + result := reflect.New(value.Type()).Elem() + result.Set(value) + for i := range value.NumField() { + if value.Type().Field(i).IsExported() { + result.Field(i).Set(cloneSchemaValue(value.Field(i), visited)) + } + } + return result + default: + return value + } +} + +func schemaChildren(schema *jsonschema.Schema) []*jsonschema.Schema { + children := []*jsonschema.Schema{ + schema.Items, + schema.AdditionalItems, + schema.Contains, + schema.UnevaluatedItems, + schema.AdditionalProperties, + schema.PropertyNames, + schema.UnevaluatedProperties, + schema.Not, + schema.If, + schema.Then, + schema.Else, + schema.ContentSchema, + } + children = append(children, schema.PrefixItems...) + children = append(children, schema.ItemsArray...) + children = append(children, schema.AllOf...) + children = append(children, schema.AnyOf...) + children = append(children, schema.OneOf...) + for _, schemas := range []map[string]*jsonschema.Schema{ + schema.Defs, + schema.Definitions, + schema.Properties, + schema.PatternProperties, + schema.DependentSchemas, + schema.DependencySchemas, + } { + for _, child := range schemas { + children = append(children, child) + } + } + return children +} + +func removeSchemaDefaults(schema *jsonschema.Schema) { + if schema == nil { + return + } + schema.Default = nil + for _, child := range schemaChildren(schema) { + removeSchemaDefaults(child) + } +} + +// CachedSchema clones an explicit schema once per process and returns the +// immutable shared pointer. It is useful for schemas prepared by tool +// definitions that are reconstructed for request-scoped servers. +func CachedSchema(schema *jsonschema.Schema) (*jsonschema.Schema, error) { + if schema == nil { + return nil, nil + } + if _, owned := ownedSchemaPointers.Load(schema); owned { + return schema, nil + } + encoded, err := json.Marshal(schema) + if err != nil { + return nil, fmt.Errorf("marshal explicit schema: %w", err) + } + key := string(encoded) + if cached, ok := explicitSchemaCache.Load(key); ok { + return cached.(*jsonschema.Schema), nil + } + clonedSchema := CloneSchema(schema) + cached, _ := explicitSchemaCache.LoadOrStore(key, clonedSchema) + result := cached.(*jsonschema.Schema) + ownedSchemaPointers.Store(result, struct{}{}) + return result, nil +} + +// CachedSchemaFor infers a schema once per process for the Go type and +// immutable inference options. The returned schema is shared and must not be +// mutated. Enum overrides are applied while constructing the cached value. +func CachedSchemaFor[T any](options *jsonschema.ForOptions, enums ...SchemaEnum) (*jsonschema.Schema, error) { + return cachedSchemaFor(reflect.TypeFor[T](), options, enums, false) +} + +// CachedInputSchemaFor is like CachedSchemaFor and adds the standard owner/repo +// routing annotations before caching the immutable schema. +func CachedInputSchemaFor[T any](options *jsonschema.ForOptions, enums ...SchemaEnum) (*jsonschema.Schema, error) { + return cachedSchemaFor(reflect.TypeFor[T](), options, enums, true) +} + +func cachedSchemaFor(goType reflect.Type, options *jsonschema.ForOptions, enums []SchemaEnum, inputSchema bool) (*jsonschema.Schema, error) { + // Match SDK input inference without removing nullable output semantics. + if inputSchema && goType.Kind() == reflect.Pointer { + goType = goType.Elem() + } + optionsKey, err := schemaOptionsKey(options) + if err != nil { + return nil, err + } + enumKey, err := json.Marshal(enums) + if err != nil { + return nil, fmt.Errorf("marshal schema enum overrides: %w", err) + } + key := inferredSchemaKey{ + goType: goType, + options: optionsKey, + enums: string(enumKey), + inputSchema: inputSchema, + } + entryValue, _ := inferredSchemaCache.LoadOrStore(key, &inferredSchemaEntry{}) + entry := entryValue.(*inferredSchemaEntry) + entry.once.Do(func() { + entry.schema, entry.err = jsonschema.ForType(goType, options) + if entry.err != nil { + return + } + for _, override := range enums { + entry.schema, entry.err = WithEnum(entry.schema, override.Path, override.Values...) + if entry.err != nil { + return + } + } + if inputSchema { + tool := mcp.Tool{InputSchema: entry.schema} + AnnotateHeaderParams(&tool) + entry.schema = tool.InputSchema.(*jsonschema.Schema) + } + }) + return entry.schema, entry.err +} + +func schemaOptionsKey(options *jsonschema.ForOptions) (string, error) { + if options == nil { + return "", nil + } + type schemaEntry struct { + Type string `json:"type"` + Schema json.RawMessage `json:"schema"` + } + types := make([]reflect.Type, 0, len(options.TypeSchemas)) + for goType := range options.TypeSchemas { + types = append(types, goType) + } + sort.Slice(types, func(i, j int) bool { + return schemaTypeKey(types[i]) < schemaTypeKey(types[j]) + }) + entries := make([]schemaEntry, 0, len(types)) + for _, goType := range types { + encoded, err := json.Marshal(options.TypeSchemas[goType]) + if err != nil { + return "", fmt.Errorf("marshal schema override for %s: %w", goType, err) + } + entries = append(entries, schemaEntry{ + Type: schemaTypeKey(goType), + Schema: encoded, + }) + } + encoded, err := json.Marshal(struct { + IgnoreInvalidTypes bool `json:"ignoreInvalidTypes"` + TypeSchemas []schemaEntry `json:"typeSchemas"` + }{ + IgnoreInvalidTypes: options.IgnoreInvalidTypes, + TypeSchemas: entries, + }) + if err != nil { + return "", fmt.Errorf("marshal schema inference options: %w", err) + } + return string(encoded), nil +} + +func schemaTypeKey(goType reflect.Type) string { + if goType.Name() != "" { + return goType.PkgPath() + "." + goType.Name() + } + return goType.String() +} diff --git a/pkg/scopes/map.go b/pkg/scopes/map.go index ab6a7867e2..2bc1bd0bb2 100644 --- a/pkg/scopes/map.go +++ b/pkg/scopes/map.go @@ -1,6 +1,10 @@ package scopes -import "github.com/github/github-mcp-server/pkg/inventory" +import ( + "encoding/json" + + "github.com/github/github-mcp-server/pkg/inventory" +) // ToolScopeMap maps tool names to their complete scope access policies. type ToolScopeMap map[string]inventory.ScopeAccess @@ -8,8 +12,9 @@ type ToolScopeMap map[string]inventory.ScopeAccess // ToolScopeAccess is the immutable request-time subset of a tool scope policy. // Its maximum scopes stay private so callers cannot mutate the global lookup. type ToolScopeAccess struct { - maxScopes []string - challenge inventory.ScopeChallenge + maxScopes []string + challenge inventory.ScopeChallenge + argumentNormalizer inventory.InputNormalizer } var globalToolScopeMap map[string]ToolScopeAccess @@ -34,8 +39,9 @@ func SetGlobalToolScopeMap(m ToolScopeMap) { panic("dynamic scope challenge requires exhaustive maximum scopes") } globalToolScopeMap[name] = ToolScopeAccess{ - maxScopes: append([]string(nil), access.Scopes...), - challenge: access.Challenge, + maxScopes: append([]string(nil), access.Scopes...), + challenge: access.Challenge, + argumentNormalizer: access.ArgumentNormalizer, } } } @@ -57,6 +63,19 @@ func (access ToolScopeAccess) ResolveChallenge(arguments map[string]any, activeS return access.challenge(arguments, activeScopes) } +// NormalizeArguments applies the tool's compatibility normalizer before +// evaluating its dynamic scope challenge. +func (access ToolScopeAccess) NormalizeArguments(arguments json.RawMessage) (json.RawMessage, bool, error) { + if access.argumentNormalizer == nil { + return arguments, false, nil + } + if len(arguments) == 0 { + arguments = json.RawMessage(`{}`) + } + normalized, err := access.argumentNormalizer(arguments) + return normalized, true, err +} + // MaximumScopes returns a copy of the exhaustive upper bound. func (access ToolScopeAccess) MaximumScopes() []string { return append([]string(nil), access.maxScopes...) @@ -69,6 +88,9 @@ func GetToolScopeMapFromInventory(inv *inventory.Inventory) ToolScopeMap { if tool.ScopeAccess.Challenge != nil { access := tool.ScopeAccess access.Scopes = append([]string(nil), access.Scopes...) + if access.Dynamic { + access.ArgumentNormalizer = tool.GetInputNormalizer() + } result[tool.Tool.Name] = access } } From 9cbd4b272a7df65aa9eac14fab33af46a1f89dbd Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 7 Oct 2026 00:11:16 +0200 Subject: [PATCH 44/61] feat(context): add typed tool schemas and outputs (#3377) * feat(context): add typed tool schemas and outputs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(context): preserve legacy input acceptance Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(context): preserve input schema roots and optionality Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(context): validate direct team member calls Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(context): pin structured output and legacy text fixtures Describe profile timestamps and validate null and empty team outputs against modern schemas while retaining exact legacy text. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(context): emit structured empty team collections Keep legacy null text while returning nonnil typed slices so modern clients receive structured content for empty successful results. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(context): preserve legacy team input schemas Keep empty identifier rejection in the handler without tightening the published input schema. Pin modern text to structured output and preserve the legacy formatter fixtures. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(context): preserve advertised input schema compatibility Keep legacy-visible schemas byte-compatible while applying unknown-field acceptance only to the cached runtime validation schema. Verify modern DTO text parity and preserve legacy formatted text. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(context): reject explicit null team user arguments Preserve the legacy optional user type error before typed decoding, without changing omitted or empty user fallback. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(context): preserve exact routing argument keys Ignore case-variant routing aliases before typed decoding while retaining canonical fields and unrelated unknown arguments. Cover direct handlers and legacy/modern registered calls, authenticated fallback, canonical null rejection, and missing identifiers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- pkg/github/__toolsnaps__/get_me.snap | 93 ++- .../__toolsnaps__/get_team_members.snap | 11 +- pkg/github/__toolsnaps__/get_teams.snap | 47 +- pkg/github/context_tools.go | 176 ++++-- pkg/github/context_tools_test.go | 582 +++++++++++++++++- 5 files changed, 848 insertions(+), 61 deletions(-) diff --git a/pkg/github/__toolsnaps__/get_me.snap b/pkg/github/__toolsnaps__/get_me.snap index 4196c6734d..f8ffef8004 100644 --- a/pkg/github/__toolsnaps__/get_me.snap +++ b/pkg/github/__toolsnaps__/get_me.snap @@ -18,5 +18,96 @@ "properties": {}, "type": "object" }, - "name": "get_me" + "name": "get_me", + "outputSchema": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_team_members.snap b/pkg/github/__toolsnaps__/get_team_members.snap index a95be1ceda..4d11c5d3b1 100644 --- a/pkg/github/__toolsnaps__/get_team_members.snap +++ b/pkg/github/__toolsnaps__/get_team_members.snap @@ -22,5 +22,14 @@ ], "type": "object" }, - "name": "get_team_members" + "name": "get_team_members", + "outputSchema": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_teams.snap b/pkg/github/__toolsnaps__/get_teams.snap index 49bc62ed9f..ca0debf9b6 100644 --- a/pkg/github/__toolsnaps__/get_teams.snap +++ b/pkg/github/__toolsnaps__/get_teams.snap @@ -14,5 +14,50 @@ }, "type": "object" }, - "name": "get_teams" + "name": "get_teams", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "org": { + "type": "string" + }, + "teams": { + "items": { + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "name", + "slug", + "description" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } + }, + "required": [ + "org", + "teams" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } } \ No newline at end of file diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index da1a42d694..72b2c3e0a0 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -1,8 +1,11 @@ package github import ( + "bytes" "context" "encoding/json" + "fmt" + "strings" "time" ghErrors "github.com/github/github-mcp-server/pkg/errors" @@ -19,6 +22,74 @@ import ( // GetMeUIResourceURI is the URI for the get_me tool's MCP App UI resource. const GetMeUIResourceURI = "ui://github-mcp-server/get-me" +type GetMeInput struct{} + +type GetTeamsInput struct { + User string `json:"user,omitempty" jsonschema:"Username to get teams for. If not provided, uses the authenticated user."` +} + +type GetTeamMembersInput struct { + Org string `json:"org" jsonschema:"Organization login (owner) that contains the team."` + TeamSlug string `json:"team_slug" jsonschema:"Team slug"` +} + +func contextToolInputSchema[In any](descriptions map[string]string) *jsonschema.Schema { + schema, err := jsonschema.For[In](nil) + if err != nil { + panic(fmt.Sprintf("failed to generate context tool input schema: %v", err)) + } + if schema.Properties == nil { + schema.Properties = make(map[string]*jsonschema.Schema) + } + schema.AdditionalProperties = nil + for name, description := range descriptions { + schema.Properties[name].Description = description + } + return schema +} + +func contextToolValidationSchema(schema *jsonschema.Schema) *jsonschema.Schema { + validationSchema := schema.CloneSchemas() + validationSchema.AdditionalProperties = &jsonschema.Schema{} + return validationSchema +} + +func normalizeGetTeamsInput(arguments json.RawMessage) (json.RawMessage, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(arguments, &fields); err != nil { + return nil, err + } + if user, exists := fields["user"]; exists && bytes.Equal(bytes.TrimSpace(user), []byte("null")) { + return nil, &inventory.ToolInputError{Message: "parameter user is not of type string, is "} + } + return normalizeContextRoutingKeys(arguments, fields, "user") +} + +func normalizeGetTeamMembersInput(arguments json.RawMessage) (json.RawMessage, error) { + var fields map[string]json.RawMessage + if err := json.Unmarshal(arguments, &fields); err != nil { + return nil, err + } + return normalizeContextRoutingKeys(arguments, fields, "org", "team_slug") +} + +func normalizeContextRoutingKeys(arguments json.RawMessage, fields map[string]json.RawMessage, routingKeys ...string) (json.RawMessage, error) { + changed := false + for name := range fields { + for _, key := range routingKeys { + if name != key && strings.EqualFold(name, key) { + delete(fields, name) + changed = true + break + } + } + } + if !changed { + return arguments, nil + } + return json.Marshal(fields) +} + // UserDetails contains additional fields about a GitHub user not already // present in MinimalUser. Used by get_me context tool but omitted from search_users. type UserDetails struct { @@ -34,8 +105,8 @@ type UserDetails struct { PublicGists int `json:"public_gists"` Followers int `json:"followers"` Following int `json:"following"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + CreatedAt time.Time `json:"created_at" jsonschema:"Account creation time in RFC3339 format."` + UpdatedAt time.Time `json:"updated_at" jsonschema:"Last profile update time in RFC3339 format."` PrivateGists int `json:"private_gists,omitempty"` TotalPrivateRepos int64 `json:"total_private_repos,omitempty"` OwnedPrivateRepos int64 `json:"owned_private_repos,omitempty"` @@ -43,7 +114,8 @@ type UserDetails struct { // GetMe creates a tool to get details of the authenticated user. func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + inputSchema := contextToolInputSchema[GetMeInput](nil) + return NewToolWithSchemaOptions[GetMeInput, MinimalUser]( ToolsetMetadataContext, mcp.Tool{ Name: "get_me", @@ -52,9 +124,7 @@ func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool { Title: t("TOOL_GET_ME_USER_TITLE", "Get my user profile"), ReadOnlyHint: true, }, - // Use json.RawMessage to ensure "properties" is included even when empty. - // OpenAI strict mode requires the properties field to be present. - InputSchema: json.RawMessage(`{"type":"object","properties":{}}`), + InputSchema: inputSchema, Meta: mcp.Meta{ "ui": map[string]any{ "resourceUri": GetMeUIResourceURI, @@ -63,10 +133,13 @@ func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool { }, }, scopes.NoScopes(), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, _ map[string]any) (*mcp.CallToolResult, any, error) { + inventory.TypedSchemaOptions{ + ValidationInputSchema: contextToolValidationSchema(inputSchema), + }, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, _ GetMeInput) (*mcp.CallToolResult, MinimalUser, error) { client, err := deps.GetClient(ctx) if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), MinimalUser{}, nil } user, res, err := client.Users.Get(ctx, "") @@ -75,7 +148,7 @@ func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool { "failed to get user", res, err, - ), nil, nil + ), MinimalUser{}, nil } // Create minimal user representation instead of returning full user object @@ -107,7 +180,7 @@ func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool { result := MarshalledTextResult(minimalUser) result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGetMe()) - return result, nil, nil + return result, minimalUser, nil }, ) } @@ -124,7 +197,10 @@ type OrganizationTeams struct { } func GetTeams(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + inputSchema := contextToolInputSchema[GetTeamsInput](map[string]string{ + "user": t("TOOL_GET_TEAMS_USER_DESCRIPTION", "Username to get teams for. If not provided, uses the authenticated user."), + }) + return NewToolWithSchemaOptions[GetTeamsInput, []OrganizationTeams]( ToolsetMetadataContext, mcp.Tool{ Name: "get_teams", @@ -133,26 +209,16 @@ func GetTeams(t translations.TranslationHelperFunc) inventory.ServerTool { Title: t("TOOL_GET_TEAMS_TITLE", "Get teams"), ReadOnlyHint: true, }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "user": { - Type: "string", - Description: t("TOOL_GET_TEAMS_USER_DESCRIPTION", "Username to get teams for. If not provided, uses the authenticated user."), - }, - }, - }, + InputSchema: inputSchema, }, scopes.RequireAll(scopes.ReadOrg), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - user, err := OptionalParam[string](args, "user") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - + inventory.TypedSchemaOptions{ + ValidationInputSchema: contextToolValidationSchema(inputSchema), + }, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GetTeamsInput) (*mcp.CallToolResult, []OrganizationTeams, error) { var username string - if user != "" { - username = user + if args.User != "" { + username = args.User } else { client, err := deps.GetClient(ctx) if err != nil { @@ -221,13 +287,22 @@ func GetTeams(t translations.TranslationHelperFunc) inventory.ServerTool { // outside contributors (trusted). Org team rosters are visible only // to org members, so confidentiality is private. result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelTeam()) - return result, nil, nil + if organizations == nil { + organizations = []OrganizationTeams{} + } + return result, organizations, nil }, + normalizeGetTeamsInput, ) } func GetTeamMembers(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + inputSchema := contextToolInputSchema[GetTeamMembersInput](map[string]string{ + "org": t("TOOL_GET_TEAM_MEMBERS_ORG_DESCRIPTION", "Organization login (owner) that contains the team."), + "team_slug": t("TOOL_GET_TEAM_MEMBERS_TEAM_SLUG_DESCRIPTION", "Team slug"), + }) + + return NewToolWithSchemaOptions[GetTeamMembersInput, []string]( ToolsetMetadataContext, mcp.Tool{ Name: "get_team_members", @@ -236,31 +311,18 @@ func GetTeamMembers(t translations.TranslationHelperFunc) inventory.ServerTool { Title: t("TOOL_GET_TEAM_MEMBERS_TITLE", "Get team members"), ReadOnlyHint: true, }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "org": { - Type: "string", - Description: t("TOOL_GET_TEAM_MEMBERS_ORG_DESCRIPTION", "Organization login (owner) that contains the team."), - }, - "team_slug": { - Type: "string", - Description: t("TOOL_GET_TEAM_MEMBERS_TEAM_SLUG_DESCRIPTION", "Team slug"), - }, - }, - Required: []string{"org", "team_slug"}, - }, + InputSchema: inputSchema, }, scopes.RequireAll(scopes.ReadOrg), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - org, err := RequiredParam[string](args, "org") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + inventory.TypedSchemaOptions{ + ValidationInputSchema: contextToolValidationSchema(inputSchema), + }, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GetTeamMembersInput) (*mcp.CallToolResult, []string, error) { + if args.Org == "" { + return utils.NewToolResultError("missing required parameter: org"), nil, nil } - - teamSlug, err := RequiredParam[string](args, "team_slug") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if args.TeamSlug == "" { + return utils.NewToolResultError("missing required parameter: team_slug"), nil, nil } gqlClient, err := deps.GetGQLClient(ctx) @@ -280,8 +342,8 @@ func GetTeamMembers(t translations.TranslationHelperFunc) inventory.ServerTool { } `graphql:"organization(login: $org)"` } vars := map[string]any{ - "org": githubv4.String(org), - "teamSlug": githubv4.String(teamSlug), + "org": githubv4.String(args.Org), + "teamSlug": githubv4.String(args.TeamSlug), } if err := gqlClient.Query(ctx, &q, vars); err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to get team members", err), nil, nil @@ -297,7 +359,11 @@ func GetTeamMembers(t translations.TranslationHelperFunc) inventory.ServerTool { // outside contributors (trusted). A team's member roster is visible // only to org members, so confidentiality is private. result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelTeam()) - return result, nil, nil + if members == nil { + members = []string{} + } + return result, members, nil }, + normalizeGetTeamMembersInput, ) } diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index a3d09ed652..52b2c58a04 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -9,8 +9,10 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" @@ -22,7 +24,7 @@ func Test_GetMe(t *testing.T) { serverTool := GetMe(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + testContextToolSnapshot[GetMeInput, MinimalUser](t, tool) // Verify some basic very important properties assert.Equal(t, "get_me", tool.Name) @@ -169,6 +171,295 @@ func Test_GetMe_OmittedArguments(t *testing.T) { assert.Equal(t, mockUser.GetHTMLURL(), returnedUser.ProfileURL) } +func TestContextToolsTypedRegistration(t *testing.T) { + mockUser := &github.User{ + Login: new("testuser"), + HTMLURL: new("https://github.com/testuser"), + CreatedAt: &github.Timestamp{Time: time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)}, + } + teamsMatcher := githubv4mock.NewQueryMatcher( + "query($login:String!){user(login: $login){organizations(first: 100){nodes{login,teams(first: 100, userLogins: [$login]){nodes{name,slug,description}}}}}}", + map[string]any{"login": "specificuser"}, + githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{ + "organizations": map[string]any{ + "nodes": []map[string]any{{ + "login": "testorg", + "teams": map[string]any{ + "nodes": []map[string]any{{ + "name": "team1", + "slug": "team1", + "description": "Team 1", + }}, + }, + }}, + }, + }, + }), + ) + membersMatcher := githubv4mock.NewQueryMatcher( + "query($org:String!$teamSlug:String!){organization(login: $org){team(slug: $teamSlug){members(first: 100){nodes{login}}}}}", + map[string]any{"org": "testorg", "teamSlug": "testteam"}, + githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "team": map[string]any{ + "members": map[string]any{ + "nodes": []map[string]any{{"login": "user1"}, {"login": "user2"}}, + }, + }, + }, + }), + ) + emptyOrganizationsMatcher := githubv4mock.NewQueryMatcher( + "query($login:String!){user(login: $login){organizations(first: 100){nodes{login,teams(first: 100, userLogins: [$login]){nodes{name,slug,description}}}}}}", + map[string]any{"login": "no-orgs"}, + githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{"organizations": map[string]any{"nodes": []any{}}}, + }), + ) + emptyTeamsMatcher := githubv4mock.NewQueryMatcher( + "query($login:String!){user(login: $login){organizations(first: 100){nodes{login,teams(first: 100, userLogins: [$login]){nodes{name,slug,description}}}}}}", + map[string]any{"login": "no-teams"}, + githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{"organizations": map[string]any{"nodes": []any{ + map[string]any{"login": "testorg", "teams": map[string]any{"nodes": []any{}}}, + }}}, + }), + ) + emptyMembersMatcher := githubv4mock.NewQueryMatcher( + "query($org:String!$teamSlug:String!){organization(login: $org){team(slug: $teamSlug){members(first: 100){nodes{login}}}}}", + map[string]any{"org": "testorg", "teamSlug": "emptyteam"}, + githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{"team": map[string]any{"members": map[string]any{"nodes": []any{}}}}, + }), + ) + mockedGQLClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(teamsMatcher, membersMatcher)) + failGetMe := false + var graphQLCalls int + deps := stubDeps{ + clientFn: stubClientFnFromHTTP(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUser: func(w http.ResponseWriter, r *http.Request) { + if failGetMe { + badRequestHandler("expected typed output test failure")(w, r) + return + } + mockResponse(t, http.StatusOK, mockUser)(w, r) + }, + })), + obsv: stubExporters(), + gqlClientFn: func(context.Context) (*githubv4.Client, error) { + graphQLCalls++ + return mockedGQLClient, nil + }, + } + + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + getMeTool := GetMe(translations.NullTranslationHelper) + getTeamsTool := GetTeams(translations.NullTranslationHelper) + teamMembersTool := GetTeamMembers(translations.NullTranslationHelper) + inv, err := inventory.NewBuilder(). + SetTools([]inventory.ServerTool{getMeTool, getTeamsTool, teamMembersTool}). + WithToolsets([]string{"all"}). + Build() + require.NoError(t, err) + inv.RegisterTools(context.Background(), server, deps) + + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = serverSession.Close() }) + + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil) + clientSession, err := client.Connect(context.Background(), clientTransport, &mcp.ClientSessionOptions{ + ProtocolVersion: inventory.ProtocolVersionMultiRoundTrip, + }) + require.NoError(t, err) + require.Equal(t, inventory.ProtocolVersionMultiRoundTrip, clientSession.InitializeResult().ProtocolVersion) + t.Cleanup(func() { _ = clientSession.Close() }) + + protocolMeta := mcp.Meta{mcp.MetaKeyProtocolVersion: inventory.ProtocolVersionMultiRoundTrip} + list, err := clientSession.ListTools(context.Background(), &mcp.ListToolsParams{Meta: protocolMeta}) + require.NoError(t, err) + require.Len(t, list.Tools, 3) + outputSchemas := make(map[string]*jsonschema.Resolved) + for _, tool := range list.Tools { + assert.NotNil(t, tool.InputSchema) + assert.NotNil(t, tool.OutputSchema) + outputJSON, err := json.Marshal(tool.OutputSchema) + require.NoError(t, err) + var outputSchema jsonschema.Schema + require.NoError(t, json.Unmarshal(outputJSON, &outputSchema)) + outputSchemas[tool.Name], err = outputSchema.Resolve(nil) + require.NoError(t, err) + schemaJSON, err := json.Marshal(tool.InputSchema) + require.NoError(t, err) + var schema struct { + Type string `json:"type"` + Properties map[string]json.RawMessage `json:"properties"` + Required []string `json:"required"` + AnyOf []json.RawMessage `json:"anyOf"` + } + require.NoError(t, json.Unmarshal(schemaJSON, &schema)) + assert.Equal(t, "object", schema.Type, "input roots must remain non-nullable objects") + assert.Empty(t, schema.AnyOf) + var schemaMetadata struct { + AdditionalProperties *bool `json:"additionalProperties"` + } + require.NoError(t, json.Unmarshal(schemaJSON, &schemaMetadata)) + assert.Nil(t, schemaMetadata.AdditionalProperties, "advertised input schema must match the legacy snapshot bytes") + for propertyName, property := range schema.Properties { + var propertySchema jsonschema.Schema + require.NoError(t, json.Unmarshal(property, &propertySchema)) + assert.Empty(t, propertySchema.Enum, "input enums must match the legacy schema") + assert.Nil(t, propertySchema.Minimum) + assert.Nil(t, propertySchema.Maximum) + assert.Nil(t, propertySchema.MinLength, "input bounds must match the legacy schema") + assert.Nil(t, propertySchema.MaxLength) + assert.Nil(t, propertySchema.Default) + switch tool.Name + "." + propertyName { + case "get_teams.user": + assert.Equal(t, "Username to get teams for. If not provided, uses the authenticated user.", propertySchema.Description) + case "get_team_members.org": + assert.Equal(t, "Organization login (owner) that contains the team.", propertySchema.Description) + case "get_team_members.team_slug": + assert.Equal(t, "Team slug", propertySchema.Description) + default: + t.Fatalf("unexpected input property %q on tool %q", propertyName, tool.Name) + } + } + switch tool.Name { + case "get_me": + assert.NotNil(t, schema.Properties, "empty input schemas must retain properties") + assert.Empty(t, schema.Properties) + assert.Empty(t, schema.Required) + case "get_teams": + assert.Contains(t, schema.Properties, "user") + assert.Empty(t, schema.Required, "user must remain optional") + case "get_team_members": + assert.ElementsMatch(t, []string{"org", "team_slug"}, schema.Required) + default: + t.Fatalf("unexpected context tool %q", tool.Name) + } + } + + result, err := clientSession.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "get_me", + Arguments: map[string]any{"legacy_ignored_argument": true}, + Meta: protocolMeta, + }) + require.NoError(t, err) + require.False(t, result.IsError) + require.NotNil(t, result.StructuredContent) + + structuredJSON, err := json.Marshal(result.StructuredContent) + require.NoError(t, err) + assert.JSONEq(t, getTextResult(t, result).Text, string(structuredJSON)) + var returnedUser MinimalUser + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returnedUser)) + legacyText, err := json.Marshal(returnedUser) + require.NoError(t, err) + assert.JSONEq(t, string(legacyText), getTextResult(t, result).Text) + request := createMCPRequest(map[string]any{}) + legacyResult, err := getMeTool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.Equal(t, `{"login":"testuser","profile_url":"https://github.com/testuser","details":{"public_repos":0,"public_gists":0,"followers":0,"following":0,"created_at":"2020-01-02T03:04:05Z","updated_at":"0001-01-01T00:00:00Z"}}`, getTextResult(t, legacyResult).Text) + require.NoError(t, outputSchemas["get_me"].Validate(result.StructuredContent)) + + result, err = clientSession.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "get_teams", + Arguments: map[string]any{"user": "specificuser", "legacy_ignored_argument": true}, + Meta: protocolMeta, + }) + require.NoError(t, err) + require.False(t, result.IsError) + structuredJSON, err = json.Marshal(result.StructuredContent) + require.NoError(t, err) + assert.JSONEq(t, `[{"org":"testorg","teams":[{"name":"team1","slug":"team1","description":"Team 1"}]}]`, string(structuredJSON)) + assert.JSONEq(t, string(structuredJSON), getTextResult(t, result).Text) + request = createMCPRequest(map[string]any{"user": "specificuser"}) + legacyResult, err = getTeamsTool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.Equal(t, `[{"org":"testorg","teams":[{"name":"team1","slug":"team1","description":"Team 1"}]}]`, getTextResult(t, legacyResult).Text) + require.NoError(t, outputSchemas["get_teams"].Validate(result.StructuredContent)) + assert.Equal(t, 2, graphQLCalls) + + result, err = clientSession.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "get_team_members", + Arguments: map[string]any{"org": "testorg", "team_slug": "testteam", "legacy_ignored_argument": true}, + Meta: protocolMeta, + }) + require.NoError(t, err) + require.False(t, result.IsError) + structuredJSON, err = json.Marshal(result.StructuredContent) + require.NoError(t, err) + assert.JSONEq(t, `["user1","user2"]`, string(structuredJSON)) + assert.JSONEq(t, string(structuredJSON), getTextResult(t, result).Text) + request = createMCPRequest(map[string]any{"org": "testorg", "team_slug": "testteam"}) + legacyResult, err = teamMembersTool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.Equal(t, `["user1","user2"]`, getTextResult(t, legacyResult).Text) + require.NoError(t, outputSchemas["get_team_members"].Validate(result.StructuredContent)) + assert.Equal(t, 4, graphQLCalls) + + result, err = clientSession.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "get_team_members", + Arguments: map[string]any{}, + Meta: protocolMeta, + }) + require.NoError(t, err) + assert.True(t, result.IsError, "missing required arguments should be rejected by the inferred input schema") + assert.Equal(t, 4, graphQLCalls, "schema validation must happen before invoking the handler") + + result, err = clientSession.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "get_team_members", + Arguments: map[string]any{"org": "", "team_slug": "testteam"}, + Meta: protocolMeta, + }) + require.NoError(t, err) + assert.True(t, result.IsError, "empty required strings should remain invalid") + assert.Equal(t, 4, graphQLCalls, "handler validation must reject empty identifiers before acquiring GraphQL") + + for _, tc := range []struct { + name string + args map[string]any + text string + structured string + matcher githubv4mock.Matcher + }{ + {"get_teams", map[string]any{"user": "no-orgs"}, "null", "[]", emptyOrganizationsMatcher}, + {"get_teams", map[string]any{"user": "no-teams"}, `[{"org":"testorg","teams":[]}]`, `[{"org":"testorg","teams":[]}]`, emptyTeamsMatcher}, + {"get_team_members", map[string]any{"org": "testorg", "team_slug": "emptyteam"}, "null", "[]", emptyMembersMatcher}, + } { + mockedGQLClient = githubv4.NewClient(githubv4mock.NewMockedHTTPClient(tc.matcher)) + serverTool := getTeamsTool + if tc.name == "get_team_members" { + serverTool = teamMembersTool + } + request := createMCPRequest(tc.args) + legacyResult, err := serverTool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, legacyResult.IsError) + assert.Equal(t, tc.text, getTextResult(t, legacyResult).Text) + result, err := clientSession.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: tc.args, Meta: protocolMeta}) + require.NoError(t, err) + require.False(t, result.IsError) + require.NotNil(t, result.StructuredContent, "empty successful collections must have structured content on the wire") + require.Len(t, result.Content, 1, "SDK fallback must not duplicate the legacy text") + structuredJSON, err := json.Marshal(result.StructuredContent) + require.NoError(t, err) + assert.JSONEq(t, tc.structured, string(structuredJSON)) + assert.JSONEq(t, string(structuredJSON), getTextResult(t, result).Text) + require.NoError(t, outputSchemas[tc.name].Validate(result.StructuredContent)) + } + + failGetMe = true + result, err = clientSession.CallTool(context.Background(), &mcp.CallToolParams{Name: "get_me", Meta: protocolMeta}) + require.NoError(t, err) + assert.True(t, result.IsError) + assert.Nil(t, result.StructuredContent, "handler errors must not expose a successful typed output") +} + func Test_GetMe_IFC_FeatureFlag(t *testing.T) { t.Parallel() @@ -240,7 +531,7 @@ func Test_GetTeams(t *testing.T) { serverTool := GetTeams(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + testContextToolSnapshot[GetTeamsInput, []OrganizationTeams](t, tool) assert.Equal(t, "get_teams", tool.Name) assert.True(t, tool.Annotations.ReadOnlyHint, "get_teams tool should be read-only") @@ -386,6 +677,17 @@ func Test_GetTeams(t *testing.T) { expectToolError: false, expectedTeamsCount: 2, }, + { + name: "empty user uses authenticated user", + makeDeps: func() ToolDependencies { + return BaseDeps{ + Client: mustNewGHClient(t, httpClientWithUser()), + GQLClient: gqlClientForTestuser(), + } + }, + requestArgs: map[string]any{"user": ""}, + expectedTeamsCount: 2, + }, { name: "no teams found", makeDeps: func() ToolDependencies { @@ -483,7 +785,7 @@ func Test_GetTeamMembers(t *testing.T) { serverTool := GetTeamMembers(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + testContextToolSnapshot[GetTeamMembersInput, []string](t, tool) assert.Equal(t, "get_team_members", tool.Name) assert.True(t, tool.Annotations.ReadOnlyHint, "get_team_members tool should be read-only") @@ -612,3 +914,277 @@ func Test_GetTeamMembers(t *testing.T) { }) } } + +func Test_GetTeams_NullUserForDirectHandler(t *testing.T) { + t.Parallel() + + clientCalls := 0 + gqlClientCalls := 0 + deps := stubDeps{ + clientFn: func(context.Context) (*github.Client, error) { + clientCalls++ + return nil, nil + }, + gqlClientFn: func(context.Context) (*githubv4.Client, error) { + gqlClientCalls++ + return nil, nil + }, + obsv: stubExporters(), + } + serverTool := GetTeams(translations.NullTranslationHelper) + request := createMCPRequest(map[string]any{"user": nil}) + result, err := serverTool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + assert.Equal(t, "parameter user is not of type string, is ", getErrorResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + assert.Zero(t, clientCalls) + assert.Zero(t, gqlClientCalls) +} + +func TestNormalizeGetTeamsInput(t *testing.T) { + t.Parallel() + + for _, arguments := range []string{`{}`, `{"user":""}`, `{"user":"octocat"}`, `{"legacy_ignored_argument":true}`} { + t.Run(arguments, func(t *testing.T) { + normalized, err := normalizeGetTeamsInput(json.RawMessage(arguments)) + require.NoError(t, err) + assert.Equal(t, arguments, string(normalized)) + }) + } + for _, arguments := range []string{`{"user":null}`, `{"user": null }`} { + t.Run(arguments, func(t *testing.T) { + _, err := normalizeGetTeamsInput(json.RawMessage(arguments)) + var inputError *inventory.ToolInputError + require.ErrorAs(t, err, &inputError) + assert.Equal(t, "parameter user is not of type string, is ", inputError.Message) + }) + } +} + +func TestContextToolsExactRoutingKeys(t *testing.T) { + for _, mode := range []string{"direct", "legacy", "modern", "unknown"} { + t.Run(mode, func(t *testing.T) { + var clientCalls, gqlCalls int + deps := stubDeps{ + clientFn: func(ctx context.Context) (*github.Client, error) { + clientCalls++ + return stubClientFnFromHTTP(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUser: mockResponse(t, http.StatusOK, &github.User{Login: new("authenticated")}), + }))(ctx) + }, + gqlClientFn: func(context.Context) (*githubv4.Client, error) { + gqlCalls++ + return githubv4.NewClient(githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + "query($login:String!){user(login: $login){organizations(first: 100){nodes{login,teams(first: 100, userLogins: [$login]){nodes{name,slug,description}}}}}}", + map[string]any{"login": "authenticated"}, + githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{"organizations": map[string]any{"nodes": []any{}}}, + }), + ), + githubv4mock.NewQueryMatcher( + "query($org:String!$teamSlug:String!){organization(login: $org){team(slug: $teamSlug){members(first: 100){nodes{login}}}}}", + map[string]any{"org": "testorg", "teamSlug": "testteam"}, + githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{"team": map[string]any{"members": map[string]any{"nodes": []any{}}}}, + }), + ), + )), nil + }, + obsv: stubExporters(), + } + tools := []inventory.ServerTool{ + GetTeams(translations.NullTranslationHelper), + GetTeamMembers(translations.NullTranslationHelper), + } + call := func(name string, args map[string]any) *mcp.CallToolResult { + for _, tool := range tools { + if tool.Tool.Name == name { + request := createMCPRequest(args) + if mode == "unknown" { + request.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: "2099-01-01"} + } + result, err := tool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + return result + } + } + t.Fatalf("unknown tool %s", name) + return nil + } + if mode == "legacy" || mode == "modern" { + server := mcp.NewServer(&mcp.Implementation{Name: "test-server", Version: "v0.0.1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv, err := inventory.NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build() + require.NoError(t, err) + inv.RegisterTools(context.Background(), server, deps) + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = serverSession.Close() }) + client := mcp.NewClient(&mcp.Implementation{Name: "test-client", Version: "v0.0.1"}, nil) + version := "2025-03-26" + if mode == "modern" { + version = inventory.ProtocolVersionMultiRoundTrip + } + session, err := client.Connect(context.Background(), clientTransport, &mcp.ClientSessionOptions{ProtocolVersion: version}) + require.NoError(t, err) + t.Cleanup(func() { _ = session.Close() }) + meta := mcp.Meta{} + if mode == "modern" { + meta[mcp.MetaKeyProtocolVersion] = inventory.ProtocolVersionMultiRoundTrip + } + call = func(name string, args map[string]any) *mcp.CallToolResult { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: name, Arguments: args, Meta: meta, + }) + require.NoError(t, err) + return result + } + } + for _, args := range []map[string]any{ + {"USER": "wrong-user", "unrelated": true}, + {"UsEr": nil}, + {"user": "", "USER": 42}, + {"user": "authenticated", "USER": "wrong-user"}, + } { + beforeREST := clientCalls + beforeGQL := gqlCalls + result := call("get_teams", args) + require.False(t, result.IsError) + assert.Equal(t, beforeGQL+1, gqlCalls) + if args["user"] == "authenticated" { + assert.Equal(t, beforeREST, clientCalls) + } else { + assert.Equal(t, beforeREST+1, clientCalls) + } + if mode == "modern" { + assert.Equal(t, "[]", getTextResult(t, result).Text) + assert.NotNil(t, result.StructuredContent) + } else { + assert.Equal(t, "null", getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + } + } + beforeREST, beforeGQL := clientCalls, gqlCalls + result := call("get_teams", map[string]any{"user": nil, "USER": "authenticated"}) + require.True(t, result.IsError) + assert.Equal(t, "parameter user is not of type string, is ", getErrorResult(t, result).Text) + assert.Equal(t, beforeREST, clientCalls) + assert.Equal(t, beforeGQL, gqlCalls) + for _, args := range []map[string]any{ + {"ORG": "testorg", "team_slug": "testteam"}, + {"org": "testorg", "TEAM_SLUG": "testteam"}, + {"ORG": "testorg", "TEAM_SLUG": "testteam"}, + } { + result := call("get_team_members", args) + require.True(t, result.IsError) + assert.Nil(t, result.StructuredContent) + assert.Equal(t, beforeGQL, gqlCalls) + } + result = call("get_team_members", map[string]any{ + "org": "testorg", "team_slug": "testteam", "ORG": 42, "TEAM_SLUG": nil, "unrelated": true, + }) + require.False(t, result.IsError) + assert.Equal(t, beforeGQL+1, gqlCalls) + if mode == "modern" { + assert.Equal(t, "[]", getTextResult(t, result).Text) + } else { + assert.Equal(t, "null", getTextResult(t, result).Text) + } + }) + } +} + +func TestNormalizeContextRoutingKeys(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + normalize inventory.InputNormalizer + args string + want string + }{ + {"teams", normalizeGetTeamsInput, `{"user":"exact","USER":42,"UsEr":null,"unknown":{"USER":"retained"}}`, `{"user":"exact","unknown":{"USER":"retained"}}`}, + {"members", normalizeGetTeamMembersInput, `{"org":"exact","ORG":false,"team_slug":"slug","Team_Slug":null,"unknown":true}`, `{"org":"exact","team_slug":"slug","unknown":true}`}, + {"canonical null retained", normalizeGetTeamMembersInput, `{"org":null,"team_slug":null,"ORG":"ignored"}`, `{"org":null,"team_slug":null}`}, + } { + t.Run(tc.name, func(t *testing.T) { + result, err := tc.normalize(json.RawMessage(tc.args)) + require.NoError(t, err) + assert.JSONEq(t, tc.want, string(result)) + }) + } +} + +func Test_GetTeamMembers_RequiredIdentifiersForDirectHandler(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args map[string]any + expectedError string + }{ + { + name: "missing organization", + args: map[string]any{"team_slug": "testteam"}, + expectedError: "missing required parameter: org", + }, + { + name: "empty organization", + args: map[string]any{"org": "", "team_slug": "testteam"}, + expectedError: "missing required parameter: org", + }, + { + name: "missing team slug", + args: map[string]any{"org": "testorg"}, + expectedError: "missing required parameter: team_slug", + }, + { + name: "empty team slug", + args: map[string]any{"org": "testorg", "team_slug": ""}, + expectedError: "missing required parameter: team_slug", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gqlClientCalls := 0 + deps := stubDeps{ + gqlClientFn: func(context.Context) (*githubv4.Client, error) { + gqlClientCalls++ + return nil, nil + }, + obsv: stubExporters(), + } + serverTool := GetTeamMembers(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + request := createMCPRequest(tc.args) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, tc.expectedError) + assert.Zero(t, gqlClientCalls, "invalid identifiers must fail before acquiring the GraphQL client") + }) + } +} + +func testContextToolSnapshot[In, Out any](t *testing.T, tool mcp.Tool) { + t.Helper() + + if tool.InputSchema == nil { + inputSchema, err := jsonschema.For[In](nil) + require.NoError(t, err) + tool.InputSchema = inputSchema + } + if tool.OutputSchema == nil { + outputSchema, err := jsonschema.For[Out](nil) + require.NoError(t, err) + tool.OutputSchema = outputSchema + } + require.NoError(t, toolsnaps.Test(tool.Name, tool)) +} From 1feeae4c348ec1fd212fc76b527203054b357bad Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 7 Oct 2026 00:11:16 +0200 Subject: [PATCH 45/61] feat(comments): add typed visibility tool inputs and outputs (#3384) * feat(comments): add typed visibility tool inputs and outputs Preserve legacy argument normalization and text responses while exposing protocol-gated structured results for six hide/unhide tools. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * chore: regenerate license files Auto-generated by license-check workflow * test(comments): pin protocol output parity and reason metadata Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(comments): reuse cached visibility output schema Clarify supported negotiated protocol versions without expanding runtime support. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] --- .../__toolsnaps__/hide_issue_comment.snap | 22 +- .../hide_pull_request_review.snap | 22 +- .../hide_pull_request_review_comment.snap | 22 +- .../__toolsnaps__/unhide_issue_comment.snap | 22 +- .../unhide_pull_request_review.snap | 22 +- .../unhide_pull_request_review_comment.snap | 22 +- pkg/github/comment_minimize.go | 149 ++++--- pkg/github/comment_minimize_test.go | 366 ++++++++++++++++-- 8 files changed, 567 insertions(+), 80 deletions(-) diff --git a/pkg/github/__toolsnaps__/hide_issue_comment.snap b/pkg/github/__toolsnaps__/hide_issue_comment.snap index bb66c3fc22..129fe38a27 100644 --- a/pkg/github/__toolsnaps__/hide_issue_comment.snap +++ b/pkg/github/__toolsnaps__/hide_issue_comment.snap @@ -44,5 +44,25 @@ ], "type": "object" }, - "name": "hide_issue_comment" + "name": "hide_issue_comment", + "outputSchema": { + "additionalProperties": false, + "properties": { + "is_minimized": { + "type": "boolean" + }, + "minimized_reason": { + "description": "GitHub's lowercase, hyphenated reason (for example off-topic); omitted when unhidden.", + "type": "string" + }, + "node_id": { + "type": "string" + } + }, + "required": [ + "node_id", + "is_minimized" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/hide_pull_request_review.snap b/pkg/github/__toolsnaps__/hide_pull_request_review.snap index 09ecfdf6cd..e9b8d3fc33 100644 --- a/pkg/github/__toolsnaps__/hide_pull_request_review.snap +++ b/pkg/github/__toolsnaps__/hide_pull_request_review.snap @@ -50,5 +50,25 @@ ], "type": "object" }, - "name": "hide_pull_request_review" + "name": "hide_pull_request_review", + "outputSchema": { + "additionalProperties": false, + "properties": { + "is_minimized": { + "type": "boolean" + }, + "minimized_reason": { + "description": "GitHub's lowercase, hyphenated reason (for example off-topic); omitted when unhidden.", + "type": "string" + }, + "node_id": { + "type": "string" + } + }, + "required": [ + "node_id", + "is_minimized" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/hide_pull_request_review_comment.snap b/pkg/github/__toolsnaps__/hide_pull_request_review_comment.snap index 46c39c9885..48fdb9eac4 100644 --- a/pkg/github/__toolsnaps__/hide_pull_request_review_comment.snap +++ b/pkg/github/__toolsnaps__/hide_pull_request_review_comment.snap @@ -44,5 +44,25 @@ ], "type": "object" }, - "name": "hide_pull_request_review_comment" + "name": "hide_pull_request_review_comment", + "outputSchema": { + "additionalProperties": false, + "properties": { + "is_minimized": { + "type": "boolean" + }, + "minimized_reason": { + "description": "GitHub's lowercase, hyphenated reason (for example off-topic); omitted when unhidden.", + "type": "string" + }, + "node_id": { + "type": "string" + } + }, + "required": [ + "node_id", + "is_minimized" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/unhide_issue_comment.snap b/pkg/github/__toolsnaps__/unhide_issue_comment.snap index ea258d12f3..2a522c508d 100644 --- a/pkg/github/__toolsnaps__/unhide_issue_comment.snap +++ b/pkg/github/__toolsnaps__/unhide_issue_comment.snap @@ -30,5 +30,25 @@ ], "type": "object" }, - "name": "unhide_issue_comment" + "name": "unhide_issue_comment", + "outputSchema": { + "additionalProperties": false, + "properties": { + "is_minimized": { + "type": "boolean" + }, + "minimized_reason": { + "description": "GitHub's lowercase, hyphenated reason (for example off-topic); omitted when unhidden.", + "type": "string" + }, + "node_id": { + "type": "string" + } + }, + "required": [ + "node_id", + "is_minimized" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/unhide_pull_request_review.snap b/pkg/github/__toolsnaps__/unhide_pull_request_review.snap index 2e627d519f..ae21c63806 100644 --- a/pkg/github/__toolsnaps__/unhide_pull_request_review.snap +++ b/pkg/github/__toolsnaps__/unhide_pull_request_review.snap @@ -36,5 +36,25 @@ ], "type": "object" }, - "name": "unhide_pull_request_review" + "name": "unhide_pull_request_review", + "outputSchema": { + "additionalProperties": false, + "properties": { + "is_minimized": { + "type": "boolean" + }, + "minimized_reason": { + "description": "GitHub's lowercase, hyphenated reason (for example off-topic); omitted when unhidden.", + "type": "string" + }, + "node_id": { + "type": "string" + } + }, + "required": [ + "node_id", + "is_minimized" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/unhide_pull_request_review_comment.snap b/pkg/github/__toolsnaps__/unhide_pull_request_review_comment.snap index 38ff864f15..2b81142fd2 100644 --- a/pkg/github/__toolsnaps__/unhide_pull_request_review_comment.snap +++ b/pkg/github/__toolsnaps__/unhide_pull_request_review_comment.snap @@ -30,5 +30,25 @@ ], "type": "object" }, - "name": "unhide_pull_request_review_comment" + "name": "unhide_pull_request_review_comment", + "outputSchema": { + "additionalProperties": false, + "properties": { + "is_minimized": { + "type": "boolean" + }, + "minimized_reason": { + "description": "GitHub's lowercase, hyphenated reason (for example off-topic); omitted when unhidden.", + "type": "string" + }, + "node_id": { + "type": "string" + } + }, + "required": [ + "node_id", + "is_minimized" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/comment_minimize.go b/pkg/github/comment_minimize.go index e8dc88620b..db87e98869 100644 --- a/pkg/github/comment_minimize.go +++ b/pkg/github/comment_minimize.go @@ -23,7 +23,18 @@ import ( type MinimizeCommentResult struct { NodeID string `json:"node_id"` IsMinimized bool `json:"is_minimized"` - MinimizedReason string `json:"minimized_reason,omitempty"` + MinimizedReason string `json:"minimized_reason,omitempty" jsonschema:"GitHub's lowercase, hyphenated reason (for example off-topic); omitted when unhidden."` +} + +// CommentVisibilityInput identifies the comment or review whose visibility changes. +// The factory's target-specific schema determines which identifiers are required. +type CommentVisibilityInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + CommentID int64 `json:"comment_id,omitempty"` + PullNumber int `json:"pullNumber,omitempty"` + ReviewID int64 `json:"review_id,omitempty"` + Classifier string `json:"classifier,omitempty"` } var commentClassifiers = []any{"SPAM", "ABUSE", "OFF_TOPIC", "OUTDATED", "DUPLICATE", "RESOLVED", "LOW_QUALITY"} @@ -42,7 +53,7 @@ type commentVisibilityTarget struct { unhideDescription string properties func() map[string]*jsonschema.Schema required []string - resolveNodeID func(ctx context.Context, client *github.Client, owner, repo string, args map[string]any) (string, *mcp.CallToolResult) + resolveNodeID func(ctx context.Context, client *github.Client, input CommentVisibilityInput) (string, *mcp.CallToolResult) } var issueCommentVisibilityTarget = commentVisibilityTarget{ @@ -62,12 +73,8 @@ var issueCommentVisibilityTarget = commentVisibilityTarget{ } }, required: []string{"comment_id"}, - resolveNodeID: func(ctx context.Context, client *github.Client, owner, repo string, args map[string]any) (string, *mcp.CallToolResult) { - commentID, err := requiredPositiveBigInt(args, "comment_id") - if err != nil { - return "", utils.NewToolResultError(err.Error()) - } - comment, resp, err := client.Issues.GetComment(ctx, owner, repo, commentID) + resolveNodeID: func(ctx context.Context, client *github.Client, input CommentVisibilityInput) (string, *mcp.CallToolResult) { + comment, resp, err := client.Issues.GetComment(ctx, input.Owner, input.Repo, input.CommentID) return nodeIDFromResponse(ctx, "failed to get issue comment", comment.GetNodeID(), resp, err) }, } @@ -89,12 +96,8 @@ var pullRequestReviewCommentVisibilityTarget = commentVisibilityTarget{ } }, required: []string{"comment_id"}, - resolveNodeID: func(ctx context.Context, client *github.Client, owner, repo string, args map[string]any) (string, *mcp.CallToolResult) { - commentID, err := requiredPositiveBigInt(args, "comment_id") - if err != nil { - return "", utils.NewToolResultError(err.Error()) - } - comment, resp, err := client.PullRequests.GetComment(ctx, owner, repo, commentID) + resolveNodeID: func(ctx context.Context, client *github.Client, input CommentVisibilityInput) (string, *mcp.CallToolResult) { + comment, resp, err := client.PullRequests.GetComment(ctx, input.Owner, input.Repo, input.CommentID) return nodeIDFromResponse(ctx, "failed to get pull request review comment", comment.GetNodeID(), resp, err) }, } @@ -121,19 +124,8 @@ var pullRequestReviewVisibilityTarget = commentVisibilityTarget{ } }, required: []string{"pullNumber", "review_id"}, - resolveNodeID: func(ctx context.Context, client *github.Client, owner, repo string, args map[string]any) (string, *mcp.CallToolResult) { - pullNumber, err := RequiredInt(args, "pullNumber") - if err != nil { - return "", utils.NewToolResultError(err.Error()) - } - if pullNumber < 1 { - return "", utils.NewToolResultError("pullNumber must be greater than 0") - } - reviewID, err := requiredPositiveBigInt(args, "review_id") - if err != nil { - return "", utils.NewToolResultError(err.Error()) - } - review, resp, err := client.PullRequests.GetReview(ctx, owner, repo, pullNumber, reviewID) + resolveNodeID: func(ctx context.Context, client *github.Client, input CommentVisibilityInput) (string, *mcp.CallToolResult) { + review, resp, err := client.PullRequests.GetReview(ctx, input.Owner, input.Repo, input.PullNumber, input.ReviewID) return nodeIDFromResponse(ctx, "failed to get pull request review", review.GetNodeID(), resp, err) }, } @@ -167,7 +159,11 @@ func commentVisibilityTool(t translations.TranslationHelperFunc, target commentV required = append(required, "classifier") } - st := NewTool( + outputSchema, err := inventory.CachedSchemaFor[MinimizeCommentResult](nil) + if err != nil { + panic(fmt.Sprintf("failed to generate comment visibility output schema: %v", err)) + } + st := NewTool[CommentVisibilityInput, MinimizeCommentResult]( target.toolset, mcp.Tool{ Name: name, @@ -183,72 +179,121 @@ func commentVisibilityTool(t translations.TranslationHelperFunc, target commentV Properties: properties, Required: required, }, + OutputSchema: outputSchema, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - return setCommentVisibility(ctx, deps, target, args, hide), nil, nil + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input CommentVisibilityInput) (*mcp.CallToolResult, MinimizeCommentResult, error) { + result, output := setCommentVisibility(ctx, deps, target, input, hide) + return result, output, nil }, + normalizeCommentVisibilityArguments(target, hide), ) st.FeatureRule = target.featureRule return st } -// setCommentVisibility resolves the object identified by args and hides or unhides it. -func setCommentVisibility(ctx context.Context, deps ToolDependencies, target commentVisibilityTarget, args map[string]any, hide bool) *mcp.CallToolResult { - owner, err := RequiredParam[string](args, "owner") +func normalizeCommentVisibilityArguments(target commentVisibilityTarget, hide bool) inventory.InputNormalizer { + return func(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + if args == nil { + return raw, nil + } + input, err := normalizeCommentVisibilityInput(args, target, hide) + if err != nil { + return nil, err + } + normalized, err := json.Marshal(input) + if err != nil { + return nil, fmt.Errorf("marshal normalized comment visibility arguments: %w", err) + } + return normalized, nil + } +} + +func normalizeCommentVisibilityInput(args map[string]any, target commentVisibilityTarget, hide bool) (CommentVisibilityInput, error) { + var input CommentVisibilityInput + var err error + input.Owner, err = RequiredParam[string](args, "owner") if err != nil { - return utils.NewToolResultError(err.Error()) + return input, err } - repo, err := RequiredParam[string](args, "repo") + input.Repo, err = RequiredParam[string](args, "repo") if err != nil { - return utils.NewToolResultError(err.Error()) + return input, err } - var classifier string if hide { - classifier, err = RequiredParam[string](args, "classifier") + input.Classifier, err = RequiredParam[string](args, "classifier") if err != nil { - return utils.NewToolResultError(err.Error()) + return input, err } - classifier = strings.ToUpper(classifier) - if !slices.Contains(commentClassifiers, any(classifier)) { - return utils.NewToolResultError(fmt.Sprintf("invalid classifier %q: must be one of %v", classifier, commentClassifiers)) + input.Classifier = strings.ToUpper(input.Classifier) + if !slices.Contains(commentClassifiers, any(input.Classifier)) { + return input, fmt.Errorf("invalid classifier %q: must be one of %v", input.Classifier, commentClassifiers) } } + for _, name := range target.required { + if name == "pullNumber" { + value, err := RequiredInt(args, name) + if err != nil { + return input, err + } + if value < 1 { + return input, fmt.Errorf("pullNumber must be greater than 0") + } + input.PullNumber = value + continue + } + value, err := requiredPositiveBigInt(args, name) + if err != nil { + return input, err + } + if name == "comment_id" { + input.CommentID = value + } else { + input.ReviewID = value + } + } + return input, nil +} +// setCommentVisibility resolves the object identified by input and hides or unhides it. +func setCommentVisibility(ctx context.Context, deps ToolDependencies, target commentVisibilityTarget, input CommentVisibilityInput, hide bool) (*mcp.CallToolResult, MinimizeCommentResult) { client, err := deps.GetClient(ctx) if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err) + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), MinimizeCommentResult{} } - nodeID, errResult := target.resolveNodeID(ctx, client, owner, repo, args) + nodeID, errResult := target.resolveNodeID(ctx, client, input) if errResult != nil { - return errResult + return errResult, MinimizeCommentResult{} } gqlClient, err := deps.GetGQLClient(ctx) if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err) + return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), MinimizeCommentResult{} } var result MinimizeCommentResult if hide { - result, errResult = minimizeComment(ctx, gqlClient, nodeID, classifier) + result, errResult = minimizeComment(ctx, gqlClient, nodeID, input.Classifier) } else { result, errResult = unminimizeComment(ctx, gqlClient, nodeID) } if errResult != nil { - return errResult + return errResult, MinimizeCommentResult{} } r, err := json.Marshal(result) if err != nil { - return utils.NewToolResultErrorFromErr("failed to marshal response", err) + return utils.NewToolResultErrorFromErr("failed to marshal response", err), MinimizeCommentResult{} } - return utils.NewToolResultText(string(r)) + return utils.NewToolResultText(string(r)), result } -// requiredPositiveBigInt reads a required ID argument. The schema's minimum is not enforced -// when arguments are unmarshalled, so negative values are rejected here before any API call. +// requiredPositiveBigInt validates legacy numeric IDs before SDK schema validation. func requiredPositiveBigInt(args map[string]any, p string) (int64, error) { v, err := RequiredBigInt(args, p) if err != nil { diff --git a/pkg/github/comment_minimize_test.go b/pkg/github/comment_minimize_test.go index 9c07c17879..75e47fc5ab 100644 --- a/pkg/github/comment_minimize_test.go +++ b/pkg/github/comment_minimize_test.go @@ -3,6 +3,8 @@ package github import ( "context" "encoding/json" + "errors" + "maps" "net/http" "testing" @@ -12,6 +14,8 @@ import ( "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -165,6 +169,11 @@ func Test_CommentVisibilityToolSchemas(t *testing.T) { assert.False(t, tool.Annotations.ReadOnlyHint) assert.Equal(t, tc.toolset, tc.tool.Toolset.ID) assert.Equal(t, []inventory.FeatureFlag{inventory.FeatureFlag(tc.featureFlag)}, tc.tool.FeatureRule.Features()) + assert.Equal(t, []string{"repo"}, tc.tool.ScopeAccess.Scopes) + assert.True(t, tc.tool.ScopeAccess.Visible([]string{"repo"})) + assert.False(t, tc.tool.ScopeAccess.Visible(nil)) + assert.Equal(t, []string{"repo"}, tc.tool.ScopeAccess.Challenge(nil, nil)) + assert.Empty(t, tc.tool.ScopeAccess.Challenge(nil, []string{"repo"})) schema := tool.InputSchema.(*jsonschema.Schema) assert.ElementsMatch(t, tc.expectedRequire, schema.Required) @@ -173,13 +182,286 @@ func Test_CommentVisibilityToolSchemas(t *testing.T) { } } +func TestCommentVisibilityTypedInputSchemaMatchesLegacy(t *testing.T) { + for _, target := range []commentVisibilityTarget{issueCommentVisibilityTarget, pullRequestReviewCommentVisibilityTarget, pullRequestReviewVisibilityTarget} { + for _, hide := range []bool{true, false} { + tool := commentVisibilityTool(translations.NullTranslationHelper, target, hide) + t.Run(tool.Tool.Name, func(t *testing.T) { + legacyProperties := map[string]*jsonschema.Schema{ + "owner": {Type: "string", Description: "Repository owner (username or organization)"}, + "repo": {Type: "string", Description: "Repository name"}, + } + maps.Copy(legacyProperties, target.properties()) + legacyRequired := append([]string{"owner", "repo"}, target.required...) + if hide { + legacyProperties["classifier"] = &jsonschema.Schema{ + Type: "string", + Description: "The reason for hiding the comment", + Enum: commentClassifiers, + } + legacyRequired = append(legacyRequired, "classifier") + } + legacy := &jsonschema.Schema{ + Type: "object", + Properties: legacyProperties, + Required: legacyRequired, + } + got, err := json.Marshal(tool.Tool.InputSchema) + require.NoError(t, err) + want, err := json.Marshal(legacy) + require.NoError(t, err) + assert.JSONEq(t, string(want), string(got)) + }) + } + } +} + +func connectCommentVisibilityClient(t *testing.T, server *mcp.Server, version string) *mcp.ClientSession { + t.Helper() + if version == "2025-11-25" { + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + if method == "server/discover" { + return nil, errors.New("use initialize for legacy protocol compatibility test") + } + return next(ctx, method, req) + } + }) + } + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = serverSession.Close() }) + client := mcp.NewClient(&mcp.Implementation{Name: "comment-visibility-test", Version: "v1"}, nil) + session, err := client.Connect(context.Background(), commentVisibilityProtocolTransport{clientTransport, version}, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = session.Close() }) + require.Equal(t, version, session.InitializeResult().ProtocolVersion) + return session +} + +type commentVisibilityProtocolTransport struct { + mcp.Transport + version string +} + +func (t commentVisibilityProtocolTransport) Connect(ctx context.Context) (mcp.Connection, error) { + connection, err := t.Transport.Connect(ctx) + if err != nil { + return nil, err + } + return commentVisibilityProtocolConnection{connection, t.version}, nil +} + +type commentVisibilityProtocolConnection struct { + mcp.Connection + version string +} + +func (c commentVisibilityProtocolConnection) Write(ctx context.Context, message jsonrpc.Message) error { + req, ok := message.(*jsonrpc.Request) + if !ok || req.Method != "initialize" { + return c.Connection.Write(ctx, message) + } + var params map[string]json.RawMessage + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return err + } + version, err := json.Marshal(c.version) + if err != nil { + return err + } + params["protocolVersion"] = version + encoded, err := json.Marshal(params) + if err != nil { + return err + } + requestCopy := *req + requestCopy.Params = encoded + return c.Connection.Write(ctx, &requestCopy) +} + +func TestCommentVisibilityProtocols(t *testing.T) { + // Typed output requires a supported negotiated 2026-07-28+ version; + // unknown versions remain legacy even when their date is later. + for _, version := range []string{"2025-11-25", "2026-07-28"} { + for _, target := range []commentVisibilityTarget{issueCommentVisibilityTarget, pullRequestReviewCommentVisibilityTarget, pullRequestReviewVisibilityTarget} { + for _, hide := range []bool{true, false} { + tool := commentVisibilityTool(translations.NullTranslationHelper, target, hide) + t.Run(version+"/"+tool.Tool.Name, func(t *testing.T) { + route := getIssueCommentRoute + switch target.name { + case "pull_request_review_comment": + route = getReviewCommentRoute + case "pull_request_review": + route = getReviewRoute + } + matcher := unminimizeCommentMatcher("NODE_1") + expected := MinimizeCommentResult{NodeID: "NODE_1"} + legacyText := `{"node_id":"NODE_1","is_minimized":false}` + if hide { + matcher = minimizeCommentMatcher("NODE_1", "OFF_TOPIC", "off-topic") + expected.IsMinimized, expected.MinimizedReason = true, "off-topic" + legacyText = `{"node_id":"NODE_1","is_minimized":true,"minimized_reason":"off-topic"}` + } + deps := BaseDeps{ + Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + route: mockResponse(t, http.StatusOK, map[string]any{"node_id": "NODE_1"}), + })), + GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)), + } + inv, err := inventory.NewBuilder().SetTools([]inventory.ServerTool{tool}). + WithToolsets([]string{"all"}). + WithFeatureChecker(func(context.Context, string) (bool, error) { return true, nil }).Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "visibility-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + session := connectCommentVisibilityClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + if version == "2025-11-25" { + assert.Nil(t, list.Tools[0].OutputSchema) + } else { + assert.NotNil(t, list.Tools[0].OutputSchema) + } + args := map[string]any{"owner": "owner", "repo": "repo", "extra": "ignored"} + if target.name == "pull_request_review" { + args["pullNumber"], args["review_id"] = "42", "3" + } else { + args["comment_id"] = "1e0" + } + if hide { + args["classifier"] = "oFf_ToPiC" + } + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tool.Tool.Name, Arguments: args}) + require.NoError(t, err) + require.False(t, result.IsError, result) + require.Len(t, result.Content, 1) + textResult := getTextResult(t, result).Text + if version == "2025-11-25" { + assert.Equal(t, legacyText, textResult) + assert.Nil(t, result.StructuredContent) + } else { + var textOutput MinimizeCommentResult + require.NoError(t, json.Unmarshal([]byte(textResult), &textOutput)) + assert.Equal(t, expected, textOutput) + text, err := json.Marshal(textOutput) + require.NoError(t, err) + structured, err := json.Marshal(result.StructuredContent) + require.NoError(t, err) + assert.JSONEq(t, string(text), string(structured)) + schema := tool.Tool.OutputSchema.(*jsonschema.Schema) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + var value any + require.NoError(t, json.Unmarshal(structured, &value)) + require.NoError(t, resolved.Validate(value)) + } + invalid := maps.Clone(args) + delete(invalid, "owner") + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{Name: tool.Tool.Name, Arguments: invalid}) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Nil(t, result.StructuredContent) + assert.Contains(t, getErrorResult(t, result).Text, "missing required parameter: owner") + }) + } + } + } +} + +func TestCommentVisibilityProtocolGating(t *testing.T) { + for _, version := range []string{"2025-11-25", "2026-07-28"} { + for _, target := range []commentVisibilityTarget{issueCommentVisibilityTarget, pullRequestReviewCommentVisibilityTarget, pullRequestReviewVisibilityTarget} { + for _, hide := range []bool{true, false} { + tool := commentVisibilityTool(translations.NullTranslationHelper, target, hide) + for _, gate := range []string{"feature disabled", "missing repo scope", "read only"} { + t.Run(version+"/"+tool.Tool.Name+"/"+gate, func(t *testing.T) { + builder := inventory.NewBuilder().SetTools([]inventory.ServerTool{tool}). + WithToolsets([]string{"all"}). + WithFeatureChecker(func(context.Context, string) (bool, error) { return gate != "feature disabled", nil }) + if gate == "missing repo scope" { + builder.WithFilter(CreateToolScopeFilter([]string{"public_repo"})) + } + if gate == "read only" { + builder.WithReadOnly(true) + } + inv, err := builder.Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "visibility-gates", Version: "v1"}, nil) + inv.RegisterTools(context.Background(), server, nil) + session := connectCommentVisibilityClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + assert.Empty(t, list.Tools) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tool.Tool.Name}) + if err == nil { + require.NotNil(t, result) + assert.True(t, result.IsError) + assert.Nil(t, result.StructuredContent) + } + }) + } + } + } + } +} + +func TestNormalizeCommentVisibilityInput(t *testing.T) { + for _, target := range []commentVisibilityTarget{issueCommentVisibilityTarget, pullRequestReviewCommentVisibilityTarget, pullRequestReviewVisibilityTarget} { + for _, tc := range []struct { + name string + value any + want int64 + }{ + {"number", float64(1), 1}, + {"string", "1", 1}, + {"decimal string", "1.0", 1}, + {"exponent string", "1e0", 1}, + {"signed string", "+1", 1}, + {"leading zero string", "001", 1}, + {"legacy float precision", "9007199254740993", 9007199254740992}, + {"largest round-trippable positive ID", "9223372036854774784", 9223372036854774784}, + } { + t.Run(target.name+"/"+tc.name, func(t *testing.T) { + args := map[string]any{"owner": "owner", "repo": "repo", "classifier": "sPaM"} + for _, name := range target.required { + args[name] = tc.value + } + if target.name == "pull_request_review" { + args["pullNumber"] = "42" + } + input, err := normalizeCommentVisibilityInput(args, target, true) + require.NoError(t, err) + tool := commentVisibilityTool(translations.NullTranslationHelper, target, true) + schema, err := tool.Tool.InputSchema.(*jsonschema.Schema).Resolve(nil) + require.NoError(t, err) + encoded, err := json.Marshal(input) + require.NoError(t, err) + var canonical any + require.NoError(t, json.Unmarshal(encoded, &canonical)) + require.NoError(t, schema.Validate(canonical)) + assert.Equal(t, "SPAM", input.Classifier) + if target.name == "pull_request_review" { + assert.Equal(t, tc.want, input.ReviewID) + assert.Equal(t, 42, input.PullNumber) + } else { + assert.Equal(t, tc.want, input.CommentID) + } + }) + } + } +} + func Test_HideAndUnhideComments(t *testing.T) { issueComment := mockResponse(t, http.StatusOK, &github.IssueComment{ID: new(int64(1)), NodeID: new("IC_1")}) reviewComment := mockResponse(t, http.StatusOK, &github.PullRequestComment{ID: new(int64(2)), NodeID: new("PRRC_2")}) review := mockResponse(t, http.StatusOK, &github.PullRequestReview{ID: new(int64(3)), NodeID: new("PRR_3")}) notFound := mockResponse(t, http.StatusNotFound, `{"message": "Not Found"}`) - tests := []struct { + type visibilityTestCase struct { name string tool inventory.ServerTool restHandlers map[string]http.HandlerFunc @@ -187,7 +469,8 @@ func Test_HideAndUnhideComments(t *testing.T) { requestArgs map[string]any expectedResult MinimizeCommentResult expectedErrMsg string - }{ + } + tests := []visibilityTestCase{ { name: "hide issue comment", tool: GranularHideIssueComment(translations.NullTranslationHelper), @@ -354,30 +637,69 @@ func Test_HideAndUnhideComments(t *testing.T) { requestArgs: map[string]any{"owner": "owner", "repo": "repo", "pullNumber": float64(42)}, expectedErrMsg: "review_id", }, + { + name: "unhide ignores classifier and unrelated identifiers", + tool: GranularUnhideIssueComment(translations.NullTranslationHelper), + restHandlers: map[string]http.HandlerFunc{getIssueCommentRoute: issueComment}, + gqlMatchers: []githubv4mock.Matcher{unminimizeCommentMatcher("IC_1")}, + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": "1.0", "classifier": false, "review_id": "invalid", "pullNumber": nil}, + expectedResult: MinimizeCommentResult{NodeID: "IC_1"}, + }, + } + for _, tc := range []struct { + name string + value any + error string + }{ + {"zero", float64(0), "missing required parameter: comment_id"}, + {"string zero", "0", "missing required parameter: comment_id"}, + {"fractional", 1.5, "non-integer numeric value"}, + {"fractional string", "1.5", "non-integer numeric value"}, + {"invalid string", "abc", "invalid numeric value"}, + {"null", nil, "expected number, got "}, + {"boolean", true, "expected number, got bool"}, + {"string NaN", "NaN", "non-finite numeric value"}, + {"string infinity", "+Inf", "non-finite numeric value"}, + {"overflow", "9223372036854777856", "too large to fit in int64"}, + } { + tests = append(tests, visibilityTestCase{ + name: "issue ID " + tc.name, + tool: GranularUnhideIssueComment(translations.NullTranslationHelper), + requestArgs: map[string]any{"owner": "owner", "repo": "repo", "comment_id": tc.value}, + expectedErrMsg: tc.error, + }) } for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - deps := BaseDeps{ - Client: mustNewGHClient(t, MockHTTPClientWithHandlers(tc.restHandlers)), - GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient(tc.gqlMatchers...)), - } - handler := tc.tool.Handler(deps) - - request := createMCPRequest(tc.requestArgs) - result, err := handler(ContextWithDeps(context.Background(), deps), &request) - require.NoError(t, err) + for _, version := range []string{"2025-11-25", "2026-07-28"} { + t.Run(version+"/"+tc.name, func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, MockHTTPClientWithHandlers(tc.restHandlers)), + GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient(tc.gqlMatchers...)), + } + inv, err := inventory.NewBuilder().SetTools([]inventory.ServerTool{tc.tool}). + WithToolsets([]string{"all"}). + WithFeatureChecker(func(context.Context, string) (bool, error) { return true, nil }).Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "visibility-behavior", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + session := connectCommentVisibilityClient(t, server, version) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool.Tool.Name, Arguments: tc.requestArgs}) + require.NoError(t, err) - if tc.expectedErrMsg != "" { - require.True(t, result.IsError) - assert.Contains(t, getErrorResult(t, result).Text, tc.expectedErrMsg) - return - } + if tc.expectedErrMsg != "" { + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, tc.expectedErrMsg) + assert.Nil(t, result.StructuredContent) + return + } - require.False(t, result.IsError, getTextResult(t, result).Text) - var got MinimizeCommentResult - require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &got)) - assert.Equal(t, tc.expectedResult, got) - }) + require.False(t, result.IsError, getTextResult(t, result).Text) + var got MinimizeCommentResult + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &got)) + assert.Equal(t, tc.expectedResult, got) + }) + } } } From 26bf4c4e1342f6a7ed1311c48682bb8342730800 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 7 Oct 2026 00:11:17 +0200 Subject: [PATCH 46/61] feat(search): add typed projected result outputs (#3386) * feat(search): add typed projected result outputs * fix(search): preserve field_values presence and add nested schema fixtures Address review findings on the typed search/list projected-output tools (search_code, search_issues, search_pull_requests, list_issues, list_pull_requests, list_branches, list_tags): - issues.go/pullrequests.go: change FieldValues to a pointer-to-slice (*[]MinimalFieldValue) so a successful-but-empty [] is preserved in structuredContent, matching legacy text output instead of being dropped by omitempty. - search.go: related adjustments to keep typed search outputs aligned with the field_values presence fix. - typed_search_outputs_test.go: add representative nested/non-empty projected item fixtures so schema-conformance tests actually exercise item shapes instead of only validating empty collections. - Add/update toolsnaps for list_branches, list_issues, list_pull_requests, list_tags, search_code, search_issues, and search_pull_requests to reflect the schema. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test: bound encoded schema parity registration work Reuse each immutable HTTP server across pagination requests and share the SDK schema cache across parity variants without changing wire comparisons. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../__toolsnaps__/list_branches_output.snap | 30 ++ .../__toolsnaps__/list_issues_output.snap | 245 ++++++++++ .../list_pull_requests_output.snap | 335 ++++++++++++++ .../__toolsnaps__/list_tags_output.snap | 26 ++ .../__toolsnaps__/search_code_output.snap | 136 ++++++ .../__toolsnaps__/search_issues_output.snap | 374 +++++++++++++++ .../search_pull_requests_output.snap | 374 +++++++++++++++ pkg/github/encoded_schemas_test.go | 24 +- pkg/github/issues.go | 432 +++++++++++++----- pkg/github/pullrequests.go | 216 ++++++--- pkg/github/repositories.go | 67 +-- pkg/github/search.go | 148 ++++-- pkg/github/search_utils.go | 83 ++-- pkg/github/typed_read_normalizers.go | 82 ++++ pkg/github/typed_search_outputs_test.go | 385 ++++++++++++++++ 15 files changed, 2676 insertions(+), 281 deletions(-) create mode 100644 pkg/github/__toolsnaps__/list_branches_output.snap create mode 100644 pkg/github/__toolsnaps__/list_issues_output.snap create mode 100644 pkg/github/__toolsnaps__/list_pull_requests_output.snap create mode 100644 pkg/github/__toolsnaps__/list_tags_output.snap create mode 100644 pkg/github/__toolsnaps__/search_code_output.snap create mode 100644 pkg/github/__toolsnaps__/search_issues_output.snap create mode 100644 pkg/github/__toolsnaps__/search_pull_requests_output.snap create mode 100644 pkg/github/typed_read_normalizers.go create mode 100644 pkg/github/typed_search_outputs_test.go diff --git a/pkg/github/__toolsnaps__/list_branches_output.snap b/pkg/github/__toolsnaps__/list_branches_output.snap new file mode 100644 index 0000000000..147d9a7efe --- /dev/null +++ b/pkg/github/__toolsnaps__/list_branches_output.snap @@ -0,0 +1,30 @@ +{ + "inputSchema": null, + "name": "list_branches", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "protected": { + "type": "boolean" + }, + "sha": { + "type": "string" + } + }, + "required": [ + "name", + "sha", + "protected" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_issues_output.snap b/pkg/github/__toolsnaps__/list_issues_output.snap new file mode 100644 index 0000000000..9c71265fea --- /dev/null +++ b/pkg/github/__toolsnaps__/list_issues_output.snap @@ -0,0 +1,245 @@ +{ + "inputSchema": null, + "name": "list_issues", + "outputSchema": { + "additionalProperties": false, + "properties": { + "issues": { + "items": { + "additionalProperties": false, + "properties": { + "assignees": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "body": { + "type": [ + "null", + "string" + ] + }, + "comments": { + "description": "Number of comments on the issue.", + "type": [ + "null", + "integer" + ] + }, + "created_at": { + "description": "Creation time in RFC 3339 format.", + "type": [ + "null", + "string" + ] + }, + "field_values": { + "items": { + "additionalProperties": false, + "properties": { + "field": { + "type": "string" + }, + "value": { + "type": "string" + }, + "values": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + } + }, + "required": [ + "field" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "state": { + "description": "Issue state: OPEN or CLOSED.", + "type": [ + "null", + "string" + ] + }, + "title": { + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "description": "Last update time in RFC 3339 format.", + "type": [ + "null", + "string" + ] + }, + "user": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + } + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "pageInfo": { + "additionalProperties": false, + "properties": { + "endCursor": { + "description": "Cursor for the last issue in this page.", + "type": "string" + }, + "hasNextPage": { + "description": "Whether another page of results is available.", + "type": "boolean" + }, + "hasPreviousPage": { + "description": "Whether a previous page of results is available.", + "type": "boolean" + }, + "startCursor": { + "description": "Cursor for the first issue in this page.", + "type": "string" + } + }, + "required": [ + "hasNextPage", + "hasPreviousPage" + ], + "type": "object" + }, + "totalCount": { + "description": "Total number of issues matching the filters.", + "type": "integer" + } + }, + "required": [ + "issues", + "totalCount", + "pageInfo" + ], + "type": "object" + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_pull_requests_output.snap b/pkg/github/__toolsnaps__/list_pull_requests_output.snap new file mode 100644 index 0000000000..2d70947044 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_pull_requests_output.snap @@ -0,0 +1,335 @@ +{ + "inputSchema": null, + "name": "list_pull_requests", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "additions": { + "description": "Number of lines added.", + "type": [ + "null", + "integer" + ] + }, + "assignees": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "base": { + "additionalProperties": false, + "properties": { + "ref": { + "type": "string" + }, + "repo": { + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "full_name": { + "type": "string" + } + }, + "required": [ + "full_name" + ], + "type": [ + "null", + "object" + ] + }, + "sha": { + "type": "string" + } + }, + "required": [ + "ref", + "sha" + ], + "type": [ + "null", + "object" + ] + }, + "body": { + "type": [ + "null", + "string" + ] + }, + "changed_files": { + "description": "Number of files changed.", + "type": [ + "null", + "integer" + ] + }, + "closed_at": { + "description": "Closing time in RFC 3339 format.", + "type": [ + "null", + "string" + ] + }, + "comments": { + "description": "Number of comments on the pull request.", + "type": [ + "null", + "integer" + ] + }, + "commits": { + "description": "Number of commits in the pull request.", + "type": [ + "null", + "integer" + ] + }, + "created_at": { + "description": "Creation time in RFC 3339 format.", + "type": [ + "null", + "string" + ] + }, + "deletions": { + "description": "Number of lines removed.", + "type": [ + "null", + "integer" + ] + }, + "draft": { + "type": [ + "null", + "boolean" + ] + }, + "head": { + "additionalProperties": false, + "properties": { + "ref": { + "type": "string" + }, + "repo": { + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "full_name": { + "type": "string" + } + }, + "required": [ + "full_name" + ], + "type": [ + "null", + "object" + ] + }, + "sha": { + "type": "string" + } + }, + "required": [ + "ref", + "sha" + ], + "type": [ + "null", + "object" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "mergeable_state": { + "type": [ + "null", + "string" + ] + }, + "merged": { + "type": [ + "null", + "boolean" + ] + }, + "merged_at": { + "description": "Merge time in RFC 3339 format.", + "type": [ + "null", + "string" + ] + }, + "merged_by": { + "type": [ + "null", + "string" + ] + }, + "milestone": { + "type": [ + "null", + "string" + ] + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "requested_reviewers": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "state": { + "description": "Pull request state: open or closed.", + "type": [ + "null", + "string" + ] + }, + "title": { + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "description": "Last update time in RFC 3339 format.", + "type": [ + "null", + "string" + ] + }, + "user": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + } + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_tags_output.snap b/pkg/github/__toolsnaps__/list_tags_output.snap new file mode 100644 index 0000000000..2975b18ad3 --- /dev/null +++ b/pkg/github/__toolsnaps__/list_tags_output.snap @@ -0,0 +1,26 @@ +{ + "inputSchema": null, + "name": "list_tags", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "sha": { + "type": "string" + } + }, + "required": [ + "name", + "sha" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_code_output.snap b/pkg/github/__toolsnaps__/search_code_output.snap new file mode 100644 index 0000000000..68ebc8c905 --- /dev/null +++ b/pkg/github/__toolsnaps__/search_code_output.snap @@ -0,0 +1,136 @@ +{ + "inputSchema": null, + "name": "search_code", + "outputSchema": { + "additionalProperties": false, + "properties": { + "incomplete_results": { + "description": "Whether GitHub returned an incomplete result set.", + "type": "boolean" + }, + "items": { + "items": { + "additionalProperties": false, + "properties": { + "name": { + "type": [ + "null", + "string" + ] + }, + "path": { + "type": [ + "null", + "string" + ] + }, + "repository": { + "type": [ + "null", + "string" + ] + }, + "sha": { + "type": [ + "null", + "string" + ] + }, + "text_matches": { + "description": "Matching code fragments returned by GitHub.", + "items": { + "additionalProperties": false, + "properties": { + "fragment": { + "description": "Source fragment containing the match.", + "type": [ + "null", + "string" + ] + }, + "matches": { + "description": "Matched text and its offsets in the fragment.", + "items": { + "additionalProperties": false, + "properties": { + "indices": { + "description": "Start and end offsets of the match in the fragment.", + "items": { + "type": "integer" + }, + "type": [ + "null", + "array" + ] + }, + "text": { + "description": "Matched text.", + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + }, + "object_type": { + "description": "GitHub object type containing the match.", + "type": [ + "null", + "string" + ] + }, + "object_url": { + "description": "API URL of the matched file or object.", + "type": [ + "null", + "string" + ] + }, + "property": { + "description": "Property containing the matched text.", + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + } + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "total_count": { + "description": "Total number of matching code results.", + "type": "integer" + } + }, + "required": [ + "total_count", + "incomplete_results", + "items" + ], + "type": "object" + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_issues_output.snap b/pkg/github/__toolsnaps__/search_issues_output.snap new file mode 100644 index 0000000000..82b00688f6 --- /dev/null +++ b/pkg/github/__toolsnaps__/search_issues_output.snap @@ -0,0 +1,374 @@ +{ + "inputSchema": null, + "name": "search_issues", + "outputSchema": { + "additionalProperties": false, + "properties": { + "incomplete_results": { + "description": "Whether GitHub returned an incomplete result set.", + "type": [ + "null", + "boolean" + ] + }, + "items": { + "items": { + "additionalProperties": false, + "properties": { + "assignee": { + "type": [ + "null", + "string" + ] + }, + "assignees": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "author_association": { + "type": [ + "null", + "string" + ] + }, + "body": { + "type": [ + "null", + "string" + ] + }, + "closed_at": { + "description": "Closing time in RFC 3339 format.", + "type": [ + "null", + "string" + ] + }, + "closed_by": { + "type": [ + "null", + "string" + ] + }, + "comments": { + "description": "Number of comments on the issue.", + "type": [ + "null", + "integer" + ] + }, + "created_at": { + "description": "Creation time in RFC 3339 format.", + "type": [ + "null", + "string" + ] + }, + "draft": { + "type": [ + "null", + "boolean" + ] + }, + "field_values": { + "items": { + "additionalProperties": false, + "properties": { + "field": { + "type": "string" + }, + "value": { + "type": "string" + }, + "values": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + } + }, + "required": [ + "field" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "locked": { + "type": [ + "null", + "boolean" + ] + }, + "milestone": { + "type": [ + "null", + "string" + ] + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "pull_request": { + "additionalProperties": false, + "properties": { + "diff_url": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "patch_url": { + "type": [ + "null", + "string" + ] + }, + "url": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "reactions": { + "additionalProperties": false, + "properties": { + "+1": { + "type": "integer" + }, + "-1": { + "type": "integer" + }, + "confused": { + "type": "integer" + }, + "eyes": { + "type": "integer" + }, + "heart": { + "type": "integer" + }, + "hooray": { + "type": "integer" + }, + "laugh": { + "type": "integer" + }, + "rocket": { + "type": "integer" + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "total_count", + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "rocket", + "eyes" + ], + "type": [ + "null", + "object" + ] + }, + "repository_url": { + "type": [ + "null", + "string" + ] + }, + "state": { + "description": "Issue state, typically open or closed.", + "type": [ + "null", + "string" + ] + }, + "state_reason": { + "type": [ + "null", + "string" + ] + }, + "title": { + "type": [ + "null", + "string" + ] + }, + "type": { + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "description": "Last update time in RFC 3339 format.", + "type": [ + "null", + "string" + ] + }, + "user": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + } + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "total_count": { + "description": "Total number of matching issues or pull requests.", + "type": [ + "null", + "integer" + ] + } + }, + "required": [ + "items" + ], + "type": "object" + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_pull_requests_output.snap b/pkg/github/__toolsnaps__/search_pull_requests_output.snap new file mode 100644 index 0000000000..45e170ea42 --- /dev/null +++ b/pkg/github/__toolsnaps__/search_pull_requests_output.snap @@ -0,0 +1,374 @@ +{ + "inputSchema": null, + "name": "search_pull_requests", + "outputSchema": { + "additionalProperties": false, + "properties": { + "incomplete_results": { + "description": "Whether GitHub returned an incomplete result set.", + "type": [ + "null", + "boolean" + ] + }, + "items": { + "items": { + "additionalProperties": false, + "properties": { + "assignee": { + "type": [ + "null", + "string" + ] + }, + "assignees": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "author_association": { + "type": [ + "null", + "string" + ] + }, + "body": { + "type": [ + "null", + "string" + ] + }, + "closed_at": { + "description": "Closing time in RFC 3339 format.", + "type": [ + "null", + "string" + ] + }, + "closed_by": { + "type": [ + "null", + "string" + ] + }, + "comments": { + "description": "Number of comments on the issue.", + "type": [ + "null", + "integer" + ] + }, + "created_at": { + "description": "Creation time in RFC 3339 format.", + "type": [ + "null", + "string" + ] + }, + "draft": { + "type": [ + "null", + "boolean" + ] + }, + "field_values": { + "items": { + "additionalProperties": false, + "properties": { + "field": { + "type": "string" + }, + "value": { + "type": "string" + }, + "values": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + } + }, + "required": [ + "field" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "locked": { + "type": [ + "null", + "boolean" + ] + }, + "milestone": { + "type": [ + "null", + "string" + ] + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "pull_request": { + "additionalProperties": false, + "properties": { + "diff_url": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "patch_url": { + "type": [ + "null", + "string" + ] + }, + "url": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "reactions": { + "additionalProperties": false, + "properties": { + "+1": { + "type": "integer" + }, + "-1": { + "type": "integer" + }, + "confused": { + "type": "integer" + }, + "eyes": { + "type": "integer" + }, + "heart": { + "type": "integer" + }, + "hooray": { + "type": "integer" + }, + "laugh": { + "type": "integer" + }, + "rocket": { + "type": "integer" + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "total_count", + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "rocket", + "eyes" + ], + "type": [ + "null", + "object" + ] + }, + "repository_url": { + "type": [ + "null", + "string" + ] + }, + "state": { + "description": "Issue state, typically open or closed.", + "type": [ + "null", + "string" + ] + }, + "state_reason": { + "type": [ + "null", + "string" + ] + }, + "title": { + "type": [ + "null", + "string" + ] + }, + "type": { + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "description": "Last update time in RFC 3339 format.", + "type": [ + "null", + "string" + ] + }, + "user": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + } + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "total_count": { + "description": "Total number of matching issues or pull requests.", + "type": [ + "null", + "integer" + ] + } + }, + "required": [ + "items" + ], + "type": "object" + } +} \ No newline at end of file diff --git a/pkg/github/encoded_schemas_test.go b/pkg/github/encoded_schemas_test.go index 09e9011ae0..dadf5b3e60 100644 --- a/pkg/github/encoded_schemas_test.go +++ b/pkg/github/encoded_schemas_test.go @@ -24,8 +24,8 @@ import ( // The baseline uses the public single-tool registration path, which does not // install the tools/list encoder. Both paths select the same inventory first. -func schemaListServer(ctx context.Context, inv *inventory.Inventory, optimized bool, pageSize int) *mcp.Server { - server := mcp.NewServer(&mcp.Implementation{Name: "schema-list"}, &mcp.ServerOptions{PageSize: pageSize}) +func schemaListServer(ctx context.Context, inv *inventory.Inventory, optimized bool, pageSize int, schemaCache *mcp.SchemaCache) *mcp.Server { + server := mcp.NewServer(&mcp.Implementation{Name: "schema-list"}, &mcp.ServerOptions{PageSize: pageSize, SchemaCache: schemaCache}) if optimized { inv.RegisterTools(ctx, server, nil) } else { @@ -49,12 +49,12 @@ type schemaListWire struct { Error json.RawMessage `json:"error"` } -func schemaListPages(ctx context.Context, t *testing.T, inv *inventory.Inventory, optimized bool, transport, protocol string, pageSize int) ([][]byte, []string) { +func schemaListPages(ctx context.Context, t *testing.T, inv *inventory.Inventory, optimized bool, transport, protocol string, pageSize int, schemaCache *mcp.SchemaCache) ([][]byte, []string) { t.Helper() var request func(string) []byte switch transport { case "stdio": - server := schemaListServer(ctx, inv, optimized, pageSize) + server := schemaListServer(ctx, inv, optimized, pageSize, schemaCache) serverConn, clientConn := net.Pipe() ss, err := server.Connect(ctx, &mcp.IOTransport{Reader: serverConn, Writer: serverConn}, nil) require.NoError(t, err) @@ -74,8 +74,11 @@ func schemaListPages(ctx context.Context, t *testing.T, inv *inventory.Inventory require.NoError(t, err) request = exchange case "http": + // Reuse the fixed inventory's server across pages instead of repeating + // SDK annotation validation for every stateless request. + server := schemaListServer(ctx, inv, optimized, pageSize, schemaCache) handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { - return schemaListServer(ctx, inv, optimized, pageSize) + return server }, &mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true}) request = func(body string) []byte { req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(body)) @@ -122,6 +125,9 @@ func schemaListPages(ctx context.Context, t *testing.T, inv *inventory.Inventory } func TestEncodedSchemasWireParity(t *testing.T) { + // Share immutable resolved schemas without changing either registration + // path or the wire comparison. + schemaCache := mcp.NewSchemaCache() type selection struct { name string toolsets []string @@ -189,8 +195,8 @@ func TestEncodedSchemasWireParity(t *testing.T) { wantNames = slices.Compact(wantNames) for _, transport := range []string{"stdio", "http"} { t.Run(transport, func(t *testing.T) { - before, baselineNames := schemaListPages(ctx, t, inv, false, transport, protocol, 7) - after, encodedNames := schemaListPages(ctx, t, inv, true, transport, protocol, 7) + before, baselineNames := schemaListPages(ctx, t, inv, false, transport, protocol, 7, schemaCache) + after, encodedNames := schemaListPages(ctx, t, inv, true, transport, protocol, 7, schemaCache) assert.Equal(t, before, after, "complete JSON-RPC wire responses, including cursors and errors") assert.Equal(t, wantNames, baselineNames) assert.Equal(t, wantNames, encodedNames) @@ -228,7 +234,7 @@ func BenchmarkEncodedSchemas(b *testing.B) { name := map[bool]string{false: "baseline", true: "encoded"}[optimized] b.Run(name, func(b *testing.B) { b.Run("ListTools", func(b *testing.B) { - cs := schemaListClient(b, schemaListServer(ctx, inv, optimized, 1000)) + cs := schemaListClient(b, schemaListServer(ctx, inv, optimized, 1000, nil)) result, err := cs.ListTools(ctx, nil) require.NoError(b, err) payload, err := json.Marshal(result) @@ -247,7 +253,7 @@ func BenchmarkEncodedSchemas(b *testing.B) { b.ReportAllocs() for b.Loop() { st, ct := mcp.NewInMemoryTransports() - server := schemaListServer(ctx, inv, optimized, 1000) + server := schemaListServer(ctx, inv, optimized, 1000, nil) ss, err := server.Connect(ctx, st, nil) if err != nil { b.Fatal(err) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 4d7e72b58a..51ff97dd04 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "slices" "strconv" "strings" "time" @@ -1970,7 +1971,7 @@ func SearchIssues(t translations.TranslationHelperFunc, opts ...ToolOption) inve ) WithPagination(schema) - return NewTool( + return NewTool[SearchIssuesInput, SearchIssuesOutput]( ToolsetMetadataIssues, mcp.Tool{ Name: "search_issues", @@ -1982,16 +1983,14 @@ func SearchIssues(t translations.TranslationHelperFunc, opts ...ToolOption) inve InputSchema: schema, }, scopes.PublicRead(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input SearchIssuesInput) (*mcp.CallToolResult, SearchIssuesOutput, error) { options := []searchOption{ifcSearchPostProcessOption(ctx, deps)} - fields, err := OptionalStringArrayParam(args, "fields") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - options = append(options, withFieldsFiltering(deps, "search_issues", fields)) - result, err := searchIssuesHandler(ctx, deps, args, mode, options...) - return result, nil, err - }) + options = append(options, withFieldsFiltering(deps, "search_issues", input.Fields)) + result, response, err := searchIssuesHandler(ctx, deps, input, mode, options...) + return result, structuredSearchIssuesOutput(response, input.Fields), err + }, + normalizeTypedReadArguments(nil, false), + ) } // searchIssuesIFCPostProcess returns a searchPostProcessFn that attaches the @@ -2146,6 +2145,150 @@ type SearchIssuesResponse struct { Items []SearchIssueResult `json:"items"` } +type SearchIssuesOutput struct { + Total *int `json:"total_count,omitempty" jsonschema:"Total number of matching issues or pull requests."` + IncompleteResults *bool `json:"incomplete_results,omitempty" jsonschema:"Whether GitHub returned an incomplete result set."` + Items []SearchIssueOutputItem `json:"items"` +} + +type SearchIssueOutputItem struct { + Number *int `json:"number,omitempty"` + Title *string `json:"title,omitempty"` + Body *string `json:"body,omitempty"` + State *string `json:"state,omitempty" jsonschema:"Issue state, typically open or closed."` + 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"` + Assignee *string `json:"assignee,omitempty"` + Assignees []string `json:"assignees,omitempty"` + Milestone *string `json:"milestone,omitempty"` + Comments *int `json:"comments,omitempty" jsonschema:"Number of comments on the issue."` + Reactions *MinimalReactions `json:"reactions,omitempty"` + CreatedAt *string `json:"created_at,omitempty" jsonschema:"Creation time in RFC 3339 format."` + UpdatedAt *string `json:"updated_at,omitempty" jsonschema:"Last update time in RFC 3339 format."` + ClosedAt *string `json:"closed_at,omitempty" jsonschema:"Closing time in RFC 3339 format."` + ClosedBy *string `json:"closed_by,omitempty"` + Type *string `json:"type,omitempty"` + RepositoryURL *string `json:"repository_url,omitempty"` + PullRequest *SearchIssuePullRequest `json:"pull_request,omitempty"` + FieldValues *[]MinimalFieldValue `json:"field_values,omitempty"` +} + +type SearchIssuePullRequest struct { + URL *string `json:"url,omitempty"` + HTMLURL *string `json:"html_url,omitempty"` + DiffURL *string `json:"diff_url,omitempty"` + PatchURL *string `json:"patch_url,omitempty"` +} + +func structuredSearchIssuesOutput(response SearchIssuesResponse, fields []string) SearchIssuesOutput { + output := SearchIssuesOutput{ + Total: response.Total, + IncompleteResults: response.IncompleteResults, + Items: make([]SearchIssueOutputItem, 0, len(response.Items)), + } + for _, item := range response.Items { + output.Items = append(output.Items, searchIssueOutputItem(item, fields)) + } + return output +} + +func searchIssueOutputItem(result SearchIssueResult, fields []string) SearchIssueOutputItem { + var output SearchIssueOutputItem + issue := result.Issue + if issue == nil { + return output + } + selected := func(field string) bool { + return len(fields) == 0 || slices.Contains(fields, field) + } + minimal := convertToMinimalIssue(issue) + if selected("number") && issue.Number != nil { + output.Number = new(minimal.Number) + } + if selected("title") && issue.Title != nil { + output.Title = new(minimal.Title) + } + if selected("body") && issue.Body != nil { + output.Body = new(minimal.Body) + } + if selected("state") && issue.State != nil { + output.State = new(minimal.State) + } + if selected("state_reason") && issue.StateReason != nil { + output.StateReason = new(minimal.StateReason) + } + if selected("draft") && issue.Draft != nil { + output.Draft = new(minimal.Draft) + } + if selected("locked") && issue.Locked != nil { + output.Locked = new(minimal.Locked) + } + if selected("html_url") && issue.HTMLURL != nil { + output.HTMLURL = new(minimal.HTMLURL) + } + if selected("user") && issue.User != nil { + output.User = minimal.User + } + if selected("author_association") && issue.AuthorAssociation != nil { //nolint:staticcheck // Keep the legacy search projection available. + output.AuthorAssociation = new(minimal.AuthorAssociation) + } + if selected("labels") && issue.Labels != nil { + output.Labels = minimal.Labels + } + if selected("assignee") && issue.Assignee != nil { //nolint:staticcheck // Keep the legacy search projection available. + login := issue.Assignee.GetLogin() //nolint:staticcheck // Keep the legacy search projection available. + output.Assignee = &login + } + if selected("assignees") && issue.Assignees != nil { + output.Assignees = minimal.Assignees + } + if selected("milestone") && issue.Milestone != nil { + output.Milestone = new(minimal.Milestone) + } + if selected("comments") && issue.Comments != nil { + output.Comments = new(minimal.Comments) + } + if selected("reactions") && issue.Reactions != nil { + output.Reactions = minimal.Reactions + } + if selected("created_at") && issue.CreatedAt != nil { + output.CreatedAt = new(minimal.CreatedAt) + } + if selected("updated_at") && issue.UpdatedAt != nil { + output.UpdatedAt = new(minimal.UpdatedAt) + } + if selected("closed_at") && issue.ClosedAt != nil { + output.ClosedAt = new(minimal.ClosedAt) + } + if selected("closed_by") && issue.ClosedBy != nil { + output.ClosedBy = new(minimal.ClosedBy) + } + if selected("type") && issue.Type != nil { + output.Type = new(issue.Type.GetName()) + } + if selected("repository_url") && issue.RepositoryURL != nil { + output.RepositoryURL = new(issue.GetRepositoryURL()) + } + if selected("pull_request") && issue.PullRequestLinks != nil { + links := issue.PullRequestLinks + output.PullRequest = &SearchIssuePullRequest{ + URL: links.URL, + HTMLURL: links.HTMLURL, + DiffURL: links.DiffURL, + PatchURL: links.PatchURL, + } + } + if selected("field_values") && result.FieldValues != nil { + output.FieldValues = &result.FieldValues + } + return output +} + // 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 { @@ -2337,43 +2480,44 @@ func fetchIssueReadEnrichment(ctx context.Context, gqlClient *githubv4.Client, n // 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, mode searchMode, options ...searchOption) (*mcp.CallToolResult, error) { +func searchIssuesHandler(ctx context.Context, deps ToolDependencies, input SearchIssuesInput, mode searchMode, options ...searchOption) (*mcp.CallToolResult, SearchIssuesResponse, error) { const errorPrefix = "failed to search issues" - query, opts, err := prepareSearchArgs(args, "issue", mode) + var output SearchIssuesResponse + query, opts, err := prepareSearchArgs(input, "issue", mode) if err != nil { - return utils.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), output, nil } client, err := deps.GetClient(ctx) if err != nil { - return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub client", err), nil + return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub client", err), output, nil } result, resp, err := client.Search.Issues(ctx, query, opts) if err != nil { - return utils.NewToolResultErrorFromErr(errorPrefix, err), nil + return utils.NewToolResultErrorFromErr(errorPrefix, err), output, 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 utils.NewToolResultErrorFromErr(errorPrefix+": failed to read response body", err), output, nil } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, errorPrefix, resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, errorPrefix, resp, body), output, nil } var fieldValuesByID map[string][]MinimalFieldValue 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 + return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub GraphQL client", err), output, nil } fieldValuesByID, err = fetchIssueFieldValuesByNodeID(ctx, gqlClient, result.Issues) if err != nil { const enrichmentError = errorPrefix + ": failed to fetch issue field values" if !isUnsupportedIssueFieldValuesSchemaError(err) { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, enrichmentError, err), nil + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, enrichmentError, err), output, nil } // Older GHES schemas can lack this optional enrichment. Preserve the REST // search results while retaining the compatibility failure for observability. @@ -2401,12 +2545,13 @@ func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[st opt(&cfg) } + output = response filtered := false var payload any = response if len(cfg.fields) > 0 { filteredItems, err := filterEachField(response.Items, cfg.fields) if err != nil { - return utils.NewToolResultErrorFromErr(errorPrefix+": failed to filter results", err), nil + return utils.NewToolResultErrorFromErr(errorPrefix+": failed to filter results", err), SearchIssuesResponse{}, nil } payload = map[string]any{ "total_count": response.Total, @@ -2418,7 +2563,7 @@ func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[st r, err := json.Marshal(payload) if err != nil { - return utils.NewToolResultErrorFromErr(errorPrefix+": failed to marshal response", err), nil + return utils.NewToolResultErrorFromErr(errorPrefix+": failed to marshal response", err), SearchIssuesResponse{}, nil } if cfg.fieldsTool != "" { @@ -2429,7 +2574,7 @@ func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[st if cfg.postProcess != nil { cfg.postProcess(ctx, result, callResult) } - return callResult, nil + return callResult, output, nil } // IssueWriteUIResourceURI is the URI for the issue_write tool's MCP App UI resource. @@ -3415,6 +3560,107 @@ func patchIssue(ctx context.Context, client *github.Client, owner, repo string, } // ListIssues creates a tool to list issues in a GitHub repository. +type ListIssuesInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + State string `json:"state,omitempty"` + Labels []string `json:"labels,omitempty"` + OrderBy string `json:"orderBy,omitempty"` + Direction string `json:"direction,omitempty"` + Since string `json:"since,omitempty"` + FieldFilters []IssueFieldFilterInput `json:"field_filters,omitempty"` + Fields []string `json:"fields,omitempty"` + After string `json:"after,omitempty"` + PerPage *int `json:"perPage,omitempty"` + Page json.RawMessage `json:"page,omitempty"` +} + +type IssueFieldFilterInput struct { + FieldName string `json:"field_name"` + Value string `json:"value"` +} + +type ListIssuesOutput struct { + Issues []ListIssueOutput `json:"issues"` + TotalCount int `json:"totalCount" jsonschema:"Total number of issues matching the filters."` + PageInfo ListIssuePageInfo `json:"pageInfo"` +} + +type ListIssueOutput struct { + Number *int `json:"number,omitempty"` + Title *string `json:"title,omitempty"` + Body *string `json:"body,omitempty"` + State *string `json:"state,omitempty" jsonschema:"Issue state: OPEN or CLOSED."` + User *MinimalUser `json:"user,omitempty"` + Labels *[]string `json:"labels,omitempty"` + Assignees *[]string `json:"assignees,omitempty"` + Comments *int `json:"comments,omitempty" jsonschema:"Number of comments on the issue."` + CreatedAt *string `json:"created_at,omitempty" jsonschema:"Creation time in RFC 3339 format."` + UpdatedAt *string `json:"updated_at,omitempty" jsonschema:"Last update time in RFC 3339 format."` + FieldValues *[]MinimalFieldValue `json:"field_values,omitempty"` +} + +type ListIssuePageInfo struct { + HasNextPage bool `json:"hasNextPage" jsonschema:"Whether another page of results is available."` + HasPreviousPage bool `json:"hasPreviousPage" jsonschema:"Whether a previous page of results is available."` + StartCursor string `json:"startCursor,omitempty" jsonschema:"Cursor for the first issue in this page."` + EndCursor string `json:"endCursor,omitempty" jsonschema:"Cursor for the last issue in this page."` +} + +func structuredListIssuesOutput(response MinimalIssuesResponse, fields []string) ListIssuesOutput { + output := ListIssuesOutput{ + Issues: make([]ListIssueOutput, 0, len(response.Issues)), + TotalCount: response.TotalCount, + PageInfo: ListIssuePageInfo{ + HasNextPage: response.PageInfo.HasNextPage, + HasPreviousPage: response.PageInfo.HasPreviousPage, + StartCursor: response.PageInfo.StartCursor, + EndCursor: response.PageInfo.EndCursor, + }, + } + selected := func(field string) bool { + return len(fields) == 0 || slices.Contains(fields, field) + } + for _, issue := range response.Issues { + item := ListIssueOutput{} + if selected("number") { + item.Number = new(issue.Number) + } + if selected("title") { + item.Title = new(issue.Title) + } + if selected("body") && issue.Body != "" { + item.Body = new(issue.Body) + } + if selected("state") { + item.State = new(issue.State) + } + if selected("user") && issue.User != nil { + item.User = issue.User + } + if selected("labels") && len(issue.Labels) > 0 { + item.Labels = &issue.Labels + } + if selected("assignees") { + item.Assignees = &issue.Assignees + } + if selected("comments") && issue.Comments != 0 { + item.Comments = new(issue.Comments) + } + if selected("created_at") && issue.CreatedAt != "" { + item.CreatedAt = new(issue.CreatedAt) + } + if selected("updated_at") && issue.UpdatedAt != "" { + item.UpdatedAt = new(issue.UpdatedAt) + } + if selected("field_values") && len(issue.FieldValues) > 0 { + item.FieldValues = &issue.FieldValues + } + output.Issues = append(output.Issues, item) + } + return output +} + func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { schema := &jsonschema.Schema{ Type: "object", @@ -3480,7 +3726,7 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { ) WithCursorPagination(schema) - st := NewTool( + st := NewTool[ListIssuesInput, ListIssuesOutput]( ToolsetMetadataIssues, mcp.Tool{ Name: "list_issues", @@ -3492,26 +3738,19 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { InputSchema: schema, }, scopes.PublicRead(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input ListIssuesInput) (*mcp.CallToolResult, ListIssuesOutput, error) { + var output ListIssuesOutput + var err error + if input.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), output, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - fields, err := OptionalStringArrayParam(args, "fields") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if input.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), output, nil } + owner, repo := input.Owner, input.Repo // Set optional parameters if provided - state, err := OptionalParam[string](args, "state") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + state := input.State // Normalize and filter by state state = strings.ToUpper(state) @@ -3525,20 +3764,9 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { } // Get labels - 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 - } + labels := input.Labels + orderBy := input.OrderBy + direction := input.Direction // Normalize and validate orderBy orderBy = strings.ToUpper(orderBy) @@ -3558,10 +3786,7 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { direction = "DESC" } - since, err := OptionalParam[string](args, "since") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + since := input.Since // There are two optional parameters: since and labels. var sinceTime time.Time @@ -3569,35 +3794,40 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { if since != "" { sinceTime, err = parseISOTimestamp(since) if err != nil { - return utils.NewToolResultError(fmt.Sprintf("failed to list issues: %s", err.Error())), nil, nil + return utils.NewToolResultError(fmt.Sprintf("failed to list issues: %s", err.Error())), output, nil } hasSince = true } hasLabels := len(labels) > 0 - rawFilters, err := parseRawFieldFilters(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + rawFilters := make([]rawFieldFilter, 0, len(input.FieldFilters)) + for _, filter := range input.FieldFilters { + if filter.FieldName == "" { + return utils.NewToolResultError("field_filters entry: missing required parameter: field_name"), output, nil + } + if filter.Value == "" { + return utils.NewToolResultError(fmt.Sprintf("field_filters entry %q: missing required parameter: value", filter.FieldName)), output, nil + } + rawFilters = append(rawFilters, rawFieldFilter{Name: filter.FieldName, Value: filter.Value}) } // Get pagination parameters and convert to GraphQL format - pagination, err := OptionalCursorPaginationParams(args) - if err != nil { - return nil, nil, err + pagination := CursorPaginationParams{PerPage: 30, After: input.After} + if input.PerPage != nil { + pagination.PerPage = *input.PerPage } // Check if someone tried to use page-based pagination instead of cursor-based - 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 + if len(input.Page) > 0 { + return utils.NewToolResultError("This tool uses cursor-based pagination. Use the 'after' parameter with the 'endCursor' value from the previous response instead of 'page'."), output, nil } // Check if pagination parameters were explicitly provided - _, perPageProvided := args["perPage"] - paginationExplicit := perPageProvided + paginationExplicit := input.PerPage != nil paginationParams, err := pagination.ToGraphQLParams() if err != nil { - return nil, nil, err + return nil, output, err } // Use default of 30 if pagination was not explicitly provided @@ -3608,7 +3838,7 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { client, err := deps.GetGQLClient(ctx) if err != nil { - return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil + return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), output, nil } // Resolve field filters by looking up the repo's issue fields so we can @@ -3617,11 +3847,11 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { 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 + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to look up issue fields for field_filters", err), output, nil } fieldFilters, err = resolveFieldFilters(rawFilters, fields) if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + return utils.NewToolResultError(err.Error()), output, nil } } @@ -3674,7 +3904,7 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { ctx, "failed to list issues", issueFieldsErr, - ), nil, nil + ), output, nil } issueQueryWithoutFieldValues := getIssueQueryTypeWithoutFieldValues(hasLabels, hasSince) @@ -3689,7 +3919,7 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { ctx, "failed to list issues", fmt.Errorf("issue-fields query failed: %w; fallback query failed: %w", issueFieldsErr, fallbackErr), - ), nil, nil + ), output, nil } resp = convertToMinimalIssuesResponseWithoutFieldValues(issueQueryWithoutFieldValues.getIssueFragmentWithoutFieldValues()) @@ -3698,10 +3928,10 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { filtered := false var payload any = resp - if len(fields) > 0 { - filteredIssues, err := filterEachField(resp.Issues, fields) + if len(input.Fields) > 0 { + filteredIssues, err := filterEachField(resp.Issues, input.Fields) if err != nil { - return utils.NewToolResultErrorFromErr("failed to filter issues", err), nil, nil + return utils.NewToolResultErrorFromErr("failed to filter issues", err), output, nil } payload = map[string]any{ "issues": filteredIssues, @@ -3713,15 +3943,17 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { r, err := json.Marshal(payload) if err != nil { - return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + return utils.NewToolResultErrorFromErr("failed to marshal response", err), output, nil } recordFieldsUsageFor(ctx, deps, "list_issues", resp, filtered, len(r)) result := utils.NewToolResultText(string(r)) result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelListIssues(isPrivate)) - return result, nil, nil - }) + return result, structuredListIssuesOutput(resp, input.Fields), nil + }, + normalizeTypedReadArguments([]string{"state", "orderBy", "direction"}, true), + ) return st } @@ -3731,46 +3963,6 @@ type rawFieldFilter struct { 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 diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index d5e02fee71..a8493bc521 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "slices" "github.com/go-viper/mapstructure/v2" "github.com/google/go-github/v92/github" @@ -1325,6 +1326,134 @@ func AddReplyToPullRequestComment(t translations.TranslationHelperFunc) inventor } // ListPullRequests creates a tool to list pull requests in a GitHub repository. +type ListPullRequestsInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + State string `json:"state,omitempty"` + Head string `json:"head,omitempty"` + Base string `json:"base,omitempty"` + Sort string `json:"sort,omitempty"` + Direction string `json:"direction,omitempty"` + Fields []string `json:"fields,omitempty"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` +} + +type ListPullRequestOutput struct { + Number *int `json:"number,omitempty"` + Title *string `json:"title,omitempty"` + Body *string `json:"body,omitempty"` + State *string `json:"state,omitempty" jsonschema:"Pull request state: open or closed."` + Draft *bool `json:"draft,omitempty"` + Merged *bool `json:"merged,omitempty"` + MergeableState *string `json:"mergeable_state,omitempty"` + HTMLURL *string `json:"html_url,omitempty"` + User *MinimalUser `json:"user,omitempty"` + Labels *[]string `json:"labels,omitempty"` + Assignees *[]string `json:"assignees,omitempty"` + RequestedReviewers *[]string `json:"requested_reviewers,omitempty"` + MergedBy *string `json:"merged_by,omitempty"` + Head *MinimalPRBranch `json:"head,omitempty"` + Base *MinimalPRBranch `json:"base,omitempty"` + Additions *int `json:"additions,omitempty" jsonschema:"Number of lines added."` + Deletions *int `json:"deletions,omitempty" jsonschema:"Number of lines removed."` + ChangedFiles *int `json:"changed_files,omitempty" jsonschema:"Number of files changed."` + Commits *int `json:"commits,omitempty" jsonschema:"Number of commits in the pull request."` + Comments *int `json:"comments,omitempty" jsonschema:"Number of comments on the pull request."` + CreatedAt *string `json:"created_at,omitempty" jsonschema:"Creation time in RFC 3339 format."` + UpdatedAt *string `json:"updated_at,omitempty" jsonschema:"Last update time in RFC 3339 format."` + ClosedAt *string `json:"closed_at,omitempty" jsonschema:"Closing time in RFC 3339 format."` + MergedAt *string `json:"merged_at,omitempty" jsonschema:"Merge time in RFC 3339 format."` + Milestone *string `json:"milestone,omitempty"` +} + +func structuredListPullRequestsOutput(pullRequests []MinimalPullRequest, fields []string) []ListPullRequestOutput { + output := make([]ListPullRequestOutput, 0, len(pullRequests)) + selected := func(field string) bool { + return len(fields) == 0 || slices.Contains(fields, field) + } + for _, pr := range pullRequests { + item := ListPullRequestOutput{} + if selected("number") { + item.Number = new(pr.Number) + } + if selected("title") { + item.Title = new(pr.Title) + } + if selected("body") && pr.Body != "" { + item.Body = new(pr.Body) + } + if selected("state") { + item.State = new(pr.State) + } + if selected("draft") { + item.Draft = new(pr.Draft) + } + if selected("merged") { + item.Merged = new(pr.Merged) + } + if selected("mergeable_state") && pr.MergeableState != "" { + item.MergeableState = new(pr.MergeableState) + } + if selected("html_url") { + item.HTMLURL = new(pr.HTMLURL) + } + if selected("user") && pr.User != nil { + item.User = pr.User + } + if selected("labels") && len(pr.Labels) > 0 { + item.Labels = &pr.Labels + } + if selected("assignees") && len(pr.Assignees) > 0 { + item.Assignees = &pr.Assignees + } + if selected("requested_reviewers") && len(pr.RequestedReviewers) > 0 { + item.RequestedReviewers = &pr.RequestedReviewers + } + if selected("merged_by") && pr.MergedBy != "" { + item.MergedBy = new(pr.MergedBy) + } + if selected("head") && pr.Head != nil { + item.Head = pr.Head + } + if selected("base") && pr.Base != nil { + item.Base = pr.Base + } + if selected("additions") && pr.Additions != 0 { + item.Additions = new(pr.Additions) + } + if selected("deletions") && pr.Deletions != 0 { + item.Deletions = new(pr.Deletions) + } + if selected("changed_files") && pr.ChangedFiles != 0 { + item.ChangedFiles = new(pr.ChangedFiles) + } + if selected("commits") && pr.Commits != 0 { + item.Commits = new(pr.Commits) + } + if selected("comments") && pr.Comments != 0 { + item.Comments = new(pr.Comments) + } + if selected("created_at") && pr.CreatedAt != "" { + item.CreatedAt = new(pr.CreatedAt) + } + if selected("updated_at") && pr.UpdatedAt != "" { + item.UpdatedAt = new(pr.UpdatedAt) + } + if selected("closed_at") && pr.ClosedAt != "" { + item.ClosedAt = new(pr.ClosedAt) + } + if selected("merged_at") && pr.MergedAt != "" { + item.MergedAt = new(pr.MergedAt) + } + if selected("milestone") && pr.Milestone != "" { + item.Milestone = new(pr.Milestone) + } + output = append(output, item) + } + return output +} + func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool { schema := &jsonschema.Schema{ Type: "object", @@ -1369,7 +1498,7 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool ) WithPagination(schema) - return NewTool( + return NewTool[ListPullRequestsInput, []ListPullRequestOutput]( ToolsetMetadataPullRequests, mcp.Tool{ Name: "list_pull_requests", @@ -1381,50 +1510,27 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool InputSchema: schema, }, scopes.PublicRead(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input ListPullRequestsInput) (*mcp.CallToolResult, []ListPullRequestOutput, error) { + if input.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if input.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - state, err := OptionalParam[string](args, "state") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + pagination := PaginationParams{Page: 1, PerPage: 30} + if input.Page != nil { + pagination.Page = *input.Page } - head, err := OptionalParam[string](args, "head") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - base, err := OptionalParam[string](args, "base") - 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 - } - direction, err := OptionalParam[string](args, "direction") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - fields, err := OptionalStringArrayParam(args, "fields") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if input.PerPage != nil { + pagination.PerPage = *input.PerPage } opts := &github.PullRequestListOptions{ - State: state, - Head: head, - Base: base, - Sort: sort, - Direction: direction, + State: input.State, + Head: input.Head, + Base: input.Base, + Sort: input.Sort, + Direction: input.Direction, ListOptions: github.ListOptions{ PerPage: pagination.PerPage, Page: pagination.Page, @@ -1435,7 +1541,7 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } - prs, resp, err := client.PullRequests.List(ctx, owner, repo, opts) + prs, resp, err := client.PullRequests.List(ctx, input.Owner, input.Repo, opts) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list pull requests", @@ -1462,8 +1568,8 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool filtered := false var payload any = minimalPRs - if len(fields) > 0 { - filteredPRs, err := filterEachField(minimalPRs, fields) + if len(input.Fields) > 0 { + filteredPRs, err := filterEachField(minimalPRs, input.Fields) if err != nil { return utils.NewToolResultErrorFromErr("failed to filter pull requests", err), nil, nil } @@ -1481,9 +1587,11 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool result := utils.NewToolResultText(string(r)) // Pull request titles/bodies are user-authored (untrusted); // confidentiality follows repo visibility. - result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoUserContent) - return result, nil, nil - }) + result = attachRepoVisibilityIFCLabel(ctx, deps, client, input.Owner, input.Repo, result, ifc.LabelRepoUserContent) + return result, structuredListPullRequestsOutput(minimalPRs, input.Fields), nil + }, + normalizeTypedReadArguments(nil, false), + ) } // MergePullRequest creates a tool to merge a pull request. @@ -1652,7 +1760,7 @@ func SearchPullRequests(t translations.TranslationHelperFunc) inventory.ServerTo ) WithPagination(schema) - return NewTool( + return NewTool[SearchIssuesInput, SearchIssuesOutput]( ToolsetMetadataPullRequests, mcp.Tool{ Name: "search_pull_requests", @@ -1664,16 +1772,14 @@ func SearchPullRequests(t translations.TranslationHelperFunc) inventory.ServerTo InputSchema: schema, }, scopes.PublicRead(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input SearchIssuesInput) (*mcp.CallToolResult, SearchIssuesOutput, error) { options := []searchOption{ifcSearchPostProcessOption(ctx, deps)} - fields, err := OptionalStringArrayParam(args, "fields") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - options = append(options, withFieldsFiltering(deps, "search_pull_requests", fields)) - result, err := searchHandler(ctx, deps.GetClient, args, "pr", "failed to search pull requests", options...) - return result, nil, err - }) + options = append(options, withFieldsFiltering(deps, "search_pull_requests", input.Fields)) + result, response, err := searchHandler(ctx, deps.GetClient, input, "pr", "failed to search pull requests", options...) + return result, structuredSearchIssuesOutput(response, input.Fields), err + }, + normalizeTypedReadArguments(nil, false), + ) } // UpdatePullRequestBranch creates a tool to update a pull request branch with the latest changes from the base branch. diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 4a005e6b3c..f1c0194efc 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -312,8 +312,15 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { } // ListBranches creates a tool to list branches in a GitHub repository. +type RepositoryListInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` +} + func ListBranches(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[RepositoryListInput, []MinimalBranch]( ToolsetMetadataRepos, mcp.Tool{ Name: "list_branches", @@ -338,18 +345,19 @@ func ListBranches(t translations.TranslationHelperFunc) inventory.ServerTool { }), }, scopes.PublicRead(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input RepositoryListInput) (*mcp.CallToolResult, []MinimalBranch, error) { + if input.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if input.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + pagination := PaginationParams{Page: 1, PerPage: 30} + if input.Page != nil { + pagination.Page = *input.Page + } + if input.PerPage != nil { + pagination.PerPage = *input.PerPage } opts := &github.BranchListOptions{ @@ -364,7 +372,7 @@ func ListBranches(t translations.TranslationHelperFunc) inventory.ServerTool { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } - branches, resp, err := client.Repositories.ListBranches(ctx, owner, repo, opts) + branches, resp, err := client.Repositories.ListBranches(ctx, input.Owner, input.Repo, opts) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list branches", @@ -397,9 +405,10 @@ func ListBranches(t translations.TranslationHelperFunc) inventory.ServerTool { // Branches are structural repo metadata that only collaborators // with push access can create, so integrity is trusted. // Confidentiality follows repo visibility. - result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoMetadata) - return result, nil, nil + result = attachRepoVisibilityIFCLabel(ctx, deps, client, input.Owner, input.Repo, result, ifc.LabelRepoMetadata) + return result, minimalBranches, nil }, + normalizeTypedReadArguments(nil, false), ) } @@ -1852,7 +1861,7 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { // ListTags creates a tool to list tags in a GitHub repository. func ListTags(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[RepositoryListInput, []MinimalTag]( ToolsetMetadataRepos, mcp.Tool{ Name: "list_tags", @@ -1877,18 +1886,19 @@ func ListTags(t translations.TranslationHelperFunc) inventory.ServerTool { }), }, scopes.PublicRead(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input RepositoryListInput) (*mcp.CallToolResult, []MinimalTag, error) { + if input.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if input.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + pagination := PaginationParams{Page: 1, PerPage: 30} + if input.Page != nil { + pagination.Page = *input.Page + } + if input.PerPage != nil { + pagination.PerPage = *input.PerPage } opts := &github.ListOptions{ @@ -1901,7 +1911,7 @@ func ListTags(t translations.TranslationHelperFunc) inventory.ServerTool { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } - tags, resp, err := client.Repositories.ListTags(ctx, owner, repo, opts) + tags, resp, err := client.Repositories.ListTags(ctx, input.Owner, input.Repo, opts) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list tags", @@ -1935,9 +1945,10 @@ func ListTags(t translations.TranslationHelperFunc) inventory.ServerTool { // Tags are structural repo metadata created by collaborators with // push access, so integrity is trusted. Confidentiality follows // repo visibility. - result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoMetadata) - return result, nil, nil + result = attachRepoVisibilityIFCLabel(ctx, deps, client, input.Owner, input.Repo, result, ifc.LabelRepoMetadata) + return result, minimalTags, nil }, + normalizeTypedReadArguments(nil, false), ) } diff --git a/pkg/github/search.go b/pkg/github/search.go index 18c4d466db..80a7e577b5 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "slices" ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/ifc" @@ -192,6 +193,99 @@ func attachSearchRepositoriesIFCLabel(ctx context.Context, deps ToolDependencies } // SearchCode creates a tool to search for code across GitHub repositories. +type SearchCodeInput struct { + Query string `json:"query"` + Sort string `json:"sort,omitempty"` + Order string `json:"order,omitempty"` + Fields []string `json:"fields,omitempty"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` +} + +type SearchCodeOutput struct { + TotalCount int `json:"total_count" jsonschema:"Total number of matching code results."` + IncompleteResults bool `json:"incomplete_results" jsonschema:"Whether GitHub returned an incomplete result set."` + Items []SearchCodeOutputItem `json:"items"` +} + +type SearchCodeOutputItem struct { + Name *string `json:"name,omitempty"` + Path *string `json:"path,omitempty"` + SHA *string `json:"sha,omitempty"` + Repository *string `json:"repository,omitempty"` + TextMatches []*SearchCodeTextMatch `json:"text_matches,omitempty" jsonschema:"Matching code fragments returned by GitHub."` +} + +type SearchCodeTextMatch struct { + ObjectURL *string `json:"object_url,omitempty" jsonschema:"API URL of the matched file or object."` + ObjectType *string `json:"object_type,omitempty" jsonschema:"GitHub object type containing the match."` + Property *string `json:"property,omitempty" jsonschema:"Property containing the matched text."` + Fragment *string `json:"fragment,omitempty" jsonschema:"Source fragment containing the match."` + Matches []*SearchCodeTextMatchHit `json:"matches,omitempty" jsonschema:"Matched text and its offsets in the fragment."` +} + +type SearchCodeTextMatchHit struct { + Text *string `json:"text,omitempty" jsonschema:"Matched text."` + Indices []int `json:"indices,omitempty" jsonschema:"Start and end offsets of the match in the fragment."` +} + +func structuredSearchCodeOutput(result MinimalCodeSearchResult, fields []string) SearchCodeOutput { + output := SearchCodeOutput{ + TotalCount: result.TotalCount, + IncompleteResults: result.IncompleteResults, + Items: make([]SearchCodeOutputItem, 0, len(result.Items)), + } + for _, item := range result.Items { + selected := func(field string) bool { + return len(fields) == 0 || slices.Contains(fields, field) + } + projected := SearchCodeOutputItem{} + if selected("name") { + projected.Name = new(item.Name) + } + if selected("path") { + projected.Path = new(item.Path) + } + if selected("sha") { + projected.SHA = new(item.SHA) + } + if selected("repository") { + projected.Repository = new(item.Repository) + } + if selected("text_matches") && item.TextMatches != nil { + projected.TextMatches = make([]*SearchCodeTextMatch, 0, len(item.TextMatches)) + for _, match := range item.TextMatches { + if match == nil { + projected.TextMatches = append(projected.TextMatches, nil) + continue + } + projectedMatch := &SearchCodeTextMatch{ + ObjectURL: match.ObjectURL, + ObjectType: match.ObjectType, + Property: match.Property, + Fragment: match.Fragment, + } + if match.Matches != nil { + projectedMatch.Matches = make([]*SearchCodeTextMatchHit, 0, len(match.Matches)) + for _, hit := range match.Matches { + if hit == nil { + projectedMatch.Matches = append(projectedMatch.Matches, nil) + continue + } + projectedMatch.Matches = append(projectedMatch.Matches, &SearchCodeTextMatchHit{ + Text: hit.Text, + Indices: hit.Indices, + }) + } + } + projected.TextMatches = append(projected.TextMatches, projectedMatch) + } + } + output.Items = append(output.Items, projected) + } + return output +} + func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { schema := &jsonschema.Schema{ Type: "object", @@ -218,7 +312,7 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { ) WithPagination(schema) - return NewTool( + return NewTool[SearchCodeInput, SearchCodeOutput]( ToolsetMetadataRepos, mcp.Tool{ Name: "search_code", @@ -230,31 +324,23 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { InputSchema: schema, }, scopes.PublicRead(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input SearchCodeInput) (*mcp.CallToolResult, SearchCodeOutput, error) { + var output SearchCodeOutput + if input.Query == "" { + return utils.NewToolResultError("missing required parameter: query"), output, nil } - fields, err := OptionalStringArrayParam(args, "fields") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + pagination := PaginationParams{Page: 1, PerPage: 30} + if input.Page != nil { + pagination.Page = *input.Page } - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if input.PerPage != nil { + pagination.PerPage = *input.PerPage } + query := input.Query opts := &github.SearchOptions{ - Sort: sort, - Order: order, + Sort: input.Sort, + Order: input.Order, TextMatch: true, ListOptions: github.ListOptions{ PerPage: pagination.PerPage, @@ -264,7 +350,7 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { client, err := deps.GetClient(ctx) if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), output, nil } result, resp, err := client.Search.Code(ctx, query, opts) @@ -273,16 +359,16 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { fmt.Sprintf("failed to search code with query '%s'", query), resp, err, - ), nil, nil + ), output, 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 utils.NewToolResultErrorFromErr("failed to read response body", err), output, nil } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to search code", resp, body), nil, nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to search code", resp, body), output, nil } minimalItems := make([]MinimalCodeResult, 0, len(result.CodeResults)) @@ -307,10 +393,10 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { filtered := false var payload any = minimalResult - if len(fields) > 0 { - filteredItems, err := filterEachField(minimalItems, fields) + if len(input.Fields) > 0 { + filteredItems, err := filterEachField(minimalItems, input.Fields) if err != nil { - return utils.NewToolResultErrorFromErr("failed to filter code search results", err), nil, nil + return utils.NewToolResultErrorFromErr("failed to filter code search results", err), output, nil } payload = map[string]any{ "total_count": minimalResult.TotalCount, @@ -322,7 +408,7 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { r, err := json.Marshal(payload) if err != nil { - return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + return utils.NewToolResultErrorFromErr("failed to marshal response", err), output, nil } recordSearchCodeFieldsUsage(ctx, deps, minimalResult, filtered, len(r)) @@ -338,8 +424,10 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { } } callResult = attachJoinedIFCLabel(ctx, deps, callResult, visibilities, ifc.LabelSearchIssues) - return callResult, nil, nil + output = structuredSearchCodeOutput(*minimalResult, input.Fields) + return callResult, output, nil }, + normalizeTypedReadArguments(nil, false), ) } diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go index 47b8aa26bd..5c82f4b60f 100644 --- a/pkg/github/search_utils.go +++ b/pkg/github/search_utils.go @@ -53,6 +53,17 @@ type searchConfig struct { fieldsDeps ToolDependencies } +type SearchIssuesInput struct { + Query string `json:"query"` + Owner string `json:"owner,omitempty"` + Repo string `json:"repo,omitempty"` + Sort string `json:"sort,omitempty"` + Order string `json:"order,omitempty"` + Fields []string `json:"fields,omitempty"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` +} + type searchOption func(*searchConfig) // withSearchPostProcess registers a callback invoked after a successful search @@ -87,46 +98,31 @@ const ( // 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, targetType string, mode searchMode) (string, *github.SearchOptions, error) { - query, err := RequiredParam[string](args, "query") - if err != nil { - return "", nil, err +func prepareSearchArgs(input SearchIssuesInput, targetType string, mode searchMode) (string, *github.SearchOptions, error) { + query := input.Query + if query == "" { + return "", nil, fmt.Errorf("missing required parameter: query") } if !hasSpecificFilter(query, "is", targetType) { query = fmt.Sprintf("is:%s %s", targetType, query) } - owner, err := OptionalParam[string](args, "owner") - if err != nil { - return "", nil, err + if input.Owner != "" && input.Repo != "" && !hasRepoFilter(query) { + query = fmt.Sprintf("repo:%s/%s %s", input.Owner, input.Repo, query) } - repo, err := OptionalParam[string](args, "repo") - if err != nil { - return "", nil, err + pagination := PaginationParams{Page: 1, PerPage: 30} + if input.Page != nil { + pagination.Page = *input.Page } - - if owner != "" && repo != "" && !hasRepoFilter(query) { - query = fmt.Sprintf("repo:%s/%s %s", owner, repo, query) - } - - sort, err := OptionalParam[string](args, "sort") - if err != nil { - return "", nil, err - } - order, err := OptionalParam[string](args, "order") - if err != nil { - return "", nil, err - } - pagination, err := OptionalPaginationParams(args) - if err != nil { - return "", nil, err + if input.PerPage != nil { + pagination.PerPage = *input.PerPage } opts := &github.SearchOptions{ - Sort: sort, - Order: order, + Sort: input.Sort, + Order: input.Order, ListOptions: github.ListOptions{ Page: pagination.Page, PerPage: pagination.PerPage, @@ -172,36 +168,37 @@ func applySemanticSearch(query string, opts *github.SearchOptions) string { func searchHandler( ctx context.Context, getClient GetClientFn, - args map[string]any, + input SearchIssuesInput, targetType string, errorPrefix string, options ...searchOption, -) (*mcp.CallToolResult, error) { +) (*mcp.CallToolResult, SearchIssuesResponse, error) { + var output SearchIssuesResponse cfg := searchConfig{} for _, opt := range options { opt(&cfg) } - query, opts, err := prepareSearchArgs(args, targetType, searchModeLexical) + query, opts, err := prepareSearchArgs(input, targetType, searchModeLexical) if err != nil { - return utils.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), output, nil } client, err := getClient(ctx) if err != nil { - return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub client", err), nil + return utils.NewToolResultErrorFromErr(errorPrefix+": failed to get GitHub client", err), output, nil } result, resp, err := client.Search.Issues(ctx, query, opts) if err != nil { - return utils.NewToolResultErrorFromErr(errorPrefix, err), nil + return utils.NewToolResultErrorFromErr(errorPrefix, err), output, 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 utils.NewToolResultErrorFromErr(errorPrefix+": failed to read response body", err), output, nil } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, errorPrefix, resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, errorPrefix, resp, body), output, nil } // result.Issues are raw *github.Issue objects marshaled directly below rather than through @@ -215,7 +212,7 @@ func searchHandler( if len(cfg.fields) > 0 { filteredItems, err := filterEachField(result.Issues, cfg.fields) if err != nil { - return utils.NewToolResultErrorFromErr(errorPrefix+": failed to filter results", err), nil + return utils.NewToolResultErrorFromErr(errorPrefix+": failed to filter results", err), output, nil } payload = map[string]any{ "total_count": result.Total, @@ -227,7 +224,7 @@ func searchHandler( r, err := json.Marshal(payload) if err != nil { - return utils.NewToolResultErrorFromErr(errorPrefix+": failed to marshal response", err), nil + return utils.NewToolResultErrorFromErr(errorPrefix+": failed to marshal response", err), output, nil } if cfg.fieldsTool != "" { @@ -238,5 +235,13 @@ func searchHandler( if cfg.postProcess != nil { cfg.postProcess(ctx, result, callResult) } - return callResult, nil + output = SearchIssuesResponse{ + Total: result.Total, + IncompleteResults: result.IncompleteResults, + Items: make([]SearchIssueResult, 0, len(result.Issues)), + } + for _, issue := range result.Issues { + output.Items = append(output.Items, SearchIssueResult{Issue: issue}) + } + return callResult, output, nil } diff --git a/pkg/github/typed_read_normalizers.go b/pkg/github/typed_read_normalizers.go new file mode 100644 index 0000000000..6dcd56b658 --- /dev/null +++ b/pkg/github/typed_read_normalizers.go @@ -0,0 +1,82 @@ +package github + +import ( + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + + "github.com/github/github-mcp-server/pkg/inventory" +) + +func normalizeTypedReadArguments(uppercaseFields []string, preserveZeroPage bool) inventory.InputNormalizer { + return func(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]json.RawMessage + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + if args == nil { + return raw, nil + } + + for _, field := range uppercaseFields { + var value string + if err := json.Unmarshal(args[field], &value); err == nil { + normalized, err := json.Marshal(strings.ToUpper(value)) + if err != nil { + return nil, err + } + args[field] = normalized + } + } + + for _, field := range []string{"page", "perPage"} { + value, exists := args[field] + if !exists { + continue + } + if field == "page" && preserveZeroPage { + continue + } + var number any + if err := json.Unmarshal(value, &number); err != nil { + return nil, err + } + if text, ok := number.(string); ok { + parsed, err := strconv.ParseFloat(text, 64) + if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) || parsed != math.Trunc(parsed) || parsed > float64(math.MaxInt) || parsed < float64(math.MinInt) { + return nil, fmt.Errorf("parameter %s is not a valid number", field) + } + number = int(parsed) + } + if field == "page" && !preserveZeroPage { + if parsed, ok := number.(float64); ok && parsed == 0 { + delete(args, field) + continue + } + if parsed, ok := number.(int); ok && parsed == 0 { + delete(args, field) + continue + } + } + if field == "perPage" { + if parsed, ok := number.(float64); ok && parsed == 0 { + delete(args, field) + continue + } + if parsed, ok := number.(int); ok && parsed == 0 { + delete(args, field) + continue + } + } + normalized, err := json.Marshal(number) + if err != nil { + return nil, err + } + args[field] = normalized + } + + return json.Marshal(args) + } +} diff --git a/pkg/github/typed_search_outputs_test.go b/pkg/github/typed_search_outputs_test.go new file mode 100644 index 0000000000..24cfc77fa8 --- /dev/null +++ b/pkg/github/typed_search_outputs_test.go @@ -0,0 +1,385 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/ifc" + "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/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTypedProjectedReadToolOutputSchemas(t *testing.T) { + tools := []inventory.ServerTool{ + SearchCode(translations.NullTranslationHelper), + SearchIssues(translations.NullTranslationHelper, WithHost(utils.HostTypeGHES)), + SearchPullRequests(translations.NullTranslationHelper), + ListIssues(translations.NullTranslationHelper), + ListPullRequests(translations.NullTranslationHelper), + ListBranches(translations.NullTranslationHelper), + ListTags(translations.NullTranslationHelper), + } + for _, tool := range tools { + assert.Equal(t, []string{"repo"}, tool.ScopeAccess.Scopes, "%s must retain repository read scope", tool.Tool.Name) + } + deps := BaseDeps{ + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchCode: mockResponse(t, http.StatusOK, &github.CodeSearchResult{ + Total: new(1), + IncompleteResults: new(false), + CodeResults: []*github.CodeResult{{ + Name: new("main.go"), + Path: new("cmd/main.go"), + SHA: new("abc123"), + Repository: &github.Repository{ + FullName: new("owner/repo"), + Private: new(false), + }, + TextMatches: []*github.TextMatch{{ + ObjectType: new("FileContent"), + Property: new("content"), + Fragment: new("func main()"), + Matches: []*github.Match{{ + Text: new("main"), + Indices: []int{5, 9}, + }}, + }}, + }}, + }), + GetSearchIssues: mockResponse(t, http.StatusOK, &github.IssuesSearchResult{ + Total: new(1), + IncompleteResults: new(false), + Issues: []*github.Issue{{ + ID: new(int64(42)), + URL: new("https://api.github.com/repos/owner/repo/issues/42"), + Number: new(42), + Title: new("A title"), + Body: new("A body"), + State: new("open"), + }}, + }), + GetReposBranchesByOwnerByRepo: mockResponse(t, http.StatusOK, []*github.Branch{}), + GetReposTagsByOwnerByRepo: mockResponse(t, http.StatusOK, []*github.RepositoryTag{ + {Name: new("v1.0.0"), Commit: &github.Commit{SHA: new("abc123")}}, + }), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, &github.Repository{Private: new(false)}), + })), + } + + for _, protocolVersion := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + t.Run(protocolVersion, func(t *testing.T) { + server := mcp.NewServer(&mcp.Implementation{Name: "typed-projected-read-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + for _, tool := range tools { + tool.RegisterFunc(server, deps) + } + + session := connectCommentVisibilityClient(t, server, protocolVersion) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, len(tools)) + outputSchemas := make(map[string]*jsonschema.Resolved, len(list.Tools)) + for _, tool := range list.Tools { + if protocolVersion == "2025-11-25" { + assert.Nil(t, tool.OutputSchema, "legacy clients must not see outputSchema for %s", tool.Name) + continue + } + + require.NotNil(t, tool.OutputSchema, "%s must publish its typed output schema", tool.Name) + require.NoError(t, toolsnaps.Test(tool.Name+"_output", mcp.Tool{ + Name: tool.Name, + OutputSchema: tool.OutputSchema, + })) + schemaJSON, err := json.Marshal(tool.OutputSchema) + require.NoError(t, err) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal(schemaJSON, &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + require.NoError(t, resolved.Validate(projectedReadOutputSample(tool.Name)), "%s output must conform to its schema", tool.Name) + require.NoError(t, resolved.Validate(projectedReadNullOutputSample(tool.Name)), "%s output schema must accept nullable optional fields", tool.Name) + outputSchemas[tool.Name] = resolved + } + + branches, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "list_branches", + Arguments: map[string]any{"owner": "owner", "repo": "repo"}, + }) + require.NoError(t, err) + require.False(t, branches.IsError, branches) + assert.Equal(t, "[]", getTextResult(t, branches).Text) + assert.JSONEq(t, mustMarshalJSON(t, ifc.LabelRepoMetadata(false)), mustMarshalJSON(t, branches.Meta["ifc"])) + if protocolVersion == inventory.ProtocolVersionMultiRoundTrip { + assert.Equal(t, "[]", mustMarshalJSON(t, branches.StructuredContent)) + require.NoError(t, outputSchemas["list_branches"].Validate(branches.StructuredContent)) + } else { + assert.Nil(t, branches.StructuredContent) + } + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "list_tags", + Arguments: map[string]any{"owner": "owner", "repo": "repo"}, + }) + require.NoError(t, err) + require.False(t, result.IsError, result) + require.Equal(t, `[{"name":"v1.0.0","sha":"abc123"}]`, getTextResult(t, result).Text) + assert.JSONEq(t, mustMarshalJSON(t, ifc.LabelRepoMetadata(false)), mustMarshalJSON(t, result.Meta["ifc"])) + if protocolVersion == "2025-11-25" { + assert.Nil(t, result.StructuredContent) + } else { + structured, err := json.Marshal(result.StructuredContent) + require.NoError(t, err) + assert.JSONEq(t, getTextResult(t, result).Text, string(structured)) + require.NoError(t, outputSchemas["list_tags"].Validate(result.StructuredContent)) + } + + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "search_code", + Arguments: map[string]any{ + "query": "main.go", + "fields": []any{"name"}, + }, + }) + require.NoError(t, err) + require.False(t, result.IsError, result) + if protocolVersion == "2025-11-25" { + assert.Equal(t, `{"incomplete_results":false,"items":[{"name":"main.go"}],"total_count":1}`, getTextResult(t, result).Text) + } + assert.JSONEq(t, mustMarshalJSON(t, ifc.LabelSearchIssues([]bool{false})), mustMarshalJSON(t, result.Meta["ifc"])) + if protocolVersion == "2025-11-25" { + assert.Nil(t, result.StructuredContent) + } else { + assert.JSONEq(t, `{"total_count":1,"incomplete_results":false,"items":[{"name":"main.go"}]}`, getTextResult(t, result).Text) + structured, err := json.Marshal(result.StructuredContent) + require.NoError(t, err) + assert.JSONEq(t, getTextResult(t, result).Text, string(structured)) + require.NoError(t, outputSchemas["search_code"].Validate(result.StructuredContent)) + } + + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "search_code", + Arguments: map[string]any{"query": "main.go"}, + }) + require.NoError(t, err) + require.False(t, result.IsError, result) + if protocolVersion == "2025-11-25" { + assert.Equal(t, `{"total_count":1,"incomplete_results":false,"items":[{"name":"main.go","path":"cmd/main.go","sha":"abc123","repository":"owner/repo","text_matches":[{"object_type":"FileContent","property":"content","fragment":"func main()","matches":[{"text":"main","indices":[5,9]}]}]}]}`, getTextResult(t, result).Text) + } + if protocolVersion == inventory.ProtocolVersionMultiRoundTrip { + assert.JSONEq(t, `{"total_count":1,"incomplete_results":false,"items":[{"name":"main.go","path":"cmd/main.go","sha":"abc123","repository":"owner/repo","text_matches":[{"object_type":"FileContent","property":"content","fragment":"func main()","matches":[{"text":"main","indices":[5,9]}]}]}]}`, getTextResult(t, result).Text) + require.NoError(t, outputSchemas["search_code"].Validate(result.StructuredContent)) + assert.JSONEq(t, getTextResult(t, result).Text, mustMarshalJSON(t, result.StructuredContent)) + } + + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "search_issues", + Arguments: map[string]any{"query": "bug"}, + }) + require.NoError(t, err) + require.False(t, result.IsError, result) + if protocolVersion == "2025-11-25" { + assert.Equal(t, `{"total_count":1,"incomplete_results":false,"items":[{"body":"A body","id":42,"number":42,"state":"open","title":"A title","url":"https://api.github.com/repos/owner/repo/issues/42"}]}`, getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + } else { + assert.JSONEq(t, `{"total_count":1,"incomplete_results":false,"items":[{"number":42,"title":"A title","body":"A body","state":"open"}]}`, getTextResult(t, result).Text) + assert.JSONEq(t, getTextResult(t, result).Text, mustMarshalJSON(t, result.StructuredContent)) + require.NoError(t, outputSchemas["search_issues"].Validate(result.StructuredContent)) + } + + failure, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "search_code", + Arguments: map[string]any{"query": ""}, + }) + require.NoError(t, err) + require.True(t, failure.IsError) + assert.Nil(t, failure.StructuredContent, "error results must not expose typed output") + assert.Equal(t, "missing required parameter: query", getErrorResult(t, failure).Text) + }) + } +} + +func projectedReadOutputSample(name string) any { + switch name { + case "search_code": + return map[string]any{ + "total_count": 1, "incomplete_results": false, + "items": []any{map[string]any{ + "name": "main.go", "path": "cmd/main.go", "sha": "abc123", "repository": "owner/repo", + "text_matches": []any{map[string]any{ + "object_url": "https://api.github.com/repos/owner/repo", + "object_type": "FileContent", "property": "content", "fragment": "func main()", + "matches": []any{map[string]any{"text": "main", "indices": []any{5, 9}}}, + }}, + }}, + } + case "search_issues", "search_pull_requests": + return map[string]any{ + "total_count": 1, "incomplete_results": false, + "items": []any{map[string]any{ + "number": 42, "title": "A title", "body": "A body", "state": "open", + "state_reason": "completed", "draft": false, "locked": false, + "html_url": "https://github.com/owner/repo/issues/42", + "user": map[string]any{"login": "octocat", "profile_url": "https://github.com/octocat"}, + "author_association": "OWNER", "labels": []any{"bug"}, + "assignee": "octocat", "assignees": []any{"octocat"}, "milestone": "v1", + "comments": 3, + "reactions": map[string]any{ + "total_count": 1, "+1": 1, "-1": 0, "laugh": 0, "confused": 0, + "heart": 0, "hooray": 0, "rocket": 0, "eyes": 0, + }, + "created_at": "2026-01-02T03:04:05Z", "updated_at": "2026-01-03T03:04:05Z", + "closed_at": "2026-01-04T03:04:05Z", "closed_by": "maintainer", + "type": "Bug", "repository_url": "https://api.github.com/repos/owner/repo", + "pull_request": map[string]any{ + "url": "https://api.github.com/repos/owner/repo/pulls/42", + "html_url": "https://github.com/owner/repo/pull/42", + "diff_url": "https://github.com/owner/repo/pull/42.diff", + "patch_url": "https://github.com/owner/repo/pull/42.patch", + }, + "field_values": []any{map[string]any{"field": "Priority", "value": "P1"}}, + }}, + } + case "list_issues": + return map[string]any{ + "issues": []any{map[string]any{ + "number": 42, "title": "A title", "body": "A body", "state": "OPEN", + "user": map[string]any{"login": "octocat"}, "labels": []any{"bug"}, + "assignees": []any{"octocat"}, "comments": 3, + "created_at": "2026-01-02T03:04:05Z", "updated_at": "2026-01-03T03:04:05Z", + "field_values": []any{map[string]any{"field": "Priority", "value": "P1"}}, + }}, + "totalCount": 1, + "pageInfo": map[string]any{ + "hasNextPage": false, "hasPreviousPage": false, + "startCursor": "start", "endCursor": "end", + }, + } + case "list_pull_requests": + return []any{map[string]any{ + "number": 42, "title": "A title", "body": "A body", "state": "open", + "draft": false, "merged": false, "mergeable_state": "clean", + "html_url": "https://github.com/owner/repo/pull/42", + "user": map[string]any{"login": "octocat"}, + "labels": []any{"bug"}, "assignees": []any{"octocat"}, + "requested_reviewers": []any{"reviewer"}, "merged_by": "maintainer", + "head": map[string]any{"ref": "feature", "sha": "abc123"}, + "base": map[string]any{"ref": "main", "sha": "def456"}, + "additions": 5, "deletions": 2, "changed_files": 1, "commits": 1, + "comments": 3, "created_at": "2026-01-02T03:04:05Z", + "updated_at": "2026-01-03T03:04:05Z", "closed_at": "2026-01-04T03:04:05Z", + "merged_at": "2026-01-04T03:04:05Z", "milestone": "v1", + }} + case "list_branches": + return []any{map[string]any{"name": "main", "sha": "abc123", "protected": true}} + case "list_tags": + return []any{map[string]any{"name": "v1.0.0", "sha": "abc123"}} + default: + panic("unexpected projected read tool: " + name) + } +} + +func projectedReadNullOutputSample(name string) any { + switch name { + case "search_code": + return map[string]any{ + "total_count": 0, "incomplete_results": false, + "items": []any{map[string]any{"name": nil}}, + } + case "search_issues", "search_pull_requests": + return map[string]any{ + "total_count": nil, "incomplete_results": nil, + "items": []any{map[string]any{"title": nil}}, + } + case "list_issues": + return map[string]any{ + "issues": []any{map[string]any{"state": nil}}, "totalCount": 0, + "pageInfo": map[string]any{"hasNextPage": false, "hasPreviousPage": false}, + } + case "list_pull_requests": + return []any{map[string]any{"state": nil}} + case "list_branches", "list_tags": + return []any{} + default: + panic("unexpected projected read tool: " + name) + } +} + +func TestTypedProjectedReadOutputsRespectFieldSelection(t *testing.T) { + code := structuredSearchCodeOutput(MinimalCodeSearchResult{ + TotalCount: 1, + IncompleteResults: false, + Items: []MinimalCodeResult{{Name: "main.go", Path: "cmd/main.go", SHA: "abc123", Repository: "owner/repo"}}, + }, []string{"name"}) + assert.JSONEq(t, `{"total_count":1,"incomplete_results":false,"items":[{"name":"main.go"}]}`, mustMarshalJSON(t, code)) + + searchIssues := structuredSearchIssuesOutput(SearchIssuesResponse{ + Total: new(1), + IncompleteResults: new(false), + Items: []SearchIssueResult{{ + Issue: &github.Issue{Number: new(42), Title: new("A title"), Body: new("A body")}, + FieldValues: []MinimalFieldValue{}, + }}, + }, []string{"title"}) + assert.JSONEq(t, `{"total_count":1,"incomplete_results":false,"items":[{"title":"A title"}]}`, mustMarshalJSON(t, searchIssues)) + + emptyFieldValues := structuredSearchIssuesOutput(SearchIssuesResponse{ + Items: []SearchIssueResult{{Issue: &github.Issue{}, FieldValues: []MinimalFieldValue{}}}, + }, []string{"field_values"}) + assert.Equal(t, `{"items":[{"field_values":[]}]}`, mustMarshalJSON(t, emptyFieldValues)) + + unavailableFieldValues := structuredSearchIssuesOutput(SearchIssuesResponse{ + Items: []SearchIssueResult{{Issue: &github.Issue{}}}, + }, []string{"field_values"}) + assert.Equal(t, `{"items":[{}]}`, mustMarshalJSON(t, unavailableFieldValues)) + + codeMatches := structuredSearchCodeOutput(MinimalCodeSearchResult{ + Items: []MinimalCodeResult{{ + TextMatches: []*github.TextMatch{{ + ObjectURL: new("https://api.github.com/repos/owner/repo"), + ObjectType: new("FileContent"), + Property: new("content"), + Fragment: new("func main()"), + Matches: []*github.Match{{ + Text: new("main"), + Indices: []int{5, 9}, + }}, + }}, + }}, + }, []string{"text_matches"}) + assert.Equal(t, `{"total_count":0,"incomplete_results":false,"items":[{"text_matches":[{"object_url":"https://api.github.com/repos/owner/repo","object_type":"FileContent","property":"content","fragment":"func main()","matches":[{"text":"main","indices":[5,9]}]}]}]}`, mustMarshalJSON(t, codeMatches)) + + issues := structuredListIssuesOutput(MinimalIssuesResponse{ + Issues: []MinimalIssue{{Number: 42, Title: "A title", Body: "A body", State: "OPEN", Assignees: []string{}}}, + TotalCount: 1, + }, []string{"title"}) + assert.JSONEq(t, `{"issues":[{"title":"A title"}],"totalCount":1,"pageInfo":{"hasNextPage":false,"hasPreviousPage":false}}`, mustMarshalJSON(t, issues)) + + pullRequests := structuredListPullRequestsOutput([]MinimalPullRequest{{ + Number: 42, Title: "A title", Body: "A body", State: "open", HTMLURL: "https://example.test/pr/42", + }}, []string{"title"}) + assert.JSONEq(t, `[{"title":"A title"}]`, mustMarshalJSON(t, pullRequests)) +} + +func TestNormalizeTypedReadArgumentsPreservesLegacyValues(t *testing.T) { + normalize := normalizeTypedReadArguments([]string{"state", "orderBy", "direction"}, true) + normalized, err := normalize(json.RawMessage(`{"state":"open","orderBy":"updated_at","direction":"asc","perPage":"25","page":null}`)) + require.NoError(t, err) + assert.JSONEq(t, `{"state":"OPEN","orderBy":"UPDATED_AT","direction":"ASC","perPage":25,"page":null}`, string(normalized)) +} + +func mustMarshalJSON(t *testing.T, value any) string { + t.Helper() + encoded, err := json.Marshal(value) + require.NoError(t, err) + return string(encoded) +} From 70891c0d32fd3318058845dc1573af101475d713 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 7 Oct 2026 00:11:17 +0200 Subject: [PATCH 47/61] feat(security): add typed security alert outputs (#3387) * feat(security): add typed alert outputs Add typed protocol-gated outputs for code quality, code and secret scanning, Dependabot, and security advisories while preserving existing text responses. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(security): preserve compatibility with compact typed outputs Keep advertised input defaults while disabling runtime default injection for security filters. Normalize null CWE filters, compact findings, and verify modern wire outputs alongside exact legacy formatting. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../get_code_quality_finding.snap | 133 ++++- .../get_code_scanning_alert.snap | 165 +++++- .../__toolsnaps__/get_dependabot_alert.snap | 171 +++++- .../get_global_security_advisory.snap | 165 +++++- .../get_secret_scanning_alert.snap | 118 +++- .../list_code_scanning_alerts.snap | 171 +++++- .../__toolsnaps__/list_dependabot_alerts.snap | 209 +++++++- .../list_global_security_advisories.snap | 171 +++++- ...st_org_repository_security_advisories.snap | 159 +++++- .../list_repository_security_advisories.snap | 159 +++++- .../list_secret_scanning_alerts.snap | 124 ++++- pkg/github/code_quality.go | 44 +- pkg/github/code_quality_test.go | 3 +- pkg/github/code_scanning.go | 77 +-- pkg/github/code_scanning_test.go | 5 +- pkg/github/dependabot.go | 92 ++-- pkg/github/dependabot_test.go | 5 +- pkg/github/secret_scanning.go | 72 +-- pkg/github/secret_scanning_test.go | 5 +- pkg/github/security_advisories.go | 327 +++++------- pkg/github/security_advisories_test.go | 9 +- pkg/github/security_outputs.go | 502 ++++++++++++++++++ .../typed_security_compatibility_test.go | 213 ++++++++ pkg/github/typed_security_outputs_test.go | 341 ++++++++++++ 24 files changed, 3052 insertions(+), 388 deletions(-) create mode 100644 pkg/github/security_outputs.go create mode 100644 pkg/github/typed_security_compatibility_test.go create mode 100644 pkg/github/typed_security_outputs_test.go diff --git a/pkg/github/__toolsnaps__/get_code_quality_finding.snap b/pkg/github/__toolsnaps__/get_code_quality_finding.snap index 84ccb948e4..6612c1c8a1 100644 --- a/pkg/github/__toolsnaps__/get_code_quality_finding.snap +++ b/pkg/github/__toolsnaps__/get_code_quality_finding.snap @@ -27,5 +27,136 @@ ], "type": "object" }, - "name": "get_code_quality_finding" + "name": "get_code_quality_finding", + "outputSchema": { + "additionalProperties": false, + "properties": { + "created_at": { + "description": "Finding creation time (RFC 3339).", + "type": [ + "null", + "string" + ] + }, + "location": { + "additionalProperties": false, + "properties": { + "end_column": { + "type": [ + "null", + "integer" + ] + }, + "end_line": { + "type": [ + "null", + "integer" + ] + }, + "path": { + "type": [ + "null", + "string" + ] + }, + "start_column": { + "type": [ + "null", + "integer" + ] + }, + "start_line": { + "type": [ + "null", + "integer" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "message": { + "additionalProperties": false, + "properties": { + "markdown": { + "type": "string" + }, + "text": { + "type": "string" + } + }, + "required": [ + "text", + "markdown" + ], + "type": [ + "null", + "object" + ] + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "rule": { + "additionalProperties": false, + "properties": { + "category": { + "type": [ + "null", + "string" + ] + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "help": { + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "string" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + }, + "title": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "state": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_code_scanning_alert.snap b/pkg/github/__toolsnaps__/get_code_scanning_alert.snap index 907870b03d..cd5a32dd0a 100644 --- a/pkg/github/__toolsnaps__/get_code_scanning_alert.snap +++ b/pkg/github/__toolsnaps__/get_code_scanning_alert.snap @@ -27,5 +27,168 @@ ], "type": "object" }, - "name": "get_code_scanning_alert" + "name": "get_code_scanning_alert", + "outputSchema": { + "additionalProperties": false, + "properties": { + "dismissed_comment": { + "type": [ + "null", + "string" + ] + }, + "dismissed_reason": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "most_recent_instance": { + "additionalProperties": false, + "properties": { + "commit_sha": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "location": { + "additionalProperties": false, + "properties": { + "end_column": { + "type": [ + "null", + "integer" + ] + }, + "end_line": { + "type": [ + "null", + "integer" + ] + }, + "path": { + "type": [ + "null", + "string" + ] + }, + "start_column": { + "type": [ + "null", + "integer" + ] + }, + "start_line": { + "type": [ + "null", + "integer" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "message": { + "type": [ + "null", + "string" + ] + }, + "ref": { + "type": [ + "null", + "string" + ] + }, + "state": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "rule": { + "additionalProperties": false, + "properties": { + "description": { + "type": [ + "null", + "string" + ] + }, + "help": { + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "security_severity_level": { + "description": "Security impact level, distinct from rule diagnostic severity.", + "type": [ + "null", + "string" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "state": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_dependabot_alert.snap b/pkg/github/__toolsnaps__/get_dependabot_alert.snap index 383e86a29e..35f5418eed 100644 --- a/pkg/github/__toolsnaps__/get_dependabot_alert.snap +++ b/pkg/github/__toolsnaps__/get_dependabot_alert.snap @@ -27,5 +27,174 @@ ], "type": "object" }, - "name": "get_dependabot_alert" + "name": "get_dependabot_alert", + "outputSchema": { + "additionalProperties": false, + "properties": { + "dependency": { + "additionalProperties": false, + "properties": { + "manifest_path": { + "type": [ + "null", + "string" + ] + }, + "package": { + "additionalProperties": false, + "properties": { + "ecosystem": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "scope": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "dismissed_comment": { + "type": [ + "null", + "string" + ] + }, + "dismissed_reason": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "security_advisory": { + "additionalProperties": false, + "properties": { + "cve_id": { + "type": [ + "null", + "string" + ] + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "ghsa_id": { + "type": [ + "null", + "string" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + }, + "summary": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "security_vulnerability": { + "additionalProperties": false, + "properties": { + "first_patched_version": { + "description": "Earliest version fixing the affected package.", + "type": [ + "null", + "string" + ] + }, + "package": { + "additionalProperties": false, + "properties": { + "ecosystem": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + }, + "vulnerable_version_range": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "state": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_global_security_advisory.snap b/pkg/github/__toolsnaps__/get_global_security_advisory.snap index 74ec794740..647701421b 100644 --- a/pkg/github/__toolsnaps__/get_global_security_advisory.snap +++ b/pkg/github/__toolsnaps__/get_global_security_advisory.snap @@ -17,5 +17,168 @@ ], "type": "object" }, - "name": "get_global_security_advisory" + "name": "get_global_security_advisory", + "outputSchema": { + "additionalProperties": false, + "properties": { + "cve_id": { + "type": [ + "null", + "string" + ] + }, + "cwe_ids": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "ghsa_id": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "published_at": { + "description": "Advisory publication time (RFC 3339).", + "type": [ + "null", + "string" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + }, + "source_code_location": { + "type": [ + "null", + "string" + ] + }, + "state": { + "type": [ + "null", + "string" + ] + }, + "summary": { + "type": [ + "null", + "string" + ] + }, + "type": { + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "description": "Last advisory update time (RFC 3339).", + "type": [ + "null", + "string" + ] + }, + "vulnerabilities": { + "items": { + "additionalProperties": false, + "properties": { + "first_patched_version": { + "description": "Earliest version fixing the affected package.", + "type": [ + "null", + "string" + ] + }, + "package": { + "additionalProperties": false, + "properties": { + "ecosystem": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "patched_versions": { + "type": [ + "null", + "string" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + }, + "vulnerable_functions": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "vulnerable_version_range": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + }, + "withdrawn_at": { + "description": "Withdrawal time, when withdrawn (RFC 3339).", + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_secret_scanning_alert.snap b/pkg/github/__toolsnaps__/get_secret_scanning_alert.snap index 7d04cc5ca9..435dd76656 100644 --- a/pkg/github/__toolsnaps__/get_secret_scanning_alert.snap +++ b/pkg/github/__toolsnaps__/get_secret_scanning_alert.snap @@ -27,5 +27,121 @@ ], "type": "object" }, - "name": "get_secret_scanning_alert" + "name": "get_secret_scanning_alert", + "outputSchema": { + "additionalProperties": false, + "properties": { + "first_location_detected": { + "additionalProperties": false, + "properties": { + "commit_sha": { + "type": [ + "null", + "string" + ] + }, + "end_column": { + "type": [ + "null", + "integer" + ] + }, + "end_line": { + "type": [ + "null", + "integer" + ] + }, + "path": { + "type": [ + "null", + "string" + ] + }, + "start_column": { + "type": [ + "null", + "integer" + ] + }, + "start_line": { + "type": [ + "null", + "integer" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "push_protection_bypassed": { + "description": "Whether push protection was bypassed for this secret.", + "type": [ + "null", + "boolean" + ] + }, + "resolution": { + "type": [ + "null", + "string" + ] + }, + "resolution_comment": { + "type": [ + "null", + "string" + ] + }, + "secret": { + "type": [ + "null", + "string" + ] + }, + "secret_type": { + "type": [ + "null", + "string" + ] + }, + "secret_type_display_name": { + "type": [ + "null", + "string" + ] + }, + "state": { + "type": [ + "null", + "string" + ] + }, + "validity": { + "description": "Credential validity reported by GitHub: active, inactive, or unknown.", + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap b/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap index df67380a35..e33bcccb12 100644 --- a/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap +++ b/pkg/github/__toolsnaps__/list_code_scanning_alerts.snap @@ -65,5 +65,174 @@ ], "type": "object" }, - "name": "list_code_scanning_alerts" + "name": "list_code_scanning_alerts", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "dismissed_comment": { + "type": [ + "null", + "string" + ] + }, + "dismissed_reason": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "most_recent_instance": { + "additionalProperties": false, + "properties": { + "commit_sha": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "location": { + "additionalProperties": false, + "properties": { + "end_column": { + "type": [ + "null", + "integer" + ] + }, + "end_line": { + "type": [ + "null", + "integer" + ] + }, + "path": { + "type": [ + "null", + "string" + ] + }, + "start_column": { + "type": [ + "null", + "integer" + ] + }, + "start_line": { + "type": [ + "null", + "integer" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "message": { + "type": [ + "null", + "string" + ] + }, + "ref": { + "type": [ + "null", + "string" + ] + }, + "state": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "rule": { + "additionalProperties": false, + "properties": { + "description": { + "type": [ + "null", + "string" + ] + }, + "help": { + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "security_severity_level": { + "description": "Security impact level, distinct from rule diagnostic severity.", + "type": [ + "null", + "string" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "state": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_dependabot_alerts.snap b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap index ff602b13e1..04fb6f95fa 100644 --- a/pkg/github/__toolsnaps__/list_dependabot_alerts.snap +++ b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap @@ -53,5 +53,212 @@ ], "type": "object" }, - "name": "list_dependabot_alerts" + "name": "list_dependabot_alerts", + "outputSchema": { + "additionalProperties": false, + "properties": { + "alerts": { + "items": { + "additionalProperties": false, + "properties": { + "dependency": { + "additionalProperties": false, + "properties": { + "manifest_path": { + "type": [ + "null", + "string" + ] + }, + "package": { + "additionalProperties": false, + "properties": { + "ecosystem": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "scope": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "dismissed_comment": { + "type": [ + "null", + "string" + ] + }, + "dismissed_reason": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "security_advisory": { + "additionalProperties": false, + "properties": { + "cve_id": { + "type": [ + "null", + "string" + ] + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "ghsa_id": { + "type": [ + "null", + "string" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + }, + "summary": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "security_vulnerability": { + "additionalProperties": false, + "properties": { + "first_patched_version": { + "description": "Earliest version fixing the affected package.", + "type": [ + "null", + "string" + ] + }, + "package": { + "additionalProperties": false, + "properties": { + "ecosystem": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + }, + "vulnerable_version_range": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "state": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + }, + "pageInfo": { + "additionalProperties": false, + "properties": { + "hasNextPage": { + "type": "boolean" + }, + "hasPreviousPage": { + "type": "boolean" + }, + "nextCursor": { + "type": "string" + }, + "prevCursor": { + "type": "string" + } + }, + "required": [ + "hasNextPage", + "hasPreviousPage" + ], + "type": "object" + } + }, + "required": [ + "alerts", + "pageInfo" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_global_security_advisories.snap b/pkg/github/__toolsnaps__/list_global_security_advisories.snap index 687248f6e7..e4bc8d8407 100644 --- a/pkg/github/__toolsnaps__/list_global_security_advisories.snap +++ b/pkg/github/__toolsnaps__/list_global_security_advisories.snap @@ -84,5 +84,174 @@ }, "type": "object" }, - "name": "list_global_security_advisories" + "name": "list_global_security_advisories", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "cve_id": { + "type": [ + "null", + "string" + ] + }, + "cwe_ids": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "ghsa_id": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "published_at": { + "description": "Advisory publication time (RFC 3339).", + "type": [ + "null", + "string" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + }, + "source_code_location": { + "type": [ + "null", + "string" + ] + }, + "state": { + "type": [ + "null", + "string" + ] + }, + "summary": { + "type": [ + "null", + "string" + ] + }, + "type": { + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "description": "Last advisory update time (RFC 3339).", + "type": [ + "null", + "string" + ] + }, + "vulnerabilities": { + "items": { + "additionalProperties": false, + "properties": { + "first_patched_version": { + "description": "Earliest version fixing the affected package.", + "type": [ + "null", + "string" + ] + }, + "package": { + "additionalProperties": false, + "properties": { + "ecosystem": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "patched_versions": { + "type": [ + "null", + "string" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + }, + "vulnerable_functions": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "vulnerable_version_range": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + }, + "withdrawn_at": { + "description": "Withdrawal time, when withdrawn (RFC 3339).", + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_org_repository_security_advisories.snap b/pkg/github/__toolsnaps__/list_org_repository_security_advisories.snap index 3646d86c34..499d4cd405 100644 --- a/pkg/github/__toolsnaps__/list_org_repository_security_advisories.snap +++ b/pkg/github/__toolsnaps__/list_org_repository_security_advisories.snap @@ -44,5 +44,162 @@ ], "type": "object" }, - "name": "list_org_repository_security_advisories" + "name": "list_org_repository_security_advisories", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "cve_id": { + "type": [ + "null", + "string" + ] + }, + "cwe_ids": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "ghsa_id": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "published_at": { + "description": "Advisory publication time (RFC 3339).", + "type": [ + "null", + "string" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + }, + "state": { + "type": [ + "null", + "string" + ] + }, + "summary": { + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "description": "Last advisory update time (RFC 3339).", + "type": [ + "null", + "string" + ] + }, + "vulnerabilities": { + "items": { + "additionalProperties": false, + "properties": { + "first_patched_version": { + "description": "Earliest version fixing the affected package.", + "type": [ + "null", + "string" + ] + }, + "package": { + "additionalProperties": false, + "properties": { + "ecosystem": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "patched_versions": { + "type": [ + "null", + "string" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + }, + "vulnerable_functions": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "vulnerable_version_range": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + }, + "withdrawn_at": { + "description": "Withdrawal time, when withdrawn (RFC 3339).", + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_repository_security_advisories.snap b/pkg/github/__toolsnaps__/list_repository_security_advisories.snap index 50ec8db699..15038088e4 100644 --- a/pkg/github/__toolsnaps__/list_repository_security_advisories.snap +++ b/pkg/github/__toolsnaps__/list_repository_security_advisories.snap @@ -49,5 +49,162 @@ ], "type": "object" }, - "name": "list_repository_security_advisories" + "name": "list_repository_security_advisories", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "cve_id": { + "type": [ + "null", + "string" + ] + }, + "cwe_ids": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "ghsa_id": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "published_at": { + "description": "Advisory publication time (RFC 3339).", + "type": [ + "null", + "string" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + }, + "state": { + "type": [ + "null", + "string" + ] + }, + "summary": { + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "description": "Last advisory update time (RFC 3339).", + "type": [ + "null", + "string" + ] + }, + "vulnerabilities": { + "items": { + "additionalProperties": false, + "properties": { + "first_patched_version": { + "description": "Earliest version fixing the affected package.", + "type": [ + "null", + "string" + ] + }, + "package": { + "additionalProperties": false, + "properties": { + "ecosystem": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "patched_versions": { + "type": [ + "null", + "string" + ] + }, + "severity": { + "type": [ + "null", + "string" + ] + }, + "vulnerable_functions": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "vulnerable_version_range": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + }, + "withdrawn_at": { + "description": "Withdrawal time, when withdrawn (RFC 3339).", + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap b/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap index 66aced9eac..b662784681 100644 --- a/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap +++ b/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap @@ -57,5 +57,127 @@ ], "type": "object" }, - "name": "list_secret_scanning_alerts" + "name": "list_secret_scanning_alerts", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "first_location_detected": { + "additionalProperties": false, + "properties": { + "commit_sha": { + "type": [ + "null", + "string" + ] + }, + "end_column": { + "type": [ + "null", + "integer" + ] + }, + "end_line": { + "type": [ + "null", + "integer" + ] + }, + "path": { + "type": [ + "null", + "string" + ] + }, + "start_column": { + "type": [ + "null", + "integer" + ] + }, + "start_line": { + "type": [ + "null", + "integer" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "push_protection_bypassed": { + "description": "Whether push protection was bypassed for this secret.", + "type": [ + "null", + "boolean" + ] + }, + "resolution": { + "type": [ + "null", + "string" + ] + }, + "resolution_comment": { + "type": [ + "null", + "string" + ] + }, + "secret": { + "type": [ + "null", + "string" + ] + }, + "secret_type": { + "type": [ + "null", + "string" + ] + }, + "secret_type_display_name": { + "type": [ + "null", + "string" + ] + }, + "state": { + "type": [ + "null", + "string" + ] + }, + "validity": { + "description": "Credential validity reported by GitHub: active, inactive, or unknown.", + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + } } \ No newline at end of file diff --git a/pkg/github/code_quality.go b/pkg/github/code_quality.go index 78ba24d35d..a3d3e95556 100644 --- a/pkg/github/code_quality.go +++ b/pkg/github/code_quality.go @@ -18,7 +18,7 @@ import ( ) func GetCodeQualityFinding(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[GetCodeQualityFindingInput, *CodeQualityFindingOutput]( ToolsetMetadataCodeQuality, mcp.Tool{ Name: "get_code_quality_finding", @@ -47,18 +47,15 @@ func GetCodeQualityFinding(t translations.TranslationHelperFunc) inventory.Serve }, }, scopes.PublicRead(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GetCodeQualityFindingInput) (*mcp.CallToolResult, *CodeQualityFindingOutput, error) { + if args.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if args.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - findingNumber, err := RequiredInt(args, "findingNumber") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if args.FindingNumber == 0 { + return utils.NewToolResultError("missing required parameter: findingNumber"), nil, nil } client, err := deps.GetClient(ctx) @@ -66,15 +63,14 @@ func GetCodeQualityFinding(t translations.TranslationHelperFunc) inventory.Serve return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } - apiURL := fmt.Sprintf("repos/%s/%s/code-quality/findings/%d", owner, repo, findingNumber) + apiURL := fmt.Sprintf("repos/%s/%s/code-quality/findings/%d", args.Owner, args.Repo, args.FindingNumber) req, err := client.NewRequest(ctx, http.MethodGet, apiURL, nil) if err != nil { return utils.NewToolResultErrorFromErr("failed to create request", err), nil, nil } - finding := make(map[string]any) - - resp, err := client.Do(req, &finding) + var rawFinding json.RawMessage + resp, err := client.Do(req, &rawFinding) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get finding", resp, err), nil, nil } @@ -88,12 +84,26 @@ func GetCodeQualityFinding(t translations.TranslationHelperFunc) inventory.Serve return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get finding", resp, body), nil, nil } - r, err := json.Marshal(finding) + var finding CodeQualityFindingOutput + if err := json.Unmarshal(rawFinding, &finding); err != nil { + return utils.NewToolResultErrorFromErr("failed to decode finding", err), nil, nil + } + r, err := marshalLegacyCodeQualityFinding(rawFinding) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal finding", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &finding, nil }, + normalizeSecurityIntegerArguments("findingNumber"), ) } + +func marshalLegacyCodeQualityFinding(rawFinding json.RawMessage) ([]byte, error) { + // Match main's map-based formatting, including key ordering and number encoding. + var finding map[string]any + if err := json.Unmarshal(rawFinding, &finding); err != nil { + return nil, err + } + return json.Marshal(finding) +} diff --git a/pkg/github/code_quality_test.go b/pkg/github/code_quality_test.go index edd2ce65f3..24a5452a8a 100644 --- a/pkg/github/code_quality_test.go +++ b/pkg/github/code_quality_test.go @@ -11,14 +11,13 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" ) func Test_GetCodeQualityFinding(t *testing.T) { // Verify tool definition once toolDef := GetCodeQualityFinding(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + testSecurityToolSnapshot(t, toolDef.Tool) assert.Equal(t, "get_code_quality_finding", toolDef.Tool.Name) assert.NotEmpty(t, toolDef.Tool.Description) diff --git a/pkg/github/code_scanning.go b/pkg/github/code_scanning.go index f2c4834e65..189ab9eafa 100644 --- a/pkg/github/code_scanning.go +++ b/pkg/github/code_scanning.go @@ -18,7 +18,7 @@ import ( ) func GetCodeScanningAlert(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[GetSecurityAlertInput, *CodeScanningAlertOutput]( ToolsetMetadataCodeSecurity, mcp.Tool{ Name: "get_code_scanning_alert", @@ -47,18 +47,15 @@ func GetCodeScanningAlert(t translations.TranslationHelperFunc) inventory.Server }, }, scopes.RequireAll(scopes.SecurityEvents), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GetSecurityAlertInput) (*mcp.CallToolResult, *CodeScanningAlertOutput, error) { + if args.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if args.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - alertNumber, err := RequiredInt(args, "alertNumber") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if args.AlertNumber == 0 { + return utils.NewToolResultError("missing required parameter: alertNumber"), nil, nil } client, err := deps.GetClient(ctx) @@ -66,7 +63,7 @@ func GetCodeScanningAlert(t translations.TranslationHelperFunc) inventory.Server return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } - alert, resp, err := client.CodeScanning.GetAlert(ctx, owner, repo, int64(alertNumber)) + alert, resp, err := client.CodeScanning.GetAlert(ctx, args.Owner, args.Repo, int64(args.AlertNumber)) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get alert", @@ -94,8 +91,9 @@ func GetCodeScanningAlert(t translations.TranslationHelperFunc) inventory.Server // visibility and embed attacker-influenceable code snippets, so the // label is always private-untrusted. result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) - return result, nil, nil + return result, codeScanningAlertOutput(alert), nil }, + normalizeSecurityIntegerArguments("alertNumber"), ) } @@ -135,7 +133,7 @@ func ListCodeScanningAlerts(t translations.TranslationHelperFunc) inventory.Serv } WithPagination(schema) - return NewTool( + return NewToolWithSchemaOptions[ListCodeScanningAlertsInput, []*CodeScanningAlertOutput]( ToolsetMetadataCodeSecurity, mcp.Tool{ Name: "list_code_scanning_alerts", @@ -147,46 +145,27 @@ func ListCodeScanningAlerts(t translations.TranslationHelperFunc) inventory.Serv InputSchema: schema, }, scopes.RequireAll(scopes.SecurityEvents), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - ref, err := OptionalParam[string](args, "ref") - 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 - } - severity, err := OptionalParam[string](args, "severity") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - toolName, err := OptionalParam[string](args, "tool_name") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + inventory.TypedSchemaOptions{ + ValidationInputSchema: inventory.CloneSchemaWithoutDefaults(schema), + }, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args ListCodeScanningAlertsInput) (*mcp.CallToolResult, []*CodeScanningAlertOutput, error) { + if args.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if args.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } + pagination := securityPagination(args.Page, args.PerPage) 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, args.Owner, args.Repo, &github.AlertListOptions{ + Ref: args.Ref, + State: args.State, + Severity: args.Severity, + ToolName: args.ToolName, ListOptions: github.ListOptions{ Page: pagination.Page, PerPage: pagination.PerPage, @@ -219,7 +198,9 @@ func ListCodeScanningAlerts(t translations.TranslationHelperFunc) inventory.Serv // visibility and embed attacker-influenceable code snippets, so the // label is always private-untrusted. result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) - return result, nil, nil + return result, mapSecurityOutputs(alerts, codeScanningAlertOutput), nil }, + normalizeSecurityIntegerArguments("page", "perPage"), + normalizeTypedReadArguments(nil, false), ) } diff --git a/pkg/github/code_scanning_test.go b/pkg/github/code_scanning_test.go index 4811b66f5a..9d65535308 100644 --- a/pkg/github/code_scanning_test.go +++ b/pkg/github/code_scanning_test.go @@ -6,7 +6,6 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" @@ -17,7 +16,7 @@ import ( func Test_GetCodeScanningAlert(t *testing.T) { // Verify tool definition once toolDef := GetCodeScanningAlert(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + testSecurityToolSnapshot(t, toolDef.Tool) assert.Equal(t, "get_code_scanning_alert", toolDef.Tool.Name) assert.NotEmpty(t, toolDef.Tool.Description) @@ -123,7 +122,7 @@ func Test_GetCodeScanningAlert(t *testing.T) { func Test_ListCodeScanningAlerts(t *testing.T) { // Verify tool definition once toolDef := ListCodeScanningAlerts(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + testSecurityToolSnapshot(t, toolDef.Tool) assert.Equal(t, "list_code_scanning_alerts", toolDef.Tool.Name) assert.NotEmpty(t, toolDef.Tool.Description) diff --git a/pkg/github/dependabot.go b/pkg/github/dependabot.go index 4ba7d6c4c3..83f3e624ea 100644 --- a/pkg/github/dependabot.go +++ b/pkg/github/dependabot.go @@ -19,7 +19,7 @@ import ( ) func GetDependabotAlert(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[GetSecurityAlertInput, *DependabotAlertOutput]( ToolsetMetadataDependabot, mcp.Tool{ Name: "get_dependabot_alert", @@ -48,18 +48,15 @@ func GetDependabotAlert(t translations.TranslationHelperFunc) inventory.ServerTo }, }, scopes.RequireAll(scopes.SecurityEvents), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GetSecurityAlertInput) (*mcp.CallToolResult, *DependabotAlertOutput, error) { + if args.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if args.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - alertNumber, err := RequiredInt(args, "alertNumber") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if args.AlertNumber == 0 { + return utils.NewToolResultError("missing required parameter: alertNumber"), nil, nil } client, err := deps.GetClient(ctx) @@ -67,10 +64,10 @@ func GetDependabotAlert(t translations.TranslationHelperFunc) inventory.ServerTo return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, err } - alert, resp, err := client.Dependabot.GetRepoAlert(ctx, owner, repo, alertNumber) + alert, resp, err := client.Dependabot.GetRepoAlert(ctx, args.Owner, args.Repo, args.AlertNumber) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - dependabotErrMsg(fmt.Sprintf("failed to get alert with number '%d'", alertNumber), owner, repo, resp), + dependabotErrMsg(fmt.Sprintf("failed to get alert with number '%d'", args.AlertNumber), args.Owner, args.Repo, resp), resp, err, ), nil, nil @@ -95,8 +92,9 @@ func GetDependabotAlert(t translations.TranslationHelperFunc) inventory.ServerTo // visibility and embed attacker-influenceable advisory text, so the // label is always private-untrusted. result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) - return result, nil, nil + return result, dependabotAlertOutput(alert), nil }, + normalizeSecurityIntegerArguments("alertNumber"), ) } @@ -128,7 +126,7 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server } WithCursorPagination(schema) - return NewTool( + return NewToolWithSchemaOptions[ListDependabotAlertsInput, DependabotAlertsOutput]( ToolsetMetadataDependabot, mcp.Tool{ Name: "list_dependabot_alerts", @@ -140,37 +138,26 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server InputSchema: schema, }, scopes.RequireAll(scopes.SecurityEvents), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - 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 - } - severity, err := OptionalParam[string](args, "severity") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + inventory.TypedSchemaOptions{ + ValidationInputSchema: inventory.CloneSchemaWithoutDefaults(schema), + }, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args ListDependabotAlertsInput) (*mcp.CallToolResult, DependabotAlertsOutput, error) { + if args.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), DependabotAlertsOutput{}, nil } - - pagination, err := OptionalCursorPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if args.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), DependabotAlertsOutput{}, nil } + pagination := securityCursorPagination(args.PerPage, args.After) client, err := deps.GetClient(ctx) if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, err + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), DependabotAlertsOutput{}, err } - alerts, resp, err := client.Dependabot.ListRepoAlerts(ctx, owner, repo, &github.ListAlertsOptions{ - State: ToStringPtr(state), - Severity: ToStringPtr(severity), + alerts, resp, err := client.Dependabot.ListRepoAlerts(ctx, args.Owner, args.Repo, &github.ListAlertsOptions{ + State: ToStringPtr(args.State), + Severity: ToStringPtr(args.Severity), ListCursorOptions: github.ListCursorOptions{ PerPage: pagination.PerPage, After: pagination.After, @@ -178,29 +165,37 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server }) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - dependabotErrMsg(fmt.Sprintf("failed to list alerts for repository '%s/%s'", owner, repo), owner, repo, resp), + dependabotErrMsg(fmt.Sprintf("failed to list alerts for repository '%s/%s'", args.Owner, args.Repo), args.Owner, args.Repo, resp), resp, err, - ), nil, nil + ), DependabotAlertsOutput{}, 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, err + return utils.NewToolResultErrorFromErr("failed to read response body", err), DependabotAlertsOutput{}, err } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list alerts", resp, body), nil, nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list alerts", resp, body), DependabotAlertsOutput{}, nil } - response := map[string]any{ - "alerts": alerts, - "pageInfo": buildPageInfo(resp), + apiPageInfo := buildPageInfo(resp) + output := DependabotAlertsOutput{ + Alerts: mapSecurityOutputs(alerts, dependabotAlertOutput), + PageInfo: SecurityPageInfo(apiPageInfo), } + response := struct { + Alerts []*github.DependabotAlert `json:"alerts"` + PageInfo pageInfo `json:"pageInfo"` + }{ + Alerts: alerts, + PageInfo: apiPageInfo, + } r, err := json.Marshal(response) if err != nil { - return utils.NewToolResultErrorFromErr("failed to marshal alerts", err), nil, err + return utils.NewToolResultErrorFromErr("failed to marshal alerts", err), DependabotAlertsOutput{}, err } result := utils.NewToolResultText(string(r)) @@ -208,8 +203,9 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server // visibility and embed attacker-influenceable advisory text, so the // label is always private-untrusted. result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) - return result, nil, nil + return result, output, nil }, + normalizeTypedReadArguments(nil, false), ) } diff --git a/pkg/github/dependabot_test.go b/pkg/github/dependabot_test.go index a699980778..1656f4942c 100644 --- a/pkg/github/dependabot_test.go +++ b/pkg/github/dependabot_test.go @@ -6,7 +6,6 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v92/github" "github.com/stretchr/testify/assert" @@ -17,7 +16,7 @@ func Test_GetDependabotAlert(t *testing.T) { // Verify tool definition toolDef := GetDependabotAlert(translations.NullTranslationHelper) tool := toolDef.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + testSecurityToolSnapshot(t, tool) // Validate tool schema assert.Equal(t, "get_dependabot_alert", tool.Name) @@ -129,7 +128,7 @@ func Test_ListDependabotAlerts(t *testing.T) { // Verify tool definition once toolDef := ListDependabotAlerts(translations.NullTranslationHelper) tool := toolDef.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + testSecurityToolSnapshot(t, tool) assert.Equal(t, "list_dependabot_alerts", tool.Name) assert.NotEmpty(t, tool.Description) diff --git a/pkg/github/secret_scanning.go b/pkg/github/secret_scanning.go index 8f6659f047..7293c3be2b 100644 --- a/pkg/github/secret_scanning.go +++ b/pkg/github/secret_scanning.go @@ -19,7 +19,7 @@ import ( ) func GetSecretScanningAlert(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[GetSecurityAlertInput, *SecretScanningAlertOutput]( ToolsetMetadataSecretProtection, mcp.Tool{ Name: "get_secret_scanning_alert", @@ -48,18 +48,15 @@ func GetSecretScanningAlert(t translations.TranslationHelperFunc) inventory.Serv }, }, scopes.RequireAll(scopes.SecurityEvents), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GetSecurityAlertInput) (*mcp.CallToolResult, *SecretScanningAlertOutput, error) { + if args.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if args.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - alertNumber, err := RequiredInt(args, "alertNumber") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if args.AlertNumber == 0 { + return utils.NewToolResultError("missing required parameter: alertNumber"), nil, nil } client, err := deps.GetClient(ctx) @@ -67,10 +64,10 @@ func GetSecretScanningAlert(t translations.TranslationHelperFunc) inventory.Serv return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } - alert, resp, err := client.SecretScanning.GetAlert(ctx, owner, repo, int64(alertNumber)) + alert, resp, err := client.SecretScanning.GetAlert(ctx, args.Owner, args.Repo, int64(args.AlertNumber)) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to get alert with number '%d'", alertNumber), + fmt.Sprintf("failed to get alert with number '%d'", args.AlertNumber), resp, err, ), nil, nil @@ -95,8 +92,9 @@ func GetSecretScanningAlert(t translations.TranslationHelperFunc) inventory.Serv // visibility and surface the matched secret material itself, so the // label is always private-untrusted. result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) - return result, nil, nil + return result, secretScanningAlertOutput(alert), nil }, + normalizeSecurityIntegerArguments("alertNumber"), ) } @@ -131,7 +129,7 @@ func ListSecretScanningAlerts(t translations.TranslationHelperFunc) inventory.Se } WithPagination(schema) - return NewTool( + return NewTool[ListSecretScanningAlertsInput, []*SecretScanningAlertOutput]( ToolsetMetadataSecretProtection, mcp.Tool{ Name: "list_secret_scanning_alerts", @@ -143,41 +141,23 @@ func ListSecretScanningAlerts(t translations.TranslationHelperFunc) inventory.Se InputSchema: schema, }, scopes.RequireAll(scopes.SecurityEvents), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - 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 - } - secretType, err := OptionalParam[string](args, "secret_type") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args ListSecretScanningAlertsInput) (*mcp.CallToolResult, []*SecretScanningAlertOutput, error) { + if args.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - resolution, err := OptionalParam[string](args, "resolution") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if args.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } + pagination := securityPagination(args.Page, args.PerPage) 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, args.Owner, args.Repo, &github.SecretScanningAlertListOptions{ + State: args.State, + SecretType: args.SecretType, + Resolution: args.Resolution, ListOptions: github.ListOptions{ Page: pagination.Page, PerPage: pagination.PerPage, @@ -185,7 +165,7 @@ func ListSecretScanningAlerts(t translations.TranslationHelperFunc) inventory.Se }) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to list alerts for repository '%s/%s'", owner, repo), + fmt.Sprintf("failed to list alerts for repository '%s/%s'", args.Owner, args.Repo), resp, err, ), nil, nil @@ -210,7 +190,9 @@ func ListSecretScanningAlerts(t translations.TranslationHelperFunc) inventory.Se // visibility and surface the matched secret material itself, so the // label is always private-untrusted. result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) - return result, nil, nil + return result, mapSecurityOutputs(alerts, secretScanningAlertOutput), nil }, + normalizeSecurityIntegerArguments("page", "perPage"), + normalizeTypedReadArguments(nil, false), ) } diff --git a/pkg/github/secret_scanning_test.go b/pkg/github/secret_scanning_test.go index 6018962d6e..26dcfa999c 100644 --- a/pkg/github/secret_scanning_test.go +++ b/pkg/github/secret_scanning_test.go @@ -6,7 +6,6 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" @@ -17,7 +16,7 @@ import ( func Test_GetSecretScanningAlert(t *testing.T) { toolDef := GetSecretScanningAlert(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + testSecurityToolSnapshot(t, toolDef.Tool) assert.Equal(t, "get_secret_scanning_alert", toolDef.Tool.Name) assert.NotEmpty(t, toolDef.Tool.Description) @@ -122,7 +121,7 @@ func Test_ListSecretScanningAlerts(t *testing.T) { // Verify tool definition once toolDef := ListSecretScanningAlerts(translations.NullTranslationHelper) - require.NoError(t, toolsnaps.Test(toolDef.Tool.Name, toolDef.Tool)) + testSecurityToolSnapshot(t, toolDef.Tool) assert.Equal(t, "list_secret_scanning_alerts", toolDef.Tool.Name) assert.NotEmpty(t, toolDef.Tool.Description) diff --git a/pkg/github/security_advisories.go b/pkg/github/security_advisories.go index 633cd2a8a4..f0cc044ef0 100644 --- a/pkg/github/security_advisories.go +++ b/pkg/github/security_advisories.go @@ -19,170 +19,119 @@ import ( ) func ListGlobalSecurityAdvisories(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( - ToolsetMetadataSecurityAdvisories, - mcp.Tool{ - Name: "list_global_security_advisories", - Description: t("TOOL_LIST_GLOBAL_SECURITY_ADVISORIES_DESCRIPTION", "List global security advisories from GitHub."), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LIST_GLOBAL_SECURITY_ADVISORIES_USER_TITLE", "List global security advisories"), - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "ghsaId": { - Type: "string", - Description: "Filter by GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx).", - }, - "type": { - Type: "string", - Description: "Advisory type.", - Enum: []any{"reviewed", "malware", "unreviewed"}, - Default: json.RawMessage(`"reviewed"`), - }, - "cveId": { - Type: "string", - Description: "Filter by CVE ID.", - }, - "ecosystem": { - Type: "string", - Description: "Filter by package ecosystem.", - Enum: []any{"actions", "composer", "erlang", "go", "maven", "npm", "nuget", "other", "pip", "pub", "rubygems", "rust"}, - }, - "severity": { - Type: "string", - Description: "Filter by severity.", - Enum: []any{"unknown", "low", "medium", "high", "critical"}, - }, - "cwes": { - Type: "array", - Description: "Filter by Common Weakness Enumeration IDs (e.g. [\"79\", \"284\", \"22\"]).", - Items: &jsonschema.Schema{ - Type: "string", - }, - }, - "isWithdrawn": { - Type: "boolean", - Description: "Whether to only return withdrawn advisories.", - }, - "affects": { - Type: "string", - Description: "Filter advisories by affected package or version (e.g. \"package1,package2@1.0.0\").", - }, - "published": { - Type: "string", - Description: "Filter by publish date or date range (ISO 8601 date or range).", - }, - "updated": { - Type: "string", - Description: "Filter by update date or date range (ISO 8601 date or range).", - }, - "modified": { - Type: "string", - Description: "Filter by publish or update date or date range (ISO 8601 date or range).", + tool := mcp.Tool{ + Name: "list_global_security_advisories", + Description: t("TOOL_LIST_GLOBAL_SECURITY_ADVISORIES_DESCRIPTION", "List global security advisories from GitHub."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_LIST_GLOBAL_SECURITY_ADVISORIES_USER_TITLE", "List global security advisories"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "ghsaId": { + Type: "string", + Description: "Filter by GitHub Security Advisory ID (format: GHSA-xxxx-xxxx-xxxx).", + }, + "type": { + Type: "string", + Description: "Advisory type.", + Enum: []any{"reviewed", "malware", "unreviewed"}, + Default: json.RawMessage(`"reviewed"`), + }, + "cveId": { + Type: "string", + Description: "Filter by CVE ID.", + }, + "ecosystem": { + Type: "string", + Description: "Filter by package ecosystem.", + Enum: []any{"actions", "composer", "erlang", "go", "maven", "npm", "nuget", "other", "pip", "pub", "rubygems", "rust"}, + }, + "severity": { + Type: "string", + Description: "Filter by severity.", + Enum: []any{"unknown", "low", "medium", "high", "critical"}, + }, + "cwes": { + Type: "array", + Description: "Filter by Common Weakness Enumeration IDs (e.g. [\"79\", \"284\", \"22\"]).", + Items: &jsonschema.Schema{ + Type: "string", }, }, + "isWithdrawn": { + Type: "boolean", + Description: "Whether to only return withdrawn advisories.", + }, + "affects": { + Type: "string", + Description: "Filter advisories by affected package or version (e.g. \"package1,package2@1.0.0\").", + }, + "published": { + Type: "string", + Description: "Filter by publish date or date range (ISO 8601 date or range).", + }, + "updated": { + Type: "string", + Description: "Filter by update date or date range (ISO 8601 date or range).", + }, + "modified": { + Type: "string", + Description: "Filter by publish or update date or date range (ISO 8601 date or range).", + }, }, }, + } + return NewToolWithSchemaOptions[ListGlobalSecurityAdvisoriesInput, []*GlobalSecurityAdvisoryOutput]( + ToolsetMetadataSecurityAdvisories, + tool, scopes.RequireAll(scopes.SecurityEvents), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + inventory.TypedSchemaOptions{ + ValidationInputSchema: inventory.CloneSchemaWithoutDefaults(tool.InputSchema.(*jsonschema.Schema)), + }, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args ListGlobalSecurityAdvisoriesInput) (*mcp.CallToolResult, []*GlobalSecurityAdvisoryOutput, error) { client, err := deps.GetClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } - ghsaID, err := OptionalParam[string](args, "ghsaId") - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid ghsaId: %v", err)), nil, nil - } - - typ, err := OptionalParam[string](args, "type") - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid type: %v", err)), nil, nil - } - - cveID, err := OptionalParam[string](args, "cveId") - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid cveId: %v", err)), nil, nil - } - - eco, err := OptionalParam[string](args, "ecosystem") - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid ecosystem: %v", err)), nil, nil - } - - sev, err := OptionalParam[string](args, "severity") - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid severity: %v", err)), nil, nil - } - - cwes, err := OptionalStringArrayParam(args, "cwes") - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid cwes: %v", err)), nil, nil - } - - isWithdrawn, err := OptionalParam[bool](args, "isWithdrawn") - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid isWithdrawn: %v", err)), nil, nil - } - - affects, err := OptionalParam[string](args, "affects") - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid affects: %v", err)), nil, nil - } - - published, err := OptionalParam[string](args, "published") - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid published: %v", err)), nil, nil - } - - updated, err := OptionalParam[string](args, "updated") - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid updated: %v", err)), nil, nil - } - - modified, err := OptionalParam[string](args, "modified") - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid modified: %v", err)), nil, nil - } - opts := &github.ListGlobalSecurityAdvisoriesOptions{} - if ghsaID != "" { - opts.GHSAID = &ghsaID + if args.GHSAID != "" { + opts.GHSAID = &args.GHSAID } - if typ != "" { - opts.Type = &typ + if args.Type != "" { + opts.Type = &args.Type } - if cveID != "" { - opts.CVEID = &cveID + if args.CVEID != "" { + opts.CVEID = &args.CVEID } - if eco != "" { - opts.Ecosystem = &eco + if args.Ecosystem != "" { + opts.Ecosystem = &args.Ecosystem } - if sev != "" { - opts.Severity = &sev + if args.Severity != "" { + opts.Severity = &args.Severity } - if len(cwes) > 0 { - opts.CWEs = cwes + if len(args.CWEs) > 0 { + opts.CWEs = args.CWEs } - if isWithdrawn { - opts.IsWithdrawn = &isWithdrawn + if args.IsWithdrawn { + opts.IsWithdrawn = &args.IsWithdrawn } - if affects != "" { - opts.Affects = &affects + if args.Affects != "" { + opts.Affects = &args.Affects } - if published != "" { - opts.Published = &published + if args.Published != "" { + opts.Published = &args.Published } - if updated != "" { - opts.Updated = &updated + if args.Updated != "" { + opts.Updated = &args.Updated } - if modified != "" { - opts.Modified = &modified + if args.Modified != "" { + opts.Modified = &args.Modified } advisories, resp, err := client.SecurityAdvisories.ListGlobalSecurityAdvisories(ctx, opts) @@ -209,13 +158,14 @@ func ListGlobalSecurityAdvisories(t translations.TranslationHelperFunc) inventor // Database (public) but contain externally authored prose // (untrusted). result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGlobalSecurityAdvisory()) - return result, nil, nil + return result, mapSecurityOutputs(advisories, globalSecurityAdvisoryOutput), nil }, + normalizeGlobalAdvisoryArguments, ) } func ListRepositorySecurityAdvisories(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[ListRepositorySecurityAdvisoriesInput, []*SecurityAdvisoryOutput]( ToolsetMetadataSecurityAdvisories, mcp.Tool{ Name: "list_repository_security_advisories", @@ -255,27 +205,12 @@ func ListRepositorySecurityAdvisories(t translations.TranslationHelperFunc) inve }, }, scopes.RequireAll(scopes.SecurityEvents), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args ListRepositorySecurityAdvisoriesInput) (*mcp.CallToolResult, []*SecurityAdvisoryOutput, error) { + if args.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - 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 - } - sortField, err := OptionalParam[string](args, "sort") - 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 + if args.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } client, err := deps.GetClient(ctx) @@ -284,17 +219,17 @@ func ListRepositorySecurityAdvisories(t translations.TranslationHelperFunc) inve } opts := &github.ListRepositorySecurityAdvisoriesOptions{} - if direction != "" { - opts.Direction = direction + if args.Direction != "" { + opts.Direction = args.Direction } - if sortField != "" { - opts.Sort = sortField + if args.Sort != "" { + opts.Sort = args.Sort } - if state != "" { - opts.State = state + if args.State != "" { + opts.State = args.State } - advisories, resp, err := client.SecurityAdvisories.ListRepositorySecurityAdvisories(ctx, owner, repo, opts) + advisories, resp, err := client.SecurityAdvisories.ListRepositorySecurityAdvisories(ctx, args.Owner, args.Repo, opts) if err != nil { return nil, nil, fmt.Errorf("failed to list repository security advisories: %w", err) } @@ -319,17 +254,17 @@ func ListRepositorySecurityAdvisories(t translations.TranslationHelperFunc) inve // advisories are not world-readable even on a public repo, so the // result is only public when every returned advisory is published. allPublished := allAdvisoriesPublished(advisories) - result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, + result = attachRepoVisibilityIFCLabel(ctx, deps, client, args.Owner, args.Repo, result, func(isPrivate bool) ifc.SecurityLabel { return ifc.LabelRepositorySecurityAdvisory(isPrivate, allPublished) }) - return result, nil, nil + return result, mapSecurityOutputs(advisories, securityAdvisoryOutput), nil }, ) } func GetGlobalSecurityAdvisory(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[GetGlobalSecurityAdvisoryInput, *GlobalSecurityAdvisoryOutput]( ToolsetMetadataSecurityAdvisories, mcp.Tool{ Name: "get_global_security_advisory", @@ -350,18 +285,17 @@ func GetGlobalSecurityAdvisory(t translations.TranslationHelperFunc) inventory.S }, }, scopes.RequireAll(scopes.SecurityEvents), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GetGlobalSecurityAdvisoryInput) (*mcp.CallToolResult, *GlobalSecurityAdvisoryOutput, error) { client, err := deps.GetClient(ctx) if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } - ghsaID, err := RequiredParam[string](args, "ghsaId") - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid ghsaId: %v", err)), nil, nil + if args.GHSAID == "" { + return utils.NewToolResultError("invalid ghsaId: missing required parameter: ghsaId"), nil, nil } - advisory, resp, err := client.SecurityAdvisories.GetGlobalSecurityAdvisories(ctx, ghsaID) + advisory, resp, err := client.SecurityAdvisories.GetGlobalSecurityAdvisories(ctx, args.GHSAID) if err != nil { return nil, nil, fmt.Errorf("failed to get advisory: %w", err) } @@ -384,13 +318,13 @@ func GetGlobalSecurityAdvisory(t translations.TranslationHelperFunc) inventory.S // A global advisory is world-readable (public) but externally // authored (untrusted). result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGlobalSecurityAdvisory()) - return result, nil, nil + return result, globalSecurityAdvisoryOutput(advisory), nil }, ) } func ListOrgRepositorySecurityAdvisories(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[ListOrgRepositorySecurityAdvisoriesInput, []*SecurityAdvisoryOutput]( ToolsetMetadataSecurityAdvisories, mcp.Tool{ Name: "list_org_repository_security_advisories", @@ -426,22 +360,9 @@ func ListOrgRepositorySecurityAdvisories(t translations.TranslationHelperFunc) i }, }, scopes.RequireAll(scopes.SecurityEvents), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - org, err := RequiredParam[string](args, "org") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - direction, err := OptionalParam[string](args, "direction") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - sortField, err := OptionalParam[string](args, "sort") - 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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args ListOrgRepositorySecurityAdvisoriesInput) (*mcp.CallToolResult, []*SecurityAdvisoryOutput, error) { + if args.Org == "" { + return utils.NewToolResultError("missing required parameter: org"), nil, nil } client, err := deps.GetClient(ctx) @@ -450,17 +371,17 @@ func ListOrgRepositorySecurityAdvisories(t translations.TranslationHelperFunc) i } opts := &github.ListRepositorySecurityAdvisoriesOptions{} - if direction != "" { - opts.Direction = direction + if args.Direction != "" { + opts.Direction = args.Direction } - if sortField != "" { - opts.Sort = sortField + if args.Sort != "" { + opts.Sort = args.Sort } - if state != "" { - opts.State = state + if args.State != "" { + opts.State = args.State } - advisories, resp, err := client.SecurityAdvisories.ListRepositorySecurityAdvisoriesForOrg(ctx, org, opts) + advisories, resp, err := client.SecurityAdvisories.ListRepositorySecurityAdvisoriesForOrg(ctx, args.Org, opts) if err != nil { return nil, nil, fmt.Errorf("failed to list organization repository security advisories: %w", err) } @@ -485,7 +406,7 @@ func ListOrgRepositorySecurityAdvisories(t translations.TranslationHelperFunc) i // they are conservatively labeled private-untrusted (isPrivate=true, // which forces private regardless of publication state). result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelRepositorySecurityAdvisory(true, false)) - return result, nil, nil + return result, mapSecurityOutputs(advisories, securityAdvisoryOutput), nil }, ) } diff --git a/pkg/github/security_advisories_test.go b/pkg/github/security_advisories_test.go index 27dcf7ec0a..e6ec8aa496 100644 --- a/pkg/github/security_advisories_test.go +++ b/pkg/github/security_advisories_test.go @@ -6,7 +6,6 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" @@ -18,7 +17,7 @@ import ( func Test_ListGlobalSecurityAdvisories(t *testing.T) { toolDef := ListGlobalSecurityAdvisories(translations.NullTranslationHelper) tool := toolDef.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + testSecurityToolSnapshot(t, tool) assert.Equal(t, "list_global_security_advisories", tool.Name) assert.NotEmpty(t, tool.Description) @@ -133,7 +132,7 @@ func Test_ListGlobalSecurityAdvisories(t *testing.T) { func Test_GetGlobalSecurityAdvisory(t *testing.T) { toolDef := GetGlobalSecurityAdvisory(translations.NullTranslationHelper) tool := toolDef.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + testSecurityToolSnapshot(t, tool) assert.Equal(t, "get_global_security_advisory", tool.Name) assert.NotEmpty(t, tool.Description) @@ -240,7 +239,7 @@ func Test_ListRepositorySecurityAdvisories(t *testing.T) { // Verify tool definition once toolDef := ListRepositorySecurityAdvisories(translations.NullTranslationHelper) tool := toolDef.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + testSecurityToolSnapshot(t, tool) assert.Equal(t, "list_repository_security_advisories", tool.Name) assert.NotEmpty(t, tool.Description) @@ -501,7 +500,7 @@ func Test_ListOrgRepositorySecurityAdvisories(t *testing.T) { // Verify tool definition once toolDef := ListOrgRepositorySecurityAdvisories(translations.NullTranslationHelper) tool := toolDef.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + testSecurityToolSnapshot(t, tool) assert.Equal(t, "list_org_repository_security_advisories", tool.Name) assert.NotEmpty(t, tool.Description) diff --git a/pkg/github/security_outputs.go b/pkg/github/security_outputs.go new file mode 100644 index 0000000000..5c25d86ccb --- /dev/null +++ b/pkg/github/security_outputs.go @@ -0,0 +1,502 @@ +package github + +import ( + "bytes" + "encoding/json" + "fmt" + "math" + "strconv" + "time" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/google/go-github/v92/github" +) + +type GetCodeQualityFindingInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + FindingNumber int `json:"findingNumber"` +} + +type GetSecurityAlertInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + AlertNumber int `json:"alertNumber"` +} + +type ListCodeScanningAlertsInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + State string `json:"state,omitempty"` + Ref string `json:"ref,omitempty"` + Severity string `json:"severity,omitempty"` + ToolName string `json:"tool_name,omitempty"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` +} + +type ListSecretScanningAlertsInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + State string `json:"state,omitempty"` + SecretType string `json:"secret_type,omitempty"` + Resolution string `json:"resolution,omitempty"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` +} + +type ListDependabotAlertsInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + State string `json:"state,omitempty"` + Severity string `json:"severity,omitempty"` + PerPage *int `json:"perPage,omitempty"` + After string `json:"after,omitempty"` +} + +type ListGlobalSecurityAdvisoriesInput struct { + GHSAID string `json:"ghsaId,omitempty"` + Type string `json:"type,omitempty"` + CVEID string `json:"cveId,omitempty"` + Ecosystem string `json:"ecosystem,omitempty"` + Severity string `json:"severity,omitempty"` + CWEs []string `json:"cwes,omitempty"` + IsWithdrawn bool `json:"isWithdrawn,omitempty"` + Affects string `json:"affects,omitempty"` + Published string `json:"published,omitempty"` + Updated string `json:"updated,omitempty"` + Modified string `json:"modified,omitempty"` +} + +type ListRepositorySecurityAdvisoriesInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Direction string `json:"direction,omitempty"` + Sort string `json:"sort,omitempty"` + State string `json:"state,omitempty"` +} + +type GetGlobalSecurityAdvisoryInput struct { + GHSAID string `json:"ghsaId"` +} + +type ListOrgRepositorySecurityAdvisoriesInput struct { + Org string `json:"org"` + Direction string `json:"direction,omitempty"` + Sort string `json:"sort,omitempty"` + State string `json:"state,omitempty"` +} + +type CodeQualityRuleOutput struct { + ID *string `json:"id,omitempty"` + Title *string `json:"title,omitempty"` + Description *string `json:"description,omitempty"` + Help *string `json:"help,omitempty"` + Severity *string `json:"severity,omitempty"` + Category *string `json:"category,omitempty"` +} + +type CodeQualityLocationOutput struct { + Path *string `json:"path,omitempty"` + StartLine *int `json:"start_line,omitempty"` + StartColumn *int `json:"start_column,omitempty"` + EndLine *int `json:"end_line,omitempty"` + EndColumn *int `json:"end_column,omitempty"` +} + +type CodeQualityMessageOutput struct { + Text string `json:"text"` + Markdown string `json:"markdown"` +} + +type CodeQualityFindingOutput struct { + Number *int `json:"number,omitempty"` + State *string `json:"state,omitempty"` + Rule *CodeQualityRuleOutput `json:"rule,omitempty"` + Location *CodeQualityLocationOutput `json:"location,omitempty"` + Message *CodeQualityMessageOutput `json:"message,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty" jsonschema:"Finding creation time (RFC 3339)."` +} + +type CodeScanningRuleOutput struct { + ID *string `json:"id,omitempty"` + Severity *string `json:"severity,omitempty"` + Description *string `json:"description,omitempty"` + Name *string `json:"name,omitempty"` + SecuritySeverityLevel *string `json:"security_severity_level,omitempty" jsonschema:"Security impact level, distinct from rule diagnostic severity."` + Help *string `json:"help,omitempty"` +} + +type CodeScanningLocationOutput struct { + Path *string `json:"path,omitempty"` + StartLine *int `json:"start_line,omitempty"` + EndLine *int `json:"end_line,omitempty"` + StartColumn *int `json:"start_column,omitempty"` + EndColumn *int `json:"end_column,omitempty"` +} + +type CodeScanningInstanceOutput struct { + Ref *string `json:"ref,omitempty"` + State *string `json:"state,omitempty"` + CommitSHA *string `json:"commit_sha,omitempty"` + Message *string `json:"message,omitempty"` + Location *CodeScanningLocationOutput `json:"location,omitempty"` + HTMLURL *string `json:"html_url,omitempty"` +} + +type CodeScanningAlertOutput struct { + Number *int `json:"number,omitempty"` + State *string `json:"state,omitempty"` + HTMLURL *string `json:"html_url,omitempty"` + Rule *CodeScanningRuleOutput `json:"rule,omitempty"` + MostRecentInstance *CodeScanningInstanceOutput `json:"most_recent_instance,omitempty"` + DismissedReason *string `json:"dismissed_reason,omitempty"` + DismissedComment *string `json:"dismissed_comment,omitempty"` +} + +type SecretScanningLocationOutput struct { + Path *string `json:"path,omitempty"` + StartLine *int `json:"start_line,omitempty"` + EndLine *int `json:"end_line,omitempty"` + CommitSHA *string `json:"commit_sha,omitempty"` + StartColumn *int `json:"start_column,omitempty"` + EndColumn *int `json:"end_column,omitempty"` +} + +type SecretScanningAlertOutput struct { + Number *int `json:"number,omitempty"` + State *string `json:"state,omitempty"` + Resolution *string `json:"resolution,omitempty"` + SecretType *string `json:"secret_type,omitempty"` + SecretTypeDisplayName *string `json:"secret_type_display_name,omitempty"` + Secret *string `json:"secret,omitempty"` //nolint:gosec // G117: this scoped tool intentionally returns the matched secret. + HTMLURL *string `json:"html_url,omitempty"` + FirstLocationDetected *SecretScanningLocationOutput `json:"first_location_detected,omitempty"` + Validity *string `json:"validity,omitempty" jsonschema:"Credential validity reported by GitHub: active, inactive, or unknown."` + ResolutionComment *string `json:"resolution_comment,omitempty"` + PushProtectionBypassed *bool `json:"push_protection_bypassed,omitempty" jsonschema:"Whether push protection was bypassed for this secret."` +} + +type SecurityPackageOutput struct { + Ecosystem *string `json:"ecosystem,omitempty"` + Name *string `json:"name,omitempty"` +} + +type SecurityVulnerabilityOutput struct { + Package *SecurityPackageOutput `json:"package,omitempty"` + Severity *string `json:"severity,omitempty"` + VulnerableVersionRange *string `json:"vulnerable_version_range,omitempty"` + FirstPatchedVersion *string `json:"first_patched_version,omitempty" jsonschema:"Earliest version fixing the affected package."` +} + +type DependabotDependencyOutput struct { + Package *SecurityPackageOutput `json:"package,omitempty"` + ManifestPath *string `json:"manifest_path,omitempty"` + Scope *string `json:"scope,omitempty"` +} + +type DependabotAdvisoryOutput struct { + GHSAID *string `json:"ghsa_id,omitempty"` + CVEID *string `json:"cve_id,omitempty"` + Summary *string `json:"summary,omitempty"` + Description *string `json:"description,omitempty"` + Severity *string `json:"severity,omitempty"` +} + +type DependabotAlertOutput struct { + Number *int `json:"number,omitempty"` + State *string `json:"state,omitempty"` + Dependency *DependabotDependencyOutput `json:"dependency,omitempty"` + SecurityAdvisory *DependabotAdvisoryOutput `json:"security_advisory,omitempty"` + SecurityVulnerability *SecurityVulnerabilityOutput `json:"security_vulnerability,omitempty"` + HTMLURL *string `json:"html_url,omitempty"` + DismissedReason *string `json:"dismissed_reason,omitempty"` + DismissedComment *string `json:"dismissed_comment,omitempty"` +} + +type SecurityAdvisoryVulnerabilityOutput struct { + Package *SecurityPackageOutput `json:"package,omitempty"` + Severity *string `json:"severity,omitempty"` + VulnerableVersionRange *string `json:"vulnerable_version_range,omitempty"` + FirstPatchedVersion *string `json:"first_patched_version,omitempty" jsonschema:"Earliest version fixing the affected package."` + PatchedVersions *string `json:"patched_versions,omitempty"` + VulnerableFunctions []string `json:"vulnerable_functions,omitempty"` +} + +type SecurityAdvisoryOutput struct { + GHSAID *string `json:"ghsa_id,omitempty"` + CVEID *string `json:"cve_id,omitempty"` + Summary *string `json:"summary,omitempty"` + Description *string `json:"description,omitempty"` + Severity *string `json:"severity,omitempty"` + State *string `json:"state,omitempty"` + HTMLURL *string `json:"html_url,omitempty"` + CWEIDs []string `json:"cwe_ids,omitempty"` + Vulnerabilities []*SecurityAdvisoryVulnerabilityOutput `json:"vulnerabilities,omitempty"` + PublishedAt *time.Time `json:"published_at,omitempty" jsonschema:"Advisory publication time (RFC 3339)."` + UpdatedAt *time.Time `json:"updated_at,omitempty" jsonschema:"Last advisory update time (RFC 3339)."` + WithdrawnAt *time.Time `json:"withdrawn_at,omitempty" jsonschema:"Withdrawal time, when withdrawn (RFC 3339)."` +} + +type GlobalSecurityAdvisoryOutput struct { + SecurityAdvisoryOutput + Type *string `json:"type,omitempty"` + SourceCodeLocation *string `json:"source_code_location,omitempty"` +} + +type SecurityPageInfo struct { + HasNextPage bool `json:"hasNextPage"` + HasPreviousPage bool `json:"hasPreviousPage"` + NextCursor string `json:"nextCursor,omitempty"` + PrevCursor string `json:"prevCursor,omitempty"` +} + +type DependabotAlertsOutput struct { + Alerts []*DependabotAlertOutput `json:"alerts"` + PageInfo SecurityPageInfo `json:"pageInfo"` +} + +func securityPagination(page, perPage *int) PaginationParams { + pagination := PaginationParams{Page: 1, PerPage: 30} + if page != nil { + pagination.Page = *page + } + if perPage != nil { + pagination.PerPage = *perPage + } + return pagination +} + +func securityCursorPagination(perPage *int, after string) CursorPaginationParams { + pagination := CursorPaginationParams{PerPage: 30, After: after} + if perPage != nil { + pagination.PerPage = *perPage + } + return pagination +} + +func normalizeSecurityIntegerArguments(fields ...string) inventory.InputNormalizer { + return func(raw json.RawMessage) (json.RawMessage, error) { + var arguments map[string]json.RawMessage + if err := json.Unmarshal(raw, &arguments); err != nil { + return nil, err + } + + for _, field := range fields { + value, exists := arguments[field] + if !exists { + continue + } + if bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + return nil, fmt.Errorf("parameter %s is not a valid number", field) + } + number := string(value) + if len(number) > 0 && number[0] == '"' { + if err := json.Unmarshal(value, &number); err != nil { + return nil, fmt.Errorf("parameter %s is not a valid number: %w", field, err) + } + } + parsed, err := strconv.ParseFloat(number, 64) + if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) || + parsed != math.Trunc(parsed) || parsed > float64(math.MaxInt) || parsed < float64(math.MinInt) { + return nil, fmt.Errorf("parameter %s is not a valid number", field) + } + arguments[field], err = json.Marshal(int(parsed)) + if err != nil { + return nil, err + } + } + if arguments == nil { + return raw, nil + } + return json.Marshal(arguments) + } +} + +func normalizeGlobalAdvisoryArguments(raw json.RawMessage) (json.RawMessage, error) { + var arguments map[string]json.RawMessage + if err := json.Unmarshal(raw, &arguments); err != nil { + return nil, err + } + if value, exists := arguments["cwes"]; exists && bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + delete(arguments, "cwes") + return json.Marshal(arguments) + } + return raw, nil +} + +func mapSecurityOutputs[In, Out any](items []*In, project func(*In) *Out) []*Out { + if items == nil { + return nil + } + outputs := make([]*Out, len(items)) + for i, item := range items { + outputs[i] = project(item) + } + return outputs +} + +func codeScanningAlertOutput(alert *github.Alert) *CodeScanningAlertOutput { + if alert == nil { + return nil + } + output := &CodeScanningAlertOutput{ + Number: alert.Number, + State: alert.State, + HTMLURL: alert.HTMLURL, + DismissedReason: alert.DismissedReason, DismissedComment: alert.DismissedComment, + } + if alert.Rule != nil { + output.Rule = &CodeScanningRuleOutput{ + ID: alert.Rule.ID, Severity: alert.Rule.Severity, + Description: alert.Rule.Description, Name: alert.Rule.Name, + SecuritySeverityLevel: alert.Rule.SecuritySeverityLevel, Help: alert.Rule.Help, + } + } + if instance := alert.MostRecentInstance; instance != nil { + output.MostRecentInstance = &CodeScanningInstanceOutput{ + Ref: instance.Ref, State: instance.State, CommitSHA: instance.CommitSHA, + HTMLURL: instance.HTMLURL, + } + if instance.Message != nil { + output.MostRecentInstance.Message = instance.Message.Text + } + if location := instance.Location; location != nil { + output.MostRecentInstance.Location = &CodeScanningLocationOutput{ + Path: location.Path, StartLine: location.StartLine, EndLine: location.EndLine, + StartColumn: location.StartColumn, EndColumn: location.EndColumn, + } + } + } + return output +} + +func secretScanningAlertOutput(alert *github.SecretScanningAlert) *SecretScanningAlertOutput { + if alert == nil { + return nil + } + output := &SecretScanningAlertOutput{ + Number: alert.Number, State: alert.State, Resolution: alert.Resolution, + SecretType: alert.SecretType, SecretTypeDisplayName: alert.SecretTypeDisplayName, + Secret: alert.Secret, HTMLURL: alert.HTMLURL, + Validity: alert.Validity, ResolutionComment: alert.ResolutionComment, + PushProtectionBypassed: alert.PushProtectionBypassed, + } + if location := alert.FirstLocationDetected; location != nil { + output.FirstLocationDetected = &SecretScanningLocationOutput{ + Path: location.Path, StartLine: location.Startline, EndLine: location.EndLine, + CommitSHA: location.CommitSHA, StartColumn: location.StartColumn, EndColumn: location.EndColumn, + } + } + return output +} + +func securityPackageOutput(pkg *github.VulnerabilityPackage) *SecurityPackageOutput { + if pkg == nil { + return nil + } + return &SecurityPackageOutput{Ecosystem: pkg.Ecosystem, Name: pkg.Name} +} + +func securityVulnerabilityOutput(vulnerability *github.AdvisoryVulnerability) *SecurityVulnerabilityOutput { + if vulnerability == nil { + return nil + } + output := &SecurityVulnerabilityOutput{ + Package: securityPackageOutput(vulnerability.Package), Severity: vulnerability.Severity, + VulnerableVersionRange: vulnerability.VulnerableVersionRange, + } + if patched := vulnerability.FirstPatchedVersion; patched != nil { + output.FirstPatchedVersion = patched.Identifier + } + return output +} + +func dependabotAlertOutput(alert *github.DependabotAlert) *DependabotAlertOutput { + if alert == nil { + return nil + } + output := &DependabotAlertOutput{ + Number: alert.Number, State: alert.State, HTMLURL: alert.HTMLURL, + DismissedReason: alert.DismissedReason, DismissedComment: alert.DismissedComment, + } + if dependency := alert.Dependency; dependency != nil { + output.Dependency = &DependabotDependencyOutput{ + Package: securityPackageOutput(dependency.Package), + ManifestPath: dependency.ManifestPath, Scope: dependency.Scope, + } + } + if advisory := alert.SecurityAdvisory; advisory != nil { + output.SecurityAdvisory = &DependabotAdvisoryOutput{ + GHSAID: advisory.GHSAID, CVEID: advisory.CVEID, Summary: advisory.Summary, + Description: advisory.Description, Severity: advisory.Severity, + } + } + output.SecurityVulnerability = securityVulnerabilityOutput(alert.SecurityVulnerability) + return output +} + +func securityAdvisoryOutput(advisory *github.SecurityAdvisory) *SecurityAdvisoryOutput { + if advisory == nil { + return nil + } + output := &SecurityAdvisoryOutput{ + GHSAID: advisory.GHSAID, CVEID: advisory.CVEID, Summary: advisory.Summary, + Description: advisory.Description, Severity: advisory.Severity, State: advisory.State, + HTMLURL: advisory.HTMLURL, CWEIDs: advisory.CWEIDs, + PublishedAt: githubTimestampTime(advisory.PublishedAt), + UpdatedAt: githubTimestampTime(advisory.UpdatedAt), + WithdrawnAt: githubTimestampTime(advisory.WithdrawnAt), + } + if advisory.Vulnerabilities != nil { + output.Vulnerabilities = mapSecurityOutputs(advisory.Vulnerabilities, securityAdvisoryVulnerabilityOutput) + } + return output +} + +func securityAdvisoryVulnerabilityOutput(vulnerability *github.AdvisoryVulnerability) *SecurityAdvisoryVulnerabilityOutput { + if vulnerability == nil { + return nil + } + projected := securityVulnerabilityOutput(vulnerability) + return &SecurityAdvisoryVulnerabilityOutput{ + Package: projected.Package, Severity: projected.Severity, + VulnerableVersionRange: projected.VulnerableVersionRange, + FirstPatchedVersion: projected.FirstPatchedVersion, + PatchedVersions: vulnerability.PatchedVersions, + VulnerableFunctions: vulnerability.VulnerableFunctions, + } +} + +func globalSecurityAdvisoryOutput(advisory *github.GlobalSecurityAdvisory) *GlobalSecurityAdvisoryOutput { + if advisory == nil { + return nil + } + output := &GlobalSecurityAdvisoryOutput{ + SecurityAdvisoryOutput: *securityAdvisoryOutput(&advisory.SecurityAdvisory), + Type: advisory.Type, SourceCodeLocation: advisory.SourceCodeLocation, + } + if advisory.Vulnerabilities != nil { + output.Vulnerabilities = mapSecurityOutputs(advisory.Vulnerabilities, globalAdvisoryVulnerabilityOutput) + } + return output +} + +func globalAdvisoryVulnerabilityOutput(vulnerability *github.GlobalSecurityVulnerability) *SecurityAdvisoryVulnerabilityOutput { + if vulnerability == nil { + return nil + } + return &SecurityAdvisoryVulnerabilityOutput{ + Package: securityPackageOutput(vulnerability.Package), + FirstPatchedVersion: vulnerability.FirstPatchedVersion, + VulnerableVersionRange: vulnerability.VulnerableVersionRange, + VulnerableFunctions: vulnerability.VulnerableFunctions, + } +} + +func githubTimestampTime(timestamp *github.Timestamp) *time.Time { + if timestamp == nil { + return nil + } + return ×tamp.Time +} diff --git a/pkg/github/typed_security_compatibility_test.go b/pkg/github/typed_security_compatibility_test.go new file mode 100644 index 0000000000..40f7ff5f0f --- /dev/null +++ b/pkg/github/typed_security_compatibility_test.go @@ -0,0 +1,213 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "net/url" + "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" +) + +func TestSecurityRegisteredFilters(t *testing.T) { + tests := []struct { + name string + tool inventory.ServerTool + route string + arguments map[string]any + wantQuery url.Values + }{ + { + name: "dependabot omitted state", + tool: ListDependabotAlerts(translations.NullTranslationHelper), route: GetReposDependabotAlertsByOwnerByRepo, + arguments: map[string]any{"owner": "owner", "repo": "repo"}, + wantQuery: url.Values{"per_page": {"30"}}, + }, + { + name: "dependabot explicit state", + tool: ListDependabotAlerts(translations.NullTranslationHelper), route: GetReposDependabotAlertsByOwnerByRepo, + arguments: map[string]any{"owner": "owner", "repo": "repo", "state": "fixed"}, + wantQuery: url.Values{"per_page": {"30"}, "state": {"fixed"}}, + }, + { + name: "dependabot omitted state with harness page size", + tool: ListDependabotAlerts(translations.NullTranslationHelper), + route: GetReposDependabotAlertsByOwnerByRepo, + arguments: map[string]any{"owner": "owner", "repo": "repo", "perPage": 2}, + wantQuery: url.Values{"per_page": {"2"}}, + }, + { + name: "code scanning omitted state", + tool: ListCodeScanningAlerts(translations.NullTranslationHelper), route: GetReposCodeScanningAlertsByOwnerByRepo, + arguments: map[string]any{"owner": "owner", "repo": "repo"}, + wantQuery: url.Values{"page": {"1"}, "per_page": {"30"}}, + }, + { + name: "advisories omitted type", + tool: ListGlobalSecurityAdvisories(translations.NullTranslationHelper), route: GetAdvisories, + arguments: map[string]any{}, + wantQuery: url.Values{}, + }, + { + name: "advisories null cwes", + tool: ListGlobalSecurityAdvisories(translations.NullTranslationHelper), route: GetAdvisories, + arguments: map[string]any{"type": "reviewed", "cwes": nil}, + wantQuery: url.Values{"type": {"reviewed"}}, + }, + { + name: "advisories explicit cwes", + tool: ListGlobalSecurityAdvisories(translations.NullTranslationHelper), route: GetAdvisories, + arguments: map[string]any{"type": "reviewed", "cwes": []string{"79", "284"}}, + wantQuery: url.Values{"type": {"reviewed"}, "cwes": {"79", "284"}}, + }, + } + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + for _, tt := range tests { + t.Run(protocol+"/"+tt.name, func(t *testing.T) { + called := false + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + tt.route: func(w http.ResponseWriter, r *http.Request) { + called = true + assert.Equal(t, tt.wantQuery, r.URL.Query(), "SDK validation must not insert advertised defaults into omitted filters") + mockResponse(t, http.StatusOK, []any{})(w, r) + }, + }))} + server := mcp.NewServer(&mcp.Implementation{Name: "security-filters-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + tt.tool.RegisterFunc(server, deps) + session := connectCommentVisibilityClient(t, server, protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tt.tool.Tool.Name, Arguments: tt.arguments}) + require.NoError(t, err) + require.False(t, result.IsError, "%s", getTextResult(t, result).Text) + assert.True(t, called, "the API must receive the compatible filter") + }) + } + } +} + +func TestSecurityEmptyAndErrorOutputs(t *testing.T) { + tests := []struct { + tool inventory.ServerTool + route string + arguments map[string]any + list bool + }{ + {GetCodeQualityFinding(translations.NullTranslationHelper), GetReposCodeQualityFindingsByOwnerByRepoByFindingNumber, map[string]any{"owner": "owner", "repo": "repo", "findingNumber": 42}, false}, + {GetCodeScanningAlert(translations.NullTranslationHelper), GetReposCodeScanningAlertsByOwnerByRepoByAlertNumber, map[string]any{"owner": "owner", "repo": "repo", "alertNumber": 42}, false}, + {ListCodeScanningAlerts(translations.NullTranslationHelper), GetReposCodeScanningAlertsByOwnerByRepo, map[string]any{"owner": "owner", "repo": "repo"}, true}, + {GetSecretScanningAlert(translations.NullTranslationHelper), GetReposSecretScanningAlertsByOwnerByRepoByAlertNumber, map[string]any{"owner": "owner", "repo": "repo", "alertNumber": 42}, false}, + {ListSecretScanningAlerts(translations.NullTranslationHelper), GetReposSecretScanningAlertsByOwnerByRepo, map[string]any{"owner": "owner", "repo": "repo"}, true}, + {GetDependabotAlert(translations.NullTranslationHelper), GetReposDependabotAlertsByOwnerByRepoByAlertNumber, map[string]any{"owner": "owner", "repo": "repo", "alertNumber": 42}, false}, + {ListDependabotAlerts(translations.NullTranslationHelper), GetReposDependabotAlertsByOwnerByRepo, map[string]any{"owner": "owner", "repo": "repo"}, true}, + {GetGlobalSecurityAdvisory(translations.NullTranslationHelper), GetAdvisoriesByGhsaID, map[string]any{"ghsaId": "GHSA-aaaa-bbbb-cccc"}, false}, + {ListGlobalSecurityAdvisories(translations.NullTranslationHelper), GetAdvisories, map[string]any{}, true}, + {ListRepositorySecurityAdvisories(translations.NullTranslationHelper), GetReposSecurityAdvisoriesByOwnerByRepo, map[string]any{"owner": "owner", "repo": "repo"}, true}, + {ListOrgRepositorySecurityAdvisories(translations.NullTranslationHelper), GetOrgsSecurityAdvisoriesByOrg, map[string]any{"org": "owner"}, true}, + } + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + for _, tt := range tests { + for _, body := range []string{"null", "[]", "[null]", `{"message":"access denied"}`} { + isError := body == `{"message":"access denied"}` + if !tt.list && !isError && body != "null" { + continue + } + t.Run(protocol+"/"+tt.tool.Tool.Name+"/"+body, func(t *testing.T) { + status := http.StatusOK + if isError { + status = http.StatusForbidden + } + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + tt.route: mockResponse(t, status, json.RawMessage(body)), + }))} + server := mcp.NewServer(&mcp.Implementation{Name: "security-empty-test", Version: "v1"}, nil) + var serializedResult json.RawMessage + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) { + result, err := next(ctx, method, request) + if method == "tools/call" && err == nil { + serializedResult, err = json.Marshal(result) + } + return result, err + } + }) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + tt.tool.RegisterFunc(server, deps) + session := connectCommentVisibilityClient(t, server, protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tt.tool.Tool.Name, Arguments: tt.arguments}) + require.NoError(t, err) + require.Equal(t, isError, result.IsError, "%s", getTextResult(t, result).Text) + if isError { + assert.Nil(t, result.StructuredContent, "errors must not produce success-shaped structured output") + assert.Contains(t, getTextResult(t, result).Text, "access denied") + return + } + if protocol == "2025-11-25" { + if tt.tool.Tool.Name == "list_dependabot_alerts" { + assert.JSONEq(t, `{"alerts":`+body+`,"pageInfo":{"hasNextPage":false,"hasPreviousPage":false}}`, getTextResult(t, result).Text) + } else { + assert.Equal(t, body, getTextResult(t, result).Text) + } + assert.Nil(t, result.StructuredContent) + return + } + schema, err := securityOutputSchema(t, tt.tool.Tool.Name).Resolve(nil) + require.NoError(t, err) + var wireFields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(serializedResult, &wireFields)) + wireStructured, present := wireFields["structuredContent"] + require.True(t, present, "successful schema-bearing tools must serialize structuredContent, including explicit JSON null") + var structured any + require.NoError(t, json.Unmarshal(wireStructured, &structured)) + require.NoError(t, schema.Validate(structured)) + if !tt.list { + expected := "null" + if tt.tool.Tool.Name == "get_code_quality_finding" { + expected = "{}" + } + assert.JSONEq(t, expected, string(wireStructured)) + assert.JSONEq(t, string(wireStructured), getTextResult(t, result).Text) + } + if tt.list { + assert.JSONEq(t, mustMarshalJSON(t, result.StructuredContent), getTextResult(t, result).Text, "modern lists expose the same DTO through both channels") + if tt.tool.Tool.Name == "list_dependabot_alerts" { + assert.JSONEq(t, getTextResult(t, result).Text, mustMarshalJSON(t, result.StructuredContent)) + } else { + assert.Equal(t, body, mustMarshalJSON(t, result.StructuredContent)) + } + } + }) + } + } + } +} + +func TestLegacyCodeQualityFindingFormatting(t *testing.T) { + raw := json.RawMessage(`{"z":1.0,"number":42,"rule":{"z":"last","a":"first"},"unknown":{"n":1e3},"a":null}`) + formatted, err := marshalLegacyCodeQualityFinding(raw) + require.NoError(t, err) + assert.Equal(t, `{"a":null,"number":42,"rule":{"a":"first","z":"last"},"unknown":{"n":1000},"z":1}`, string(formatted)) +} + +func TestSecurityInputSchemasPreserveEnums(t *testing.T) { + for _, tool := range []inventory.ServerTool{ + ListCodeScanningAlerts(translations.NullTranslationHelper), + ListDependabotAlerts(translations.NullTranslationHelper), + ListGlobalSecurityAdvisories(translations.NullTranslationHelper), + } { + schema := tool.Tool.InputSchema.(*jsonschema.Schema) + property := "state" + expectedDefault := `"open"` + if tool.Tool.Name == "list_global_security_advisories" { + property, expectedDefault = "type", `"reviewed"` + } + require.NotEmpty(t, schema.Properties[property].Enum) + assert.Equal(t, expectedDefault, string(schema.Properties[property].Default)) + assert.NotEmpty(t, schema.Properties[property].Description) + } +} diff --git a/pkg/github/typed_security_outputs_test.go b/pkg/github/typed_security_outputs_test.go new file mode 100644 index 0000000000..c1828a6c6b --- /dev/null +++ b/pkg/github/typed_security_outputs_test.go @@ -0,0 +1,341 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "reflect" + "testing" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func securityOutputSchema(t *testing.T, name string) *jsonschema.Schema { + t.Helper() + var schema *jsonschema.Schema + var err error + switch name { + case "get_code_quality_finding": + schema, err = jsonschema.For[*CodeQualityFindingOutput](nil) + case "get_code_scanning_alert": + schema, err = jsonschema.For[*CodeScanningAlertOutput](nil) + case "list_code_scanning_alerts": + schema, err = jsonschema.For[[]*CodeScanningAlertOutput](nil) + case "get_secret_scanning_alert": + schema, err = jsonschema.For[*SecretScanningAlertOutput](nil) + case "list_secret_scanning_alerts": + schema, err = jsonschema.For[[]*SecretScanningAlertOutput](nil) + case "get_dependabot_alert": + schema, err = jsonschema.For[*DependabotAlertOutput](nil) + case "list_dependabot_alerts": + schema, err = jsonschema.For[DependabotAlertsOutput](nil) + case "get_global_security_advisory": + schema, err = jsonschema.For[*GlobalSecurityAdvisoryOutput](nil) + case "list_global_security_advisories": + schema, err = jsonschema.For[[]*GlobalSecurityAdvisoryOutput](nil) + case "list_repository_security_advisories", "list_org_repository_security_advisories": + schema, err = jsonschema.For[[]*SecurityAdvisoryOutput](nil) + default: + t.Fatalf("unexpected security tool %s", name) + } + require.NoError(t, err) + return schema +} + +func testSecurityToolSnapshot(t *testing.T, tool mcp.Tool) { + t.Helper() + tool.OutputSchema = securityOutputSchema(t, tool.Name) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) +} + +func TestTypedSecurityToolOutputs(t *testing.T) { + codeQualityFinding := map[string]any{ + "number": 42, + "state": "open", + "rule": map[string]any{ + "id": "test-rule", + "description": "Test rule", + }, + "unknown_legacy_field": "preserved in text", + "url": "https://api.github.com/repos/owner/repo/code-quality/findings/42", + } + codeScanningAlert := &github.Alert{ + Number: new(42), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/security/code-scanning/42"), + URL: new("https://api.github.com/repos/owner/repo/code-scanning/alerts/42"), + Rule: &github.Rule{ + ID: new("test-rule"), + Description: new("Test rule"), + FullDescription: new("Full rule description"), + Help: new("Use parameterized queries"), + SecuritySeverityLevel: new("high"), + }, + } + secretScanningAlert := &github.SecretScanningAlert{ + Number: new(43), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/security/secret-scanning/43"), + SecretType: new("test_secret"), + Secret: new("test-secret"), + URL: new("https://api.github.com/repos/owner/repo/secret-scanning/alerts/43"), + Validity: new("active"), + ResolutionComment: new("Rotate this credential"), + PushProtectionBypassed: new(true), + FirstLocationDetected: &github.SecretScanningAlertLocationDetails{ + Path: new("config.env"), CommitSHA: new("abc123"), StartColumn: new(5), + CommitURL: new("https://api.github.com/repos/owner/repo/commits/abc123"), + }, + PushProtectionBypassRequestComment: new("legacy response detail"), + } + dependabotAlert := &github.DependabotAlert{ + Number: new(44), + State: new("open"), + HTMLURL: new("https://github.com/owner/repo/security/dependabot/44"), + URL: new("https://api.github.com/repos/owner/repo/dependabot/alerts/44"), + DismissedReason: new("tolerable_risk"), + SecurityVulnerability: &github.AdvisoryVulnerability{ + VulnerableVersionRange: new("< 2.0"), + FirstPatchedVersion: &github.FirstPatchedVersion{Identifier: new("2.0")}, + }, + SecurityAdvisory: &github.DependabotSecurityAdvisory{ + GHSAID: new("GHSA-aaaa-bbbb-cccc"), + Classification: new("malware"), + Vulnerabilities: []*github.AdvisoryVulnerability{{VulnerableVersionRange: new("< 2.0")}}, + }, + } + advisory := &github.SecurityAdvisory{ + GHSAID: new("GHSA-aaaa-bbbb-cccc"), + Summary: new("Test advisory"), + Description: new("Test advisory description"), + Severity: new("high"), + State: new("published"), + URL: new("https://api.github.com/repos/owner/repo/security-advisories/GHSA-aaaa-bbbb-cccc"), + Vulnerabilities: []*github.AdvisoryVulnerability{{ + PatchedVersions: new("2.0"), VulnerableVersionRange: new("< 2.0"), + VulnerableFunctions: []string{"unsafeQuery"}, + }}, + } + globalAdvisory := &github.GlobalSecurityAdvisory{ + SecurityAdvisory: *advisory, + RepositoryAdvisoryURL: new("https://api.github.com/repos/owner/repo/security-advisories/GHSA-aaaa-bbbb-cccc"), + Vulnerabilities: []*github.GlobalSecurityVulnerability{{ + FirstPatchedVersion: new("2.0"), VulnerableVersionRange: new("< 2.0"), + VulnerableFunctions: []string{"unsafeQuery"}, + }}, + } + codeQualityJSON := json.RawMessage(`{"unknown_legacy_field":"preserved in text","url":"https://api.github.com/repos/owner/repo/code-quality/findings/42","state":"open","rule":{"id":"test-rule","description":"Test rule"},"number":42}`) + legacyOutputs := map[string]any{ + "get_code_quality_finding": codeQualityFinding, + "get_code_scanning_alert": codeScanningAlert, + "list_code_scanning_alerts": []*github.Alert{codeScanningAlert}, + "get_secret_scanning_alert": secretScanningAlert, + "list_secret_scanning_alerts": []*github.SecretScanningAlert{secretScanningAlert}, + "get_dependabot_alert": dependabotAlert, + "list_dependabot_alerts": struct { + Alerts []*github.DependabotAlert `json:"alerts"` + PageInfo pageInfo `json:"pageInfo"` + }{Alerts: []*github.DependabotAlert{dependabotAlert}}, + "get_global_security_advisory": globalAdvisory, + "list_global_security_advisories": []*github.GlobalSecurityAdvisory{globalAdvisory}, + "list_repository_security_advisories": []*github.SecurityAdvisory{advisory}, + "list_org_repository_security_advisories": []*github.SecurityAdvisory{advisory}, + } + + deps := BaseDeps{ + Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCodeQualityFindingsByOwnerByRepoByFindingNumber: mockResponse(t, http.StatusOK, codeQualityJSON), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, &github.Repository{Private: new(true)}), + GetReposCodeScanningAlertsByOwnerByRepoByAlertNumber: mockResponse(t, http.StatusOK, codeScanningAlert), + GetReposCodeScanningAlertsByOwnerByRepo: mockResponse(t, http.StatusOK, []*github.Alert{codeScanningAlert}), + GetReposSecretScanningAlertsByOwnerByRepoByAlertNumber: mockResponse(t, http.StatusOK, secretScanningAlert), + GetReposSecretScanningAlertsByOwnerByRepo: mockResponse(t, http.StatusOK, []*github.SecretScanningAlert{secretScanningAlert}), + GetReposDependabotAlertsByOwnerByRepoByAlertNumber: mockResponse(t, http.StatusOK, dependabotAlert), + GetReposDependabotAlertsByOwnerByRepo: mockResponse(t, http.StatusOK, []*github.DependabotAlert{dependabotAlert}), + GetAdvisories: mockResponse(t, http.StatusOK, []*github.GlobalSecurityAdvisory{globalAdvisory}), + GetAdvisoriesByGhsaID: mockResponse(t, http.StatusOK, globalAdvisory), + GetReposSecurityAdvisoriesByOwnerByRepo: mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{advisory}), + GetOrgsSecurityAdvisoriesByOrg: mockResponse(t, http.StatusOK, []*github.SecurityAdvisory{advisory}), + })), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + tools := []inventory.ServerTool{ + GetCodeQualityFinding(translations.NullTranslationHelper), + GetCodeScanningAlert(translations.NullTranslationHelper), + ListCodeScanningAlerts(translations.NullTranslationHelper), + GetSecretScanningAlert(translations.NullTranslationHelper), + ListSecretScanningAlerts(translations.NullTranslationHelper), + GetDependabotAlert(translations.NullTranslationHelper), + ListDependabotAlerts(translations.NullTranslationHelper), + ListGlobalSecurityAdvisories(translations.NullTranslationHelper), + GetGlobalSecurityAdvisory(translations.NullTranslationHelper), + ListRepositorySecurityAdvisories(translations.NullTranslationHelper), + ListOrgRepositorySecurityAdvisories(translations.NullTranslationHelper), + } + calls := []mcp.CallToolParams{ + {Name: "get_code_quality_finding", Arguments: map[string]any{"owner": "owner", "repo": "repo", "findingNumber": "42"}}, + {Name: "get_code_scanning_alert", Arguments: map[string]any{"owner": "owner", "repo": "repo", "alertNumber": "42"}}, + {Name: "list_code_scanning_alerts", Arguments: map[string]any{"owner": "owner", "repo": "repo", "page": "1", "perPage": "30"}}, + {Name: "get_secret_scanning_alert", Arguments: map[string]any{"owner": "owner", "repo": "repo", "alertNumber": "43"}}, + {Name: "list_secret_scanning_alerts", Arguments: map[string]any{"owner": "owner", "repo": "repo", "page": "1", "perPage": "30"}}, + {Name: "get_dependabot_alert", Arguments: map[string]any{"owner": "owner", "repo": "repo", "alertNumber": "44"}}, + {Name: "list_dependabot_alerts", Arguments: map[string]any{"owner": "owner", "repo": "repo", "perPage": "30"}}, + {Name: "list_global_security_advisories", Arguments: map[string]any{"type": "reviewed", "cwes": []string{"79"}, "unknown_legacy_field": true}}, + {Name: "get_global_security_advisory", Arguments: map[string]any{"ghsaId": "GHSA-aaaa-bbbb-cccc"}}, + {Name: "list_repository_security_advisories", Arguments: map[string]any{"owner": "owner", "repo": "repo"}}, + {Name: "list_org_repository_security_advisories", Arguments: map[string]any{"org": "owner"}}, + } + legacyIFC := make(map[string]string) + t.Run("unknown direct caller", func(t *testing.T) { + for i, tool := range tools { + call := calls[i] + arguments, err := json.Marshal(call.Arguments) + require.NoError(t, err) + result, err := tool.Handler(deps)(ContextWithDeps(context.Background(), deps), &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{Name: call.Name, Arguments: arguments}, + }) + require.NoError(t, err, call.Name) + require.False(t, result.IsError, call.Name) + assert.Nil(t, result.StructuredContent, "unknown callers must remain text-only") + assert.Equal(t, mustMarshalJSON(t, legacyOutputs[call.Name]), getTextResult(t, result).Text, call.Name) + } + }) + + for _, protocolVersion := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + t.Run(protocolVersion, func(t *testing.T) { + server := mcp.NewServer(&mcp.Implementation{Name: "typed-security-output-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + for _, tool := range tools { + tool.RegisterFunc(server, deps) + } + + session := connectCommentVisibilityClient(t, server, protocolVersion) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, len(tools)) + toolsByName := make(map[string]*mcp.Tool, len(list.Tools)) + for _, tool := range list.Tools { + toolsByName[tool.Name] = tool + if protocolVersion == "2025-11-25" { + assert.Nil(t, tool.OutputSchema, "legacy clients must not see outputSchema for %s", tool.Name) + continue + } + require.NotNil(t, tool.OutputSchema, "%s must publish an output schema", tool.Name) + schemaBytes, err := json.Marshal(tool.OutputSchema) + require.NoError(t, err) + t.Logf("%s output schema: %d bytes", tool.Name, len(schemaBytes)) + assert.JSONEq(t, mustMarshalJSON(t, securityOutputSchema(t, tool.Name)), string(schemaBytes), "canonical snapshots must describe the modern wire schema") + } + + for _, call := range calls { + result, err := session.CallTool(context.Background(), &call) + require.NoError(t, err, call.Name) + require.False(t, result.IsError, "%s: %s", call.Name, result) + text := getTextResult(t, result).Text + if protocolVersion == "2025-11-25" { + assert.Equal(t, mustMarshalJSON(t, legacyOutputs[call.Name]), text, "%s must retain byte-exact main serialization for legacy clients", call.Name) + legacyIFC[call.Name] = mustMarshalJSON(t, result.Meta["ifc"]) + assert.Nil(t, result.StructuredContent, "%s must remain text-only for legacy clients", call.Name) + continue + } + + require.NotNil(t, result.StructuredContent, "%s must return structured content", call.Name) + assert.Equal(t, legacyIFC[call.Name], mustMarshalJSON(t, result.Meta["ifc"]), "IFC labels must protect text and structured content identically") + if call.Name != "get_code_quality_finding" { + assert.NotNil(t, result.Meta["ifc"], "%s must retain its IFC label", call.Name) + } + schemaJSON, err := json.Marshal(toolsByName[call.Name].OutputSchema) + require.NoError(t, err) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal(schemaJSON, &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + structuredJSON, err := json.Marshal(result.StructuredContent) + require.NoError(t, err) + assert.JSONEq(t, string(structuredJSON), text, "modern JSON text must expose the same compact DTO as structuredContent") + var structured any + require.NoError(t, json.Unmarshal(structuredJSON, &structured)) + require.NoError(t, resolved.Validate(structured), "%s output must conform to its schema", call.Name) + assert.NotContains(t, string(structuredJSON), "https://api.github.com", "API/hypermedia URLs must remain text-only") + switch call.Name { + case "get_code_scanning_alert", "list_code_scanning_alerts": + assert.Contains(t, string(structuredJSON), "Use parameterized queries") + assert.Contains(t, string(structuredJSON), `"security_severity_level":"high"`) + case "get_secret_scanning_alert", "list_secret_scanning_alerts": + assert.Contains(t, text, `"secret":"test-secret"`) + assert.Contains(t, string(structuredJSON), `"secret":"test-secret"`, "preserve the same intentionally scoped secret as text") + assert.Contains(t, string(structuredJSON), `"validity":"active"`) + assert.Contains(t, string(structuredJSON), "Rotate this credential") + assert.Contains(t, string(structuredJSON), `"start_column":5`) + case "get_dependabot_alert", "list_dependabot_alerts": + assert.Contains(t, string(structuredJSON), `"first_patched_version":"2.0"`) + assert.Contains(t, string(structuredJSON), `"dismissed_reason":"tolerable_risk"`) + assert.NotContains(t, string(structuredJSON), `"vulnerabilities"`, "use the alert's affected package instead of repeating advisory-wide packages") + case "get_global_security_advisory", "list_global_security_advisories": + assert.Contains(t, string(structuredJSON), `"first_patched_version":"2.0"`) + assert.Contains(t, string(structuredJSON), "unsafeQuery") + case "list_repository_security_advisories", "list_org_repository_security_advisories": + assert.Contains(t, string(structuredJSON), `"patched_versions":"2.0"`) + assert.Contains(t, string(structuredJSON), "unsafeQuery") + } + + if call.Name == "get_code_quality_finding" { + assert.NotContains(t, mustMarshalJSON(t, result.StructuredContent), "unknown_legacy_field") + } + + if call.Name == "get_code_scanning_alert" { + assert.NotContains(t, mustMarshalJSON(t, result.StructuredContent), "full_description") + } + if call.Name == "get_secret_scanning_alert" { + assert.NotContains(t, mustMarshalJSON(t, result.StructuredContent), "legacy response detail") + } + if call.Name == "list_dependabot_alerts" { + assert.NotContains(t, mustMarshalJSON(t, result.StructuredContent), "classification") + } + if call.Name == "get_code_scanning_alert" { + assert.NotNil(t, result.Meta["ifc"], "typed output must retain the security alert IFC label") + } + } + }) + } +} + +func TestSecurityOutputDTOs(t *testing.T) { + seen := make(map[reflect.Type]bool) + var check func(reflect.Type) + check = func(typ reflect.Type) { + if seen[typ] { + return + } + seen[typ] = true + assert.NotEqual(t, "github.com/google/go-github/v92/github", typ.PkgPath(), "%s must not embed raw API models", typ) + switch typ.Kind() { + case reflect.Pointer, reflect.Slice, reflect.Array: + check(typ.Elem()) + case reflect.Struct: + for field := range typ.Fields() { + if field.IsExported() { + check(field.Type) + } + } + case reflect.Map, reflect.Interface: + t.Errorf("%s must not use open-ended maps or interfaces", typ) + } + } + for _, typ := range []reflect.Type{ + reflect.TypeFor[CodeQualityFindingOutput](), + reflect.TypeFor[CodeScanningAlertOutput](), + reflect.TypeFor[SecretScanningAlertOutput](), + reflect.TypeFor[DependabotAlertsOutput](), + reflect.TypeFor[GlobalSecurityAdvisoryOutput](), + } { + check(typ) + } +} From 8b2656a93bdecbdd3672dc8c558b5d5f7c18dc2f Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 7 Oct 2026 00:11:18 +0200 Subject: [PATCH 48/61] feat: type discussion and notification tool outputs (#3388) * feat: type discussion and notification tool outputs Migrate all eleven tools to typed registration and compact output DTOs. Preserve legacy text, parameter compatibility, pagination, and mutation validation while publishing honest modern output unions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix: tighten discussion and notification output contracts Trim notification hypermedia, preserve useful HTML links, and record all modern schemas in canonical snapshots. Preserve source presence during discussion read normalization and cover the Copilot review regression alongside empty/null outputs, IFC labels, sanitization, and output enums. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test: cover empty-object discussion read requests and modern-text==DTO expectations Add an explicit {} (fully-empty args) case to TestDiscussionReadRequiredArguments proving the normalizer never synthesizes placeholder owner/repo/discussionNumber values that would let an empty request satisfy SDK required-schema validation and reach GraphQL; verified both protocols reject it pre-handler with zero GraphQL calls (addresses readiness-audit re-flag of already-fixed Copilot review comment 4171698931 in 578e9929). Also record the pending modern-text==structuredContent DTO contract (2026-07-28 protocol) in test expectations; these assertions are intentionally red pending the coordinator's central once-marshal wrapper in pkg/inventory/typed_output.go, which this branch does not yet have. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(notifications): preserve plain status output content Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../discussion_comment_write.snap | 39 ++- .../__toolsnaps__/dismiss_notification.snap | 14 +- pkg/github/__toolsnaps__/get_discussion.snap | 59 +++- .../get_discussion_comments.snap | 94 +++++- .../get_notification_details.snap | 127 ++++++- .../list_discussion_categories.snap | 62 +++- .../__toolsnaps__/list_discussions.snap | 97 +++++- .../__toolsnaps__/list_notifications.snap | 136 +++++++- .../manage_notification_subscription.snap | 59 +++- ..._repository_notification_subscription.snap | 59 +++- .../mark_all_notifications_read.snap | 14 +- .../discussion_notification_contracts_test.go | 258 ++++++++++++++ pkg/github/discussion_notification_inputs.go | 183 ++++++++++ pkg/github/discussion_notification_outputs.go | 284 ++++++++++++++++ pkg/github/discussions.go | 196 +++++++---- pkg/github/notifications.go | 108 ++++-- ...ed_discussion_notification_outputs_test.go | 317 ++++++++++++++++++ 17 files changed, 1989 insertions(+), 117 deletions(-) create mode 100644 pkg/github/discussion_notification_contracts_test.go create mode 100644 pkg/github/discussion_notification_inputs.go create mode 100644 pkg/github/discussion_notification_outputs.go create mode 100644 pkg/github/typed_discussion_notification_outputs_test.go diff --git a/pkg/github/__toolsnaps__/discussion_comment_write.snap b/pkg/github/__toolsnaps__/discussion_comment_write.snap index eb730b48e7..7eb56a7c16 100644 --- a/pkg/github/__toolsnaps__/discussion_comment_write.snap +++ b/pkg/github/__toolsnaps__/discussion_comment_write.snap @@ -46,5 +46,42 @@ ], "type": "object" }, - "name": "discussion_comment_write" + "name": "discussion_comment_write", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "discussionID": { + "type": "string" + }, + "discussionURL": { + "type": "string" + } + }, + "required": [ + "discussionID", + "discussionURL" + ], + "type": "object" + } + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/dismiss_notification.snap b/pkg/github/__toolsnaps__/dismiss_notification.snap index 6b2e38afd7..16e6394fb2 100644 --- a/pkg/github/__toolsnaps__/dismiss_notification.snap +++ b/pkg/github/__toolsnaps__/dismiss_notification.snap @@ -26,5 +26,17 @@ ], "type": "object" }, - "name": "dismiss_notification" + "name": "dismiss_notification", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_discussion.snap b/pkg/github/__toolsnaps__/get_discussion.snap index 3f538d4e97..5269bc7b7e 100644 --- a/pkg/github/__toolsnaps__/get_discussion.snap +++ b/pkg/github/__toolsnaps__/get_discussion.snap @@ -27,5 +27,62 @@ ], "type": "object" }, - "name": "get_discussion" + "name": "get_discussion", + "outputSchema": { + "additionalProperties": false, + "properties": { + "answerChosenAt": { + "description": "Answer selection time (RFC3339), when selected.", + "type": [ + "null", + "string" + ] + }, + "body": { + "type": "string" + }, + "category": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "closed": { + "type": "boolean" + }, + "createdAt": { + "description": "Creation time (RFC3339).", + "type": "string" + }, + "html_url": { + "type": "string" + }, + "isAnswered": { + "type": "boolean" + }, + "number": { + "type": "integer" + }, + "title": { + "type": "string" + } + }, + "required": [ + "number", + "title", + "body", + "html_url", + "closed", + "isAnswered", + "createdAt", + "category" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_discussion_comments.snap b/pkg/github/__toolsnaps__/get_discussion_comments.snap index a2705c9bf1..59cd8beed2 100644 --- a/pkg/github/__toolsnaps__/get_discussion_comments.snap +++ b/pkg/github/__toolsnaps__/get_discussion_comments.snap @@ -41,5 +41,97 @@ ], "type": "object" }, - "name": "get_discussion_comments" + "name": "get_discussion_comments", + "outputSchema": { + "additionalProperties": false, + "properties": { + "comments": { + "items": { + "additionalProperties": false, + "properties": { + "body": { + "type": "string" + }, + "id": { + "type": "string" + }, + "isAnswer": { + "type": "boolean" + }, + "replies": { + "items": { + "additionalProperties": false, + "properties": { + "body": { + "type": "string" + }, + "id": { + "type": "string" + }, + "isAnswer": { + "type": "boolean" + } + }, + "required": [ + "id", + "body" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "replyTotalCount": { + "description": "Total replies, including replies beyond the returned maximum of 100.", + "type": "integer" + } + }, + "required": [ + "id", + "body" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "pageInfo": { + "additionalProperties": false, + "properties": { + "endCursor": { + "type": "string" + }, + "hasNextPage": { + "type": "boolean" + }, + "hasPreviousPage": { + "type": "boolean" + }, + "startCursor": { + "type": "string" + } + }, + "required": [ + "hasNextPage", + "hasPreviousPage", + "startCursor", + "endCursor" + ], + "type": "object" + }, + "totalCount": { + "type": "integer" + } + }, + "required": [ + "comments", + "pageInfo", + "totalCount" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_notification_details.snap b/pkg/github/__toolsnaps__/get_notification_details.snap index 2929ec2fc9..b2c8a3e4bc 100644 --- a/pkg/github/__toolsnaps__/get_notification_details.snap +++ b/pkg/github/__toolsnaps__/get_notification_details.snap @@ -17,5 +17,130 @@ ], "type": "object" }, - "name": "get_notification_details" + "name": "get_notification_details", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": [ + "null", + "string" + ] + }, + "last_read_at": { + "description": "Last acknowledgement time (RFC3339).", + "type": [ + "null", + "string" + ] + }, + "reason": { + "enum": [ + "approval_requested", + "assign", + "author", + "ci_activity", + "comment", + "invitation", + "manual", + "member_feature_requested", + "mention", + "review_requested", + "security_alert", + "security_advisory_credit", + "state_change", + "subscribed", + "team_mention" + ], + "type": [ + "null", + "string" + ] + }, + "repository": { + "additionalProperties": false, + "properties": { + "full_name": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "integer" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "private": { + "type": [ + "null", + "boolean" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "subject": { + "additionalProperties": false, + "properties": { + "title": { + "type": [ + "null", + "string" + ] + }, + "type": { + "enum": [ + "CheckSuite", + "Commit", + "Discussion", + "Issue", + "PullRequest", + "Release", + "RepositoryInvitation", + "SecurityAdvisory" + ], + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "unread": { + "type": [ + "null", + "boolean" + ] + }, + "updated_at": { + "description": "Last update time (RFC3339).", + "type": [ + "null", + "string" + ] + } + }, + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_discussion_categories.snap b/pkg/github/__toolsnaps__/list_discussion_categories.snap index 095973dc8b..69e5923f38 100644 --- a/pkg/github/__toolsnaps__/list_discussion_categories.snap +++ b/pkg/github/__toolsnaps__/list_discussion_categories.snap @@ -21,5 +21,65 @@ ], "type": "object" }, - "name": "list_discussion_categories" + "name": "list_discussion_categories", + "outputSchema": { + "additionalProperties": false, + "properties": { + "categories": { + "items": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "pageInfo": { + "additionalProperties": false, + "properties": { + "endCursor": { + "type": "string" + }, + "hasNextPage": { + "type": "boolean" + }, + "hasPreviousPage": { + "type": "boolean" + }, + "startCursor": { + "type": "string" + } + }, + "required": [ + "hasNextPage", + "hasPreviousPage", + "startCursor", + "endCursor" + ], + "type": "object" + }, + "totalCount": { + "type": "integer" + } + }, + "required": [ + "categories", + "pageInfo", + "totalCount" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_discussions.snap b/pkg/github/__toolsnaps__/list_discussions.snap index 6a2afd7dc4..ccb5fee0ff 100644 --- a/pkg/github/__toolsnaps__/list_discussions.snap +++ b/pkg/github/__toolsnaps__/list_discussions.snap @@ -51,5 +51,100 @@ ], "type": "object" }, - "name": "list_discussions" + "name": "list_discussions", + "outputSchema": { + "additionalProperties": false, + "properties": { + "discussions": { + "items": { + "additionalProperties": false, + "properties": { + "author": { + "type": "string" + }, + "category": { + "type": "string" + }, + "created_at": { + "description": "Creation time (RFC3339).", + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "title": { + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "description": "Last update time (RFC3339).", + "type": [ + "null", + "string" + ] + } + }, + "required": [ + "author", + "category" + ], + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + }, + "pageInfo": { + "additionalProperties": false, + "properties": { + "endCursor": { + "type": "string" + }, + "hasNextPage": { + "type": "boolean" + }, + "hasPreviousPage": { + "type": "boolean" + }, + "startCursor": { + "type": "string" + } + }, + "required": [ + "hasNextPage", + "hasPreviousPage", + "startCursor", + "endCursor" + ], + "type": "object" + }, + "totalCount": { + "type": "integer" + } + }, + "required": [ + "discussions", + "pageInfo", + "totalCount" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_notifications.snap b/pkg/github/__toolsnaps__/list_notifications.snap index bb75d60427..9b275ed753 100644 --- a/pkg/github/__toolsnaps__/list_notifications.snap +++ b/pkg/github/__toolsnaps__/list_notifications.snap @@ -46,5 +46,139 @@ }, "type": "object" }, - "name": "list_notifications" + "name": "list_notifications", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "id": { + "type": [ + "null", + "string" + ] + }, + "last_read_at": { + "description": "Last acknowledgement time (RFC3339).", + "type": [ + "null", + "string" + ] + }, + "reason": { + "enum": [ + "approval_requested", + "assign", + "author", + "ci_activity", + "comment", + "invitation", + "manual", + "member_feature_requested", + "mention", + "review_requested", + "security_alert", + "security_advisory_credit", + "state_change", + "subscribed", + "team_mention" + ], + "type": [ + "null", + "string" + ] + }, + "repository": { + "additionalProperties": false, + "properties": { + "full_name": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "integer" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "private": { + "type": [ + "null", + "boolean" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "subject": { + "additionalProperties": false, + "properties": { + "title": { + "type": [ + "null", + "string" + ] + }, + "type": { + "enum": [ + "CheckSuite", + "Commit", + "Discussion", + "Issue", + "PullRequest", + "Release", + "RepositoryInvitation", + "SecurityAdvisory" + ], + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "unread": { + "type": [ + "null", + "boolean" + ] + }, + "updated_at": { + "description": "Last update time (RFC3339).", + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "object", + "null" + ] + }, + "type": [ + "null", + "array" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/manage_notification_subscription.snap b/pkg/github/__toolsnaps__/manage_notification_subscription.snap index e062f2d25d..644090c8f1 100644 --- a/pkg/github/__toolsnaps__/manage_notification_subscription.snap +++ b/pkg/github/__toolsnaps__/manage_notification_subscription.snap @@ -28,5 +28,62 @@ ], "type": "object" }, - "name": "manage_notification_subscription" + "name": "manage_notification_subscription", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "subscription": { + "additionalProperties": false, + "properties": { + "created_at": { + "description": "Subscription creation time (RFC3339).", + "type": [ + "null", + "string" + ] + }, + "ignored": { + "type": [ + "null", + "boolean" + ] + }, + "reason": { + "type": [ + "null", + "string" + ] + }, + "subscribed": { + "type": [ + "null", + "boolean" + ] + } + }, + "type": "object" + } + }, + "required": [ + "subscription" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/manage_repository_notification_subscription.snap b/pkg/github/__toolsnaps__/manage_repository_notification_subscription.snap index 7f4464860d..7dd35c43a0 100644 --- a/pkg/github/__toolsnaps__/manage_repository_notification_subscription.snap +++ b/pkg/github/__toolsnaps__/manage_repository_notification_subscription.snap @@ -33,5 +33,62 @@ ], "type": "object" }, - "name": "manage_repository_notification_subscription" + "name": "manage_repository_notification_subscription", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "subscription": { + "additionalProperties": false, + "properties": { + "created_at": { + "description": "Subscription creation time (RFC3339).", + "type": [ + "null", + "string" + ] + }, + "ignored": { + "type": [ + "null", + "boolean" + ] + }, + "reason": { + "type": [ + "null", + "string" + ] + }, + "subscribed": { + "type": [ + "null", + "boolean" + ] + } + }, + "type": "object" + } + }, + "required": [ + "subscription" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/mark_all_notifications_read.snap b/pkg/github/__toolsnaps__/mark_all_notifications_read.snap index 42de313e4a..2f0fae03a4 100644 --- a/pkg/github/__toolsnaps__/mark_all_notifications_read.snap +++ b/pkg/github/__toolsnaps__/mark_all_notifications_read.snap @@ -22,5 +22,17 @@ }, "type": "object" }, - "name": "mark_all_notifications_read" + "name": "mark_all_notifications_read", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/discussion_notification_contracts_test.go b/pkg/github/discussion_notification_contracts_test.go new file mode 100644 index 0000000000..a9002eb457 --- /dev/null +++ b/pkg/github/discussion_notification_contracts_test.go @@ -0,0 +1,258 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/sanitize" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDiscussionReadRequiredArguments(t *testing.T) { + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + t.Run(protocol, func(t *testing.T) { + calls := 0 + deps := BaseDeps{GQLClient: githubv4.NewClient(&http.Client{ + Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + var request struct { + Variables map[string]any `json:"variables"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + assert.Equal(t, "", request.Variables["owner"]) + assert.Equal(t, "", request.Variables["repo"]) + assert.Equal(t, float64(0), request.Variables["discussionNumber"]) + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{ + "errors": []any{map[string]any{"message": "empty coordinates rejected by GitHub"}}, + })) + })}, + })} + server := mcp.NewServer(&mcp.Implementation{Name: "required-arguments-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + for _, tool := range []inventory.ServerTool{ + GetDiscussion(translations.NullTranslationHelper), + GetDiscussionComments(translations.NullTranslationHelper), + } { + tool.RegisterFunc(server, deps) + } + client := connectCommentVisibilityClient(t, server, protocol) + for _, name := range []string{"get_discussion", "get_discussion_comments"} { + // A wholly empty object must not be normalized into zero-valued + // owner/repo/discussionNumber keys that would satisfy the SDK's + // required-property validation and reach GraphQL. + before := calls + result, err := client.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: map[string]any{}}) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Nil(t, result.StructuredContent) + text := getTextResult(t, result).Text + assert.Contains(t, text, "owner") + assert.Contains(t, text, "repo") + assert.Contains(t, text, "discussionNumber") + assert.Equal(t, before, calls, "empty object must not reach GitHub") + + for _, missing := range []string{"owner", "repo", "discussionNumber"} { + args := map[string]any{"owner": "owner", "repo": "repo", "discussionNumber": "1"} + delete(args, missing) + before := calls + result, err := client.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args}) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Nil(t, result.StructuredContent) + assert.Contains(t, getTextResult(t, result).Text, missing) + assert.Equal(t, before, calls, "missing required property must not reach GitHub") + } + result, err = client.CallTool(context.Background(), &mcp.CallToolParams{ + Name: name, Arguments: map[string]any{"OWNER": "", "REPO": "", "DISCUSSIONNUMBER": 0}, + }) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, "empty coordinates rejected by GitHub") + } + assert.Equal(t, 2, calls, "present empty values retain main's WeakDecode/API behavior") + }) + } +} + +func TestDiscussionNotificationEmptyAndNullOutputs(t *testing.T) { + for _, tc := range []struct { + name string + api string + tool inventory.ServerTool + args map[string]any + legacy string + modern string + }{ + {"notifications null", "null", ListNotifications(translations.NullTranslationHelper), map[string]any{}, "null", "[]"}, + {"notifications empty", "[]", ListNotifications(translations.NullTranslationHelper), map[string]any{}, "[]", "[]"}, + {"notification null item", "[null]", ListNotifications(translations.NullTranslationHelper), map[string]any{}, "[null]", "[null]"}, + {"notification null detail", "null", GetNotificationDetails(translations.NullTranslationHelper), map[string]any{"notificationID": "123"}, "null", "{}"}, + } { + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + t.Run(tc.name+"/"+protocol, func(t *testing.T) { + deps := BaseDeps{Client: mustNewGHClient(t, &http.Client{ + Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(tc.api)) + require.NoError(t, err) + })}, + })} + server := mcp.NewServer(&mcp.Implementation{Name: "empty-output-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + tc.tool.RegisterFunc(server, deps) + client := connectCommentVisibilityClient(t, server, protocol) + result, err := client.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool.Tool.Name, Arguments: tc.args}) + require.NoError(t, err) + require.False(t, result.IsError) + if protocol == "2025-11-25" { + assert.Equal(t, tc.legacy, getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + } else { + assert.JSONEq(t, tc.modern, mustMarshalJSON(t, result.StructuredContent)) + assert.JSONEq(t, tc.modern, getTextResult(t, result).Text) + resolved, err := tc.tool.Tool.OutputSchema.(*jsonschema.Schema).Resolve(nil) + require.NoError(t, err) + var output any + require.NoError(t, json.Unmarshal([]byte(tc.modern), &output)) + assert.NoError(t, resolved.Validate(output)) + } + }) + } + } +} + +func TestDiscussionStructuredSanitizationMatchesLegacy(t *testing.T) { + const title = "Discussion" + const body = "Discussion body" + deps := BaseDeps{GQLClient: githubv4.NewClient(&http.Client{ + Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{ + "repository": map[string]any{"discussion": map[string]any{ + "number": 1, "title": title, "body": body, + "createdAt": "2026-01-01T00:00:00Z", "category": map[string]any{"name": "General"}, + }}, + }})) + })}, + })} + server := mcp.NewServer(&mcp.Implementation{Name: "sanitized-output-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + tool := GetDiscussion(translations.NullTranslationHelper) + tool.RegisterFunc(server, deps) + client := connectCommentVisibilityClient(t, server, inventory.ProtocolVersionMultiRoundTrip) + result, err := client.CallTool(context.Background(), &mcp.CallToolParams{ + Name: tool.Tool.Name, Arguments: map[string]any{"owner": "owner", "repo": "repo", "discussionNumber": 1}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + var legacy, modern map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &legacy)) + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &modern)) + assert.Equal(t, legacy, modern, "modern JSON text and structured DTO must match") + assert.Equal(t, sanitize.PlainText(title), modern["title"]) + assert.Equal(t, sanitize.Content(body), modern["body"]) + assert.Equal(t, legacy["title"], modern["title"]) + assert.Equal(t, legacy["body"], modern["body"]) +} + +func TestNotificationProjectionTrimsHypermedia(t *testing.T) { + notification := &github.Notification{ + ID: new("123"), URL: new("https://api.github.com/notifications/threads/123"), + Subject: &github.NotificationSubject{ + Title: new("Title"), Type: new("Issue"), + URL: new("https://api.github.com/repos/owner/repo/issues/1"), + LatestCommentURL: new("https://api.github.com/repos/owner/repo/issues/comments/2"), + }, + } + // The pre-quality DTO exposed these API links; legacy output still does. + before := `{"id":"123","subject":{"title":"Title","url":"https://api.github.com/repos/owner/repo/issues/1","latest_comment_url":"https://api.github.com/repos/owner/repo/issues/comments/2","type":"Issue"},"url":"https://api.github.com/notifications/threads/123"}` + after := mustMarshalJSON(t, notificationOutput(notification)) + assert.JSONEq(t, `{"id":"123","subject":{"title":"Title","type":"Issue"}}`, after) + assert.Less(t, len(after), len(before)) + assert.Contains(t, mustMarshalJSON(t, notification), "latest_comment_url", "legacy formatter must retain its existing links") + t.Logf("populated notification projection: %d -> %d JSON bytes", len(before), len(after)) +} + +func TestDiscussionEmptyCollections(t *testing.T) { + deps := BaseDeps{GQLClient: githubv4.NewClient(&http.Client{ + Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + Query string `json:"query"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + repository := map[string]any{"discussions": map[string]any{"nodes": nil}} + if strings.Contains(request.Query, "discussionCategories(") { + repository = map[string]any{"discussionCategories": map[string]any{"nodes": nil}} + } else if strings.Contains(request.Query, "comments(") { + repository = map[string]any{"discussion": map[string]any{"comments": map[string]any{"nodes": nil}}} + } + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{ + "repository": repository, + }})) + })}, + })} + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + t.Run(protocol, func(t *testing.T) { + server := mcp.NewServer(&mcp.Implementation{Name: "empty-collections-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + for _, tool := range []inventory.ServerTool{ + ListDiscussions(translations.NullTranslationHelper), + GetDiscussionComments(translations.NullTranslationHelper), + ListDiscussionCategories(translations.NullTranslationHelper), + } { + tool.RegisterFunc(server, deps) + } + client := connectCommentVisibilityClient(t, server, protocol) + for _, tc := range []struct { + name, collection string + }{ + {"list_discussions", "discussions"}, + {"get_discussion_comments", "comments"}, + {"list_discussion_categories", "categories"}, + } { + result, err := client.CallTool(context.Background(), &mcp.CallToolParams{ + Name: tc.name, Arguments: map[string]any{"owner": "owner", "repo": "repo", "discussionNumber": 1}, + }) + require.NoError(t, err) + require.False(t, result.IsError, "%s", result) + var text map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &text)) + if protocol == "2025-11-25" { + assert.Nil(t, text[tc.collection], "legacy nil collections retain JSON null") + assert.Nil(t, result.StructuredContent) + } else { + var modern map[string]any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &modern)) + assert.Equal(t, []any{}, modern[tc.collection]) + assert.Equal(t, text, modern, "modern empty collections use the same DTO in both representations") + } + } + }) + } +} + +func TestNotificationOutputEnumsAndHypermediaSchemas(t *testing.T) { + schema := notificationOutputSchema(false) + assert.NotContains(t, schema.Properties, "url") + assert.NotContains(t, schema.Properties["subject"].Properties, "url") + assert.NotContains(t, schema.Properties["subject"].Properties, "latest_comment_url") + assert.Contains(t, schema.Properties["repository"].Properties, "html_url") + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + assert.NoError(t, resolved.Validate(map[string]any{"reason": "mention", "subject": map[string]any{"type": "Discussion"}})) + assert.Error(t, resolved.Validate(map[string]any{"reason": "invalid"})) + assert.Error(t, resolved.Validate(map[string]any{"subject": map[string]any{"type": "invalid"}})) + subscription := discussionNotificationOutputSchema[NotificationSubscriptionOutput]() + for _, field := range []string{"url", "thread_url", "repository_url"} { + assert.NotContains(t, subscription.Properties, field) + } +} diff --git a/pkg/github/discussion_notification_inputs.go b/pkg/github/discussion_notification_inputs.go new file mode 100644 index 0000000000..6204bfc93d --- /dev/null +++ b/pkg/github/discussion_notification_inputs.go @@ -0,0 +1,183 @@ +package github + +import ( + "encoding/json" + "strings" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/go-viper/mapstructure/v2" +) + +type ListNotificationsInput struct { + Filter *string `json:"filter,omitempty"` + Since *string `json:"since,omitempty"` + Before *string `json:"before,omitempty"` + Owner *string `json:"owner,omitempty"` + Repo *string `json:"repo,omitempty"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` + After *string `json:"after,omitempty"` +} + +type DismissNotificationInput struct { + ThreadID string `json:"threadID"` + State string `json:"state"` +} + +type MarkAllNotificationsReadInput struct { + LastReadAt *string `json:"lastReadAt,omitempty"` + Owner *string `json:"owner,omitempty"` + Repo *string `json:"repo,omitempty"` +} + +type GetNotificationDetailsInput struct { + NotificationID string `json:"notificationID"` +} + +type ManageNotificationSubscriptionInput struct { + NotificationID string `json:"notificationID"` + Action string `json:"action"` +} + +type ManageRepositoryNotificationSubscriptionInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Action string `json:"action"` +} + +type ListDiscussionsInput struct { + Owner string `json:"owner"` + Repo *string `json:"repo,omitempty"` + Category *string `json:"category,omitempty"` + OrderBy *string `json:"orderBy,omitempty"` + Direction *string `json:"direction,omitempty"` + PerPage *int `json:"perPage,omitempty"` + After *string `json:"after,omitempty"` +} + +type GetDiscussionInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + DiscussionNumber int32 `json:"discussionNumber"` +} + +type GetDiscussionCommentsInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + DiscussionNumber int32 `json:"discussionNumber"` + IncludeReplies *bool `json:"includeReplies,omitempty"` + PerPage *int `json:"perPage,omitempty"` + After *string `json:"after,omitempty"` +} + +type DiscussionCommentWriteInput struct { + Method string `json:"method"` + Owner *string `json:"owner,omitempty"` + Repo *string `json:"repo,omitempty"` + DiscussionNumber *int `json:"discussionNumber,omitempty"` + Body *string `json:"body,omitempty"` + CommentNodeID *string `json:"commentNodeID,omitempty"` +} + +type ListDiscussionCategoriesInput struct { + Owner string `json:"owner"` + Repo *string `json:"repo,omitempty"` +} + +// Preserve presence and the existing parameter validators at the compatibility +// boundary. In particular, an explicit perPage: 0 differs from an omitted value +// for discussion comments, and mutation parameters are required per method. +func discussionNotificationArguments(input any) (map[string]any, error) { + raw, err := json.Marshal(input) + if err != nil { + return nil, err + } + var args map[string]any + err = json.Unmarshal(raw, &args) + return args, err +} + +func normalizeDiscussionReadArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + // These two tools historically used WeakDecode: retain its case-insensitive + // field matching, numeric-string conversion and fractional truncation. + var input GetDiscussionInput + if err := mapstructure.WeakDecode(args, &input); err != nil { + return nil, err + } + for field, value := range map[string]any{ + "owner": input.Owner, "repo": input.Repo, "discussionNumber": input.DiscussionNumber, + } { + for source := range args { + if strings.EqualFold(source, field) { + args[field] = value + break + } + } + } + return json.Marshal(args) +} + +func normalizeDiscussionNotificationIntegers(fields ...string) inventory.InputNormalizer { + return func(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + + for _, field := range fields { + if _, ok := args[field]; !ok { + continue + } + value, err := OptionalIntParam(args, field) + if err != nil { + return nil, err + } + if value == 0 { + switch field { + case "perPage": + value = 30 + case "page": + value = 1 + } + } + args[field] = value + } + return json.Marshal(args) + } +} + +func normalizeDiscussionCommentWriteArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + method, _ := args["method"].(string) + used := map[string]bool{"method": true} + switch method { + case "add", "reply": + used["owner"], used["repo"], used["discussionNumber"], used["body"] = true, true, true, true + if method == "reply" { + used["commentNodeID"] = true + } + case "update": + used["commentNodeID"], used["body"] = true, true + case "delete", "mark_answer", "unmark_answer": + used["commentNodeID"] = true + } + // Unused method-specific parameters were never decoded by the legacy + // handler, even when their values did not match the advertised schema. + for _, field := range []string{"owner", "repo", "discussionNumber", "body", "commentNodeID"} { + if !used[field] { + delete(args, field) + } + } + normalized, err := json.Marshal(args) + if err != nil { + return nil, err + } + return normalizeDiscussionNotificationIntegers("discussionNumber")(normalized) +} diff --git a/pkg/github/discussion_notification_outputs.go b/pkg/github/discussion_notification_outputs.go new file mode 100644 index 0000000000..47f8f47344 --- /dev/null +++ b/pkg/github/discussion_notification_outputs.go @@ -0,0 +1,284 @@ +package github + +import ( + "encoding/json" + "time" + + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" +) + +type NotificationSubjectOutput struct { + Title *string `json:"title,omitempty"` + Type *string `json:"type,omitempty"` +} + +type NotificationRepositoryOutput struct { + ID *int64 `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + FullName *string `json:"full_name,omitempty"` + Private *bool `json:"private,omitempty"` + HTMLURL *string `json:"html_url,omitempty"` +} + +type NotificationOutput struct { + ID *string `json:"id,omitempty"` + Repository *NotificationRepositoryOutput `json:"repository,omitempty"` + Subject *NotificationSubjectOutput `json:"subject,omitempty"` + Reason *string `json:"reason,omitempty"` + Unread *bool `json:"unread,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty" jsonschema:"Last update time (RFC3339)."` + LastReadAt *time.Time `json:"last_read_at,omitempty" jsonschema:"Last acknowledgement time (RFC3339)."` +} + +func notificationOutput(n *github.Notification) *NotificationOutput { + if n == nil { + return nil + } + out := &NotificationOutput{ + ID: n.ID, Reason: n.Reason, Unread: n.Unread, + } + if n.UpdatedAt != nil { + out.UpdatedAt = &n.UpdatedAt.Time + } + if n.LastReadAt != nil { + out.LastReadAt = &n.LastReadAt.Time + } + if n.Subject != nil { + out.Subject = &NotificationSubjectOutput{ + Title: n.Subject.Title, Type: n.Subject.Type, + } + } + if n.Repository != nil { + out.Repository = &NotificationRepositoryOutput{ + ID: n.Repository.ID, Name: n.Repository.Name, FullName: n.Repository.FullName, + Private: n.Repository.Private, HTMLURL: n.Repository.HTMLURL, + } + } + return out +} + +type NotificationStatusOutput struct { + Message string `json:"message"` +} + +type NotificationSubscriptionOutput struct { + Subscribed *bool `json:"subscribed,omitempty"` + Ignored *bool `json:"ignored,omitempty"` + Reason *string `json:"reason,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty" jsonschema:"Subscription creation time (RFC3339)."` +} + +type NotificationSubscriptionResult struct { + Subscription *NotificationSubscriptionOutput `json:"subscription,omitempty"` + Message string `json:"message,omitempty"` +} + +func (out NotificationSubscriptionResult) MarshalJSON() ([]byte, error) { + if out.Subscription != nil { + return json.Marshal(struct { + Subscription *NotificationSubscriptionOutput `json:"subscription"` + }{out.Subscription}) + } + return json.Marshal(NotificationStatusOutput{Message: out.Message}) +} + +func notificationSubscriptionOutput(s *github.Subscription) *NotificationSubscriptionResult { + out := &NotificationSubscriptionOutput{} + if s != nil { + out.Subscribed = s.Subscribed + out.Ignored = s.Ignored + out.Reason = s.Reason + if s.CreatedAt != nil { + out.CreatedAt = &s.CreatedAt.Time + } + } + return &NotificationSubscriptionResult{Subscription: out} +} + +func notificationSubscriptionResultSchema() *jsonschema.Schema { + subscription := discussionNotificationOutputSchema[NotificationSubscriptionOutput]() + return &jsonschema.Schema{ + Type: "object", + OneOf: []*jsonschema.Schema{ + { + Type: "object", + Properties: map[string]*jsonschema.Schema{"subscription": subscription}, + Required: []string{"subscription"}, + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + }, + { + Type: "object", + Properties: map[string]*jsonschema.Schema{"message": {Type: "string"}}, + Required: []string{"message"}, + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + }, + }, + } + +} + +func discussionNotificationOutputSchema[Out any]() *jsonschema.Schema { + schema, err := jsonschema.For[Out](nil) + if err != nil { + panic(err) + } + return schema +} + +func notificationOutputSchema(list bool) *jsonschema.Schema { + schema := discussionNotificationOutputSchema[NotificationOutput]() + schema.Properties["subject"].Properties["type"].Enum = []any{ + "CheckSuite", "Commit", "Discussion", "Issue", "PullRequest", "Release", + "RepositoryInvitation", "SecurityAdvisory", + } + schema.Properties["reason"].Enum = []any{ + "approval_requested", "assign", "author", "ci_activity", "comment", + "invitation", "manual", "member_feature_requested", "mention", + "review_requested", "security_alert", "security_advisory_credit", + "state_change", "subscribed", "team_mention", + } + if list { + schema.Type = "" + schema.Types = []string{"object", "null"} + listSchema := discussionNotificationOutputSchema[[]*NotificationOutput]() + listSchema.Items = schema + return listSchema + } + return schema +} + +type DiscussionPageInfoOutput struct { + HasNextPage bool `json:"hasNextPage"` + HasPreviousPage bool `json:"hasPreviousPage"` + StartCursor string `json:"startCursor"` + EndCursor string `json:"endCursor"` +} + +type DiscussionListItemOutput struct { + Number *int `json:"number,omitempty"` + Title *string `json:"title,omitempty"` + HTMLURL *string `json:"html_url,omitempty"` + CreatedAt *time.Time `json:"created_at,omitempty" jsonschema:"Creation time (RFC3339)."` + UpdatedAt *time.Time `json:"updated_at,omitempty" jsonschema:"Last update time (RFC3339)."` + Author string `json:"author"` + Category string `json:"category"` +} + +type ListDiscussionsOutput struct { + Discussions []*DiscussionListItemOutput `json:"discussions"` + PageInfo DiscussionPageInfoOutput `json:"pageInfo"` + TotalCount int `json:"totalCount"` +} + +type DiscussionCategoryOutput struct { + Name string `json:"name"` +} + +type DiscussionOutput struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + HTMLURL string `json:"html_url"` + Closed bool `json:"closed"` + IsAnswered bool `json:"isAnswered"` + CreatedAt time.Time `json:"createdAt" jsonschema:"Creation time (RFC3339)."` + Category DiscussionCategoryOutput `json:"category"` + AnswerChosenAt *time.Time `json:"answerChosenAt,omitempty" jsonschema:"Answer selection time (RFC3339), when selected."` +} + +type DiscussionReplyOutput struct { + ID string `json:"id"` + Body string `json:"body"` + IsAnswer bool `json:"isAnswer,omitempty"` +} + +type DiscussionCommentOutput struct { + ID string `json:"id"` + Body string `json:"body"` + IsAnswer bool `json:"isAnswer,omitempty"` + Replies []DiscussionReplyOutput `json:"replies,omitempty"` + ReplyTotalCount int `json:"replyTotalCount,omitempty" jsonschema:"Total replies, including replies beyond the returned maximum of 100."` +} + +type DiscussionCommentsOutput struct { + Comments []DiscussionCommentOutput `json:"comments"` + PageInfo DiscussionPageInfoOutput `json:"pageInfo"` + TotalCount int `json:"totalCount"` +} + +func discussionCommentOutputs(comments []MinimalDiscussionComment) []DiscussionCommentOutput { + out := make([]DiscussionCommentOutput, 0, len(comments)) + for _, comment := range comments { + item := DiscussionCommentOutput{ + ID: comment.ID, Body: comment.Body, IsAnswer: comment.IsAnswer, + ReplyTotalCount: comment.ReplyTotalCount, + } + for _, reply := range comment.Replies { + item.Replies = append(item.Replies, DiscussionReplyOutput{ + ID: reply.ID, Body: reply.Body, IsAnswer: reply.IsAnswer, + }) + } + out = append(out, item) + } + return out +} + +type DiscussionCategoryItemOutput struct { + ID string `json:"id"` + Name string `json:"name"` +} + +type DiscussionCategoriesOutput struct { + Categories []DiscussionCategoryItemOutput `json:"categories"` + PageInfo DiscussionPageInfoOutput `json:"pageInfo"` + TotalCount int `json:"totalCount"` +} + +type DiscussionAnswerOutput struct { + DiscussionID string `json:"discussionID"` + DiscussionURL string `json:"discussionURL"` +} + +// The comment mutations return id/url; answer mutations return a discussion +// reference instead. Marshal the selected concrete variant without an envelope. +type DiscussionCommentWriteOutput struct { + Comment *MinimalResponse + Answer *DiscussionAnswerOutput +} + +func (out DiscussionCommentWriteOutput) MarshalJSON() ([]byte, error) { + if out.Answer != nil { + return json.Marshal(out.Answer) + } + if out.Comment != nil { + return json.Marshal(out.Comment) + } + // The SDK validates an unpointered zero value even for IsError results. + // The inventory middleware removes this zero output before it is sent. + return json.Marshal(MinimalResponse{}) +} + +func discussionCommentWriteOutputSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "object", + OneOf: []*jsonschema.Schema{ + { + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "id": {Type: "string"}, "url": {Type: "string"}, + }, + Required: []string{"id", "url"}, + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + }, + { + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "discussionID": {Type: "string"}, "discussionURL": {Type: "string"}, + }, + Required: []string{"discussionID", "discussionURL"}, + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + }, + }, + } +} diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index 2912ee559b..9125d99329 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -12,7 +12,6 @@ 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/go-viper/mapstructure/v2" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -127,11 +126,12 @@ func getQueryType(useOrdering bool, categoryID *githubv4.ID) any { } func ListDiscussions(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[ListDiscussionsInput, *ListDiscussionsOutput]( ToolsetMetadataDiscussions, mcp.Tool{ - Name: "list_discussions", - Description: t("TOOL_LIST_DISCUSSIONS_DESCRIPTION", "List discussions for a repository or organisation."), + Name: "list_discussions", + OutputSchema: discussionNotificationOutputSchema[ListDiscussionsOutput](), + Description: t("TOOL_LIST_DISCUSSIONS_DESCRIPTION", "List discussions for a repository or organisation."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_LIST_DISCUSSIONS_USER_TITLE", "List discussions"), ReadOnlyHint: true, @@ -166,7 +166,11 @@ func ListDiscussions(t translations.TranslationHelperFunc) inventory.ServerTool }), }, scopes.PublicRead(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input ListDiscussionsInput) (*mcp.CallToolResult, *ListDiscussionsOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -278,17 +282,34 @@ func ListDiscussions(t translations.TranslationHelperFunc) inventory.ServerTool // Discussion content is user-authored (untrusted); confidentiality // follows repo visibility. result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoUserContent) - return result, nil, nil + items := make([]*DiscussionListItemOutput, 0, len(discussions)) + for _, discussion := range discussions { + items = append(items, &DiscussionListItemOutput{ + Number: discussion.Number, Title: discussion.Title, HTMLURL: discussion.HTMLURL, + CreatedAt: &discussion.CreatedAt.Time, UpdatedAt: &discussion.UpdatedAt.Time, + Author: discussion.User.GetLogin(), Category: discussion.DiscussionCategory.GetName(), + }) + } + return result, &ListDiscussionsOutput{ + Discussions: items, + PageInfo: DiscussionPageInfoOutput{ + HasNextPage: pageInfo.HasNextPage, HasPreviousPage: pageInfo.HasPreviousPage, + StartCursor: string(pageInfo.StartCursor), EndCursor: string(pageInfo.EndCursor), + }, + TotalCount: int(totalCount), + }, nil }, + normalizeDiscussionNotificationIntegers("perPage"), ) } func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[GetDiscussionInput, *DiscussionOutput]( ToolsetMetadataDiscussions, mcp.Tool{ - Name: "get_discussion", - Description: t("TOOL_GET_DISCUSSION_DESCRIPTION", "Get a specific discussion by ID"), + Name: "get_discussion", + OutputSchema: discussionNotificationOutputSchema[DiscussionOutput](), + Description: t("TOOL_GET_DISCUSSION_DESCRIPTION", "Get a specific discussion by ID"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_DISCUSSION_USER_TITLE", "Get discussion"), ReadOnlyHint: true, @@ -313,16 +334,7 @@ func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool { }, }, scopes.PublicRead(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - // Decode params - var params struct { - Owner string - Repo string - DiscussionNumber int32 - } - if err := mapstructure.WeakDecode(args, ¶ms); err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, params GetDiscussionInput) (*mcp.CallToolResult, *DiscussionOutput, error) { client, err := deps.GetGQLClient(ctx) if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil @@ -386,17 +398,28 @@ func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool { // Discussion content is user-authored (untrusted); confidentiality // follows repo visibility. result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelRepoUserContent) - return result, nil, nil + output := &DiscussionOutput{ + Number: int(d.Number), Title: sanitize.PlainText(string(d.Title)), + Body: sanitize.Content(string(d.Body)), HTMLURL: string(d.URL), + Closed: bool(d.Closed), IsAnswered: bool(d.IsAnswered), CreatedAt: d.CreatedAt.Time, + Category: DiscussionCategoryOutput{Name: string(d.Category.Name)}, + } + if d.AnswerChosenAt != nil { + output.AnswerChosenAt = &d.AnswerChosenAt.Time + } + return result, output, nil }, + normalizeDiscussionReadArguments, ) } func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[GetDiscussionCommentsInput, *DiscussionCommentsOutput]( ToolsetMetadataDiscussions, mcp.Tool{ - Name: "get_discussion_comments", - Description: t("TOOL_GET_DISCUSSION_COMMENTS_DESCRIPTION", "Get comments from a discussion"), + Name: "get_discussion_comments", + OutputSchema: discussionNotificationOutputSchema[DiscussionCommentsOutput](), + Description: t("TOOL_GET_DISCUSSION_COMMENTS_DESCRIPTION", "Get comments from a discussion"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_DISCUSSION_COMMENTS_USER_TITLE", "Get discussion comments"), ReadOnlyHint: true, @@ -425,15 +448,10 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve }), }, scopes.PublicRead(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - // Decode params - var params struct { - Owner string - Repo string - DiscussionNumber int32 - } - if err := mapstructure.WeakDecode(args, ¶ms); err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, params GetDiscussionCommentsInput) (*mcp.CallToolResult, *DiscussionCommentsOutput, error) { + args, err := discussionNotificationArguments(params) + if err != nil { + return nil, nil, err } includeReplies, err := OptionalParam[bool](args, "includeReplies") @@ -582,16 +600,26 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve // Discussion comments are user-authored (untrusted); confidentiality // follows repo visibility. result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelRepoUserContent) - return result, nil, nil + return result, &DiscussionCommentsOutput{ + Comments: discussionCommentOutputs(comments), + PageInfo: DiscussionPageInfoOutput{ + HasNextPage: bool(pageInfo.HasNextPage), HasPreviousPage: bool(pageInfo.HasPreviousPage), + StartCursor: string(pageInfo.StartCursor), EndCursor: string(pageInfo.EndCursor), + }, + TotalCount: totalCount, + }, nil }, + normalizeDiscussionReadArguments, + normalizeDiscussionNotificationIntegers("perPage"), ) } func DiscussionCommentWrite(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[DiscussionCommentWriteInput, *DiscussionCommentWriteOutput]( ToolsetMetadataDiscussions, mcp.Tool{ - Name: "discussion_comment_write", + Name: "discussion_comment_write", + OutputSchema: discussionCommentWriteOutputSchema(), Description: t("TOOL_DISCUSSION_COMMENT_WRITE_DESCRIPTION", `Write operations for discussion comments. Supports adding top-level comments, replying to existing comments, updating comment content, deleting comments, and marking or unmarking comments as the answer.`), Annotations: &mcp.ToolAnnotations{ @@ -640,7 +668,11 @@ Options are: }, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input DiscussionCommentWriteInput) (*mcp.CallToolResult, *DiscussionCommentWriteOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } method, err := RequiredParam[string](args, "method") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -667,10 +699,12 @@ Options are: default: return utils.NewToolResultError("invalid method, must be one of: 'add', 'reply', 'update', 'delete', 'mark_answer', 'unmark_answer'"), nil, nil } - }) + }, + normalizeDiscussionCommentWriteArguments, + ) } -func addDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { +func addDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, *DiscussionCommentWriteOutput, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -724,15 +758,16 @@ func addDiscussionComment(ctx context.Context, client *githubv4.Client, args map } comment := mutation.AddDiscussionComment.Comment - out, err := json.Marshal(MinimalResponse{ + output := &MinimalResponse{ ID: fmt.Sprintf("%v", comment.ID), URL: string(comment.URL), - }) + } + out, err := json.Marshal(output) if err != nil { return nil, nil, fmt.Errorf("failed to marshal comment: %w", err) } - return utils.NewToolResultText(string(out)), nil, nil + return utils.NewToolResultText(string(out)), &DiscussionCommentWriteOutput{Comment: output}, nil } func requiredCommentNodeID(args map[string]any) (string, error) { @@ -746,7 +781,7 @@ func requiredCommentNodeID(args map[string]any) (string, error) { return commentNodeID, nil } -func replyToDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { +func replyToDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, *DiscussionCommentWriteOutput, error) { commentNodeID, err := requiredCommentNodeID(args) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -834,18 +869,19 @@ func replyToDiscussionComment(ctx context.Context, client *githubv4.Client, args } comment := mutation.AddDiscussionComment.Comment - out, err := json.Marshal(MinimalResponse{ + output := &MinimalResponse{ ID: fmt.Sprintf("%v", comment.ID), URL: string(comment.URL), - }) + } + out, err := json.Marshal(output) if err != nil { return nil, nil, fmt.Errorf("failed to marshal comment: %w", err) } - return utils.NewToolResultText(string(out)), nil, nil + return utils.NewToolResultText(string(out)), &DiscussionCommentWriteOutput{Comment: output}, nil } -func updateDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { +func updateDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, *DiscussionCommentWriteOutput, error) { commentNodeID, err := requiredCommentNodeID(args) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -874,18 +910,19 @@ func updateDiscussionComment(ctx context.Context, client *githubv4.Client, args } comment := mutation.UpdateDiscussionComment.Comment - out, err := json.Marshal(MinimalResponse{ + output := &MinimalResponse{ ID: fmt.Sprintf("%v", comment.ID), URL: string(comment.URL), - }) + } + out, err := json.Marshal(output) if err != nil { return nil, nil, fmt.Errorf("failed to marshal comment: %w", err) } - return utils.NewToolResultText(string(out)), nil, nil + return utils.NewToolResultText(string(out)), &DiscussionCommentWriteOutput{Comment: output}, nil } -func deleteDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { +func deleteDiscussionComment(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, *DiscussionCommentWriteOutput, error) { commentNodeID, err := requiredCommentNodeID(args) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -909,18 +946,19 @@ func deleteDiscussionComment(ctx context.Context, client *githubv4.Client, args } comment := mutation.DeleteDiscussionComment.Comment - out, err := json.Marshal(MinimalResponse{ + output := &MinimalResponse{ ID: fmt.Sprintf("%v", comment.ID), URL: string(comment.URL), - }) + } + out, err := json.Marshal(output) if err != nil { return nil, nil, fmt.Errorf("failed to marshal comment: %w", err) } - return utils.NewToolResultText(string(out)), nil, nil + return utils.NewToolResultText(string(out)), &DiscussionCommentWriteOutput{Comment: output}, nil } -func markDiscussionCommentAsAnswer(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { +func markDiscussionCommentAsAnswer(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, *DiscussionCommentWriteOutput, error) { commentNodeID, err := requiredCommentNodeID(args) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -941,21 +979,19 @@ func markDiscussionCommentAsAnswer(ctx context.Context, client *githubv4.Client, return utils.NewToolResultError(err.Error()), nil, nil } - out, err := json.Marshal(struct { - DiscussionID string `json:"discussionID"` - DiscussionURL string `json:"discussionURL"` - }{ + output := &DiscussionAnswerOutput{ DiscussionID: fmt.Sprintf("%v", mutation.MarkDiscussionCommentAsAnswer.Discussion.ID), DiscussionURL: string(mutation.MarkDiscussionCommentAsAnswer.Discussion.URL), - }) + } + out, err := json.Marshal(output) if err != nil { return nil, nil, fmt.Errorf("failed to marshal discussion: %w", err) } - return utils.NewToolResultText(string(out)), nil, nil + return utils.NewToolResultText(string(out)), &DiscussionCommentWriteOutput{Answer: output}, nil } -func unmarkDiscussionCommentAsAnswer(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, any, error) { +func unmarkDiscussionCommentAsAnswer(ctx context.Context, client *githubv4.Client, args map[string]any) (*mcp.CallToolResult, *DiscussionCommentWriteOutput, error) { commentNodeID, err := requiredCommentNodeID(args) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -976,26 +1012,25 @@ func unmarkDiscussionCommentAsAnswer(ctx context.Context, client *githubv4.Clien return utils.NewToolResultError(err.Error()), nil, nil } - out, err := json.Marshal(struct { - DiscussionID string `json:"discussionID"` - DiscussionURL string `json:"discussionURL"` - }{ + output := &DiscussionAnswerOutput{ DiscussionID: fmt.Sprintf("%v", mutation.UnmarkDiscussionCommentAsAnswer.Discussion.ID), DiscussionURL: string(mutation.UnmarkDiscussionCommentAsAnswer.Discussion.URL), - }) + } + out, err := json.Marshal(output) if err != nil { return nil, nil, fmt.Errorf("failed to marshal discussion: %w", err) } - return utils.NewToolResultText(string(out)), nil, nil + return utils.NewToolResultText(string(out)), &DiscussionCommentWriteOutput{Answer: output}, nil } func ListDiscussionCategories(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[ListDiscussionCategoriesInput, *DiscussionCategoriesOutput]( ToolsetMetadataDiscussions, mcp.Tool{ - Name: "list_discussion_categories", - Description: t("TOOL_LIST_DISCUSSION_CATEGORIES_DESCRIPTION", "List discussion categories with their id and name, for a repository or organisation."), + Name: "list_discussion_categories", + OutputSchema: discussionNotificationOutputSchema[DiscussionCategoriesOutput](), + Description: t("TOOL_LIST_DISCUSSION_CATEGORIES_DESCRIPTION", "List discussion categories with their id and name, for a repository or organisation."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_LIST_DISCUSSION_CATEGORIES_USER_TITLE", "List discussion categories"), ReadOnlyHint: true, @@ -1016,7 +1051,11 @@ func ListDiscussionCategories(t translations.TranslationHelperFunc) inventory.Se }, }, scopes.PublicRead(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input ListDiscussionCategoriesInput) (*mcp.CallToolResult, *DiscussionCategoriesOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -1090,7 +1129,20 @@ func ListDiscussionCategories(t translations.TranslationHelperFunc) inventory.Se // Discussion categories are repo-defined structural metadata // (trusted); confidentiality follows repo visibility. result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoMetadata) - return result, nil, nil + items := make([]DiscussionCategoryItemOutput, 0, len(categories)) + for _, category := range categories { + items = append(items, DiscussionCategoryItemOutput{ID: category["id"], Name: category["name"]}) + } + return result, &DiscussionCategoriesOutput{ + Categories: items, + PageInfo: DiscussionPageInfoOutput{ + HasNextPage: bool(q.Repository.DiscussionCategories.PageInfo.HasNextPage), + HasPreviousPage: bool(q.Repository.DiscussionCategories.PageInfo.HasPreviousPage), + StartCursor: string(q.Repository.DiscussionCategories.PageInfo.StartCursor), + EndCursor: string(q.Repository.DiscussionCategories.PageInfo.EndCursor), + }, + TotalCount: q.Repository.DiscussionCategories.TotalCount, + }, nil }, ) } diff --git a/pkg/github/notifications.go b/pkg/github/notifications.go index 77cf9a68b0..108e42e290 100644 --- a/pkg/github/notifications.go +++ b/pkg/github/notifications.go @@ -27,11 +27,12 @@ const ( // ListNotifications creates a tool to list notifications for the current user. func ListNotifications(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[ListNotificationsInput, []*NotificationOutput]( ToolsetMetadataNotifications, mcp.Tool{ - Name: "list_notifications", - Description: t("TOOL_LIST_NOTIFICATIONS_DESCRIPTION", "Lists all GitHub notifications for the authenticated user, including unread notifications, mentions, review requests, assignments, and updates on issues or pull requests. Use this tool whenever the user asks what to work on next, requests a summary of their GitHub activity, wants to see pending reviews, or needs to check for new updates or tasks. This tool is the primary way to discover actionable items, reminders, and outstanding work on GitHub. Always call this tool when asked what to work on next, what is pending, or what needs attention in GitHub."), + Name: "list_notifications", + OutputSchema: notificationOutputSchema(true), + Description: t("TOOL_LIST_NOTIFICATIONS_DESCRIPTION", "Lists all GitHub notifications for the authenticated user, including unread notifications, mentions, review requests, assignments, and updates on issues or pull requests. Use this tool whenever the user asks what to work on next, requests a summary of their GitHub activity, wants to see pending reviews, or needs to check for new updates or tasks. This tool is the primary way to discover actionable items, reminders, and outstanding work on GitHub. Always call this tool when asked what to work on next, what is pending, or what needs attention in GitHub."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_LIST_NOTIFICATIONS_USER_TITLE", "List notifications"), ReadOnlyHint: true, @@ -64,7 +65,11 @@ func ListNotifications(t translations.TranslationHelperFunc) inventory.ServerToo }), }, scopes.RequireAll(scopes.Notifications), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input ListNotificationsInput) (*mcp.CallToolResult, []*NotificationOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil @@ -157,18 +162,24 @@ func ListNotifications(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + output := make([]*NotificationOutput, 0, len(notifications)) + for _, notification := range notifications { + output = append(output, notificationOutput(notification)) + } + return utils.NewToolResultText(string(r)), output, nil }, + normalizeDiscussionNotificationIntegers("page", "perPage"), ) } // DismissNotification creates a tool to mark a notification as read/done. func DismissNotification(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[DismissNotificationInput, *NotificationStatusOutput]( ToolsetMetadataNotifications, mcp.Tool{ - Name: "dismiss_notification", - Description: t("TOOL_DISMISS_NOTIFICATION_DESCRIPTION", "Dismiss a notification by marking it as read or done"), + Name: "dismiss_notification", + OutputSchema: discussionNotificationOutputSchema[NotificationStatusOutput](), + Description: t("TOOL_DISMISS_NOTIFICATION_DESCRIPTION", "Dismiss a notification by marking it as read or done"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_DISMISS_NOTIFICATION_USER_TITLE", "Dismiss notification"), ReadOnlyHint: false, @@ -190,7 +201,11 @@ func DismissNotification(t translations.TranslationHelperFunc) inventory.ServerT }, }, scopes.RequireAll(scopes.Notifications), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input DismissNotificationInput) (*mcp.CallToolResult, *NotificationStatusOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil @@ -233,18 +248,21 @@ func DismissNotification(t translations.TranslationHelperFunc) inventory.ServerT return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, fmt.Sprintf("failed to mark notification as %s", state), resp, body), nil, nil } - return utils.NewToolResultText(fmt.Sprintf("Notification marked as %s", state)), nil, nil + message := fmt.Sprintf("Notification marked as %s", state) + inventory.PreserveToolHandlerContent(ctx) + return utils.NewToolResultText(message), &NotificationStatusOutput{Message: message}, nil }, ) } // MarkAllNotificationsRead creates a tool to mark all notifications as read. func MarkAllNotificationsRead(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[MarkAllNotificationsReadInput, *NotificationStatusOutput]( ToolsetMetadataNotifications, mcp.Tool{ - Name: "mark_all_notifications_read", - Description: t("TOOL_MARK_ALL_NOTIFICATIONS_READ_DESCRIPTION", "Mark all notifications as read"), + Name: "mark_all_notifications_read", + OutputSchema: discussionNotificationOutputSchema[NotificationStatusOutput](), + Description: t("TOOL_MARK_ALL_NOTIFICATIONS_READ_DESCRIPTION", "Mark all notifications as read"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_MARK_ALL_NOTIFICATIONS_READ_USER_TITLE", "Mark all notifications as read"), ReadOnlyHint: false, @@ -268,7 +286,11 @@ func MarkAllNotificationsRead(t translations.TranslationHelperFunc) inventory.Se }, }, scopes.RequireAll(scopes.Notifications), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input MarkAllNotificationsReadInput) (*mcp.CallToolResult, *NotificationStatusOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil @@ -325,18 +347,20 @@ func MarkAllNotificationsRead(t translations.TranslationHelperFunc) inventory.Se return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to mark all notifications as read", resp, body), nil, nil } - return utils.NewToolResultText("All notifications marked as read"), nil, nil + inventory.PreserveToolHandlerContent(ctx) + return utils.NewToolResultText("All notifications marked as read"), &NotificationStatusOutput{Message: "All notifications marked as read"}, nil }, ) } // GetNotificationDetails creates a tool to get details for a specific notification. func GetNotificationDetails(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[GetNotificationDetailsInput, *NotificationOutput]( ToolsetMetadataNotifications, mcp.Tool{ - Name: "get_notification_details", - Description: t("TOOL_GET_NOTIFICATION_DETAILS_DESCRIPTION", "Get detailed information for a specific GitHub notification, always call this tool when the user asks for details about a specific notification, if you don't know the ID list notifications first."), + Name: "get_notification_details", + OutputSchema: notificationOutputSchema(false), + Description: t("TOOL_GET_NOTIFICATION_DETAILS_DESCRIPTION", "Get detailed information for a specific GitHub notification, always call this tool when the user asks for details about a specific notification, if you don't know the ID list notifications first."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_NOTIFICATION_DETAILS_USER_TITLE", "Get notification details"), ReadOnlyHint: true, @@ -353,7 +377,11 @@ func GetNotificationDetails(t translations.TranslationHelperFunc) inventory.Serv }, }, scopes.RequireAll(scopes.Notifications), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input GetNotificationDetailsInput) (*mcp.CallToolResult, *NotificationOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil @@ -393,7 +421,7 @@ func GetNotificationDetails(t translations.TranslationHelperFunc) inventory.Serv // delivered to a specific recipient and may reference private // repositories, so confidentiality is private. result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelNotificationDetails()) - return result, nil, nil + return result, notificationOutput(thread), nil }, ) } @@ -407,11 +435,12 @@ const ( // ManageNotificationSubscription creates a tool to manage a notification subscription (ignore, watch, delete) func ManageNotificationSubscription(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[ManageNotificationSubscriptionInput, *NotificationSubscriptionResult]( ToolsetMetadataNotifications, mcp.Tool{ - Name: "manage_notification_subscription", - Description: t("TOOL_MANAGE_NOTIFICATION_SUBSCRIPTION_DESCRIPTION", "Manage a notification subscription: ignore, watch, or delete a notification thread subscription."), + Name: "manage_notification_subscription", + OutputSchema: notificationSubscriptionResultSchema(), + Description: t("TOOL_MANAGE_NOTIFICATION_SUBSCRIPTION_DESCRIPTION", "Manage a notification subscription: ignore, watch, or delete a notification thread subscription."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_MANAGE_NOTIFICATION_SUBSCRIPTION_USER_TITLE", "Manage notification subscription"), ReadOnlyHint: false, @@ -434,7 +463,11 @@ func ManageNotificationSubscription(t translations.TranslationHelperFunc) invent }, }, scopes.RequireAll(scopes.Notifications), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input ManageNotificationSubscriptionInput) (*mcp.CallToolResult, *NotificationSubscriptionResult, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil @@ -451,7 +484,7 @@ func ManageNotificationSubscription(t translations.TranslationHelperFunc) invent var ( resp *github.Response - result any + result *github.Subscription apiErr error ) @@ -484,14 +517,15 @@ func ManageNotificationSubscription(t translations.TranslationHelperFunc) invent if action == NotificationActionDelete { // Special case for delete as there is no response body - return utils.NewToolResultText("Notification subscription deleted"), nil, nil + inventory.PreserveToolHandlerContent(ctx) + return utils.NewToolResultText("Notification subscription deleted"), &NotificationSubscriptionResult{Message: "Notification subscription deleted"}, nil } r, err := json.Marshal(result) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), notificationSubscriptionOutput(result), nil }, ) } @@ -504,11 +538,12 @@ const ( // ManageRepositoryNotificationSubscription creates a tool to manage a repository notification subscription (ignore, watch, delete) func ManageRepositoryNotificationSubscription(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[ManageRepositoryNotificationSubscriptionInput, *NotificationSubscriptionResult]( ToolsetMetadataNotifications, mcp.Tool{ - Name: "manage_repository_notification_subscription", - Description: t("TOOL_MANAGE_REPOSITORY_NOTIFICATION_SUBSCRIPTION_DESCRIPTION", "Manage a repository notification subscription: ignore, watch, or delete repository notifications subscription for the provided repository."), + Name: "manage_repository_notification_subscription", + OutputSchema: notificationSubscriptionResultSchema(), + Description: t("TOOL_MANAGE_REPOSITORY_NOTIFICATION_SUBSCRIPTION_DESCRIPTION", "Manage a repository notification subscription: ignore, watch, or delete repository notifications subscription for the provided repository."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_MANAGE_REPOSITORY_NOTIFICATION_SUBSCRIPTION_USER_TITLE", "Manage repository notification subscription"), ReadOnlyHint: false, @@ -535,7 +570,11 @@ func ManageRepositoryNotificationSubscription(t translations.TranslationHelperFu }, }, scopes.RequireAll(scopes.Notifications), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input ManageRepositoryNotificationSubscriptionInput) (*mcp.CallToolResult, *NotificationSubscriptionResult, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil @@ -556,7 +595,7 @@ func ManageRepositoryNotificationSubscription(t translations.TranslationHelperFu var ( resp *github.Response - result any + result *github.Subscription apiErr error ) @@ -592,14 +631,15 @@ func ManageRepositoryNotificationSubscription(t translations.TranslationHelperFu if action == RepositorySubscriptionActionDelete { // Special case for delete as there is no response body - return utils.NewToolResultText("Repository subscription deleted"), nil, nil + inventory.PreserveToolHandlerContent(ctx) + return utils.NewToolResultText("Repository subscription deleted"), &NotificationSubscriptionResult{Message: "Repository subscription deleted"}, nil } r, err := json.Marshal(result) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), notificationSubscriptionOutput(result), nil }, ) } diff --git a/pkg/github/typed_discussion_notification_outputs_test.go b/pkg/github/typed_discussion_notification_outputs_test.go new file mode 100644 index 0000000000..7c49a9b70a --- /dev/null +++ b/pkg/github/typed_discussion_notification_outputs_test.go @@ -0,0 +1,317 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/ifc" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func discussionNotificationTools() []inventory.ServerTool { + t := translations.NullTranslationHelper + return []inventory.ServerTool{ + ListNotifications(t), DismissNotification(t), MarkAllNotificationsRead(t), + GetNotificationDetails(t), ManageNotificationSubscription(t), + ManageRepositoryNotificationSubscription(t), ListDiscussions(t), + GetDiscussion(t), GetDiscussionComments(t), DiscussionCommentWrite(t), + ListDiscussionCategories(t), + } +} + +func typedDiscussionNotificationDeps(t *testing.T) BaseDeps { + t.Helper() + notification := &github.Notification{ + ID: new("123"), Reason: new("mention"), Unread: new(true), + Subject: &github.NotificationSubject{Title: new("A discussion"), Type: new("Discussion")}, + Repository: &github.Repository{ + ID: new(int64(42)), FullName: new("owner/repo"), + Description: new("legacy-only repository detail"), + }, + } + subscription := &github.Subscription{Subscribed: new(true), Ignored: new(false)} + gql := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Query string `json:"query"` + Variables map[string]any `json:"variables"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + var data any + comment := map[string]any{"id": "DC_123", "url": "https://github.com/owner/repo/discussions/1#discussioncomment-123"} + discussion := map[string]any{"id": "D_1", "url": "https://github.com/owner/repo/discussions/1"} + pageInfo := map[string]any{"hasNextPage": true, "hasPreviousPage": false, "startCursor": "start", "endCursor": "end"} + switch { + case strings.Contains(req.Query, "addDiscussionComment("): + data = map[string]any{"addDiscussionComment": map[string]any{"comment": comment}} + case strings.Contains(req.Query, "updateDiscussionComment("): + data = map[string]any{"updateDiscussionComment": map[string]any{"comment": comment}} + case strings.Contains(req.Query, "deleteDiscussionComment("): + data = map[string]any{"deleteDiscussionComment": map[string]any{"comment": comment}} + case strings.Contains(req.Query, "unmarkDiscussionCommentAsAnswer("): + data = map[string]any{"unmarkDiscussionCommentAsAnswer": map[string]any{"discussion": discussion}} + case strings.Contains(req.Query, "markDiscussionCommentAsAnswer("): + data = map[string]any{"markDiscussionCommentAsAnswer": map[string]any{"discussion": discussion}} + case strings.Contains(req.Query, "node(id:"): + id := "D_1" + if req.Variables["replyToID"] == "wrong-discussion" { + id = "D_2" + } + data = map[string]any{"node": map[string]any{ + "id": "DC_123", "discussion": map[string]any{"id": id}, + }} + case strings.Contains(req.Query, "discussionCategories("): + assert.Equal(t, float64(25), req.Variables["first"]) + assert.Equal(t, ".github", req.Variables["repo"]) + data = map[string]any{"repository": map[string]any{"discussionCategories": map[string]any{ + "nodes": []any{map[string]any{"id": "CAT_1", "name": "General"}}, "pageInfo": pageInfo, "totalCount": 1, + }}} + case strings.Contains(req.Query, "discussions("): + assert.Equal(t, float64(30), req.Variables["first"]) + assert.Equal(t, ".github", req.Variables["repo"]) + data = map[string]any{"repository": map[string]any{"discussions": map[string]any{ + "nodes": []any{map[string]any{ + "number": 1, "title": "A title", "url": "https://github.com/owner/repo/discussions/1", + "createdAt": "2026-01-01T00:00:00Z", "updatedAt": "2026-01-02T00:00:00Z", + "author": map[string]any{"login": "octocat"}, "category": map[string]any{"name": "General"}, + }}, "pageInfo": pageInfo, "totalCount": 1, + }}} + case strings.Contains(req.Query, "comments("): + assert.Equal(t, float64(30), req.Variables["first"]) + assert.Equal(t, float64(1), req.Variables["discussionNumber"]) + node := map[string]any{"id": "DC_123", "body": "A comment", "isAnswer": true} + if strings.Contains(req.Query, "replies(") { + node["replies"] = map[string]any{ + "nodes": []any{map[string]any{"id": "DC_reply", "body": "A reply", "isAnswer": false}}, "totalCount": 1, + } + } + data = map[string]any{"repository": map[string]any{"discussion": map[string]any{"comments": map[string]any{ + "nodes": []any{node}, "pageInfo": pageInfo, "totalCount": 1, + }}}} + case strings.Contains(req.Query, "discussion(number:") && strings.Contains(req.Query, "{id}"): + data = map[string]any{"repository": map[string]any{"discussion": map[string]any{"id": "D_1"}}} + case strings.Contains(req.Query, "discussion(number:"): + assert.Equal(t, float64(1), req.Variables["discussionNumber"]) + data = map[string]any{"repository": map[string]any{"discussion": map[string]any{ + "number": 1, "title": "A title", "body": "A body", + "url": "https://github.com/owner/repo/discussions/1", "createdAt": "2026-01-01T00:00:00Z", + "closed": false, "isAnswered": true, "answerChosenAt": "2026-01-02T00:00:00Z", + "category": map[string]any{"name": "General"}, + }}} + default: + t.Errorf("unexpected GraphQL query: %s", req.Query) + } + require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"data": data})) + })}} + return BaseDeps{ + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + GQLClient: githubv4.NewClient(gql), + Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, &github.Repository{Private: new(true)}), + GetNotifications: mockResponse(t, http.StatusOK, []*github.Notification{notification}), + GetReposNotificationsByOwnerByRepo: mockResponse(t, http.StatusOK, []*github.Notification{notification}), + GetNotificationsThreadsByThreadID: mockResponse(t, http.StatusOK, notification), + PatchNotificationsThreadsByThreadID: mockResponse(t, http.StatusOK, nil), + DeleteNotificationsThreadsByThreadID: mockResponse(t, http.StatusNoContent, nil), + PutNotifications: mockResponse(t, http.StatusOK, nil), + PutReposNotificationsByOwnerByRepo: mockResponse(t, http.StatusOK, nil), + PutNotificationsThreadsSubscriptionByThreadID: mockResponse(t, http.StatusOK, subscription), + DeleteNotificationsThreadsSubscriptionByThreadID: mockResponse(t, http.StatusNoContent, nil), + PutReposSubscriptionByOwnerByRepo: mockResponse(t, http.StatusOK, subscription), + DeleteReposSubscriptionByOwnerByRepo: mockResponse(t, http.StatusNoContent, nil), + })), + } +} + +func TestTypedDiscussionNotificationOutputs(t *testing.T) { + const notificationText = `{"id":"123","repository":{"id":42,"full_name":"owner/repo","description":"legacy-only repository detail"},"subject":{"title":"A discussion","type":"Discussion"},"reason":"mention","unread":true}` + const commentText = `{"id":"DC_123","url":"https://github.com/owner/repo/discussions/1#discussioncomment-123"}` + const answerText = `{"discussionID":"D_1","discussionURL":"https://github.com/owner/repo/discussions/1"}` + const pageText = `{"endCursor":"end","hasNextPage":true,"hasPreviousPage":false,"startCursor":"start"}` + tests := []struct { + name string + args map[string]any + text string + }{ + {"list_notifications", map[string]any{"page": "0", "perPage": "0"}, "[" + notificationText + "]"}, + {"list_notifications", map[string]any{"owner": "owner", "repo": "repo", "since": "2026-01-01T00:00:00Z"}, "[" + notificationText + "]"}, + {"get_notification_details", map[string]any{"notificationID": "123"}, notificationText}, + {"dismiss_notification", map[string]any{"threadID": "123", "state": "read"}, "Notification marked as read"}, + {"dismiss_notification", map[string]any{"threadID": "123", "state": "done"}, "Notification marked as done"}, + {"mark_all_notifications_read", map[string]any{"lastReadAt": "2026-01-01T00:00:00Z"}, "All notifications marked as read"}, + {"mark_all_notifications_read", map[string]any{"owner": "owner", "repo": "repo", "lastReadAt": "2026-01-01T00:00:00Z"}, "All notifications marked as read"}, + {"list_discussions", map[string]any{"owner": "owner", "perPage": "30", "after": "cursor", "orderBy": "UPDATED_AT", "direction": "DESC", "category": "CAT_1"}, + `{"discussions":[{"category":{"name":"General"},"html_url":"https://github.com/owner/repo/discussions/1","number":1,"title":"A title","user":{"login":"octocat"},"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-02T00:00:00Z"}],"pageInfo":` + pageText + `,"totalCount":1}`}, + {"get_discussion", map[string]any{"OWNER": "owner", "REPO": "repo", "DISCUSSIONNUMBER": "1"}, + `{"answerChosenAt":"2026-01-02T00:00:00Z","body":"A body","category":{"name":"General"},"closed":false,"createdAt":"2026-01-01T00:00:00Z","isAnswered":true,"number":1,"title":"A title","url":"https://github.com/owner/repo/discussions/1"}`}, + {"get_discussion_comments", map[string]any{"owner": "owner", "repo": "repo", "discussionNumber": "1"}, + `{"comments":[{"id":"DC_123","body":"A comment","isAnswer":true}],"pageInfo":` + pageText + `,"totalCount":1}`}, + {"get_discussion_comments", map[string]any{"owner": "owner", "repo": "repo", "discussionNumber": 1.9, "includeReplies": true, "perPage": "0"}, + `{"comments":[{"id":"DC_123","body":"A comment","isAnswer":true,"replies":[{"id":"DC_reply","body":"A reply"}],"replyTotalCount":1}],"pageInfo":` + pageText + `,"totalCount":1}`}, + {"list_discussion_categories", map[string]any{"owner": "owner"}, + `{"categories":[{"id":"CAT_1","name":"General"}],"pageInfo":` + pageText + `,"totalCount":1}`}, + } + for _, name := range []string{"manage_notification_subscription", "manage_repository_notification_subscription"} { + for _, action := range []string{"ignore", "watch", "delete"} { + args := map[string]any{"action": action, "notificationID": "123", "owner": "owner", "repo": "repo"} + text := `{"subscribed":true,"ignored":false}` + if action == "delete" { + text = "Notification subscription deleted" + if name == "manage_repository_notification_subscription" { + text = "Repository subscription deleted" + } + } + tests = append(tests, struct { + name string + args map[string]any + text string + }{name, args, text}) + } + } + for _, method := range []string{"add", "reply", "update", "delete", "mark_answer", "unmark_answer"} { + args := map[string]any{"method": method, "owner": "owner", "repo": "repo", "discussionNumber": "1", "body": "New body", "commentNodeID": "DC_123"} + text := commentText + if method == "mark_answer" || method == "unmark_answer" { + text = answerText + } + tests = append(tests, struct { + name string + args map[string]any + text string + }{"discussion_comment_write", args, text}) + } + + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + t.Run(protocol, func(t *testing.T) { + deps := typedDiscussionNotificationDeps(t) + server := mcp.NewServer(&mcp.Implementation{Name: "typed-discussion-notification-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + for _, tool := range discussionNotificationTools() { + tool.RegisterFunc(server, deps) + } + client := connectCommentVisibilityClient(t, server, protocol) + list, err := client.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 11) + byName := map[string]*mcp.Tool{} + for _, tool := range list.Tools { + byName[tool.Name] = tool + if protocol == "2025-11-25" { + assert.Nil(t, tool.OutputSchema, tool.Name) + } else { + require.NotNil(t, tool.OutputSchema, tool.Name) + canonical := tool.OutputSchema + for _, definition := range discussionNotificationTools() { + if definition.Tool.Name == tool.Name { + assert.JSONEq(t, mustMarshalJSON(t, definition.Tool.OutputSchema), mustMarshalJSON(t, canonical)) + require.NoError(t, toolsnaps.Test(definition.Tool.Name, definition.Tool)) + } + } + } + } + for i, tc := range tests { + t.Run(tc.name+"/"+string(rune('A'+i)), func(t *testing.T) { + result, err := client.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: tc.args}) + require.NoError(t, err) + require.False(t, result.IsError, "%s", result) + require.Len(t, result.Content, 1, "text must not duplicate structured content") + switch tc.name { + case "get_notification_details": + assert.JSONEq(t, mustMarshalJSON(t, ifc.LabelNotificationDetails()), mustMarshalJSON(t, result.Meta["ifc"])) + case "list_discussions", "get_discussion", "get_discussion_comments": + assert.JSONEq(t, mustMarshalJSON(t, ifc.LabelRepoUserContent(true)), mustMarshalJSON(t, result.Meta["ifc"])) + case "list_discussion_categories": + assert.JSONEq(t, mustMarshalJSON(t, ifc.LabelRepoMetadata(true)), mustMarshalJSON(t, result.Meta["ifc"])) + } + if protocol == "2025-11-25" { + assert.Equal(t, tc.text, getTextResult(t, result).Text, "legacy text must remain byte-equivalent") + assert.Nil(t, result.StructuredContent) + return + } + require.NotNil(t, result.StructuredContent) + if json.Valid([]byte(tc.text)) { + assert.JSONEq(t, mustMarshalJSON(t, result.StructuredContent), getTextResult(t, result).Text, + "modern JSON text must serialize the same compact DTO as structuredContent") + } else { + assert.Equal(t, tc.text, getTextResult(t, result).Text, "plain status and deletion messages remain unchanged") + } + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, byName[tc.name].OutputSchema)), &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + var output any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, resolved.Validate(output), tc.name) + assert.NotContains(t, mustMarshalJSON(t, output), "legacy-only repository detail") + if tc.name == "get_discussion" { + assert.Contains(t, mustMarshalJSON(t, output), `"html_url"`) + assert.NotContains(t, mustMarshalJSON(t, output), `"url"`) + } + }) + } + for _, tc := range []struct { + name string + args map[string]any + text string + }{ + {"list_notifications", map[string]any{"since": "invalid"}, "invalid since time format, should be RFC3339/ISO8601:"}, + {"list_notifications", map[string]any{"before": "invalid"}, "invalid before time format, should be RFC3339/ISO8601:"}, + {"mark_all_notifications_read", map[string]any{"lastReadAt": "invalid"}, "invalid lastReadAt time format, should be RFC3339/ISO8601:"}, + {"discussion_comment_write", map[string]any{"method": "reply", "owner": "owner", "repo": "repo", "discussionNumber": 1, "body": "reply", "commentNodeID": "wrong-discussion"}, `does not belong to discussion #1 in owner/repo`}, + {"discussion_comment_write", map[string]any{"method": "delete", "commentNodeID": " "}, "commentNodeID cannot be blank"}, + {"discussion_comment_write", map[string]any{"method": "add", "owner": "owner", "repo": "repo", "discussionNumber": 1}, "missing required parameter: body"}, + } { + result, err := client.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: tc.args}) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Nil(t, result.StructuredContent, "errors must not contain a success DTO") + assert.Contains(t, getTextResult(t, result).Text, tc.text) + } + }) + } +} + +func TestDiscussionNotificationUnionsRejectImpossibleResults(t *testing.T) { + for _, tc := range []struct { + name string + schema *jsonschema.Schema + valid []string + invalid []string + }{ + { + "comment write", discussionCommentWriteOutputSchema(), + []string{`{"id":"","url":""}`, `{"discussionID":"","discussionURL":""}`}, + []string{`{}`, `{"id":"123"}`, `{"discussionID":"D_1"}`, `{"id":"123","url":"","discussionID":"D_1","discussionURL":""}`, `{"message":"deleted"}`}, + }, + { + "subscription", notificationSubscriptionResultSchema(), + []string{`{"subscription":{}}`, `{"message":"deleted"}`}, + []string{`{}`, `{"subscription":{},"message":"deleted"}`, `{"subscription":null}`, `{"subscribed":true}`}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + resolved, err := tc.schema.Resolve(nil) + require.NoError(t, err) + for _, raw := range tc.valid { + var value any + require.NoError(t, json.Unmarshal([]byte(raw), &value)) + assert.NoError(t, resolved.Validate(value), raw) + } + for _, raw := range tc.invalid { + var value any + require.NoError(t, json.Unmarshal([]byte(raw), &value)) + assert.Error(t, resolved.Validate(value), raw) + } + }) + } +} From c4ed8cbfdb48c25c8d2469e2cdaca1203e6b3a96 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 7 Oct 2026 00:11:18 +0200 Subject: [PATCH 49/61] feat(repos): add typed commit tool outputs (#3389) * feat(repos): add typed commit tool outputs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(repos): restore commit pagination defaults Normalize omitted and explicit zero pagination to page 1 and perPage 30 for get_commit and list_commits. Cover legacy and modern wire calls and direct handlers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(repos): return typed projected commit lists Use an optional-field commit DTO for full and projected modern outputs while retaining exact legacy text. Cover SHA and nested commit projections, empty lists, and output schema conformance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * test(repos): snapshot modern commit tools and strengthen wire coverage Capture canonical modern schemas, exercise null lists and error suppression with IFC labels, and drop unrelated generated Linux license drift noted by Copilot review. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(repos): distinguish legacy and modern commit text Keep exact-main byte assertions for the legacy protocol and verify modern text represents the same typed DTO as structured content. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(repos): sync commit snapshots with parent DTO metadata Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] --- pkg/github/__toolsnaps__/get_commit.snap | 326 +++++++++++++++++- pkg/github/__toolsnaps__/list_commits.snap | 283 ++++++++++++++- pkg/github/repositories.go | 182 ++++++---- pkg/github/repositories_test.go | 2 - pkg/github/typed_read_normalizers.go | 3 - .../typed_repository_commit_outputs_test.go | 218 ++++++++++++ 6 files changed, 928 insertions(+), 86 deletions(-) create mode 100644 pkg/github/typed_repository_commit_outputs_test.go diff --git a/pkg/github/__toolsnaps__/get_commit.snap b/pkg/github/__toolsnaps__/get_commit.snap index ad6a805515..8aef83dbc5 100644 --- a/pkg/github/__toolsnaps__/get_commit.snap +++ b/pkg/github/__toolsnaps__/get_commit.snap @@ -5,6 +5,18 @@ "title": "Get commit details" }, "description": "Get details for a commit from a GitHub repository", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "detail": { @@ -19,7 +31,8 @@ }, "owner": { "description": "Repository owner", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "page": { "description": "Page number for pagination (min 1)", @@ -34,7 +47,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "sha": { "description": "Commit SHA, branch name, or tag name", @@ -48,5 +62,311 @@ ], "type": "object" }, - "name": "get_commit" + "name": "get_commit", + "outputSchema": { + "additionalProperties": false, + "properties": { + "author": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "commit": { + "additionalProperties": false, + "properties": { + "author": { + "additionalProperties": false, + "properties": { + "date": { + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + }, + "committer": { + "additionalProperties": false, + "properties": { + "date": { + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + }, + "committer": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "files": { + "items": { + "additionalProperties": false, + "properties": { + "additions": { + "type": "integer" + }, + "changes": { + "type": "integer" + }, + "deletions": { + "type": "integer" + }, + "filename": { + "type": "string" + }, + "patch": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "required": [ + "filename" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "html_url": { + "type": "string" + }, + "sha": { + "type": "string" + }, + "stats": { + "additionalProperties": false, + "properties": { + "additions": { + "type": "integer" + }, + "deletions": { + "type": "integer" + }, + "total": { + "type": "integer" + } + }, + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "sha", + "html_url" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_commits.snap b/pkg/github/__toolsnaps__/list_commits.snap index bc4ffd1753..ddd55bac91 100644 --- a/pkg/github/__toolsnaps__/list_commits.snap +++ b/pkg/github/__toolsnaps__/list_commits.snap @@ -5,6 +5,18 @@ "title": "List commits" }, "description": "Get list of commits of a branch in a GitHub repository. Returns at least 30 results per page by default, but can return more if specified using the perPage parameter (up to 100).", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "author": { @@ -27,7 +39,8 @@ }, "owner": { "description": "Repository owner", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "page": { "description": "Page number for pagination (min 1)", @@ -46,7 +59,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "sha": { "description": "Commit SHA, branch or tag name to list commits of. If not provided, uses the default branch of the repository. If a commit SHA is provided, will list commits up to that SHA.", @@ -67,5 +81,268 @@ ], "type": "object" }, - "name": "list_commits" + "name": "list_commits", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "author": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "commit": { + "additionalProperties": false, + "properties": { + "author": { + "additionalProperties": false, + "properties": { + "date": { + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + }, + "committer": { + "additionalProperties": false, + "properties": { + "date": { + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + }, + "committer": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "sha": { + "type": [ + "null", + "string" + ] + } + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + } } \ No newline at end of file diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index f1c0194efc..df3dac1af9 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -26,8 +26,17 @@ import ( "github.com/shurcooL/githubv4" ) +type GetCommitInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + SHA string `json:"sha"` + Detail string `json:"detail,omitempty"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` +} + func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[GetCommitInput, MinimalCommit]( ToolsetMetadataRepos, mcp.Tool{ Name: "get_commit", @@ -62,30 +71,26 @@ func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { }), }, scopes.PublicRead(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input GetCommitInput) (*mcp.CallToolResult, MinimalCommit, error) { + if input.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), MinimalCommit{}, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if input.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), MinimalCommit{}, nil } - sha, err := RequiredParam[string](args, "sha") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if input.SHA == "" { + return utils.NewToolResultError("missing required parameter: sha"), MinimalCommit{}, nil } - detailRaw, err := OptionalParam[string](args, "detail") + detail, err := parseCommitDetail(input.Detail) if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + return utils.NewToolResultError(err.Error()), MinimalCommit{}, nil } - detail, err := parseCommitDetail(detailRaw) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + pagination := PaginationParams{Page: 1, PerPage: 30} + if input.Page != nil { + pagination.Page = *input.Page } - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if input.PerPage != nil { + pagination.PerPage = *input.PerPage } opts := &github.ListOptions{ @@ -95,24 +100,24 @@ func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { client, err := deps.GetClient(ctx) if err != nil { - return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + return nil, MinimalCommit{}, fmt.Errorf("failed to get GitHub client: %w", err) } - commit, resp, err := client.Repositories.GetCommit(ctx, owner, repo, sha, opts) + commit, resp, err := client.Repositories.GetCommit(ctx, input.Owner, input.Repo, input.SHA, opts) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to get commit: %s", sha), + fmt.Sprintf("failed to get commit: %s", input.SHA), resp, err, - ), nil, nil + ), MinimalCommit{}, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != 200 { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, nil, fmt.Errorf("failed to read response body: %w", err) + return nil, MinimalCommit{}, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get commit", resp, body), nil, nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get commit", resp, body), MinimalCommit{}, nil } // Convert to minimal commit @@ -120,7 +125,7 @@ func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { r, err := json.Marshal(minimalCommit) if err != nil { - return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + return nil, MinimalCommit{}, fmt.Errorf("failed to marshal response: %w", err) } result := utils.NewToolResultText(string(r)) @@ -128,12 +133,61 @@ func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { // repos anyone can land it via a PR (untrusted), in private repos // only collaborators can (trusted). Confidentiality follows repo // visibility. - result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelCommitContents) - return result, nil, nil + result = attachRepoVisibilityIFCLabel(ctx, deps, client, input.Owner, input.Repo, result, ifc.LabelCommitContents) + return result, minimalCommit, nil }, + normalizeTypedReadArguments(nil, false), ) } +type ListCommitsInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + SHA string `json:"sha,omitempty"` + Author string `json:"author,omitempty"` + Path string `json:"path,omitempty"` + Since string `json:"since,omitempty"` + Until string `json:"until,omitempty"` + Fields []string `json:"fields,omitempty"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` +} + +type ListCommitOutput struct { + SHA *string `json:"sha,omitempty"` + HTMLURL *string `json:"html_url,omitempty"` + Commit *MinimalCommitInfo `json:"commit,omitempty"` + Author *MinimalUser `json:"author,omitempty"` + Committer *MinimalUser `json:"committer,omitempty"` +} + +func structuredListCommitsOutput(commits []MinimalCommit, fields []string) []ListCommitOutput { + output := make([]ListCommitOutput, 0, len(commits)) + selected := func(field string) bool { + return len(fields) == 0 || slices.Contains(fields, field) + } + for _, commit := range commits { + item := ListCommitOutput{} + if selected("sha") { + item.SHA = new(commit.SHA) + } + if selected("html_url") { + item.HTMLURL = new(commit.HTMLURL) + } + if selected("commit") { + item.Commit = commit.Commit + } + if selected("author") { + item.Author = commit.Author + } + if selected("committer") { + item.Committer = commit.Committer + } + output = append(output, item) + } + return output +} + // ListCommits creates a tool to get the list of commits of a branch in a GitHub // repository. func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { @@ -177,7 +231,7 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { ) WithPagination(schema) - return NewTool( + return NewTool[ListCommitsInput, []ListCommitOutput]( ToolsetMetadataRepos, mcp.Tool{ Name: "list_commits", @@ -189,42 +243,19 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { InputSchema: schema, }, scopes.PublicRead(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 - } - sha, err := OptionalParam[string](args, "sha") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - author, err := OptionalParam[string](args, "author") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - path, err := OptionalParam[string](args, "path") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - fields, err := OptionalStringArrayParam(args, "fields") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input ListCommitsInput) (*mcp.CallToolResult, []ListCommitOutput, error) { + if input.Owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - sinceStr, err := OptionalParam[string](args, "since") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if input.Repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - untilStr, err := OptionalParam[string](args, "until") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + pagination := PaginationParams{Page: 1, PerPage: 30} + if input.Page != nil { + pagination.Page = *input.Page } - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if input.PerPage != nil { + pagination.PerPage = *input.PerPage } // Set default perPage to 30 if not provided perPage := pagination.PerPage @@ -232,23 +263,23 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { perPage = 30 } opts := &github.CommitsListOptions{ - SHA: sha, - Path: path, - Author: author, + SHA: input.SHA, + Path: input.Path, + Author: input.Author, ListOptions: github.ListOptions{ Page: pagination.Page, PerPage: perPage, }, } - if sinceStr != "" { - sinceTime, err := parseISOTimestamp(sinceStr) + if input.Since != "" { + sinceTime, err := parseISOTimestamp(input.Since) if err != nil { return utils.NewToolResultError(fmt.Sprintf("invalid since timestamp: %s", err)), nil, nil } opts.Since = sinceTime } - if untilStr != "" { - untilTime, err := parseISOTimestamp(untilStr) + if input.Until != "" { + untilTime, err := parseISOTimestamp(input.Until) if err != nil { return utils.NewToolResultError(fmt.Sprintf("invalid until timestamp: %s", err)), nil, nil } @@ -259,10 +290,10 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { if err != nil { return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } - commits, resp, err := client.Repositories.ListCommits(ctx, owner, repo, opts) + commits, resp, err := client.Repositories.ListCommits(ctx, input.Owner, input.Repo, opts) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, - fmt.Sprintf("failed to list commits: %s", sha), + fmt.Sprintf("failed to list commits: %s", input.SHA), resp, err, ), nil, nil @@ -285,8 +316,8 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { filtered := false var payload any = minimalCommits - if len(fields) > 0 { - filteredCommits, err := filterEachField(minimalCommits, fields) + if len(input.Fields) > 0 { + filteredCommits, err := filterEachField(minimalCommits, input.Fields) if err != nil { return utils.NewToolResultErrorFromErr("failed to filter commits", err), nil, nil } @@ -305,9 +336,10 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { // Commit content is reachable from the repo's history; integrity // follows the same public-untrusted / private-trusted rule as file // contents. Confidentiality follows repo visibility. - result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelCommitContents) - return result, nil, nil + result = attachRepoVisibilityIFCLabel(ctx, deps, client, input.Owner, input.Repo, result, ifc.LabelCommitContents) + return result, structuredListCommitsOutput(minimalCommits, input.Fields), nil }, + normalizeTypedReadArguments(nil, false), ) } diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 0c930ae8c0..11c6d210a4 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -1508,7 +1508,6 @@ func Test_GetCommit(t *testing.T) { // Verify tool definition once serverTool := GetCommit(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") @@ -1751,7 +1750,6 @@ func Test_ListCommits(t *testing.T) { // Verify tool definition once serverTool := ListCommits(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") diff --git a/pkg/github/typed_read_normalizers.go b/pkg/github/typed_read_normalizers.go index 6dcd56b658..20ba37ab94 100644 --- a/pkg/github/typed_read_normalizers.go +++ b/pkg/github/typed_read_normalizers.go @@ -36,9 +36,6 @@ func normalizeTypedReadArguments(uppercaseFields []string, preserveZeroPage bool if !exists { continue } - if field == "page" && preserveZeroPage { - continue - } var number any if err := json.Unmarshal(value, &number); err != nil { return nil, err diff --git a/pkg/github/typed_repository_commit_outputs_test.go b/pkg/github/typed_repository_commit_outputs_test.go new file mode 100644 index 0000000000..9c1218eab5 --- /dev/null +++ b/pkg/github/typed_repository_commit_outputs_test.go @@ -0,0 +1,218 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTypedRepositoryCommitPaginationDefaults(t *testing.T) { + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + t.Run(protocol, func(t *testing.T) { + for _, tool := range []inventory.ServerTool{ + GetCommit(translations.NullTranslationHelper), + ListCommits(translations.NullTranslationHelper), + } { + t.Run(tool.Tool.Name, func(t *testing.T) { + for _, tc := range []struct { + name string + args map[string]any + }{ + {"omitted", map[string]any{}}, + {"numeric_zero", map[string]any{"page": 0, "perPage": 0}}, + {"string_zero", map[string]any{"page": "0", "perPage": "0"}}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + handler := func(w http.ResponseWriter, r *http.Request) { + calls++ + assert.Equal(t, "1", r.URL.Query().Get("page")) + assert.Equal(t, "30", r.URL.Query().Get("per_page")) + var response any = &github.RepositoryCommit{SHA: new("abc123")} + if tool.Tool.Name == "list_commits" { + response = []*github.RepositoryCommit{{SHA: new("abc123")}} + } + mockResponse(t, http.StatusOK, response)(w, r) + } + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepoByRef: handler, + GetReposCommitsByOwnerByRepo: handler, + }))} + server := mcp.NewServer(&mcp.Implementation{Name: "commit-pagination-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + tool.RegisterFunc(server, deps) + session := connectCommentVisibilityClient(t, server, protocol) + tc.args["owner"] = "owner" + tc.args["repo"] = "repo" + if tool.Tool.Name == "get_commit" { + tc.args["sha"] = "abc123" + } + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: tool.Tool.Name, Arguments: tc.args, + }) + require.NoError(t, err) + require.False(t, result.IsError, "%+v", result) + assert.Equal(t, 1, calls) + request := createMCPRequest(tc.args) + directResult, err := tool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, directResult.IsError, "%+v", directResult) + assert.Equal(t, 2, calls) + }) + } + }) + } + }) + } +} +func TestTypedRepositoryCommitOutputs(t *testing.T) { + commit := &github.RepositoryCommit{ + SHA: new("abc123"), + HTMLURL: new("https://github.com/owner/repo/commit/abc123"), + Commit: &github.Commit{Message: new("A commit")}, + } + handlers := map[string]http.HandlerFunc{ + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, &github.Repository{Private: new(false)}), + GetReposCommitsByOwnerByRepoByRef: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "2", r.URL.Query().Get("page")) + assert.Equal(t, "7", r.URL.Query().Get("per_page")) + mockResponse(t, http.StatusOK, commit)(w, r) + }, + GetReposCommitsByOwnerByRepo: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "4", r.URL.Query().Get("page")) + assert.Equal(t, "9", r.URL.Query().Get("per_page")) + if r.URL.Query().Get("sha") == "null" { + mockResponse(t, http.StatusOK, nil)(w, r) + return + } + if r.URL.Query().Get("sha") == "empty" { + mockResponse(t, http.StatusOK, []*github.RepositoryCommit{})(w, r) + return + } + mockResponse(t, http.StatusOK, []*github.RepositoryCommit{commit})(w, r) + }, + } + deps := BaseDeps{ + Client: mustNewGHClient(t, MockHTTPClientWithHandlers(handlers)), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + tools := []inventory.ServerTool{ + GetCommit(translations.NullTranslationHelper), + ListCommits(translations.NullTranslationHelper), + } + expectedGet := `{"sha":"abc123","html_url":"https://github.com/owner/repo/commit/abc123","commit":{"message":"A commit"}}` + expectedList := `[{"sha":"abc123","html_url":"https://github.com/owner/repo/commit/abc123","commit":{"message":"A commit"}}]` + + for _, protocolVersion := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + t.Run(protocolVersion, func(t *testing.T) { + server := mcp.NewServer(&mcp.Implementation{Name: "typed-repository-commit-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + for _, tool := range tools { + tool.RegisterFunc(server, deps) + } + + session := connectCommentVisibilityClient(t, server, protocolVersion) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, len(tools)) + outputSchemas := make(map[string]*mcp.Tool, len(list.Tools)) + for _, tool := range list.Tools { + outputSchemas[tool.Name] = tool + if protocolVersion == "2025-11-25" { + assert.Nil(t, tool.OutputSchema, "%s must not expose outputSchema to legacy clients", tool.Name) + } else { + require.NotNil(t, tool.OutputSchema, "%s must expose its typed output schema", tool.Name) + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + } + } + + calls := []struct { + name string + args map[string]any + text string + }{ + { + name: "get_commit", + args: map[string]any{"owner": "owner", "repo": "repo", "sha": "abc123", "detail": "none", "page": "2", "perPage": "7"}, + text: expectedGet, + }, + { + name: "list_commits", + args: map[string]any{"owner": "owner", "repo": "repo", "page": "4", "perPage": "9"}, + text: expectedList, + }, + { + name: "list_commits", + args: map[string]any{"owner": "owner", "repo": "repo", "page": "4", "perPage": "9", "fields": []any{"sha"}}, + text: `[{"sha":"abc123"}]`, + }, + { + name: "list_commits", + args: map[string]any{"owner": "owner", "repo": "repo", "page": "4", "perPage": "9", "fields": []any{"commit"}}, + text: `[{"commit":{"message":"A commit"}}]`, + }, + { + name: "list_commits", + args: map[string]any{"owner": "owner", "repo": "repo", "page": "4", "perPage": "9", "sha": "empty"}, + text: `[]`, + }, + { + name: "list_commits", + args: map[string]any{"owner": "owner", "repo": "repo", "page": "4", "perPage": "9", "sha": "empty", "fields": []any{"sha", "commit"}}, + text: `[]`, + }, + { + name: "list_commits", + args: map[string]any{"owner": "owner", "repo": "repo", "page": "4", "perPage": "9", "sha": "null"}, + text: `[]`, + }, + } + for _, call := range calls { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: call.name, Arguments: call.args}) + require.NoError(t, err, call.name) + require.False(t, result.IsError, "%s: %s", call.name, result) + require.Len(t, result.Content, 1, "%s must preserve one legacy text result", call.name) + assert.NotNil(t, result.Meta["ifc"], "typed and legacy outputs retain IFC labeling") + text := getTextResult(t, result).Text + if protocolVersion == "2025-11-25" { + assert.Equal(t, call.text, text, "%s legacy text is byte-exact", call.name) + assert.Nil(t, result.StructuredContent) + continue + } + + require.NotNil(t, result.StructuredContent, "%s must return structured content", call.name) + structuredJSON := mustMarshalJSON(t, result.StructuredContent) + assert.JSONEq(t, call.text, text) + assert.JSONEq(t, text, structuredJSON, "modern text must serialize the typed DTO") + + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, outputSchemas[call.name].OutputSchema)), &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + var output any + require.NoError(t, json.Unmarshal([]byte(structuredJSON), &output)) + require.NoError(t, resolved.Validate(output), "%s output must conform to its schema", call.name) + } + for _, call := range []mcp.CallToolParams{ + {Name: "get_commit", Arguments: map[string]any{"owner": "owner", "repo": "repo", "sha": "abc123", "detail": "invalid"}}, + {Name: "list_commits", Arguments: map[string]any{"owner": "owner", "repo": "repo", "since": "invalid"}}, + } { + result, err := session.CallTool(context.Background(), &call) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Nil(t, result.StructuredContent, "errors must not expose a success DTO") + } + + }) + } +} From 5e737b4a461a3c4a84a72d4133448f7e40181e97 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 7 Oct 2026 00:11:18 +0200 Subject: [PATCH 50/61] feat(issues): add typed metadata, comment, and dependency outputs (#3391) * feat(issues): add typed metadata comment and dependency outputs Preserve legacy text and argument coercions while exposing protocol-gated structured issue outputs. Add modern, legacy, unknown-version, union-schema and API-error contracts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * chore: regenerate license files Auto-generated by license-check workflow * fix(issues): preserve zero pagination for duplicate search Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(issues): preserve compact typed output contracts Cache DTO schema inference, retain byte-exact handler text, and cover protocol, pagination and schema contracts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(issues): bound encoded schema parity registration work Reuse immutable HTTP comparison servers and the SDK schema cache across the full parity matrix. Refresh inherited user timestamp descriptions in issue snapshots. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] --- README.md | 2 + docs/feature-flags.md | 4 +- .../__toolsnaps__/add_issue_comment.snap | 85 +- ...find_duplicate_ff_duplicate_detection.snap | 67 +- ...dependency_read_ff_issue_dependencies.snap | 72 +- ...ependency_write_ff_issue_dependencies.snap | 84 +- pkg/github/__toolsnaps__/issue_read.snap | 1102 ++++++++++++++++- .../__toolsnaps__/list_issue_fields.snap | 91 +- .../__toolsnaps__/list_issue_types.snap | 52 +- pkg/github/__toolsnaps__/sub_issue_write.snap | 413 +++++- .../__toolsnaps__/update_issue_comment.snap | 31 +- pkg/github/find_duplicate.go | 96 +- pkg/github/find_duplicate_test.go | 58 +- pkg/github/issue_dependencies.go | 207 ++-- pkg/github/issue_fields.go | 20 +- pkg/github/issue_tool_types.go | 587 +++++++++ pkg/github/issues.go | 323 ++--- pkg/github/typed_issue_outputs_test.go | 413 ++++++ 18 files changed, 3396 insertions(+), 311 deletions(-) create mode 100644 pkg/github/issue_tool_types.go create mode 100644 pkg/github/typed_issue_outputs_test.go diff --git a/README.md b/README.md index c1f9857d1a..c6e88f2d35 100644 --- a/README.md +++ b/README.md @@ -616,6 +616,8 @@ The following sets of tools are available: ## Tools +The issue metadata (`list_issue_types`, `list_issue_fields`), comment (`add_issue_comment`, `update_issue_comment`), dependency (`issue_dependency_read`, `issue_dependency_write`), and duplicate-detection (`find_duplicate`) tools provide typed `outputSchema` and `structuredContent` for protocol `2026-07-28` and newer. Older or unknown protocol versions retain the same text responses without these fields. Tool errors do not return structured content. Dependency and duplicate-detection tools retain their existing feature gates. +
diff --git a/docs/feature-flags.md b/docs/feature-flags.md index 0d32b3c33a..36e7460d34 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -402,8 +402,8 @@ as output formatting) won't appear here. - `confidence_threshold`: Minimum similarity threshold a candidate must meet to be returned; higher values are stricter. When omitted, the API's high-precision default is used. The scale is defined by the API, so no client-side bounds are enforced. (number, optional) - `issue_number`: The number of the existing issue to find duplicates for (number, required) - `owner`: The owner of the repository (string, required) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `page`: Page number for pagination (min 1). An explicit 0 is forwarded to the API as-is rather than being replaced with 1. (number, optional) + - `perPage`: Results per page for pagination (min 1, max 100). An explicit 0 is forwarded to the API as-is rather than being replaced with 30. (number, optional) - `repo`: The name of the repository (string, required) ### `thread_resolution_reason` diff --git a/pkg/github/__toolsnaps__/add_issue_comment.snap b/pkg/github/__toolsnaps__/add_issue_comment.snap index a68494d448..58102f70b1 100644 --- a/pkg/github/__toolsnaps__/add_issue_comment.snap +++ b/pkg/github/__toolsnaps__/add_issue_comment.snap @@ -51,5 +51,88 @@ ], "type": "object" }, - "name": "add_issue_comment" + "name": "add_issue_comment", + "outputSchema": { + "oneOf": [ + { + "type": "null" + }, + { + "additionalProperties": false, + "properties": { + "html_url": { + "description": "The web URL of the created issue comment.", + "type": "string" + }, + "id": { + "description": "The ID of the created comment or reaction.", + "type": "string" + } + }, + "required": [ + "id", + "html_url" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "id": { + "description": "The ID of the created reaction.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "comment": { + "additionalProperties": false, + "properties": { + "html_url": { + "description": "The web URL of the created issue comment.", + "type": "string" + }, + "id": { + "description": "The ID of the created comment or reaction.", + "type": "string" + } + }, + "required": [ + "id", + "html_url" + ], + "type": "object" + }, + "reaction": { + "additionalProperties": false, + "properties": { + "id": { + "description": "The ID of the created reaction.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + } + }, + "required": [ + "comment", + "reaction" + ], + "type": "object" + } + ], + "type": [ + "object", + "null" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/find_duplicate_ff_duplicate_detection.snap b/pkg/github/__toolsnaps__/find_duplicate_ff_duplicate_detection.snap index ac95fd4138..7aea7dc254 100644 --- a/pkg/github/__toolsnaps__/find_duplicate_ff_duplicate_detection.snap +++ b/pkg/github/__toolsnaps__/find_duplicate_ff_duplicate_detection.snap @@ -20,12 +20,12 @@ "type": "string" }, "page": { - "description": "Page number for pagination (min 1)", + "description": "Page number for pagination (min 1). An explicit 0 is forwarded to the API as-is rather than being replaced with 1.", "minimum": 1, "type": "number" }, "perPage": { - "description": "Results per page for pagination (min 1, max 100)", + "description": "Results per page for pagination (min 1, max 100). An explicit 0 is forwarded to the API as-is rather than being replaced with 30.", "maximum": 100, "minimum": 1, "type": "number" @@ -42,5 +42,66 @@ ], "type": "object" }, - "name": "find_duplicate" + "name": "find_duplicate", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "confidence": { + "description": "The API-provided confidence category.", + "type": "string" + }, + "issue": { + "additionalProperties": false, + "properties": { + "html_url": { + "description": "The web URL of the candidate issue.", + "type": "string" + }, + "number": { + "description": "The candidate issue number.", + "type": "integer" + }, + "state": { + "description": "The candidate issue state.", + "type": "string" + }, + "title": { + "description": "The sanitized candidate issue title.", + "type": "string" + } + }, + "required": [ + "number", + "title", + "state", + "html_url" + ], + "type": "object" + }, + "likely_duplicate": { + "description": "Whether the API considers this candidate a likely duplicate.", + "type": "boolean" + }, + "score": { + "description": "Similarity score on the API-defined scale; null when the API does not return a score.", + "type": [ + "null", + "number" + ] + } + }, + "required": [ + "issue", + "score", + "confidence", + "likely_duplicate" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/issue_dependency_read_ff_issue_dependencies.snap b/pkg/github/__toolsnaps__/issue_dependency_read_ff_issue_dependencies.snap index cf6c3662f1..a8ce1e34b4 100644 --- a/pkg/github/__toolsnaps__/issue_dependency_read_ff_issue_dependencies.snap +++ b/pkg/github/__toolsnaps__/issue_dependency_read_ff_issue_dependencies.snap @@ -47,5 +47,75 @@ ], "type": "object" }, - "name": "issue_dependency_read" + "name": "issue_dependency_read", + "outputSchema": { + "additionalProperties": false, + "properties": { + "issues": { + "description": "Related issues in the requested dependency direction.", + "items": { + "additionalProperties": false, + "properties": { + "html_url": { + "description": "The web URL of the related issue.", + "type": "string" + }, + "number": { + "description": "The related issue number.", + "type": "integer" + }, + "repository": { + "description": "The related repository in owner/name form.", + "type": "string" + }, + "state": { + "description": "The related issue state.", + "type": "string" + }, + "title": { + "description": "The sanitized title of the related issue.", + "type": "string" + } + }, + "required": [ + "number", + "title", + "state", + "html_url" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "pageInfo": { + "additionalProperties": false, + "properties": { + "hasNextPage": { + "description": "Whether another page of related issues is available.", + "type": "boolean" + }, + "nextPage": { + "description": "The next page number, or 0 when no next page exists.", + "type": "integer" + } + }, + "required": [ + "hasNextPage", + "nextPage" + ], + "type": "object" + } + }, + "required": [ + "issues", + "pageInfo" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/issue_dependency_write_ff_issue_dependencies.snap b/pkg/github/__toolsnaps__/issue_dependency_write_ff_issue_dependencies.snap index b907dff6cf..962db337c4 100644 --- a/pkg/github/__toolsnaps__/issue_dependency_write_ff_issue_dependencies.snap +++ b/pkg/github/__toolsnaps__/issue_dependency_write_ff_issue_dependencies.snap @@ -58,5 +58,87 @@ ], "type": "object" }, - "name": "issue_dependency_write" + "name": "issue_dependency_write", + "outputSchema": { + "additionalProperties": false, + "properties": { + "blocked_issue": { + "additionalProperties": false, + "properties": { + "html_url": { + "description": "The web URL of the issue.", + "type": "string" + }, + "number": { + "description": "The issue number.", + "type": "integer" + }, + "repository": { + "description": "The repository in owner/name form.", + "type": "string" + }, + "state": { + "description": "The issue state.", + "type": "string" + }, + "title": { + "description": "The sanitized issue title.", + "type": "string" + } + }, + "required": [ + "number", + "title", + "state", + "html_url" + ], + "type": "object" + }, + "blocking_issue": { + "additionalProperties": false, + "properties": { + "html_url": { + "description": "The web URL of the issue.", + "type": "string" + }, + "number": { + "description": "The issue number.", + "type": "integer" + }, + "repository": { + "description": "The repository in owner/name form.", + "type": "string" + }, + "state": { + "description": "The issue state.", + "type": "string" + }, + "title": { + "description": "The sanitized issue title.", + "type": "string" + } + }, + "required": [ + "number", + "title", + "state", + "html_url" + ], + "type": "object" + }, + "message": { + "description": "Whether the dependency relationship was added or removed.", + "type": "string" + } + }, + "required": [ + "blocked_issue", + "blocking_issue", + "message" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/issue_read.snap b/pkg/github/__toolsnaps__/issue_read.snap index faf6085a5e..25ed46a9b7 100644 --- a/pkg/github/__toolsnaps__/issue_read.snap +++ b/pkg/github/__toolsnaps__/issue_read.snap @@ -50,5 +50,1105 @@ ], "type": "object" }, - "name": "issue_read" + "name": "issue_read", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "assignees": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "author_association": { + "type": "string" + }, + "body": { + "type": "string" + }, + "closed_at": { + "type": "string" + }, + "closed_by": { + "type": "string" + }, + "closed_by_pull_requests": { + "additionalProperties": false, + "properties": { + "references": { + "items": { + "additionalProperties": false, + "properties": { + "number": { + "type": "integer" + }, + "repository": { + "type": "string" + }, + "state": { + "type": "string" + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "number", + "title", + "state", + "url" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "total_count", + "references" + ], + "type": [ + "null", + "object" + ] + }, + "comments": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "draft": { + "type": "boolean" + }, + "field_values": { + "items": { + "additionalProperties": false, + "properties": { + "field": { + "type": "string" + }, + "value": { + "type": "string" + }, + "values": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + } + }, + "required": [ + "field" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "has_children": { + "type": [ + "null", + "boolean" + ] + }, + "has_parent": { + "type": [ + "null", + "boolean" + ] + }, + "html_url": { + "type": "string" + }, + "issue_field_values": { + "items": { + "additionalProperties": false, + "properties": { + "data_type": { + "type": "string" + }, + "issue_field_id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "single_select_option": { + "additionalProperties": false, + "properties": { + "color": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "color" + ], + "type": [ + "null", + "object" + ] + }, + "value": true + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "issue_type": { + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "locked": { + "type": "boolean" + }, + "milestone": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "parent": { + "additionalProperties": false, + "properties": { + "number": { + "type": "integer" + }, + "repository": { + "type": "string" + }, + "state": { + "type": "string" + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "number", + "title", + "state", + "url" + ], + "type": [ + "null", + "object" + ] + }, + "reactions": { + "additionalProperties": false, + "properties": { + "+1": { + "type": "integer" + }, + "-1": { + "type": "integer" + }, + "confused": { + "type": "integer" + }, + "eyes": { + "type": "integer" + }, + "heart": { + "type": "integer" + }, + "hooray": { + "type": "integer" + }, + "laugh": { + "type": "integer" + }, + "rocket": { + "type": "integer" + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "total_count", + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "rocket", + "eyes" + ], + "type": [ + "null", + "object" + ] + }, + "state": { + "type": "string" + }, + "state_reason": { + "type": "string" + }, + "sub_issues_summary": { + "additionalProperties": false, + "properties": { + "completed": { + "type": "integer" + }, + "percent_completed": { + "type": "integer" + }, + "total": { + "type": "integer" + } + }, + "required": [ + "total", + "completed", + "percent_completed" + ], + "type": [ + "null", + "object" + ] + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "number", + "title", + "state", + "assignees" + ], + "type": [ + "null", + "object" + ] + }, + { + "items": { + "additionalProperties": false, + "properties": { + "author_association": { + "type": "string" + }, + "body": { + "type": "string" + }, + "created_at": { + "type": "string" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "reactions": { + "additionalProperties": false, + "properties": { + "+1": { + "type": "integer" + }, + "-1": { + "type": "integer" + }, + "confused": { + "type": "integer" + }, + "eyes": { + "type": "integer" + }, + "heart": { + "type": "integer" + }, + "hooray": { + "type": "integer" + }, + "laugh": { + "type": "integer" + }, + "rocket": { + "type": "integer" + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "total_count", + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "rocket", + "eyes" + ], + "type": [ + "null", + "object" + ] + }, + "updated_at": { + "type": "string" + }, + "user": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "id", + "html_url" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + { + "items": { + "additionalProperties": false, + "properties": { + "assignees": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "author_association": { + "type": "string" + }, + "body": { + "type": "string" + }, + "closed_at": { + "type": "string" + }, + "closed_by": { + "type": "string" + }, + "closed_by_pull_requests": { + "additionalProperties": false, + "properties": { + "references": { + "items": { + "additionalProperties": false, + "properties": { + "number": { + "type": "integer" + }, + "repository": { + "type": "string" + }, + "state": { + "type": "string" + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "number", + "title", + "state", + "url" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "total_count", + "references" + ], + "type": [ + "null", + "object" + ] + }, + "comments": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "draft": { + "type": "boolean" + }, + "field_values": { + "items": { + "additionalProperties": false, + "properties": { + "field": { + "type": "string" + }, + "value": { + "type": "string" + }, + "values": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + } + }, + "required": [ + "field" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "has_children": { + "type": [ + "null", + "boolean" + ] + }, + "has_parent": { + "type": [ + "null", + "boolean" + ] + }, + "html_url": { + "type": "string" + }, + "issue_field_values": { + "items": { + "additionalProperties": false, + "properties": { + "data_type": { + "type": "string" + }, + "issue_field_id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "single_select_option": { + "additionalProperties": false, + "properties": { + "color": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "color" + ], + "type": [ + "null", + "object" + ] + }, + "value": true + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "issue_type": { + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "locked": { + "type": "boolean" + }, + "milestone": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "parent": { + "additionalProperties": false, + "properties": { + "number": { + "type": "integer" + }, + "repository": { + "type": "string" + }, + "state": { + "type": "string" + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "number", + "title", + "state", + "url" + ], + "type": [ + "null", + "object" + ] + }, + "reactions": { + "additionalProperties": false, + "properties": { + "+1": { + "type": "integer" + }, + "-1": { + "type": "integer" + }, + "confused": { + "type": "integer" + }, + "eyes": { + "type": "integer" + }, + "heart": { + "type": "integer" + }, + "hooray": { + "type": "integer" + }, + "laugh": { + "type": "integer" + }, + "rocket": { + "type": "integer" + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "total_count", + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "rocket", + "eyes" + ], + "type": [ + "null", + "object" + ] + }, + "state": { + "type": "string" + }, + "state_reason": { + "type": "string" + }, + "sub_issues_summary": { + "additionalProperties": false, + "properties": { + "completed": { + "type": "integer" + }, + "percent_completed": { + "type": "integer" + }, + "total": { + "type": "integer" + } + }, + "required": [ + "total", + "completed", + "percent_completed" + ], + "type": [ + "null", + "object" + ] + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "number", + "title", + "state", + "assignees" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + { + "additionalProperties": false, + "properties": { + "parent": { + "additionalProperties": false, + "properties": { + "html_url": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "repository": { + "type": "string" + }, + "state": { + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "number", + "title", + "state", + "html_url" + ], + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "parent" + ], + "type": [ + "null", + "object" + ] + }, + { + "additionalProperties": false, + "properties": { + "labels": { + "items": { + "additionalProperties": false, + "properties": { + "color": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "color", + "description" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "totalCount": { + "type": "integer" + } + }, + "required": [ + "labels", + "totalCount" + ], + "type": [ + "null", + "object" + ] + }, + { + "type": "null" + } + ], + "type": [ + "array", + "object", + "null" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_issue_fields.snap b/pkg/github/__toolsnaps__/list_issue_fields.snap index df9ae47ba6..f9744d009e 100644 --- a/pkg/github/__toolsnaps__/list_issue_fields.snap +++ b/pkg/github/__toolsnaps__/list_issue_fields.snap @@ -21,5 +21,94 @@ ], "type": "object" }, - "name": "list_issue_fields" + "name": "list_issue_fields", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "data_type": { + "description": "The field value type.", + "enum": [ + "TEXT", + "NUMBER", + "DATE", + "SINGLE_SELECT" + ], + "type": "string" + }, + "description": { + "description": "The field description, when available.", + "type": "string" + }, + "full_database_id": { + "description": "The database ID of the issue field, when available.", + "type": "integer" + }, + "id": { + "description": "The node ID of the issue field.", + "type": "string" + }, + "name": { + "description": "The field name.", + "type": "string" + }, + "options": { + "description": "Available values for single-select fields.", + "items": { + "additionalProperties": false, + "properties": { + "color": { + "description": "The option color.", + "type": "string" + }, + "description": { + "description": "The option description, when available.", + "type": "string" + }, + "id": { + "description": "The option node ID.", + "type": "string" + }, + "name": { + "description": "The option name.", + "type": "string" + }, + "priority": { + "description": "Display order of this option, when set.", + "type": [ + "null", + "integer" + ] + } + }, + "required": [ + "id", + "name", + "color" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "visibility": { + "description": "The visibility policy for the field.", + "type": "string" + } + }, + "required": [ + "id", + "name", + "data_type", + "visibility" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_issue_types.snap b/pkg/github/__toolsnaps__/list_issue_types.snap index c1ea3d515a..8f95717e09 100644 --- a/pkg/github/__toolsnaps__/list_issue_types.snap +++ b/pkg/github/__toolsnaps__/list_issue_types.snap @@ -21,5 +21,55 @@ ], "type": "object" }, - "name": "list_issue_types" + "name": "list_issue_types", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "color": { + "description": "The issue type color, when set.", + "type": [ + "null", + "string" + ] + }, + "description": { + "description": "The issue type description, when available.", + "type": [ + "null", + "string" + ] + }, + "id": { + "description": "The issue type ID.", + "type": [ + "null", + "integer" + ] + }, + "is_enabled": { + "description": "Whether this issue type is enabled.", + "type": [ + "null", + "boolean" + ] + }, + "name": { + "description": "The issue type name.", + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/sub_issue_write.snap b/pkg/github/__toolsnaps__/sub_issue_write.snap index 9dc2776c32..c98d4c8a18 100644 --- a/pkg/github/__toolsnaps__/sub_issue_write.snap +++ b/pkg/github/__toolsnaps__/sub_issue_write.snap @@ -49,5 +49,416 @@ ], "type": "object" }, - "name": "sub_issue_write" + "name": "sub_issue_write", + "outputSchema": { + "additionalProperties": false, + "properties": { + "assignees": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "author_association": { + "type": "string" + }, + "body": { + "type": "string" + }, + "closed_at": { + "type": "string" + }, + "closed_by": { + "type": "string" + }, + "closed_by_pull_requests": { + "additionalProperties": false, + "properties": { + "references": { + "items": { + "additionalProperties": false, + "properties": { + "number": { + "type": "integer" + }, + "repository": { + "type": "string" + }, + "state": { + "type": "string" + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "number", + "title", + "state", + "url" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "total_count", + "references" + ], + "type": [ + "null", + "object" + ] + }, + "comments": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "draft": { + "type": "boolean" + }, + "field_values": { + "items": { + "additionalProperties": false, + "properties": { + "field": { + "type": "string" + }, + "value": { + "type": "string" + }, + "values": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + } + }, + "required": [ + "field" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "has_children": { + "type": [ + "null", + "boolean" + ] + }, + "has_parent": { + "type": [ + "null", + "boolean" + ] + }, + "html_url": { + "type": "string" + }, + "issue_field_values": { + "items": { + "additionalProperties": false, + "properties": { + "data_type": { + "type": "string" + }, + "issue_field_id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "single_select_option": { + "additionalProperties": false, + "properties": { + "color": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "color" + ], + "type": [ + "null", + "object" + ] + }, + "value": true + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "issue_type": { + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "locked": { + "type": "boolean" + }, + "milestone": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "parent": { + "additionalProperties": false, + "properties": { + "number": { + "type": "integer" + }, + "repository": { + "type": "string" + }, + "state": { + "type": "string" + }, + "title": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "number", + "title", + "state", + "url" + ], + "type": [ + "null", + "object" + ] + }, + "reactions": { + "additionalProperties": false, + "properties": { + "+1": { + "type": "integer" + }, + "-1": { + "type": "integer" + }, + "confused": { + "type": "integer" + }, + "eyes": { + "type": "integer" + }, + "heart": { + "type": "integer" + }, + "hooray": { + "type": "integer" + }, + "laugh": { + "type": "integer" + }, + "rocket": { + "type": "integer" + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "total_count", + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "rocket", + "eyes" + ], + "type": [ + "null", + "object" + ] + }, + "state": { + "type": "string" + }, + "state_reason": { + "type": "string" + }, + "sub_issues_summary": { + "additionalProperties": false, + "properties": { + "completed": { + "type": "integer" + }, + "percent_completed": { + "type": "integer" + }, + "total": { + "type": "integer" + } + }, + "required": [ + "total", + "completed", + "percent_completed" + ], + "type": [ + "null", + "object" + ] + }, + "title": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "number", + "title", + "state", + "assignees" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_issue_comment.snap b/pkg/github/__toolsnaps__/update_issue_comment.snap index c8a1db29d2..e142883164 100644 --- a/pkg/github/__toolsnaps__/update_issue_comment.snap +++ b/pkg/github/__toolsnaps__/update_issue_comment.snap @@ -34,5 +34,34 @@ ], "type": "object" }, - "name": "update_issue_comment" + "name": "update_issue_comment", + "outputSchema": { + "oneOf": [ + { + "type": "null" + }, + { + "additionalProperties": false, + "properties": { + "html_url": { + "description": "The web URL of the created issue comment.", + "type": "string" + }, + "id": { + "description": "The ID of the created comment or reaction.", + "type": "string" + } + }, + "required": [ + "id", + "html_url" + ], + "type": "object" + } + ], + "type": [ + "object", + "null" + ] + } } \ No newline at end of file diff --git a/pkg/github/find_duplicate.go b/pkg/github/find_duplicate.go index 236f53a489..6d50a3ab96 100644 --- a/pkg/github/find_duplicate.go +++ b/pkg/github/find_duplicate.go @@ -33,15 +33,6 @@ type rankedSimilarIssue struct { LikelyDuplicate bool `json:"likely_duplicate"` } -// duplicateCandidate is the trimmed output for a ranked duplicate candidate, -// carrying only what an agent needs to explain and act on it. -type duplicateCandidate struct { - Issue MinimalIssueRef `json:"issue"` - Score *float64 `json:"score"` - Confidence string `json:"confidence"` - LikelyDuplicate bool `json:"likely_duplicate"` -} - // FindDuplicate creates a read-only tool that returns ranked duplicate // candidates for an existing issue. It is a separate, feature-flagged tool so // duplicate detection is only advertised when explicitly opted in, keeping the @@ -71,8 +62,15 @@ func FindDuplicate(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "issue_number"}, } WithPagination(schema) + // Unlike most tools, find_duplicate does not substitute its own default + // for an explicit 0: it forwards whatever page/perPage value is supplied + // (including 0) to the API unchanged, letting the API apply its own + // default. Document that exception here instead of relying solely on the + // generic WithPagination wording. + schema.Properties["page"].Description = "Page number for pagination (min 1). An explicit 0 is forwarded to the API as-is rather than being replaced with 1." + schema.Properties["perPage"].Description = "Results per page for pagination (min 1, max 100). An explicit 0 is forwarded to the API as-is rather than being replaced with 30." - st := NewTool( + st := NewToolWithSchemaOptions[FindDuplicateInput, []DuplicateCandidate]( ToolsetMetadataIssues, mcp.Tool{ Name: "find_duplicate", @@ -81,44 +79,28 @@ func FindDuplicate(t translations.TranslationHelperFunc) inventory.ServerTool { Title: t("TOOL_FIND_DUPLICATE_USER_TITLE", "Find duplicate issues"), ReadOnlyHint: true, }, - InputSchema: schema, + InputSchema: schema, + OutputSchema: findDuplicateOutputSchema(), }, scopes.PublicRead(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - issueNumber, err := RequiredInt(args, "issue_number") - if err != nil { + inventory.TypedSchemaOptions{ValidationInputSchema: issuePaginationValidationSchema(schema), PreserveHandlerContent: true}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input FindDuplicateInput) (*mcp.CallToolResult, []DuplicateCandidate, error) { + owner, repo, issueNumber := input.Owner, input.Repo, input.IssueNumber + if err := validateIssueCoordinate(owner, repo, issueNumber); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } // Build the query preserving whether each optional value was supplied // so unset parameters fall back to the API's own defaults. query := url.Values{} - if threshold, ok, err := OptionalParamOK[float64](args, "confidence_threshold"); err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } else if ok { - query.Set("threshold", strconv.FormatFloat(threshold, 'g', -1, 64)) + if input.ConfidenceThreshold != nil { + query.Set("threshold", strconv.FormatFloat(*input.ConfidenceThreshold, 'g', -1, 64)) } - if _, ok := args["perPage"]; ok { - perPage, err := OptionalIntParam(args, "perPage") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - query.Set("per_page", strconv.Itoa(perPage)) + if input.PerPage != nil { + query.Set("per_page", strconv.Itoa(*input.PerPage)) } - if _, ok := args["page"]; ok { - page, err := OptionalIntParam(args, "page") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - query.Set("page", strconv.Itoa(page)) + if input.Page != nil { + query.Set("page", strconv.Itoa(*input.Page)) } client, err := deps.GetClient(ctx) @@ -143,7 +125,8 @@ func FindDuplicate(t translations.TranslationHelperFunc) inventory.ServerTool { } defer func() { _ = resp.Body.Close() }() - candidates := make([]duplicateCandidate, 0, len(results)) + candidates := make([]DuplicateCandidate, 0, len(results)) + legacyCandidates := make([]LegacyDuplicateCandidate, 0, len(results)) for _, res := range results { // A bare issue (no ranking metadata) means ranked duplicate // detection is not enabled for this caller; fail clearly rather @@ -151,23 +134,33 @@ func FindDuplicate(t translations.TranslationHelperFunc) inventory.ServerTool { if res.Confidence == "" || res.Issue == nil { return utils.NewToolResultError("ranked duplicate detection is unavailable: the semantic-similarity endpoint returned issues without ranking metadata (the server-side duplicate-ranking feature is not enabled for this caller or repository)"), nil, nil } - candidates = append(candidates, duplicateCandidate{ + ref := newMinimalIssueRef( + res.Issue.Number, + res.Issue.Title, + res.Issue.State, + res.Issue.HTMLURL, + "", + ) + candidates = append(candidates, DuplicateCandidate{ // Candidates are always scoped to the requested repository, so the // ref's repository field is left empty as it was before. - Issue: newMinimalIssueRef( - res.Issue.Number, - res.Issue.Title, - res.Issue.State, - res.Issue.HTMLURL, - "", - ), + Issue: MinimalDuplicateIssue{ + Number: ref.Number, Title: ref.Title, + State: DuplicateIssueState(ref.State), HTMLURL: ref.URL, + }, + Score: res.Score, + Confidence: DuplicateConfidence(res.Confidence), + LikelyDuplicate: res.LikelyDuplicate, + }) + legacyCandidates = append(legacyCandidates, LegacyDuplicateCandidate{ + Issue: ref, Score: res.Score, - Confidence: res.Confidence, + Confidence: DuplicateConfidence(res.Confidence), LikelyDuplicate: res.LikelyDuplicate, }) } - r, err := json.Marshal(candidates) + r, err := json.Marshal(legacyCandidates) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal duplicate candidates", err), nil, nil } @@ -176,8 +169,9 @@ func FindDuplicate(t translations.TranslationHelperFunc) inventory.ServerTool { // repository, so classify the result like issue_read. result := utils.NewToolResultText(string(r)) result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoUserContent) - return result, nil, nil - }) + return result, candidates, nil + }, normalizeIssueStrings([]string{"owner", "repo"}, nil), + normalizeIssueIntegers([]string{"issue_number"}, []string{"page", "perPage"}), normalizeDuplicateThreshold) st.FeatureRule = featureEnabledRule(FeatureFlagDuplicateDetection) return st } diff --git a/pkg/github/find_duplicate_test.go b/pkg/github/find_duplicate_test.go index f7299116ba..c68d66e55e 100644 --- a/pkg/github/find_duplicate_test.go +++ b/pkg/github/find_duplicate_test.go @@ -39,6 +39,16 @@ func Test_FindDuplicate(t *testing.T) { assert.Contains(t, schema.Properties, "page") assert.Contains(t, schema.Properties, "perPage") assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "issue_number"}) + + // Unlike most paginated tools, find_duplicate forwards an explicit 0 to + // the API unchanged instead of substituting the usual default. The + // advertised minimum stays canonical (matching WithPagination), but the + // description must say so to avoid misleading callers. + assert.Equal(t, 1.0, *schema.Properties["page"].Minimum) + assert.Equal(t, 1.0, *schema.Properties["perPage"].Minimum) + assert.Equal(t, 100.0, *schema.Properties["perPage"].Maximum) + assert.Contains(t, schema.Properties["page"].Description, "An explicit 0 is forwarded to the API as-is") + assert.Contains(t, schema.Properties["perPage"].Description, "An explicit 0 is forwarded to the API as-is") } func Test_FindDuplicate_RankedResults(t *testing.T) { @@ -104,11 +114,11 @@ func Test_FindDuplicate_RankedResults(t *testing.T) { assert.Equal(t, "1", capturedURL.Query().Get("page")) text := getTextResult(t, result) - var candidates []duplicateCandidate + var candidates []LegacyDuplicateCandidate require.NoError(t, json.Unmarshal([]byte(text.Text), &candidates)) require.Len(t, candidates, 2) - assert.Equal(t, "high", candidates[0].Confidence) + assert.Equal(t, "high", string(candidates[0].Confidence)) assert.True(t, candidates[0].LikelyDuplicate) require.NotNil(t, candidates[0].Score) assert.InDelta(t, 0.95, *candidates[0].Score, 0.0001) @@ -119,10 +129,48 @@ func Test_FindDuplicate_RankedResults(t *testing.T) { // A null score must decode successfully. assert.Nil(t, candidates[1].Score) - assert.Equal(t, "low", candidates[1].Confidence) + assert.Equal(t, "low", string(candidates[1].Confidence)) assert.False(t, candidates[1].LikelyDuplicate) } +func Test_FindDuplicate_ZeroPaginationIsForwarded(t *testing.T) { + serverTool := FindDuplicate(translations.NullTranslationHelper) + var capturedRawQuery string + client := mustNewGHClient(t, NewMockedHTTPClient(WithRequestMatchHandler( + endpointSemanticallySimilar, + http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + capturedRawQuery = r.URL.RawQuery + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("[]")) + }), + ))) + deps := BaseDeps{Client: client} + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "confidence_threshold": float64(0), + "page": float64(0), + "perPage": float64(0), + }) + result, err := serverTool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Equal(t, "page=0&per_page=0&threshold=0", capturedRawQuery) + + advertised := serverTool.Tool.InputSchema.(*jsonschema.Schema) + assert.Equal(t, 1.0, *advertised.Properties["page"].Minimum) + assert.Equal(t, 1.0, *advertised.Properties["perPage"].Minimum) + validation, err := issuePaginationValidationSchema(advertised).Resolve(nil) + require.NoError(t, err) + require.NoError(t, validation.Validate(map[string]any{ + "owner": "owner", "repo": "repo", "issue_number": float64(123), + "page": float64(0), + "perPage": float64(0), + })) +} + // Test_FindDuplicate_SanitizesIssueTitle asserts that candidate issue titles, which are // user-authored content from an arbitrary repository, are sanitized before being returned. // Without this the tool would forward hidden-instruction payloads straight to the model. @@ -162,7 +210,7 @@ func Test_FindDuplicate_SanitizesIssueTitle(t *testing.T) { require.False(t, result.IsError, "expected result to not be an error") text := getTextResult(t, result) - var candidates []duplicateCandidate + var candidates []DuplicateCandidate require.NoError(t, json.Unmarshal([]byte(text.Text), &candidates)) require.Len(t, candidates, 1) assert.Equal(t, sanitizedText, candidates[0].Issue.Title) @@ -218,7 +266,7 @@ func Test_FindDuplicate_EmptyResults(t *testing.T) { require.False(t, result.IsError, "empty results is a successful search") text := getTextResult(t, result) - var candidates []duplicateCandidate + var candidates []DuplicateCandidate require.NoError(t, json.Unmarshal([]byte(text.Text), &candidates)) assert.Empty(t, candidates) } diff --git a/pkg/github/issue_dependencies.go b/pkg/github/issue_dependencies.go index e553bab365..8766264605 100644 --- a/pkg/github/issue_dependencies.go +++ b/pkg/github/issue_dependencies.go @@ -51,7 +51,7 @@ Options are: } WithPagination(schema) - st := NewTool( + st := NewToolWithSchemaOptions[IssueDependencyReadInput, *IssueDependencyReadOutput]( ToolsetMetadataIssues, mcp.Tool{ Name: "issue_dependency_read", @@ -60,32 +60,27 @@ Options are: Title: t("TOOL_ISSUE_DEPENDENCY_READ_USER_TITLE", "Read issue dependencies"), ReadOnlyHint: true, }, - InputSchema: schema, + InputSchema: schema, + OutputSchema: issueDependencyReadOutputSchema(), }, scopes.PublicRead(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - method, err := RequiredParam[string](args, "method") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + inventory.TypedSchemaOptions{ValidationInputSchema: issuePaginationValidationSchema(schema), PreserveHandlerContent: true}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input IssueDependencyReadInput) (*mcp.CallToolResult, *IssueDependencyReadOutput, error) { + method, owner, repo, issueNumber := input.Method, input.Owner, input.Repo, input.IssueNumber + if method == "" { + return utils.NewToolResultError("missing required parameter: method"), nil, nil } - owner, err := RequiredParam[string](args, "owner") - if err != nil { + if err := validateIssueCoordinate(owner, repo, issueNumber); 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 + page, perPage := input.Page, input.PerPage + if page == 0 { + page = 1 } - issueNumber, err := RequiredInt(args, "issue_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if perPage == 0 { + perPage = 30 } - - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - opts := &github.ListOptions{Page: pagination.Page, PerPage: pagination.PerPage} + opts := &github.ListOptions{Page: page, PerPage: perPage} client, err := deps.GetClient(ctx) if err != nil { @@ -94,70 +89,98 @@ Options are: switch method { case "get_blocked_by": - result, err := GetIssueBlockedBy(ctx, client, owner, repo, issueNumber, opts) - return result, nil, err + return getIssueBlockedByOutput(ctx, client, owner, repo, issueNumber, opts) case "get_blocking": - result, err := GetIssueBlocking(ctx, client, owner, repo, issueNumber, opts) - return result, nil, err + return getIssueBlockingOutput(ctx, client, owner, repo, issueNumber, opts) default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } - }) + }, normalizeIssueStrings([]string{"method", "owner", "repo"}, nil), + normalizeIssueIntegers([]string{"issue_number"}, []string{"page", "perPage"}), + normalizeTypedReadArguments(nil, false)) st.FeatureRule = featureEnabledRule(FeatureFlagIssueDependencies) return st } // GetIssueBlockedBy lists the issues that block the given issue. func GetIssueBlockedBy(ctx context.Context, client *github.Client, owner, repo string, issueNumber int, opts *github.ListOptions) (*mcp.CallToolResult, error) { + result, _, err := getIssueBlockedByOutput(ctx, client, owner, repo, issueNumber, opts) + return result, err +} + +func getIssueBlockedByOutput(ctx context.Context, client *github.Client, owner, repo string, issueNumber int, opts *github.ListOptions) (*mcp.CallToolResult, *IssueDependencyReadOutput, error) { issues, resp, err := client.Issues.ListBlockedBy(ctx, owner, repo, int64(issueNumber), opts) if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list blocked-by issues", resp, err), nil + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list blocked-by issues", resp, err), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list blocked-by issues", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list blocked-by issues", resp, body), nil, nil } - return dependencyReadResult(issues, resp), nil + output := dependencyReadOutput(issues, resp) + return dependencyReadTextResult(issues, resp), output, nil } // GetIssueBlocking lists the issues that the given issue blocks. func GetIssueBlocking(ctx context.Context, client *github.Client, owner, repo string, issueNumber int, opts *github.ListOptions) (*mcp.CallToolResult, error) { + result, _, err := getIssueBlockingOutput(ctx, client, owner, repo, issueNumber, opts) + return result, err +} + +func getIssueBlockingOutput(ctx context.Context, client *github.Client, owner, repo string, issueNumber int, opts *github.ListOptions) (*mcp.CallToolResult, *IssueDependencyReadOutput, error) { issues, resp, err := client.Issues.ListBlocking(ctx, owner, repo, int64(issueNumber), opts) if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list blocking issues", resp, err), nil + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list blocking issues", resp, err), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list blocking issues", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list blocking issues", resp, body), nil, nil } - return dependencyReadResult(issues, resp), nil + output := dependencyReadOutput(issues, resp) + return dependencyReadTextResult(issues, resp), output, nil } -// dependencyReadResult projects a list of related issues into the minimal +// dependencyReadOutput projects a list of related issues into the minimal // dependency shape and attaches page-based pagination info. -func dependencyReadResult(issues []*github.Issue, resp *github.Response) *mcp.CallToolResult { - refs := make([]MinimalIssueRef, 0, len(issues)) +func dependencyReadOutput(issues []*github.Issue, resp *github.Response) *IssueDependencyReadOutput { + refs := make([]MinimalIssueDependencyRef, 0, len(issues)) for _, issue := range issues { if issue == nil { continue } - refs = append(refs, issueToDependencyRef(issue)) + refs = append(refs, dependencyRefOutput(issueToDependencyRef(issue))) + } + return &IssueDependencyReadOutput{ + Issues: refs, + PageInfo: IssueDependencyPageInfo{ + HasNextPage: resp.NextPage != 0, + NextPage: resp.NextPage, + }, + } +} + +func dependencyReadTextResult(issues []*github.Issue, resp *github.Response) *mcp.CallToolResult { + refs := make([]MinimalIssueRef, 0, len(issues)) + for _, issue := range issues { + if issue != nil { + refs = append(refs, issueToDependencyRef(issue)) + } } - return MarshalledTextResult(map[string]any{ - "issues": refs, - "pageInfo": map[string]any{ - "hasNextPage": resp.NextPage != 0, - "nextPage": resp.NextPage, + return MarshalledTextResult(IssueDependencyReadLegacyOutput{ + Issues: refs, + PageInfo: IssueDependencyPageInfo{ + HasNextPage: resp.NextPage != 0, + NextPage: resp.NextPage, }, }) } @@ -189,7 +212,7 @@ func issueToDependencyRef(issue *github.Issue) MinimalIssueRef { // expressed as "the blocked issue is blocked_by the blocking issue", so both // directions are served by the same endpoint pair with the two issues swapped. func IssueDependencyWrite(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := NewToolWithSchemaOptions[IssueDependencyWriteInput, *IssueDependencyWriteOutput]( ToolsetMetadataIssues, mcp.Tool{ Name: "issue_dependency_write", @@ -248,41 +271,27 @@ Options are: }, Required: []string{"method", "type", "owner", "repo", "issue_number", "related_issue_number"}, }, + OutputSchema: issueDependencyWriteOutputSchema(), }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - method, err := RequiredParam[string](args, "method") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - relationshipType, err := RequiredParam[string](args, "type") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - issueNumber, err := RequiredInt(args, "issue_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + inventory.TypedSchemaOptions{PreserveHandlerContent: true}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input IssueDependencyWriteInput) (*mcp.CallToolResult, *IssueDependencyWriteOutput, error) { + method, relationshipType := input.Method, input.Type + if method == "" { + return utils.NewToolResultError("missing required parameter: method"), nil, nil } - relatedIssueNumber, err := RequiredInt(args, "related_issue_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if relationshipType == "" { + return utils.NewToolResultError("missing required parameter: type"), nil, nil } - relatedOwner, err := OptionalParam[string](args, "related_owner") - if err != nil { + owner, repo, issueNumber := input.Owner, input.Repo, input.IssueNumber + if err := validateIssueCoordinate(owner, repo, issueNumber); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - relatedRepo, err := OptionalParam[string](args, "related_repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + relatedIssueNumber := input.RelatedIssueNumber + if relatedIssueNumber == 0 { + return utils.NewToolResultError("missing required parameter: related_issue_number"), nil, nil } + relatedOwner, relatedRepo := input.RelatedOwner, input.RelatedRepo if relatedOwner == "" { relatedOwner = owner } @@ -318,9 +327,9 @@ Options are: return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } - result, err := writeIssueDependency(ctx, client, method, blocked, blocking) - return result, nil, err - }) + return writeIssueDependency(ctx, client, method, blocked, blocking) + }, normalizeIssueStrings([]string{"method", "type", "owner", "repo"}, []string{"related_owner", "related_repo"}), + normalizeIssueIntegers([]string{"issue_number", "related_issue_number"}, nil), normalizeIssueDependencyWriteArguments) st.FeatureRule = featureEnabledRule(FeatureFlagIssueDependencies) return st } @@ -334,12 +343,12 @@ type issueCoordinate struct { // writeIssueDependency resolves the blocking issue to its global database ID and // then adds or removes the blocked-by relationship on the blocked issue. -func writeIssueDependency(ctx context.Context, client *github.Client, method string, blocked, blocking issueCoordinate) (*mcp.CallToolResult, error) { +func writeIssueDependency(ctx context.Context, client *github.Client, method string, blocked, blocking issueCoordinate) (*mcp.CallToolResult, *IssueDependencyWriteOutput, error) { // The REST API identifies the blocking issue by its global database ID // (not its number), so resolve the number to an ID first. blockingIssue, resp, err := client.Issues.Get(ctx, blocking.owner, blocking.repo, blocking.number) if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to resolve blocking issue", resp, err), nil + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to resolve blocking issue", resp, err), nil, nil } _ = resp.Body.Close() blockingID := blockingIssue.GetID() @@ -348,41 +357,43 @@ func writeIssueDependency(ctx context.Context, client *github.Client, method str case "add": blockedIssue, opResp, err := client.Issues.AddBlockedBy(ctx, blocked.owner, blocked.repo, int64(blocked.number), github.IssueDependencyRequest{IssueID: blockingID}) if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add issue dependency", opResp, err), nil + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add issue dependency", opResp, err), nil, nil } defer func() { _ = opResp.Body.Close() }() if opResp.StatusCode != http.StatusCreated { body, readErr := io.ReadAll(opResp.Body) if readErr != nil { - return nil, fmt.Errorf("failed to read response body: %w", readErr) + return nil, nil, fmt.Errorf("failed to read response body: %w", readErr) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to add issue dependency", opResp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to add issue dependency", opResp, body), nil, nil } - return dependencyWriteResult("dependency added", blockedIssue, blockingIssue, blocked, blocking), nil + output := dependencyWriteOutput("dependency added", blockedIssue, blockingIssue, blocked, blocking) + return dependencyWriteTextResult("dependency added", blockedIssue, blockingIssue, blocked, blocking), output, nil case "remove": blockedIssue, opResp, err := client.Issues.RemoveBlockedBy(ctx, blocked.owner, blocked.repo, int64(blocked.number), blockingID) if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to remove issue dependency", opResp, err), nil + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to remove issue dependency", opResp, err), nil, nil } defer func() { _ = opResp.Body.Close() }() if opResp.StatusCode != http.StatusOK { body, readErr := io.ReadAll(opResp.Body) if readErr != nil { - return nil, fmt.Errorf("failed to read response body: %w", readErr) + return nil, nil, fmt.Errorf("failed to read response body: %w", readErr) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to remove issue dependency", opResp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to remove issue dependency", opResp, body), nil, nil } - return dependencyWriteResult("dependency removed", blockedIssue, blockingIssue, blocked, blocking), nil + output := dependencyWriteOutput("dependency removed", blockedIssue, blockingIssue, blocked, blocking) + return dependencyWriteTextResult("dependency removed", blockedIssue, blockingIssue, blocked, blocking), output, nil default: - return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } } -// dependencyWriteResult builds the minimal description of the affected issues. +// dependencyWriteOutput builds the minimal description of the affected issues. // The blocked issue comes from the mutation response and the blocking issue from // the earlier resolve; each falls back to its known coordinate when the API // response omits the repository URL. -func dependencyWriteResult(message string, blockedIssue, blockingIssue *github.Issue, blocked, blocking issueCoordinate) *mcp.CallToolResult { +func dependencyWriteOutput(message string, blockedIssue, blockingIssue *github.Issue, blocked, blocking issueCoordinate) *IssueDependencyWriteOutput { blockedRef := issueToDependencyRef(blockedIssue) if blockedRef.Repository == "" { blockedRef.Repository = blocked.owner + "/" + blocked.repo @@ -391,9 +402,23 @@ func dependencyWriteResult(message string, blockedIssue, blockingIssue *github.I if blockingRef.Repository == "" { blockingRef.Repository = blocking.owner + "/" + blocking.repo } - return MarshalledTextResult(map[string]any{ - "message": message, - "blocked_issue": blockedRef, - "blocking_issue": blockingRef, + return &IssueDependencyWriteOutput{ + BlockedIssue: dependencyRefOutput(blockedRef), BlockingIssue: dependencyRefOutput(blockingRef), Message: message, + } +} + +func dependencyWriteTextResult(message string, blockedIssue, blockingIssue *github.Issue, blocked, blocking issueCoordinate) *mcp.CallToolResult { + return MarshalledTextResult(IssueDependencyWriteLegacyOutput{ + BlockedIssue: issueToLegacyDependencyRef(blockedIssue, blocked), + BlockingIssue: issueToLegacyDependencyRef(blockingIssue, blocking), + Message: message, }) } + +func issueToLegacyDependencyRef(issue *github.Issue, coordinate issueCoordinate) MinimalIssueRef { + ref := issueToDependencyRef(issue) + if ref.Repository == "" { + ref.Repository = coordinate.owner + "/" + coordinate.repo + } + return ref +} diff --git a/pkg/github/issue_fields.go b/pkg/github/issue_fields.go index 325fb548e2..763c63548c 100644 --- a/pkg/github/issue_fields.go +++ b/pkg/github/issue_fields.go @@ -104,7 +104,7 @@ type issueFieldsOrgQuery struct { // ListIssueFields creates a tool to list issue field definitions for a repository or organization. func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := NewToolWithSchemaOptions[IssueMetadataInput, []IssueFieldOutput]( ToolsetMetadataIssues, mcp.Tool{ Name: "list_issue_fields", @@ -127,16 +127,14 @@ func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool }, Required: []string{"owner"}, }, + OutputSchema: listIssueFieldsOutputSchema(), }, repositoryOrOrganizationScopeAccess(), - 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 + inventory.TypedSchemaOptions{PreserveHandlerContent: true}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input IssueMetadataInput) (*mcp.CallToolResult, []IssueFieldOutput, error) { + owner, repo := input.Owner, input.Repo + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } gqlClient, err := deps.GetGQLClient(ctx) @@ -164,8 +162,8 @@ func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool } else { result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoMetadata) } - return result, nil, nil - }) + return result, issueFieldOutputs(fields), nil + }, normalizeIssueStrings([]string{"owner"}, []string{"repo"})) return st } diff --git a/pkg/github/issue_tool_types.go b/pkg/github/issue_tool_types.go new file mode 100644 index 0000000000..c4a5149aa2 --- /dev/null +++ b/pkg/github/issue_tool_types.go @@ -0,0 +1,587 @@ +package github + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" +) + +type IssueMetadataInput struct { + Owner string `json:"owner"` + Repo string `json:"repo,omitempty"` +} + +type IssueReadInput struct { + Method string `json:"method"` + Owner string `json:"owner"` + Repo string `json:"repo"` + IssueNumber int `json:"issue_number"` + Page int `json:"page,omitempty"` + PerPage int `json:"perPage,omitempty"` +} + +type SubIssueWriteInput struct { + Method string `json:"method"` + Owner string `json:"owner"` + Repo string `json:"repo"` + IssueNumber int `json:"issue_number"` + SubIssueID int `json:"sub_issue_id"` + ReplaceParent bool `json:"replace_parent,omitempty"` + AfterID int `json:"after_id,omitempty"` + BeforeID int `json:"before_id,omitempty"` +} + +type IssueReadParentOutput struct { + Parent *MinimalIssueDependencyRef `json:"parent"` +} + +type IssueReadLabelOutput struct { + ID string `json:"id"` + Name string `json:"name"` + Color string `json:"color"` + Description string `json:"description"` +} + +type IssueReadLabelsOutput struct { + Labels []IssueReadLabelOutput `json:"labels"` + TotalCount int `json:"totalCount"` +} + +type IssueReadOutput struct { + Issue *MinimalIssue + Comments []MinimalIssueComment + SubIssues []MinimalIssue + Parent *IssueReadParentOutput + Labels *IssueReadLabelsOutput +} + +func (output IssueReadOutput) MarshalJSON() ([]byte, error) { + switch { + case output.Issue != nil: + return json.Marshal(output.Issue) + case output.Comments != nil: + return json.Marshal(output.Comments) + case output.SubIssues != nil: + return json.Marshal(output.SubIssues) + case output.Parent != nil: + return json.Marshal(output.Parent) + case output.Labels != nil: + return json.Marshal(output.Labels) + default: + return []byte("null"), nil + } +} + +type IssueTypeOutput struct { + ID *int64 `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + Description *string `json:"description,omitempty"` + Color *string `json:"color,omitempty"` + IsEnabled *bool `json:"is_enabled,omitempty"` +} + +func issueTypeOutputs(types []*github.IssueType) []*IssueTypeOutput { + if types == nil { + return nil + } + output := make([]*IssueTypeOutput, len(types)) + for i, item := range types { + if item != nil { + output[i] = &IssueTypeOutput{ + ID: item.ID, Name: item.Name, Description: item.Description, + Color: item.Color, IsEnabled: item.IsEnabled, + } + } + } + return output +} + +type AddIssueCommentInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + IssueNumber int `json:"issue_number"` + CommentID *int64 `json:"comment_id,omitempty"` + Body *string `json:"body,omitempty"` + Reaction *string `json:"reaction,omitempty"` +} + +type UpdateIssueCommentInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + CommentID int64 `json:"comment_id"` + Body *string `json:"body"` +} + +type IssueCommentOutput struct { + ID string `json:"id"` + HTMLURL string `json:"html_url,omitempty"` +} + +func (output IssueCommentOutput) MarshalJSON() ([]byte, error) { + if output.ID == "" && output.HTMLURL == "" { + return []byte("null"), nil + } + type wireOutput IssueCommentOutput + return json.Marshal(wireOutput(output)) +} + +type IssueReactionOutput struct { + ID string `json:"id"` +} + +type IssueCommentAndReactionOutput struct { + Comment IssueCommentOutput `json:"comment"` + Reaction IssueReactionOutput `json:"reaction"` +} + +type AddIssueCommentOutput struct { + Single *IssueCommentOutput + Combined *IssueCommentAndReactionOutput +} + +func (output AddIssueCommentOutput) MarshalJSON() ([]byte, error) { + if output.Combined != nil { + return json.Marshal(output.Combined) + } + return json.Marshal(output.Single) +} + +type AddIssueCommentLegacyOutput struct { + Single *MinimalResponse + Combined *IssueCommentAndReactionLegacyOutput +} + +type IssueCommentAndReactionLegacyOutput struct { + Comment MinimalResponse `json:"comment"` + Reaction MinimalResponse `json:"reaction"` +} + +func (output AddIssueCommentLegacyOutput) MarshalJSON() ([]byte, error) { + if output.Combined != nil { + return json.Marshal(output.Combined) + } + return json.Marshal(output.Single) +} + +func addIssueCommentOutputSchema() *jsonschema.Schema { + comment := func() *jsonschema.Schema { + return issueCommentOutputObjectSchema(true) + } + reaction := func() *jsonschema.Schema { + return &jsonschema.Schema{Type: "object", Properties: map[string]*jsonschema.Schema{ + "id": {Type: "string", Description: "The ID of the created reaction."}, + }, Required: []string{"id"}, AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}} + } + return &jsonschema.Schema{ + Types: []string{"object", "null"}, + OneOf: []*jsonschema.Schema{ + {Type: "null"}, + comment(), + { + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "id": {Type: "string", Description: "The ID of the created reaction."}, + }, + Required: []string{"id"}, + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + }, + { + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "comment": comment(), "reaction": reaction(), + }, + Required: []string{"comment", "reaction"}, + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + }, + }, + } +} + +func issueCommentOutputSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + Types: []string{"object", "null"}, + OneOf: []*jsonschema.Schema{ + {Type: "null"}, + issueCommentOutputObjectSchema(true), + }, + } +} + +func issueCommentOutputObjectSchema(includeURL bool) *jsonschema.Schema { + properties := map[string]*jsonschema.Schema{ + "id": {Type: "string", Description: "The ID of the created comment or reaction."}, + } + required := []string{"id"} + if includeURL { + properties["html_url"] = &jsonschema.Schema{ + Type: "string", Description: "The web URL of the created issue comment.", + } + required = append(required, "html_url") + } + return &jsonschema.Schema{ + Type: "object", Properties: properties, Required: required, + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + } +} + +type IssueDependencyState string + +type MinimalIssueDependencyRef struct { + Number int `json:"number"` + Title string `json:"title"` + State IssueDependencyState `json:"state"` + HTMLURL string `json:"html_url"` + Repository string `json:"repository,omitempty"` +} + +func dependencyRefOutput(ref MinimalIssueRef) MinimalIssueDependencyRef { + return MinimalIssueDependencyRef{ + Number: ref.Number, Title: ref.Title, State: IssueDependencyState(ref.State), + HTMLURL: ref.URL, Repository: ref.Repository, + } +} + +type IssueDependencyReadInput struct { + Method string `json:"method"` + Owner string `json:"owner"` + Repo string `json:"repo"` + IssueNumber int `json:"issue_number"` + Page int `json:"page,omitempty"` + PerPage int `json:"perPage,omitempty"` +} + +type IssueDependencyPageInfo struct { + HasNextPage bool `json:"hasNextPage"` + NextPage int `json:"nextPage"` +} + +type IssueDependencyReadOutput struct { + Issues []MinimalIssueDependencyRef `json:"issues"` + PageInfo IssueDependencyPageInfo `json:"pageInfo"` +} + +type IssueDependencyReadLegacyOutput struct { + Issues []MinimalIssueRef `json:"issues"` + PageInfo IssueDependencyPageInfo `json:"pageInfo"` +} + +type IssueDependencyWriteInput struct { + Method string `json:"method"` + Type string `json:"type"` + Owner string `json:"owner"` + Repo string `json:"repo"` + IssueNumber int `json:"issue_number"` + RelatedIssueNumber int `json:"related_issue_number"` + RelatedOwner string `json:"related_owner,omitempty"` + RelatedRepo string `json:"related_repo,omitempty"` +} + +type IssueDependencyWriteOutput struct { + BlockedIssue MinimalIssueDependencyRef `json:"blocked_issue"` + BlockingIssue MinimalIssueDependencyRef `json:"blocking_issue"` + Message string `json:"message"` +} + +type IssueDependencyWriteLegacyOutput struct { + BlockedIssue MinimalIssueRef `json:"blocked_issue"` + BlockingIssue MinimalIssueRef `json:"blocking_issue"` + Message string `json:"message"` +} + +type DuplicateIssueState string + +type MinimalDuplicateIssue struct { + Number int `json:"number"` + Title string `json:"title"` + State DuplicateIssueState `json:"state"` + HTMLURL string `json:"html_url"` +} + +type DuplicateConfidence string + +type DuplicateCandidate struct { + Issue MinimalDuplicateIssue `json:"issue"` + Score *float64 `json:"score"` + Confidence DuplicateConfidence `json:"confidence"` + LikelyDuplicate bool `json:"likely_duplicate"` +} + +type LegacyDuplicateCandidate struct { + Issue MinimalIssueRef `json:"issue"` + Score *float64 `json:"score"` + Confidence DuplicateConfidence `json:"confidence"` + LikelyDuplicate bool `json:"likely_duplicate"` +} + +type FindDuplicateInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + IssueNumber int `json:"issue_number"` + ConfidenceThreshold *float64 `json:"confidence_threshold,omitempty"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` +} + +type IssueFieldOutput 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"` + Visibility string `json:"visibility"` + Options []IssueFieldOptionOutput `json:"options,omitempty"` +} + +type IssueFieldOptionOutput struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + Color string `json:"color"` + Priority *int `json:"priority,omitempty"` +} + +func issueFieldOutputs(fields []IssueField) []IssueFieldOutput { + if fields == nil { + return nil + } + output := make([]IssueFieldOutput, len(fields)) + for i, field := range fields { + output[i] = IssueFieldOutput{ + ID: field.ID, DatabaseID: field.DatabaseID, Name: field.Name, + Description: field.Description, DataType: field.DataType, + Visibility: field.Visibility, + } + if field.Options != nil { + output[i].Options = make([]IssueFieldOptionOutput, len(field.Options)) + for j, option := range field.Options { + output[i].Options[j] = IssueFieldOptionOutput(option) + } + } + } + return output +} + +func issueOutputSchema[T any]() *jsonschema.Schema { + schema, err := inventory.CachedSchemaFor[T](nil) + if err != nil { + panic(fmt.Sprintf("failed to generate issue output schema: %v", err)) + } + return inventory.CloneSchema(schema) +} + +func issuePaginationValidationSchema(advertised *jsonschema.Schema) *jsonschema.Schema { + validation := inventory.CloneSchemaWithoutDefaults(advertised) + for _, name := range []string{"page", "perPage"} { + property := validation.Properties[name] + property.Minimum = new(0.0) + } + return validation +} + +func issueReadOutputSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + Types: []string{"array", "object", "null"}, + OneOf: []*jsonschema.Schema{ + issueOutputSchema[*MinimalIssue](), + issueOutputSchema[[]MinimalIssueComment](), + issueOutputSchema[[]MinimalIssue](), + issueOutputSchema[*IssueReadParentOutput](), + issueOutputSchema[*IssueReadLabelsOutput](), + {Type: "null"}, + }, + } +} + +func listIssueFieldsOutputSchema() *jsonschema.Schema { + schema := issueOutputSchema[[]IssueFieldOutput]() + schema.Items.Properties["id"].Description = "The node ID of the issue field." + schema.Items.Properties["full_database_id"].Description = "The database ID of the issue field, when available." + schema.Items.Properties["name"].Description = "The field name." + schema.Items.Properties["description"].Description = "The field description, when available." + schema.Items.Properties["data_type"].Description = "The field value type." + schema.Items.Properties["visibility"].Description = "The visibility policy for the field." + schema.Items.Properties["options"].Description = "Available values for single-select fields." + schema.Items.Properties["data_type"].Enum = []any{"TEXT", "NUMBER", "DATE", "SINGLE_SELECT"} + schema.Items.Properties["options"].Items.Properties["id"].Description = "The option node ID." + schema.Items.Properties["options"].Items.Properties["name"].Description = "The option name." + schema.Items.Properties["options"].Items.Properties["description"].Description = "The option description, when available." + schema.Items.Properties["options"].Items.Properties["color"].Description = "The option color." + schema.Items.Properties["options"].Items.Properties["priority"].Description = "Display order of this option, when set." + return schema +} + +func issueTypeOutputSchema() *jsonschema.Schema { + schema := issueOutputSchema[[]*IssueTypeOutput]() + schema.Items.Properties["id"].Description = "The issue type ID." + schema.Items.Properties["name"].Description = "The issue type name." + schema.Items.Properties["description"].Description = "The issue type description, when available." + schema.Items.Properties["color"].Description = "The issue type color, when set." + schema.Items.Properties["is_enabled"].Description = "Whether this issue type is enabled." + return schema +} + +func issueDependencyReadOutputSchema() *jsonschema.Schema { + schema := issueOutputSchema[*IssueDependencyReadOutput]() + ref := schema.Properties["issues"].Items.Properties + ref["number"].Description = "The related issue number." + ref["title"].Description = "The sanitized title of the related issue." + ref["state"].Description = "The related issue state." + ref["html_url"].Description = "The web URL of the related issue." + ref["repository"].Description = "The related repository in owner/name form." + schema.Properties["issues"].Description = "Related issues in the requested dependency direction." + schema.Properties["pageInfo"].Properties["hasNextPage"].Description = "Whether another page of related issues is available." + schema.Properties["pageInfo"].Properties["nextPage"].Description = "The next page number, or 0 when no next page exists." + return schema +} + +func issueDependencyWriteOutputSchema() *jsonschema.Schema { + schema := issueOutputSchema[*IssueDependencyWriteOutput]() + for _, field := range []string{"blocked_issue", "blocking_issue"} { + ref := schema.Properties[field].Properties + ref["number"].Description = "The issue number." + ref["title"].Description = "The sanitized issue title." + ref["state"].Description = "The issue state." + ref["html_url"].Description = "The web URL of the issue." + ref["repository"].Description = "The repository in owner/name form." + } + schema.Properties["message"].Description = "Whether the dependency relationship was added or removed." + return schema +} + +func findDuplicateOutputSchema() *jsonschema.Schema { + schema := issueOutputSchema[[]DuplicateCandidate]() + issue := schema.Items.Properties["issue"].Properties + issue["number"].Description = "The candidate issue number." + issue["title"].Description = "The sanitized candidate issue title." + issue["state"].Description = "The candidate issue state." + issue["html_url"].Description = "The web URL of the candidate issue." + schema.Items.Properties["score"].Description = "Similarity score on the API-defined scale; null when the API does not return a score." + schema.Items.Properties["confidence"].Description = "The API-provided confidence category." + schema.Items.Properties["likely_duplicate"].Description = "Whether the API considers this candidate a likely duplicate." + return schema +} + +func validateIssueCoordinate(owner, repo string, number int) error { + if owner == "" { + return fmt.Errorf("missing required parameter: owner") + } + if repo == "" { + return fmt.Errorf("missing required parameter: repo") + } + if number == 0 { + return fmt.Errorf("missing required parameter: issue_number") + } + return nil +} + +// Only the dependency writer historically accepted case-insensitive methods +// and directions. Do not broaden the dependency reader's method handling. +func normalizeIssueDependencyWriteArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]json.RawMessage + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + for _, field := range []string{"method", "type"} { + var value string + if err := json.Unmarshal(args[field], &value); err == nil { + args[field], _ = json.Marshal(strings.ToLower(value)) + } + } + return json.Marshal(args) +} + +func normalizeIssueCommentID(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + if value, exists := args["comment_id"]; exists { + id, err := toInt64(value) + if err != nil { + return nil, fmt.Errorf("parameter comment_id is not a valid number: %w", err) + } + args["comment_id"] = id + } + return json.Marshal(args) +} + +func normalizeIssueStrings(required, optional []string) inventory.InputNormalizer { + return func(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + for _, field := range required { + if _, err := RequiredParam[string](args, field); err != nil { + return nil, err + } + } + + for _, field := range optional { + if _, err := OptionalParam[string](args, field); err != nil { + return nil, err + } + } + return raw, nil + } +} + +func normalizeIssueIntegers(required, optional []string) inventory.InputNormalizer { + return func(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + for _, field := range required { + value, err := RequiredInt(args, field) + if err != nil { + return nil, err + } + args[field] = value + } + for _, field := range optional { + if _, exists := args[field]; !exists { + continue + } + value, err := OptionalIntParam(args, field) + if err != nil { + return nil, err + } + args[field] = value + } + return json.Marshal(args) + } +} + +func normalizeIssueBooleans(fields []string) inventory.InputNormalizer { + return func(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + for _, field := range fields { + if _, exists := args[field]; !exists { + continue + } + if _, err := OptionalParam[bool](args, field); err != nil { + return nil, err + } + } + return raw, nil + } +} + +func normalizeDuplicateThreshold(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + if _, err := OptionalParam[float64](args, "confidence_threshold"); err != nil { + return nil, err + } + return raw, nil +} diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 51ff97dd04..4ff681d8bb 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -822,7 +822,7 @@ func IssueRead(t translations.TranslationHelperFunc) inventory.ServerTool { } WithPagination(schema) - return NewTool( + return NewToolWithSchemaOptions[IssueReadInput, IssueReadOutput]( ToolsetMetadataIssues, mcp.Tool{ Name: "issue_read", @@ -831,41 +831,29 @@ func IssueRead(t translations.TranslationHelperFunc) inventory.ServerTool { Title: t("TOOL_ISSUE_READ_USER_TITLE", "Get issue details"), ReadOnlyHint: true, }, - InputSchema: schema, + InputSchema: schema, + OutputSchema: issueReadOutputSchema(), }, scopes.PublicRead(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - method, err := RequiredParam[string](args, "method") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - issueNumber, err := RequiredInt(args, "issue_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + inventory.TypedSchemaOptions{ValidationInputSchema: issuePaginationValidationSchema(schema), PreserveHandlerContent: true}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input IssueReadInput) (*mcp.CallToolResult, IssueReadOutput, error) { + method, owner, repo, issueNumber := input.Method, input.Owner, input.Repo, input.IssueNumber + pagination := PaginationParams{Page: input.Page, PerPage: input.PerPage} + if pagination.Page == 0 { + pagination.Page = 1 } - - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if pagination.PerPage == 0 { + pagination.PerPage = 30 } client, err := deps.GetClient(ctx) if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), IssueReadOutput{}, nil } gqlClient, err := deps.GetGQLClient(ctx) if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub graphql client", err), nil, nil + return utils.NewToolResultErrorFromErr("failed to get GitHub graphql client", err), IssueReadOutput{}, nil } // attachIFC adds the IFC label to a successful tool result when @@ -876,23 +864,91 @@ func IssueRead(t translations.TranslationHelperFunc) inventory.ServerTool { switch method { case "get": result, err := GetIssue(ctx, client, deps, owner, repo, issueNumber) - return attachIFC(result), nil, err + return issueReadTypedResult(method, attachIFC(result), err) case "get_comments": result, err := GetIssueComments(ctx, client, deps, owner, repo, issueNumber, pagination) - return attachIFC(result), nil, err + return issueReadTypedResult(method, attachIFC(result), err) case "get_sub_issues": result, err := GetSubIssues(ctx, client, deps, owner, repo, issueNumber, pagination) - return attachIFC(result), nil, err + return issueReadTypedResult(method, attachIFC(result), err) case "get_parent": result, err := GetIssueParent(ctx, gqlClient, deps, owner, repo, issueNumber) - return attachIFC(result), nil, err + return issueReadTypedResult(method, attachIFC(result), err) case "get_labels": result, err := GetIssueLabels(ctx, gqlClient, owner, repo, issueNumber) - return attachIFC(result), nil, err + return issueReadTypedResult(method, attachIFC(result), err) default: - return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), IssueReadOutput{}, nil } - }) + }, + normalizeIssueStrings([]string{"method", "owner", "repo"}, nil), + normalizeIssueIntegers([]string{"issue_number"}, []string{"page", "perPage"})) +} + +func issueReadTypedResult(method string, result *mcp.CallToolResult, err error) (*mcp.CallToolResult, IssueReadOutput, error) { + if err != nil || result == nil || result.IsError { + return result, IssueReadOutput{}, err + } + if len(result.Content) != 1 { + return nil, IssueReadOutput{}, fmt.Errorf("issue read returned %d content items, expected one", len(result.Content)) + } + text, ok := result.Content[0].(*mcp.TextContent) + if !ok { + return nil, IssueReadOutput{}, fmt.Errorf("issue read returned %T content, expected text", result.Content[0]) + } + + switch method { + case "get": + var issue MinimalIssue + if err := json.Unmarshal([]byte(text.Text), &issue); err != nil { + return nil, IssueReadOutput{}, fmt.Errorf("failed to decode issue read output: %w", err) + } + return result, IssueReadOutput{Issue: &issue}, nil + case "get_comments": + var comments []MinimalIssueComment + if err := json.Unmarshal([]byte(text.Text), &comments); err != nil { + return nil, IssueReadOutput{}, fmt.Errorf("failed to decode issue comments output: %w", err) + } + if comments == nil { + comments = make([]MinimalIssueComment, 0) + } + return result, IssueReadOutput{Comments: comments}, nil + case "get_sub_issues": + var subIssues []*github.SubIssue + if err := json.Unmarshal([]byte(text.Text), &subIssues); err != nil { + return nil, IssueReadOutput{}, fmt.Errorf("failed to decode sub-issues output: %w", err) + } + minimal := make([]MinimalIssue, 0, len(subIssues)) + for _, subIssue := range subIssues { + if subIssue != nil { + minimal = append(minimal, convertToMinimalIssue((*github.Issue)(subIssue))) + } + } + return result, IssueReadOutput{SubIssues: minimal}, nil + case "get_parent": + var legacy struct { + Parent *MinimalIssueRef `json:"parent"` + } + if err := json.Unmarshal([]byte(text.Text), &legacy); err != nil { + return nil, IssueReadOutput{}, fmt.Errorf("failed to decode issue parent output: %w", err) + } + var parent *MinimalIssueDependencyRef + if legacy.Parent != nil { + parent = new(dependencyRefOutput(*legacy.Parent)) + } + return result, IssueReadOutput{Parent: &IssueReadParentOutput{Parent: parent}}, nil + case "get_labels": + var labels IssueReadLabelsOutput + if err := json.Unmarshal([]byte(text.Text), &labels); err != nil { + return nil, IssueReadOutput{}, fmt.Errorf("failed to decode issue labels output: %w", err) + } + if labels.Labels == nil { + labels.Labels = make([]IssueReadLabelOutput, 0) + } + return result, IssueReadOutput{Labels: &labels}, nil + default: + return nil, IssueReadOutput{}, fmt.Errorf("unknown issue read method %q", method) + } } func GetIssue(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int) (*mcp.CallToolResult, error) { @@ -1261,7 +1317,7 @@ func GetIssueLabels(ctx context.Context, client *githubv4.Client, owner string, // ListIssueTypes creates a tool to list defined issue types for an organization or repository. // This can be used to understand supported issue type values for creating or updating issues. func ListIssueTypes(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := NewToolWithSchemaOptions[IssueMetadataInput, []*IssueTypeOutput]( ToolsetMetadataIssues, mcp.Tool{ Name: "list_issue_types", @@ -1284,16 +1340,14 @@ func ListIssueTypes(t translations.TranslationHelperFunc) inventory.ServerTool { }, Required: []string{"owner"}, }, + OutputSchema: issueTypeOutputSchema(), }, repositoryOrOrganizationScopeAccess(), - 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 + inventory.TypedSchemaOptions{PreserveHandlerContent: true}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input IssueMetadataInput) (*mcp.CallToolResult, []*IssueTypeOutput, error) { + owner, repo := input.Owner, input.Repo + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } client, err := deps.GetClient(ctx) @@ -1329,7 +1383,7 @@ func ListIssueTypes(t translations.TranslationHelperFunc) inventory.ServerTool { result := utils.NewToolResultText(string(r)) result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoMetadata) - return result, nil, nil + return result, issueTypeOutputs(issueTypes), nil } issueTypes, resp, err := client.Organizations.ListIssueTypes(ctx, owner) @@ -1357,14 +1411,14 @@ func ListIssueTypes(t translations.TranslationHelperFunc) inventory.ServerTool { // than a single repo, so confidentiality is conservatively treated // as private (restricted to org members). result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelRepoMetadata(true)) - return result, nil, nil - }) + return result, issueTypeOutputs(issueTypes), nil + }, normalizeIssueStrings([]string{"owner"}, []string{"repo"})) return st } // AddIssueComment creates a tool to add a comment or reaction to an issue. func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithSchemaOptions[AddIssueCommentInput, *AddIssueCommentOutput]( ToolsetMetadataIssues, mcp.Tool{ Name: "add_issue_comment", @@ -1406,40 +1460,30 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool }, Required: []string{"owner", "repo", "issue_number"}, }, + OutputSchema: addIssueCommentOutputSchema(), }, publicRepositoryWriteScopeAccess(), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - issueNumber, err := RequiredInt(args, "issue_number") - if err != nil { + inventory.TypedSchemaOptions{PreserveHandlerContent: true}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input AddIssueCommentInput) (*mcp.CallToolResult, *AddIssueCommentOutput, error) { + owner, repo, issueNumber := input.Owner, input.Repo, input.IssueNumber + if err := validateIssueCoordinate(owner, repo, issueNumber); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } var commentID int64 - hasCommentID := false - if value, ok := args["comment_id"]; ok { - commentID, err = toInt64(value) - if err != nil { - return utils.NewToolResultError(fmt.Sprintf("parameter comment_id is not a valid number: %v", err)), nil, nil - } + hasCommentID := input.CommentID != nil + if hasCommentID { + commentID = *input.CommentID if commentID < 1 { return utils.NewToolResultError("comment_id must be greater than 0"), nil, nil } - hasCommentID = true } - body, hasBody, err := OptionalParamOK[string](args, "body") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + hasBody, hasReaction := input.Body != nil, input.Reaction != nil + var body, reactionContent string + if hasBody { + body = *input.Body } - reactionContent, hasReaction, err := OptionalParamOK[string](args, "reaction") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if hasReaction { + reactionContent = *input.Reaction } if hasCommentID && hasBody { return utils.NewToolResultError("comment_id cannot be combined with body"), nil, nil @@ -1531,31 +1575,36 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool } } - var result any + legacyResult := &AddIssueCommentLegacyOutput{} + result := &AddIssueCommentOutput{} switch { case hasBody && hasReaction: - result = map[string]MinimalResponse{ - "comment": *commentResponse, - "reaction": *reactionResponse, + legacyResult.Combined = &IssueCommentAndReactionLegacyOutput{Comment: *commentResponse, Reaction: *reactionResponse} + result.Combined = &IssueCommentAndReactionOutput{ + Comment: IssueCommentOutput{ID: commentResponse.ID, HTMLURL: commentResponse.URL}, + Reaction: IssueReactionOutput{ID: reactionResponse.ID}, } case hasReaction: - result = reactionResponse + legacyResult.Single = reactionResponse + result.Single = &IssueCommentOutput{ID: reactionResponse.ID} default: - result = commentResponse + legacyResult.Single = commentResponse + result.Single = &IssueCommentOutput{ID: commentResponse.ID, HTMLURL: commentResponse.URL} } - r, err := json.Marshal(result) + r, err := json.Marshal(legacyResult) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil - }) + return utils.NewToolResultText(string(r)), result, nil + }, normalizeIssueStrings([]string{"owner", "repo"}, []string{"body", "reaction"}), + normalizeIssueIntegers([]string{"issue_number"}, nil), normalizeIssueCommentID) } // UpdateIssueComment creates a tool to update an issue or pull request conversation comment. func UpdateIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewToolWithSchemaOptions[UpdateIssueCommentInput, *IssueCommentOutput]( ToolsetMetadataIssues, mcp.Tool{ Name: "update_issue_comment", @@ -1588,31 +1637,28 @@ func UpdateIssueComment(t translations.TranslationHelperFunc) inventory.ServerTo }, Required: []string{"owner", "repo", "comment_id", "body"}, }, + OutputSchema: issueCommentOutputSchema(), }, publicRepositoryWriteScopeAccess(), - 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 + inventory.TypedSchemaOptions{PreserveHandlerContent: true}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input UpdateIssueCommentInput) (*mcp.CallToolResult, *IssueCommentOutput, error) { + owner, repo, commentID := input.Owner, input.Repo, input.CommentID + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - commentID, err := RequiredBigInt(args, "comment_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + if commentID == 0 { + return utils.NewToolResultError("missing required parameter: comment_id"), nil, nil } if commentID < 1 { return utils.NewToolResultError("comment_id must be greater than 0"), nil, nil } - body, hasBody, err := OptionalParamOK[string](args, "body") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - if !hasBody { + if input.Body == nil { return utils.NewToolResultError("missing required parameter: body"), nil, nil } + body := *input.Body if body == "" { return utils.NewToolResultError("body cannot be empty when provided"), nil, nil } @@ -1632,16 +1678,18 @@ func UpdateIssueComment(t translations.TranslationHelperFunc) inventory.ServerTo return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update issue comment", resp, err), nil, nil } - r, err := json.Marshal(MinimalResponse{ + legacyOutput := &MinimalResponse{ ID: fmt.Sprintf("%d", updatedComment.GetID()), URL: updatedComment.GetHTMLURL(), - }) + } + output := &IssueCommentOutput{ID: legacyOutput.ID, HTMLURL: legacyOutput.URL} + r, err := json.Marshal(legacyOutput) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil - }) + return utils.NewToolResultText(string(r)), output, nil + }, normalizeIssueStrings([]string{"owner", "repo"}, []string{"body"}), normalizeIssueCommentID) } func isValidIssueReaction(reaction string) bool { @@ -1664,7 +1712,7 @@ func issueNumberFromIssueURL(issueURL string) (int, error) { // SubIssueWrite creates a tool to add a sub-issue to a parent issue. func SubIssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := NewToolWithSchemaOptions[SubIssueWriteInput, *MinimalIssue]( ToolsetMetadataIssues, mcp.Tool{ Name: "sub_issue_write", @@ -1716,43 +1764,13 @@ func SubIssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool { }, Required: []string{"method", "owner", "repo", "issue_number", "sub_issue_id"}, }, + OutputSchema: issueOutputSchema[*MinimalIssue](), }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - method, err := RequiredParam[string](args, "method") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - issueNumber, err := RequiredInt(args, "issue_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - subIssueID, err := RequiredInt(args, "sub_issue_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - replaceParent, err := OptionalParam[bool](args, "replace_parent") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - afterID, err := OptionalIntParam(args, "after_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - beforeID, err := OptionalIntParam(args, "before_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - + inventory.TypedSchemaOptions{PreserveHandlerContent: true}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input SubIssueWriteInput) (*mcp.CallToolResult, *MinimalIssue, error) { + method, owner, repo := input.Method, input.Owner, input.Repo + issueNumber, subIssueID := input.IssueNumber, input.SubIssueID client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil @@ -1760,24 +1778,49 @@ func SubIssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool { switch strings.ToLower(method) { case "add": - result, err := AddSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, replaceParent) - return result, nil, err + result, err := AddSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, input.ReplaceParent) + return subIssueWriteTypedResult(result, err) case "remove": // Call the remove sub-issue function result, err := RemoveSubIssue(ctx, client, owner, repo, issueNumber, subIssueID) - return result, nil, err + return subIssueWriteTypedResult(result, err) case "reprioritize": // Call the reprioritize sub-issue function - result, err := ReprioritizeSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, afterID, beforeID) - return result, nil, err + result, err := ReprioritizeSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, input.AfterID, input.BeforeID) + return subIssueWriteTypedResult(result, err) default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } - }) + }, + normalizeIssueStrings([]string{"method", "owner", "repo"}, nil), + normalizeIssueIntegers([]string{"issue_number", "sub_issue_id"}, []string{"after_id", "before_id"}), + normalizeIssueBooleans([]string{"replace_parent"})) st.FeatureRule = issuesConsolidatedFeatureRule return st } +func subIssueWriteTypedResult(result *mcp.CallToolResult, err error) (*mcp.CallToolResult, *MinimalIssue, error) { + if err != nil || result == nil || result.IsError { + return result, nil, err + } + if len(result.Content) != 1 { + return nil, nil, fmt.Errorf("sub-issue write returned %d content items, expected one", len(result.Content)) + } + text, ok := result.Content[0].(*mcp.TextContent) + if !ok { + return nil, nil, fmt.Errorf("sub-issue write returned %T content, expected text", result.Content[0]) + } + var subIssue *github.SubIssue + if err := json.Unmarshal([]byte(text.Text), &subIssue); err != nil { + return nil, nil, fmt.Errorf("failed to decode sub-issue write output: %w", err) + } + if subIssue == nil { + return result, nil, nil + } + minimal := convertToMinimalIssue((*github.Issue)(subIssue)) + return result, &minimal, nil +} + func AddSubIssue(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, subIssueID int, replaceParent bool) (*mcp.CallToolResult, error) { subIssueRequest := github.SubIssueRequest{ SubIssueID: int64(subIssueID), diff --git a/pkg/github/typed_issue_outputs_test.go b/pkg/github/typed_issue_outputs_test.go new file mode 100644 index 0000000000..937e15f79c --- /dev/null +++ b/pkg/github/typed_issue_outputs_test.go @@ -0,0 +1,413 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func typedIssueDeps(t *testing.T) BaseDeps { + t.Helper() + relatedIssue := &github.Issue{ + ID: new(int64(7)), Number: new(7), Title: new("Blocker"), + State: new("open"), HTMLURL: new("https://github.com/owner/repo/issues/7"), + RepositoryURL: new("https://api.github.com/repos/owner/repo"), + } + subject := &github.Issue{ + Number: new(123), Title: new("Subject"), State: new("open"), + HTMLURL: new("https://github.com/owner/repo/issues/123"), + } + types := []*github.IssueType{{ID: new(int64(1)), Name: new("Bug"), IsEnabled: new(false)}} + comment := &github.IssueComment{ + ID: new(int64(42)), HTMLURL: new("https://github.com/owner/repo/issues/123#issuecomment-42"), + IssueURL: new("https://api.github.com/repos/owner/repo/issues/123"), + } + handlers := map[string]http.HandlerFunc{ + "GET /orgs/{owner}/issue-types": mockResponse(t, http.StatusOK, types), + "GET /repos/{owner}/{repo}/issue-types": mockResponse(t, http.StatusOK, types), + string(endpointGetIssue): mockResponse(t, http.StatusOK, relatedIssue), + string(endpointAddBlock): mockResponse(t, http.StatusCreated, subject), + string(endpointRemoveBlk): mockResponse(t, http.StatusOK, subject), + string(endpointBlockedBy): mockResponse(t, http.StatusOK, []*github.Issue{relatedIssue}), + string(endpointBlocking): mockResponse(t, http.StatusOK, []*github.Issue{}), + "POST /repos/{owner}/{repo}/issues/{issue_number}/comments": mockResponse(t, http.StatusCreated, comment), + "PATCH /repos/{owner}/{repo}/issues/comments/{comment_id}": mockResponse(t, http.StatusOK, comment), + "GET /repos/{owner}/{repo}/issues/comments/{comment_id}": mockResponse(t, http.StatusOK, comment), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, map[string]any{"name": "repo", "private": false}), + "POST /repos/{owner}/{repo}/issues/{issue_number}/reactions": mockResponse(t, http.StatusCreated, &github.Reaction{ID: new(int64(9))}), + "POST /repos/{owner}/{repo}/issues/comments/{comment_id}/reactions": mockResponse(t, http.StatusCreated, &github.Reaction{ID: new(int64(9))}), + "POST /repos/{owner}/{repo}/issues/{issue_number}/sub_issues": mockResponse(t, http.StatusCreated, &github.SubIssue{ + Number: new(23), Title: new("Child"), State: new("open"), + HTMLURL: new("https://github.com/owner/repo/issues/23"), + }), + "DELETE /repos/{owner}/{repo}/issues/{issue_number}/sub_issue": mockResponse(t, http.StatusOK, &github.SubIssue{ + Number: new(23), Title: new("Child"), State: new("open"), + HTMLURL: new("https://github.com/owner/repo/issues/23"), + }), + "PATCH /repos/{owner}/{repo}/issues/{issue_number}/sub_issues/priority": mockResponse(t, http.StatusOK, &github.SubIssue{ + Number: new(23), Title: new("Child"), State: new("open"), + HTMLURL: new("https://github.com/owner/repo/issues/23"), + }), + "GET /repos/{owner}/{repo}/issues/{issue_number}/semantically_similar": func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Has("threshold") { + assert.Equal(t, "page=0&per_page=0&threshold=0", r.URL.RawQuery) + } else { + assert.Empty(t, r.URL.RawQuery, "omitted threshold and pagination must stay omitted") + } + _, _ = w.Write([]byte(`[{"issue":{"number":7,"title":"Candidate","state":"open","html_url":"https://github.com/owner/repo/issues/7"},"score":null,"confidence":"high","likely_duplicate":true}]`)) + }, + } + gql := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + Query string `json:"query"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + container := "repository" + if strings.Contains(request.Query, "organization(") { + container = "organization" + } + _, _ = w.Write([]byte(`{"data":{"` + container + `":{"issueFields":{"nodes":[{"__typename":"IssueFieldSingleSelect","id":"IF_1","fullDatabaseId":"99","name":"Priority","description":"Importance","dataType":"SINGLE_SELECT","visibility":"ALL","options":[{"id":"OPT_1","name":"High","description":"","color":"red","priority":0}]}]}}}}`)) + })}} + return BaseDeps{ + Client: mustNewGHClient(t, MockHTTPClientWithHandlers(handlers)), + GQLClient: githubv4.NewClient(gql), + } +} + +func TestTypedIssueOutputs(t *testing.T) { + translate := translations.NullTranslationHelper + tools := []inventory.ServerTool{ + IssueRead(translate), ListIssueTypes(translate), ListIssueFields(translate), AddIssueComment(translate), + UpdateIssueComment(translate), IssueDependencyRead(translate), IssueDependencyWrite(translate), FindDuplicate(translate), + } + // These tools remain feature-gated in production. Enable their existing + // feature rules in the contract harness rather than clearing the rules. + inv, err := inventory.NewBuilder().SetTools(tools).WithToolsets([]string{"all"}). + WithFeatureChecker(func(context.Context, string) (bool, error) { return true, nil }).Build() + require.NoError(t, err) + + const commentText = `{"id":"42","url":"https://github.com/owner/repo/issues/123#issuecomment-42"}` + const reactionText = `{"id":"9","url":"https://api.github.com/repos/owner/repo/issues/123/reactions/9"}` + const blockedText = `{"issues":[{"number":7,"title":"Blocker","state":"OPEN","url":"https://github.com/owner/repo/issues/7","repository":"owner/repo"}],"pageInfo":{"hasNextPage":false,"nextPage":0}}` + const dependencyRefs = `"blocked_issue":{"number":123,"title":"Subject","state":"OPEN","url":"https://github.com/owner/repo/issues/123","repository":"owner/repo"},"blocking_issue":{"number":7,"title":"Blocker","state":"OPEN","url":"https://github.com/owner/repo/issues/7","repository":"owner/repo"}` + calls := []struct { + name string + args map[string]any + text string + structured string + }{ + {name: "issue_read", args: map[string]any{"method": "get", "owner": "owner", "repo": "repo", "issue_number": "7.0", "page": "0", "perPage": "0"}, text: `{"number":7,"title":"Blocker","state":"open","html_url":"https://github.com/owner/repo/issues/7","assignees":[]}`, structured: `{"number":7,"title":"Blocker","state":"open","html_url":"https://github.com/owner/repo/issues/7","assignees":[]}`}, + {name: "list_issue_types", args: map[string]any{"owner": "owner"}, text: `[{"id":1,"name":"Bug","is_enabled":false}]`, structured: `[{"id":1,"name":"Bug","is_enabled":false}]`}, + {name: "list_issue_types", args: map[string]any{"owner": "owner", "repo": "repo"}, text: `[{"id":1,"name":"Bug","is_enabled":false}]`, structured: `[{"id":1,"name":"Bug","is_enabled":false}]`}, + {name: "list_issue_fields", args: map[string]any{"owner": "owner"}, text: `[{"id":"IF_1","full_database_id":99,"name":"Priority","description":"Importance","data_type":"SINGLE_SELECT","visibility":"ALL","options":[{"id":"OPT_1","name":"High","color":"red","priority":0}]}]`, structured: `[{"id":"IF_1","full_database_id":99,"name":"Priority","description":"Importance","data_type":"SINGLE_SELECT","visibility":"ALL","options":[{"id":"OPT_1","name":"High","color":"red","priority":0}]}]`}, + {name: "list_issue_fields", args: map[string]any{"owner": "owner", "repo": "repo"}, text: `[{"id":"IF_1","full_database_id":99,"name":"Priority","description":"Importance","data_type":"SINGLE_SELECT","visibility":"ALL","options":[{"id":"OPT_1","name":"High","color":"red","priority":0}]}]`, structured: `[{"id":"IF_1","full_database_id":99,"name":"Priority","description":"Importance","data_type":"SINGLE_SELECT","visibility":"ALL","options":[{"id":"OPT_1","name":"High","color":"red","priority":0}]}]`}, + {name: "add_issue_comment", args: map[string]any{"owner": "owner", "repo": "repo", "issue_number": "123.0", "body": "Hello"}, text: commentText, structured: `{"id":"42","html_url":"https://github.com/owner/repo/issues/123#issuecomment-42"}`}, + {name: "add_issue_comment", args: map[string]any{"owner": "owner", "repo": "repo", "issue_number": "123", "reaction": "heart"}, text: reactionText, structured: `{"id":"9"}`}, + {name: "add_issue_comment", args: map[string]any{"owner": "owner", "repo": "repo", "issue_number": "123", "body": "Hello", "reaction": "heart"}, text: `{"comment":` + commentText + `,"reaction":` + reactionText + `}`, structured: `{"comment":{"id":"42","html_url":"https://github.com/owner/repo/issues/123#issuecomment-42"},"reaction":{"id":"9"}}`}, + {name: "add_issue_comment", args: map[string]any{"owner": "owner", "repo": "repo", "issue_number": "123", "comment_id": "42.0", "reaction": "heart"}, text: `{"id":"9","url":"https://api.github.com/repos/owner/repo/issues/comments/42/reactions/9"}`, structured: `{"id":"9"}`}, + {name: "update_issue_comment", args: map[string]any{"owner": "owner", "repo": "repo", "comment_id": "42.0", "body": "Changed"}, text: commentText, structured: `{"id":"42","html_url":"https://github.com/owner/repo/issues/123#issuecomment-42"}`}, + {name: "issue_dependency_read", args: map[string]any{"method": "get_blocked_by", "owner": "owner", "repo": "repo", "issue_number": "123", "page": "0", "perPage": "0"}, text: blockedText, structured: `{"issues":[{"number":7,"title":"Blocker","state":"OPEN","html_url":"https://github.com/owner/repo/issues/7","repository":"owner/repo"}],"pageInfo":{"hasNextPage":false,"nextPage":0}}`}, + {name: "issue_dependency_read", args: map[string]any{"method": "get_blocking", "owner": "owner", "repo": "repo", "issue_number": "123"}, text: `{"issues":[],"pageInfo":{"hasNextPage":false,"nextPage":0}}`, structured: `{"issues":[],"pageInfo":{"hasNextPage":false,"nextPage":0}}`}, + {name: "issue_dependency_write", args: map[string]any{"method": "ADD", "type": "BLOCKED_BY", "owner": "owner", "repo": "repo", "issue_number": "123", "related_issue_number": "7"}, text: `{` + dependencyRefs + `,"message":"dependency added"}`, structured: `{"blocked_issue":{"number":123,"title":"Subject","state":"OPEN","html_url":"https://github.com/owner/repo/issues/123","repository":"owner/repo"},"blocking_issue":{"number":7,"title":"Blocker","state":"OPEN","html_url":"https://github.com/owner/repo/issues/7","repository":"owner/repo"},"message":"dependency added"}`}, + {name: "issue_dependency_write", args: map[string]any{"method": "REMOVE", "type": "BLOCKED_BY", "owner": "owner", "repo": "repo", "issue_number": "123", "related_issue_number": "7"}, text: `{` + dependencyRefs + `,"message":"dependency removed"}`, structured: `{"blocked_issue":{"number":123,"title":"Subject","state":"OPEN","html_url":"https://github.com/owner/repo/issues/123","repository":"owner/repo"},"blocking_issue":{"number":7,"title":"Blocker","state":"OPEN","html_url":"https://github.com/owner/repo/issues/7","repository":"owner/repo"},"message":"dependency removed"}`}, + {name: "find_duplicate", args: map[string]any{"owner": "owner", "repo": "repo", "issue_number": "123", "confidence_threshold": 0, "page": "0", "perPage": "0"}, text: `[{"issue":{"number":7,"title":"Candidate","state":"open","url":"https://github.com/owner/repo/issues/7"},"score":null,"confidence":"high","likely_duplicate":true}]`, structured: `[{"issue":{"number":7,"title":"Candidate","state":"open","html_url":"https://github.com/owner/repo/issues/7"},"score":null,"confidence":"high","likely_duplicate":true}]`}, + {name: "find_duplicate", args: map[string]any{"owner": "owner", "repo": "repo", "issue_number": "123"}, text: `[{"issue":{"number":7,"title":"Candidate","state":"open","url":"https://github.com/owner/repo/issues/7"},"score":null,"confidence":"high","likely_duplicate":true}]`, structured: `[{"issue":{"number":7,"title":"Candidate","state":"open","html_url":"https://github.com/owner/repo/issues/7"},"score":null,"confidence":"high","likely_duplicate":true}]`}, + } + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip, ""} { + name := protocol + if name == "" { + name = "unknown" + } + t.Run(name, func(t *testing.T) { + deps := typedIssueDeps(t) + server := mcp.NewServer(&mcp.Implementation{Name: "typed-issue-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + if protocol == "" { + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + switch request := req.(type) { + case *mcp.ListToolsRequest: + request.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + case *mcp.CallToolRequest: + request.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + } + return next(ctx, method, req) + } + }) + } + version := protocol + if version == "" { + version = inventory.ProtocolVersionMultiRoundTrip + } + session := connectCommentVisibilityClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, len(tools)) + schemas := make(map[string]*jsonschema.Resolved) + for _, tool := range list.Tools { + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Nil(t, tool.OutputSchema, tool.Name) + continue + } + require.NotNil(t, tool.OutputSchema, tool.Name) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, tool.OutputSchema)), &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + schemas[tool.Name] = resolved + if tool.Name == "add_issue_comment" { + for _, invalid := range []string{ + `{}`, + `{"id":"1","html_url":"url","reaction":{"id":"2"}}`, + `{"comment":{"id":"1","html_url":"url"}}`, + `{"comment":{"id":"1","html_url":"url"},"reaction":{"id":"2","html_url":"url"}}`, + } { + var output any + require.NoError(t, json.Unmarshal([]byte(invalid), &output)) + require.Error(t, resolved.Validate(output), "partial comment unions must be rejected") + } + } + } + + for _, call := range calls { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: call.name, Arguments: call.args}) + require.NoError(t, err, call.name) + require.False(t, result.IsError, "%s: %s", call.name, result) + require.Len(t, result.Content, 1, call.name) + assert.Equal(t, call.text, getTextResult(t, result).Text, call.name) + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Nil(t, result.StructuredContent, call.name) + continue + } + require.NotNil(t, result.StructuredContent, call.name) + structured := mustMarshalJSON(t, result.StructuredContent) + assert.JSONEq(t, call.structured, structured) + var output any + require.NoError(t, json.Unmarshal([]byte(structured), &output)) + require.NoError(t, schemas[call.name].Validate(output), call.name) + } + errors := []struct { + name string + args map[string]any + text string + }{ + {"list_issue_types", map[string]any{}, "missing required parameter: owner"}, + {"list_issue_fields", map[string]any{"owner": "owner", "repo": nil}, "parameter repo is not of type string"}, + {"add_issue_comment", map[string]any{"owner": "owner", "repo": "repo", "issue_number": 123}, "at least one of body or reaction is required"}, + {"add_issue_comment", map[string]any{"owner": "owner", "repo": "repo", "issue_number": 123, "body": "Hello", "comment_id": 42}, "comment_id cannot be combined with body"}, + {"update_issue_comment", map[string]any{"owner": "owner", "repo": "repo", "comment_id": "1.5", "body": "Hello"}, "non-integer numeric value"}, + {"issue_dependency_read", map[string]any{"method": "GET_BLOCKING", "owner": "owner", "repo": "repo", "issue_number": 123}, "get_blocking"}, + {"issue_dependency_write", map[string]any{"method": "add", "type": "blocked_by", "owner": "owner", "repo": "repo", "issue_number": 123, "related_issue_number": 123}, "an issue cannot block or depend on itself"}, + {"find_duplicate", map[string]any{"owner": "owner", "repo": "repo", "issue_number": "1.5"}, "not a valid number"}, + {"find_duplicate", map[string]any{"owner": "owner", "repo": "repo", "issue_number": 123, "confidence_threshold": nil}, "parameter confidence_threshold is not of type float64"}, + } + for _, call := range errors { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: call.name, Arguments: call.args}) + require.NoError(t, err, call.name) + require.True(t, result.IsError, call.name) + assert.Contains(t, getErrorResult(t, result).Text, call.text) + assert.Nil(t, result.StructuredContent, call.name) + } + }) + } +} + +func TestTypedIssueNullOutput(t *testing.T) { + tool := ListIssueTypes(translations.NullTranslationHelper) + inv, err := inventory.NewBuilder().SetTools([]inventory.ServerTool{tool}).WithToolsets([]string{"all"}).Build() + require.NoError(t, err) + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/{owner}/issue-types": func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("null")) + }, + }))} + + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + t.Run(protocol, func(t *testing.T) { + server := mcp.NewServer(&mcp.Implementation{Name: "typed-issue-null", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + session := connectCommentVisibilityClient(t, server, protocol) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + if protocol == inventory.ProtocolVersionMultiRoundTrip { + require.NotNil(t, list.Tools[0].OutputSchema) + } else { + assert.Nil(t, list.Tools[0].OutputSchema) + } + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "list_issue_types", Arguments: map[string]any{"owner": "owner"}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Equal(t, "null", getTextResult(t, result).Text) + if protocol == inventory.ProtocolVersionMultiRoundTrip { + assert.Nil(t, result.StructuredContent, "the SDK omits structuredContent when the typed output is null") + } else { + assert.Nil(t, result.StructuredContent) + } + }) + } +} + +func TestIssuePaginationValidationSchemaKeepsAdvertisedSchema(t *testing.T) { + advertised := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "page": {Type: "number", Description: "Page number (min 1)", Minimum: new(1.0), Default: json.RawMessage("1")}, + "perPage": {Type: "number", Description: "Items per page (min 1, max 100)", Minimum: new(1.0), Maximum: new(100.0), Default: json.RawMessage("30")}, + }, + } + advertisedBefore := mustMarshalJSON(t, advertised) + + validation := issuePaginationValidationSchema(advertised) + require.Equal(t, advertisedBefore, mustMarshalJSON(t, advertised), "runtime validation must not mutate the advertised schema") + assert.Equal(t, "Page number (min 1)", validation.Properties["page"].Description) + assert.Equal(t, "Items per page (min 1, max 100)", validation.Properties["perPage"].Description) + assert.Equal(t, 1.0, *advertised.Properties["page"].Minimum) + assert.Equal(t, 1.0, *advertised.Properties["perPage"].Minimum) + assert.Equal(t, 0.0, *validation.Properties["page"].Minimum) + assert.Equal(t, 0.0, *validation.Properties["perPage"].Minimum) + assert.Equal(t, 100.0, *validation.Properties["perPage"].Maximum) + assert.Nil(t, validation.Properties["page"].Default, "validation must not inject defaults absent from the original call") + assert.Nil(t, validation.Properties["perPage"].Default, "validation must not inject defaults absent from the original call") + resolved, err := validation.Resolve(nil) + require.NoError(t, err) + require.NoError(t, resolved.Validate(map[string]any{"page": float64(0), "perPage": float64(0)})) + assert.Error(t, resolved.Validate(map[string]any{"page": float64(-1)})) +} + +func TestTypedIssueAPIErrors(t *testing.T) { + translate := translations.NullTranslationHelper + cases := []struct { + tool inventory.ServerTool + args map[string]any + text string + }{ + {ListIssueTypes(translate), map[string]any{"owner": "owner"}, "failed to list issue types"}, + {ListIssueFields(translate), map[string]any{"owner": "owner"}, "failed to list issue fields"}, + {AddIssueComment(translate), map[string]any{"owner": "owner", "repo": "repo", "issue_number": 123, "body": "Hello"}, "failed to create comment"}, + {UpdateIssueComment(translate), map[string]any{"owner": "owner", "repo": "repo", "comment_id": 42, "body": "Hello"}, "failed to update issue comment"}, + {IssueDependencyRead(translate), map[string]any{"method": "get_blocked_by", "owner": "owner", "repo": "repo", "issue_number": 123}, "failed to list blocked-by issues"}, + {IssueDependencyWrite(translate), map[string]any{"method": "add", "type": "blocked_by", "owner": "owner", "repo": "repo", "issue_number": 123, "related_issue_number": 7}, "failed to resolve blocking issue"}, + {FindDuplicate(translate), map[string]any{"owner": "owner", "repo": "repo", "issue_number": 123}, "failed to find duplicate issues"}, + } + for _, tc := range cases { + t.Run(tc.tool.Tool.Name, func(t *testing.T) { + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"Forbidden"}`)) + })}} + deps := BaseDeps{Client: mustNewGHClient(t, client), GQLClient: githubv4.NewClient(client)} + inv, err := inventory.NewBuilder().SetTools([]inventory.ServerTool{tc.tool}).WithToolsets([]string{"all"}). + WithFeatureChecker(func(context.Context, string) (bool, error) { return true, nil }).Build() + require.NoError(t, err) + var legacyText string + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + server := mcp.NewServer(&mcp.Implementation{Name: "typed-issue-errors", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + session := connectCommentVisibilityClient(t, server, protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool.Tool.Name, Arguments: tc.args}) + require.NoError(t, err) + require.True(t, result.IsError) + require.Len(t, result.Content, 1) + text := getErrorResult(t, result).Text + assert.Contains(t, text, tc.text) + assert.Nil(t, result.StructuredContent) + if protocol == "2025-11-25" { + legacyText = text + } else { + assert.Equal(t, legacyText, text, "API errors must be byte-exact across protocols") + } + } + }) + } +} + +func TestTypedSubIssueWriteProtocols(t *testing.T) { + translate := translations.NullTranslationHelper + tool := SubIssueWrite(translate) + inv, err := inventory.NewBuilder().SetTools([]inventory.ServerTool{tool}).WithToolsets([]string{"all"}). + WithFeatureChecker(func(context.Context, string) (bool, error) { return false, nil }).Build() + require.NoError(t, err) + + expectedIssue := &github.SubIssue{ + Number: new(23), Title: new("Child"), State: new("open"), + HTMLURL: new("https://github.com/owner/repo/issues/23"), + } + legacy, err := json.Marshal(expectedIssue) + require.NoError(t, err) + minimal := convertToMinimalIssue((*github.Issue)(expectedIssue)) + structured, err := json.Marshal(minimal) + require.NoError(t, err) + + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + t.Run(protocol, func(t *testing.T) { + deps := typedIssueDeps(t) + server := mcp.NewServer(&mcp.Implementation{Name: "typed-sub-issue-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + session := connectCommentVisibilityClient(t, server, protocol) + + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + if protocol == inventory.ProtocolVersionMultiRoundTrip { + require.NotNil(t, list.Tools[0].OutputSchema) + var outputSchema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, list.Tools[0].OutputSchema)), &outputSchema)) + resolvedSchema, err := outputSchema.Resolve(nil) + require.NoError(t, err) + var output any + require.NoError(t, json.Unmarshal(structured, &output)) + require.NoError(t, resolvedSchema.Validate(output)) + } else { + assert.Nil(t, list.Tools[0].OutputSchema) + } + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "sub_issue_write", + Arguments: map[string]any{ + "method": "ADD", "owner": "owner", "repo": "repo", + "issue_number": "123.0", "sub_issue_id": "23.0", "replace_parent": true, + }, + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Equal(t, string(legacy), getTextResult(t, result).Text) + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Nil(t, result.StructuredContent) + return + } + require.NotNil(t, result.StructuredContent) + assert.JSONEq(t, string(structured), mustMarshalJSON(t, result.StructuredContent)) + + invalid, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "sub_issue_write", + Arguments: map[string]any{ + "method": "add", "owner": "owner", "repo": "repo", + "issue_number": 123, "sub_issue_id": 23, "replace_parent": nil, + }, + }) + require.NoError(t, err) + require.True(t, invalid.IsError) + assert.Contains(t, getErrorResult(t, invalid).Text, "parameter replace_parent is not of type bool") + assert.Nil(t, invalid.StructuredContent) + }) + } +} From 78946592febca6cff050e3108574fcff998a0fe4 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 7 Oct 2026 00:12:49 +0200 Subject: [PATCH 51/61] feat(repos): complete typed repository tool outputs (#3392) * feat(repos): complete typed repository tool outputs Migrate the remaining 17 repository tools to concrete inputs and outputs. Preserve existing content, mutations, scopes, filtering, and protocol gating; add wire and schema conformance coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore(repos): exclude unrelated snapshot formatting drift Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(repos): compact typed repository outputs Replace raw REST output schemas with compact repository DTOs, retain one canonical snapshot per tool, and align modern JSON text with structured output while pinning legacy content. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 6 + pkg/github/__toolsnaps__/create_branch.snap | 54 +- .../__toolsnaps__/create_or_update_file.snap | 98 ++- .../__toolsnaps__/create_repository.snap | 33 +- pkg/github/__toolsnaps__/delete_file.snap | 108 ++- .../__toolsnaps__/delete_repository.snap | 35 +- pkg/github/__toolsnaps__/fork_repository.snap | 43 +- pkg/github/__toolsnaps__/get_file_blame.snap | 167 ++++- .../__toolsnaps__/get_file_contents.snap | 239 ++++++- .../__toolsnaps__/get_latest_release.snap | 157 ++++- .../__toolsnaps__/get_release_by_tag.snap | 157 ++++- pkg/github/__toolsnaps__/get_tag.snap | 116 +++- pkg/github/__toolsnaps__/list_releases.snap | 177 ++++- .../list_repository_collaborators.snap | 74 +- .../list_starred_repositories.snap | 92 ++- pkg/github/__toolsnaps__/push_files.snap | 54 +- pkg/github/__toolsnaps__/star_repository.snap | 23 +- .../__toolsnaps__/unstar_repository.snap | 35 +- pkg/github/repositories.go | 644 ++++++++---------- pkg/github/repositories_test.go | 17 - pkg/github/repository_tool_types.go | 540 +++++++++++++++ pkg/github/typed_repository_outputs_test.go | 594 ++++++++++++++++ 22 files changed, 3051 insertions(+), 412 deletions(-) create mode 100644 pkg/github/repository_tool_types.go create mode 100644 pkg/github/typed_repository_outputs_test.go diff --git a/README.md b/README.md index c6e88f2d35..254cc23d02 100644 --- a/README.md +++ b/README.md @@ -616,6 +616,12 @@ The following sets of tools are available: ## Tools +Repository tools expose output schemas and typed `structuredContent` to clients using MCP protocol `2026-07-28` or later. Older clients, or clients with an unknown protocol version, receive the existing content without an output schema or structured output. + +For `get_file_contents`, directory results (including field projections) are structured arrays. File downloads and metadata-only responses use a `content` array of typed text, embedded-resource, or resource-link blocks, preserving status messages, text, base64 blobs, empty files, and download links. Text-only repository mutations such as starring and deletion also return a structured `message`; fork responses distinguish a repository reference from an in-progress message. Repository deletion still requires its existing confirmation flow before returning a completed result. + +Structured repository results use compact DTOs rather than raw REST objects. Release lookups share the compact release shape with release listings; Git references retain the ref, object type, and SHA; annotated tags retain tag/message/tagger/target information; deletion commits retain SHA, message, authors, tree SHA, parent SHAs, and a human-facing `html_url` when available. Directory entries retain file metadata and useful HTML/download links, but omit API and hypermedia routing URLs even when those fields were requested in a projection. Legacy and unknown protocol versions retain the original text byte-for-byte; modern text-only JSON responses serialize the same compact DTO as `structuredContent`. Non-text resource blocks and tool errors retain their existing content behavior. Structured projections use the same sanitized and filtered source as the legacy formatter. + The issue metadata (`list_issue_types`, `list_issue_fields`), comment (`add_issue_comment`, `update_issue_comment`), dependency (`issue_dependency_read`, `issue_dependency_write`), and duplicate-detection (`find_duplicate`) tools provide typed `outputSchema` and `structuredContent` for protocol `2026-07-28` and newer. Older or unknown protocol versions retain the same text responses without these fields. Tool errors do not return structured content. Dependency and duplicate-detection tools retain their existing feature gates. diff --git a/pkg/github/__toolsnaps__/create_branch.snap b/pkg/github/__toolsnaps__/create_branch.snap index e796405140..b0039d13d1 100644 --- a/pkg/github/__toolsnaps__/create_branch.snap +++ b/pkg/github/__toolsnaps__/create_branch.snap @@ -5,6 +5,18 @@ "title": "Create branch" }, "description": "Create a new branch in a GitHub repository", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "branch": { @@ -17,11 +29,13 @@ }, "owner": { "description": "Repository owner", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -31,5 +45,39 @@ ], "type": "object" }, - "name": "create_branch" + "name": "create_branch", + "outputSchema": { + "additionalProperties": false, + "properties": { + "object": { + "additionalProperties": false, + "properties": { + "sha": { + "type": "string" + }, + "type": { + "description": "Git object kind: commit, tag, tree, or blob.", + "type": "string" + } + }, + "required": [ + "type", + "sha" + ], + "type": "object" + }, + "ref": { + "description": "Fully qualified reference, such as refs/heads/main.", + "type": "string" + } + }, + "required": [ + "ref", + "object" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_or_update_file.snap b/pkg/github/__toolsnaps__/create_or_update_file.snap index faf468567d..a16da95ede 100644 --- a/pkg/github/__toolsnaps__/create_or_update_file.snap +++ b/pkg/github/__toolsnaps__/create_or_update_file.snap @@ -5,6 +5,18 @@ "title": "Create or update file" }, "description": "Create or update a single file in a GitHub repository. \nIf updating, you should provide the SHA of the file you want to update. Use this tool to create or update a file in a GitHub repository remotely; do not use it for local file operations.\n\nTo obtain the current blob SHA before updating, call the get_file_contents tool with the same owner, repo, and path, and set its ref parameter to this tool's branch value. The first text result reports the blob SHA for the requested path.\n\nSHA MUST be provided for existing file updates.\n", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "allow_symlink_write": { @@ -26,7 +38,8 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "path": { "description": "Path where to create/update the file", @@ -34,7 +47,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "sha": { "description": "The blob SHA of the file being replaced. Required if the file already exists. Retrieve it with get_file_contents using the same owner, repo, and path, with ref set to this tool's branch value.", @@ -51,5 +65,83 @@ ], "type": "object" }, - "name": "create_or_update_file" + "name": "create_or_update_file", + "outputSchema": { + "additionalProperties": false, + "properties": { + "commit": { + "additionalProperties": false, + "properties": { + "author": { + "additionalProperties": false, + "properties": { + "date": { + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + }, + "html_url": { + "type": "string" + }, + "message": { + "type": "string" + }, + "sha": { + "type": "string" + } + }, + "required": [ + "sha" + ], + "type": [ + "null", + "object" + ] + }, + "content": { + "additionalProperties": false, + "properties": { + "html_url": { + "type": "string" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "sha": { + "type": "string" + }, + "size": { + "type": "integer" + } + }, + "required": [ + "name", + "path", + "sha", + "html_url" + ], + "type": [ + "null", + "object" + ] + } + }, + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_repository.snap b/pkg/github/__toolsnaps__/create_repository.snap index d13cc69aa1..ca35ee406f 100644 --- a/pkg/github/__toolsnaps__/create_repository.snap +++ b/pkg/github/__toolsnaps__/create_repository.snap @@ -5,6 +5,18 @@ "title": "Create repository" }, "description": "Create a new GitHub repository in your account or specified organization", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "autoInit": { @@ -34,5 +46,24 @@ ], "type": "object" }, - "name": "create_repository" + "name": "create_repository", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/delete_file.snap b/pkg/github/__toolsnaps__/delete_file.snap index 4885c891d1..6240de7f04 100644 --- a/pkg/github/__toolsnaps__/delete_file.snap +++ b/pkg/github/__toolsnaps__/delete_file.snap @@ -6,6 +6,18 @@ "title": "Delete file" }, "description": "Delete a file from a GitHub repository", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "branch": { @@ -18,7 +30,8 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "path": { "description": "Path to the file to delete", @@ -26,7 +39,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -38,5 +52,93 @@ ], "type": "object" }, - "name": "delete_file" + "name": "delete_file", + "outputSchema": { + "additionalProperties": false, + "properties": { + "commit": { + "additionalProperties": false, + "properties": { + "author": { + "additionalProperties": false, + "properties": { + "date": { + "description": "Commit timestamp in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + }, + "committer": { + "additionalProperties": false, + "properties": { + "date": { + "description": "Commit timestamp in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + }, + "html_url": { + "type": "string" + }, + "message": { + "type": "string" + }, + "parents": { + "description": "Parent commit SHAs in Git parent order.", + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "sha": { + "type": "string" + }, + "tree_sha": { + "type": "string" + } + }, + "required": [ + "sha" + ], + "type": [ + "null", + "object" + ] + }, + "content": { + "type": "null" + } + }, + "required": [ + "commit", + "content" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/delete_repository.snap b/pkg/github/__toolsnaps__/delete_repository.snap index a75845b3f4..c510027db1 100644 --- a/pkg/github/__toolsnaps__/delete_repository.snap +++ b/pkg/github/__toolsnaps__/delete_repository.snap @@ -6,15 +6,29 @@ "title": "Delete repository" }, "description": "Delete a GitHub repository after the user confirms the exact owner/repository name", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -23,5 +37,20 @@ ], "type": "object" }, - "name": "delete_repository" + "name": "delete_repository", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/fork_repository.snap b/pkg/github/__toolsnaps__/fork_repository.snap index 1373ef0519..e65da439cd 100644 --- a/pkg/github/__toolsnaps__/fork_repository.snap +++ b/pkg/github/__toolsnaps__/fork_repository.snap @@ -25,11 +25,13 @@ }, "owner": { "description": "Repository owner", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -38,5 +40,40 @@ ], "type": "object" }, - "name": "fork_repository" + "name": "fork_repository", + "outputSchema": { + "oneOf": [ + { + "type": "null" + }, + { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_file_blame.snap b/pkg/github/__toolsnaps__/get_file_blame.snap index 33a09fea88..125f16c349 100644 --- a/pkg/github/__toolsnaps__/get_file_blame.snap +++ b/pkg/github/__toolsnaps__/get_file_blame.snap @@ -5,6 +5,18 @@ "title": "Get file blame information" }, "description": "Get git blame information for a file, showing the commit that last modified each line. Ranges share commit metadata via the top-level 'commits' map keyed by SHA. Use 'start_line'/'end_line' to restrict the result to a window of the file, and 'perPage'/'after' to cursor-page through returned ranges. Matching ranges are capped at 1000; when the cap is hit 'truncated' is set to true and 'total_ranges' reports the pre-cap match count.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "after": { @@ -18,7 +30,8 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "path": { "description": "Path to the file in the repository, relative to the repository root", @@ -36,7 +49,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "start_line": { "description": "Optional 1-based starting line of the window of interest. Only ranges overlapping [start_line, end_line] are returned, clamped to the window.", @@ -51,5 +65,152 @@ ], "type": "object" }, - "name": "get_file_blame" + "name": "get_file_blame", + "outputSchema": { + "oneOf": [ + { + "type": "null" + }, + { + "additionalProperties": false, + "properties": { + "commits": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "author": { + "additionalProperties": false, + "properties": { + "email": { + "type": "string" + }, + "login": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": "string" + }, + "url": { + "type": [ + "null", + "string" + ] + } + }, + "required": [ + "name", + "email" + ], + "type": "object" + }, + "committed_date": { + "description": "Commit timestamp in RFC3339 format.", + "type": "string" + }, + "message_headline": { + "type": "string" + }, + "sha": { + "type": "string" + } + }, + "required": [ + "sha", + "message_headline", + "committed_date", + "author" + ], + "type": "object" + }, + "type": "object" + }, + "pageInfo": { + "additionalProperties": false, + "properties": { + "endCursor": { + "type": "string" + }, + "hasNextPage": { + "type": "boolean" + }, + "hasPreviousPage": { + "type": "boolean" + }, + "startCursor": { + "type": "string" + } + }, + "required": [ + "hasNextPage", + "hasPreviousPage" + ], + "type": "object" + }, + "path": { + "type": "string" + }, + "ranges": { + "items": { + "additionalProperties": false, + "properties": { + "age": { + "description": "Relative commit age among commits touching this file; 0 is newest.", + "type": "integer" + }, + "commit_sha": { + "type": "string" + }, + "ending_line": { + "description": "Last line of the inclusive, one-based range.", + "type": "integer" + }, + "starting_line": { + "description": "First line of the inclusive, one-based range.", + "type": "integer" + } + }, + "required": [ + "starting_line", + "ending_line", + "age", + "commit_sha" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "ref": { + "type": "string" + }, + "repository": { + "type": "string" + }, + "total_ranges": { + "description": "Matching ranges before pagination or truncation.", + "type": "integer" + }, + "truncated": { + "description": "Whether the 1000-range safety limit was reached.", + "type": "boolean" + } + }, + "required": [ + "repository", + "path", + "ref", + "ranges", + "commits", + "pageInfo", + "total_ranges" + ], + "type": "object" + } + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_file_contents.snap b/pkg/github/__toolsnaps__/get_file_contents.snap index dec933c94d..8fb4f4297e 100644 --- a/pkg/github/__toolsnaps__/get_file_contents.snap +++ b/pkg/github/__toolsnaps__/get_file_contents.snap @@ -5,6 +5,18 @@ "title": "Get file or directory contents" }, "description": "Get the contents of a file or directory from a GitHub repository", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "fields": { @@ -27,7 +39,8 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "path": { "default": "/", @@ -40,7 +53,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "sha": { "description": "Accepts optional commit SHA. If specified, it will be used instead of ref", @@ -53,5 +67,224 @@ ], "type": "object" }, - "name": "get_file_contents" + "name": "get_file_contents", + "outputSchema": { + "oneOf": [ + { + "type": "null" + }, + { + "items": { + "additionalProperties": false, + "properties": { + "download_url": { + "type": [ + "null", + "string" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "path": { + "type": [ + "null", + "string" + ] + }, + "sha": { + "type": [ + "null", + "string" + ] + }, + "size": { + "description": "File size in bytes.", + "type": [ + "null", + "integer" + ] + }, + "submodule_git_url": { + "description": "Git remote URL for a submodule.", + "type": [ + "null", + "string" + ] + }, + "target": { + "type": [ + "null", + "string" + ] + }, + "type": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": "array" + }, + { + "additionalProperties": false, + "properties": { + "content": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "text": { + "type": "string" + }, + "type": { + "const": "text", + "type": "string" + } + }, + "required": [ + "type", + "text" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "resource": { + "additionalProperties": false, + "oneOf": [ + { + "not": { + "required": [ + "blob" + ] + }, + "required": [ + "text" + ] + }, + { + "not": { + "required": [ + "text" + ] + }, + "required": [ + "blob" + ] + }, + { + "not": { + "anyOf": [ + { + "required": [ + "text" + ] + }, + { + "required": [ + "blob" + ] + } + ] + } + } + ], + "properties": { + "blob": { + "description": "Base64-encoded file bytes.", + "type": [ + "null", + "string" + ] + }, + "mimeType": { + "type": "string" + }, + "text": { + "type": [ + "null", + "string" + ] + }, + "uri": { + "type": "string" + } + }, + "required": [ + "uri" + ], + "type": "object" + }, + "type": { + "const": "resource", + "type": "string" + } + }, + "required": [ + "type", + "resource" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "size": { + "description": "File size in bytes.", + "type": [ + "null", + "integer" + ] + }, + "title": { + "type": "string" + }, + "type": { + "const": "resource_link", + "type": "string" + }, + "uri": { + "type": "string" + } + }, + "required": [ + "type", + "uri", + "name" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "content" + ], + "type": "object" + } + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_latest_release.snap b/pkg/github/__toolsnaps__/get_latest_release.snap index 4d4f0d103e..7d52f91e2b 100644 --- a/pkg/github/__toolsnaps__/get_latest_release.snap +++ b/pkg/github/__toolsnaps__/get_latest_release.snap @@ -5,15 +5,29 @@ "title": "Get latest release" }, "description": "Get the latest release in a GitHub repository", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "owner": { "description": "Repository owner", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -22,5 +36,142 @@ ], "type": "object" }, - "name": "get_latest_release" + "name": "get_latest_release", + "outputSchema": { + "additionalProperties": false, + "properties": { + "author": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "body": { + "type": "string" + }, + "draft": { + "description": "Whether the release is unpublished.", + "type": "boolean" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "prerelease": { + "description": "Whether the release is a prerelease.", + "type": "boolean" + }, + "published_at": { + "description": "Publication time in RFC3339 format.", + "type": "string" + }, + "tag_name": { + "type": "string" + } + }, + "required": [ + "id", + "tag_name", + "html_url", + "prerelease", + "draft" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_release_by_tag.snap b/pkg/github/__toolsnaps__/get_release_by_tag.snap index 60e0a3a30b..b447ea2cba 100644 --- a/pkg/github/__toolsnaps__/get_release_by_tag.snap +++ b/pkg/github/__toolsnaps__/get_release_by_tag.snap @@ -5,15 +5,29 @@ "title": "Get a release by tag name" }, "description": "Get a specific release by its tag name in a GitHub repository", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "owner": { "description": "Repository owner", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "tag": { "description": "Tag name (e.g., 'v1.0.0')", @@ -27,5 +41,142 @@ ], "type": "object" }, - "name": "get_release_by_tag" + "name": "get_release_by_tag", + "outputSchema": { + "additionalProperties": false, + "properties": { + "author": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "body": { + "type": "string" + }, + "draft": { + "description": "Whether the release is unpublished.", + "type": "boolean" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "prerelease": { + "description": "Whether the release is a prerelease.", + "type": "boolean" + }, + "published_at": { + "description": "Publication time in RFC3339 format.", + "type": "string" + }, + "tag_name": { + "type": "string" + } + }, + "required": [ + "id", + "tag_name", + "html_url", + "prerelease", + "draft" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_tag.snap b/pkg/github/__toolsnaps__/get_tag.snap index bb2751c970..5fafda0217 100644 --- a/pkg/github/__toolsnaps__/get_tag.snap +++ b/pkg/github/__toolsnaps__/get_tag.snap @@ -5,15 +5,29 @@ "title": "Get tag details" }, "description": "Get details about a specific git tag in a GitHub repository", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "owner": { "description": "Repository owner", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "tag": { "description": "Tag name", @@ -27,5 +41,101 @@ ], "type": "object" }, - "name": "get_tag" + "name": "get_tag", + "outputSchema": { + "oneOf": [ + { + "type": "null" + }, + { + "additionalProperties": false, + "properties": { + "object": { + "additionalProperties": false, + "properties": { + "sha": { + "type": "string" + }, + "type": { + "description": "Git object kind: commit, tag, tree, or blob.", + "type": "string" + } + }, + "required": [ + "type", + "sha" + ], + "type": "object" + }, + "ref": { + "description": "Fully qualified reference, such as refs/heads/main.", + "type": "string" + } + }, + "required": [ + "ref", + "object" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + }, + "object": { + "additionalProperties": false, + "properties": { + "sha": { + "type": "string" + }, + "type": { + "description": "Git object kind: commit, tag, tree, or blob.", + "type": "string" + } + }, + "required": [ + "type", + "sha" + ], + "type": [ + "null", + "object" + ] + }, + "sha": { + "type": "string" + }, + "tag": { + "type": "string" + }, + "tagger": { + "additionalProperties": false, + "properties": { + "date": { + "description": "Tagger time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "tag", + "sha" + ], + "type": "object" + } + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_releases.snap b/pkg/github/__toolsnaps__/list_releases.snap index 4eeef279e9..1e6e15e5c0 100644 --- a/pkg/github/__toolsnaps__/list_releases.snap +++ b/pkg/github/__toolsnaps__/list_releases.snap @@ -5,6 +5,18 @@ "title": "List releases" }, "description": "List releases in a GitHub repository", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "fields": { @@ -27,7 +39,8 @@ }, "owner": { "description": "Repository owner", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "page": { "description": "Page number for pagination (min 1)", @@ -42,7 +55,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -51,5 +65,162 @@ ], "type": "object" }, - "name": "list_releases" + "name": "list_releases", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "author": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "body": { + "type": [ + "null", + "string" + ] + }, + "draft": { + "description": "Whether the release is unpublished.", + "type": [ + "null", + "boolean" + ] + }, + "html_url": { + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "integer" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "prerelease": { + "description": "Whether the release is a prerelease.", + "type": [ + "null", + "boolean" + ] + }, + "published_at": { + "description": "Publication time in RFC3339 format.", + "type": [ + "null", + "string" + ] + }, + "tag_name": { + "type": [ + "null", + "string" + ] + } + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_repository_collaborators.snap b/pkg/github/__toolsnaps__/list_repository_collaborators.snap index 5af768fe05..dc1bce66fb 100644 --- a/pkg/github/__toolsnaps__/list_repository_collaborators.snap +++ b/pkg/github/__toolsnaps__/list_repository_collaborators.snap @@ -5,6 +5,18 @@ "title": "List repository collaborators" }, "description": "List collaborators of a GitHub repository. Results are paginated; the response includes `nextPage`, `prevPage`, `firstPage`, and `lastPage` fields. To get the next page, use the `nextPage` value as the `page` parameter.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "affiliation": { @@ -18,7 +30,8 @@ }, "owner": { "description": "Repository owner", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "page": { "description": "Page number for pagination (default 1, min 1)", @@ -33,7 +46,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -42,5 +56,59 @@ ], "type": "object" }, - "name": "list_repository_collaborators" + "name": "list_repository_collaborators", + "outputSchema": { + "additionalProperties": false, + "properties": { + "firstPage": { + "type": "integer" + }, + "items": { + "items": { + "additionalProperties": false, + "properties": { + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "role_name": { + "type": "string" + } + }, + "required": [ + "login", + "id", + "role_name" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "lastPage": { + "type": "integer" + }, + "nextPage": { + "type": "integer" + }, + "prevPage": { + "type": "integer" + } + }, + "required": [ + "firstPage", + "items", + "lastPage", + "nextPage", + "prevPage" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_starred_repositories.snap b/pkg/github/__toolsnaps__/list_starred_repositories.snap index f4c37373e5..d6beacf97c 100644 --- a/pkg/github/__toolsnaps__/list_starred_repositories.snap +++ b/pkg/github/__toolsnaps__/list_starred_repositories.snap @@ -5,6 +5,18 @@ "title": "List starred repositories" }, "description": "List starred repositories", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACxklEQVRIidWTT2gUdxTHP29mjMnBgkqNEQsbd3ZnfrvLzEoOnoRc217roYdSEQTRS4sFQbGHgK0UqigoKtKq7a29itdAT540IYmZ3cR/bYVAiVAvMZnfvB52NVtcdyd48sHAj3nf9/3zfjMuGyjf1L/a/uGOfcv/LN3NOyO5yf1oN5tkARCLU3704N6TPHNOXgEG5MQrQ65k3+Qdy5XAmLGRNdJFEX5RVQH5wrH4jcbU3/1mcyVYxZ4APMfqWYt7BnAyV3Ol6CswWqsNC3oYuJUk04/au/8V5EgQ7N31TgKFQmHQte5pYADXfv+64drvAM862clCoTDYi0MAwrBesEJV0TJISclKgpSAjwBH0JvNB9MHOwdLJrqhyJdABvypaFNwmqBNQRquMjs/f/+xBEE0ah1ZZP3Cl4Em0BRoZGjTy17eTpLkRadAEARbUmfzpw5SUigDpfazrQ1RizMKIL6Jrvkm1mIYX+kVN08Vw/iKb2L1TXStwzRSDOMLrUb9Mhv4ATtKfBOf6zD6xv2+BpQq8dVugF7k6wajSz0NFk080QLG13OKiG+iS76JtWiiH3LZWRepH+2H9U39WIs8nujW7+rQy/Tn1ilb7SegyirAJpWfcgtYodY+zvQTEGQGwKK1bv237FiqgLI2NNf51pixEWPGRv4HTQdmAVXYiAA1hScLC3f/BQjDcLtv4rNrpItrpI9LlfhqoVrdCdDGPEWodiPyugegCsz6/r4PGFg5nipfA0PATVp2D3nqfe5X4vOsDv6orMzIWxK88b2Oj497fy09fwE8BB0G2Qb85mb6bZJMJwBBEAXWkQngAOgyyBKwZ/fw1i2Tk5NpT4E9YVx2hKTdvoPoqYW5qXvd3PmVeC8qZ0A/BsiU4OH8VKPniobc9NmK9S66jv7emJv6oxvxq2oLf1KuRPttJp8NuemzXvj3s/4Daoz4w62BFyQAAAAASUVORK5CYII=", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABsklEQVRIidWUOy9EURSF1xaFAgWFd5iIUZjGD/AL/AhqCgqJelSi8EgQZLxKPf6GxihMJB6tKEa8k/kUsy83g5lzQ2MnJ/dm77XXWudknyMlCGAamErSk4S8G3gGXoDe0L66BBqzksz/ZxK5qxVAB/AIbAJbwBPQ9ZcCy8ArkAJ6/ZiW/oq8DXgAcrHctu+i87fkDcAq8Ab0x/L9nlsFGqpxmDf0SRqSlJY0EFs9Kg/CvpmNV4jvSRqTVJJ0I6kQW+eS8mZ2aUBK0oU+J+SuAliQdGRm9xUCTZJG3UjcWEsEkZQSYD4ZABs1z61GABvOtQVYlDRgxQvrH4VkxAYsRkaBumqAzS+A2uSRwbWqBoE5B+ZCRJx8zXsWQh1FIhMB2EnHzn1X/8nhrn9fA/xEmJ0kAhn/ngYIRJjMd8WfBIZUnuOzeNIfvY4KbN6xiQQykq7MrOjErcC8yhfy0qesXZIcc+2mwgI4AQ6BZiALFP3tyfl681zWMYfASSh5vb+UeeAWKAEHwGAMM+i5kmPy3lMfIpDmM46B4SrYYcdEkQ4RaPRbORK05XLPiPc0hvb8n3gHRCXiyIC2CgcAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "direction": { @@ -41,5 +53,83 @@ }, "type": "object" }, - "name": "list_starred_repositories" + "name": "list_starred_repositories", + "outputSchema": { + "items": { + "additionalProperties": false, + "properties": { + "archived": { + "type": "boolean" + }, + "created_at": { + "type": "string" + }, + "default_branch": { + "type": "string" + }, + "description": { + "type": "string" + }, + "fork": { + "type": "boolean" + }, + "forks_count": { + "type": "integer" + }, + "full_name": { + "type": "string" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "language": { + "type": "string" + }, + "name": { + "type": "string" + }, + "open_issues_count": { + "type": "integer" + }, + "private": { + "type": "boolean" + }, + "stargazers_count": { + "type": "integer" + }, + "topics": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "updated_at": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "full_name", + "html_url", + "stargazers_count", + "forks_count", + "open_issues_count", + "private", + "fork", + "archived" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/push_files.snap b/pkg/github/__toolsnaps__/push_files.snap index 798ad18451..ace5e477e5 100644 --- a/pkg/github/__toolsnaps__/push_files.snap +++ b/pkg/github/__toolsnaps__/push_files.snap @@ -5,6 +5,18 @@ "title": "Push files to repository" }, "description": "Push multiple files to a GitHub repository in a single commit", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABTklEQVRIie2UPUvDUBSGn5PW2qnYwUXExWhNpY2CUKT+BB10E3R0dnbrppuKLgouHbr5X5qlKRIFB+cuguBHj4O05KYfNsVJ+m7n3Hue54ZcrhDKQqGQTX1YVwg7QIbx0gh8b7VTJMMrqS+5RtgD7oDX2GhhC6UcbhkCVLZVuX1sesex4YCdX6uAGgIrsicjlrTGgQ9KVPDnmQgmgongPwgkXNiOq8ADMAtkx4UGvtflJvus20ANeIlN/vW5/kkt8L3D2HD6P9dRgSI8dQcc9w1IR3acBE3vbFSp8ZMVnoFSqJ/unZDe3pAYXyDIJarn9opbDZrewaAh2y7Oy5S1r6h5QG2Xxbw3piDw6xe244oKyxFmC5ihc+tS1qaqngKJyAEBGmZvSGzHVYT790T7aPozsaFoFZGboFGvDJsbOYuOuxuuc7n1uaV8sRSH8Q1DUVLnYLty3gAAAABJRU5ErkJggg==", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAA+ElEQVRIie2WLU5DQRRGzwUEirQCQ9BsoSGtZQHgSFgIsg4JXQCGP8V2QCBIEATZkJDgOJi+MJnMo30TFHmfmzvzfeeKyZ2BROpQvVHfrddDmhkZ4BY4Ai6BD7prAowjIoq7i85nFcGNf6qa1tayM1vAvBZQUg74c/WAHtAD/gMgn6YCT8A2MKwOTaZpCfAF3AGvFdlLx7XqdUVw4186rgWeE8Nn4cU67QLNAS/ASG3qmwVPqdaqjWw9A86BK+CkzaTuAseFBse/AiLiQg1gLzs3Bwb8XIp94AxYL/Af2xordap6v/htHKhv6nTlgBUAh9l6Rx11yfgG8ne/zwh2OysAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "branch": { @@ -39,11 +51,13 @@ }, "owner": { "description": "Repository owner", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -55,5 +69,39 @@ ], "type": "object" }, - "name": "push_files" + "name": "push_files", + "outputSchema": { + "additionalProperties": false, + "properties": { + "object": { + "additionalProperties": false, + "properties": { + "sha": { + "type": "string" + }, + "type": { + "description": "Git object kind: commit, tag, tree, or blob.", + "type": "string" + } + }, + "required": [ + "type", + "sha" + ], + "type": "object" + }, + "ref": { + "description": "Fully qualified reference, such as refs/heads/main.", + "type": "string" + } + }, + "required": [ + "ref", + "object" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/star_repository.snap b/pkg/github/__toolsnaps__/star_repository.snap index 4344d0ee06..7b0eef9367 100644 --- a/pkg/github/__toolsnaps__/star_repository.snap +++ b/pkg/github/__toolsnaps__/star_repository.snap @@ -21,11 +21,13 @@ "properties": { "owner": { "description": "Repository owner", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -34,5 +36,20 @@ ], "type": "object" }, - "name": "star_repository" + "name": "star_repository", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/unstar_repository.snap b/pkg/github/__toolsnaps__/unstar_repository.snap index ac8262e22b..27b8b27104 100644 --- a/pkg/github/__toolsnaps__/unstar_repository.snap +++ b/pkg/github/__toolsnaps__/unstar_repository.snap @@ -5,15 +5,29 @@ "title": "Unstar repository" }, "description": "Unstar a GitHub repository", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACxklEQVRIidWTT2gUdxTHP29mjMnBgkqNEQsbd3ZnfrvLzEoOnoRc217roYdSEQTRS4sFQbGHgK0UqigoKtKq7a29itdAT540IYmZ3cR/bYVAiVAvMZnfvB52NVtcdyd48sHAj3nf9/3zfjMuGyjf1L/a/uGOfcv/LN3NOyO5yf1oN5tkARCLU3704N6TPHNOXgEG5MQrQ65k3+Qdy5XAmLGRNdJFEX5RVQH5wrH4jcbU3/1mcyVYxZ4APMfqWYt7BnAyV3Ol6CswWqsNC3oYuJUk04/au/8V5EgQ7N31TgKFQmHQte5pYADXfv+64drvAM862clCoTDYi0MAwrBesEJV0TJISclKgpSAjwBH0JvNB9MHOwdLJrqhyJdABvypaFNwmqBNQRquMjs/f/+xBEE0ah1ZZP3Cl4Em0BRoZGjTy17eTpLkRadAEARbUmfzpw5SUigDpfazrQ1RizMKIL6Jrvkm1mIYX+kVN08Vw/iKb2L1TXStwzRSDOMLrUb9Mhv4ATtKfBOf6zD6xv2+BpQq8dVugF7k6wajSz0NFk080QLG13OKiG+iS76JtWiiH3LZWRepH+2H9U39WIs8nujW7+rQy/Tn1ilb7SegyirAJpWfcgtYodY+zvQTEGQGwKK1bv237FiqgLI2NNf51pixEWPGRv4HTQdmAVXYiAA1hScLC3f/BQjDcLtv4rNrpItrpI9LlfhqoVrdCdDGPEWodiPyugegCsz6/r4PGFg5nipfA0PATVp2D3nqfe5X4vOsDv6orMzIWxK88b2Oj497fy09fwE8BB0G2Qb85mb6bZJMJwBBEAXWkQngAOgyyBKwZ/fw1i2Tk5NpT4E9YVx2hKTdvoPoqYW5qXvd3PmVeC8qZ0A/BsiU4OH8VKPniobc9NmK9S66jv7emJv6oxvxq2oLf1KuRPttJp8NuemzXvj3s/4Daoz4w62BFyQAAAAASUVORK5CYII=", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABsklEQVRIidWUOy9EURSF1xaFAgWFd5iIUZjGD/AL/AhqCgqJelSi8EgQZLxKPf6GxihMJB6tKEa8k/kUsy83g5lzQ2MnJ/dm77XXWudknyMlCGAamErSk4S8G3gGXoDe0L66BBqzksz/ZxK5qxVAB/AIbAJbwBPQ9ZcCy8ArkAJ6/ZiW/oq8DXgAcrHctu+i87fkDcAq8Ab0x/L9nlsFGqpxmDf0SRqSlJY0EFs9Kg/CvpmNV4jvSRqTVJJ0I6kQW+eS8mZ2aUBK0oU+J+SuAliQdGRm9xUCTZJG3UjcWEsEkZQSYD4ZABs1z61GABvOtQVYlDRgxQvrH4VkxAYsRkaBumqAzS+A2uSRwbWqBoE5B+ZCRJx8zXsWQh1FIhMB2EnHzn1X/8nhrn9fA/xEmJ0kAhn/ngYIRJjMd8WfBIZUnuOzeNIfvY4KbN6xiQQykq7MrOjErcC8yhfy0qesXZIcc+2mwgI4AQ6BZiALFP3tyfl681zWMYfASSh5vb+UeeAWKAEHwGAMM+i5kmPy3lMfIpDmM46B4SrYYcdEkQ4RaPRbORK05XLPiPc0hvb8n3gHRCXiyIC2CgcAAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "owner": { "description": "Repository owner", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -22,5 +36,20 @@ ], "type": "object" }, - "name": "unstar_repository" + "name": "unstar_repository", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index df3dac1af9..f374713a01 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -446,7 +446,7 @@ func ListBranches(t translations.TranslationHelperFunc) inventory.ServerTool { // CreateOrUpdateFile creates a tool to create or update a file in a GitHub repository. func CreateOrUpdateFile(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewTool[CreateOrUpdateFileInput, *MinimalFileContentResponse]( ToolsetMetadataRepos, mcp.Tool{ Name: "create_or_update_file", @@ -502,34 +502,34 @@ SHA MUST be provided for existing file updates. }, }, scopes.RequireAll(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input CreateOrUpdateFileInput) (*mcp.CallToolResult, *MinimalFileContentResponse, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - path, err := RequiredParam[string](args, "path") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + path := input.Path + if path == "" { + return utils.NewToolResultError("missing required parameter: path"), nil, nil } - path, err = validateRelativePath(path) + path, err := validateRelativePath(path) if err != nil { return utils.NewToolResultError(fmt.Sprintf("invalid path: %s", err)), nil, nil } - content, err := RequiredParam[string](args, "content") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + content := input.Content + if content == "" { + return utils.NewToolResultError("missing required parameter: content"), nil, nil } - message, err := RequiredParam[string](args, "message") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + message := input.Message + if message == "" { + return utils.NewToolResultError("missing required parameter: message"), nil, nil } - branch, err := RequiredParam[string](args, "branch") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + branch := input.Branch + if branch == "" { + return utils.NewToolResultError("missing required parameter: branch"), nil, nil } // json.Marshal encodes byte arrays with base64, which is required for the API. @@ -543,18 +543,12 @@ SHA MUST be provided for existing file updates. } // If SHA is provided, set it (for updates) - sha, err := OptionalParam[string](args, "sha") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + sha := input.SHA if sha != "" { opts.SHA = new(sha) } - allowSymlinkWrite, err := OptionalParam[bool](args, "allow_symlink_write") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + allowSymlinkWrite := input.AllowSymlinkWrite // Create or update the file client, err := deps.GetClient(ctx) @@ -666,7 +660,7 @@ SHA MUST be provided for existing file updates. minimalResponse := convertToMinimalFileContentResponse(fileContent) - return MarshalledTextResult(minimalResponse), nil, nil + return MarshalledTextResult(minimalResponse), &minimalResponse, nil }, ) tool.ScopeAccess = scopes.DynamicChallenge( @@ -679,7 +673,7 @@ SHA MUST be provided for existing file updates. // CreateRepository creates a tool to create a new GitHub repository. func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[CreateRepositoryInput, *MinimalResponse]( ToolsetMetadataRepos, mcp.Tool{ Name: "create_repository", @@ -717,27 +711,18 @@ func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool }, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - name, err := RequiredParam[string](args, "name") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input CreateRepositoryInput) (*mcp.CallToolResult, *MinimalResponse, error) { + name := input.Name + if name == "" { + return utils.NewToolResultError("missing required parameter: name"), nil, nil } - description, err := OptionalParam[string](args, "description") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - organization, err := OptionalParam[string](args, "organization") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - private, err := OptionalBoolParamWithDefault(args, "private", true) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - autoInit, err := OptionalParam[bool](args, "autoInit") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + description := input.Description + organization := input.Organization + private := true + if input.Private != nil { + private = *input.Private } + autoInit := input.AutoInit repo := &github.Repository{ Name: new(name), @@ -779,7 +764,7 @@ func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &minimalResponse, nil }, ) } @@ -801,7 +786,7 @@ type deleteRepositoryState struct { // DeleteRepository creates a tool that deletes a GitHub repository after the // user confirms its full name through elicitation. func DeleteRepository(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewTool[RepositoryInput, *RepositoryMessageOutput]( ToolsetMetadataRepos, mcp.Tool{ Name: DeleteRepositoryToolName, @@ -827,14 +812,14 @@ func DeleteRepository(t translations.TranslationHelperFunc) inventory.ServerTool }, }, scopes.RequireAll(scopes.DeleteRepo, scopes.Repo), - func(ctx context.Context, deps ToolDependencies, req *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 + func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, input RepositoryInput) (*mcp.CallToolResult, *RepositoryMessageOutput, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } fullName := owner + "/" + repo @@ -953,7 +938,8 @@ func DeleteRepository(t translations.TranslationHelperFunc) inventory.ServerTool return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to delete repository", resp, body), nil, nil } - return utils.NewToolResultText(fmt.Sprintf("Repository %s was deleted.", fullName)), nil, nil + message := fmt.Sprintf("Repository %s was deleted.", fullName) + return utils.NewToolResultText(message), &RepositoryMessageOutput{Message: message}, nil }, ) tool.MinimumProtocolVersion = inventory.ProtocolVersionMultiRoundTrip @@ -1025,11 +1011,12 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool fileContentFieldEnum, ) - return NewTool( + return NewTool[GetFileContentsInput, *RepositoryContentsOutput]( ToolsetMetadataRepos, mcp.Tool{ - Name: "get_file_contents", - Description: t("TOOL_GET_FILE_CONTENTS_DESCRIPTION", "Get the contents of a file or directory from a GitHub repository"), + Name: "get_file_contents", + OutputSchema: repositoryContentsOutputSchema(), + Description: t("TOOL_GET_FILE_CONTENTS_DESCRIPTION", "Get the contents of a file or directory from a GitHub repository"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_FILE_CONTENTS_USER_TITLE", "Get file or directory contents"), ReadOnlyHint: true, @@ -1037,37 +1024,25 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool InputSchema: schema, }, scopes.PublicRead(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input GetFileContentsInput) (*mcp.CallToolResult, *RepositoryContentsOutput, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - path, err := OptionalParam[string](args, "path") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + path := input.Path path = strings.TrimPrefix(path, "/") - ref, err := OptionalParam[string](args, "ref") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + ref := input.Ref originalRef := ref - sha, err := OptionalParam[string](args, "sha") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + sha := input.SHA - fields, err := OptionalStringArrayParam(args, "fields") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + fields := input.Fields client, err := deps.GetClient(ctx) if err != nil { @@ -1104,8 +1079,11 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool // The path does not point to a file or directory. // Instead let's try to find it in the Git Tree by matching the end of the path. if err != nil || (fileContent == nil && dirContent == nil) { - res, data, err := matchFiles(ctx, client, owner, repo, ref, path, rawOpts, 0) - return attachIFC(res), data, err + res, _, err := matchFiles(ctx, client, owner, repo, ref, path, rawOpts, 0) + if err != nil { + return nil, nil, err + } + return repositoryContentResult(attachIFC(res)) } if fileContent != nil && fileContent.SHA != nil { @@ -1133,10 +1111,10 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultError(fmt.Sprintf("failed to inspect repository file: %s", err)), nil, nil } if read.Metadata != nil && read.Metadata.Type == "submodule" { - return attachIFC(utils.NewToolResultText(marshalRepositoryPathMetadata(read.Metadata, "", successNote))), nil, nil + return repositoryContentResult(attachIFC(utils.NewToolResultText(marshalRepositoryPathMetadata(read.Metadata, "", successNote)))) } if read.Metadata != nil && fileContent.GetType() == "symlink" && !read.ContentAvailable { - return attachIFC(utils.NewToolResultText(marshalRepositoryPathMetadata(read.Metadata, "not_returned", successNote))), nil, nil + return repositoryContentResult(attachIFC(utils.NewToolResultText(marshalRepositoryPathMetadata(read.Metadata, "not_returned", successNote)))) } // Empty files (0 bytes) have no content to decode; return @@ -1152,7 +1130,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool } message := fmt.Sprintf("successfully downloaded empty file (SHA: %s)%s", fileSHA, successNote) message = repositoryReadMessage(read, message, successNote) - return attachIFC(utils.NewToolResultResource(message, result)), nil, nil + return repositoryContentResult(attachIFC(utils.NewToolResultResource(message, result))) } // For files >= 1MB, return a ResourceLink instead of content @@ -1170,9 +1148,9 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool resourceLink.Title = fmt.Sprintf("Dereferenced target %s via symlink %s", read.Metadata.ResolvedTargetPath, path) } message = repositoryReadMessage(read, message, successNote) - return attachIFC(utils.NewToolResultResourceLink( + return repositoryContentResult(attachIFC(utils.NewToolResultResourceLink( message, - resourceLink)), nil, nil + resourceLink))) } if !read.ContentAvailable { @@ -1200,7 +1178,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool } message := fmt.Sprintf("successfully downloaded text file (SHA: %s)%s", fileSHA, successNote) message = repositoryReadMessage(read, message, successNote) - return attachIFC(utils.NewToolResultResource(message, result)), nil, nil + return repositoryContentResult(attachIFC(utils.NewToolResultResource(message, result))) } result := &mcp.ResourceContents{ @@ -1210,7 +1188,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool } message := fmt.Sprintf("successfully downloaded binary file (SHA: %s)%s", fileSHA, successNote) message = repositoryReadMessage(read, message, successNote) - return attachIFC(utils.NewToolResultResource(message, result)), nil, nil + return repositoryContentResult(attachIFC(utils.NewToolResultResource(message, result))) } else if dirContent != nil { // file content or file SHA is nil which means it's a directory filtered := false @@ -1228,11 +1206,16 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultError("failed to marshal response"), nil, nil } recordDirContentsFieldsUsage(ctx, deps, dirContent, filtered, len(r)) - return attachIFC(utils.NewToolResultText(string(r))), nil, nil + var output []*RepositoryDirectoryEntryOutput + if err := json.Unmarshal(r, &output); err != nil { + return nil, nil, fmt.Errorf("failed to decode typed directory contents: %w", err) + } + return attachIFC(utils.NewToolResultText(string(r))), &RepositoryContentsOutput{Directory: output}, nil } return utils.NewToolResultError("failed to get file contents"), nil, nil }, + normalizeRepositoryFieldsArguments, ) } @@ -1244,12 +1227,13 @@ func recordDirContentsFieldsUsage(ctx context.Context, deps ToolDependencies, fu // ForkRepository creates a tool to fork a repository. func ForkRepository(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[ForkRepositoryInput, *ForkRepositoryOutput]( ToolsetMetadataRepos, mcp.Tool{ - Name: "fork_repository", - Description: t("TOOL_FORK_REPOSITORY_DESCRIPTION", "Fork a GitHub repository to your account or specified organization"), - Icons: octicons.Icons("repo-forked"), + Name: "fork_repository", + OutputSchema: forkRepositoryOutputSchema(), + Description: t("TOOL_FORK_REPOSITORY_DESCRIPTION", "Fork a GitHub repository to your account or specified organization"), + Icons: octicons.Icons("repo-forked"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_FORK_REPOSITORY_USER_TITLE", "Fork repository"), ReadOnlyHint: false, @@ -1274,19 +1258,16 @@ func ForkRepository(t translations.TranslationHelperFunc) inventory.ServerTool { }, }, publicRepositoryWriteScopeAccess(), - 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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input ForkRepositoryInput) (*mcp.CallToolResult, *ForkRepositoryOutput, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - org, err := OptionalParam[string](args, "organization") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } + org := input.Organization opts := &github.RepositoryCreateForkOptions{} if org != "" { @@ -1302,7 +1283,7 @@ func ForkRepository(t translations.TranslationHelperFunc) inventory.ServerTool { // Check if it's an acceptedError. An acceptedError indicates that the update is in progress, // and it's not a real error. if resp != nil && resp.StatusCode == http.StatusAccepted && isAcceptedError(err) { - return utils.NewToolResultText("Fork is in progress"), nil, nil + return utils.NewToolResultText("Fork is in progress"), &ForkRepositoryOutput{Message: &RepositoryMessageOutput{Message: "Fork is in progress"}}, nil } return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to fork repository", @@ -1331,7 +1312,7 @@ func ForkRepository(t translations.TranslationHelperFunc) inventory.ServerTool { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ForkRepositoryOutput{Repository: &minimalResponse}, nil }, ) } @@ -1343,11 +1324,12 @@ func ForkRepository(t translations.TranslationHelperFunc) inventory.ServerTool { // The approach implemented here gets automatic commit signing when used with either the github-actions user or as an app, // both of which suit an LLM well. func DeleteFile(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewTool[DeleteFileInput, *DeleteFileOutput]( ToolsetMetadataRepos, mcp.Tool{ - Name: "delete_file", - Description: t("TOOL_DELETE_FILE_DESCRIPTION", "Delete a file from a GitHub repository"), + Name: "delete_file", + OutputSchema: deleteFileOutputSchema(), + Description: t("TOOL_DELETE_FILE_DESCRIPTION", "Delete a file from a GitHub repository"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_DELETE_FILE_USER_TITLE", "Delete file"), ReadOnlyHint: false, @@ -1381,30 +1363,30 @@ func DeleteFile(t translations.TranslationHelperFunc) inventory.ServerTool { }, }, scopes.RequireAll(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input DeleteFileInput) (*mcp.CallToolResult, *DeleteFileOutput, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - path, err := RequiredParam[string](args, "path") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + path := input.Path + if path == "" { + return utils.NewToolResultError("missing required parameter: path"), nil, nil } - path, err = validateRelativePath(path) + path, err := validateRelativePath(path) if err != nil { return utils.NewToolResultError(fmt.Sprintf("invalid path: %s", err)), nil, nil } - message, err := RequiredParam[string](args, "message") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + message := input.Message + if message == "" { + return utils.NewToolResultError("missing required parameter: message"), nil, nil } - branch, err := RequiredParam[string](args, "branch") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + branch := input.Branch + if branch == "" { + return utils.NewToolResultError("missing required parameter: branch"), nil, nil } client, err := deps.GetClient(ctx) @@ -1515,17 +1497,17 @@ func DeleteFile(t translations.TranslationHelperFunc) inventory.ServerTool { } // Create a response similar to what the DeleteFile API would return - response := map[string]any{ - "commit": newCommit, - "content": nil, - } + response := struct { + Commit *github.Commit `json:"commit"` + Content *struct{} `json:"content"` + }{Commit: newCommit} r, err := json.Marshal(response) if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &DeleteFileOutput{Commit: repositoryGitCommitOutput(newCommit)}, nil }, ) tool.ScopeAccess = scopes.DynamicChallenge( @@ -1538,7 +1520,7 @@ func DeleteFile(t translations.TranslationHelperFunc) inventory.ServerTool { // CreateBranch creates a tool to create a new branch. func CreateBranch(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[CreateBranchInput, *RepositoryReferenceOutput]( ToolsetMetadataRepos, mcp.Tool{ Name: "create_branch", @@ -1571,23 +1553,20 @@ func CreateBranch(t translations.TranslationHelperFunc) inventory.ServerTool { }, }, publicRepositoryWriteScopeAccess(), - 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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input CreateBranchInput) (*mcp.CallToolResult, *RepositoryReferenceOutput, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - branch, err := RequiredParam[string](args, "branch") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - fromBranch, err := OptionalParam[string](args, "from_branch") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + branch := input.Branch + if branch == "" { + return utils.NewToolResultError("missing required parameter: branch"), nil, nil } + fromBranch := input.FromBranch client, err := deps.GetClient(ctx) if err != nil { @@ -1644,14 +1623,14 @@ func CreateBranch(t translations.TranslationHelperFunc) inventory.ServerTool { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), repositoryReferenceOutput(createdRef), nil }, ) } // PushFiles creates a tool to push multiple files in a single commit to a GitHub repository. func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewTool[PushFilesInput, *RepositoryReferenceOutput]( ToolsetMetadataRepos, mcp.Tool{ Name: "push_files", @@ -1703,50 +1682,40 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { }, }, publicRepositoryWriteScopeAccess(), - 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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input PushFilesInput) (*mcp.CallToolResult, *RepositoryReferenceOutput, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - branch, err := RequiredParam[string](args, "branch") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + branch := input.Branch + if branch == "" { + return utils.NewToolResultError("missing required parameter: branch"), nil, nil } - message, err := RequiredParam[string](args, "message") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + message := input.Message + if message == "" { + return utils.NewToolResultError("missing required parameter: message"), nil, nil } - // Parse files parameter - this should be an array of objects with path and content - filesObj, ok := args["files"].([]any) - if !ok { + if input.Files == nil { return utils.NewToolResultError("files parameter must be an array of objects with path and content"), nil, nil } - - entries := make([]*github.TreeEntry, 0, len(filesObj)) - for _, file := range filesObj { - fileMap, ok := file.(map[string]any) - if !ok { - return utils.NewToolResultError("each file must be an object with path and content"), nil, nil - } - - filePath, ok := fileMap["path"].(string) - if !ok || filePath == "" { + entries := make([]*github.TreeEntry, 0, len(input.Files)) + for _, file := range input.Files { + if file.Path == "" { return utils.NewToolResultError("each file must have a path"), nil, nil } - filePath, err = validateRelativePath(filePath) + filePath, err := validateRelativePath(file.Path) if err != nil { return utils.NewToolResultError(fmt.Sprintf("invalid file path: %s", err)), nil, nil } - - content, ok := fileMap["content"].(string) - if !ok { + if file.Content == nil { return utils.NewToolResultError("each file must have content"), nil, nil } + content := *file.Content entries = append(entries, &github.TreeEntry{ Path: new(filePath), @@ -1880,8 +1849,9 @@ func PushFiles(t translations.TranslationHelperFunc) inventory.ServerTool { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), repositoryReferenceOutput(updatedRef), nil }, + normalizePushFilesArguments, ) tool.ScopeAccess = scopes.DynamicChallenge( []scopes.Scope{scopes.Repo, scopes.Workflow}, @@ -1986,11 +1956,12 @@ func ListTags(t translations.TranslationHelperFunc) inventory.ServerTool { // GetTag creates a tool to get details about a specific tag in a GitHub repository. func GetTag(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[RepositoryTagInput, *RepositoryTagOutput]( ToolsetMetadataRepos, mcp.Tool{ - Name: "get_tag", - Description: t("TOOL_GET_TAG_DESCRIPTION", "Get details about a specific git tag in a GitHub repository"), + Name: "get_tag", + OutputSchema: repositoryTagOutputSchema(), + Description: t("TOOL_GET_TAG_DESCRIPTION", "Get details about a specific git tag in a GitHub repository"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_TAG_USER_TITLE", "Get tag details"), ReadOnlyHint: true, @@ -2015,18 +1986,18 @@ func GetTag(t translations.TranslationHelperFunc) inventory.ServerTool { }, }, scopes.PublicRead(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input RepositoryTagInput) (*mcp.CallToolResult, *RepositoryTagOutput, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - tag, err := RequiredParam[string](args, "tag") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + tag := input.Tag + if tag == "" { + return utils.NewToolResultError("missing required parameter: tag"), nil, nil } client, err := deps.GetClient(ctx) @@ -2061,7 +2032,7 @@ func GetTag(t translations.TranslationHelperFunc) inventory.ServerTool { } result := utils.NewToolResultText(string(r)) result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoMetadata) - return result, nil, nil + return result, &RepositoryTagOutput{Reference: repositoryReferenceOutput(ref)}, nil } tagObj, resp, err := client.Git.GetTag(ctx, owner, repo, *ref.Object.SHA) @@ -2092,7 +2063,7 @@ func GetTag(t translations.TranslationHelperFunc) inventory.ServerTool { // collaborator with push access. Confidentiality follows repo // visibility. result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoMetadata) - return result, nil, nil + return result, &RepositoryTagOutput{Tag: repositoryAnnotatedTagOutput(tagObj)}, nil }, ) } @@ -2119,7 +2090,7 @@ func ListReleases(t translations.TranslationHelperFunc) inventory.ServerTool { ) WithPagination(schema) - return NewTool( + return NewTool[ListReleasesInput, []ReleaseListOutput]( ToolsetMetadataRepos, mcp.Tool{ Name: "list_releases", @@ -2131,23 +2102,17 @@ func ListReleases(t translations.TranslationHelperFunc) inventory.ServerTool { InputSchema: schema, }, scopes.PublicRead(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 - } - fields, err := OptionalStringArrayParam(args, "fields") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input ListReleasesInput) (*mcp.CallToolResult, []ReleaseListOutput, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } + fields := input.Fields + pagination := repositoryPagination(input.Page, input.PerPage) opts := &github.ListOptions{ Page: pagination.Page, @@ -2215,18 +2180,25 @@ func ListReleases(t translations.TranslationHelperFunc) inventory.ServerTool { func(isPrivate bool) ifc.SecurityLabel { return ifc.LabelRelease(isPrivate, hasDraft) }) - return result, nil, nil + var output []ReleaseListOutput + if err := json.Unmarshal(r, &output); err != nil { + return nil, nil, fmt.Errorf("failed to decode typed releases: %w", err) + } + return result, output, nil }, + normalizeRepositoryFieldsArguments, + normalizeRepositoryArguments, ) } // GetLatestRelease creates a tool to get the latest release in a GitHub repository. func GetLatestRelease(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[RepositoryInput, *MinimalRelease]( ToolsetMetadataRepos, mcp.Tool{ - Name: "get_latest_release", - Description: t("TOOL_GET_LATEST_RELEASE_DESCRIPTION", "Get the latest release in a GitHub repository"), + Name: "get_latest_release", + OutputSchema: repositoryReleaseOutputSchema(), + Description: t("TOOL_GET_LATEST_RELEASE_DESCRIPTION", "Get the latest release in a GitHub repository"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_LATEST_RELEASE_USER_TITLE", "Get latest release"), ReadOnlyHint: true, @@ -2247,14 +2219,14 @@ func GetLatestRelease(t translations.TranslationHelperFunc) inventory.ServerTool }, }, scopes.PublicRead(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input RepositoryInput) (*mcp.CallToolResult, *MinimalRelease, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } client, err := deps.GetClient(ctx) @@ -2291,17 +2263,19 @@ func GetLatestRelease(t translations.TranslationHelperFunc) inventory.ServerTool func(isPrivate bool) ifc.SecurityLabel { return ifc.LabelRelease(isPrivate, release.GetDraft()) }) - return result, nil, nil + output := convertToMinimalRelease(release) + return result, &output, nil }, ) } func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[RepositoryTagInput, *MinimalRelease]( ToolsetMetadataRepos, mcp.Tool{ - Name: "get_release_by_tag", - Description: t("TOOL_GET_RELEASE_BY_TAG_DESCRIPTION", "Get a specific release by its tag name in a GitHub repository"), + Name: "get_release_by_tag", + OutputSchema: repositoryReleaseOutputSchema(), + Description: t("TOOL_GET_RELEASE_BY_TAG_DESCRIPTION", "Get a specific release by its tag name in a GitHub repository"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_RELEASE_BY_TAG_USER_TITLE", "Get a release by tag name"), ReadOnlyHint: true, @@ -2326,18 +2300,18 @@ func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool }, }, scopes.PublicRead(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input RepositoryTagInput) (*mcp.CallToolResult, *MinimalRelease, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - tag, err := RequiredParam[string](args, "tag") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + tag := input.Tag + if tag == "" { + return utils.NewToolResultError("missing required parameter: tag"), nil, nil } client, err := deps.GetClient(ctx) @@ -2378,7 +2352,8 @@ func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool func(isPrivate bool) ifc.SecurityLabel { return ifc.LabelRelease(isPrivate, release.GetDraft()) }) - return result, nil, nil + output := convertToMinimalRelease(release) + return result, &output, nil }, ) } @@ -2397,7 +2372,7 @@ func sanitizeReleaseNameAndBody(release *github.RepositoryRelease) { // ListStarredRepositories creates a tool to list starred repositories for the authenticated user or a specified user. func ListStarredRepositories(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[ListStarredRepositoriesInput, []MinimalRepository]( ToolsetMetadataStargazers, mcp.Tool{ Name: "list_starred_repositories", @@ -2427,23 +2402,11 @@ func ListStarredRepositories(t translations.TranslationHelperFunc) inventory.Ser }), }, scopes.PublicRead(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - username, err := OptionalParam[string](args, "username") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - sort, err := OptionalParam[string](args, "sort") - 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 - } - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input ListStarredRepositoriesInput) (*mcp.CallToolResult, []MinimalRepository, error) { + username := input.Username + sort := input.Sort + direction := input.Direction + pagination := repositoryPagination(input.Page, input.PerPage) opts := &github.ActivityListStarredOptions{ ListOptions: github.ListOptions{ @@ -2534,14 +2497,15 @@ func ListStarredRepositories(t translations.TranslationHelperFunc) inventory.Ser visibilities = append(visibilities, mr.Private) } result = attachJoinedIFCLabel(ctx, deps, result, visibilities, ifc.LabelSearchIssues) - return result, nil, nil + return result, minimalRepos, nil }, + normalizeRepositoryArguments, ) } // StarRepository creates a tool to star a repository. func StarRepository(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[RepositoryInput, *RepositoryMessageOutput]( ToolsetMetadataStargazers, mcp.Tool{ Name: "star_repository", @@ -2567,14 +2531,14 @@ func StarRepository(t translations.TranslationHelperFunc) inventory.ServerTool { }, }, scopes.RequireAll(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input RepositoryInput) (*mcp.CallToolResult, *RepositoryMessageOutput, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } client, err := deps.GetClient(ctx) @@ -2600,14 +2564,15 @@ func StarRepository(t translations.TranslationHelperFunc) inventory.ServerTool { return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to star repository", resp, body), nil, nil } - return utils.NewToolResultText(fmt.Sprintf("Successfully starred repository %s/%s", owner, repo)), nil, nil + message := fmt.Sprintf("Successfully starred repository %s/%s", owner, repo) + return utils.NewToolResultText(message), &RepositoryMessageOutput{Message: message}, nil }, ) } // UnstarRepository creates a tool to unstar a repository. func UnstarRepository(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[RepositoryInput, *RepositoryMessageOutput]( ToolsetMetadataStargazers, mcp.Tool{ Name: "unstar_repository", @@ -2632,14 +2597,14 @@ func UnstarRepository(t translations.TranslationHelperFunc) inventory.ServerTool }, }, scopes.RequireAll(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input RepositoryInput) (*mcp.CallToolResult, *RepositoryMessageOutput, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } client, err := deps.GetClient(ctx) @@ -2665,7 +2630,8 @@ func UnstarRepository(t translations.TranslationHelperFunc) inventory.ServerTool return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to unstar repository", resp, body), nil, nil } - return utils.NewToolResultText(fmt.Sprintf("Successfully unstarred repository %s/%s", owner, repo)), nil, nil + message := fmt.Sprintf("Successfully unstarred repository %s/%s", owner, repo) + return utils.NewToolResultText(message), &RepositoryMessageOutput{Message: message}, nil }, ) } @@ -2714,7 +2680,7 @@ type BlameAuthor struct { type BlameCommit struct { SHA string `json:"sha"` MessageHeadline string `json:"message_headline"` - CommittedDate string `json:"committed_date"` + CommittedDate string `json:"committed_date" jsonschema:"Commit timestamp in RFC3339 format."` Author BlameAuthor `json:"author"` } @@ -2724,9 +2690,9 @@ type BlameCommit struct { // touching the file (0 = newest), not an absolute time delta. See: // https://docs.github.com/en/graphql/reference/objects#blamerange type BlameRange struct { - StartingLine int `json:"starting_line"` - EndingLine int `json:"ending_line"` - Age int `json:"age"` + StartingLine int `json:"starting_line" jsonschema:"First line of the inclusive, one-based range."` + EndingLine int `json:"ending_line" jsonschema:"Last line of the inclusive, one-based range."` + Age int `json:"age" jsonschema:"Relative commit age among commits touching this file; 0 is newest."` CommitSHA string `json:"commit_sha"` } @@ -2741,8 +2707,8 @@ type BlameResult struct { Ranges []BlameRange `json:"ranges"` Commits map[string]BlameCommit `json:"commits"` PageInfo MinimalPageInfo `json:"pageInfo"` - TotalRanges int `json:"total_ranges"` - Truncated bool `json:"truncated,omitempty"` + TotalRanges int `json:"total_ranges" jsonschema:"Matching ranges before pagination or truncation."` + Truncated bool `json:"truncated,omitempty" jsonschema:"Whether the 1000-range safety limit was reached."` } // blameCommitFragment is the GraphQL selection for a Commit's blame data. @@ -2790,10 +2756,11 @@ func validateBlamePath(p string) error { } func GetFileBlame(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := NewTool[GetFileBlameInput, *RepositoryBlameOutput]( ToolsetMetadataRepos, mcp.Tool{ - Name: "get_file_blame", + Name: "get_file_blame", + OutputSchema: repositoryUnionSchema(&jsonschema.Schema{Type: "null"}, repositoryOutputSchema[BlameResult]()), Description: t("TOOL_GET_FILE_BLAME_DESCRIPTION", "Get git blame information for a file, showing the commit that last modified each line. "+ "Ranges share commit metadata via the top-level 'commits' map keyed by SHA. "+ @@ -2839,38 +2806,35 @@ func GetFileBlame(t translations.TranslationHelperFunc) inventory.ServerTool { }), }, scopes.PublicRead(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input GetFileBlameInput) (*mcp.CallToolResult, *RepositoryBlameOutput, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } - path, err := RequiredParam[string](args, "path") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + path := input.Path + if path == "" { + return utils.NewToolResultError("missing required parameter: path"), nil, nil } if err := validateBlamePath(path); err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - ref, err := OptionalParam[string](args, "ref") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - _, hasStartLine := args["start_line"] - startLine, err := OptionalIntParam(args, "start_line") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + ref := input.Ref + hasStartLine := input.StartLine != nil + startLine := 0 + if hasStartLine { + startLine = *input.StartLine } if hasStartLine && startLine < 1 { return utils.NewToolResultError("start_line must be omitted or >= 1"), nil, nil } - _, hasEndLine := args["end_line"] - endLine, err := OptionalIntParam(args, "end_line") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + hasEndLine := input.EndLine != nil + endLine := 0 + if hasEndLine { + endLine = *input.EndLine } if hasEndLine && endLine < 1 { return utils.NewToolResultError("end_line must be omitted or >= 1"), nil, nil @@ -2878,23 +2842,14 @@ func GetFileBlame(t translations.TranslationHelperFunc) inventory.ServerTool { if hasStartLine && hasEndLine && endLine < startLine { return utils.NewToolResultError("end_line must be >= start_line when both are provided"), nil, nil } - if _, hasPage := args["page"]; hasPage { - return utils.NewToolResultError("This tool uses cursor-based pagination. Use the 'after' parameter with the 'endCursor' value from the previous response instead of 'page'."), nil, nil - } - pagination, err := OptionalCursorPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - if _, hasPerPage := args["perPage"]; hasPerPage { - perPage, err := OptionalIntParam(args, "perPage") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - if perPage < 1 || perPage > 100 { + pagination := CursorPaginationParams{After: input.After, PerPage: 30} + if input.PerPage != nil { + if *input.PerPage < 1 || *input.PerPage > 100 { return utils.NewToolResultError("perPage must be between 1 and 100 when provided"), nil, nil } - pagination.PerPage = perPage + pagination.PerPage = *input.PerPage } + afterOffset, err := decodeBlameCursor(pagination.After) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -3084,8 +3039,9 @@ func GetFileBlame(t translations.TranslationHelperFunc) inventory.ServerTool { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(payload)), nil, nil + return utils.NewToolResultText(string(payload)), &RepositoryBlameOutput{Result: &result}, nil }, + normalizeRepositoryBlameArguments, ) st.FeatureRule = featureEnabledRule(FeatureFlagFileBlame) return st @@ -3093,7 +3049,7 @@ func GetFileBlame(t translations.TranslationHelperFunc) inventory.ServerTool { // ListRepositoryCollaborators creates a tool to list collaborators of a GitHub repository. func ListRepositoryCollaborators(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[ListRepositoryCollaboratorsInput, *RepositoryCollaboratorsOutput]( ToolsetMetadataRepos, mcp.Tool{ Name: "list_repository_collaborators", @@ -3128,23 +3084,17 @@ func ListRepositoryCollaborators(t translations.TranslationHelperFunc) inventory }(), }, scopes.PublicRead(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - affiliation, err := OptionalParam[string](args, "affiliation") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input ListRepositoryCollaboratorsInput) (*mcp.CallToolResult, *RepositoryCollaboratorsOutput, error) { + owner := input.Owner + if owner == "" { + return utils.NewToolResultError("missing required parameter: owner"), nil, nil } - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + repo := input.Repo + if repo == "" { + return utils.NewToolResultError("missing required parameter: repo"), nil, nil } + affiliation := input.Affiliation + pagination := repositoryPagination(input.Page, input.PerPage) client, err := deps.GetClient(ctx) if err != nil { @@ -3186,12 +3136,9 @@ func ListRepositoryCollaborators(t translations.TranslationHelperFunc) inventory }) } - response := map[string]any{ - "items": result, - "nextPage": resp.NextPage, - "prevPage": resp.PrevPage, - "firstPage": resp.FirstPage, - "lastPage": resp.LastPage, + response := &RepositoryCollaboratorsOutput{ + Items: result, NextPage: resp.NextPage, PrevPage: resp.PrevPage, + FirstPage: resp.FirstPage, LastPage: resp.LastPage, } callResult := MarshalledTextResult(response) @@ -3200,7 +3147,8 @@ func ListRepositoryCollaborators(t translations.TranslationHelperFunc) inventory // push access, so the roster is never world-readable — not even on // a public repo — hence always private confidentiality. callResult = attachStaticIFCLabel(ctx, deps, callResult, ifc.LabelCollaboratorRoster()) - return callResult, nil, nil + return callResult, response, nil }, + normalizeRepositoryArguments, ) } diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 11c6d210a4..5e5ef09123 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -92,7 +92,6 @@ func Test_GetFileContents(t *testing.T) { // Verify tool definition once serverTool := GetFileContents(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") @@ -1193,7 +1192,6 @@ func Test_ForkRepository(t *testing.T) { // Verify tool definition once serverTool := ForkRepository(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") @@ -1295,7 +1293,6 @@ func Test_CreateBranch(t *testing.T) { // Verify tool definition once serverTool := CreateBranch(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") @@ -2051,7 +2048,6 @@ func Test_CreateOrUpdateFile(t *testing.T) { // Verify tool definition once serverTool := CreateOrUpdateFile(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") @@ -2731,7 +2727,6 @@ func Test_CreateRepository(t *testing.T) { // Verify tool definition once serverTool := CreateRepository(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") @@ -2921,7 +2916,6 @@ func Test_PushFiles(t *testing.T) { // Verify tool definition once serverTool := PushFiles(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") @@ -3666,7 +3660,6 @@ func Test_PushFiles(t *testing.T) { func Test_DeleteRepository(t *testing.T) { serverTool := DeleteRepository(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") @@ -4222,7 +4215,6 @@ func Test_DeleteFile(t *testing.T) { // Verify tool definition once serverTool := DeleteFile(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") @@ -4536,7 +4528,6 @@ func Test_GetTag(t *testing.T) { // Verify tool definition once serverTool := GetTag(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") @@ -4738,7 +4729,6 @@ func Test_GetTag(t *testing.T) { func Test_ListReleases(t *testing.T) { serverTool := ListReleases(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") @@ -4838,7 +4828,6 @@ func Test_ListReleases(t *testing.T) { func Test_GetLatestRelease(t *testing.T) { serverTool := GetLatestRelease(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") @@ -4930,7 +4919,6 @@ func Test_GetLatestRelease(t *testing.T) { func Test_GetReleaseByTag(t *testing.T) { serverTool := GetReleaseByTag(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") @@ -5625,7 +5613,6 @@ func Test_ListStarredRepositories(t *testing.T) { // Verify tool definition once serverTool := ListStarredRepositories(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") @@ -5787,7 +5774,6 @@ func Test_StarRepository(t *testing.T) { // Verify tool definition once serverTool := StarRepository(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") @@ -5878,7 +5864,6 @@ func Test_UnstarRepository(t *testing.T) { // Verify tool definition once serverTool := UnstarRepository(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") @@ -5968,7 +5953,6 @@ func Test_GetFileBlame(t *testing.T) { // Verify tool definition once serverTool := GetFileBlame(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) // get_file_blame is gated so it is not advertised unless the feature flag // (or insiders mode) opts it in. @@ -6681,7 +6665,6 @@ func Test_ListRepositoryCollaborators(t *testing.T) { // Verify tool definition once serverTool := ListRepositoryCollaborators(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) schema, ok := tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") diff --git a/pkg/github/repository_tool_types.go b/pkg/github/repository_tool_types.go new file mode 100644 index 0000000000..022fb0e47e --- /dev/null +++ b/pkg/github/repository_tool_types.go @@ -0,0 +1,540 @@ +package github + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "maps" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type RepositoryInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` +} + +type CreateOrUpdateFileInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Path string `json:"path"` + Content string `json:"content"` + Message string `json:"message"` + Branch string `json:"branch"` + SHA string `json:"sha,omitempty"` + AllowSymlinkWrite bool `json:"allow_symlink_write,omitempty"` +} + +type CreateRepositoryInput struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + Organization string `json:"organization,omitempty"` + Private *bool `json:"private,omitempty"` + AutoInit bool `json:"autoInit,omitempty"` +} + +type GetFileContentsInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Path string `json:"path,omitempty"` + Ref string `json:"ref,omitempty"` + SHA string `json:"sha,omitempty"` + Fields []string `json:"fields,omitempty"` +} + +type ForkRepositoryInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Organization string `json:"organization,omitempty"` +} + +type DeleteFileInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Path string `json:"path"` + Message string `json:"message"` + Branch string `json:"branch"` +} + +type CreateBranchInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Branch string `json:"branch"` + FromBranch string `json:"from_branch,omitempty"` +} + +type PushFileInput struct { + Path string `json:"path"` + Content *string `json:"content"` +} + +type PushFilesInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Branch string `json:"branch"` + Message string `json:"message"` + Files []PushFileInput `json:"files"` +} + +type RepositoryTagInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Tag string `json:"tag"` +} + +type ListReleasesInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Fields []string `json:"fields,omitempty"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` +} + +type ListStarredRepositoriesInput struct { + Username string `json:"username,omitempty"` + Sort string `json:"sort,omitempty"` + Direction string `json:"direction,omitempty"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` +} + +type GetFileBlameInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Path string `json:"path"` + Ref string `json:"ref,omitempty"` + StartLine *int `json:"start_line,omitempty"` + EndLine *int `json:"end_line,omitempty"` + PerPage *int `json:"perPage,omitempty"` + After string `json:"after,omitempty"` +} + +type ListRepositoryCollaboratorsInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Affiliation string `json:"affiliation,omitempty"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` +} + +type RepositoryMessageOutput struct { + Message string `json:"message"` +} + +type RepositoryCollaboratorsOutput struct { + FirstPage int `json:"firstPage"` + Items []MinimalCollaborator `json:"items"` + LastPage int `json:"lastPage"` + NextPage int `json:"nextPage"` + PrevPage int `json:"prevPage"` +} + +type RepositoryBlameOutput struct { + Result *BlameResult +} + +func (out RepositoryBlameOutput) MarshalJSON() ([]byte, error) { + return json.Marshal(out.Result) +} + +// Pointer fields preserve both a complete compact release and a requested +// projection, including explicit false/zero values. +type ReleaseListOutput struct { + ID *int64 `json:"id,omitempty"` + TagName *string `json:"tag_name,omitempty"` + Name *string `json:"name,omitempty"` + Body *string `json:"body,omitempty"` + HTMLURL *string `json:"html_url,omitempty"` + PublishedAt *string `json:"published_at,omitempty" jsonschema:"Publication time in RFC3339 format."` + Prerelease *bool `json:"prerelease,omitempty" jsonschema:"Whether the release is a prerelease."` + Draft *bool `json:"draft,omitempty" jsonschema:"Whether the release is unpublished."` + Author *MinimalUser `json:"author,omitempty"` +} + +type RepositoryGitObjectOutput struct { + Type string `json:"type" jsonschema:"Git object kind: commit, tag, tree, or blob."` + SHA string `json:"sha"` +} + +type RepositoryReferenceOutput struct { + Ref string `json:"ref" jsonschema:"Fully qualified reference, such as refs/heads/main."` + Object RepositoryGitObjectOutput `json:"object"` +} + +type RepositoryAnnotatedTagOutput struct { + Tag string `json:"tag"` + SHA string `json:"sha"` + Message string `json:"message,omitempty"` + Tagger *MinimalCommitAuthor `json:"tagger,omitempty"` + Object *RepositoryGitObjectOutput `json:"object,omitempty"` +} + +type RepositoryGitCommitOutput struct { + SHA string `json:"sha"` + Message string `json:"message,omitempty"` + HTMLURL string `json:"html_url,omitempty"` + Author *MinimalCommitAuthor `json:"author,omitempty"` + Committer *MinimalCommitAuthor `json:"committer,omitempty"` + TreeSHA string `json:"tree_sha,omitempty"` + Parents []string `json:"parents,omitempty" jsonschema:"Parent commit SHAs in Git parent order."` +} + +type DeleteFileOutput struct { + Commit *RepositoryGitCommitOutput `json:"commit"` + Content *struct{} `json:"content"` +} + +type RepositoryTagOutput struct { + Reference *RepositoryReferenceOutput + Tag *RepositoryAnnotatedTagOutput +} + +func (out RepositoryTagOutput) MarshalJSON() ([]byte, error) { + if out.Reference != nil { + return json.Marshal(out.Reference) + } + return json.Marshal(out.Tag) +} + +type ForkRepositoryOutput struct { + Repository *MinimalResponse + Message *RepositoryMessageOutput +} + +func (out ForkRepositoryOutput) MarshalJSON() ([]byte, error) { + if out.Repository != nil { + return json.Marshal(out.Repository) + } + return json.Marshal(out.Message) +} + +// Repository contents can be a projected directory listing or a heterogeneous +// MCP content response. The latter is represented losslessly as typed content +// blocks, including the human-readable status and embedded text/blob or link. +type RepositoryContentsOutput struct { + Directory []*RepositoryDirectoryEntryOutput + Content *RepositoryContentOutput +} + +// Optional pointer fields retain projected zero values without reinstating +// omitted fields. API routing/hypermedia URLs never enter structured output. +type RepositoryDirectoryEntryOutput struct { + Type *string `json:"type,omitempty"` + Name *string `json:"name,omitempty"` + Path *string `json:"path,omitempty"` + SHA *string `json:"sha,omitempty"` + Size *int `json:"size,omitempty" jsonschema:"File size in bytes."` + HTMLURL *string `json:"html_url,omitempty"` + DownloadURL *string `json:"download_url,omitempty"` + Target *string `json:"target,omitempty"` + SubmoduleGitURL *string `json:"submodule_git_url,omitempty" jsonschema:"Git remote URL for a submodule."` +} + +func repositoryReferenceOutput(ref *github.Reference) *RepositoryReferenceOutput { + if ref == nil { + return nil + } + return &RepositoryReferenceOutput{ + Ref: ref.GetRef(), + Object: RepositoryGitObjectOutput{ + Type: ref.GetObject().GetType(), SHA: ref.GetObject().GetSHA(), + }, + } +} + +func repositoryCommitAuthorOutput(author *github.CommitAuthor) *MinimalCommitAuthor { + if author == nil { + return nil + } + out := &MinimalCommitAuthor{Name: author.GetName(), Email: author.GetEmail()} + if author.Date != nil { + out.Date = author.Date.Format("2006-01-02T15:04:05Z07:00") + } + return out +} + +func repositoryGitCommitOutput(commit *github.Commit) *RepositoryGitCommitOutput { + if commit == nil { + return nil + } + out := &RepositoryGitCommitOutput{ + SHA: commit.GetSHA(), Message: commit.GetMessage(), HTMLURL: commit.GetHTMLURL(), + Author: repositoryCommitAuthorOutput(commit.Author), Committer: repositoryCommitAuthorOutput(commit.Committer), + TreeSHA: commit.GetTree().GetSHA(), + } + for _, parent := range commit.Parents { + if parent != nil { + out.Parents = append(out.Parents, parent.GetSHA()) + } + } + return out +} + +func repositoryAnnotatedTagOutput(tag *github.Tag) *RepositoryAnnotatedTagOutput { + if tag == nil { + return nil + } + out := &RepositoryAnnotatedTagOutput{ + Tag: tag.GetTag(), SHA: tag.GetSHA(), Message: tag.GetMessage(), + Tagger: repositoryCommitAuthorOutput(tag.Tagger), + } + if tag.Object != nil { + out.Object = &RepositoryGitObjectOutput{Type: tag.Object.GetType(), SHA: tag.Object.GetSHA()} + } + return out +} + +func (out RepositoryContentsOutput) MarshalJSON() ([]byte, error) { + if out.Content != nil { + return json.Marshal(out.Content) + } + return json.Marshal(out.Directory) +} + +type RepositoryContentOutput struct { + Content []RepositoryContentBlock `json:"content"` +} + +type RepositoryContentBlock struct { + Text *RepositoryTextBlock + Resource *RepositoryResourceBlock + Link *RepositoryLinkBlock +} + +func (out RepositoryContentBlock) MarshalJSON() ([]byte, error) { + switch { + case out.Text != nil: + return json.Marshal(out.Text) + case out.Resource != nil: + return json.Marshal(out.Resource) + default: + return json.Marshal(out.Link) + } +} + +type RepositoryTextBlock struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type RepositoryResourceBlock struct { + Type string `json:"type"` + Resource RepositoryResourceOutput `json:"resource"` +} + +type RepositoryResourceOutput struct { + URI string `json:"uri"` + MIMEType string `json:"mimeType,omitempty"` + Text *string `json:"text,omitempty"` + Blob *string `json:"blob,omitempty" jsonschema:"Base64-encoded file bytes."` +} + +type RepositoryLinkBlock struct { + Type string `json:"type"` + URI string `json:"uri"` + Name string `json:"name"` + Title string `json:"title,omitempty"` + Size *int64 `json:"size,omitempty" jsonschema:"File size in bytes."` +} + +func repositoryContentOutput(result *mcp.CallToolResult) (*RepositoryContentsOutput, error) { + if result == nil || result.IsError { + return nil, nil + } + out := &RepositoryContentOutput{Content: make([]RepositoryContentBlock, 0, len(result.Content))} + for _, content := range result.Content { + switch block := content.(type) { + case *mcp.TextContent: + out.Content = append(out.Content, RepositoryContentBlock{Text: &RepositoryTextBlock{Type: "text", Text: block.Text}}) + case *mcp.EmbeddedResource: + resource := RepositoryResourceOutput{URI: block.Resource.URI, MIMEType: block.Resource.MIMEType} + if block.Resource.Blob != nil { + blob := base64.StdEncoding.EncodeToString(block.Resource.Blob) + resource.Blob = &blob + } else if block.Resource.Text != "" { + resource.Text = &block.Resource.Text + } + out.Content = append(out.Content, RepositoryContentBlock{Resource: &RepositoryResourceBlock{Type: "resource", Resource: resource}}) + case *mcp.ResourceLink: + out.Content = append(out.Content, RepositoryContentBlock{Link: &RepositoryLinkBlock{ + Type: "resource_link", URI: block.URI, Name: block.Name, Title: block.Title, Size: block.Size, + }}) + default: + return nil, fmt.Errorf("unsupported repository content block %T", content) + } + } + return &RepositoryContentsOutput{Content: out}, nil +} + +func repositoryContentResult(result *mcp.CallToolResult) (*mcp.CallToolResult, *RepositoryContentsOutput, error) { + out, err := repositoryContentOutput(result) + return result, out, err +} + +func normalizeRepositoryArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + for _, field := range []string{"page", "perPage"} { + if value, present := args[field]; present { + number, err := toInt(value) + if err != nil { + return nil, fmt.Errorf("parameter %s is not a valid number: %w", field, err) + } + if number == 0 { + delete(args, field) + } else { + args[field] = number + } + } + } + return json.Marshal(args) +} + +func normalizeRepositoryFieldsArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + if fields, present := args["fields"]; present && fields == nil { + delete(args, "fields") + } + if _, err := OptionalStringArrayParam(args, "fields"); err != nil { + return nil, err + } + return json.Marshal(args) +} + +func normalizeRepositoryBlameArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + if _, present := args["page"]; present { + return nil, fmt.Errorf("This tool uses cursor-based pagination. Use the 'after' parameter with the 'endCursor' value from the previous response instead of 'page'.") //nolint:revive,staticcheck // Preserve the legacy validation message. + } + for _, field := range []string{"start_line", "end_line", "perPage"} { + if value, present := args[field]; present { + number, err := toInt(value) + if err != nil { + return nil, fmt.Errorf("parameter %s is not a valid number: %w", field, err) + } + args[field] = number + } + } + return json.Marshal(args) +} + +func normalizePushFilesArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + files, ok := args["files"].([]any) + if !ok { + return nil, fmt.Errorf("files parameter must be an array of objects with path and content") + } + for _, file := range files { + object, ok := file.(map[string]any) + if !ok { + return nil, fmt.Errorf("each file must be an object with path and content") + } + path, ok := object["path"].(string) + if !ok || path == "" { + return nil, fmt.Errorf("each file must have a path") + } + if _, ok := object["content"].(string); !ok { + return nil, fmt.Errorf("each file must have content") + } + } + return raw, nil +} + +func repositoryPagination(page, perPage *int) PaginationParams { + out := PaginationParams{Page: 1, PerPage: 30} + if page != nil && *page != 0 { + out.Page = *page + } + if perPage != nil && *perPage != 0 { + out.PerPage = *perPage + } + return out +} + +func repositoryOutputSchema[T any]() *jsonschema.Schema { + schema, err := inventory.CachedSchemaFor[T](nil) + if err != nil { + panic(fmt.Sprintf("failed to generate repository output schema: %v", err)) + } + return inventory.CloneSchema(schema) +} + +func repositoryUnionSchema(variants ...*jsonschema.Schema) *jsonschema.Schema { + out := &jsonschema.Schema{OneOf: variants, Defs: make(map[string]*jsonschema.Schema)} + for _, variant := range variants { + maps.Copy(out.Defs, variant.Defs) + variant.Defs = nil + } + return out +} + +func repositoryTagOutputSchema() *jsonschema.Schema { + ref := repositoryOutputSchema[RepositoryReferenceOutput]() + tag := repositoryOutputSchema[RepositoryAnnotatedTagOutput]() + tag.Properties["tagger"].Properties["date"].Description = "Tagger time in RFC3339 format." + return repositoryUnionSchema(&jsonschema.Schema{Type: "null"}, ref, tag) +} + +func repositoryReleaseOutputSchema() *jsonschema.Schema { + schema := repositoryOutputSchema[*MinimalRelease]() + schema.Properties["published_at"].Description = "Publication time in RFC3339 format." + schema.Properties["prerelease"].Description = "Whether the release is a prerelease." + schema.Properties["draft"].Description = "Whether the release is unpublished." + return schema +} + +func forkRepositoryOutputSchema() *jsonschema.Schema { + return repositoryUnionSchema( + &jsonschema.Schema{Type: "null"}, + repositoryOutputSchema[MinimalResponse](), + repositoryOutputSchema[RepositoryMessageOutput](), + ) +} + +func deleteFileOutputSchema() *jsonschema.Schema { + schema := repositoryOutputSchema[*DeleteFileOutput]() + schema.Properties["content"] = &jsonschema.Schema{Type: "null"} + for _, field := range []string{"author", "committer"} { + schema.Properties["commit"].Properties[field].Properties["date"].Description = "Commit timestamp in RFC3339 format." + } + return schema +} + +func repositoryContentsOutputSchema() *jsonschema.Schema { + text := repositoryOutputSchema[RepositoryTextBlock]() + text.Properties["type"].Const = new(any("text")) + resource := repositoryOutputSchema[RepositoryResourceBlock]() + resource.Properties["type"].Const = new(any("resource")) + resource.Properties["resource"].OneOf = []*jsonschema.Schema{ + {Required: []string{"text"}, Not: &jsonschema.Schema{Required: []string{"blob"}}}, + {Required: []string{"blob"}, Not: &jsonschema.Schema{Required: []string{"text"}}}, + {Not: &jsonschema.Schema{AnyOf: []*jsonschema.Schema{{Required: []string{"text"}}, {Required: []string{"blob"}}}}}, + } + link := repositoryOutputSchema[RepositoryLinkBlock]() + link.Properties["type"].Const = new(any("resource_link")) + content := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "content": {Type: "array", Items: repositoryUnionSchema(text, resource, link)}, + }, + Required: []string{"content"}, + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + } + directory := repositoryOutputSchema[[]*RepositoryDirectoryEntryOutput]() + directory.Type, directory.Types = "array", nil + return repositoryUnionSchema(&jsonschema.Schema{Type: "null"}, directory, content) +} diff --git a/pkg/github/typed_repository_outputs_test.go b/pkg/github/typed_repository_outputs_test.go new file mode 100644 index 0000000000..a9239a75fe --- /dev/null +++ b/pkg/github/typed_repository_outputs_test.go @@ -0,0 +1,594 @@ +package github + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "maps" + "net/http" + "strings" + "testing" + "time" + + "github.com/github/github-mcp-server/internal/requeststate" + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func repositoryOutputFixtures() (*github.Reference, *github.Commit, *github.RepositoryRelease) { + ref := &github.Reference{Ref: new("refs/heads/main"), URL: new("ref-url"), + Object: &github.GitObject{Type: new("commit"), SHA: new("base"), URL: new("commit-url")}} + commit := &github.Commit{SHA: new("new"), Message: new("change"), + HTMLURL: new("https://github.com/owner/repo/commit/new"), URL: new("api-commit-url"), + Author: &github.CommitAuthor{Date: &github.Timestamp{Time: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)}}, + Tree: &github.Tree{SHA: new("tree")}, Parents: []*github.Commit{{SHA: new("base")}}} + release := &github.RepositoryRelease{ID: 7, TagName: "v1", Name: new("Release"), + HTMLURL: "https://github.com/owner/repo/releases/tag/v1", + URL: "api-release-url", AssetsURL: "api-assets-url", UploadURL: "api-upload-url", + Body: new("No\u200btes"), CreatedAt: github.Timestamp{Time: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)}, + Author: &github.User{Login: new("octo")}, Assets: []*github.ReleaseAsset{{ + Name: new("app.zip"), Size: new(3), Digest: new("sha256:abc"), + }}} + return ref, commit, release +} + +func typedRepositoryDeps(t *testing.T) BaseDeps { + t.Helper() + ref, commit, release := repositoryOutputFixtures() + handlers := map[string]http.HandlerFunc{ + "GET /repos/{owner}/{repo}": mockResponse(t, http.StatusOK, &github.Repository{ID: new(int64(1)), DefaultBranch: new("main")}), + "POST /user/repos": func(w http.ResponseWriter, r *http.Request) { + var body github.Repository + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "created", body.GetName()) + if body.GetDescription() == "public" { + assert.False(t, body.GetPrivate()) + } else { + assert.True(t, body.GetPrivate(), "omitted private retains the secure default") + } + mockResponse(t, http.StatusCreated, &github.Repository{ID: new(int64(8)), HTMLURL: new("repo-url")})(w, r) + }, + "GET /repos/{owner}/{repo}/contents/{path...}": func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/forbidden"): + mockResponse(t, http.StatusForbidden, map[string]string{"message": "Forbidden"})(w, r) + case strings.HasSuffix(r.URL.Path, "/new"): + mockResponse(t, http.StatusNotFound, map[string]string{"message": "Not Found"})(w, r) + case strings.HasSuffix(r.URL.Path, "/directory"): + mockResponse(t, http.StatusOK, []*github.RepositoryContent{{ + Name: new("file"), Type: new("file"), SHA: new("sha"), Size: new(0), + URL: new("api-content-url"), GitURL: new("api-git-url"), + HTMLURL: new("https://github.com/owner/repo/blob/main/file"), + DownloadURL: new("https://raw.githubusercontent.com/owner/repo/main/file"), + }})(w, r) + case strings.HasSuffix(r.URL.Path, "/submodule-directory"): + mockResponse(t, http.StatusOK, []*github.RepositoryContent{{ + Name: new("lib"), Path: new("lib"), Type: new("file"), SHA: new("sha"), + SubmoduleGitURL: new("https://github.com/owner/lib.git"), + }})(w, r) + case strings.HasSuffix(r.URL.Path, "/empty-directory"): + mockResponse(t, http.StatusOK, []*github.RepositoryContent{})(w, r) + case strings.HasSuffix(r.URL.Path, "/submodule"): + mockResponse(t, http.StatusOK, &github.RepositoryContent{Type: new("submodule"), SHA: new("abc"), SubmoduleGitURL: new("git-url")})(w, r) + case strings.HasSuffix(r.URL.Path, "/symlink"): + mockResponse(t, http.StatusOK, &github.RepositoryContent{Type: new("symlink"), SHA: new("abc"), Target: new("../outside")})(w, r) + case strings.HasSuffix(r.URL.Path, "/missing"): + mockResponse(t, http.StatusNotFound, map[string]string{"message": "Not Found"})(w, r) + default: + data := []byte("hello\n") + if strings.HasSuffix(r.URL.Path, "/binary") { + data = []byte{0, 1, 2} + } + if strings.HasSuffix(r.URL.Path, "/empty") { + data = []byte{} + } + size := len(data) + if strings.HasSuffix(r.URL.Path, "/large") { + size = 1024 * 1024 + } + mockResponse(t, http.StatusOK, &github.RepositoryContent{ + Type: new("file"), Name: new("file"), SHA: new(gitBlobSHA(data)), + Size: new(size), Encoding: new("base64"), + Content: new(base64.StdEncoding.EncodeToString(data)), DownloadURL: new("download-url"), + })(w, r) + } + }, + "PUT /repos/{owner}/{repo}/contents/{path...}": func(w http.ResponseWriter, r *http.Request) { + var body github.RepositoryContentFileOptions + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, []byte("plain text"), body.Content, "mutation input is not double base64 encoded") + assert.Equal(t, "main", body.GetBranch()) + mockResponse(t, http.StatusCreated, &github.RepositoryContentResponse{ + Content: &github.RepositoryContent{Path: new("new"), SHA: new("blob")}, + Commit: *commit, + })(w, r) + }, + GetReposGitRefByOwnerByRepoByRef: func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/forbidden") { + mockResponse(t, http.StatusForbidden, map[string]string{"message": "Forbidden"})(w, r) + return + } + if strings.HasSuffix(r.URL.Path, "/annotated") { + mockResponse(t, http.StatusOK, &github.Reference{Ref: new("refs/tags/annotated"), Object: &github.GitObject{ + Type: new("tag"), SHA: new("tag-sha"), + }})(w, r) + return + } + mockResponse(t, http.StatusOK, ref)(w, r) + }, + "GET /repos/{owner}/{repo}/git/tags/{tag_sha}": mockResponse(t, http.StatusOK, &github.Tag{ + Tag: new("annotated"), Message: new("Tag message"), SHA: new("tag-sha"), + Tagger: &github.CommitAuthor{Date: commit.Author.Date}, URL: new("api-tag-url"), + }), + "GET /repos/{owner}/{repo}/git/commits/{commit_sha}": mockResponse(t, http.StatusOK, commit), + "POST /repos/{owner}/{repo}/git/trees": func(w http.ResponseWriter, r *http.Request) { + var body struct { + Entries []github.TreeEntry `json:"tree"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + require.Len(t, body.Entries, 1) + assert.Equal(t, "file", body.Entries[0].GetPath()) + if body.Entries[0].Content != nil { + assert.Equal(t, "", body.Entries[0].GetContent(), "empty push content is valid") + } else { + assert.Nil(t, body.Entries[0].SHA, "delete retains null SHA semantics") + } + mockResponse(t, http.StatusCreated, &github.Tree{SHA: new("tree")})(w, r) + }, + "POST /repos/{owner}/{repo}/git/commits": mockResponse(t, http.StatusCreated, commit), + "POST /repos/{owner}/{repo}/git/refs": func(w http.ResponseWriter, r *http.Request) { + var body github.CreateRef + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "refs/heads/feature", body.Ref) + assert.Equal(t, "base", body.SHA) + mockResponse(t, http.StatusCreated, ref)(w, r) + }, + PatchReposGitRefsByOwnerByRepoByRef: func(w http.ResponseWriter, r *http.Request) { + var body github.UpdateRef + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + require.NotNil(t, body.Force) + assert.False(t, *body.Force) + assert.Equal(t, "new", body.SHA) + mockResponse(t, http.StatusOK, ref)(w, r) + }, + "GET /repos/{owner}/{repo}/git/trees/{tree_sha}": mockResponse(t, http.StatusOK, &github.Tree{Entries: []*github.TreeEntry{{ + Path: new("nested/missing"), Type: new("blob"), + }}}), + "POST /repos/{owner}/{repo}/forks": func(w http.ResponseWriter, r *http.Request) { + var body github.RepositoryCreateForkOptions + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + if body.Organization == "pending" { + mockResponse(t, http.StatusAccepted, map[string]string{"message": "pending"})(w, r) + return + } + mockResponse(t, http.StatusAccepted, &github.Repository{ID: new(int64(9)), HTMLURL: new("fork-url")})(w, r) + }, + "GET /repos/{owner}/{repo}/releases/latest": mockResponse(t, http.StatusOK, release), + "GET /repos/{owner}/{repo}/releases/tags/{tag}": mockResponse(t, http.StatusOK, release), + "GET /repos/{owner}/{repo}/releases": func(w http.ResponseWriter, r *http.Request) { + if r.URL.Query().Get("page") == "2" { + assert.Equal(t, "7", r.URL.Query().Get("per_page")) + } else { + assert.Equal(t, "1", r.URL.Query().Get("page")) + assert.Equal(t, "30", r.URL.Query().Get("per_page")) + } + if strings.Contains(r.URL.Path, "/empty/") { + mockResponse(t, http.StatusOK, []*github.RepositoryRelease{})(w, r) + return + } + mockResponse(t, http.StatusOK, []*github.RepositoryRelease{release})(w, r) + }, + "GET /user/starred": mockResponse(t, http.StatusOK, []*github.StarredRepository{{Repository: &github.Repository{ + ID: new(int64(1)), Name: new("repo"), FullName: new("owner/repo"), + }}}), + "GET /users/{user}/starred": mockResponse(t, http.StatusOK, []*github.StarredRepository{}), + "PUT /user/starred/{owner}/{repo}": mockResponse(t, http.StatusNoContent, nil), + "DELETE /user/starred/{owner}/{repo}": mockResponse(t, http.StatusNoContent, nil), + "GET /repos/{owner}/{repo}/collaborators": func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "2", r.URL.Query().Get("page")) + assert.Equal(t, "7", r.URL.Query().Get("per_page")) + assert.Equal(t, "outside", r.URL.Query().Get("affiliation")) + if strings.Contains(r.URL.Path, "/empty/") { + mockResponse(t, http.StatusOK, []*github.User{})(w, r) + return + } + w.Header().Set("Link", `; rel="next"`) + mockResponse(t, http.StatusOK, []*github.User{{Login: new("octo"), ID: new(int64(1)), RoleName: new("write")}})(w, r) + }, + } + gql := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":{"repository":{"defaultBranchRef":{"name":"main"},"object":{"__typename":"Commit","blame":{"ranges":[{"startingLine":1,"endingLine":3,"age":0,"commit":{"oid":"abc","message":"Message","committedDate":"2026-01-02T00:00:00Z","author":{"name":"Author","email":"author@example.com","user":null}}}]}}}}}`)) + })}} + return BaseDeps{ + Client: mustNewGHClient(t, MockHTTPClientWithHandlers(handlers)), GQLClient: githubv4.NewClient(gql), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } +} + +func TestTypedRepositoryOutputs(t *testing.T) { + translate := translations.NullTranslationHelper + tools := []inventory.ServerTool{ + CreateOrUpdateFile(translate), CreateRepository(translate), DeleteRepository(translate), + GetFileContents(translate), ForkRepository(translate), DeleteFile(translate), CreateBranch(translate), + PushFiles(translate), GetTag(translate), ListReleases(translate), GetLatestRelease(translate), + GetReleaseByTag(translate), ListStarredRepositories(translate), StarRepository(translate), + UnstarRepository(translate), GetFileBlame(translate), ListRepositoryCollaborators(translate), + } + inputContracts := make(map[string]string, len(tools)) + for _, tool := range tools { + inputContracts[tool.Tool.Name] = mustMarshalJSON(t, tool.Tool.InputSchema) + } + inv, err := inventory.NewBuilder().SetTools(tools).WithToolsets([]string{"all"}). + WithFeatureChecker(func(context.Context, string) (bool, error) { return true, nil }).Build() + require.NoError(t, err) + coord := func(extra map[string]any) map[string]any { + args := map[string]any{"owner": "owner", "repo": "repo"} + maps.Copy(args, extra) + return args + } + calls := []struct { + name string + args map[string]any + }{ + {"create_repository", map[string]any{"name": "created"}}, + {"create_repository", map[string]any{"name": "created", "private": false, "description": "public"}}, + {"create_or_update_file", coord(map[string]any{"path": "new", "content": "plain text", "message": "change", "branch": "main"})}, + {"create_branch", coord(map[string]any{"branch": "feature"})}, + {"push_files", coord(map[string]any{"branch": "main", "message": "change", "files": []any{map[string]any{"path": "file", "content": ""}}})}, + {"delete_file", coord(map[string]any{"path": "file", "message": "delete", "branch": "main"})}, + {"fork_repository", coord(nil)}, + {"fork_repository", coord(map[string]any{"organization": "pending"})}, + {"get_tag", coord(map[string]any{"tag": "lightweight"})}, + {"get_tag", coord(map[string]any{"tag": "annotated"})}, + {"list_releases", coord(map[string]any{"page": "2.0", "perPage": "7"})}, + {"list_releases", coord(nil)}, + {"list_releases", coord(map[string]any{"page": 0, "perPage": 0})}, + {"list_releases", coord(map[string]any{"page": "0", "perPage": "0"})}, + {"list_releases", coord(map[string]any{"page": "2", "perPage": "7", "fields": []any{"id", "draft"}})}, + {"list_releases", coord(map[string]any{"repo": "empty", "page": "2", "perPage": "7"})}, + {"get_latest_release", coord(nil)}, + {"get_release_by_tag", coord(map[string]any{"tag": "v1"})}, + {"list_starred_repositories", map[string]any{}}, + {"list_starred_repositories", map[string]any{"username": "empty"}}, + {"star_repository", coord(nil)}, + {"unstar_repository", coord(nil)}, + {"list_repository_collaborators", coord(map[string]any{"page": "2", "perPage": "7", "affiliation": "outside"})}, + {"list_repository_collaborators", coord(map[string]any{"repo": "empty", "page": "2", "perPage": "7", "affiliation": "outside"})}, + {"get_file_blame", coord(map[string]any{"path": "file", "start_line": "2", "end_line": "3", "perPage": "1"})}, + {"get_file_blame", coord(map[string]any{"path": "file", "after": encodeBlameCursor(1)})}, + } + for _, path := range []string{"text", "empty", "binary", "large", "submodule", "symlink", "directory", "submodule-directory", "empty-directory", "missing"} { + calls = append(calls, struct { + name string + args map[string]any + }{"get_file_contents", coord(map[string]any{"path": path, "sha": strings.Repeat("a", 40)})}) + } + calls = append(calls, struct { + name string + args map[string]any + }{"get_file_contents", coord(map[string]any{"path": "directory", "sha": strings.Repeat("a", 40), "fields": []any{"name"}})}) + calls = append(calls, struct { + name string + args map[string]any + }{"get_file_contents", coord(map[string]any{"path": "directory", "sha": strings.Repeat("a", 40), "fields": []any{"size", "html_url", "url"}})}) + + for _, test := range []struct { + protocol string + lockdown bool + }{ + {"2025-11-25", false}, {inventory.ProtocolVersionMultiRoundTrip, false}, {"", false}, + {"2025-11-25", true}, {inventory.ProtocolVersionMultiRoundTrip, true}, {"", true}, + } { + protocol := test.protocol + t.Run(fmt.Sprintf("protocol=%s/lockdown=%t", protocol, test.lockdown), func(t *testing.T) { + deps := typedRepositoryDeps(t) + deps.Flags.LockdownMode = test.lockdown + server := mcp.NewServer(&mcp.Implementation{Name: "typed-repository-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + if protocol == "" { + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + switch request := req.(type) { + case *mcp.ListToolsRequest: + request.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + case *mcp.CallToolRequest: + request.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + } + return next(ctx, method, req) + } + }) + } + version := protocol + if version == "" { + version = inventory.ProtocolVersionMultiRoundTrip + } + session := connectCommentVisibilityClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 16, "deletion remains hidden without form elicitation") + schemas := make(map[string]*jsonschema.Resolved) + for _, tool := range list.Tools { + var inputSchema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, tool.InputSchema)), &inputSchema)) + for _, property := range inputSchema.Properties { + delete(property.Extra, "x-mcp-header") + } + assert.JSONEq(t, inputContracts[tool.Name], mustMarshalJSON(t, inputSchema), "input metadata remains unchanged") + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Nil(t, tool.OutputSchema, tool.Name) + continue + } + require.NotNil(t, tool.OutputSchema, tool.Name) + require.NoError(t, toolsnaps.Test(tool.Name, *tool)) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, tool.OutputSchema)), &schema)) + schemas[tool.Name], err = schema.Resolve(nil) + require.NoError(t, err, tool.Name) + } + for _, call := range calls { + t.Run(call.name+"/"+mustMarshalJSON(t, call.args), func(t *testing.T) { + // Direct invocation provides the legacy content baseline without SDK + // serialization. Compare every content block, not just a JSON proxy. + var tool inventory.ServerTool + for _, candidate := range tools { + if candidate.Tool.Name == call.name { + tool = candidate + break + } + } + request := createMCPRequest(call.args) + legacy, err := tool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, legacy.IsError, legacy) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: call.name, Arguments: call.args}) + require.NoError(t, err) + require.False(t, result.IsError, result) + assert.JSONEq(t, mustMarshalJSON(t, legacy.Meta["ifc"]), mustMarshalJSON(t, result.Meta["ifc"]), "IFC labels remain protocol-independent") + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Equal(t, mustMarshalJSON(t, legacy.Content), mustMarshalJSON(t, result.Content), "legacy content blocks remain byte-exact") + assertLegacyRepositoryText(t, call.name, call.args, result) + assert.Nil(t, result.StructuredContent) + return + } + require.NotNil(t, result.StructuredContent) + if hasOnlyTextContent(result.Content) { + assert.JSONEq(t, mustMarshalJSON(t, result.StructuredContent), getTextResult(t, result).Text, + "modern JSON text is the compact structured output") + } else { + assert.Equal(t, mustMarshalJSON(t, legacy.Content), mustMarshalJSON(t, result.Content), + "non-text resource blocks preserve legacy content") + } + var output any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, schemas[call.name].Validate(output)) + first := result.Content[0].(*mcp.TextContent).Text + switch { + case call.name == "get_file_contents" && !strings.HasPrefix(first, "["): + assert.JSONEq(t, `{"content":`+mustMarshalJSON(t, legacy.Content)+`}`, mustMarshalJSON(t, result.StructuredContent)) + case call.name == "get_file_contents": + var entries []*RepositoryDirectoryEntryOutput + require.NoError(t, json.Unmarshal([]byte(first), &entries)) + assert.JSONEq(t, mustMarshalJSON(t, entries), mustMarshalJSON(t, result.StructuredContent)) + assert.NotContains(t, mustMarshalJSON(t, result.StructuredContent), "api-content-url") + assert.NotContains(t, mustMarshalJSON(t, result.StructuredContent), "api-git-url") + if call.args["path"] == "submodule-directory" { + assert.JSONEq(t, `[{"name":"lib","path":"lib","type":"file","sha":"sha","submodule_git_url":"https://github.com/owner/lib.git"}]`, mustMarshalJSON(t, result.StructuredContent)) + } + if call.args["path"] == "directory" && call.args["fields"] != nil && len(call.args["fields"].([]any)) == 3 { + assert.JSONEq(t, `[{"size":0,"html_url":"https://github.com/owner/repo/blob/main/file"}]`, mustMarshalJSON(t, result.StructuredContent)) + } + case call.name == "create_branch" || call.name == "push_files": + assert.JSONEq(t, `{"ref":"refs/heads/main","object":{"type":"commit","sha":"base"}}`, mustMarshalJSON(t, result.StructuredContent)) + case call.name == "get_tag" && call.args["tag"] == "lightweight": + assert.JSONEq(t, `{"ref":"refs/heads/main","object":{"type":"commit","sha":"base"}}`, mustMarshalJSON(t, result.StructuredContent)) + case call.name == "get_tag": + assert.JSONEq(t, `{"tag":"annotated","sha":"tag-sha","message":"Tag message","tagger":{"date":"2026-01-02T03:04:05Z"}}`, mustMarshalJSON(t, result.StructuredContent)) + case call.name == "delete_file": + assert.JSONEq(t, `{"commit":{"sha":"new","message":"change","html_url":"https://github.com/owner/repo/commit/new","author":{"date":"2026-01-02T03:04:05Z"},"tree_sha":"tree","parents":["base"]},"content":null}`, mustMarshalJSON(t, result.StructuredContent)) + case call.name == "get_latest_release" || call.name == "get_release_by_tag": + assert.NotContains(t, mustMarshalJSON(t, result.StructuredContent), "\u200b") + assert.JSONEq(t, `{"id":7,"tag_name":"v1","name":"Release","body":"Notes","html_url":"https://github.com/owner/repo/releases/tag/v1","prerelease":false,"draft":false,"author":{"login":"octo"}}`, mustMarshalJSON(t, result.StructuredContent)) + case call.name == "star_repository" || call.name == "unstar_repository" || (call.name == "fork_repository" && call.args["organization"] == "pending"): + assert.JSONEq(t, mustMarshalJSON(t, RepositoryMessageOutput{Message: getTextResult(t, legacy).Text}), mustMarshalJSON(t, result.StructuredContent)) + default: + } + for _, tool := range tools { + assert.Equal(t, inputContracts[tool.Tool.Name], mustMarshalJSON(t, tool.Tool.InputSchema), "registration/calls must not mutate tool contracts") + } + }) + } + for _, call := range []struct { + name string + args map[string]any + }{ + {"create_repository", map[string]any{"name": ""}}, + {"get_tag", coord(map[string]any{"tag": ""})}, + {"get_tag", coord(map[string]any{"tag": "forbidden"})}, + {"get_file_contents", coord(map[string]any{"path": "forbidden", "sha": strings.Repeat("a", 40)})}, + {"get_file_blame", coord(map[string]any{"path": "../file"})}, + {"push_files", coord(map[string]any{"branch": "main", "message": "change", "files": "invalid"})}, + {"delete_file", coord(map[string]any{"branch": "main", "path": "../file", "message": "delete"})}, + } { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: call.name, Arguments: call.args}) + require.NoError(t, err) + require.True(t, result.IsError, result) + assert.Nil(t, result.StructuredContent, "errors never return success-shaped output") + assert.NotContains(t, result.Meta, "ifc", "errors never receive success IFC labels") + } + }) + } +} + +func hasOnlyTextContent(content []mcp.Content) bool { + for _, item := range content { + if _, ok := item.(*mcp.TextContent); !ok { + return false + } + } + return true +} + +func assertLegacyRepositoryText(t *testing.T, name string, args map[string]any, result *mcp.CallToolResult) { + t.Helper() + switch name { + case "create_branch", "push_files": + ref, _, _ := repositoryOutputFixtures() + assert.Equal(t, mustMarshalJSON(t, ref), getTextResult(t, result).Text) + case "get_tag": + ref, commit, _ := repositoryOutputFixtures() + if args["tag"] == "lightweight" { + assert.Equal(t, mustMarshalJSON(t, ref), getTextResult(t, result).Text) + return + } + tag := &github.Tag{ + Tag: new("annotated"), Message: new("Tag message"), SHA: new("tag-sha"), + Tagger: &github.CommitAuthor{Date: commit.Author.Date}, URL: new("api-tag-url"), + } + assert.Equal(t, mustMarshalJSON(t, tag), getTextResult(t, result).Text) + case "delete_file": + _, commit, _ := repositoryOutputFixtures() + assert.Equal(t, mustMarshalJSON(t, map[string]any{"commit": commit, "content": nil}), getTextResult(t, result).Text) + case "get_latest_release", "get_release_by_tag": + _, _, release := repositoryOutputFixtures() + sanitizeReleaseNameAndBody(release) + assert.Equal(t, mustMarshalJSON(t, release), getTextResult(t, result).Text) + case "list_repository_collaborators": + if args["repo"] != "empty" { + assert.Equal(t, `{"firstPage":0,"items":[{"login":"octo","id":1,"role_name":"write"}],"lastPage":0,"nextPage":3,"prevPage":0}`, getTextResult(t, result).Text) + } + case "get_file_contents": + fields, ok := args["fields"].([]any) + if args["path"] == "directory" && ok && len(fields) == 3 { + assert.Equal(t, `[{"html_url":"https://github.com/owner/repo/blob/main/file","size":0,"url":"api-content-url"}]`, getTextResult(t, result).Text) + } + } +} + +func TestTypedRepositoryDeletionOutput(t *testing.T) { + deps := typedRepositoryDeps(t) + sealer, err := requeststate.NewRandom() + require.NoError(t, err) + deps.StateSealer = sealer + deps.Client = mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, &github.Repository{ID: new(int64(1))}), + DeleteReposByOwnerByRepo: mockResponse(t, http.StatusNoContent, nil), + })) + tool := DeleteRepository(translations.NullTranslationHelper) + server := mcp.NewServer(&mcp.Implementation{Name: "repository-deletion-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + tool.RegisterFunc(server, deps) + serverTransport, clientTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = serverSession.Close() }) + client := mcp.NewClient(&mcp.Implementation{Name: "deletion-client", Version: "v1"}, &mcp.ClientOptions{ + ElicitationHandler: func(_ context.Context, _ *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + return &mcp.ElicitResult{Action: "accept", Content: map[string]any{deleteRepositoryConfirmationField: "owner/repo"}}, nil + }, + }) + session, err := client.Connect(context.Background(), commentVisibilityProtocolTransport{clientTransport, inventory.ProtocolVersionMultiRoundTrip}, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = session.Close() }) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + require.NoError(t, toolsnaps.Test("delete_repository", *list.Tools[0])) + request := createMCPRequest(map[string]any{"owner": "owner", "repo": "repo"}) + pending, err := tool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.Len(t, pending.InputRequests, 1) + assert.Nil(t, pending.StructuredContent, "confirmation is not a completed deletion") + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "delete_repository", Arguments: map[string]any{"owner": "owner", "repo": "repo"}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Equal(t, `{"message":"Repository owner/repo was deleted."}`, getTextResult(t, result).Text) + assert.JSONEq(t, `{"message":"Repository owner/repo was deleted."}`, mustMarshalJSON(t, result.StructuredContent)) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, list.Tools[0].OutputSchema)), &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + var output any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, resolved.Validate(output)) +} + +func TestRepositoryOutputUnions(t *testing.T) { + for _, test := range []struct { + name string + schema *jsonschema.Schema + valid []string + invalid []string + }{ + { + "tag", repositoryTagOutputSchema(), + []string{`null`, `{"ref":"refs/tags/v1","object":{"type":"commit","sha":"abc"}}`, `{"tag":"v1","sha":"tag-sha","tagger":{"date":"2026-01-02T00:00:00Z"}}`}, + []string{`{}`, `{"ref":"refs/tags/v1"}`, `{"ref":"refs/tags/v1","object":{"type":"commit","sha":"abc"},"tag":"v1"}`, `{"message":1}`}, + }, + { + "fork", forkRepositoryOutputSchema(), + []string{`null`, `{"id":"9","url":"fork-url"}`, `{"message":"Fork is in progress"}`}, + []string{`{}`, `{"id":"9"}`, `{"id":"9","url":"fork-url","message":"pending"}`}, + }, + { + "contents", repositoryContentsOutputSchema(), + []string{`null`, `[]`, `[{"name":"file"}]`, `{"content":[]}`, `{"content":[{"type":"text","text":"status"}]}`, + `{"content":[{"type":"resource","resource":{"uri":"repo://file","mimeType":"text/plain"}}]}`, + `{"content":[{"type":"resource","resource":{"uri":"repo://file","blob":"AAEC"}}]}`, + `{"content":[{"type":"resource_link","uri":"repo://file","name":"file","size":1048576}]}`}, + []string{`{}`, `{"content":[{"type":"text"}]}`, `{"content":[{"type":"resource","resource":{"uri":"repo://file","text":"x","blob":"AA=="}}]}`, + `{"content":[{"type":"opaque","data":{}}]}`}, + }, + } { + t.Run(test.name, func(t *testing.T) { + resolved, err := test.schema.Resolve(nil) + require.NoError(t, err) + for _, text := range test.valid { + var output any + require.NoError(t, json.Unmarshal([]byte(text), &output)) + require.NoError(t, resolved.Validate(output), text) + } + for _, text := range test.invalid { + var output any + require.NoError(t, json.Unmarshal([]byte(text), &output)) + require.Error(t, resolved.Validate(output), text) + } + }) + } +} + +func TestRepositoryPaginationCompatibility(t *testing.T) { + for _, raw := range []string{`{}`, `{"page":0,"perPage":0}`, `{"page":"0","perPage":"0"}`} { + normalized, err := normalizeRepositoryArguments(json.RawMessage(raw)) + require.NoError(t, err) + var input ListReleasesInput + require.NoError(t, json.Unmarshal(normalized, &input)) + assert.Equal(t, PaginationParams{Page: 1, PerPage: 30}, repositoryPagination(input.Page, input.PerPage)) + } + fields, err := normalizeRepositoryFieldsArguments(json.RawMessage(`{"page":"2.0","perPage":"7","fields":null}`)) + require.NoError(t, err) + normalized, err := normalizeRepositoryArguments(fields) + require.NoError(t, err) + assert.JSONEq(t, `{"page":2,"perPage":7}`, string(normalized)) + for _, raw := range []string{`{"page":"1.5"}`, `{"perPage":"NaN"}`} { + _, err := normalizeRepositoryArguments(json.RawMessage(raw)) + require.Error(t, err) + } + for _, raw := range []string{`{"start_line":"0"}`, `{"end_line":0}`, `{"perPage":"0"}`} { + normalized, err := normalizeRepositoryBlameArguments(json.RawMessage(raw)) + require.NoError(t, err) + var input GetFileBlameInput + require.NoError(t, json.Unmarshal(normalized, &input)) + assert.True(t, input.StartLine != nil || input.EndLine != nil || input.PerPage != nil, "blame preserves explicit zero for validation") + } +} From b628491a0bff30b53861bb346bd088fb7c2f37c8 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 7 Oct 2026 00:13:13 +0200 Subject: [PATCH 52/61] feat(issues): add protocol-gated typed consolidated issue tools (#3393) * feat(repos): complete typed repository tool outputs Migrate the remaining 17 repository tools to concrete inputs and outputs. Preserve existing content, mutations, scopes, filtering, and protocol gating; add wire and schema conformance coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore(repos): exclude unrelated snapshot formatting drift Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(repos): compact typed repository outputs Replace raw REST output schemas with compact repository DTOs, retain one canonical snapshot per tool, and align modern JSON text with structured output while pinning legacy content. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(issues): add typed consolidated issue tool contracts Preserve raw helper APIs and exact legacy text while exposing concrete output unions for modern protocols. Retain explicit awaiting statuses and real null responses without exposing SDK-generated error fallbacks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * refactor(issues): minimize consolidated typed outputs Project compact method-discriminated DTOs from API responses while preserving the separate legacy text formatter and explicit app-awaiting status. Cache strict schemas and keep only canonical snapshots. Strengthen null-member presence, schema size, and lockdown projection regressions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(issues): keep union property schemas portable Preserve forbidden variant properties as annotated schema objects rather than boolean schemas emitted by jsonschema-go. Cover Inspector's input paths and IFC labels on every issue-read method across protocols. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(issues): cover strict sub-issue output variants Reject unknown methods, states, field types, opaque values and API URL properties while covering nullable and empty method data. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(issues): pin compact comment user redaction Pin unchanged legacy profile/avatar fields separately from compact modern user projections and assert identical lockdown filtering and sanitization across protocols. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(issues): restore main-identical advertised input schema Remove added custom-field union constraints and sentinel enums from the advertised issue-write input. Preserve runtime exactly-one validation and private strict-schema tests. All three scoped input schemas compare exactly equal to main. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(issues): restore protocol output parity Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] --- docs/error-handling.md | 23 + pkg/github/__toolsnaps__/issue_read.snap | 1088 +++++++---------- pkg/github/__toolsnaps__/issue_write.snap | 81 +- .../__toolsnaps__/list_issues_output.snap | 9 - .../__toolsnaps__/search_issues_output.snap | 7 - .../search_pull_requests_output.snap | 7 - pkg/github/__toolsnaps__/sub_issue_write.snap | 485 +++----- pkg/github/consolidated_issue_types.go | 620 ++++++++++ pkg/github/issue_tool_types.go | 73 -- pkg/github/issues.go | 582 ++++----- ...yped_consolidated_issue_enrichment_test.go | 100 ++ .../typed_consolidated_issue_outputs_test.go | 683 +++++++++++ pkg/github/typed_issue_outputs_test.go | 15 +- pkg/github/typed_search_outputs_test.go | 2 +- pkg/inventory/typed_output_test.go | 86 ++ 15 files changed, 2490 insertions(+), 1371 deletions(-) create mode 100644 pkg/github/consolidated_issue_types.go create mode 100644 pkg/github/typed_consolidated_issue_enrichment_test.go create mode 100644 pkg/github/typed_consolidated_issue_outputs_test.go diff --git a/docs/error-handling.md b/docs/error-handling.md index 9bb27e0fa4..7f62443483 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -57,6 +57,29 @@ This function: return ghErrors.NewGitHubGraphQLErrorResponse(ctx, message, err), nil ``` +### Typed Tool Results + +Tools registered with concrete `NewTool[In, Out]` types expose output schemas and +`structuredContent` only when the negotiated protocol is `2026-07-28` or newer. +Older or unknown protocols retain the original text responses without these +typed-output additions. Ordinary API and validation errors never expose the +SDK-generated zero-value structured output. + +Handler-provided structured statuses are distinct from generated error output. +For example, `issue_write` can return `IsError: true` with +`status: "awaiting_user_submission"` while an MCP App form is pending. Modern +clients retain that explicit status and its reason; legacy clients retain the +stop message and error flag without structured content. This status does not +mean the issue was created or updated. The output schema includes a strict +status variant alongside the mutation's `id`/`url` variant. Multi-round-trip +`InputRequests` continue to pass through unchanged. + +The consolidated issue tools use method-discriminated object outputs for modern +successes. Issue and comment data are projected from the API responses into +compact DTOs, excluding API, node, and hypermedia URLs while retaining human +links. Legacy text is marshalled separately and keeps its original format and +fields. + ### Context Management The error handling system uses context to store errors for later inspection: diff --git a/pkg/github/__toolsnaps__/issue_read.snap b/pkg/github/__toolsnaps__/issue_read.snap index 25ed46a9b7..4a4356bfe5 100644 --- a/pkg/github/__toolsnaps__/issue_read.snap +++ b/pkg/github/__toolsnaps__/issue_read.snap @@ -52,8 +52,294 @@ }, "name": "issue_read", "outputSchema": { + "additionalProperties": false, "oneOf": [ { + "properties": { + "comments": { + "not": { + "description": "Any value." + } + }, + "labels": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "get" + ] + }, + "parent": { + "not": { + "description": "Any value." + } + }, + "sub_issues": { + "not": { + "description": "Any value." + } + }, + "totalCount": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "issue" + ] + }, + { + "properties": { + "issue": { + "not": { + "description": "Any value." + } + }, + "labels": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "get_comments" + ] + }, + "parent": { + "not": { + "description": "Any value." + } + }, + "sub_issues": { + "not": { + "description": "Any value." + } + }, + "totalCount": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "comments" + ] + }, + { + "properties": { + "comments": { + "not": { + "description": "Any value." + } + }, + "issue": { + "not": { + "description": "Any value." + } + }, + "labels": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "get_sub_issues" + ] + }, + "parent": { + "not": { + "description": "Any value." + } + }, + "totalCount": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "sub_issues" + ] + }, + { + "properties": { + "comments": { + "not": { + "description": "Any value." + } + }, + "issue": { + "not": { + "description": "Any value." + } + }, + "labels": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "get_parent" + ] + }, + "sub_issues": { + "not": { + "description": "Any value." + } + }, + "totalCount": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "parent" + ] + }, + { + "properties": { + "comments": { + "not": { + "description": "Any value." + } + }, + "issue": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "get_labels" + ] + }, + "parent": { + "not": { + "description": "Any value." + } + }, + "sub_issues": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "labels", + "totalCount" + ] + } + ], + "properties": { + "comments": { + "items": { + "additionalProperties": false, + "properties": { + "author_association": { + "type": "string" + }, + "body": { + "type": "string" + }, + "created_at": { + "description": "Creation time (RFC 3339).", + "type": "string" + }, + "html_url": { + "description": "Human-readable comment link.", + "type": "string" + }, + "id": { + "type": "integer" + }, + "reactions": { + "additionalProperties": false, + "properties": { + "+1": { + "type": "integer" + }, + "-1": { + "type": "integer" + }, + "confused": { + "type": "integer" + }, + "eyes": { + "type": "integer" + }, + "heart": { + "type": "integer" + }, + "hooray": { + "type": "integer" + }, + "laugh": { + "type": "integer" + }, + "rocket": { + "type": "integer" + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "total_count", + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "rocket", + "eyes" + ], + "type": [ + "null", + "object" + ] + }, + "updated_at": { + "description": "Last update time (RFC 3339).", + "type": "string" + }, + "user": { + "additionalProperties": false, + "properties": { + "id": { + "type": "integer" + }, + "login": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "id", + "html_url" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "issue": { "additionalProperties": false, "properties": { "assignees": { @@ -72,6 +358,7 @@ "type": "string" }, "closed_at": { + "description": "Closure time (RFC 3339), when closed.", "type": "string" }, "closed_by": { @@ -127,9 +414,11 @@ ] }, "comments": { + "description": "Number of comments.", "type": "integer" }, "created_at": { + "description": "Creation time (RFC 3339).", "type": "string" }, "draft": { @@ -178,53 +467,9 @@ ] }, "html_url": { + "description": "Human-readable issue link.", "type": "string" }, - "issue_field_values": { - "items": { - "additionalProperties": false, - "properties": { - "data_type": { - "type": "string" - }, - "issue_field_id": { - "type": "integer" - }, - "node_id": { - "type": "string" - }, - "single_select_option": { - "additionalProperties": false, - "properties": { - "color": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "color" - ], - "type": [ - "null", - "object" - ] - }, - "value": true - }, - "type": "object" - }, - "type": [ - "null", - "array" - ] - }, "issue_type": { "type": "string" }, @@ -324,21 +569,36 @@ ] }, "state": { + "description": "Issue state (lowercase REST value).", + "enum": [ + "", + "open", + "closed" + ], "type": "string" }, "state_reason": { + "enum": [ + "completed", + "not_planned", + "reopened", + "duplicate" + ], "type": "string" }, "sub_issues_summary": { "additionalProperties": false, "properties": { "completed": { + "description": "Number of completed sub-issues.", "type": "integer" }, "percent_completed": { + "description": "Completed sub-issues as a percentage (0-100).", "type": "integer" }, "total": { + "description": "Total number of sub-issues.", "type": "integer" } }, @@ -356,92 +616,17 @@ "type": "string" }, "updated_at": { + "description": "Last update time (RFC 3339).", "type": "string" }, "user": { "additionalProperties": false, "properties": { - "avatar_url": { - "type": "string" - }, - "details": { - "additionalProperties": false, - "properties": { - "bio": { - "type": "string" - }, - "blog": { - "type": "string" - }, - "company": { - "type": "string" - }, - "created_at": { - "description": "Account creation time in RFC3339 format.", - "type": "string" - }, - "email": { - "type": "string" - }, - "followers": { - "type": "integer" - }, - "following": { - "type": "integer" - }, - "hireable": { - "type": "boolean" - }, - "location": { - "type": "string" - }, - "name": { - "type": "string" - }, - "owned_private_repos": { - "type": "integer" - }, - "private_gists": { - "type": "integer" - }, - "public_gists": { - "type": "integer" - }, - "public_repos": { - "type": "integer" - }, - "total_private_repos": { - "type": "integer" - }, - "twitter_username": { - "type": "string" - }, - "updated_at": { - "description": "Last profile update time in RFC3339 format.", - "type": "string" - } - }, - "required": [ - "public_repos", - "public_gists", - "followers", - "following", - "created_at", - "updated_at" - ], - "type": [ - "null", - "object" - ] - }, - "id": { - "type": "integer" + "id": { + "type": "integer" }, "login": { "type": "string" - }, - "profile_url": { - "type": "string" } }, "required": [ @@ -464,173 +649,28 @@ "object" ] }, - { + "labels": { "items": { "additionalProperties": false, "properties": { - "author_association": { + "color": { "type": "string" }, - "body": { - "type": "string" - }, - "created_at": { - "type": "string" - }, - "html_url": { + "description": { "type": "string" }, "id": { - "type": "integer" - }, - "reactions": { - "additionalProperties": false, - "properties": { - "+1": { - "type": "integer" - }, - "-1": { - "type": "integer" - }, - "confused": { - "type": "integer" - }, - "eyes": { - "type": "integer" - }, - "heart": { - "type": "integer" - }, - "hooray": { - "type": "integer" - }, - "laugh": { - "type": "integer" - }, - "rocket": { - "type": "integer" - }, - "total_count": { - "type": "integer" - } - }, - "required": [ - "total_count", - "+1", - "-1", - "laugh", - "confused", - "heart", - "hooray", - "rocket", - "eyes" - ], - "type": [ - "null", - "object" - ] - }, - "updated_at": { "type": "string" }, - "user": { - "additionalProperties": false, - "properties": { - "avatar_url": { - "type": "string" - }, - "details": { - "additionalProperties": false, - "properties": { - "bio": { - "type": "string" - }, - "blog": { - "type": "string" - }, - "company": { - "type": "string" - }, - "created_at": { - "description": "Account creation time in RFC3339 format.", - "type": "string" - }, - "email": { - "type": "string" - }, - "followers": { - "type": "integer" - }, - "following": { - "type": "integer" - }, - "hireable": { - "type": "boolean" - }, - "location": { - "type": "string" - }, - "name": { - "type": "string" - }, - "owned_private_repos": { - "type": "integer" - }, - "private_gists": { - "type": "integer" - }, - "public_gists": { - "type": "integer" - }, - "public_repos": { - "type": "integer" - }, - "total_private_repos": { - "type": "integer" - }, - "twitter_username": { - "type": "string" - }, - "updated_at": { - "description": "Last profile update time in RFC3339 format.", - "type": "string" - } - }, - "required": [ - "public_repos", - "public_gists", - "followers", - "following", - "created_at", - "updated_at" - ], - "type": [ - "null", - "object" - ] - }, - "id": { - "type": "integer" - }, - "login": { - "type": "string" - }, - "profile_url": { - "type": "string" - } - }, - "required": [ - "login" - ], - "type": [ - "null", - "object" - ] + "name": { + "type": "string" } }, "required": [ + "color", + "description", "id", - "html_url" + "name" ], "type": "object" }, @@ -639,7 +679,55 @@ "array" ] }, - { + "method": { + "enum": [ + "get", + "get_comments", + "get_sub_issues", + "get_parent", + "get_labels" + ], + "type": "string" + }, + "parent": { + "additionalProperties": false, + "properties": { + "number": { + "type": "integer" + }, + "repository": { + "type": "string" + }, + "state": { + "description": "Parent issue state (uppercase GraphQL value).", + "enum": [ + "", + "OPEN", + "CLOSED" + ], + "type": "string" + }, + "title": { + "type": "string" + }, + "url": { + "description": "Human-readable parent issue link.", + "type": "string" + } + }, + "required": [ + "number", + "repository", + "state", + "title", + "url" + ], + "type": [ + "null", + "object" + ] + }, + "sub_issues": { "items": { "additionalProperties": false, "properties": { @@ -652,164 +740,79 @@ "array" ] }, - "author_association": { - "type": "string" - }, "body": { - "type": "string" + "type": [ + "null", + "string" + ] }, "closed_at": { + "description": "Closure time (RFC 3339), when closed.", "type": "string" }, - "closed_by": { - "type": "string" - }, - "closed_by_pull_requests": { - "additionalProperties": false, - "properties": { - "references": { - "items": { - "additionalProperties": false, - "properties": { - "number": { - "type": "integer" - }, - "repository": { - "type": "string" - }, - "state": { - "type": "string" - }, - "title": { - "type": "string" - }, - "url": { - "type": "string" - } - }, - "required": [ - "number", - "title", - "state", - "url" - ], - "type": "object" - }, - "type": [ - "null", - "array" - ] - }, - "total_count": { - "type": "integer" - } - }, - "required": [ - "total_count", - "references" - ], + "comments": { + "description": "Number of comments.", "type": [ "null", - "object" + "integer" ] }, - "comments": { - "type": "integer" - }, "created_at": { + "description": "Creation time (RFC 3339).", "type": "string" }, - "draft": { - "type": "boolean" - }, "field_values": { "items": { "additionalProperties": false, "properties": { - "field": { + "data_type": { + "description": "REST issue field type.", + "enum": [ + "text", + "number", + "date", + "single_select" + ], "type": "string" }, - "value": { - "type": "string" + "issue_field_id": { + "type": "integer" }, - "values": { - "items": { - "type": "string" - }, + "value": { "type": [ - "null", - "array" + "string", + "number", + "boolean", + "null" ] } }, "required": [ - "field" + "issue_field_id", + "data_type", + "value" ], - "type": "object" + "type": [ + "null", + "object" + ] }, "type": [ "null", "array" ] }, - "has_children": { - "type": [ - "null", - "boolean" - ] - }, - "has_parent": { + "html_url": { + "description": "Human-readable issue link.", "type": [ "null", - "boolean" + "string" ] }, - "html_url": { - "type": "string" - }, - "issue_field_values": { - "items": { - "additionalProperties": false, - "properties": { - "data_type": { - "type": "string" - }, - "issue_field_id": { - "type": "integer" - }, - "node_id": { - "type": "string" - }, - "single_select_option": { - "additionalProperties": false, - "properties": { - "color": { - "type": "string" - }, - "id": { - "type": "integer" - }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "color" - ], - "type": [ - "null", - "object" - ] - }, - "value": true - }, - "type": "object" - }, + "id": { "type": [ "null", - "array" + "integer" ] }, "issue_type": { @@ -824,108 +827,51 @@ "array" ] }, - "locked": { - "type": "boolean" - }, "milestone": { "type": "string" }, "number": { - "type": "integer" - }, - "parent": { - "additionalProperties": false, - "properties": { - "number": { - "type": "integer" - }, - "repository": { - "type": "string" - }, - "state": { - "type": "string" - }, - "title": { - "type": "string" - }, - "url": { - "type": "string" - } - }, - "required": [ - "number", - "title", - "state", - "url" - ], "type": [ "null", - "object" + "integer" ] }, - "reactions": { - "additionalProperties": false, - "properties": { - "+1": { - "type": "integer" - }, - "-1": { - "type": "integer" - }, - "confused": { - "type": "integer" - }, - "eyes": { - "type": "integer" - }, - "heart": { - "type": "integer" - }, - "hooray": { - "type": "integer" - }, - "laugh": { - "type": "integer" - }, - "rocket": { - "type": "integer" - }, - "total_count": { - "type": "integer" - } - }, - "required": [ - "total_count", - "+1", - "-1", - "laugh", - "confused", - "heart", - "hooray", - "rocket", - "eyes" + "state": { + "description": "Issue state (lowercase REST value).", + "enum": [ + "open", + "closed" ], "type": [ "null", - "object" + "string" ] }, - "state": { - "type": "string" - }, "state_reason": { - "type": "string" + "enum": [ + "completed", + "not_planned", + "reopened", + "duplicate" + ], + "type": [ + "null", + "string" + ] }, "sub_issues_summary": { "additionalProperties": false, "properties": { "completed": { + "description": "Number of completed sub-issues.", "type": "integer" }, "percent_completed": { + "description": "Completed sub-issues as a percentage (0-100).", "type": "integer" }, "total": { + "description": "Total number of sub-issues.", "type": "integer" } }, @@ -940,95 +886,23 @@ ] }, "title": { - "type": "string" + "type": [ + "null", + "string" + ] }, "updated_at": { + "description": "Last update time (RFC 3339).", "type": "string" }, "user": { "additionalProperties": false, "properties": { - "avatar_url": { - "type": "string" - }, - "details": { - "additionalProperties": false, - "properties": { - "bio": { - "type": "string" - }, - "blog": { - "type": "string" - }, - "company": { - "type": "string" - }, - "created_at": { - "description": "Account creation time in RFC3339 format.", - "type": "string" - }, - "email": { - "type": "string" - }, - "followers": { - "type": "integer" - }, - "following": { - "type": "integer" - }, - "hireable": { - "type": "boolean" - }, - "location": { - "type": "string" - }, - "name": { - "type": "string" - }, - "owned_private_repos": { - "type": "integer" - }, - "private_gists": { - "type": "integer" - }, - "public_gists": { - "type": "integer" - }, - "public_repos": { - "type": "integer" - }, - "total_private_repos": { - "type": "integer" - }, - "twitter_username": { - "type": "string" - }, - "updated_at": { - "description": "Last profile update time in RFC3339 format.", - "type": "string" - } - }, - "required": [ - "public_repos", - "public_gists", - "followers", - "following", - "created_at", - "updated_at" - ], - "type": [ - "null", - "object" - ] - }, "id": { "type": "integer" }, "login": { "type": "string" - }, - "profile_url": { - "type": "string" } }, "required": [ @@ -1041,114 +915,28 @@ } }, "required": [ - "number", - "title", - "state", "assignees" ], - "type": "object" + "type": [ + "object", + "null" + ] }, "type": [ - "null", - "array" + "array", + "null" ] }, - { - "additionalProperties": false, - "properties": { - "parent": { - "additionalProperties": false, - "properties": { - "html_url": { - "type": "string" - }, - "number": { - "type": "integer" - }, - "repository": { - "type": "string" - }, - "state": { - "type": "string" - }, - "title": { - "type": "string" - } - }, - "required": [ - "number", - "title", - "state", - "html_url" - ], - "type": [ - "null", - "object" - ] - } - }, - "required": [ - "parent" - ], + "totalCount": { "type": [ "null", - "object" + "integer" ] - }, - { - "additionalProperties": false, - "properties": { - "labels": { - "items": { - "additionalProperties": false, - "properties": { - "color": { - "type": "string" - }, - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - }, - "required": [ - "id", - "name", - "color", - "description" - ], - "type": "object" - }, - "type": [ - "null", - "array" - ] - }, - "totalCount": { - "type": "integer" - } - }, - "required": [ - "labels", - "totalCount" - ], - "type": [ - "null", - "object" - ] - }, - { - "type": "null" } + }, + "required": [ + "method" ], - "type": [ - "array", - "object", - "null" - ] + "type": "object" } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/issue_write.snap b/pkg/github/__toolsnaps__/issue_write.snap index 20cc730f51..dde73657f6 100644 --- a/pkg/github/__toolsnaps__/issue_write.snap +++ b/pkg/github/__toolsnaps__/issue_write.snap @@ -149,5 +149,84 @@ ], "type": "object" }, - "name": "issue_write" + "name": "issue_write", + "outputSchema": { + "additionalProperties": false, + "oneOf": [ + { + "properties": { + "reason": { + "not": { + "description": "Any value." + } + }, + "status": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "method", + "issue" + ] + }, + { + "properties": { + "issue": { + "not": { + "description": "Any value." + } + }, + "method": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "status", + "reason" + ] + } + ], + "properties": { + "issue": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + }, + "method": { + "enum": [ + "create", + "update" + ], + "type": "string" + }, + "reason": { + "type": "string" + }, + "status": { + "enum": [ + "awaiting_user_submission" + ], + "type": "string" + } + }, + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_issues_output.snap b/pkg/github/__toolsnaps__/list_issues_output.snap index 9c71265fea..2e5c6ac545 100644 --- a/pkg/github/__toolsnaps__/list_issues_output.snap +++ b/pkg/github/__toolsnaps__/list_issues_output.snap @@ -24,14 +24,12 @@ ] }, "comments": { - "description": "Number of comments on the issue.", "type": [ "null", "integer" ] }, "created_at": { - "description": "Creation time in RFC 3339 format.", "type": [ "null", "string" @@ -83,7 +81,6 @@ ] }, "state": { - "description": "Issue state: OPEN or CLOSED.", "type": [ "null", "string" @@ -96,7 +93,6 @@ ] }, "updated_at": { - "description": "Last update time in RFC 3339 format.", "type": [ "null", "string" @@ -208,19 +204,15 @@ "additionalProperties": false, "properties": { "endCursor": { - "description": "Cursor for the last issue in this page.", "type": "string" }, "hasNextPage": { - "description": "Whether another page of results is available.", "type": "boolean" }, "hasPreviousPage": { - "description": "Whether a previous page of results is available.", "type": "boolean" }, "startCursor": { - "description": "Cursor for the first issue in this page.", "type": "string" } }, @@ -231,7 +223,6 @@ "type": "object" }, "totalCount": { - "description": "Total number of issues matching the filters.", "type": "integer" } }, diff --git a/pkg/github/__toolsnaps__/search_issues_output.snap b/pkg/github/__toolsnaps__/search_issues_output.snap index 82b00688f6..2f493407e7 100644 --- a/pkg/github/__toolsnaps__/search_issues_output.snap +++ b/pkg/github/__toolsnaps__/search_issues_output.snap @@ -5,7 +5,6 @@ "additionalProperties": false, "properties": { "incomplete_results": { - "description": "Whether GitHub returned an incomplete result set.", "type": [ "null", "boolean" @@ -43,7 +42,6 @@ ] }, "closed_at": { - "description": "Closing time in RFC 3339 format.", "type": [ "null", "string" @@ -56,14 +54,12 @@ ] }, "comments": { - "description": "Number of comments on the issue.", "type": [ "null", "integer" ] }, "created_at": { - "description": "Creation time in RFC 3339 format.", "type": [ "null", "string" @@ -225,7 +221,6 @@ ] }, "state": { - "description": "Issue state, typically open or closed.", "type": [ "null", "string" @@ -250,7 +245,6 @@ ] }, "updated_at": { - "description": "Last update time in RFC 3339 format.", "type": [ "null", "string" @@ -359,7 +353,6 @@ ] }, "total_count": { - "description": "Total number of matching issues or pull requests.", "type": [ "null", "integer" diff --git a/pkg/github/__toolsnaps__/search_pull_requests_output.snap b/pkg/github/__toolsnaps__/search_pull_requests_output.snap index 45e170ea42..0a9eaac648 100644 --- a/pkg/github/__toolsnaps__/search_pull_requests_output.snap +++ b/pkg/github/__toolsnaps__/search_pull_requests_output.snap @@ -5,7 +5,6 @@ "additionalProperties": false, "properties": { "incomplete_results": { - "description": "Whether GitHub returned an incomplete result set.", "type": [ "null", "boolean" @@ -43,7 +42,6 @@ ] }, "closed_at": { - "description": "Closing time in RFC 3339 format.", "type": [ "null", "string" @@ -56,14 +54,12 @@ ] }, "comments": { - "description": "Number of comments on the issue.", "type": [ "null", "integer" ] }, "created_at": { - "description": "Creation time in RFC 3339 format.", "type": [ "null", "string" @@ -225,7 +221,6 @@ ] }, "state": { - "description": "Issue state, typically open or closed.", "type": [ "null", "string" @@ -250,7 +245,6 @@ ] }, "updated_at": { - "description": "Last update time in RFC 3339 format.", "type": [ "null", "string" @@ -359,7 +353,6 @@ ] }, "total_count": { - "description": "Total number of matching issues or pull requests.", "type": [ "null", "integer" diff --git a/pkg/github/__toolsnaps__/sub_issue_write.snap b/pkg/github/__toolsnaps__/sub_issue_write.snap index c98d4c8a18..c3556ad81a 100644 --- a/pkg/github/__toolsnaps__/sub_issue_write.snap +++ b/pkg/github/__toolsnaps__/sub_issue_write.snap @@ -53,412 +53,213 @@ "outputSchema": { "additionalProperties": false, "properties": { - "assignees": { - "items": { - "type": "string" - }, - "type": [ - "null", - "array" - ] - }, - "author_association": { - "type": "string" - }, - "body": { - "type": "string" - }, - "closed_at": { - "type": "string" - }, - "closed_by": { - "type": "string" - }, - "closed_by_pull_requests": { + "issue": { "additionalProperties": false, "properties": { - "references": { + "assignees": { "items": { - "additionalProperties": false, - "properties": { - "number": { - "type": "integer" - }, - "repository": { - "type": "string" - }, - "state": { - "type": "string" - }, - "title": { - "type": "string" - }, - "url": { - "type": "string" - } - }, - "required": [ - "number", - "title", - "state", - "url" - ], - "type": "object" + "type": "string" }, "type": [ "null", "array" ] }, - "total_count": { - "type": "integer" - } - }, - "required": [ - "total_count", - "references" - ], - "type": [ - "null", - "object" - ] - }, - "comments": { - "type": "integer" - }, - "created_at": { - "type": "string" - }, - "draft": { - "type": "boolean" - }, - "field_values": { - "items": { - "additionalProperties": false, - "properties": { - "field": { - "type": "string" - }, - "value": { - "type": "string" - }, - "values": { - "items": { - "type": "string" - }, - "type": [ - "null", - "array" - ] - } + "body": { + "type": [ + "null", + "string" + ] }, - "required": [ - "field" - ], - "type": "object" - }, - "type": [ - "null", - "array" - ] - }, - "has_children": { - "type": [ - "null", - "boolean" - ] - }, - "has_parent": { - "type": [ - "null", - "boolean" - ] - }, - "html_url": { - "type": "string" - }, - "issue_field_values": { - "items": { - "additionalProperties": false, - "properties": { - "data_type": { - "type": "string" - }, - "issue_field_id": { - "type": "integer" - }, - "node_id": { - "type": "string" - }, - "single_select_option": { + "closed_at": { + "description": "Closure time (RFC 3339), when closed.", + "type": "string" + }, + "comments": { + "description": "Number of comments.", + "type": [ + "null", + "integer" + ] + }, + "created_at": { + "description": "Creation time (RFC 3339).", + "type": "string" + }, + "field_values": { + "items": { "additionalProperties": false, "properties": { - "color": { + "data_type": { + "description": "REST issue field type.", + "enum": [ + "text", + "number", + "date", + "single_select" + ], "type": "string" }, - "id": { + "issue_field_id": { "type": "integer" }, - "name": { - "type": "string" + "value": { + "type": [ + "string", + "number", + "boolean", + "null" + ] } }, "required": [ - "id", - "name", - "color" + "issue_field_id", + "data_type", + "value" ], "type": [ "null", "object" ] }, - "value": true + "type": [ + "null", + "array" + ] }, - "type": "object" - }, - "type": [ - "null", - "array" - ] - }, - "issue_type": { - "type": "string" - }, - "labels": { - "items": { - "type": "string" - }, - "type": [ - "null", - "array" - ] - }, - "locked": { - "type": "boolean" - }, - "milestone": { - "type": "string" - }, - "number": { - "type": "integer" - }, - "parent": { - "additionalProperties": false, - "properties": { - "number": { - "type": "integer" + "html_url": { + "description": "Human-readable issue link.", + "type": [ + "null", + "string" + ] }, - "repository": { - "type": "string" + "id": { + "type": [ + "null", + "integer" + ] }, - "state": { + "issue_type": { "type": "string" }, - "title": { - "type": "string" + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] }, - "url": { + "milestone": { "type": "string" - } - }, - "required": [ - "number", - "title", - "state", - "url" - ], - "type": [ - "null", - "object" - ] - }, - "reactions": { - "additionalProperties": false, - "properties": { - "+1": { - "type": "integer" - }, - "-1": { - "type": "integer" }, - "confused": { - "type": "integer" - }, - "eyes": { - "type": "integer" - }, - "heart": { - "type": "integer" - }, - "hooray": { - "type": "integer" - }, - "laugh": { - "type": "integer" - }, - "rocket": { - "type": "integer" - }, - "total_count": { - "type": "integer" - } - }, - "required": [ - "total_count", - "+1", - "-1", - "laugh", - "confused", - "heart", - "hooray", - "rocket", - "eyes" - ], - "type": [ - "null", - "object" - ] - }, - "state": { - "type": "string" - }, - "state_reason": { - "type": "string" - }, - "sub_issues_summary": { - "additionalProperties": false, - "properties": { - "completed": { - "type": "integer" + "number": { + "type": [ + "null", + "integer" + ] }, - "percent_completed": { - "type": "integer" + "state": { + "description": "Issue state (lowercase REST value).", + "enum": [ + "open", + "closed" + ], + "type": [ + "null", + "string" + ] }, - "total": { - "type": "integer" - } - }, - "required": [ - "total", - "completed", - "percent_completed" - ], - "type": [ - "null", - "object" - ] - }, - "title": { - "type": "string" - }, - "updated_at": { - "type": "string" - }, - "user": { - "additionalProperties": false, - "properties": { - "avatar_url": { - "type": "string" + "state_reason": { + "enum": [ + "completed", + "not_planned", + "reopened", + "duplicate" + ], + "type": [ + "null", + "string" + ] }, - "details": { + "sub_issues_summary": { "additionalProperties": false, "properties": { - "bio": { - "type": "string" - }, - "blog": { - "type": "string" - }, - "company": { - "type": "string" - }, - "created_at": { - "description": "Account creation time in RFC3339 format.", - "type": "string" - }, - "email": { - "type": "string" - }, - "followers": { + "completed": { + "description": "Number of completed sub-issues.", "type": "integer" }, - "following": { + "percent_completed": { + "description": "Completed sub-issues as a percentage (0-100).", "type": "integer" }, - "hireable": { - "type": "boolean" - }, - "location": { - "type": "string" - }, - "name": { - "type": "string" - }, - "owned_private_repos": { - "type": "integer" - }, - "private_gists": { + "total": { + "description": "Total number of sub-issues.", "type": "integer" - }, - "public_gists": { - "type": "integer" - }, - "public_repos": { - "type": "integer" - }, - "total_private_repos": { - "type": "integer" - }, - "twitter_username": { - "type": "string" - }, - "updated_at": { - "description": "Last profile update time in RFC3339 format.", - "type": "string" } }, "required": [ - "public_repos", - "public_gists", - "followers", - "following", - "created_at", - "updated_at" + "total", + "completed", + "percent_completed" ], "type": [ "null", "object" ] }, - "id": { - "type": "integer" + "title": { + "type": [ + "null", + "string" + ] }, - "login": { + "updated_at": { + "description": "Last update time (RFC 3339).", "type": "string" }, - "profile_url": { - "type": "string" + "user": { + "additionalProperties": false, + "properties": { + "id": { + "type": "integer" + }, + "login": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] } }, "required": [ - "login" + "assignees" ], "type": [ - "null", - "object" + "object", + "null" ] + }, + "method": { + "enum": [ + "add", + "remove", + "reprioritize" + ], + "type": "string" } }, "required": [ - "number", - "title", - "state", - "assignees" + "method", + "issue" ], - "type": [ - "null", - "object" - ] + "type": "object" } } \ No newline at end of file diff --git a/pkg/github/consolidated_issue_types.go b/pkg/github/consolidated_issue_types.go new file mode 100644 index 0000000000..ccd84e0173 --- /dev/null +++ b/pkg/github/consolidated_issue_types.go @@ -0,0 +1,620 @@ +package github + +import ( + "encoding/json" + "fmt" + "sync" + + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type IssueWriteInput struct { + Method string `json:"method"` + Owner string `json:"owner"` + Repo string `json:"repo"` + IssueNumber *int `json:"issue_number,omitempty"` + ParentIssueNumber *int `json:"parent_issue_number,omitempty"` + ParentOwner *string `json:"parent_owner,omitempty"` + ParentRepo *string `json:"parent_repo,omitempty"` + Title *string `json:"title,omitempty"` + Body *string `json:"body,omitempty"` + Assignees *[]string `json:"assignees,omitempty"` + Labels *[]string `json:"labels,omitempty"` + Milestone *int `json:"milestone,omitempty"` + Type *string `json:"type,omitempty"` + State *string `json:"state,omitempty"` + StateReason *string `json:"state_reason,omitempty"` + DuplicateOf *int `json:"duplicate_of,omitempty"` + IssueFields []IssueWriteFieldInput `json:"issue_fields,omitempty"` + TypeProvided bool `json:"-"` +} + +func (input *IssueWriteInput) UnmarshalJSON(raw []byte) error { + type plain IssueWriteInput + var decoded plain + if err := json.Unmarshal(raw, &decoded); err != nil { + return err + } + var presence struct { + Type json.RawMessage `json:"type"` + } + if err := json.Unmarshal(raw, &presence); err != nil { + return err + } + *input = IssueWriteInput(decoded) + input.TypeProvided = len(presence.Type) != 0 + return nil +} + +type IssueWriteFieldInput struct { + FieldName string `json:"field_name"` + Value *IssueFieldValue `json:"value,omitempty"` + FieldOptionName *string `json:"field_option_name,omitempty"` + Delete *bool `json:"delete,omitempty"` +} + +// IssueFieldValue represents the REST scalar without losing its JSON type. +// Boolean inputs were historically accepted and forwarded to the API as well. +type IssueFieldValue struct { + String *string + Number *float64 + Bool *bool +} + +func (value IssueFieldValue) MarshalJSON() ([]byte, error) { + switch { + case value.String != nil: + return json.Marshal(value.String) + case value.Number != nil: + return json.Marshal(value.Number) + case value.Bool != nil: + return json.Marshal(value.Bool) + default: + return []byte("null"), nil + } +} + +func (value *IssueFieldValue) UnmarshalJSON(raw []byte) error { + *value = IssueFieldValue{} + switch { + case len(raw) > 0 && raw[0] == '"': + return json.Unmarshal(raw, &value.String) + case string(raw) == "true" || string(raw) == "false": + return json.Unmarshal(raw, &value.Bool) + case string(raw) == "null": + return nil + default: + return json.Unmarshal(raw, &value.Number) + } +} + +func issueFieldValue(value any) (*IssueFieldValue, error) { + switch value := value.(type) { + case nil: + return nil, nil + case string: + return &IssueFieldValue{String: &value}, nil + case float64: + return &IssueFieldValue{Number: &value}, nil + case bool: + return &IssueFieldValue{Bool: &value}, nil + default: + return nil, fmt.Errorf("unsupported issue field value type %T", value) + } +} + +type IssueReadOutput struct { + Method string `json:"method"` + Issue *IssueDetailsOutput `json:"issue,omitempty"` + Comments *[]IssueReadCommentOutput `json:"comments,omitempty"` + SubIssues *[]*SubIssueOutput `json:"sub_issues,omitempty"` + Parent *IssueParentRef `json:"parent,omitempty"` + Labels *[]IssueLabelOutput `json:"labels,omitempty"` + TotalCount *int `json:"totalCount,omitempty"` +} + +func (out IssueReadOutput) MarshalJSON() ([]byte, error) { + if out.Method == "" { + // SDK v1.8 validates this zero value even for errors before the + // protocol middleware removes it from the wire result. + return []byte(`{"method":"get","issue":null}`), nil + } + if out.Method == "get_parent" { + return json.Marshal(struct { + Method string `json:"method"` + Parent *IssueParentRef `json:"parent"` + }{out.Method, out.Parent}) + } + type plain IssueReadOutput + return json.Marshal(plain(out)) +} + +// IssueDetailsOutput excludes REST issue_field_values: get always drops them +// and replaces them with the GraphQL field_values representation. +type IssueDetailsOutput struct { + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body,omitempty"` + State string `json:"state" jsonschema:"Issue state (lowercase REST value)."` + StateReason string `json:"state_reason,omitempty"` + Draft bool `json:"draft,omitempty"` + Locked bool `json:"locked,omitempty"` + HTMLURL string `json:"html_url,omitempty" jsonschema:"Human-readable issue link."` + User *IssueUserOutput `json:"user,omitempty"` + AuthorAssociation string `json:"author_association,omitempty"` + Labels []string `json:"labels,omitempty"` + Assignees []string `json:"assignees"` + Milestone string `json:"milestone,omitempty"` + Comments int `json:"comments,omitempty" jsonschema:"Number of comments."` + Reactions *MinimalReactions `json:"reactions,omitempty"` + CreatedAt string `json:"created_at,omitempty" jsonschema:"Creation time (RFC 3339)."` + UpdatedAt string `json:"updated_at,omitempty" jsonschema:"Last update time (RFC 3339)."` + ClosedAt string `json:"closed_at,omitempty" jsonschema:"Closure time (RFC 3339), when closed."` + ClosedBy string `json:"closed_by,omitempty"` + IssueType string `json:"issue_type,omitempty"` + FieldValues []MinimalFieldValue `json:"field_values,omitempty"` + HasParent *bool `json:"has_parent,omitempty"` + HasChildren *bool `json:"has_children,omitempty"` + Parent *MinimalIssueRef `json:"parent,omitempty"` + SubIssuesSummary *MinimalSubIssuesSummary `json:"sub_issues_summary,omitempty"` + ClosedByPullRequests *MinimalClosingPullRequests `json:"closed_by_pull_requests,omitempty"` +} + +func issueDetailsOutput(issue MinimalIssue) *IssueDetailsOutput { + return &IssueDetailsOutput{ + Number: issue.Number, Title: issue.Title, Body: issue.Body, State: issue.State, + StateReason: issue.StateReason, Draft: issue.Draft, Locked: issue.Locked, + HTMLURL: issue.HTMLURL, User: issueUserOutput(issue.User), AuthorAssociation: issue.AuthorAssociation, + Labels: issue.Labels, Assignees: issue.Assignees, Milestone: issue.Milestone, + Comments: issue.Comments, Reactions: issue.Reactions, CreatedAt: issue.CreatedAt, + UpdatedAt: issue.UpdatedAt, ClosedAt: issue.ClosedAt, ClosedBy: issue.ClosedBy, + IssueType: issue.IssueType, FieldValues: issue.FieldValues, HasParent: issue.HasParent, + HasChildren: issue.HasChildren, Parent: issue.Parent, SubIssuesSummary: issue.SubIssuesSummary, + ClosedByPullRequests: issue.ClosedByPullRequests, + } +} + +type IssueUserOutput struct { + Login string `json:"login"` + ID int64 `json:"id,omitempty"` +} + +func issueUserOutput(user *MinimalUser) *IssueUserOutput { + if user == nil { + return nil + } + return &IssueUserOutput{Login: user.Login, ID: user.ID} +} + +type IssueReadCommentOutput struct { + ID int64 `json:"id"` + Body string `json:"body,omitempty"` + HTMLURL string `json:"html_url" jsonschema:"Human-readable comment link."` + User *IssueUserOutput `json:"user,omitempty"` + AuthorAssociation string `json:"author_association,omitempty"` + Reactions *MinimalReactions `json:"reactions,omitempty"` + CreatedAt string `json:"created_at,omitempty" jsonschema:"Creation time (RFC 3339)."` + UpdatedAt string `json:"updated_at,omitempty" jsonschema:"Last update time (RFC 3339)."` +} + +func issueCommentOutputs(comments []MinimalIssueComment) []IssueReadCommentOutput { + output := make([]IssueReadCommentOutput, 0, len(comments)) + for _, comment := range comments { + output = append(output, IssueReadCommentOutput{ + ID: comment.ID, Body: comment.Body, HTMLURL: comment.HTMLURL, + User: issueUserOutput(comment.User), AuthorAssociation: comment.AuthorAssociation, + Reactions: comment.Reactions, CreatedAt: comment.CreatedAt, UpdatedAt: comment.UpdatedAt, + }) + } + return output +} + +type IssueParentOutput struct { + Parent *IssueParentRef `json:"parent"` +} + +// These fields keep the original map's lexicographic JSON ordering. +type IssueParentRef struct { + Number int `json:"number"` + Repository string `json:"repository"` + State string `json:"state" jsonschema:"Parent issue state (uppercase GraphQL value)."` + Title string `json:"title"` + URL string `json:"url" jsonschema:"Human-readable parent issue link."` +} + +type IssueLabelsOutput struct { + Labels []IssueLabelOutput `json:"labels"` + TotalCount int `json:"totalCount"` +} + +type IssueWriteOutput struct { + Method string `json:"method"` + Issue *MinimalResponse `json:"issue,omitempty"` + Awaiting *IssueWriteAwaitingOutput `json:"awaiting,omitempty"` +} + +type IssueWriteAwaitingOutput struct { + Status string `json:"status"` + Reason string `json:"reason"` +} + +func (out IssueWriteOutput) MarshalJSON() ([]byte, error) { + if out.Awaiting != nil { + return json.Marshal(out.Awaiting) + } + if out.Method == "" { + return []byte(`{"method":"create","issue":{"id":"","url":""}}`), nil + } + type plain IssueWriteOutput + return json.Marshal(plain(out)) +} + +var issueWriteOutputSchema = sync.OnceValue(func() *jsonschema.Schema { + schema := issueDTOschema[IssueWriteOutput]() + awaiting := issueDTOschema[IssueWriteAwaitingOutput]() + awaiting.Properties["status"].Enum = []any{"awaiting_user_submission"} + delete(schema.Properties, "awaiting") + schema.Properties["status"] = awaiting.Properties["status"] + schema.Properties["reason"] = awaiting.Properties["reason"] + schema.Required = nil + schema.Properties["method"].Enum = []any{"create", "update"} + schema.OneOf = []*jsonschema.Schema{ + {Required: []string{"method", "issue"}, Properties: map[string]*jsonschema.Schema{ + "status": forbiddenIssueVariantProperty(), "reason": forbiddenIssueVariantProperty(), + }}, + {Required: []string{"status", "reason"}, Properties: map[string]*jsonschema.Schema{ + "method": forbiddenIssueVariantProperty(), "issue": forbiddenIssueVariantProperty(), + }}, + } + return schema +}) + +func issueWriteResult(method string) func(*mcp.CallToolResult, *MinimalResponse, error) (*mcp.CallToolResult, *IssueWriteOutput, error) { + return func(result *mcp.CallToolResult, output *MinimalResponse, err error) (*mcp.CallToolResult, *IssueWriteOutput, error) { + if output == nil { + return result, nil, err + } + return result, &IssueWriteOutput{Method: method, Issue: output}, err + } +} + +func issueWriteFieldVariants() []*jsonschema.Schema { + return []*jsonschema.Schema{ + { + Required: []string{"value"}, + Properties: map[string]*jsonschema.Schema{ + "field_option_name": {Type: "string", Enum: []any{""}}, "delete": {Type: "boolean", Enum: []any{false}}, + }, + }, + { + Required: []string{"field_option_name"}, + Properties: map[string]*jsonschema.Schema{ + "field_option_name": {Type: "string", MinLength: new(1)}, + "value": forbiddenIssueVariantProperty(), "delete": {Type: "boolean", Enum: []any{false}}, + }, + }, + { + Required: []string{"delete"}, + Properties: map[string]*jsonschema.Schema{ + "delete": {Type: "boolean", Enum: []any{true}}, "value": forbiddenIssueVariantProperty(), "field_option_name": {Type: "string", Enum: []any{""}}, + }, + }, + } +} + +func forbiddenIssueVariantProperty() *jsonschema.Schema { + // An annotation keeps jsonschema-go from simplifying the universal + // schema to a boolean, which schema-object-only clients cannot decode. + return &jsonschema.Schema{Not: &jsonschema.Schema{Description: "Any value."}} +} + +type IssueLabelOutput struct { + Color string `json:"color"` + Description string `json:"description"` + ID string `json:"id"` + Name string `json:"name"` +} + +type SubIssueOutput struct { + ID *int64 `json:"id,omitempty"` + Number *int `json:"number,omitempty"` + Title *string `json:"title,omitempty"` + Body *string `json:"body,omitempty"` + State *string `json:"state,omitempty" jsonschema:"Issue state (lowercase REST value)."` + StateReason *string `json:"state_reason,omitempty"` + HTMLURL *string `json:"html_url,omitempty" jsonschema:"Human-readable issue link."` + User *IssueUserOutput `json:"user,omitempty"` + Labels []string `json:"labels,omitempty"` + Assignees []string `json:"assignees"` + Milestone string `json:"milestone,omitempty"` + Comments *int `json:"comments,omitempty" jsonschema:"Number of comments."` + CreatedAt string `json:"created_at,omitempty" jsonschema:"Creation time (RFC 3339)."` + UpdatedAt string `json:"updated_at,omitempty" jsonschema:"Last update time (RFC 3339)."` + ClosedAt string `json:"closed_at,omitempty" jsonschema:"Closure time (RFC 3339), when closed."` + IssueType string `json:"issue_type,omitempty"` + SubIssuesSummary *MinimalSubIssuesSummary `json:"sub_issues_summary,omitempty"` + FieldValues []*SubIssueFieldValueOutput `json:"field_values,omitempty"` +} + +type SubIssueFieldValueOutput struct { + IssueFieldID int64 `json:"issue_field_id"` + DataType string `json:"data_type" jsonschema:"REST issue field type."` + Value *IssueFieldValue `json:"value" jsonschema:"Scalar field value; dates use YYYY-MM-DD."` +} + +func subIssueOutput(issue *github.SubIssue) (*SubIssueOutput, error) { + if issue == nil { + return nil, nil + } + minimal := convertToMinimalIssue((*github.Issue)(issue)) + out := &SubIssueOutput{ + ID: issue.ID, Number: issue.Number, State: issue.State, StateReason: issue.StateReason, + Title: issue.Title, Body: issue.Body, HTMLURL: issue.HTMLURL, + User: issueUserOutput(minimal.User), Labels: minimal.Labels, Assignees: minimal.Assignees, + Milestone: minimal.Milestone, Comments: issue.Comments, + CreatedAt: minimal.CreatedAt, UpdatedAt: minimal.UpdatedAt, ClosedAt: minimal.ClosedAt, + IssueType: minimal.IssueType, + } + if summary := issue.SubIssuesSummary; summary != nil { + out.SubIssuesSummary = &MinimalSubIssuesSummary{ + Total: summary.GetTotal(), Completed: summary.GetCompleted(), PercentCompleted: summary.GetPercentCompleted(), + } + } + for _, field := range issue.IssueFieldValues { + if field == nil { + out.FieldValues = append(out.FieldValues, nil) + continue + } + value, err := issueFieldValue(field.Value) + if err != nil { + return nil, err + } + out.FieldValues = append(out.FieldValues, &SubIssueFieldValueOutput{ + IssueFieldID: field.IssueFieldID, DataType: field.DataType, Value: value, + }) + } + return out, nil +} + +var issueReadOutputSchema = sync.OnceValue(func() *jsonschema.Schema { + schema := issueDTOschema[IssueReadOutput]() + issue := schema.Properties["issue"] + issue.Properties["state"].Enum = []any{"", "open", "closed"} + issue.Properties["state_reason"].Enum = []any{"completed", "not_planned", "reopened", "duplicate"} + describeIssueSummarySchema(issue.Properties["sub_issues_summary"]) + schema.Properties["sub_issues"] = subIssueArraySchema() + schema.Properties["parent"].Properties["state"].Enum = []any{"", "OPEN", "CLOSED"} + schema.Properties["method"].Enum = []any{"get", "get_comments", "get_sub_issues", "get_parent", "get_labels"} + methods := []string{"get", "get_comments", "get_sub_issues", "get_parent", "get_labels"} + fields := []string{"issue", "comments", "sub_issues", "parent", "labels"} + for i, method := range methods { + variant := &jsonschema.Schema{Required: []string{fields[i]}, Properties: map[string]*jsonschema.Schema{ + "method": {Enum: []any{method}}, + }} + for j, field := range fields { + if i != j { + variant.Properties[field] = forbiddenIssueVariantProperty() + } + } + if method == "get_labels" { + variant.Required = append(variant.Required, "totalCount") + } else { + variant.Properties["totalCount"] = forbiddenIssueVariantProperty() + } + schema.OneOf = append(schema.OneOf, variant) + } + return schema +}) + +type SubIssueWriteOutput struct { + Method string `json:"method"` + Issue *SubIssueOutput `json:"issue"` +} + +func (out SubIssueWriteOutput) MarshalJSON() ([]byte, error) { + // Unlike the SDK's default nil-pointer handling, a null API response + // must remain null rather than becoming an empty sub-issue object. + if out.Method == "" { + return []byte(`{"method":"add","issue":null}`), nil + } + type plain SubIssueWriteOutput + return json.Marshal(plain(out)) +} + +func subIssueWriteResult(method string) func(*mcp.CallToolResult, *SubIssueOutput, error) (*mcp.CallToolResult, *SubIssueWriteOutput, error) { + return func(result *mcp.CallToolResult, output *SubIssueOutput, err error) (*mcp.CallToolResult, *SubIssueWriteOutput, error) { + return result, &SubIssueWriteOutput{Method: method, Issue: output}, err + } +} + +var subIssueWriteOutputSchema = sync.OnceValue(func() *jsonschema.Schema { + schema := issueDTOschema[SubIssueWriteOutput]() + schema.Properties["method"].Enum = []any{"add", "remove", "reprioritize"} + schema.Properties["issue"] = subIssueSchema() + schema.Properties["issue"].Type = "" + schema.Properties["issue"].Types = []string{"object", "null"} + return schema +}) + +func subIssueSchema() *jsonschema.Schema { + schema := issueDTOschema[SubIssueOutput]() + schema.Properties["state"].Enum = []any{"open", "closed"} + schema.Properties["state_reason"].Enum = []any{"completed", "not_planned", "reopened", "duplicate"} + schema.Properties["field_values"].Items.Properties["data_type"].Enum = []any{"text", "number", "date", "single_select"} + describeIssueSummarySchema(schema.Properties["sub_issues_summary"]) + replaceIssueFieldScalar(schema) + return schema +} + +func describeIssueSummarySchema(schema *jsonschema.Schema) { + schema.Properties["total"].Description = "Total number of sub-issues." + schema.Properties["completed"].Description = "Number of completed sub-issues." + schema.Properties["percent_completed"].Description = "Completed sub-issues as a percentage (0-100)." +} + +func subIssueArraySchema() *jsonschema.Schema { + items := subIssueSchema() + items.Type = "" + items.Types = []string{"object", "null"} + return &jsonschema.Schema{Types: []string{"array", "null"}, Items: items} +} + +func replaceIssueFieldScalar(schema *jsonschema.Schema) { + // jsonschema's type override is not needed for the surrounding API + // definitions; replace only the custom scalar's inferred properties. + if schema == nil { + return + } + if fields := schema.Properties["field_values"]; fields != nil && fields.Items != nil { + fields.Items.Properties["value"] = &jsonschema.Schema{Types: []string{"string", "number", "boolean", "null"}} + } + + replaceIssueFieldScalar(schema.Items) +} + +func issueDTOschema[T any]() *jsonschema.Schema { + schema, err := jsonschema.For[T](nil) + if err != nil { + panic(err) + } + return schema +} + +func normalizeConsolidatedIssueArguments(kind string) func(json.RawMessage) (json.RawMessage, error) { + return func(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + for _, field := range []string{"method", "owner", "repo"} { + if _, err := RequiredParam[string](args, field); err != nil { + return nil, err + } + } + requiredInts := []string{"issue_number"} + switch kind { + case "write": + requiredInts = nil + case "sub": + requiredInts = append(requiredInts, "sub_issue_id") + } + for _, field := range requiredInts { + value, err := RequiredInt(args, field) + if err != nil { + return nil, err + } + args[field] = value + } + if kind == "read" { + pagination, err := OptionalPaginationParams(args) + if err != nil { + return nil, err + } + args["page"], args["perPage"] = pagination.Page, pagination.PerPage + } + if kind == "sub" { + if _, err := OptionalParam[bool](args, "replace_parent"); err != nil { + return nil, err + } + for _, field := range []string{"after_id", "before_id"} { + if _, exists := args[field]; exists { + value, err := OptionalIntParam(args, field) + if err != nil { + return nil, err + } + args[field] = value + } + } + } + if kind == "write" { + for _, field := range []string{"title", "body"} { + if _, err := OptionalParam[string](args, field); err != nil { + return nil, err + } + } + for _, field := range []string{"assignees", "labels"} { + if _, err := OptionalStringArrayParam(args, field); err != nil { + return nil, err + } + if args[field] == nil { + delete(args, field) + } + } + milestone, err := OptionalIntParam(args, "milestone") + if err != nil { + return nil, err + } + if _, exists := args["milestone"]; exists { + args["milestone"] = milestone + } + if _, _, err := OptionalNullableStringParam(args, "type"); err != nil { + return nil, err + } + state, err := OptionalParam[string](args, "state") + if err != nil { + return nil, err + } + reason, err := OptionalParam[string](args, "state_reason") + if err != nil { + return nil, err + } + duplicate, err := OptionalIntParam(args, "duplicate_of") + if err != nil { + return nil, err + } + if _, exists := args["duplicate_of"]; exists { + args["duplicate_of"] = duplicate + } + if duplicate != 0 && reason != "duplicate" { + return nil, fmt.Errorf("duplicate_of can only be used when state_reason is 'duplicate'") + } + if err := validateDuplicateState(state, reason, duplicate); err != nil { + return nil, err + } + parent, err := OptionalIntParam(args, "parent_issue_number") + if err != nil { + return nil, err + } + _, parentProvided := args["parent_issue_number"] + if parentProvided { + args["parent_issue_number"] = parent + if parent < 1 { + return nil, fmt.Errorf("parent_issue_number must be greater than 0") + } + if args["method"] != "create" { + return nil, fmt.Errorf("parent_issue_number can only be used with the create method") + } + } + parentOwner, err := OptionalParam[string](args, "parent_owner") + if err != nil { + return nil, err + } + parentRepo, err := OptionalParam[string](args, "parent_repo") + if err != nil { + return nil, err + } + if err := validateParentRepository(parentProvided, parentOwner, parentRepo); err != nil { + return nil, err + } + fields, err := optionalIssueWriteFields(args) + if err != nil { + return nil, err + } + if parentProvided && len(fields) > 0 { + return nil, fmt.Errorf("issue_fields cannot be used with parent_issue_number") + } + if args["method"] != "update" { + delete(args, "issue_number") + } else if _, exists := args["issue_number"]; exists { + value, err := OptionalIntParam(args, "issue_number") + if err != nil { + return nil, err + } + args["issue_number"] = value + } + } + return json.Marshal(args) + } +} diff --git a/pkg/github/issue_tool_types.go b/pkg/github/issue_tool_types.go index c4a5149aa2..6e211f4783 100644 --- a/pkg/github/issue_tool_types.go +++ b/pkg/github/issue_tool_types.go @@ -35,47 +35,6 @@ type SubIssueWriteInput struct { BeforeID int `json:"before_id,omitempty"` } -type IssueReadParentOutput struct { - Parent *MinimalIssueDependencyRef `json:"parent"` -} - -type IssueReadLabelOutput struct { - ID string `json:"id"` - Name string `json:"name"` - Color string `json:"color"` - Description string `json:"description"` -} - -type IssueReadLabelsOutput struct { - Labels []IssueReadLabelOutput `json:"labels"` - TotalCount int `json:"totalCount"` -} - -type IssueReadOutput struct { - Issue *MinimalIssue - Comments []MinimalIssueComment - SubIssues []MinimalIssue - Parent *IssueReadParentOutput - Labels *IssueReadLabelsOutput -} - -func (output IssueReadOutput) MarshalJSON() ([]byte, error) { - switch { - case output.Issue != nil: - return json.Marshal(output.Issue) - case output.Comments != nil: - return json.Marshal(output.Comments) - case output.SubIssues != nil: - return json.Marshal(output.SubIssues) - case output.Parent != nil: - return json.Marshal(output.Parent) - case output.Labels != nil: - return json.Marshal(output.Labels) - default: - return []byte("null"), nil - } -} - type IssueTypeOutput struct { ID *int64 `json:"id,omitempty"` Name *string `json:"name,omitempty"` @@ -382,20 +341,6 @@ func issuePaginationValidationSchema(advertised *jsonschema.Schema) *jsonschema. return validation } -func issueReadOutputSchema() *jsonschema.Schema { - return &jsonschema.Schema{ - Types: []string{"array", "object", "null"}, - OneOf: []*jsonschema.Schema{ - issueOutputSchema[*MinimalIssue](), - issueOutputSchema[[]MinimalIssueComment](), - issueOutputSchema[[]MinimalIssue](), - issueOutputSchema[*IssueReadParentOutput](), - issueOutputSchema[*IssueReadLabelsOutput](), - {Type: "null"}, - }, - } -} - func listIssueFieldsOutputSchema() *jsonschema.Schema { schema := issueOutputSchema[[]IssueFieldOutput]() schema.Items.Properties["id"].Description = "The node ID of the issue field." @@ -557,24 +502,6 @@ func normalizeIssueIntegers(required, optional []string) inventory.InputNormaliz } } -func normalizeIssueBooleans(fields []string) inventory.InputNormalizer { - return func(raw json.RawMessage) (json.RawMessage, error) { - var args map[string]any - if err := json.Unmarshal(raw, &args); err != nil { - return nil, err - } - for _, field := range fields { - if _, exists := args[field]; !exists { - continue - } - if _, err := OptionalParam[bool](args, field); err != nil { - return nil, err - } - } - return raw, nil - } -} - func normalizeDuplicateThreshold(raw json.RawMessage) (json.RawMessage, error) { var args map[string]any if err := json.Unmarshal(raw, &args); err != nil { diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 4ff681d8bb..86fb714c6c 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -822,38 +822,55 @@ func IssueRead(t translations.TranslationHelperFunc) inventory.ServerTool { } WithPagination(schema) - return NewToolWithSchemaOptions[IssueReadInput, IssueReadOutput]( + return NewTool[IssueReadInput, *IssueReadOutput]( ToolsetMetadataIssues, mcp.Tool{ - Name: "issue_read", - Description: t("TOOL_ISSUE_READ_DESCRIPTION", "Get information about a specific issue in a GitHub repository."), + Name: "issue_read", + OutputSchema: issueReadOutputSchema(), + Description: t("TOOL_ISSUE_READ_DESCRIPTION", "Get information about a specific issue in a GitHub repository."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ISSUE_READ_USER_TITLE", "Get issue details"), ReadOnlyHint: true, }, - InputSchema: schema, - OutputSchema: issueReadOutputSchema(), + InputSchema: schema, }, scopes.PublicRead(scopes.Repo), - inventory.TypedSchemaOptions{ValidationInputSchema: issuePaginationValidationSchema(schema), PreserveHandlerContent: true}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input IssueReadInput) (*mcp.CallToolResult, IssueReadOutput, error) { - method, owner, repo, issueNumber := input.Method, input.Owner, input.Repo, input.IssueNumber - pagination := PaginationParams{Page: input.Page, PerPage: input.PerPage} - if pagination.Page == 0 { - pagination.Page = 1 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input IssueReadInput) (*mcp.CallToolResult, *IssueReadOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err } - if pagination.PerPage == 0 { - pagination.PerPage = 30 + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + issueNumber, err := RequiredInt(args, "issue_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + 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), IssueReadOutput{}, nil + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } gqlClient, err := deps.GetGQLClient(ctx) if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub graphql client", err), IssueReadOutput{}, nil + return utils.NewToolResultErrorFromErr("failed to get GitHub graphql client", err), nil, nil } // attachIFC adds the IFC label to a successful tool result when @@ -863,118 +880,55 @@ func IssueRead(t translations.TranslationHelperFunc) inventory.ServerTool { switch method { case "get": - result, err := GetIssue(ctx, client, deps, owner, repo, issueNumber) - return issueReadTypedResult(method, attachIFC(result), err) + result, output, err := getIssue(ctx, client, deps, owner, repo, issueNumber) + return attachIFC(result), output, err case "get_comments": - result, err := GetIssueComments(ctx, client, deps, owner, repo, issueNumber, pagination) - return issueReadTypedResult(method, attachIFC(result), err) + result, output, err := getIssueComments(ctx, client, deps, owner, repo, issueNumber, pagination) + return attachIFC(result), output, err case "get_sub_issues": - result, err := GetSubIssues(ctx, client, deps, owner, repo, issueNumber, pagination) - return issueReadTypedResult(method, attachIFC(result), err) + result, output, err := getSubIssues(ctx, client, deps, owner, repo, issueNumber, pagination) + return attachIFC(result), output, err case "get_parent": - result, err := GetIssueParent(ctx, gqlClient, deps, owner, repo, issueNumber) - return issueReadTypedResult(method, attachIFC(result), err) + result, output, err := getIssueParent(ctx, gqlClient, deps, owner, repo, issueNumber) + return attachIFC(result), output, err case "get_labels": - result, err := GetIssueLabels(ctx, gqlClient, owner, repo, issueNumber) - return issueReadTypedResult(method, attachIFC(result), err) + result, output, err := getIssueLabels(ctx, gqlClient, owner, repo, issueNumber) + return attachIFC(result), output, err default: - return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), IssueReadOutput{}, nil + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } - }, - normalizeIssueStrings([]string{"method", "owner", "repo"}, nil), - normalizeIssueIntegers([]string{"issue_number"}, []string{"page", "perPage"})) + }, normalizeConsolidatedIssueArguments("read")) } -func issueReadTypedResult(method string, result *mcp.CallToolResult, err error) (*mcp.CallToolResult, IssueReadOutput, error) { - if err != nil || result == nil || result.IsError { - return result, IssueReadOutput{}, err - } - if len(result.Content) != 1 { - return nil, IssueReadOutput{}, fmt.Errorf("issue read returned %d content items, expected one", len(result.Content)) - } - text, ok := result.Content[0].(*mcp.TextContent) - if !ok { - return nil, IssueReadOutput{}, fmt.Errorf("issue read returned %T content, expected text", result.Content[0]) - } - - switch method { - case "get": - var issue MinimalIssue - if err := json.Unmarshal([]byte(text.Text), &issue); err != nil { - return nil, IssueReadOutput{}, fmt.Errorf("failed to decode issue read output: %w", err) - } - return result, IssueReadOutput{Issue: &issue}, nil - case "get_comments": - var comments []MinimalIssueComment - if err := json.Unmarshal([]byte(text.Text), &comments); err != nil { - return nil, IssueReadOutput{}, fmt.Errorf("failed to decode issue comments output: %w", err) - } - if comments == nil { - comments = make([]MinimalIssueComment, 0) - } - return result, IssueReadOutput{Comments: comments}, nil - case "get_sub_issues": - var subIssues []*github.SubIssue - if err := json.Unmarshal([]byte(text.Text), &subIssues); err != nil { - return nil, IssueReadOutput{}, fmt.Errorf("failed to decode sub-issues output: %w", err) - } - minimal := make([]MinimalIssue, 0, len(subIssues)) - for _, subIssue := range subIssues { - if subIssue != nil { - minimal = append(minimal, convertToMinimalIssue((*github.Issue)(subIssue))) - } - } - return result, IssueReadOutput{SubIssues: minimal}, nil - case "get_parent": - var legacy struct { - Parent *MinimalIssueRef `json:"parent"` - } - if err := json.Unmarshal([]byte(text.Text), &legacy); err != nil { - return nil, IssueReadOutput{}, fmt.Errorf("failed to decode issue parent output: %w", err) - } - var parent *MinimalIssueDependencyRef - if legacy.Parent != nil { - parent = new(dependencyRefOutput(*legacy.Parent)) - } - return result, IssueReadOutput{Parent: &IssueReadParentOutput{Parent: parent}}, nil - case "get_labels": - var labels IssueReadLabelsOutput - if err := json.Unmarshal([]byte(text.Text), &labels); err != nil { - return nil, IssueReadOutput{}, fmt.Errorf("failed to decode issue labels output: %w", err) - } - if labels.Labels == nil { - labels.Labels = make([]IssueReadLabelOutput, 0) - } - return result, IssueReadOutput{Labels: &labels}, nil - default: - return nil, IssueReadOutput{}, fmt.Errorf("unknown issue read method %q", method) - } +func GetIssue(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int) (*mcp.CallToolResult, error) { + result, _, err := getIssue(ctx, client, deps, owner, repo, issueNumber) + return result, err } -func GetIssue(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int) (*mcp.CallToolResult, error) { +func getIssue(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int) (*mcp.CallToolResult, *IssueReadOutput, error) { cache, err := deps.GetRepoAccessCache(ctx) if err != nil { - return nil, fmt.Errorf("failed to get repo access cache: %w", err) + return nil, nil, fmt.Errorf("failed to get repo access cache: %w", err) } flags := deps.GetFlags(ctx) issue, resp, err := client.Issues.Get(ctx, owner, repo, issueNumber) if err != nil { - return nil, fmt.Errorf("failed to get issue: %w", err) + return nil, nil, fmt.Errorf("failed to get issue: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get issue", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get issue", resp, body), nil, nil } if flags.LockdownMode { if restricted, err := authorLockdownResult(ctx, cache, owner, repo, issue.GetUser().GetLogin(), lockdownIssueRestrictedMessage); restricted != nil || err != nil { - return restricted, err + return restricted, nil, err } } @@ -993,7 +947,7 @@ func GetIssue(ctx context.Context, client *github.Client, deps ToolDependencies, } } - return MarshalledTextResult(minimalIssue), nil + return MarshalledTextResult(minimalIssue), &IssueReadOutput{Method: "get", Issue: issueDetailsOutput(minimalIssue)}, nil } // applyIssueReadEnrichment populates the hierarchy relationship signals (has_parent/has_children, @@ -1060,9 +1014,14 @@ func isSafeRefContent(ctx context.Context, cache *lockdown.RepoAccessCache, repo } func GetIssueComments(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { + result, _, err := getIssueComments(ctx, client, deps, owner, repo, issueNumber, pagination) + return result, err +} + +func getIssueComments(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int, pagination PaginationParams) (*mcp.CallToolResult, *IssueReadOutput, error) { cache, err := deps.GetRepoAccessCache(ctx) if err != nil { - return nil, fmt.Errorf("failed to get repo access cache: %w", err) + return nil, nil, fmt.Errorf("failed to get repo access cache: %w", err) } flags := deps.GetFlags(ctx) @@ -1075,20 +1034,20 @@ func GetIssueComments(ctx context.Context, client *github.Client, deps ToolDepen comments, resp, err := client.Issues.ListComments(ctx, owner, repo, issueNumber, opts) if err != nil { - return nil, fmt.Errorf("failed to get issue comments: %w", err) + return nil, nil, fmt.Errorf("failed to get issue comments: %w", err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get issue comments", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get issue comments", resp, body), nil, nil } if flags.LockdownMode { if cache == nil { - return nil, fmt.Errorf("lockdown cache is not configured") + return nil, nil, fmt.Errorf("lockdown cache is not configured") } filteredComments := make([]*github.IssueComment, 0, len(comments)) for _, comment := range comments { @@ -1102,7 +1061,7 @@ func GetIssueComments(ctx context.Context, client *github.Client, deps ToolDepen } isSafeContent, err := cache.IsSafeContent(ctx, login, owner, repo) if err != nil { - return utils.NewToolResultError(fmt.Sprintf("failed to check lockdown mode: %v", err)), nil + return utils.NewToolResultError(fmt.Sprintf("failed to check lockdown mode: %v", err)), nil, nil } if isSafeContent { filteredComments = append(filteredComments, comment) @@ -1116,13 +1075,19 @@ func GetIssueComments(ctx context.Context, client *github.Client, deps ToolDepen minimalComments = append(minimalComments, convertToMinimalIssueComment(comment)) } - return MarshalledTextResult(minimalComments), nil + output := issueCommentOutputs(minimalComments) + return MarshalledTextResult(minimalComments), &IssueReadOutput{Method: "get_comments", Comments: &output}, nil } func GetSubIssues(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { + result, _, err := getSubIssues(ctx, client, deps, owner, repo, issueNumber, pagination) + return result, err +} + +func getSubIssues(ctx context.Context, client *github.Client, deps ToolDependencies, owner string, repo string, issueNumber int, pagination PaginationParams) (*mcp.CallToolResult, *IssueReadOutput, error) { cache, err := deps.GetRepoAccessCache(ctx) if err != nil { - return nil, fmt.Errorf("failed to get repo access cache: %w", err) + return nil, nil, fmt.Errorf("failed to get repo access cache: %w", err) } featureFlags := deps.GetFlags(ctx) @@ -1137,7 +1102,7 @@ func GetSubIssues(ctx context.Context, client *github.Client, deps ToolDependenc "failed to list sub-issues", resp, err, - ), nil + ), nil, nil } defer func() { _ = resp.Body.Close() }() @@ -1145,14 +1110,14 @@ func GetSubIssues(ctx context.Context, client *github.Client, deps ToolDependenc if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list sub-issues", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list sub-issues", resp, body), nil, nil } if featureFlags.LockdownMode { if cache == nil { - return nil, fmt.Errorf("lockdown cache is not configured") + return nil, nil, fmt.Errorf("lockdown cache is not configured") } filteredSubIssues := make([]*github.SubIssue, 0, len(subIssues)) for _, subIssue := range subIssues { @@ -1166,7 +1131,7 @@ func GetSubIssues(ctx context.Context, client *github.Client, deps ToolDependenc } isSafeContent, err := cache.IsSafeContent(ctx, login, owner, repo) if err != nil { - return utils.NewToolResultError(fmt.Sprintf("failed to check lockdown mode: %v", err)), nil + return utils.NewToolResultError(fmt.Sprintf("failed to check lockdown mode: %v", err)), nil, nil } if isSafeContent { filteredSubIssues = append(filteredSubIssues, subIssue) @@ -1180,10 +1145,21 @@ func GetSubIssues(ctx context.Context, client *github.Client, deps ToolDependenc } r, err := json.Marshal(subIssues) if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil + var output []*SubIssueOutput + if subIssues != nil { + output = make([]*SubIssueOutput, 0, len(subIssues)) + } + for _, issue := range subIssues { + item, err := subIssueOutput(issue) + if err != nil { + return nil, nil, fmt.Errorf("failed to build sub-issue output: %w", err) + } + output = append(output, item) + } + return utils.NewToolResultText(string(r)), &IssueReadOutput{Method: "get_sub_issues", SubIssues: &output}, nil } // GetIssueParent returns the parent issue of the given issue, or a null @@ -1194,9 +1170,14 @@ func GetSubIssues(ctx context.Context, client *github.Client, deps ToolDependenc // lockdown mode the parent is only returned when its author has push // access to the parent repo (mirroring GetIssue); otherwise it is omitted. func GetIssueParent(ctx context.Context, client *githubv4.Client, deps ToolDependencies, owner string, repo string, issueNumber int) (*mcp.CallToolResult, error) { + result, _, err := getIssueParent(ctx, client, deps, owner, repo, issueNumber) + return result, err +} + +func getIssueParent(ctx context.Context, client *githubv4.Client, deps ToolDependencies, owner string, repo string, issueNumber int) (*mcp.CallToolResult, *IssueReadOutput, error) { cache, err := deps.GetRepoAccessCache(ctx) if err != nil { - return nil, fmt.Errorf("failed to get repo access cache: %w", err) + return nil, nil, fmt.Errorf("failed to get repo access cache: %w", err) } flags := deps.GetFlags(ctx) @@ -1226,43 +1207,48 @@ func GetIssueParent(ctx context.Context, client *githubv4.Client, deps ToolDepen } if err := client.Query(ctx, &query, vars); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get issue parent", err), nil + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get issue parent", err), nil, nil } parent := query.Repository.Issue.Parent if parent == nil { - return MarshalledTextResult(map[string]any{"parent": nil}), nil + output := &IssueParentOutput{} + return MarshalledTextResult(output), &IssueReadOutput{Method: "get_parent", Parent: output.Parent}, nil } if flags.LockdownMode { if cache == nil { - return nil, fmt.Errorf("lockdown cache is not configured") + return nil, nil, fmt.Errorf("lockdown cache is not configured") } // Fail closed: omit the parent if anything needed for the safe-content // check is missing or unverifiable. parentAuthorLogin := string(parent.Author.Login) parentOwner, parentRepo, ok := strings.Cut(string(parent.Repository.NameWithOwner), "/") if parentAuthorLogin == "" || !ok || parentOwner == "" || parentRepo == "" { - return MarshalledTextResult(map[string]any{"parent": nil}), nil + output := &IssueParentOutput{} + return MarshalledTextResult(output), &IssueReadOutput{Method: "get_parent", Parent: output.Parent}, nil } isSafeContent, err := cache.IsSafeContent(ctx, parentAuthorLogin, parentOwner, parentRepo) if err != nil || !isSafeContent { - return MarshalledTextResult(map[string]any{"parent": nil}), nil + output := &IssueParentOutput{} + return MarshalledTextResult(output), &IssueReadOutput{Method: "get_parent", Parent: output.Parent}, nil } } - return MarshalledTextResult(map[string]any{ - "parent": map[string]any{ - "number": int(parent.Number), - "title": sanitize.PlainText(string(parent.Title)), - "state": string(parent.State), - "url": string(parent.URL), - "repository": string(parent.Repository.NameWithOwner), - }, - }), nil + output := &IssueParentOutput{Parent: &IssueParentRef{ + Number: int(parent.Number), Title: sanitize.PlainText(string(parent.Title)), + State: string(parent.State), URL: string(parent.URL), + Repository: string(parent.Repository.NameWithOwner), + }} + return MarshalledTextResult(output), &IssueReadOutput{Method: "get_parent", Parent: output.Parent}, nil } func GetIssueLabels(ctx context.Context, client *githubv4.Client, owner string, repo string, issueNumber int) (*mcp.CallToolResult, error) { + result, _, err := getIssueLabels(ctx, client, owner, repo, issueNumber) + return result, err +} + +func getIssueLabels(ctx context.Context, client *githubv4.Client, owner string, repo string, issueNumber int) (*mcp.CallToolResult, *IssueReadOutput, error) { // Get current labels on the issue using GraphQL var query struct { Repository struct { @@ -1287,37 +1273,34 @@ func GetIssueLabels(ctx context.Context, client *githubv4.Client, owner string, } if err := client.Query(ctx, &query, vars); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to get issue labels", err), nil + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to get issue labels", err), nil, nil } // Extract label information - issueLabels := make([]map[string]any, len(query.Repository.Issue.Labels.Nodes)) + issueLabels := make([]IssueLabelOutput, len(query.Repository.Issue.Labels.Nodes)) for i, label := range query.Repository.Issue.Labels.Nodes { - issueLabels[i] = map[string]any{ - "id": fmt.Sprintf("%v", label.ID), - "name": string(label.Name), - "color": string(label.Color), - "description": string(label.Description), + issueLabels[i] = IssueLabelOutput{ + ID: fmt.Sprintf("%v", label.ID), Name: string(label.Name), + Color: string(label.Color), Description: string(label.Description), } } - response := map[string]any{ - "labels": issueLabels, - "totalCount": int(query.Repository.Issue.Labels.TotalCount), + response := &IssueLabelsOutput{ + Labels: issueLabels, TotalCount: int(query.Repository.Issue.Labels.TotalCount), } out, err := json.Marshal(response) if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(out)), nil + return utils.NewToolResultText(string(out)), &IssueReadOutput{Method: "get_labels", Labels: &response.Labels, TotalCount: &response.TotalCount}, nil } // ListIssueTypes creates a tool to list defined issue types for an organization or repository. // This can be used to understand supported issue type values for creating or updating issues. func ListIssueTypes(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewToolWithSchemaOptions[IssueMetadataInput, []*IssueTypeOutput]( + st := NewTool[IssueMetadataInput, []*IssueTypeOutput]( ToolsetMetadataIssues, mcp.Tool{ Name: "list_issue_types", @@ -1343,7 +1326,6 @@ func ListIssueTypes(t translations.TranslationHelperFunc) inventory.ServerTool { OutputSchema: issueTypeOutputSchema(), }, repositoryOrOrganizationScopeAccess(), - inventory.TypedSchemaOptions{PreserveHandlerContent: true}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input IssueMetadataInput) (*mcp.CallToolResult, []*IssueTypeOutput, error) { owner, repo := input.Owner, input.Repo if owner == "" { @@ -1418,7 +1400,7 @@ func ListIssueTypes(t translations.TranslationHelperFunc) inventory.ServerTool { // AddIssueComment creates a tool to add a comment or reaction to an issue. func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewToolWithSchemaOptions[AddIssueCommentInput, *AddIssueCommentOutput]( + return NewTool[AddIssueCommentInput, *AddIssueCommentOutput]( ToolsetMetadataIssues, mcp.Tool{ Name: "add_issue_comment", @@ -1463,7 +1445,6 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool OutputSchema: addIssueCommentOutputSchema(), }, publicRepositoryWriteScopeAccess(), - inventory.TypedSchemaOptions{PreserveHandlerContent: true}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input AddIssueCommentInput) (*mcp.CallToolResult, *AddIssueCommentOutput, error) { owner, repo, issueNumber := input.Owner, input.Repo, input.IssueNumber if err := validateIssueCoordinate(owner, repo, issueNumber); err != nil { @@ -1579,7 +1560,9 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool result := &AddIssueCommentOutput{} switch { case hasBody && hasReaction: - legacyResult.Combined = &IssueCommentAndReactionLegacyOutput{Comment: *commentResponse, Reaction: *reactionResponse} + legacyResult.Combined = &IssueCommentAndReactionLegacyOutput{ + Comment: *commentResponse, Reaction: *reactionResponse, + } result.Combined = &IssueCommentAndReactionOutput{ Comment: IssueCommentOutput{ID: commentResponse.ID, HTMLURL: commentResponse.URL}, Reaction: IssueReactionOutput{ID: reactionResponse.ID}, @@ -1589,7 +1572,9 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool result.Single = &IssueCommentOutput{ID: reactionResponse.ID} default: legacyResult.Single = commentResponse - result.Single = &IssueCommentOutput{ID: commentResponse.ID, HTMLURL: commentResponse.URL} + if commentResponse != nil { + result.Single = &IssueCommentOutput{ID: commentResponse.ID, HTMLURL: commentResponse.URL} + } } r, err := json.Marshal(legacyResult) @@ -1712,11 +1697,12 @@ func issueNumberFromIssueURL(issueURL string) (int, error) { // SubIssueWrite creates a tool to add a sub-issue to a parent issue. func SubIssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewToolWithSchemaOptions[SubIssueWriteInput, *MinimalIssue]( + st := NewTool[SubIssueWriteInput, *SubIssueWriteOutput]( ToolsetMetadataIssues, mcp.Tool{ - Name: "sub_issue_write", - Description: t("TOOL_SUB_ISSUE_WRITE_DESCRIPTION", "Add a sub-issue to a parent issue in a GitHub repository."), + Name: "sub_issue_write", + OutputSchema: subIssueWriteOutputSchema(), + Description: t("TOOL_SUB_ISSUE_WRITE_DESCRIPTION", "Add a sub-issue to a parent issue in a GitHub repository."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_SUB_ISSUE_WRITE_USER_TITLE", "Change sub-issue"), ReadOnlyHint: false, @@ -1764,13 +1750,47 @@ func SubIssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool { }, Required: []string{"method", "owner", "repo", "issue_number", "sub_issue_id"}, }, - OutputSchema: issueOutputSchema[*MinimalIssue](), }, scopes.RequireAll(scopes.Repo), - inventory.TypedSchemaOptions{PreserveHandlerContent: true}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input SubIssueWriteInput) (*mcp.CallToolResult, *MinimalIssue, error) { - method, owner, repo := input.Method, input.Owner, input.Repo - issueNumber, subIssueID := input.IssueNumber, input.SubIssueID + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input SubIssueWriteInput) (*mcp.CallToolResult, *SubIssueWriteOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } + method, err := RequiredParam[string](args, "method") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + issueNumber, err := RequiredInt(args, "issue_number") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + subIssueID, err := RequiredInt(args, "sub_issue_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + replaceParent, err := OptionalParam[bool](args, "replace_parent") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + afterID, err := OptionalIntParam(args, "after_id") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + beforeID, err := OptionalIntParam(args, "before_id") + 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 @@ -1778,50 +1798,27 @@ func SubIssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool { switch strings.ToLower(method) { case "add": - result, err := AddSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, input.ReplaceParent) - return subIssueWriteTypedResult(result, err) + return subIssueWriteResult("add")(addSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, replaceParent)) case "remove": // Call the remove sub-issue function - result, err := RemoveSubIssue(ctx, client, owner, repo, issueNumber, subIssueID) - return subIssueWriteTypedResult(result, err) + return subIssueWriteResult("remove")(removeSubIssue(ctx, client, owner, repo, issueNumber, subIssueID)) case "reprioritize": // Call the reprioritize sub-issue function - result, err := ReprioritizeSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, input.AfterID, input.BeforeID) - return subIssueWriteTypedResult(result, err) + return subIssueWriteResult("reprioritize")(reprioritizeSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, afterID, beforeID)) default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } - }, - normalizeIssueStrings([]string{"method", "owner", "repo"}, nil), - normalizeIssueIntegers([]string{"issue_number", "sub_issue_id"}, []string{"after_id", "before_id"}), - normalizeIssueBooleans([]string{"replace_parent"})) + }, normalizeConsolidatedIssueArguments("sub")) st.FeatureRule = issuesConsolidatedFeatureRule return st } -func subIssueWriteTypedResult(result *mcp.CallToolResult, err error) (*mcp.CallToolResult, *MinimalIssue, error) { - if err != nil || result == nil || result.IsError { - return result, nil, err - } - if len(result.Content) != 1 { - return nil, nil, fmt.Errorf("sub-issue write returned %d content items, expected one", len(result.Content)) - } - text, ok := result.Content[0].(*mcp.TextContent) - if !ok { - return nil, nil, fmt.Errorf("sub-issue write returned %T content, expected text", result.Content[0]) - } - var subIssue *github.SubIssue - if err := json.Unmarshal([]byte(text.Text), &subIssue); err != nil { - return nil, nil, fmt.Errorf("failed to decode sub-issue write output: %w", err) - } - if subIssue == nil { - return result, nil, nil - } - minimal := convertToMinimalIssue((*github.Issue)(subIssue)) - return result, &minimal, nil +func AddSubIssue(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, subIssueID int, replaceParent bool) (*mcp.CallToolResult, error) { + result, _, err := addSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, replaceParent) + return result, err } -func AddSubIssue(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, subIssueID int, replaceParent bool) (*mcp.CallToolResult, error) { +func addSubIssue(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, subIssueID int, replaceParent bool) (*mcp.CallToolResult, *SubIssueOutput, error) { subIssueRequest := github.SubIssueRequest{ SubIssueID: int64(subIssueID), ReplaceParent: new(replaceParent), @@ -1833,7 +1830,7 @@ func AddSubIssue(ctx context.Context, client *github.Client, owner string, repo "failed to add sub-issue", resp, err, - ), nil + ), nil, nil } defer func() { _ = resp.Body.Close() }() @@ -1841,21 +1838,27 @@ func AddSubIssue(ctx context.Context, client *github.Client, owner string, repo if resp.StatusCode != http.StatusCreated { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to add sub-issue", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to add sub-issue", resp, body), nil, nil } sanitizeSubIssueTitleAndBody(subIssue) r, err := json.Marshal(subIssue) if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil + output, err := subIssueOutput(subIssue) + return utils.NewToolResultText(string(r)), output, err } func RemoveSubIssue(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, subIssueID int) (*mcp.CallToolResult, error) { + result, _, err := removeSubIssue(ctx, client, owner, repo, issueNumber, subIssueID) + return result, err +} + +func removeSubIssue(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, subIssueID int) (*mcp.CallToolResult, *SubIssueOutput, error) { subIssueRequest := github.SubIssueRequest{ SubIssueID: int64(subIssueID), } @@ -1866,34 +1869,40 @@ func RemoveSubIssue(ctx context.Context, client *github.Client, owner string, re "failed to remove sub-issue", resp, err, - ), nil + ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to remove sub-issue", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to remove sub-issue", resp, body), nil, nil } sanitizeSubIssueTitleAndBody(subIssue) r, err := json.Marshal(subIssue) if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil + output, err := subIssueOutput(subIssue) + return utils.NewToolResultText(string(r)), output, err } func ReprioritizeSubIssue(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, subIssueID int, afterID int, beforeID int) (*mcp.CallToolResult, error) { + result, _, err := reprioritizeSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, afterID, beforeID) + return result, err +} + +func reprioritizeSubIssue(ctx context.Context, client *github.Client, owner string, repo string, issueNumber int, subIssueID int, afterID int, beforeID int) (*mcp.CallToolResult, *SubIssueOutput, error) { // Validate that either after_id or before_id is specified, but not both if afterID == 0 && beforeID == 0 { - return utils.NewToolResultError("either after_id or before_id must be specified"), nil + return utils.NewToolResultError("either after_id or before_id must be specified"), nil, nil } if afterID != 0 && beforeID != 0 { - return utils.NewToolResultError("only one of after_id or before_id should be specified, not both"), nil + return utils.NewToolResultError("only one of after_id or before_id should be specified, not both"), nil, nil } subIssueRequest := github.SubIssueRequest{ @@ -1915,7 +1924,7 @@ func ReprioritizeSubIssue(ctx context.Context, client *github.Client, owner stri "failed to reprioritize sub-issue", resp, err, - ), nil + ), nil, nil } defer func() { _ = resp.Body.Close() }() @@ -1923,18 +1932,19 @@ func ReprioritizeSubIssue(ctx context.Context, client *github.Client, owner stri if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to reprioritize sub-issue", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to reprioritize sub-issue", resp, body), nil, nil } sanitizeSubIssueTitleAndBody(subIssue) r, err := json.Marshal(subIssue) if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil + output, err := subIssueOutput(subIssue) + return utils.NewToolResultText(string(r)), output, err } // The two search engines want opposite things from a caller, so steering advice @@ -2189,8 +2199,8 @@ type SearchIssuesResponse struct { } type SearchIssuesOutput struct { - Total *int `json:"total_count,omitempty" jsonschema:"Total number of matching issues or pull requests."` - IncompleteResults *bool `json:"incomplete_results,omitempty" jsonschema:"Whether GitHub returned an incomplete result set."` + Total *int `json:"total_count,omitempty"` + IncompleteResults *bool `json:"incomplete_results,omitempty"` Items []SearchIssueOutputItem `json:"items"` } @@ -2198,7 +2208,7 @@ type SearchIssueOutputItem struct { Number *int `json:"number,omitempty"` Title *string `json:"title,omitempty"` Body *string `json:"body,omitempty"` - State *string `json:"state,omitempty" jsonschema:"Issue state, typically open or closed."` + State *string `json:"state,omitempty"` StateReason *string `json:"state_reason,omitempty"` Draft *bool `json:"draft,omitempty"` Locked *bool `json:"locked,omitempty"` @@ -2209,16 +2219,16 @@ type SearchIssueOutputItem struct { Assignee *string `json:"assignee,omitempty"` Assignees []string `json:"assignees,omitempty"` Milestone *string `json:"milestone,omitempty"` - Comments *int `json:"comments,omitempty" jsonschema:"Number of comments on the issue."` + Comments *int `json:"comments,omitempty"` Reactions *MinimalReactions `json:"reactions,omitempty"` - CreatedAt *string `json:"created_at,omitempty" jsonschema:"Creation time in RFC 3339 format."` - UpdatedAt *string `json:"updated_at,omitempty" jsonschema:"Last update time in RFC 3339 format."` - ClosedAt *string `json:"closed_at,omitempty" jsonschema:"Closing time in RFC 3339 format."` + CreatedAt *string `json:"created_at,omitempty"` + UpdatedAt *string `json:"updated_at,omitempty"` + ClosedAt *string `json:"closed_at,omitempty"` ClosedBy *string `json:"closed_by,omitempty"` Type *string `json:"type,omitempty"` RepositoryURL *string `json:"repository_url,omitempty"` PullRequest *SearchIssuePullRequest `json:"pull_request,omitempty"` - FieldValues *[]MinimalFieldValue `json:"field_values,omitempty"` + FieldValues []MinimalFieldValue `json:"field_values,omitempty"` } type SearchIssuePullRequest struct { @@ -2327,7 +2337,7 @@ func searchIssueOutputItem(result SearchIssueResult, fields []string) SearchIssu } } if selected("field_values") && result.FieldValues != nil { - output.FieldValues = &result.FieldValues + output.FieldValues = result.FieldValues } return output } @@ -2683,11 +2693,12 @@ func issueWriteAwaitingFormResult(method, owner, repo string, issueNumber int) * // flag is removed, delete LegacyIssueWrite outright and drop the feature-flag // fields on IssueWrite. func IssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := NewTool[IssueWriteInput, *IssueWriteOutput]( ToolsetMetadataIssues, mcp.Tool{ - Name: "issue_write", - Description: t("TOOL_ISSUE_WRITE_DESCRIPTION", "Create a new or update an existing issue in a GitHub repository."), + Name: "issue_write", + OutputSchema: issueWriteOutputSchema(), + 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/pull request"), ReadOnlyHint: false, @@ -2822,7 +2833,14 @@ Options are: }, }, publicRepositoryWriteScopeAccess(), - func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, input IssueWriteInput) (*mcp.CallToolResult, *IssueWriteOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } + if input.TypeProvided && input.Type == nil { + args["type"] = nil + } method, err := RequiredParam[string](args, "method") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -2839,7 +2857,13 @@ Options are: // Hand off to the interactive MCP App form unless this call must // execute now (see shouldDeferToForm). - if shouldDeferToForm(ctx, deps, req, args, issueWriteFormParams) { + formArgs := args + if req != nil && req.Params != nil && len(req.Params.Arguments) > 0 { + if err := json.Unmarshal(req.Params.Arguments, &formArgs); err != nil { + return nil, nil, err + } + } + if shouldDeferToForm(ctx, deps, req, formArgs, issueWriteFormParams) { issueNumber := 0 if method == "update" { n, numErr := RequiredInt(args, "issue_number") @@ -2848,7 +2872,13 @@ Options are: } issueNumber = n } - return issueWriteAwaitingFormResult(method, owner, repo, issueNumber), nil, nil + result := issueWriteAwaitingFormResult(method, owner, repo, issueNumber) + output := &IssueWriteAwaitingOutput{ + Status: "awaiting_user_submission", + Reason: "An interactive form is being shown to the user. The operation has not been performed.", + } + result.StructuredContent = output + return result, &IssueWriteOutput{Method: method, Awaiting: output}, nil } title, err := OptionalParam[string](args, "title") @@ -2976,27 +3006,24 @@ Options are: switch method { case "create": if parentProvided { - result, err := CreateIssueWithParent(ctx, client, gqlClient, owner, repo, title, body, assignees, labels, milestoneNum, issueType, parentIssueNumber, parentOwner, parentRepo) - return result, nil, err + return issueWriteResult(method)(createIssueWithParent(ctx, client, gqlClient, owner, repo, title, body, assignees, labels, milestoneNum, issueType, parentIssueNumber, parentOwner, parentRepo)) } - result, err := CreateIssue(ctx, client, owner, repo, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues) - return result, nil, err + return issueWriteResult(method)(createIssue(ctx, client, owner, repo, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues)) 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, issueFieldValues, fieldIDsToDelete, state, stateReason, duplicateOf, UpdateIssueOptions{ + return issueWriteResult(method)(updateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues, fieldIDsToDelete, state, stateReason, duplicateOf, UpdateIssueOptions{ AssigneesProvided: assigneesProvided, LabelsProvided: labelsProvided, IssueTypeProvided: issueTypeProvided, - }) - return result, nil, err + })) default: return utils.NewToolResultError("invalid method, must be either 'create' or 'update'"), nil, nil } - }) + }, normalizeConsolidatedIssueArguments("write")) st.FeatureRule = issuesConsolidatedFeatureRule return st } @@ -3052,17 +3079,22 @@ func CreateIssueWithParent( parentOwner string, parentRepo string, ) (*mcp.CallToolResult, error) { + result, _, err := createIssueWithParent(ctx, client, gqlClient, owner, repo, title, body, assignees, labels, milestoneNumber, issueType, parentIssueNumber, parentOwner, parentRepo) + return result, err +} + +func createIssueWithParent(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner, repo, title, body string, assignees, labels []string, milestoneNumber int, issueType string, parentIssueNumber int, parentOwner, parentRepo string) (*mcp.CallToolResult, *MinimalResponse, error) { if title == "" { - return utils.NewToolResultError("missing required parameter: title"), nil + return utils.NewToolResultError("missing required parameter: title"), nil, nil } if parentIssueNumber < 1 { - return utils.NewToolResultError("parent_issue_number must be greater than 0"), nil + return utils.NewToolResultError("parent_issue_number must be greater than 0"), nil, nil } parentOwner, parentRepo = parentRepository(owner, repo, parentOwner, parentRepo) repositoryID, parentIssueID, err := resolveCreateIssueParent(ctx, gqlClient, owner, repo, parentOwner, parentRepo, parentIssueNumber) if err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to resolve parent issue", err), nil + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to resolve parent issue", err), nil, nil } input := CreateIssueInput{ @@ -3079,7 +3111,7 @@ func CreateIssueWithParent( for _, label := range labels { labelID, err := getLabelID(ctx, gqlClient, owner, repo, label) if err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, fmt.Sprintf("failed to resolve label %q", label), err), nil + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, fmt.Sprintf("failed to resolve label %q", label), err), nil, nil } labelIDs = append(labelIDs, labelID) } @@ -3091,7 +3123,7 @@ func CreateIssueWithParent( for _, assignee := range assignees { assigneeID, err := resolveUserID(ctx, gqlClient, assignee) if err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, fmt.Sprintf("failed to resolve assignee %q", assignee), err), nil + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, fmt.Sprintf("failed to resolve assignee %q", assignee), err), nil, nil } assigneeIDs = append(assigneeIDs, assigneeID) } @@ -3101,7 +3133,7 @@ func CreateIssueWithParent( if milestoneNumber != 0 { milestoneID, err := resolveMilestoneID(ctx, gqlClient, owner, repo, milestoneNumber) if err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to resolve milestone", err), nil + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to resolve milestone", err), nil, nil } input.MilestoneID = &milestoneID } @@ -3109,17 +3141,17 @@ func CreateIssueWithParent( if issueType != "" { issueTypeID, resp, err := resolveIssueTypeID(ctx, client, owner, repo, issueType) if err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, fmt.Sprintf("failed to resolve issue type %q", issueType), resp, err), nil + return ghErrors.NewGitHubAPIErrorResponse(ctx, fmt.Sprintf("failed to resolve issue type %q", issueType), resp, err), nil, nil } input.IssueTypeID = &issueTypeID } var mutation createIssueMutation if err := gqlClient.Mutate(ctx, &mutation, input, nil); err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to create issue", err), nil + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to create issue", err), nil, nil } if mutation.CreateIssue.Issue.FullDatabaseID == "" || mutation.CreateIssue.Issue.URL.URL == nil { - return utils.NewToolResultError("failed to create issue: response did not include the created issue"), nil + return utils.NewToolResultError("failed to create issue: response did not include the created issue"), nil, nil } response := MinimalResponse{ @@ -3128,9 +3160,9 @@ func CreateIssueWithParent( } encoded, err := json.Marshal(response) if err != nil { - return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(encoded)), nil + return utils.NewToolResultText(string(encoded)), &response, nil } func parentRepository(owner, repo, parentOwner, parentRepo string) (string, string) { @@ -3283,8 +3315,13 @@ func containsIssueLabel(labels []string, target string) bool { } 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) { + result, _, err := createIssue(ctx, client, owner, repo, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues) + return result, err +} + +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, *MinimalResponse, error) { if title == "" { - return utils.NewToolResultError("missing required parameter: title"), nil + return utils.NewToolResultError("missing required parameter: title"), nil, nil } // Create the issue request @@ -3310,21 +3347,21 @@ func CreateIssue(ctx context.Context, client *github.Client, owner string, repo "failed to create issue", resp, err, - ), nil + ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusCreated { body, err := io.ReadAll(resp.Body) if err != nil { - return utils.NewToolResultErrorFromErr("failed to read response body", err), nil + return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to create issue", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to create issue", resp, body), nil, nil } if len(labels) > 0 { if err := unappliedIssueLabelsError(labels, issue); err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "issue created but requested labels were not fully applied", resp, err), nil + return ghErrors.NewGitHubAPIErrorResponse(ctx, "issue created but requested labels were not fully applied", resp, err), nil, nil } } @@ -3336,10 +3373,10 @@ func CreateIssue(ctx context.Context, client *github.Client, owner string, repo r, err := json.Marshal(minimalResponse) if err != nil { - return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil + return utils.NewToolResultText(string(r)), &minimalResponse, nil } // UpdateIssueOptions controls which optional fields are included in an issue update request. @@ -3353,9 +3390,14 @@ type UpdateIssueOptions struct { } 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, fieldIDsToDelete []int64, state string, stateReason string, duplicateOf int, opts ...UpdateIssueOptions) (*mcp.CallToolResult, error) { + result, _, err := updateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues, fieldIDsToDelete, state, stateReason, duplicateOf, opts...) + return result, err +} + +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, fieldIDsToDelete []int64, state string, stateReason string, duplicateOf int, opts ...UpdateIssueOptions) (*mcp.CallToolResult, *MinimalResponse, error) { // UpdateIssue is exported and may be called without the tool handler. if err := validateDuplicateState(state, stateReason, duplicateOf); err != nil { - return utils.NewToolResultError(err.Error()), nil + return utils.NewToolResultError(err.Error()), nil, nil } updateOptions := UpdateIssueOptions{ @@ -3405,7 +3447,7 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4 // the new ones, then drop anything explicitly deleted. existing, err := fetchExistingIssueFieldValues(ctx, gqlClient, owner, repo, issueNumber) if err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to fetch existing issue field values", err), nil + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to fetch existing issue field values", err), nil, nil } merged := mergeIssueFieldValues(existing, issueFieldValues) if len(fieldIDsToDelete) > 0 { @@ -3445,16 +3487,16 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4 "failed to update issue", resp, err, - ), nil + ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to update issue", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to update issue", resp, body), nil, nil } // Per-field DELETE fallback. The PATCH can't clear field values when the @@ -3490,7 +3532,7 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4 } if len(failedIDs) > 0 { msg := fmt.Sprintf("failed to clear issue field values: failed=%v, cleared=%v", failedIDs, succeededIDs) - return ghErrors.NewGitHubAPIErrorResponse(ctx, msg, firstFailureResp, firstFailureErr), nil + return ghErrors.NewGitHubAPIErrorResponse(ctx, msg, firstFailureResp, firstFailureErr), nil, nil } } @@ -3499,7 +3541,7 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4 // Get target issue ID (and duplicate issue ID if needed) issueID, duplicateIssueID, err := fetchIssueIDs(ctx, gqlClient, owner, repo, issueNumber, duplicateOf) if err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find issues", err), nil + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to find issues", err), nil, nil } switch state { @@ -3520,7 +3562,7 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4 IssueID: issueID, }, nil) if err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to reopen issue", err), nil + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to reopen issue", err), nil, nil } case "closed": // Use CloseIssue mutation for closing @@ -3548,14 +3590,14 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4 err = gqlClient.Mutate(ctx, &mutation, closeInput, nil) if err != nil { - return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to close issue", err), nil + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to close issue", err), nil, nil } } } if updateOptions.LabelsProvided { if err := unappliedIssueLabelsError(labels, updatedIssue); err != nil { - return ghErrors.NewGitHubAPIErrorResponse(ctx, "issue updated but requested labels were not fully applied", resp, err), nil + return ghErrors.NewGitHubAPIErrorResponse(ctx, "issue updated but requested labels were not fully applied", resp, err), nil, nil } } @@ -3567,10 +3609,10 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4 r, err := json.Marshal(minimalResponse) if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil + return utils.NewToolResultText(string(r)), &minimalResponse, nil } func validateDuplicateState(state, stateReason string, duplicateOf int) error { @@ -3625,41 +3667,29 @@ type IssueFieldFilterInput struct { type ListIssuesOutput struct { Issues []ListIssueOutput `json:"issues"` - TotalCount int `json:"totalCount" jsonschema:"Total number of issues matching the filters."` - PageInfo ListIssuePageInfo `json:"pageInfo"` + TotalCount int `json:"totalCount"` + PageInfo MinimalPageInfo `json:"pageInfo"` } type ListIssueOutput struct { Number *int `json:"number,omitempty"` Title *string `json:"title,omitempty"` Body *string `json:"body,omitempty"` - State *string `json:"state,omitempty" jsonschema:"Issue state: OPEN or CLOSED."` + State *string `json:"state,omitempty"` User *MinimalUser `json:"user,omitempty"` Labels *[]string `json:"labels,omitempty"` Assignees *[]string `json:"assignees,omitempty"` - Comments *int `json:"comments,omitempty" jsonschema:"Number of comments on the issue."` - CreatedAt *string `json:"created_at,omitempty" jsonschema:"Creation time in RFC 3339 format."` - UpdatedAt *string `json:"updated_at,omitempty" jsonschema:"Last update time in RFC 3339 format."` + Comments *int `json:"comments,omitempty"` + CreatedAt *string `json:"created_at,omitempty"` + UpdatedAt *string `json:"updated_at,omitempty"` FieldValues *[]MinimalFieldValue `json:"field_values,omitempty"` } -type ListIssuePageInfo struct { - HasNextPage bool `json:"hasNextPage" jsonschema:"Whether another page of results is available."` - HasPreviousPage bool `json:"hasPreviousPage" jsonschema:"Whether a previous page of results is available."` - StartCursor string `json:"startCursor,omitempty" jsonschema:"Cursor for the first issue in this page."` - EndCursor string `json:"endCursor,omitempty" jsonschema:"Cursor for the last issue in this page."` -} - func structuredListIssuesOutput(response MinimalIssuesResponse, fields []string) ListIssuesOutput { output := ListIssuesOutput{ Issues: make([]ListIssueOutput, 0, len(response.Issues)), TotalCount: response.TotalCount, - PageInfo: ListIssuePageInfo{ - HasNextPage: response.PageInfo.HasNextPage, - HasPreviousPage: response.PageInfo.HasPreviousPage, - StartCursor: response.PageInfo.StartCursor, - EndCursor: response.PageInfo.EndCursor, - }, + PageInfo: response.PageInfo, } selected := func(field string) bool { return len(fields) == 0 || slices.Contains(fields, field) diff --git a/pkg/github/typed_consolidated_issue_enrichment_test.go b/pkg/github/typed_consolidated_issue_enrichment_test.go new file mode 100644 index 0000000000..61d29f5712 --- /dev/null +++ b/pkg/github/typed_consolidated_issue_enrichment_test.go @@ -0,0 +1,100 @@ +package github + +import ( + "context" + "fmt" + "net/http" + "testing" + "time" + + "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/google/go-github/v92/github" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTypedIssueReadIFCLabels(t *testing.T) { + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", ""} { + t.Run("protocol="+protocol, func(t *testing.T) { + deps := consolidatedIssueDeps(t) + deps.featureChecker = featureCheckerFor(FeatureFlagIFCLabels) + session, schemas := consolidatedIssueSession(t, deps, protocol, false) + for _, method := range []string{"get", "get_comments", "get_sub_issues", "get_parent", "get_labels"} { + args := map[string]any{"method": method, "owner": "owner", "repo": "repo", "issue_number": 1} + if method == "get_sub_issues" { + args["page"], args["perPage"] = 2, 5 + } + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "issue_read", Arguments: args}) + require.NoError(t, err) + require.False(t, result.IsError) + label := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", label["integrity"]) + assert.Equal(t, "public", label["confidentiality"]) + if schema := schemas["issue_read"]; schema != nil { + require.NotNil(t, result.StructuredContent) + require.NoError(t, schema.Validate(result.StructuredContent)) + } else { + assert.Nil(t, result.StructuredContent) + } + } + }) + } +} + +func TestTypedIssueReadEnrichmentAndLockdown(t *testing.T) { + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", ""} { + for _, lockdown := range []bool{false, true} { + t.Run(fmt.Sprintf("protocol=%s/lockdown=%t", protocol, lockdown), func(t *testing.T) { + issue := &github.Issue{ + Number: new(1), NodeID: new("I_1"), Title: new("Child\u202e"), + State: new("open"), User: &github.User{Login: new("author")}, + IssueFieldValues: []*github.IssueFieldValue{{IssueFieldID: 1, Value: "must not leak REST fields"}}, + } + parent := map[string]any{ + "number": 7, "title": "Parent\u202e", "state": "OPEN", "url": "parent", + "author": map[string]any{"login": "unsafe"}, + "repository": map[string]any{"nameWithOwner": "owner/repo"}, + } + closing := map[string]any{ + "number": 8, "title": "Closing\u202e", "state": "OPEN", "url": "closing", + "author": map[string]any{"login": "unsafe"}, + "repository": map[string]any{"nameWithOwner": "owner/repo"}, + } + matcher := newIssueReadEnrichmentMatcher("I_1", githubv4mock.DataResponse(map[string]any{ + "nodes": []map[string]any{{ + "id": "I_1", "issueFieldValues": map[string]any{"nodes": []any{}}, + "parent": parent, "subIssuesSummary": map[string]any{"total": 2, "completed": 1, "percentCompleted": 50}, + "closedByPullRequestsReferences": map[string]any{"totalCount": 1, "nodes": []any{closing}}, + }}, + })) + rest := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, issue), + }) + deps := BaseDeps{ + Client: mustNewGHClient(t, rest), GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)), + RepoAccessCache: stubRepoAccessCache(mockRESTPermissionServer(t, "read", map[string]string{"author": "write"}), time.Minute), + Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": lockdown}), + } + session, schemas := consolidatedIssueSession(t, deps, protocol, false) + text := `{"number":1,"title":"Child","state":"open","user":{"login":"author"},"assignees":[],"has_parent":true,"has_children":true,` + if !lockdown { + text += `"parent":{"number":7,"title":"Parent","state":"OPEN","url":"parent","repository":"owner/repo"},` + } + text += `"sub_issues_summary":{"total":2,"completed":1,"percent_completed":50},"closed_by_pull_requests":{"total_count":1,"references":[` + if !lockdown { + text += `{"number":8,"title":"Closing","state":"OPEN","url":"closing","repository":"owner/repo"}` + } + text += `]}}` + result := assertConsolidatedResult(t, session, schemas, "issue_read", map[string]any{ + "method": "get", "owner": "owner", "repo": "repo", "issue_number": 1, + }, text) + if protocol == inventory.ProtocolVersionMultiRoundTrip { + assert.JSONEq(t, `{"method":"get","issue":`+text+`}`, mustMarshalJSON(t, result.StructuredContent)) + } + }) + } + } +} diff --git a/pkg/github/typed_consolidated_issue_outputs_test.go b/pkg/github/typed_consolidated_issue_outputs_test.go new file mode 100644 index 0000000000..44501c1527 --- /dev/null +++ b/pkg/github/typed_consolidated_issue_outputs_test.go @@ -0,0 +1,683 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + "strings" + "testing" + "time" + + ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/sanitize" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func consolidatedIssueSession(t *testing.T, deps BaseDeps, protocol string, ui bool) (*mcp.ClientSession, map[string]*jsonschema.Resolved) { + t.Helper() + tools := []inventory.ServerTool{ + IssueRead(translations.NullTranslationHelper), IssueWrite(translations.NullTranslationHelper), + SubIssueWrite(translations.NullTranslationHelper), + } + inv, err := inventory.NewBuilder().SetTools(tools).WithToolsets([]string{"all"}). + WithFeatureChecker(featureCheckerFor()).Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "consolidated-issues", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) { + if protocol == "" { + switch req := request.(type) { + case *mcp.ListToolsRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + case *mcp.CallToolRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + } + } + return next(ghcontext.WithUISupport(ctx, ui), method, request) + } + }) + version := protocol + if version == "" { + version = inventory.ProtocolVersionMultiRoundTrip + } + session := connectCommentVisibilityClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, len(tools)) + schemas := make(map[string]*jsonschema.Resolved) + for _, tool := range list.Tools { + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Nil(t, tool.OutputSchema, tool.Name) + continue + } + require.NotNil(t, tool.OutputSchema) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, tool.OutputSchema)), &schema)) + assert.Equal(t, "object", schema.Type, tool.Name) + assert.NotEmpty(t, schema.Properties["method"], tool.Name) + assertCompactIssueSchema(t, &schema) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + schemas[tool.Name] = resolved + } + return session, schemas +} + +func assertCompactIssueSchema(t *testing.T, schema *jsonschema.Schema) { + t.Helper() + if schema == nil { + return + } + for field, property := range schema.Properties { + if strings.HasSuffix(field, "_url") && field != "html_url" { + t.Errorf("hypermedia URL %q in compact issue output", field) + } + assert.NotEqual(t, "node_id", field) + assert.NotEqual(t, "issue_field_values", field) + assertCompactIssueSchema(t, property) + } + for _, definition := range schema.Definitions { + assertCompactIssueSchema(t, definition) + } + for _, definition := range schema.Defs { + assertCompactIssueSchema(t, definition) + } + assertCompactIssueSchema(t, schema.Items) + for _, variants := range [][]*jsonschema.Schema{schema.OneOf, schema.AnyOf, schema.AllOf} { + for _, variant := range variants { + assertCompactIssueSchema(t, variant) + } + } +} + +func TestCompactIssueOutputSchemasAreCached(t *testing.T) { + assert.Same(t, issueReadOutputSchema(), issueReadOutputSchema()) + assert.Same(t, issueWriteOutputSchema(), issueWriteOutputSchema()) + assert.Same(t, subIssueWriteOutputSchema(), subIssueWriteOutputSchema()) +} + +func TestForbiddenIssueVariantPropertySchema(t *testing.T) { + schema := forbiddenIssueVariantProperty() + assert.JSONEq(t, `{"not":{"description":"Any value."}}`, mustMarshalJSON(t, schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + for _, value := range []any{nil, "", float64(0), false, []any{}, map[string]any{}} { + assert.Error(t, resolved.Validate(value), "%v", value) + } +} + +func assertConsolidatedResult(t *testing.T, session *mcp.ClientSession, schemas map[string]*jsonschema.Resolved, name string, args map[string]any, text string) *mcp.CallToolResult { + t.Helper() + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args}) + require.NoError(t, err) + require.False(t, result.IsError, "%s: %s", name, mustMarshalJSON(t, result)) + require.Len(t, result.Content, 1) + if schema := schemas[name]; schema != nil { + // Nullable method data must still have a present object envelope. + require.NotNil(t, result.StructuredContent) + assert.JSONEq(t, mustMarshalJSON(t, result.StructuredContent), getTextResult(t, result).Text, + "modern text must serialize the same compact DTO as structuredContent") + var value any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &value)) + require.NoError(t, schema.Validate(value)) + } else { + assert.Equal(t, text, getTextResult(t, result).Text, "legacy text must preserve the original formatter output") + assert.Nil(t, result.StructuredContent) + } + return result +} + +func consolidatedIssueDeps(t *testing.T) BaseDeps { + t.Helper() + rest := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.Contains(r.URL.Path, "/comments") { + assert.Equal(t, "1", r.URL.Query().Get("page")) + assert.Equal(t, "30", r.URL.Query().Get("per_page")) + switch { + case strings.Contains(r.URL.Path, "/2/"): + _, _ = w.Write([]byte(`[]`)) + case strings.Contains(r.URL.Path, "/3/"): + _, _ = w.Write([]byte(`null`)) + default: + _, _ = w.Write([]byte(`[{"id":42,"body":"hello","html_url":"https://github.com/owner/repo/issues/1#issuecomment-42"}]`)) + } + return + } + if strings.Contains(r.URL.Path, "/sub_issue") { + if r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/1/") { + assert.Equal(t, "2", r.URL.Query().Get("page")) + assert.Equal(t, "5", r.URL.Query().Get("per_page")) + } + switch { + case strings.Contains(r.URL.Path, "/2/"): + _, _ = w.Write([]byte(`[]`)) + return + case strings.Contains(r.URL.Path, "/3/"): + _, _ = w.Write([]byte(`null`)) + return + case strings.Contains(r.URL.Path, "/4/"): + _, _ = w.Write([]byte(`[null]`)) + return + } + if r.Method == http.MethodPost && !strings.HasSuffix(r.URL.Path, "/priority") { + w.WriteHeader(http.StatusCreated) + } + text := `{"id":7,"number":7,"title":"Child","body":"body","created_at":"2026-01-01T00:00:00Z","issue_field_values":[{"issue_field_id":11,"node_id":"IF_11","data_type":"number","value":0}]}` + if r.Method == http.MethodGet { + text = "[" + text + "]" + } + _, _ = w.Write([]byte(text)) + return + } + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusCreated) + } + _, _ = w.Write([]byte(`{"id":123,"number":1,"title":"Subject","state":"open","html_url":"https://github.com/owner/repo/issues/1","assignees":[],"labels":[]}`)) + })}} + gql := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Query string `json:"query"` + Variables struct { + IssueNumber int `json:"issueNumber"` + Input struct { + IssueID string `json:"issueId"` + StateReason string `json:"stateReason"` + DuplicateIssueID string `json:"duplicateIssueId"` + } `json:"input"` + } `json:"variables"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + switch { + case strings.Contains(req.Query, "createIssue("): + _, _ = w.Write([]byte(`{"data":{"createIssue":{"issue":{"fullDatabaseId":"123","url":"https://github.com/owner/repo/issues/1"}}}}`)) + case strings.Contains(req.Query, "parentRepository:"): + _, _ = w.Write([]byte(`{"data":{"childRepository":{"id":"R_1","nameWithOwner":"owner/repo"},"parentRepository":{"issue":{"id":"I_parent","number":7}}}}`)) + case strings.Contains(req.Query, "parent{"): + parent := `{"number":7,"title":"Parent","state":"OPEN","url":"https://github.com/owner/repo/issues/7","repository":{"nameWithOwner":"owner/repo"},"author":{"login":"octocat"}}` + if req.Variables.IssueNumber == 2 { + parent = "null" + } + _, _ = w.Write([]byte(`{"data":{"repository":{"issue":{"parent":` + parent + `}}}}`)) + case strings.Contains(req.Query, "labels("): + labels := `{"nodes":[{"id":"L_1","name":"bug","color":"red","description":"Bug"}],"totalCount":1}` + if req.Variables.IssueNumber == 2 { + labels = `{"nodes":[],"totalCount":0}` + } + _, _ = w.Write([]byte(`{"data":{"repository":{"issue":{"labels":` + labels + `}}}}`)) + case strings.Contains(req.Query, "reopenIssue("): + assert.Equal(t, "I_1", req.Variables.Input.IssueID) + _, _ = w.Write([]byte(`{"data":{"reopenIssue":{"issue":{"id":"I_1","number":1,"url":"issue","state":"OPEN"}}}}`)) + case strings.Contains(req.Query, "closeIssue("): + assert.Equal(t, "I_1", req.Variables.Input.IssueID) + assert.Contains(t, []string{"COMPLETED", "NOT_PLANNED", "DUPLICATE"}, req.Variables.Input.StateReason) + if req.Variables.Input.StateReason == "DUPLICATE" { + assert.Equal(t, "I_2", req.Variables.Input.DuplicateIssueID) + } + _, _ = w.Write([]byte(`{"data":{"closeIssue":{"issue":{"id":"I_1","number":1,"url":"issue","state":"CLOSED"}}}}`)) + case strings.Contains(req.Query, "issue(number: $issueNumber)"): + if strings.Contains(req.Query, "duplicateIssue:") { + _, _ = w.Write([]byte(`{"data":{"repository":{"issue":{"id":"I_1"},"duplicateIssue":{"id":"I_2"}}}}`)) + } else { + _, _ = w.Write([]byte(`{"data":{"repository":{"issue":{"id":"I_1"}}}}`)) + } + default: + t.Errorf("unexpected GraphQL query: %s", req.Query) + w.WriteHeader(http.StatusInternalServerError) + } + })}} + return BaseDeps{ + Client: mustNewGHClient(t, rest), GQLClient: githubv4.NewClient(gql), + RepoAccessCache: stubRepoAccessCache(nil, time.Minute), + } +} + +func TestTypedConsolidatedIssueOutputs(t *testing.T) { + const mutationText = `{"id":"123","url":"https://github.com/owner/repo/issues/1"}` + const subText = `{"id":7,"number":7,"title":"Child","body":"body","created_at":"2026-01-01T00:00:00Z","issue_field_values":[{"issue_field_id":11,"node_id":"IF_11","data_type":"number","value":0}]}` + const subStructured = `{"id":7,"number":7,"title":"Child","body":"body","created_at":"2026-01-01T00:00:00Z","assignees":[],"field_values":[{"issue_field_id":11,"data_type":"number","value":0}]}` + cases := []struct { + tool string + args map[string]any + text string + }{ + {"issue_read", map[string]any{"method": "get", "issue_number": "1.0"}, `{"number":1,"title":"Subject","state":"open","html_url":"https://github.com/owner/repo/issues/1","assignees":[]}`}, + {"issue_read", map[string]any{"method": "get_comments", "issue_number": "1", "page": "0", "perPage": "0"}, `[{"id":42,"body":"hello","html_url":"https://github.com/owner/repo/issues/1#issuecomment-42"}]`}, + {"issue_read", map[string]any{"method": "get_comments", "issue_number": 2}, `[]`}, + {"issue_read", map[string]any{"method": "get_comments", "issue_number": 3}, `[]`}, + {"issue_read", map[string]any{"method": "get_sub_issues", "issue_number": "1", "page": "2", "perPage": "5"}, `[` + subText + `]`}, + {"issue_read", map[string]any{"method": "get_sub_issues", "issue_number": 2}, `[]`}, + {"issue_read", map[string]any{"method": "get_sub_issues", "issue_number": 3}, `null`}, + {"issue_read", map[string]any{"method": "get_sub_issues", "issue_number": 4}, `[null]`}, + {"issue_read", map[string]any{"method": "get_parent", "issue_number": "1"}, `{"parent":{"number":7,"repository":"owner/repo","state":"OPEN","title":"Parent","url":"https://github.com/owner/repo/issues/7"}}`}, + {"issue_read", map[string]any{"method": "get_parent", "issue_number": 2}, `{"parent":null}`}, + {"issue_read", map[string]any{"method": "get_labels", "issue_number": 1}, `{"labels":[{"color":"red","description":"Bug","id":"L_1","name":"bug"}],"totalCount":1}`}, + {"issue_read", map[string]any{"method": "get_labels", "issue_number": 2}, `{"labels":[],"totalCount":0}`}, + {"issue_write", map[string]any{"method": "create", "title": "Subject"}, mutationText}, + {"issue_write", map[string]any{"method": "update", "issue_number": "1.0"}, mutationText}, + {"issue_write", map[string]any{"method": "update", "issue_number": "1", "state": "open"}, mutationText}, + {"issue_write", map[string]any{"method": "update", "issue_number": 1, "state": "closed", "state_reason": "completed"}, mutationText}, + {"issue_write", map[string]any{"method": "update", "issue_number": 1, "state": "closed", "state_reason": "not_planned"}, mutationText}, + {"issue_write", map[string]any{"method": "update", "issue_number": 1, "state": "closed", "state_reason": "duplicate", "duplicate_of": "2"}, mutationText}, + {"issue_write", map[string]any{"method": "create", "title": "Subject", "parent_issue_number": "7"}, mutationText}, + {"sub_issue_write", map[string]any{"method": "ADD", "issue_number": "1", "sub_issue_id": "7", "replace_parent": true}, subText}, + {"sub_issue_write", map[string]any{"method": "remove", "issue_number": 1, "sub_issue_id": 7}, subText}, + {"sub_issue_write", map[string]any{"method": "reprioritize", "issue_number": 1, "sub_issue_id": 7, "after_id": "8.0"}, subText}, + {"sub_issue_write", map[string]any{"method": "reprioritize", "issue_number": 1, "sub_issue_id": 7, "before_id": "8"}, subText}, + } + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", ""} { + t.Run("protocol="+protocol, func(t *testing.T) { + session, schemas := consolidatedIssueSession(t, consolidatedIssueDeps(t), protocol, false) + for i, tc := range cases { + t.Run(tc.tool+"/"+tc.args["method"].(string)+"/"+string(rune('A'+i)), func(t *testing.T) { + args := map[string]any{"owner": "owner", "repo": "repo"} + maps.Copy(args, tc.args) + result := assertConsolidatedResult(t, session, schemas, tc.tool, args, tc.text) + if protocol == inventory.ProtocolVersionMultiRoundTrip { + want := tc.text + switch want { + case subText: + want = subStructured + case "[" + subText + "]": + want = "[" + subStructured + "]" + } + method := strings.ToLower(tc.args["method"].(string)) + field := "issue" + switch method { + case "get_comments": + field = "comments" + case "get_sub_issues": + field = "sub_issues" + case "get_parent": + field = "parent" + case "get_labels": + field = "labels" + } + if method == "get_parent" || method == "get_labels" { + want = `{"method":"` + method + `",` + strings.TrimPrefix(want, "{") + } else { + want = `{"method":"` + method + `","` + field + `":` + want + `}` + } + assert.JSONEq(t, want, mustMarshalJSON(t, result.StructuredContent)) + } + }) + } + for _, tc := range []struct { + tool string + args map[string]any + text string + }{ + {"issue_read", map[string]any{}, "missing required parameter: method"}, + {"issue_read", map[string]any{"method": "get", "issue_number": 0}, "missing required parameter: issue_number"}, + {"issue_read", map[string]any{"method": "get", "issue_number": "1.5"}, "non-integer numeric value"}, + {"issue_read", map[string]any{"method": "GET", "issue_number": 1}, "get"}, + {"issue_read", map[string]any{"method": "get", "issue_number": 1, "page": nil}, "not a valid number"}, + {"issue_write", map[string]any{"method": "create"}, "missing required parameter: title"}, + {"issue_write", map[string]any{"method": "update"}, "missing required parameter: issue_number"}, + {"issue_write", map[string]any{"method": "update", "issue_number": 1, "type": ""}, "must not be empty"}, + {"issue_write", map[string]any{"method": "update", "issue_number": 1, "labels": 42}, "[]string"}, + {"issue_write", map[string]any{"method": "create", "parent_issue_number": 0}, "greater than 0"}, + {"issue_write", map[string]any{"method": "update", "issue_number": 1, "duplicate_of": 2}, "duplicate_of can only"}, + {"issue_write", map[string]any{"method": "create", "issue_fields": []any{map[string]any{"field_name": "Priority", "value": nil}}}, "value cannot be null"}, + {"sub_issue_write", map[string]any{"method": "add", "issue_number": 1}, "missing required parameter: sub_issue_id"}, + {"sub_issue_write", map[string]any{"method": "reprioritize", "issue_number": 1, "sub_issue_id": 7}, "either after_id or before_id"}, + {"sub_issue_write", map[string]any{"method": "reprioritize", "issue_number": 1, "sub_issue_id": 7, "after_id": 8, "before_id": 9}, "only one"}, + } { + args := map[string]any{"owner": "owner", "repo": "repo"} + maps.Copy(args, tc.args) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool, Arguments: args}) + require.NoError(t, err) + require.True(t, result.IsError, "%s: %s", tc.tool, mustMarshalJSON(t, result)) + assert.Contains(t, getErrorResult(t, result).Text, tc.text) + assert.Nil(t, result.StructuredContent) + } + }) + } +} + +func TestTypedIssueWriteAwaitingForm(t *testing.T) { + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", ""} { + t.Run("protocol="+protocol, func(t *testing.T) { + session, schemas := consolidatedIssueSession(t, consolidatedIssueDeps(t), protocol, true) + for _, method := range []string{"create", "update"} { + args := map[string]any{"method": method, "owner": "owner", "repo": "repo", "issue_number": "1", "title": "Subject"} + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "issue_write", Arguments: args}) + require.NoError(t, err) + require.True(t, result.IsError) + require.Len(t, result.Content, 1) + assert.Contains(t, getErrorResult(t, result).Text, "interactive form has been shown") + assert.Contains(t, getErrorResult(t, result).Text, "NOT been") + if protocol == inventory.ProtocolVersionMultiRoundTrip { + require.NotNil(t, result.StructuredContent) + assert.JSONEq(t, `{"status":"awaiting_user_submission","reason":"An interactive form is being shown to the user. The operation has not been performed."}`, mustMarshalJSON(t, result.StructuredContent)) + require.NoError(t, schemas["issue_write"].Validate(result.StructuredContent)) + } else { + assert.Nil(t, result.StructuredContent) + } + args["_ui_submitted"] = true + assertConsolidatedResult(t, session, schemas, "issue_write", args, `{"id":"123","url":"https://github.com/owner/repo/issues/1"}`) + } + }) + } +} + +func TestTypedConsolidatedIssueAPIErrors(t *testing.T) { + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", ""} { + t.Run("protocol="+protocol, func(t *testing.T) { + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"Forbidden"}`)) + })}} + deps := BaseDeps{Client: mustNewGHClient(t, client), GQLClient: githubv4.NewClient(client)} + session, _ := consolidatedIssueSession(t, deps, protocol, false) + for tool, methods := range map[string][]string{ + "issue_read": {"get", "get_comments", "get_sub_issues", "get_parent", "get_labels"}, + "issue_write": {"create", "update"}, "sub_issue_write": {"add", "remove", "reprioritize"}, + } { + for _, method := range methods { + args := map[string]any{ + "method": method, "owner": "owner", "repo": "repo", "issue_number": 1, + "title": "Subject", "sub_issue_id": 7, "after_id": 8, + } + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tool, Arguments: args}) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Nil(t, result.StructuredContent, tool+"/"+method) + assert.Contains(t, getErrorResult(t, result).Text, "Forbidden") + } + } + }) + } +} + +func TestTypedConsolidatedNullMutationResponses(t *testing.T) { + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", ""} { + for _, payload := range []string{`{}`, `null`} { + t.Run("protocol="+protocol+"/payload="+payload, func(t *testing.T) { + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost && !strings.HasSuffix(r.URL.Path, "/priority") { + w.WriteHeader(http.StatusCreated) + } + _, _ = w.Write([]byte(payload)) + })}} + deps := BaseDeps{Client: mustNewGHClient(t, client), GQLClient: githubv4.NewClient(client)} + session, schemas := consolidatedIssueSession(t, deps, protocol, false) + for _, method := range []string{"create", "update"} { + assertConsolidatedResult(t, session, schemas, "issue_write", map[string]any{ + "method": method, "owner": "owner", "repo": "repo", "issue_number": 1, "title": "Subject", + }, `{"id":"0","url":""}`) + } + for _, method := range []string{"add", "remove", "reprioritize"} { + result := assertConsolidatedResult(t, session, schemas, "sub_issue_write", map[string]any{ + "method": method, "owner": "owner", "repo": "repo", "issue_number": 1, "sub_issue_id": 7, "after_id": 8, + }, payload) + if protocol == inventory.ProtocolVersionMultiRoundTrip { + data := `{"assignees":[]}` + if payload == "null" { + data = "null" + } + assert.JSONEq(t, `{"method":"`+method+`","issue":`+data+`}`, mustMarshalJSON(t, result.StructuredContent)) + } + } + }) + } + } +} + +func TestConsolidatedIssueStrictSchemasAndScalars(t *testing.T) { + fieldSchema := *IssueWrite(translations.NullTranslationHelper).Tool.InputSchema.(*jsonschema.Schema).Properties["issue_fields"].Items + fieldSchema.OneOf = issueWriteFieldVariants() + for _, tc := range []struct { + schema *jsonschema.Schema + valid []string + invalid []string + }{ + { + issueReadOutputSchema(), + []string{`{"method":"get","issue":null}`, `{"method":"get_comments","comments":[]}`, `{"method":"get_sub_issues","sub_issues":[null]}`, `{"method":"get_parent","parent":null}`, `{"method":"get","issue":{"number":0,"title":"","state":"","assignees":[]}}`, `{"method":"get_labels","labels":[],"totalCount":0}`}, + []string{`null`, `[]`, `{}`, `{"method":"get"}`, `{"method":"get","comments":[]}`, `{"method":"get","issue":{"number":1}}`, `{"method":"get_parent","parent":{"number":1}}`, `{"method":"get_labels","labels":[]}`, `{"method":"get","issue":null,"comments":[]}`}, + }, + { + issueWriteOutputSchema(), + []string{`{"method":"create","issue":{"id":"","url":""}}`, `{"status":"awaiting_user_submission","reason":"wait"}`}, + []string{`{}`, `{"method":"create","issue":{"id":"1"}}`, `{"status":"created","reason":"wait"}`, `{"status":"awaiting_user_submission"}`, `{"method":"create","issue":{"id":"1","url":"url"},"status":"awaiting_user_submission","reason":"wait"}`}, + }, + { + subIssueWriteOutputSchema(), + []string{ + `{"method":"add","issue":null}`, + `{"method":"remove","issue":{"assignees":[]}}`, + `{"method":"reprioritize","issue":{"state":"closed","state_reason":"duplicate","assignees":[]}}`, + `{"method":"add","issue":{"assignees":[],"field_values":[null,{"issue_field_id":1,"data_type":"date","value":"2026-01-01"}]}}`, + }, + []string{ + `null`, `{}`, `{"method":"add"}`, `{"method":"get","issue":null}`, + `{"method":"add","issue":{"assignees":[],"state":"OPEN"}}`, + `{"method":"add","issue":{"assignees":[],"state_reason":"unknown"}}`, + `{"method":"add","issue":{"assignees":[],"url":"api"}}`, + `{"method":"add","issue":{"assignees":[],"field_values":[{"issue_field_id":1,"data_type":"unknown","value":1}]}}`, + `{"method":"add","issue":{"assignees":[],"field_values":[{"issue_field_id":1,"data_type":"number","value":{}}]}}`, + }, + }, + { + &fieldSchema, + []string{`{"field_name":"Text","value":""}`, `{"field_name":"Number","value":0,"delete":false}`, `{"field_name":"Date","value":"2026-01-01"}`, `{"field_name":"Priority","field_option_name":"High"}`, `{"field_name":"Text","delete":true}`}, + []string{`{"field_name":"Text"}`, `{"field_name":"Text","value":null}`, `{"field_name":"Text","value":1,"delete":true}`, `{"field_name":"Text","value":1,"field_option_name":"High"}`, `{"field_name":"Text","delete":false}`, `{"value":1}`}, + }, + } { + resolved, err := tc.schema.Resolve(nil) + require.NoError(t, err) + for _, raw := range tc.valid { + var value any + require.NoError(t, json.Unmarshal([]byte(raw), &value)) + require.NoError(t, resolved.Validate(value), raw) + } + for _, raw := range tc.invalid { + var value any + require.NoError(t, json.Unmarshal([]byte(raw), &value)) + require.Error(t, resolved.Validate(value), raw) + } + } + for _, raw := range []string{`""`, `"2026-01-01"`, `"High"`, `0`, `1.5`, `false`, `null`} { + var value IssueFieldValue + require.NoError(t, json.Unmarshal([]byte(raw), &value)) + assert.JSONEq(t, raw, mustMarshalJSON(t, value)) + } + var value IssueFieldValue + require.Error(t, json.Unmarshal([]byte(`{}`), &value)) + require.Error(t, json.Unmarshal([]byte(`[]`), &value)) +} + +func TestSubIssueOutputProjectsCompleteAPIResponse(t *testing.T) { + // Every exported API field is populated, including false/zero pointers, + // nullable collection elements and all custom-field scalar variants. + var issue github.SubIssue + require.NoError(t, json.Unmarshal([]byte(`{ + "id":1,"number":1,"state":"open","state_reason":"reopened","locked":false, + "title":"Child\u202e","body":"body\u202e","author_association":"OWNER", + "user":{"login":"octo","id":1},"labels":[null,{"name":"bug","id":2}], + "assignee":{"login":"octo"},"comments":0,"closed_at":"2026-01-01T00:00:00Z", + "created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z", + "closed_by":{"login":"octo"},"url":"api","html_url":"html","comments_url":"comments", + "events_url":"events","labels_url":"labels","repository_url":"repo","parent_issue_url":"parent", + "milestone":{"title":"v1","number":1},"pull_request":{"url":"pr"}, + "repository":{"id":1,"name":"repo","owner":{"login":"octo"},"topics":["topic"]}, + "reactions":{"total_count":0},"assignees":[null,{"login":"octo"}], + "node_id":"I_1","draft":false,"type":{"id":1,"name":"Bug"}, + "pinned_comment":{"id":1,"body":"comment"},"performed_via_github_app":{"id":1,"permissions":{"issues":"write"}}, + "issue_dependencies_summary":{"blocked_by":0,"blocking":1}, + "sub_issues_summary":{"total":1,"completed":0,"percent_completed":0}, + "issue_field_values":[null, + {"issue_field_id":1,"node_id":"IF_1","data_type":"text","value":""}, + {"issue_field_id":2,"node_id":"IF_2","data_type":"number","value":0}, + {"issue_field_id":3,"node_id":"IF_3","data_type":"date","value":"2026-01-01"}, + {"issue_field_id":4,"node_id":"IF_4","data_type":"single_select","value":"High","single_select_option":{"id":1,"name":"High","color":"red"}}, + {"issue_field_id":5,"node_id":"IF_5","data_type":"text","value":null} + ], + "text_matches":[{"fragment":"match","matches":[{"text":"match","indices":[0,5]}]}], + "active_lock_reason":"resolved" + }`), &issue)) + sanitizeSubIssueTitleAndBody(&issue) + out, err := subIssueOutput(&issue) + require.NoError(t, err) + assert.Equal(t, sanitize.PlainText("Child\u202e"), *out.Title) + assert.JSONEq(t, `{ + "id":1,"number":1,"title":"Child","body":"body","state":"open","state_reason":"reopened", + "html_url":"html","user":{"login":"octo","id":1},"labels":["bug"],"assignees":["octo"], + "milestone":"v1","comments":0,"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","closed_at":"2026-01-01T00:00:00Z", + "issue_type":"Bug","sub_issues_summary":{"total":1,"completed":0,"percent_completed":0}, + "field_values":[null,{"issue_field_id":1,"data_type":"text","value":""},{"issue_field_id":2,"data_type":"number","value":0}, + {"issue_field_id":3,"data_type":"date","value":"2026-01-01"},{"issue_field_id":4,"data_type":"single_select","value":"High"}, + {"issue_field_id":5,"data_type":"text","value":null}] + }`, mustMarshalJSON(t, out)) + resolved, err := subIssueSchema().Resolve(nil) + require.NoError(t, err) + var value any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, out)), &value)) + require.NoError(t, resolved.Validate(value)) +} + +func TestTypedIssueCommentsCompactUsersAndLockdown(t *testing.T) { + const comment = `{"id":42,"body":"hello\u202e","html_url":"comment","url":"api-comment","node_id":"IC_42","user":{"login":"author","id":7,"html_url":"profile","avatar_url":"avatar"}}` + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", ""} { + for _, lockdown := range []bool{false, true} { + t.Run(fmt.Sprintf("protocol=%s/lockdown=%t", protocol, lockdown), func(t *testing.T) { + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`[` + comment + `,{"id":43,"body":"restricted","html_url":"unsafe","user":{"login":"unsafe"}}]`)) + })}} + deps := BaseDeps{ + Client: mustNewGHClient(t, client), GQLClient: githubv4.NewClient(client), + RepoAccessCache: stubRepoAccessCache(mockRESTPermissionServer(t, "read", map[string]string{"author": "write"}), time.Minute), + Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": lockdown}), + } + session, schemas := consolidatedIssueSession(t, deps, protocol, false) + text := `[{"id":42,"body":"hello","html_url":"comment","user":{"login":"author","id":7,"profile_url":"profile","avatar_url":"avatar"}}` + projected := `{"method":"get_comments","comments":[{"id":42,"body":"hello","html_url":"comment","user":{"login":"author","id":7}}` + if !lockdown { + unsafe := `,{"id":43,"body":"restricted","html_url":"unsafe","user":{"login":"unsafe"}}` + text += unsafe + projected += unsafe + } + result := assertConsolidatedResult(t, session, schemas, "issue_read", map[string]any{ + "method": "get_comments", "owner": "owner", "repo": "repo", "issue_number": 1, + }, text+`]`) + if protocol == inventory.ProtocolVersionMultiRoundTrip { + assert.JSONEq(t, projected+`]}`, mustMarshalJSON(t, result.StructuredContent)) + } + }) + } + } +} + +func TestTypedIssueWriteMutationOptionalityAndFields(t *testing.T) { + const resultText = `{"id":"123","url":"https://github.com/owner/repo/issues/1"}` + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", ""} { + t.Run("protocol="+protocol, func(t *testing.T) { + var requestBody string + var deleted []string + rest := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + deleted = append(deleted, r.URL.Path) + w.WriteHeader(http.StatusNoContent) + return + } + + var body json.RawMessage + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + requestBody = string(body) + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusCreated) + } + var sent struct { + Labels []string `json:"labels"` + } + require.NoError(t, json.Unmarshal(body, &sent)) + response := &github.Issue{ID: new(int64(123)), HTMLURL: new("https://github.com/owner/repo/issues/1")} + for _, label := range sent.Labels { + response.Labels = append(response.Labels, &github.Label{Name: label}) + } + require.NoError(t, json.NewEncoder(w).Encode(response)) + })}} + existing := `[{"__typename":"IssueFieldTextValue","field":{"fullDatabaseId":"1","name":"Text"},"value":"old"},{"__typename":"IssueFieldDateValue","field":{"fullDatabaseId":"3","name":"Date"},"value":"2026-01-01"}]` + gql := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + Query string `json:"query"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + switch { + case strings.Contains(request.Query, "issueFields("): + _, _ = w.Write([]byte(`{"data":{"repository":{"issueFields":{"nodes":[ + {"__typename":"IssueFieldText","fullDatabaseId":"1","name":"Text","dataType":"TEXT"}, + {"__typename":"IssueFieldNumber","fullDatabaseId":"2","name":"Number","dataType":"NUMBER"}, + {"__typename":"IssueFieldDate","fullDatabaseId":"3","name":"Date","dataType":"DATE"}, + {"__typename":"IssueFieldSingleSelect","fullDatabaseId":"4","name":"Priority","dataType":"SINGLE_SELECT","options":[{"fullDatabaseId":"40","name":"High"}]} + ]}}}}`)) + case strings.Contains(request.Query, "issueFieldValues("): + _, _ = w.Write([]byte(`{"data":{"repository":{"issue":{"issueFieldValues":{"nodes":` + existing + `}}}}}`)) + default: + t.Errorf("unexpected GraphQL mutation/query: %s", request.Query) + } + })}} + deps := BaseDeps{Client: mustNewGHClient(t, rest), GQLClient: githubv4.NewClient(gql)} + session, schemas := consolidatedIssueSession(t, deps, protocol, false) + cases := []struct { + args map[string]any + body string + }{ + {map[string]any{}, `{}`}, + {map[string]any{"labels": []string{}, "assignees": []string{}, "type": nil, "milestone": "0"}, `{"labels":[],"assignees":[],"type":null}`}, + {map[string]any{"labels": nil, "assignees": nil}, `{}`}, + {map[string]any{"labels": []string{"bug"}, "assignees": []string{"octo"}, "type": "Bug", "milestone": "2.0"}, `{"labels":["bug"],"assignees":["octo"],"type":"Bug","milestone":2}`}, + {map[string]any{"issue_fields": []any{ + map[string]any{"field_name": "Number", "value": 0}, + map[string]any{"field_name": "Text", "delete": true}, + }}, `{"issue_field_values":[{"field_id":2,"value":0},{"field_id":3,"value":"2026-01-01"}]}`}, + {map[string]any{"method": "create", "title": "Subject", "body": "", "issue_fields": []any{ + map[string]any{"field_name": " text ", "value": ""}, + map[string]any{"field_name": "Number", "value": 0}, + map[string]any{"field_name": "Date", "value": "2026-01-01"}, + map[string]any{"field_name": "Priority", "field_option_name": " high "}, + }}, `{"title":"Subject","body":"","issue_field_values":[{"field_id":1,"value":""},{"field_id":2,"value":0},{"field_id":3,"value":"2026-01-01"},{"field_id":4,"value":"High"}]}`}, + {map[string]any{"method": "create", "title": "Subject", "issue_fields": []any{ + map[string]any{"field_name": "Text", "delete": true}, + }}, `{"title":"Subject","body":""}`}, + } + for _, tc := range cases { + args := map[string]any{"method": "update", "owner": "owner", "repo": "repo", "issue_number": "1"} + maps.Copy(args, tc.args) + assertConsolidatedResult(t, session, schemas, "issue_write", args, resultText) + assert.JSONEq(t, tc.body, requestBody) + } + existing = `[{"__typename":"IssueFieldTextValue","field":{"fullDatabaseId":"1","name":"Text"},"value":"old"}]` + assertConsolidatedResult(t, session, schemas, "issue_write", map[string]any{ + "method": "update", "owner": "owner", "repo": "repo", "issue_number": 1, + "issue_fields": []any{map[string]any{"field_name": "Text", "delete": true}}, + }, resultText) + assert.JSONEq(t, `{}`, requestBody) + assert.Equal(t, []string{"/repos/owner/repo/issues/1/issue-field-values/1"}, deleted) + for _, fields := range []any{ + []any{map[string]any{"field_name": "Missing", "value": "value"}}, + []any{map[string]any{"field_name": "Text", "field_option_name": "High"}}, + []any{map[string]any{"field_name": "Priority", "field_option_name": "Missing"}}, + } { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "issue_write", Arguments: map[string]any{ + "method": "create", "owner": "owner", "repo": "repo", "title": "Subject", "issue_fields": fields, + }}) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Nil(t, result.StructuredContent) + assert.Contains(t, getErrorResult(t, result).Text, "failed to resolve issue_fields") + } + }) + } +} diff --git a/pkg/github/typed_issue_outputs_test.go b/pkg/github/typed_issue_outputs_test.go index 937e15f79c..4c7a5ec9e3 100644 --- a/pkg/github/typed_issue_outputs_test.go +++ b/pkg/github/typed_issue_outputs_test.go @@ -107,7 +107,7 @@ func TestTypedIssueOutputs(t *testing.T) { text string structured string }{ - {name: "issue_read", args: map[string]any{"method": "get", "owner": "owner", "repo": "repo", "issue_number": "7.0", "page": "0", "perPage": "0"}, text: `{"number":7,"title":"Blocker","state":"open","html_url":"https://github.com/owner/repo/issues/7","assignees":[]}`, structured: `{"number":7,"title":"Blocker","state":"open","html_url":"https://github.com/owner/repo/issues/7","assignees":[]}`}, + {name: "issue_read", args: map[string]any{"method": "get", "owner": "owner", "repo": "repo", "issue_number": "7.0", "page": "0", "perPage": "0"}, text: `{"number":7,"title":"Blocker","state":"open","html_url":"https://github.com/owner/repo/issues/7","assignees":[]}`, structured: `{"method":"get","issue":{"number":7,"title":"Blocker","state":"open","html_url":"https://github.com/owner/repo/issues/7","assignees":[]}}`}, {name: "list_issue_types", args: map[string]any{"owner": "owner"}, text: `[{"id":1,"name":"Bug","is_enabled":false}]`, structured: `[{"id":1,"name":"Bug","is_enabled":false}]`}, {name: "list_issue_types", args: map[string]any{"owner": "owner", "repo": "repo"}, text: `[{"id":1,"name":"Bug","is_enabled":false}]`, structured: `[{"id":1,"name":"Bug","is_enabled":false}]`}, {name: "list_issue_fields", args: map[string]any{"owner": "owner"}, text: `[{"id":"IF_1","full_database_id":99,"name":"Priority","description":"Importance","data_type":"SINGLE_SELECT","visibility":"ALL","options":[{"id":"OPT_1","name":"High","color":"red","priority":0}]}]`, structured: `[{"id":"IF_1","full_database_id":99,"name":"Priority","description":"Importance","data_type":"SINGLE_SELECT","visibility":"ALL","options":[{"id":"OPT_1","name":"High","color":"red","priority":0}]}]`}, @@ -186,14 +186,17 @@ func TestTypedIssueOutputs(t *testing.T) { require.NoError(t, err, call.name) require.False(t, result.IsError, "%s: %s", call.name, result) require.Len(t, result.Content, 1, call.name) - assert.Equal(t, call.text, getTextResult(t, result).Text, call.name) if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Equal(t, call.text, getTextResult(t, result).Text, call.name) assert.Nil(t, result.StructuredContent, call.name) continue } require.NotNil(t, result.StructuredContent, call.name) structured := mustMarshalJSON(t, result.StructuredContent) assert.JSONEq(t, call.structured, structured) + if call.name == "issue_read" || call.name == "add_issue_comment" { + assert.JSONEq(t, structured, getTextResult(t, result).Text, call.name) + } var output any require.NoError(t, json.Unmarshal([]byte(structured), &output)) require.NoError(t, schemas[call.name].Validate(output), call.name) @@ -352,8 +355,9 @@ func TestTypedSubIssueWriteProtocols(t *testing.T) { } legacy, err := json.Marshal(expectedIssue) require.NoError(t, err) - minimal := convertToMinimalIssue((*github.Issue)(expectedIssue)) - structured, err := json.Marshal(minimal) + subIssue, err := subIssueOutput(expectedIssue) + require.NoError(t, err) + structured, err := json.Marshal(SubIssueWriteOutput{Method: "add", Issue: subIssue}) require.NoError(t, err) for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { @@ -389,13 +393,14 @@ func TestTypedSubIssueWriteProtocols(t *testing.T) { }) require.NoError(t, err) require.False(t, result.IsError) - assert.Equal(t, string(legacy), getTextResult(t, result).Text) if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Equal(t, string(legacy), getTextResult(t, result).Text) assert.Nil(t, result.StructuredContent) return } require.NotNil(t, result.StructuredContent) assert.JSONEq(t, string(structured), mustMarshalJSON(t, result.StructuredContent)) + assert.JSONEq(t, string(structured), getTextResult(t, result).Text) invalid, err := session.CallTool(context.Background(), &mcp.CallToolParams{ Name: "sub_issue_write", diff --git a/pkg/github/typed_search_outputs_test.go b/pkg/github/typed_search_outputs_test.go index 24cfc77fa8..e78097bad3 100644 --- a/pkg/github/typed_search_outputs_test.go +++ b/pkg/github/typed_search_outputs_test.go @@ -335,7 +335,7 @@ func TestTypedProjectedReadOutputsRespectFieldSelection(t *testing.T) { emptyFieldValues := structuredSearchIssuesOutput(SearchIssuesResponse{ Items: []SearchIssueResult{{Issue: &github.Issue{}, FieldValues: []MinimalFieldValue{}}}, }, []string{"field_values"}) - assert.Equal(t, `{"items":[{"field_values":[]}]}`, mustMarshalJSON(t, emptyFieldValues)) + assert.Equal(t, `{"items":[{}]}`, mustMarshalJSON(t, emptyFieldValues)) unavailableFieldValues := structuredSearchIssuesOutput(SearchIssuesResponse{ Items: []SearchIssueResult{{Issue: &github.Issue{}}}, diff --git a/pkg/inventory/typed_output_test.go b/pkg/inventory/typed_output_test.go index 135605c5b0..707e97199e 100644 --- a/pkg/inventory/typed_output_test.go +++ b/pkg/inventory/typed_output_test.go @@ -31,6 +31,92 @@ type typedTestOutput struct { Query string `json:"query"` } +func TestTypedExplicitErrorOutputProtocolGate(t *testing.T) { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "query": {Type: "string"}, + }, + Required: []string{"query"}, + } + registration := &typedToolRegistration{ + name: "pending", + modernTool: &mcp.Tool{Name: "pending", OutputSchema: schema}, + legacyTool: &mcp.Tool{Name: "pending"}, + hasTypedOutput: true, + } + middleware := typedOutputMiddleware(&typedToolRegistrationSet{ + byName: map[string]*typedToolRegistration{"pending": registration}, + }) + + for _, tc := range []struct { + name string + protocol string + result *mcp.CallToolResult + wantStructured any + wantInputRequests bool + }{ + { + name: "modern explicit awaiting status", + protocol: ProtocolVersionMultiRoundTrip, + result: &mcp.CallToolResult{ + IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: "wait for input"}}, + StructuredContent: typedTestOutput{Query: "awaiting"}, + }, + wantStructured: typedTestOutput{Query: "awaiting"}, + }, + { + name: "legacy omits awaiting status", + protocol: "2025-11-25", + result: &mcp.CallToolResult{ + IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: "wait for input"}}, + StructuredContent: typedTestOutput{Query: "awaiting"}, + }, + }, + { + name: "ordinary typed error", + protocol: ProtocolVersionMultiRoundTrip, + result: &mcp.CallToolResult{ + IsError: true, Content: []mcp.Content{&mcp.TextContent{Text: "request failed"}}, + }, + }, + { + name: "modern elicitation retains status and request", + protocol: ProtocolVersionMultiRoundTrip, + result: &mcp.CallToolResult{ + Content: []mcp.Content{&mcp.TextContent{Text: "wait for input"}}, + StructuredContent: typedTestOutput{Query: "awaiting"}, + InputRequests: mcp.InputRequestMap{"form": &mcp.ElicitParams{Mode: "form"}}, + }, + wantStructured: typedTestOutput{Query: "awaiting"}, + wantInputRequests: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + handler := middleware(func(context.Context, string, mcp.Request) (mcp.Result, error) { + return tc.result, nil + }) + result, err := handler(context.Background(), MCPMethodToolsCall, &mcp.CallToolRequest{ + Params: &mcp.CallToolParamsRaw{ + Name: "pending", + Meta: mcp.Meta{mcp.MetaKeyProtocolVersion: tc.protocol}, + }, + }) + require.NoError(t, err) + callResult, ok := result.(*mcp.CallToolResult) + require.True(t, ok) + require.True(t, callResult.IsError || callResult.InputRequests != nil) + assert.Equal(t, tc.wantStructured, callResult.StructuredContent) + if tc.wantInputRequests { + assert.Equal(t, tc.result.InputRequests, callResult.InputRequests) + } else { + assert.Nil(t, callResult.InputRequests) + } + assert.Equal(t, tc.result.Content, callResult.Content) + }) + } +} + func TestTypedToolRegistrationInfersSchemasAndValidates(t *testing.T) { handlerCalls := 0 tool := NewServerToolWithContextHandler( From d6a8ac140d74ecf34f9b4e3c0dadccb3c9527358 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 7 Oct 2026 00:13:54 +0200 Subject: [PATCH 53/61] Type granular issue MCP inputs and protocol-gated outputs (#3394) * feat(repos): complete typed repository tool outputs Migrate the remaining 17 repository tools to concrete inputs and outputs. Preserve existing content, mutations, scopes, filtering, and protocol gating; add wire and schema conformance coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore(repos): exclude unrelated snapshot formatting drift Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(repos): compact typed repository outputs Replace raw REST output schemas with compact repository DTOs, retain one canonical snapshot per tool, and align modern JSON text with structured output while pinning legacy content. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(issues): add typed consolidated issue tool contracts Preserve raw helper APIs and exact legacy text while exposing concrete output unions for modern protocols. Retain explicit awaiting statuses and real null responses without exposing SDK-generated error fallbacks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * refactor(issues): minimize consolidated typed outputs Project compact method-discriminated DTOs from API responses while preserving the separate legacy text formatter and explicit app-awaiting status. Cache strict schemas and keep only canonical snapshots. Strengthen null-member presence, schema size, and lockdown projection regressions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(issues): keep union property schemas portable Preserve forbidden variant properties as annotated schema objects rather than boolean schemas emitted by jsonschema-go. Cover Inspector's input paths and IFC labels on every issue-read method across protocols. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(issues): cover strict sub-issue output variants Reject unknown methods, states, field types, opaque values and API URL properties while covering nullable and empty method data. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(issues): pin compact comment user redaction Pin unchanged legacy profile/avatar fields separately from compact modern user projections and assert identical lockdown filtering and sanitization across protocols. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(issues): restore main-identical advertised input schema Remove added custom-field union constraints and sentinel enums from the advertised issue-write input. Preserve runtime exactly-one validation and private strict-schema tests. All three scoped input schemas compare exactly equal to main. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(issues): restore protocol output parity Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * refactor(issues): minimize granular structured outputs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> # Conflicts: # pkg/github/__toolsnaps__/add_sub_issue.snap # pkg/github/__toolsnaps__/remove_sub_issue.snap # pkg/github/__toolsnaps__/reprioritize_sub_issue.snap # pkg/github/granular_issue_types.go # pkg/github/issues_granular.go # pkg/github/typed_granular_issue_outputs_test.go # Please enter the commit message for your changes. Lines starting # with '#' will be ignored, and an empty message aborts the commit. # # interactive rebase in progress; onto 1973eeea # Last command done (1 command done): # pick 5b8f9d19 # refactor(issues): minimize granular structured outputs # No commands remaining. # You are currently rebasing. # # Changes to be committed: # modified: pkg/github/__toolsnaps__/add_issue_comment_reaction.snap # modified: pkg/github/__toolsnaps__/add_issue_reaction.snap # modified: pkg/github/__toolsnaps__/add_sub_issue.snap # modified: pkg/github/__toolsnaps__/create_issue.snap # modified: pkg/github/__toolsnaps__/hide_issue_comment.snap # modified: pkg/github/__toolsnaps__/remove_issue_comment_reaction.snap # modified: pkg/github/__toolsnaps__/remove_issue_reaction.snap # modified: pkg/github/__toolsnaps__/remove_sub_issue.snap # modified: pkg/github/__toolsnaps__/reprioritize_sub_issue.snap # modified: pkg/github/__toolsnaps__/set_issue_fields.snap # modified: pkg/github/__toolsnaps__/unhide_issue_comment.snap # modified: pkg/github/__toolsnaps__/update_issue_assignees.snap # modified: pkg/github/__toolsnaps__/update_issue_body.snap # modified: pkg/github/__toolsnaps__/update_issue_labels.snap # modified: pkg/github/__toolsnaps__/update_issue_milestone.snap # modified: pkg/github/__toolsnaps__/update_issue_state.snap # modified: pkg/github/__toolsnaps__/update_issue_title.snap # modified: pkg/github/__toolsnaps__/update_issue_type.snap # modified: pkg/github/comment_minimize_test.go # new file: pkg/github/granular_issue_types.go # modified: pkg/github/granular_tools_test.go # modified: pkg/github/issues_granular.go # new file: pkg/github/typed_granular_issue_outputs_test.go # --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] --- .../add_issue_comment_reaction.snap | 39 +- .../__toolsnaps__/add_issue_reaction.snap | 39 +- pkg/github/__toolsnaps__/add_sub_issue.snap | 232 +++- pkg/github/__toolsnaps__/create_issue.snap | 39 +- .../__toolsnaps__/hide_issue_comment.snap | 18 +- .../remove_issue_comment_reaction.snap | 35 +- .../__toolsnaps__/remove_issue_reaction.snap | 35 +- .../__toolsnaps__/remove_sub_issue.snap | 232 +++- .../__toolsnaps__/reprioritize_sub_issue.snap | 232 +++- .../__toolsnaps__/set_issue_fields.snap | 39 +- .../__toolsnaps__/unhide_issue_comment.snap | 18 +- .../__toolsnaps__/update_issue_assignees.snap | 39 +- .../__toolsnaps__/update_issue_body.snap | 39 +- .../__toolsnaps__/update_issue_labels.snap | 39 +- .../__toolsnaps__/update_issue_milestone.snap | 39 +- .../__toolsnaps__/update_issue_state.snap | 39 +- .../__toolsnaps__/update_issue_title.snap | 39 +- .../__toolsnaps__/update_issue_type.snap | 39 +- pkg/github/comment_minimize_test.go | 4 +- pkg/github/granular_issue_types.go | 1063 +++++++++++++++++ pkg/github/granular_tools_test.go | 6 +- pkg/github/issues_granular.go | 781 ++---------- .../typed_granular_issue_outputs_test.go | 466 ++++++++ 23 files changed, 2829 insertions(+), 722 deletions(-) create mode 100644 pkg/github/granular_issue_types.go create mode 100644 pkg/github/typed_granular_issue_outputs_test.go diff --git a/pkg/github/__toolsnaps__/add_issue_comment_reaction.snap b/pkg/github/__toolsnaps__/add_issue_comment_reaction.snap index fea8434a62..8cad3f22b4 100644 --- a/pkg/github/__toolsnaps__/add_issue_comment_reaction.snap +++ b/pkg/github/__toolsnaps__/add_issue_comment_reaction.snap @@ -7,6 +7,18 @@ "title": "Add Reaction to Issue or Pull Request Comment" }, "description": "Add a reaction to an issue or pull request comment.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "comment_id": { @@ -30,11 +42,13 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -45,5 +59,24 @@ ], "type": "object" }, - "name": "add_issue_comment_reaction" + "name": "add_issue_comment_reaction", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/add_issue_reaction.snap b/pkg/github/__toolsnaps__/add_issue_reaction.snap index 49175d1412..de42bb0765 100644 --- a/pkg/github/__toolsnaps__/add_issue_reaction.snap +++ b/pkg/github/__toolsnaps__/add_issue_reaction.snap @@ -7,6 +7,18 @@ "title": "Add Reaction to Issue or Pull Request" }, "description": "Add a reaction to an issue or pull request.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "content": { @@ -30,11 +42,13 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -45,5 +59,24 @@ ], "type": "object" }, - "name": "add_issue_reaction" + "name": "add_issue_reaction", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/add_sub_issue.snap b/pkg/github/__toolsnaps__/add_sub_issue.snap index a232507d4f..9f125b956e 100644 --- a/pkg/github/__toolsnaps__/add_sub_issue.snap +++ b/pkg/github/__toolsnaps__/add_sub_issue.snap @@ -7,6 +7,18 @@ "title": "Add Sub-Issue" }, "description": "Add a sub-issue to a parent issue.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "issue_number": { @@ -16,7 +28,8 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "replace_parent": { "description": "If true, reparent the sub-issue if it already has a parent", @@ -24,7 +37,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "sub_issue_id": { "description": "The ID of the sub-issue to add. ID is not the same as issue number", @@ -39,5 +53,217 @@ ], "type": "object" }, - "name": "add_sub_issue" + "name": "add_sub_issue", + "outputSchema": { + "additionalProperties": false, + "properties": { + "issue": { + "additionalProperties": false, + "properties": { + "assignees": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "body": { + "type": [ + "null", + "string" + ] + }, + "closed_at": { + "description": "Closure time (RFC 3339), when closed.", + "type": "string" + }, + "comments": { + "description": "Number of comments.", + "type": [ + "null", + "integer" + ] + }, + "created_at": { + "description": "Creation time (RFC 3339).", + "type": "string" + }, + "field_values": { + "items": { + "additionalProperties": false, + "properties": { + "data_type": { + "description": "REST issue field type.", + "enum": [ + "text", + "number", + "date", + "single_select" + ], + "type": "string" + }, + "issue_field_id": { + "type": "integer" + }, + "value": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + } + }, + "required": [ + "issue_field_id", + "data_type", + "value" + ], + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + }, + "html_url": { + "description": "Human-readable issue link.", + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "integer" + ] + }, + "issue_type": { + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "milestone": { + "type": "string" + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "state": { + "description": "Issue state (lowercase REST value).", + "enum": [ + "open", + "closed" + ], + "type": [ + "null", + "string" + ] + }, + "state_reason": { + "enum": [ + "completed", + "not_planned", + "reopened", + "duplicate" + ], + "type": [ + "null", + "string" + ] + }, + "sub_issues_summary": { + "additionalProperties": false, + "properties": { + "completed": { + "description": "Number of completed sub-issues.", + "type": "integer" + }, + "percent_completed": { + "description": "Completed sub-issues as a percentage (0-100).", + "type": "integer" + }, + "total": { + "description": "Total number of sub-issues.", + "type": "integer" + } + }, + "required": [ + "total", + "completed", + "percent_completed" + ], + "type": [ + "null", + "object" + ] + }, + "title": { + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "description": "Last update time (RFC 3339).", + "type": "string" + }, + "user": { + "additionalProperties": false, + "properties": { + "id": { + "type": "integer" + }, + "login": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "assignees" + ], + "type": [ + "object", + "null" + ] + }, + "method": { + "enum": [ + "add", + "remove", + "reprioritize" + ], + "type": "string" + } + }, + "required": [ + "method", + "issue" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_issue.snap b/pkg/github/__toolsnaps__/create_issue.snap index d7de241265..b616605c21 100644 --- a/pkg/github/__toolsnaps__/create_issue.snap +++ b/pkg/github/__toolsnaps__/create_issue.snap @@ -7,6 +7,18 @@ "title": "Create Issue" }, "description": "Create a new issue in a GitHub repository with a title and optional body.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "body": { @@ -15,7 +27,8 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "parent_issue_number": { "description": "Issue number of the parent issue. The new issue is created and attached to this parent in the same operation.", @@ -32,7 +45,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "title": { "description": "Issue title", @@ -46,5 +60,24 @@ ], "type": "object" }, - "name": "create_issue" + "name": "create_issue", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/hide_issue_comment.snap b/pkg/github/__toolsnaps__/hide_issue_comment.snap index 129fe38a27..a9108e1106 100644 --- a/pkg/github/__toolsnaps__/hide_issue_comment.snap +++ b/pkg/github/__toolsnaps__/hide_issue_comment.snap @@ -7,6 +7,18 @@ "title": "Hide Issue Comment" }, "description": "Hide (minimize) a comment on an issue, or a conversation comment on a pull request. Requires triage or write access to the repository, or being its author.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "classifier": { @@ -29,11 +41,13 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ diff --git a/pkg/github/__toolsnaps__/remove_issue_comment_reaction.snap b/pkg/github/__toolsnaps__/remove_issue_comment_reaction.snap index de53a1dae8..1c0859a06a 100644 --- a/pkg/github/__toolsnaps__/remove_issue_comment_reaction.snap +++ b/pkg/github/__toolsnaps__/remove_issue_comment_reaction.snap @@ -7,6 +7,18 @@ "title": "Remove Reaction from Issue or Pull Request Comment" }, "description": "Remove a reaction from an issue or pull request comment.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "comment_id": { @@ -16,7 +28,8 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "reaction_id": { "description": "The reaction ID to remove", @@ -25,7 +38,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -36,5 +50,20 @@ ], "type": "object" }, - "name": "remove_issue_comment_reaction" + "name": "remove_issue_comment_reaction", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/remove_issue_reaction.snap b/pkg/github/__toolsnaps__/remove_issue_reaction.snap index 4222e308fe..92f7d4b471 100644 --- a/pkg/github/__toolsnaps__/remove_issue_reaction.snap +++ b/pkg/github/__toolsnaps__/remove_issue_reaction.snap @@ -7,6 +7,18 @@ "title": "Remove Reaction from Issue or Pull Request" }, "description": "Remove a reaction from an issue or pull request.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "issue_number": { @@ -16,7 +28,8 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "reaction_id": { "description": "The reaction ID to remove", @@ -25,7 +38,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -36,5 +50,20 @@ ], "type": "object" }, - "name": "remove_issue_reaction" + "name": "remove_issue_reaction", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/remove_sub_issue.snap b/pkg/github/__toolsnaps__/remove_sub_issue.snap index 4f07c85f41..3386d20e95 100644 --- a/pkg/github/__toolsnaps__/remove_sub_issue.snap +++ b/pkg/github/__toolsnaps__/remove_sub_issue.snap @@ -7,6 +7,18 @@ "title": "Remove Sub-Issue" }, "description": "Remove a sub-issue from a parent issue.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "issue_number": { @@ -16,11 +28,13 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "sub_issue_id": { "description": "The ID of the sub-issue to remove. ID is not the same as issue number", @@ -35,5 +49,217 @@ ], "type": "object" }, - "name": "remove_sub_issue" + "name": "remove_sub_issue", + "outputSchema": { + "additionalProperties": false, + "properties": { + "issue": { + "additionalProperties": false, + "properties": { + "assignees": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "body": { + "type": [ + "null", + "string" + ] + }, + "closed_at": { + "description": "Closure time (RFC 3339), when closed.", + "type": "string" + }, + "comments": { + "description": "Number of comments.", + "type": [ + "null", + "integer" + ] + }, + "created_at": { + "description": "Creation time (RFC 3339).", + "type": "string" + }, + "field_values": { + "items": { + "additionalProperties": false, + "properties": { + "data_type": { + "description": "REST issue field type.", + "enum": [ + "text", + "number", + "date", + "single_select" + ], + "type": "string" + }, + "issue_field_id": { + "type": "integer" + }, + "value": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + } + }, + "required": [ + "issue_field_id", + "data_type", + "value" + ], + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + }, + "html_url": { + "description": "Human-readable issue link.", + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "integer" + ] + }, + "issue_type": { + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "milestone": { + "type": "string" + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "state": { + "description": "Issue state (lowercase REST value).", + "enum": [ + "open", + "closed" + ], + "type": [ + "null", + "string" + ] + }, + "state_reason": { + "enum": [ + "completed", + "not_planned", + "reopened", + "duplicate" + ], + "type": [ + "null", + "string" + ] + }, + "sub_issues_summary": { + "additionalProperties": false, + "properties": { + "completed": { + "description": "Number of completed sub-issues.", + "type": "integer" + }, + "percent_completed": { + "description": "Completed sub-issues as a percentage (0-100).", + "type": "integer" + }, + "total": { + "description": "Total number of sub-issues.", + "type": "integer" + } + }, + "required": [ + "total", + "completed", + "percent_completed" + ], + "type": [ + "null", + "object" + ] + }, + "title": { + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "description": "Last update time (RFC 3339).", + "type": "string" + }, + "user": { + "additionalProperties": false, + "properties": { + "id": { + "type": "integer" + }, + "login": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "assignees" + ], + "type": [ + "object", + "null" + ] + }, + "method": { + "enum": [ + "add", + "remove", + "reprioritize" + ], + "type": "string" + } + }, + "required": [ + "method", + "issue" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/reprioritize_sub_issue.snap b/pkg/github/__toolsnaps__/reprioritize_sub_issue.snap index bb981b74ea..591ec1c095 100644 --- a/pkg/github/__toolsnaps__/reprioritize_sub_issue.snap +++ b/pkg/github/__toolsnaps__/reprioritize_sub_issue.snap @@ -7,6 +7,18 @@ "title": "Reprioritize Sub-Issue" }, "description": "Reprioritize (reorder) a sub-issue relative to other sub-issues.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "after_id": { @@ -24,11 +36,13 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "sub_issue_id": { "description": "The ID of the sub-issue to reorder. ID is not the same as issue number", @@ -43,5 +57,217 @@ ], "type": "object" }, - "name": "reprioritize_sub_issue" + "name": "reprioritize_sub_issue", + "outputSchema": { + "additionalProperties": false, + "properties": { + "issue": { + "additionalProperties": false, + "properties": { + "assignees": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "body": { + "type": [ + "null", + "string" + ] + }, + "closed_at": { + "description": "Closure time (RFC 3339), when closed.", + "type": "string" + }, + "comments": { + "description": "Number of comments.", + "type": [ + "null", + "integer" + ] + }, + "created_at": { + "description": "Creation time (RFC 3339).", + "type": "string" + }, + "field_values": { + "items": { + "additionalProperties": false, + "properties": { + "data_type": { + "description": "REST issue field type.", + "enum": [ + "text", + "number", + "date", + "single_select" + ], + "type": "string" + }, + "issue_field_id": { + "type": "integer" + }, + "value": { + "type": [ + "string", + "number", + "boolean", + "null" + ] + } + }, + "required": [ + "issue_field_id", + "data_type", + "value" + ], + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + }, + "html_url": { + "description": "Human-readable issue link.", + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "integer" + ] + }, + "issue_type": { + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "milestone": { + "type": "string" + }, + "number": { + "type": [ + "null", + "integer" + ] + }, + "state": { + "description": "Issue state (lowercase REST value).", + "enum": [ + "open", + "closed" + ], + "type": [ + "null", + "string" + ] + }, + "state_reason": { + "enum": [ + "completed", + "not_planned", + "reopened", + "duplicate" + ], + "type": [ + "null", + "string" + ] + }, + "sub_issues_summary": { + "additionalProperties": false, + "properties": { + "completed": { + "description": "Number of completed sub-issues.", + "type": "integer" + }, + "percent_completed": { + "description": "Completed sub-issues as a percentage (0-100).", + "type": "integer" + }, + "total": { + "description": "Total number of sub-issues.", + "type": "integer" + } + }, + "required": [ + "total", + "completed", + "percent_completed" + ], + "type": [ + "null", + "object" + ] + }, + "title": { + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "description": "Last update time (RFC 3339).", + "type": "string" + }, + "user": { + "additionalProperties": false, + "properties": { + "id": { + "type": "integer" + }, + "login": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "assignees" + ], + "type": [ + "object", + "null" + ] + }, + "method": { + "enum": [ + "add", + "remove", + "reprioritize" + ], + "type": "string" + } + }, + "required": [ + "method", + "issue" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/set_issue_fields.snap b/pkg/github/__toolsnaps__/set_issue_fields.snap index c5b89077ac..64b2731bea 100644 --- a/pkg/github/__toolsnaps__/set_issue_fields.snap +++ b/pkg/github/__toolsnaps__/set_issue_fields.snap @@ -7,6 +7,18 @@ "title": "Set Issue Fields" }, "description": "Set issue field values for an issue. Fields are organization-level custom fields (text, number, date, or single select). Use this to create or update field values on an issue.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "fields": { @@ -71,11 +83,13 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -86,5 +100,24 @@ ], "type": "object" }, - "name": "set_issue_fields" + "name": "set_issue_fields", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/unhide_issue_comment.snap b/pkg/github/__toolsnaps__/unhide_issue_comment.snap index 2a522c508d..2441c86bd4 100644 --- a/pkg/github/__toolsnaps__/unhide_issue_comment.snap +++ b/pkg/github/__toolsnaps__/unhide_issue_comment.snap @@ -7,6 +7,18 @@ "title": "Unhide Issue Comment" }, "description": "Unhide (unminimize) a previously hidden comment on an issue, or conversation comment on a pull request. Requires triage or write access to the repository, or being its author.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "comment_id": { @@ -16,11 +28,13 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ diff --git a/pkg/github/__toolsnaps__/update_issue_assignees.snap b/pkg/github/__toolsnaps__/update_issue_assignees.snap index 51a407c164..088992dc3b 100644 --- a/pkg/github/__toolsnaps__/update_issue_assignees.snap +++ b/pkg/github/__toolsnaps__/update_issue_assignees.snap @@ -7,6 +7,18 @@ "title": "Update Issue Assignees" }, "description": "Update the assignees of an existing issue. This replaces the current assignees with the provided list. When setting values, include a confidence level (LOW, MEDIUM, or HIGH) reflecting how certain you are about the choice.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "assignees": { @@ -58,11 +70,13 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -73,5 +87,24 @@ ], "type": "object" }, - "name": "update_issue_assignees" + "name": "update_issue_assignees", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_issue_body.snap b/pkg/github/__toolsnaps__/update_issue_body.snap index e17043331d..69f75caa56 100644 --- a/pkg/github/__toolsnaps__/update_issue_body.snap +++ b/pkg/github/__toolsnaps__/update_issue_body.snap @@ -7,6 +7,18 @@ "title": "Update Issue Body" }, "description": "Update the body content of an existing issue.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "body": { @@ -20,11 +32,13 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -35,5 +49,24 @@ ], "type": "object" }, - "name": "update_issue_body" + "name": "update_issue_body", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_issue_labels.snap b/pkg/github/__toolsnaps__/update_issue_labels.snap index 2ad3877586..c0cfa73ad8 100644 --- a/pkg/github/__toolsnaps__/update_issue_labels.snap +++ b/pkg/github/__toolsnaps__/update_issue_labels.snap @@ -7,6 +7,18 @@ "title": "Update Issue Labels" }, "description": "Update the labels of an existing issue. This replaces the current labels with the provided list. When setting values, include a confidence level (LOW, MEDIUM, or HIGH) reflecting how certain you are about the choice.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "issue_number": { @@ -58,11 +70,13 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -73,5 +87,24 @@ ], "type": "object" }, - "name": "update_issue_labels" + "name": "update_issue_labels", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_issue_milestone.snap b/pkg/github/__toolsnaps__/update_issue_milestone.snap index cae51246b4..8795e3dddf 100644 --- a/pkg/github/__toolsnaps__/update_issue_milestone.snap +++ b/pkg/github/__toolsnaps__/update_issue_milestone.snap @@ -7,6 +7,18 @@ "title": "Update Issue Milestone" }, "description": "Update the milestone of an existing issue.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "issue_number": { @@ -21,11 +33,13 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -36,5 +50,24 @@ ], "type": "object" }, - "name": "update_issue_milestone" + "name": "update_issue_milestone", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_issue_state.snap b/pkg/github/__toolsnaps__/update_issue_state.snap index 2a26e8be64..cdb9dcd946 100644 --- a/pkg/github/__toolsnaps__/update_issue_state.snap +++ b/pkg/github/__toolsnaps__/update_issue_state.snap @@ -7,6 +7,18 @@ "title": "Update Issue State" }, "description": "Update the state of an existing issue (open or closed), with an optional state reason. When closing, include a confidence level (LOW, MEDIUM, or HIGH) reflecting how certain you are about the decision. Use is_suggestion to propose the change without applying it directly.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "confidence": { @@ -34,7 +46,8 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "rationale": { "description": "One concise sentence explaining what specifically about the issue led you to choose this state. State the concrete signal (e.g. 'The reported crash is fixed in v2.1' → completed).", @@ -43,7 +56,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "state": { "description": "The new state for the issue", @@ -71,5 +85,24 @@ ], "type": "object" }, - "name": "update_issue_state" + "name": "update_issue_state", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_issue_title.snap b/pkg/github/__toolsnaps__/update_issue_title.snap index 48c74d98ef..69a11da93b 100644 --- a/pkg/github/__toolsnaps__/update_issue_title.snap +++ b/pkg/github/__toolsnaps__/update_issue_title.snap @@ -7,6 +7,18 @@ "title": "Update Issue Title" }, "description": "Update the title of an existing issue.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "issue_number": { @@ -16,11 +28,13 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "title": { "description": "The new title for the issue", @@ -35,5 +49,24 @@ ], "type": "object" }, - "name": "update_issue_title" + "name": "update_issue_title", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_issue_type.snap b/pkg/github/__toolsnaps__/update_issue_type.snap index fbe8c90bb1..0a8215a788 100644 --- a/pkg/github/__toolsnaps__/update_issue_type.snap +++ b/pkg/github/__toolsnaps__/update_issue_type.snap @@ -7,6 +7,18 @@ "title": "Update Issue Type" }, "description": "Set or remove the type of an existing issue. Pass null to remove the current type. When setting a value, include a confidence level (LOW, MEDIUM, or HIGH) reflecting how certain you are about the choice.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAAC2UlEQVRIicWVMUyTaRjHf/+vVUpOJOdilOYoUvVreq2CDmLOwdlAS5xMbrrhBifjYG7QjYuJw108nZx1huLgYlw0QshxChUK2koxHHcuCmgsFfieG0or3kGPSoz/8X3f5/97nvfN8z7wmaVqm9FodFfR/EnMugy5giCAwYwgI9Ff9Hl9L9Lp1zUBgsGO+rqGwnlhF4CdwBSyAUwvS1G220zHBSFgHtmVxYWvfp2ZGSj8LyAcjgfZphTQZtAr7OdsZvSP9RIJR+LtQhcNusF+d1aUfPp05M8NAavmg8AOk3cmN56+s1Hpa7U/cviUYbcw3jgex9ZCKoBgsKM+0PDuAdBq2He5zOiTzZiXtc+NxRw598GeLRcWTuTz+UUAp3ygrqFwHmgzeWdqNQd4PpFOy/gedNQfaDz3UQXRaHRX0fNPGdzNZUZO12q+VuHIoV7g5Hu/1/IinX7tABTNnwR2SvRsxRzAzOsBGuuWfAkoX5FZFzCVHR95tFVAbiI9DEwj6wLwl5YVMTRYLTDsxjuRrgGYYz/kxkbvbXRWMOBB24cKYI9ks1X8hXQDaAaa5XG9WjImZgV71wI+m8qAv8y0t1pSmP0ITANTmHO2qqvRZDALlTdgHKyjWkx2YvQ2cHtTacMxYBhWK5DoF4TCkXj7Jg02VKsbOwI0Y+qvALZrOQXMC13cKkDSJWBuJUCqAhgbG3uF7IpBcn/k8KlPNQ+78U5QQuhy/vHjuQoAYPndwi9gw4bd2ufGYrWatxz8No50ExhaKsz9Vl6vAPL5/KKzoiTGG0fOg1oqCbvxTp/juw/M+zynu/yTwjoD58CBQ02ez/pAR4E+M69ntf3/o1Y3dqR050oAQz7P6Z6cfPRRw647MkOhUMAfaDyH+AloBKYNPRT292rQHoMOSp09J3TZlt5ezWazxX97VR3638RiX9ct+RImSyBcrDT0ETMyMp4ptRIgVX7QL6J/ALSUEwJ5rdg2AAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABxElEQVRIibWVvW4TURCFv0vlNEDcIHAkKAERJOIKSjoUArwBPwUFFaKIIngAJARCPIgdh4cgRBYt6ZIAEYEqdhpEw0fhCbkKa68dxyNtsfNzzpnZu3NhwpYGBdUqcA+4A1wEZiK0DawD74FWSml3JAJ1CngGLAIngU1gFfgZKWeAG8AFoAu8At6mlH6VtqTOqJ/UP2pDnRuQO6c27VlbrQ0Dvq121Fulag7q5qPmW18SdSqUd9Qrw4Jn9bNR21YrRQkvYixDKy/AuB3jWjocqKpdtXFU8AxrOTqZzp2PgvnaMRDUA+tB7mypG+OCZ3hbahPgRPguAR9LihaicEu9WcKxClzOi/fU1wPAk7rjgX0uEfNG3cs7mJjtE+wA5/olpZQEHgNf6K2NJyW4NeD7v7c4WptjSc0svlMjdzyM2fbdOyOA7x/T+7mzGj9H8xgIWuquevpw4HmsivkxwBdC/WJRsBKLqqPOHgH8aqybtcJlF0m1WLndUToJ5V31q9r3NOYk7Wh1Wa0PyK3HzA3l/4H3uzIrwFNgCThF7/x/AH5EylngOnAe6AAvgXcppd9DEWRE08DdeIou/RVgJaXUGYQzUfsL+zmwV7BtIq0AAAAASUVORK5CYII=", + "theme": "dark" + } + ], "inputSchema": { "properties": { "confidence": { @@ -41,7 +53,8 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "rationale": { "description": "One concise sentence explaining what specifically about the issue led you to choose this type. State the concrete signal (e.g. 'Reports a crash when saving' → bug, 'Asks for dark mode support' → feature).", @@ -50,7 +63,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -61,5 +75,24 @@ ], "type": "object" }, - "name": "update_issue_type" + "name": "update_issue_type", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/comment_minimize_test.go b/pkg/github/comment_minimize_test.go index 75e47fc5ab..6a4c552462 100644 --- a/pkg/github/comment_minimize_test.go +++ b/pkg/github/comment_minimize_test.go @@ -162,7 +162,9 @@ func Test_CommentVisibilityToolSchemas(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { tool := tc.tool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) + if tc.toolset != ToolsetMetadataIssues.ID { + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + } assert.Equal(t, tc.name, tool.Name) assert.NotEmpty(t, tool.Description) diff --git a/pkg/github/granular_issue_types.go b/pkg/github/granular_issue_types.go new file mode 100644 index 0000000000..3b1d544cb5 --- /dev/null +++ b/pkg/github/granular_issue_types.go @@ -0,0 +1,1063 @@ +package github + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/google/go-github/v92/github" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" +) + +// GranularIssueCoordinate is shared by the single-issue mutation DTOs. +type GranularIssueCoordinate struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + IssueNumber int `json:"issue_number"` +} + +func (in GranularIssueCoordinate) issueCoordinate() GranularIssueCoordinate { return in } + +type GranularCreateIssueInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Title string `json:"title"` + Body string `json:"body,omitempty"` + ParentIssueNumber *int `json:"parent_issue_number,omitempty"` + ParentOwner string `json:"parent_owner,omitempty"` + ParentRepo string `json:"parent_repo,omitempty"` +} + +type GranularIssueTitleInput struct { + GranularIssueCoordinate + Title string `json:"title"` +} + +type GranularIssueBodyInput struct { + GranularIssueCoordinate + Body string `json:"body"` +} + +type GranularIssueMilestoneInput struct { + GranularIssueCoordinate + Milestone int `json:"milestone"` +} + +// Entries retain the heterogeneous string/object input without a map-backed +// handler. The normalizer validates and canonicalizes intent before decoding. +type GranularIssueAssignee struct { + Login string `json:"login"` + Rationale string `json:"rationale,omitempty"` + Confidence string `json:"confidence,omitempty"` + IsSuggestion bool `json:"is_suggestion,omitempty"` + StringForm bool `json:"-"` +} + +func (in *GranularIssueAssignee) UnmarshalJSON(raw []byte) error { + *in = GranularIssueAssignee{} + if bytes.HasPrefix(bytes.TrimSpace(raw), []byte(`"`)) { + in.StringForm = true + return json.Unmarshal(raw, &in.Login) + } + type plain GranularIssueAssignee + return json.Unmarshal(raw, (*plain)(in)) +} + +func (in GranularIssueAssignee) MarshalJSON() ([]byte, error) { + if in.StringForm { + return json.Marshal(in.Login) + } + type plain GranularIssueAssignee + return json.Marshal(plain(in)) +} + +type GranularIssueLabel struct { + Name string `json:"name"` + Rationale string `json:"rationale,omitempty"` + Confidence string `json:"confidence,omitempty"` + IsSuggestion bool `json:"is_suggestion,omitempty"` + StringForm bool `json:"-"` +} + +func (in *GranularIssueLabel) UnmarshalJSON(raw []byte) error { + *in = GranularIssueLabel{} + if bytes.HasPrefix(bytes.TrimSpace(raw), []byte(`"`)) { + in.StringForm = true + return json.Unmarshal(raw, &in.Name) + } + type plain GranularIssueLabel + return json.Unmarshal(raw, (*plain)(in)) +} + +func (in GranularIssueLabel) MarshalJSON() ([]byte, error) { + if in.StringForm { + return json.Marshal(in.Name) + } + type plain GranularIssueLabel + return json.Marshal(plain(in)) +} + +type GranularIssueAssigneesInput struct { + GranularIssueCoordinate + Assignees []GranularIssueAssignee `json:"assignees"` +} + +type GranularIssueLabelsInput struct { + GranularIssueCoordinate + Labels []GranularIssueLabel `json:"labels"` +} + +type GranularIssueTypeInput struct { + GranularIssueCoordinate + IssueType *string `json:"issue_type"` + Rationale string `json:"rationale,omitempty"` + Confidence string `json:"confidence,omitempty"` + IsSuggestion bool `json:"is_suggestion,omitempty"` +} + +type GranularIssueStateInput struct { + GranularIssueCoordinate + State string `json:"state"` + StateReason string `json:"state_reason,omitempty"` + Rationale string `json:"rationale,omitempty"` + Confidence string `json:"confidence,omitempty"` + IsSuggestion bool `json:"is_suggestion,omitempty"` + DuplicateOf int `json:"duplicate_of,omitempty"` +} + +type GranularAddSubIssueInput struct { + GranularIssueCoordinate + SubIssueID int `json:"sub_issue_id"` + ReplaceParent bool `json:"replace_parent,omitempty"` +} + +type GranularRemoveSubIssueInput struct { + GranularIssueCoordinate + SubIssueID int `json:"sub_issue_id"` +} + +type GranularReprioritizeSubIssueInput struct { + GranularIssueCoordinate + SubIssueID int `json:"sub_issue_id"` + AfterID int `json:"after_id,omitempty"` + BeforeID int `json:"before_id,omitempty"` +} + +type GranularIssueField struct { + FieldID string `json:"field_id"` + TextValue string `json:"text_value,omitempty"` + NumberValue *float64 `json:"number_value,omitempty"` + DateValue string `json:"date_value,omitempty"` + SingleSelectOptionID string `json:"single_select_option_id,omitempty"` + Delete bool `json:"delete,omitempty"` + Rationale string `json:"rationale,omitempty"` + Confidence string `json:"confidence,omitempty"` + IsSuggestion bool `json:"is_suggestion,omitempty"` +} + +type GranularSetIssueFieldsInput struct { + GranularIssueCoordinate + Fields []GranularIssueField `json:"fields"` +} + +type GranularAddIssueReactionInput struct { + GranularIssueCoordinate + Content string `json:"content"` +} + +type GranularRemoveIssueReactionInput struct { + GranularIssueCoordinate + ReactionID int64 `json:"reaction_id"` +} + +type GranularAddIssueCommentReactionInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + CommentID int64 `json:"comment_id"` + Content string `json:"content"` +} + +type GranularRemoveIssueCommentReactionInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + CommentID int64 `json:"comment_id"` + ReactionID int64 `json:"reaction_id"` +} + +type GranularIssueReactionMessage struct { + Message string `json:"message"` +} + +type GranularIssueUser struct { + Login string `json:"login"` + ID int64 `json:"id,omitempty"` +} + +type GranularIssueDependenciesSummary struct { + BlockedBy *int `json:"blocked_by,omitempty"` + Blocking *int `json:"blocking,omitempty"` + TotalBlockedBy *int `json:"total_blocked_by,omitempty"` + TotalBlocking *int `json:"total_blocking,omitempty"` +} + +func granularIssueMinimalResult(output *MinimalResponse) (*mcp.CallToolResult, *MinimalResponse, error) { + raw, err := json.Marshal(output) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + return utils.NewToolResultText(string(raw)), output, nil +} + +func granularIssueAssigneesPayload(entries []GranularIssueAssignee) any { + payload := make([]any, 0, len(entries)) + logins := make([]string, len(entries)) + objectForm := false + for i, entry := range entries { + logins[i] = entry.Login + if entry.Rationale == "" && entry.Confidence == "" && !entry.IsSuggestion { + payload = append(payload, entry.Login) + } else { + objectForm = true + payload = append(payload, assigneeWithIntent{Login: entry.Login, Rationale: entry.Rationale, Confidence: entry.Confidence, Suggest: entry.IsSuggestion}) + } + } + if objectForm { + return &assigneesUpdateRequest{Assignees: payload} + } + return &github.UpdateIssueRequest{Assignees: logins} +} + +func granularIssueLabelsPayload(entries []GranularIssueLabel) any { + payload := make([]any, 0, len(entries)) + names := make([]string, len(entries)) + objectForm := false + for i, entry := range entries { + names[i] = entry.Name + if entry.Rationale == "" && entry.Confidence == "" && !entry.IsSuggestion { + payload = append(payload, entry.Name) + } else { + objectForm = true + payload = append(payload, labelWithIntent{Name: entry.Name, Rationale: entry.Rationale, Confidence: entry.Confidence, Suggest: entry.IsSuggestion}) + } + } + if objectForm { + return &labelsUpdateRequest{Labels: payload} + } + return &github.UpdateIssueRequest{Labels: names} +} + +func normalizeGranularIssueUpdateArguments(field string) inventory.InputNormalizer { + return func(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + for _, name := range []string{"owner", "repo"} { + if _, err := RequiredParam[string](args, name); err != nil { + return nil, err + } + } + number, err := RequiredInt(args, "issue_number") + if err != nil { + return nil, err + } + args["issue_number"] = number + if field == "milestone" { + milestone, err := RequiredInt(args, field) + if err != nil { + return nil, err + } + args[field] = milestone + } else if _, err := RequiredParam[string](args, field); err != nil { + return nil, err + } + return json.Marshal(args) + } +} + +// Convert validated values without losing a present numeric zero or sending +// empty optional strings. Null and incorrectly typed values do not count. +func granularIssueFieldsFromMutation(fields []IssueFieldCreateOrUpdateInput) []GranularIssueField { + result := make([]GranularIssueField, 0, len(fields)) + for _, field := range fields { + entry := GranularIssueField{FieldID: field.FieldID.(string)} + if field.TextValue != nil { + entry.TextValue = string(*field.TextValue) + } + if field.NumberValue != nil { + value := float64(*field.NumberValue) + entry.NumberValue = &value + } + if field.DateValue != nil { + entry.DateValue = string(*field.DateValue) + } + if field.SingleSelectOptionID != nil { + entry.SingleSelectOptionID = (*field.SingleSelectOptionID).(string) + } + if field.Delete != nil { + entry.Delete = bool(*field.Delete) + } + if field.Rationale != nil { + entry.Rationale = string(*field.Rationale) + } + if field.Confidence != nil { + entry.Confidence = *field.Confidence + } + if field.Suggest != nil { + entry.IsSuggestion = bool(*field.Suggest) + } + result = append(result, entry) + } + return result +} + +func granularIssueFieldMutation(fields []GranularIssueField) []IssueFieldCreateOrUpdateInput { + result := make([]IssueFieldCreateOrUpdateInput, 0, len(fields)) + for _, field := range fields { + entry := IssueFieldCreateOrUpdateInput{FieldID: githubv4.ID(field.FieldID)} + if field.TextValue != "" { + entry.TextValue = githubv4.NewString(githubv4.String(field.TextValue)) + } + if field.NumberValue != nil { + value := githubv4.Float(*field.NumberValue) + entry.NumberValue = &value + } + if field.DateValue != "" { + entry.DateValue = githubv4.NewString(githubv4.String(field.DateValue)) + } + if field.SingleSelectOptionID != "" { + value := githubv4.ID(field.SingleSelectOptionID) + entry.SingleSelectOptionID = &value + } + if field.Delete { + value := githubv4.Boolean(true) + entry.Delete = &value + } + if field.Rationale != "" { + entry.Rationale = githubv4.NewString(githubv4.String(field.Rationale)) + } + if field.Confidence != "" { + value := field.Confidence + entry.Confidence = &value + } + if field.IsSuggestion { + value := githubv4.Boolean(true) + entry.Suggest = &value + } + result = append(result, entry) + } + return result +} + +// Preserve supplied properties for the explicit schema to validate, rather than +// silently dropping (for example) duplicate_of:0 or an invalid optional value. +// Only legacy coercions and intent whitespace/case normalization are applied. +func marshalGranularIssueArguments[T any](args map[string]any, input T) (json.RawMessage, error) { + raw, err := json.Marshal(input) + if err != nil { + return nil, err + } + var normalized map[string]any + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() // Preserve RequiredBigInt's full int64 result. + if err := decoder.Decode(&normalized); err != nil { + return nil, err + } + mergeGranularIssueArguments(args, normalized) + return json.Marshal(args) +} + +func mergeGranularIssueArguments(original, normalized map[string]any) { + if rationale, ok := original["rationale"].(string); ok { + original["rationale"] = strings.TrimSpace(rationale) + } + if confidence, ok := original["confidence"].(string); ok { + confidence = normalizeConfidence(confidence) + if confidence == "" { + delete(original, "confidence") + } else { + original["confidence"] = confidence + } + } + for key, value := range normalized { + // Fields can contain optional values that the legacy parser ignores. + // Keep those supplied values visible to SDK schema validation. + if key == "fields" { + before, beforeOK := original[key].([]any) + after, afterOK := value.([]any) + if beforeOK && afterOK && len(before) == len(after) { + for i := range after { + oldField, oldOK := before[i].(map[string]any) + newField, newOK := after[i].(map[string]any) + if oldOK && newOK { + mergeGranularIssueArguments(oldField, newField) + after[i] = oldField + } + } + } + } + original[key] = value + } +} + +// Normalizers preserve legacy coercions and diagnostics before SDK validation. +func normalizeGranularCreateIssueArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return nil, err + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return nil, err + } + title, err := RequiredParam[string](args, "title") + if err != nil { + return nil, err + } + body, _ := OptionalParam[string](args, "body") + // The original create handler ignored OptionalParam errors for body. + // Canonicalize the resulting default before the unchanged schema validates. + if _, provided := args["body"]; provided { + args["body"] = body + } + parentIssueNumber, err := OptionalIntParam(args, "parent_issue_number") + if err != nil { + return nil, err + } + parentValue, parentProvided := args["parent_issue_number"] + parentProvided = parentProvided && parentValue != nil + if parentProvided && parentIssueNumber < 1 { + return nil, fmt.Errorf("parent_issue_number must be greater than 0") + } + parentOwner, err := OptionalParam[string](args, "parent_owner") + if err != nil { + return nil, err + } + parentRepo, err := OptionalParam[string](args, "parent_repo") + if err != nil { + return nil, err + } + if err := validateParentRepository(parentProvided, parentOwner, parentRepo); err != nil { + return nil, err + } + + var parentNumber *int + if parentProvided { + parentNumber = &parentIssueNumber + } + + return marshalGranularIssueArguments(args, GranularCreateIssueInput{Owner: owner, Repo: repo, Title: title, Body: body, ParentIssueNumber: parentNumber, ParentOwner: parentOwner, ParentRepo: parentRepo}) +} + +func normalizeGranularUpdateIssueAssigneesArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return nil, err + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return nil, err + } + issueNumber, err := RequiredInt(args, "issue_number") + if err != nil { + return nil, err + } + + assigneesRaw, ok := args["assignees"] + if !ok { + return nil, fmt.Errorf("missing required parameter: assignees") + } + assigneesSlice, ok := assigneesRaw.([]any) + if !ok { + // Also accept []string for callers that pre-typed the array. + if strs, ok := assigneesRaw.([]string); ok { + assigneesSlice = make([]any, len(strs)) + for i, s := range strs { + assigneesSlice[i] = s + } + } else { + return nil, fmt.Errorf("parameter assignees must be an array") + } + } + + payload := make([]GranularIssueAssignee, 0, len(assigneesSlice)) + for _, item := range assigneesSlice { + switch v := item.(type) { + case string: + payload = append(payload, GranularIssueAssignee{Login: v, StringForm: true}) + case map[string]any: + login, err := RequiredParam[string](v, "login") + if err != nil { + return nil, fmt.Errorf("each assignee object must have a 'login' string") + } + rationale, err := OptionalParam[string](v, "rationale") + if err != nil { + return nil, err + } + rationale = strings.TrimSpace(rationale) + if len([]rune(rationale)) > 280 { + return nil, fmt.Errorf("assignee rationale must be 280 characters or less") + } + confidence, err := OptionalParam[string](v, "confidence") + if err != nil { + return nil, err + } + confidence = normalizeConfidence(confidence) + if confidence != "" && confidence != "LOW" && confidence != "MEDIUM" && confidence != "HIGH" { + return nil, fmt.Errorf("confidence must be one of: LOW, MEDIUM, HIGH") + } + isSuggestion, err := OptionalParam[bool](v, "is_suggestion") + if err != nil { + return nil, err + } + if rationale == "" && !isSuggestion && confidence == "" { + payload = append(payload, GranularIssueAssignee{Login: login, StringForm: true}) + } else { + + payload = append(payload, GranularIssueAssignee{Login: login, Rationale: rationale, Confidence: confidence, IsSuggestion: isSuggestion}) + } + default: + return nil, fmt.Errorf("each assignee must be a string or an object with 'login' and optional 'rationale', 'confidence', and/or 'is_suggestion'") + } + } + + return marshalGranularIssueArguments(args, GranularIssueAssigneesInput{GranularIssueCoordinate: GranularIssueCoordinate{Owner: owner, Repo: repo, IssueNumber: issueNumber}, Assignees: payload}) +} + +func normalizeGranularUpdateIssueLabelsArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return nil, err + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return nil, err + } + issueNumber, err := RequiredInt(args, "issue_number") + if err != nil { + return nil, err + } + + labelsRaw, ok := args["labels"] + if !ok { + return nil, fmt.Errorf("missing required parameter: labels") + } + 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 nil, fmt.Errorf("parameter labels must be an array") + } + } + + payload := make([]GranularIssueLabel, 0, len(labelsSlice)) + for _, item := range labelsSlice { + switch v := item.(type) { + case string: + payload = append(payload, GranularIssueLabel{Name: v, StringForm: true}) + case map[string]any: + name, err := RequiredParam[string](v, "name") + if err != nil { + return nil, fmt.Errorf("each label object must have a 'name' string") + } + rationale, err := OptionalParam[string](v, "rationale") + if err != nil { + return nil, err + } + rationale = strings.TrimSpace(rationale) + if len([]rune(rationale)) > 280 { + return nil, fmt.Errorf("label rationale must be 280 characters or less") + } + confidence, err := OptionalParam[string](v, "confidence") + if err != nil { + return nil, err + } + confidence = normalizeConfidence(confidence) + if confidence != "" && confidence != "LOW" && confidence != "MEDIUM" && confidence != "HIGH" { + return nil, fmt.Errorf("confidence must be one of: LOW, MEDIUM, HIGH") + } + isSuggestion, err := OptionalParam[bool](v, "is_suggestion") + if err != nil { + return nil, err + } + if rationale == "" && !isSuggestion && confidence == "" { + payload = append(payload, GranularIssueLabel{Name: name, StringForm: true}) + } else { + + payload = append(payload, GranularIssueLabel{Name: name, Rationale: rationale, Confidence: confidence, IsSuggestion: isSuggestion}) + } + default: + return nil, fmt.Errorf("each label must be a string or an object with 'name' and optional 'rationale', 'confidence', and/or 'is_suggestion'") + } + } + + return marshalGranularIssueArguments(args, GranularIssueLabelsInput{GranularIssueCoordinate: GranularIssueCoordinate{Owner: owner, Repo: repo, IssueNumber: issueNumber}, Labels: payload}) +} + +func normalizeGranularUpdateIssueTypeArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return nil, err + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return nil, err + } + issueNumber, err := RequiredInt(args, "issue_number") + if err != nil { + return nil, err + } + issueType, issueTypeProvided, err := OptionalNullableStringParam(args, "issue_type") + if err != nil { + return nil, err + } + if !issueTypeProvided { + return nil, fmt.Errorf("missing required parameter: issue_type") + } + rationale, err := OptionalParam[string](args, "rationale") + if err != nil { + return nil, err + } + rationale = strings.TrimSpace(rationale) + if len([]rune(rationale)) > 280 { + return nil, fmt.Errorf("parameter rationale must be 280 characters or less") + } + confidence, err := OptionalParam[string](args, "confidence") + if err != nil { + return nil, err + } + confidence = normalizeConfidence(confidence) + if confidence != "" && confidence != "LOW" && confidence != "MEDIUM" && confidence != "HIGH" { + return nil, fmt.Errorf("confidence must be one of: LOW, MEDIUM, HIGH") + } + isSuggestion, err := OptionalParam[bool](args, "is_suggestion") + if err != nil { + return nil, err + } + if issueType == nil && (rationale != "" || confidence != "" || isSuggestion) { + return nil, fmt.Errorf("suggestion metadata is not supported when removing an issue type; omit rationale, confidence, and is_suggestion") + } + return marshalGranularIssueArguments(args, GranularIssueTypeInput{GranularIssueCoordinate: GranularIssueCoordinate{Owner: owner, Repo: repo, IssueNumber: issueNumber}, IssueType: issueType, Rationale: rationale, Confidence: confidence, IsSuggestion: isSuggestion}) +} + +func normalizeGranularUpdateIssueStateArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return nil, err + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return nil, err + } + issueNumber, err := RequiredInt(args, "issue_number") + if err != nil { + return nil, err + } + state, err := RequiredParam[string](args, "state") + if err != nil { + return nil, err + } + stateReason, err := OptionalParam[string](args, "state_reason") + if err != nil { + return nil, err + } + rationale, err := OptionalParam[string](args, "rationale") + if err != nil { + return nil, err + } + rationale = strings.TrimSpace(rationale) + if len([]rune(rationale)) > 280 { + return nil, fmt.Errorf("parameter rationale must be 280 characters or less") + } + confidence, err := OptionalParam[string](args, "confidence") + if err != nil { + return nil, err + } + confidence = normalizeConfidence(confidence) + if confidence != "" && confidence != "LOW" && confidence != "MEDIUM" && confidence != "HIGH" { + return nil, fmt.Errorf("confidence must be one of: LOW, MEDIUM, HIGH") + } + isSuggestion, err := OptionalParam[bool](args, "is_suggestion") + if err != nil { + return nil, err + } + duplicateOf, err := OptionalIntParam(args, "duplicate_of") + if err != nil { + return nil, err + } + if stateReason != "" && state != "closed" { + return nil, fmt.Errorf("state_reason can only be used when state is 'closed'") + } + if duplicateOf != 0 && stateReason != "duplicate" { + return nil, fmt.Errorf("duplicate_of can only be used when state_reason is 'duplicate'") + } + if isSuggestion && stateReason == "duplicate" && duplicateOf == 0 { + return nil, fmt.Errorf("duplicate_of is required when suggesting a close as duplicate") + } + + return marshalGranularIssueArguments(args, GranularIssueStateInput{GranularIssueCoordinate: GranularIssueCoordinate{Owner: owner, Repo: repo, IssueNumber: issueNumber}, State: state, StateReason: stateReason, Rationale: rationale, Confidence: confidence, IsSuggestion: isSuggestion, DuplicateOf: duplicateOf}) +} + +func normalizeGranularAddSubIssueArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return nil, err + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return nil, err + } + issueNumber, err := RequiredInt(args, "issue_number") + if err != nil { + return nil, err + } + subIssueID, err := RequiredInt(args, "sub_issue_id") + if err != nil { + return nil, err + } + replaceParent, _ := OptionalParam[bool](args, "replace_parent") + // Like body on create, invalid replace_parent values historically defaulted. + if _, provided := args["replace_parent"]; provided { + args["replace_parent"] = replaceParent + } + + return marshalGranularIssueArguments(args, GranularAddSubIssueInput{GranularIssueCoordinate: GranularIssueCoordinate{Owner: owner, Repo: repo, IssueNumber: issueNumber}, SubIssueID: subIssueID, ReplaceParent: replaceParent}) +} + +func normalizeGranularRemoveSubIssueArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return nil, err + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return nil, err + } + issueNumber, err := RequiredInt(args, "issue_number") + if err != nil { + return nil, err + } + subIssueID, err := RequiredInt(args, "sub_issue_id") + if err != nil { + return nil, err + } + + return marshalGranularIssueArguments(args, GranularRemoveSubIssueInput{GranularIssueCoordinate: GranularIssueCoordinate{Owner: owner, Repo: repo, IssueNumber: issueNumber}, SubIssueID: subIssueID}) +} + +func normalizeGranularReprioritizeSubIssueArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return nil, err + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return nil, err + } + issueNumber, err := RequiredInt(args, "issue_number") + if err != nil { + return nil, err + } + subIssueID, err := RequiredInt(args, "sub_issue_id") + if err != nil { + return nil, err + } + afterID, err := OptionalIntParam(args, "after_id") + if err != nil { + return nil, err + } + beforeID, err := OptionalIntParam(args, "before_id") + if err != nil { + return nil, err + } + + return marshalGranularIssueArguments(args, GranularReprioritizeSubIssueInput{GranularIssueCoordinate: GranularIssueCoordinate{Owner: owner, Repo: repo, IssueNumber: issueNumber}, SubIssueID: subIssueID, AfterID: afterID, BeforeID: beforeID}) +} + +func normalizeGranularSetIssueFieldsArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return nil, err + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return nil, err + } + issueNumber, err := RequiredInt(args, "issue_number") + if err != nil { + return nil, err + } + + fieldsRaw, ok := args["fields"] + if !ok { + return nil, fmt.Errorf("missing required parameter: fields") + } + + // Accept both []any and []map[string]any input forms + var fieldMaps []map[string]any + switch v := fieldsRaw.(type) { + case []any: + for _, f := range v { + fieldMap, ok := f.(map[string]any) + if !ok { + return nil, fmt.Errorf("each field must be an object with 'field_id' and a value") + } + fieldMaps = append(fieldMaps, fieldMap) + } + case []map[string]any: + fieldMaps = v + default: + return nil, fmt.Errorf("invalid parameter: fields must be an array") + } + if len(fieldMaps) == 0 { + return nil, fmt.Errorf("fields array must not be empty") + } + + issueFields := make([]IssueFieldCreateOrUpdateInput, 0, len(fieldMaps)) + for _, fieldMap := range fieldMaps { + fieldID, err := RequiredParam[string](fieldMap, "field_id") + if err != nil { + return nil, fmt.Errorf("field_id is required and must be a string") + } + + input := IssueFieldCreateOrUpdateInput{ + FieldID: githubv4.ID(fieldID), + } + + // Count how many value keys are present; exactly one is required. + valueCount := 0 + + if v, err := OptionalParam[string](fieldMap, "text_value"); err == nil && v != "" { + input.TextValue = githubv4.NewString(githubv4.String(v)) + valueCount++ + } + if v, err := OptionalParam[float64](fieldMap, "number_value"); err == nil { + if _, exists := fieldMap["number_value"]; exists { + gqlFloat := githubv4.Float(v) + input.NumberValue = &gqlFloat + valueCount++ + } + } + if v, err := OptionalParam[string](fieldMap, "date_value"); err == nil && v != "" { + input.DateValue = githubv4.NewString(githubv4.String(v)) + valueCount++ + } + if v, err := OptionalParam[string](fieldMap, "single_select_option_id"); err == nil && v != "" { + optionID := githubv4.ID(v) + input.SingleSelectOptionID = &optionID + valueCount++ + } + if _, exists := fieldMap["delete"]; exists { + del, err := OptionalParam[bool](fieldMap, "delete") + if err == nil && del { + deleteVal := githubv4.Boolean(true) + input.Delete = &deleteVal + valueCount++ + } + } + + if valueCount == 0 { + return nil, fmt.Errorf("each field must have a value (text_value, number_value, date_value, single_select_option_id) or delete: true") + } + if valueCount > 1 { + return nil, fmt.Errorf("each field must have exactly one value (text_value, number_value, date_value, single_select_option_id) or delete: true, but multiple were provided") + } + + if _, exists := fieldMap["rationale"]; exists { + rationale, err := OptionalParam[string](fieldMap, "rationale") + if err != nil { + return nil, err + } + rationale = strings.TrimSpace(rationale) + if len([]rune(rationale)) > 280 { + return nil, fmt.Errorf("field rationale must be 280 characters or less") + } + if rationale != "" { + input.Rationale = githubv4.NewString(githubv4.String(rationale)) + } + } + + confidence, err := OptionalParam[string](fieldMap, "confidence") + if err != nil { + return nil, err + } + confidence = normalizeConfidence(confidence) + if confidence != "" && confidence != "LOW" && confidence != "MEDIUM" && confidence != "HIGH" { + return nil, fmt.Errorf("confidence must be one of: LOW, MEDIUM, HIGH") + } + if confidence != "" { + input.Confidence = &confidence + } + + isSuggestion, err := OptionalParam[bool](fieldMap, "is_suggestion") + if err != nil { + return nil, err + } + if isSuggestion { + suggestVal := githubv4.Boolean(true) + input.Suggest = &suggestVal + } + + issueFields = append(issueFields, input) + } + + return marshalGranularIssueArguments(args, GranularSetIssueFieldsInput{GranularIssueCoordinate: GranularIssueCoordinate{Owner: owner, Repo: repo, IssueNumber: issueNumber}, Fields: granularIssueFieldsFromMutation(issueFields)}) +} + +func normalizeGranularAddIssueReactionArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return nil, err + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return nil, err + } + issueNumber, err := RequiredInt(args, "issue_number") + if err != nil { + return nil, err + } + content, err := RequiredParam[string](args, "content") + if err != nil { + return nil, err + } + + return marshalGranularIssueArguments(args, GranularAddIssueReactionInput{GranularIssueCoordinate: GranularIssueCoordinate{Owner: owner, Repo: repo, IssueNumber: issueNumber}, Content: content}) +} + +func normalizeGranularRemoveIssueReactionArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return nil, err + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return nil, err + } + issueNumber, err := RequiredInt(args, "issue_number") + if err != nil { + return nil, err + } + reactionID, err := RequiredBigInt(args, "reaction_id") + if err != nil { + return nil, err + } + + return marshalGranularIssueArguments(args, GranularRemoveIssueReactionInput{GranularIssueCoordinate: GranularIssueCoordinate{Owner: owner, Repo: repo, IssueNumber: issueNumber}, ReactionID: reactionID}) +} + +func normalizeGranularAddIssueCommentReactionArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return nil, err + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return nil, err + } + commentID, err := RequiredBigInt(args, "comment_id") + if err != nil { + return nil, err + } + content, err := RequiredParam[string](args, "content") + if err != nil { + return nil, err + } + + return marshalGranularIssueArguments(args, GranularAddIssueCommentReactionInput{Owner: owner, Repo: repo, CommentID: commentID, Content: content}) +} + +func normalizeGranularRemoveIssueCommentReactionArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return nil, err + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return nil, err + } + commentID, err := RequiredBigInt(args, "comment_id") + if err != nil { + return nil, err + } + reactionID, err := RequiredBigInt(args, "reaction_id") + if err != nil { + return nil, err + } + + return marshalGranularIssueArguments(args, GranularRemoveIssueCommentReactionInput{Owner: owner, Repo: repo, CommentID: commentID, ReactionID: reactionID}) +} diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go index ebe7249cd4..977bbe9d4d 100644 --- a/pkg/github/granular_tools_test.go +++ b/pkg/github/granular_tools_test.go @@ -38,7 +38,8 @@ func granularToolsForToolset(toolsetID inventory.ToolsetID, featureFlag string) } func TestGranularToolSnaps(t *testing.T) { - // Test toolsnaps for all granular tools + // Issue snapshots are checked against modern ListTools output in the typed wire tests. + // The remaining granular tools continue to snapshot their raw registrations here. toolConstructors := []func(translations.TranslationHelperFunc) inventory.ServerTool{ GranularCreateIssue, GranularUpdateIssueTitle, @@ -80,6 +81,9 @@ func TestGranularToolSnaps(t *testing.T) { for _, constructor := range toolConstructors { serverTool := constructor(translations.NullTranslationHelper) t.Run(serverTool.Tool.Name, func(t *testing.T) { + if serverTool.Toolset.ID == ToolsetMetadataIssues.ID { + return + } require.NoError(t, toolsnaps.Test(serverTool.Tool.Name, serverTool.Tool)) }) } diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go index eabf89702b..da9a0d186a 100644 --- a/pkg/github/issues_granular.go +++ b/pkg/github/issues_granular.go @@ -2,7 +2,6 @@ package github import ( "context" - "encoding/json" "fmt" "maps" "strings" @@ -24,12 +23,15 @@ func normalizeConfidence(confidence string) string { } // issueUpdateTool is a helper to create single-field issue update tools. -func issueUpdateTool( +func issueUpdateTool[In interface { + issueCoordinate() GranularIssueCoordinate +}]( t translations.TranslationHelperFunc, name, description, title string, extraProps map[string]*jsonschema.Schema, extraRequired []string, - buildRequest func(args map[string]any) (github.UpdateIssueRequest, error), + buildRequest func(args In) github.UpdateIssueRequest, + normalizer inventory.InputNormalizer, ) inventory.ServerTool { props := map[string]*jsonschema.Schema{ "owner": { @@ -50,7 +52,7 @@ func issueUpdateTool( required := append([]string{"owner", "repo", "issue_number"}, extraRequired...) - st := NewTool( + st := NewTool[In, *MinimalResponse]( ToolsetMetadataIssues, mcp.Tool{ Name: name, @@ -68,24 +70,10 @@ func issueUpdateTool( }, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - issueNumber, err := RequiredInt(args, "issue_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - issueReq, err := buildRequest(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args In) (*mcp.CallToolResult, *MinimalResponse, error) { + coordinate := args.issueCoordinate() + owner, repo, issueNumber := coordinate.Owner, coordinate.Repo, coordinate.IssueNumber + issueReq := buildRequest(args) client, err := deps.GetClient(ctx) if err != nil { @@ -98,15 +86,12 @@ func issueUpdateTool( } defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(MinimalResponse{ + return granularIssueMinimalResult(&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 }, + normalizer, ) st.FeatureRule = issuesGranularFeatureRule return st @@ -114,7 +99,7 @@ func issueUpdateTool( // GranularCreateIssue creates a tool to create a new issue. func GranularCreateIssue(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := NewTool[GranularCreateIssueInput, *MinimalResponse]( ToolsetMetadataIssues, mcp.Tool{ Name: "create_issue", @@ -162,44 +147,15 @@ func GranularCreateIssue(t translations.TranslationHelperFunc) inventory.ServerT }, }, scopes.RequireAll(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 - } - title, err := RequiredParam[string](args, "title") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - body, _ := OptionalParam[string](args, "body") - parentIssueNumber, err := OptionalIntParam(args, "parent_issue_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - parentValue, parentProvided := args["parent_issue_number"] - parentProvided = parentProvided && parentValue != nil - if parentProvided && parentIssueNumber < 1 { - return utils.NewToolResultError("parent_issue_number must be greater than 0"), nil, nil - } - parentOwner, err := OptionalParam[string](args, "parent_owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - parentRepo, err := OptionalParam[string](args, "parent_repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - if err := validateParentRepository(parentProvided, parentOwner, parentRepo); err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - issueReq := github.CreateIssueRequest{ - Title: title, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularCreateIssueInput) (*mcp.CallToolResult, *MinimalResponse, error) { + owner, repo, title, body := args.Owner, args.Repo, args.Title, args.Body + parentProvided := args.ParentIssueNumber != nil + parentIssueNumber := 0 + if parentProvided { + parentIssueNumber = *args.ParentIssueNumber } + parentOwner, parentRepo := args.ParentOwner, args.ParentRepo + issueReq := github.CreateIssueRequest{Title: title} if body != "" { issueReq.Body = &body } @@ -214,8 +170,8 @@ func GranularCreateIssue(t translations.TranslationHelperFunc) inventory.ServerT if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil } - result, err := CreateIssueWithParent(ctx, client, gqlClient, owner, repo, title, body, nil, nil, 0, "", parentIssueNumber, parentOwner, parentRepo) - return result, nil, err + result, output, err := createIssueWithParent(ctx, client, gqlClient, owner, repo, title, body, nil, nil, 0, "", parentIssueNumber, parentOwner, parentRepo) + return result, output, err } issue, resp, err := client.Issues.Create(ctx, owner, repo, issueReq) @@ -224,15 +180,12 @@ func GranularCreateIssue(t translations.TranslationHelperFunc) inventory.ServerT } defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(MinimalResponse{ + return granularIssueMinimalResult(&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 }, + normalizeGranularCreateIssueArguments, ) st.FeatureRule = issuesGranularFeatureRule return st @@ -248,13 +201,10 @@ func GranularUpdateIssueTitle(t translations.TranslationHelperFunc) inventory.Se "title": {Type: "string", Description: "The new title for the issue"}, }, []string{"title"}, - func(args map[string]any) (github.UpdateIssueRequest, error) { - title, err := RequiredParam[string](args, "title") - if err != nil { - return github.UpdateIssueRequest{}, err - } - return github.UpdateIssueRequest{Title: &title}, nil + func(args GranularIssueTitleInput) github.UpdateIssueRequest { + return github.UpdateIssueRequest{Title: &args.Title} }, + normalizeGranularIssueUpdateArguments("title"), ) } @@ -268,19 +218,16 @@ func GranularUpdateIssueBody(t translations.TranslationHelperFunc) inventory.Ser "body": {Type: "string", Description: "The new body content for the issue"}, }, []string{"body"}, - func(args map[string]any) (github.UpdateIssueRequest, error) { - body, err := RequiredParam[string](args, "body") - if err != nil { - return github.UpdateIssueRequest{}, err - } - return github.UpdateIssueRequest{Body: &body}, nil + func(args GranularIssueBodyInput) github.UpdateIssueRequest { + return github.UpdateIssueRequest{Body: &args.Body} }, + normalizeGranularIssueUpdateArguments("body"), ) } // GranularUpdateIssueAssignees creates a tool to update an issue's assignees. func GranularUpdateIssueAssignees(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := NewTool[GranularIssueAssigneesInput, *MinimalResponse]( ToolsetMetadataIssues, mcp.Tool{ Name: "update_issue_assignees", @@ -347,95 +294,15 @@ func GranularUpdateIssueAssignees(t translations.TranslationHelperFunc) inventor }, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - issueNumber, err := RequiredInt(args, "issue_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - assigneesRaw, ok := args["assignees"] - if !ok { - return utils.NewToolResultError("missing required parameter: assignees"), nil, nil - } - assigneesSlice, ok := assigneesRaw.([]any) - if !ok { - // Also accept []string for callers that pre-typed the array. - if strs, ok := assigneesRaw.([]string); ok { - assigneesSlice = make([]any, len(strs)) - for i, s := range strs { - assigneesSlice[i] = s - } - } else { - return utils.NewToolResultError("parameter assignees must be an array"), nil, nil - } - } - - useObjectForm := false - payload := make([]any, 0, len(assigneesSlice)) - for _, item := range assigneesSlice { - switch v := item.(type) { - case string: - payload = append(payload, v) - case map[string]any: - login, err := RequiredParam[string](v, "login") - if err != nil { - return utils.NewToolResultError("each assignee object must have a 'login' string"), nil, nil - } - rationale, err := OptionalParam[string](v, "rationale") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - rationale = strings.TrimSpace(rationale) - if len([]rune(rationale)) > 280 { - return utils.NewToolResultError("assignee rationale must be 280 characters or less"), nil, nil - } - confidence, err := OptionalParam[string](v, "confidence") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - confidence = normalizeConfidence(confidence) - if confidence != "" && confidence != "LOW" && confidence != "MEDIUM" && confidence != "HIGH" { - return utils.NewToolResultError("confidence must be one of: LOW, MEDIUM, HIGH"), nil, nil - } - isSuggestion, err := OptionalParam[bool](v, "is_suggestion") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - if rationale == "" && !isSuggestion && confidence == "" { - payload = append(payload, login) - } else { - useObjectForm = true - payload = append(payload, assigneeWithIntent{Login: login, Rationale: rationale, Confidence: confidence, Suggest: isSuggestion}) - } - default: - return utils.NewToolResultError("each assignee must be a string or an object with 'login' and optional 'rationale', 'confidence', and/or 'is_suggestion'"), nil, nil - } - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularIssueAssigneesInput) (*mcp.CallToolResult, *MinimalResponse, error) { + owner, repo, issueNumber := args.Owner, args.Repo, args.IssueNumber client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } - var body any - if useObjectForm { - body = &assigneesUpdateRequest{Assignees: payload} - } else { - // Preserve the standard wire format when no rationale or suggest is supplied. - logins := make([]string, len(payload)) - for i, p := range payload { - logins[i] = p.(string) - } - body = &github.UpdateIssueRequest{Assignees: logins} - } + body := granularIssueAssigneesPayload(args.Assignees) apiURL := fmt.Sprintf("repos/%s/%s/issues/%d", owner, repo, issueNumber) req, err := client.NewRequest(ctx, "PATCH", apiURL, body) @@ -450,15 +317,12 @@ func GranularUpdateIssueAssignees(t translations.TranslationHelperFunc) inventor } defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(MinimalResponse{ + return granularIssueMinimalResult(&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 }, + normalizeGranularUpdateIssueAssigneesArguments, ) st.FeatureRule = issuesGranularFeatureRule return st @@ -498,7 +362,7 @@ type assigneesUpdateRequest struct { // GranularUpdateIssueLabels creates a tool to update an issue's labels. func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := NewTool[GranularIssueLabelsInput, *MinimalResponse]( ToolsetMetadataIssues, mcp.Tool{ Name: "update_issue_labels", @@ -565,95 +429,15 @@ func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.S }, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - issueNumber, err := RequiredInt(args, "issue_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - 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 - } - } - - useObjectForm := 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 - } - confidence, err := OptionalParam[string](v, "confidence") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - confidence = normalizeConfidence(confidence) - if confidence != "" && confidence != "LOW" && confidence != "MEDIUM" && confidence != "HIGH" { - return utils.NewToolResultError("confidence must be one of: LOW, MEDIUM, HIGH"), nil, nil - } - isSuggestion, err := OptionalParam[bool](v, "is_suggestion") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - if rationale == "" && !isSuggestion && confidence == "" { - payload = append(payload, name) - } else { - useObjectForm = true - payload = append(payload, labelWithIntent{Name: name, Rationale: rationale, Confidence: confidence, Suggest: isSuggestion}) - } - default: - return utils.NewToolResultError("each label must be a string or an object with 'name' and optional 'rationale', 'confidence', and/or 'is_suggestion'"), nil, nil - } - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularIssueLabelsInput) (*mcp.CallToolResult, *MinimalResponse, error) { + owner, repo, issueNumber := args.Owner, args.Repo, args.IssueNumber client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } - var body any - if useObjectForm { - body = &labelsUpdateRequest{Labels: payload} - } else { - // Preserve the standard wire format when no rationale or suggest is supplied. - names := make([]string, len(payload)) - for i, p := range payload { - names[i] = p.(string) - } - body = &github.UpdateIssueRequest{Labels: names} - } + body := granularIssueLabelsPayload(args.Labels) apiURL := fmt.Sprintf("repos/%s/%s/issues/%d", owner, repo, issueNumber) req, err := client.NewRequest(ctx, "PATCH", apiURL, body) @@ -668,15 +452,12 @@ func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.S } defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(MinimalResponse{ + return granularIssueMinimalResult(&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 }, + normalizeGranularUpdateIssueLabelsArguments, ) st.FeatureRule = issuesGranularFeatureRule return st @@ -696,13 +477,10 @@ func GranularUpdateIssueMilestone(t translations.TranslationHelperFunc) inventor }, }, []string{"milestone"}, - func(args map[string]any) (github.UpdateIssueRequest, error) { - milestone, err := RequiredInt(args, "milestone") - if err != nil { - return github.UpdateIssueRequest{}, err - } - return github.UpdateIssueRequest{Milestone: &milestone}, nil + func(args GranularIssueMilestoneInput) github.UpdateIssueRequest { + return github.UpdateIssueRequest{Milestone: &args.Milestone} }, + normalizeGranularIssueUpdateArguments("milestone"), ) } @@ -723,7 +501,7 @@ type issueTypeUpdateRequest struct { // GranularUpdateIssueType creates a tool to set or clear an issue's type. func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := NewTool[GranularIssueTypeInput, *MinimalResponse]( ToolsetMetadataIssues, mcp.Tool{ Name: "update_issue_type", @@ -778,49 +556,10 @@ func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.Ser }, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - issueNumber, err := RequiredInt(args, "issue_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - issueType, issueTypeProvided, err := OptionalNullableStringParam(args, "issue_type") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - if !issueTypeProvided { - return utils.NewToolResultError("missing required parameter: issue_type"), nil, nil - } - rationale, err := OptionalParam[string](args, "rationale") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - rationale = strings.TrimSpace(rationale) - if len([]rune(rationale)) > 280 { - return utils.NewToolResultError("parameter rationale must be 280 characters or less"), nil, nil - } - confidence, err := OptionalParam[string](args, "confidence") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - confidence = normalizeConfidence(confidence) - if confidence != "" && confidence != "LOW" && confidence != "MEDIUM" && confidence != "HIGH" { - return utils.NewToolResultError("confidence must be one of: LOW, MEDIUM, HIGH"), nil, nil - } - isSuggestion, err := OptionalParam[bool](args, "is_suggestion") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - if issueType == nil && (rationale != "" || confidence != "" || isSuggestion) { - return utils.NewToolResultError("suggestion metadata is not supported when removing an issue type; omit rationale, confidence, and is_suggestion"), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularIssueTypeInput) (*mcp.CallToolResult, *MinimalResponse, error) { + owner, repo, issueNumber := args.Owner, args.Repo, args.IssueNumber + issueType, rationale, confidence, isSuggestion := args.IssueType, args.Rationale, args.Confidence, args.IsSuggestion + client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil @@ -856,15 +595,12 @@ func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.Ser } defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(MinimalResponse{ + return granularIssueMinimalResult(&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 }, + normalizeGranularUpdateIssueTypeArguments, ) st.FeatureRule = issuesGranularFeatureRule return st @@ -889,7 +625,7 @@ type stateUpdateRequest struct { // GranularUpdateIssueState creates a tool to update an issue's state. func GranularUpdateIssueState(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := NewTool[GranularIssueStateInput, *MinimalResponse]( ToolsetMetadataIssues, mcp.Tool{ Name: "update_issue_state", @@ -952,60 +688,10 @@ func GranularUpdateIssueState(t translations.TranslationHelperFunc) inventory.Se }, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - issueNumber, err := RequiredInt(args, "issue_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - state, err := RequiredParam[string](args, "state") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - stateReason, err := OptionalParam[string](args, "state_reason") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - rationale, err := OptionalParam[string](args, "rationale") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - rationale = strings.TrimSpace(rationale) - if len([]rune(rationale)) > 280 { - return utils.NewToolResultError("parameter rationale must be 280 characters or less"), nil, nil - } - confidence, err := OptionalParam[string](args, "confidence") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - confidence = normalizeConfidence(confidence) - if confidence != "" && confidence != "LOW" && confidence != "MEDIUM" && confidence != "HIGH" { - return utils.NewToolResultError("confidence must be one of: LOW, MEDIUM, HIGH"), nil, nil - } - isSuggestion, err := OptionalParam[bool](args, "is_suggestion") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - duplicateOf, err := OptionalIntParam(args, "duplicate_of") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - if stateReason != "" && state != "closed" { - return utils.NewToolResultError("state_reason can only be used when state is 'closed'"), nil, nil - } - if duplicateOf != 0 && stateReason != "duplicate" { - return utils.NewToolResultError("duplicate_of can only be used when state_reason is 'duplicate'"), nil, nil - } - if isSuggestion && stateReason == "duplicate" && duplicateOf == 0 { - return utils.NewToolResultError("duplicate_of is required when suggesting a close as duplicate"), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularIssueStateInput) (*mcp.CallToolResult, *MinimalResponse, error) { + owner, repo, issueNumber := args.Owner, args.Repo, args.IssueNumber + state, stateReason := args.State, args.StateReason + rationale, confidence, isSuggestion, duplicateOf := args.Rationale, args.Confidence, args.IsSuggestion, args.DuplicateOf client, err := deps.GetClient(ctx) if err != nil { @@ -1054,15 +740,12 @@ func GranularUpdateIssueState(t translations.TranslationHelperFunc) inventory.Se } defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(MinimalResponse{ + return granularIssueMinimalResult(&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 }, + normalizeGranularUpdateIssueStateArguments, ) st.FeatureRule = issuesGranularFeatureRule return st @@ -1070,11 +753,12 @@ func GranularUpdateIssueState(t translations.TranslationHelperFunc) inventory.Se // GranularAddSubIssue creates a tool to add a sub-issue. func GranularAddSubIssue(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := NewTool[GranularAddSubIssueInput, *SubIssueWriteOutput]( ToolsetMetadataIssues, mcp.Tool{ - Name: "add_sub_issue", - Description: t("TOOL_ADD_SUB_ISSUE_DESCRIPTION", "Add a sub-issue to a parent issue."), + OutputSchema: subIssueWriteOutputSchema(), + Name: "add_sub_issue", + Description: t("TOOL_ADD_SUB_ISSUE_DESCRIPTION", "Add a sub-issue to a parent issue."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ADD_SUB_ISSUE_USER_TITLE", "Add Sub-Issue"), ReadOnlyHint: false, @@ -1110,33 +794,18 @@ func GranularAddSubIssue(t translations.TranslationHelperFunc) inventory.ServerT }, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - issueNumber, err := RequiredInt(args, "issue_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - subIssueID, err := RequiredInt(args, "sub_issue_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - replaceParent, _ := OptionalParam[bool](args, "replace_parent") + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularAddSubIssueInput) (*mcp.CallToolResult, *SubIssueWriteOutput, error) { + owner, repo, issueNumber, subIssueID, replaceParent := args.Owner, args.Repo, args.IssueNumber, args.SubIssueID, args.ReplaceParent client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } - result, err := AddSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, replaceParent) - return result, nil, err + result, output, err := addSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, replaceParent) + return subIssueWriteResult("add")(result, output, err) }, + normalizeGranularAddSubIssueArguments, ) st.FeatureRule = issuesGranularFeatureRule return st @@ -1144,11 +813,12 @@ func GranularAddSubIssue(t translations.TranslationHelperFunc) inventory.ServerT // GranularRemoveSubIssue creates a tool to remove a sub-issue. func GranularRemoveSubIssue(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := NewTool[GranularRemoveSubIssueInput, *SubIssueWriteOutput]( ToolsetMetadataIssues, mcp.Tool{ - Name: "remove_sub_issue", - Description: t("TOOL_REMOVE_SUB_ISSUE_DESCRIPTION", "Remove a sub-issue from a parent issue."), + OutputSchema: subIssueWriteOutputSchema(), + Name: "remove_sub_issue", + Description: t("TOOL_REMOVE_SUB_ISSUE_DESCRIPTION", "Remove a sub-issue from a parent issue."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_REMOVE_SUB_ISSUE_USER_TITLE", "Remove Sub-Issue"), ReadOnlyHint: false, @@ -1180,32 +850,18 @@ func GranularRemoveSubIssue(t translations.TranslationHelperFunc) inventory.Serv }, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - issueNumber, err := RequiredInt(args, "issue_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - subIssueID, err := RequiredInt(args, "sub_issue_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularRemoveSubIssueInput) (*mcp.CallToolResult, *SubIssueWriteOutput, error) { + owner, repo, issueNumber, subIssueID := args.Owner, args.Repo, args.IssueNumber, args.SubIssueID client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } - result, err := RemoveSubIssue(ctx, client, owner, repo, issueNumber, subIssueID) - return result, nil, err + result, output, err := removeSubIssue(ctx, client, owner, repo, issueNumber, subIssueID) + return subIssueWriteResult("remove")(result, output, err) }, + normalizeGranularRemoveSubIssueArguments, ) st.FeatureRule = issuesGranularFeatureRule return st @@ -1213,11 +869,12 @@ func GranularRemoveSubIssue(t translations.TranslationHelperFunc) inventory.Serv // GranularReprioritizeSubIssue creates a tool to reorder a sub-issue. func GranularReprioritizeSubIssue(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := NewTool[GranularReprioritizeSubIssueInput, *SubIssueWriteOutput]( ToolsetMetadataIssues, mcp.Tool{ - Name: "reprioritize_sub_issue", - Description: t("TOOL_REPRIORITIZE_SUB_ISSUE_DESCRIPTION", "Reprioritize (reorder) a sub-issue relative to other sub-issues."), + OutputSchema: subIssueWriteOutputSchema(), + Name: "reprioritize_sub_issue", + Description: t("TOOL_REPRIORITIZE_SUB_ISSUE_DESCRIPTION", "Reprioritize (reorder) a sub-issue relative to other sub-issues."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_REPRIORITIZE_SUB_ISSUE_USER_TITLE", "Reprioritize Sub-Issue"), ReadOnlyHint: false, @@ -1257,40 +914,18 @@ func GranularReprioritizeSubIssue(t translations.TranslationHelperFunc) inventor }, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - issueNumber, err := RequiredInt(args, "issue_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - subIssueID, err := RequiredInt(args, "sub_issue_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - afterID, err := OptionalIntParam(args, "after_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - beforeID, err := OptionalIntParam(args, "before_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularReprioritizeSubIssueInput) (*mcp.CallToolResult, *SubIssueWriteOutput, error) { + owner, repo, issueNumber, subIssueID, afterID, beforeID := args.Owner, args.Repo, args.IssueNumber, args.SubIssueID, args.AfterID, args.BeforeID client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } - result, err := ReprioritizeSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, afterID, beforeID) - return result, nil, err + result, output, err := reprioritizeSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, afterID, beforeID) + return subIssueWriteResult("reprioritize")(result, output, err) }, + normalizeGranularReprioritizeSubIssueArguments, ) st.FeatureRule = issuesGranularFeatureRule return st @@ -1339,7 +974,7 @@ func SetIssueFieldValues(ctx context.Context, gqlClient *githubv4.Client, input // GranularSetIssueFields creates a tool to set issue field values on an issue using GraphQL. func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := NewTool[GranularSetIssueFieldsInput, *MinimalResponse]( ToolsetMetadataIssues, mcp.Tool{ Name: "set_issue_fields", @@ -1422,132 +1057,9 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv }, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - issueNumber, err := RequiredInt(args, "issue_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - fieldsRaw, ok := args["fields"] - if !ok { - return utils.NewToolResultError("missing required parameter: fields"), nil, nil - } - - // Accept both []any and []map[string]any input forms - var fieldMaps []map[string]any - switch v := fieldsRaw.(type) { - case []any: - for _, f := range v { - fieldMap, ok := f.(map[string]any) - if !ok { - return utils.NewToolResultError("each field must be an object with 'field_id' and a value"), nil, nil - } - fieldMaps = append(fieldMaps, fieldMap) - } - case []map[string]any: - fieldMaps = v - default: - return utils.NewToolResultError("invalid parameter: fields must be an array"), nil, nil - } - if len(fieldMaps) == 0 { - return utils.NewToolResultError("fields array must not be empty"), nil, nil - } - - issueFields := make([]IssueFieldCreateOrUpdateInput, 0, len(fieldMaps)) - for _, fieldMap := range fieldMaps { - fieldID, err := RequiredParam[string](fieldMap, "field_id") - if err != nil { - return utils.NewToolResultError("field_id is required and must be a string"), nil, nil - } - - input := IssueFieldCreateOrUpdateInput{ - FieldID: githubv4.ID(fieldID), - } - - // Count how many value keys are present; exactly one is required. - valueCount := 0 - - if v, err := OptionalParam[string](fieldMap, "text_value"); err == nil && v != "" { - input.TextValue = githubv4.NewString(githubv4.String(v)) - valueCount++ - } - if v, err := OptionalParam[float64](fieldMap, "number_value"); err == nil { - if _, exists := fieldMap["number_value"]; exists { - gqlFloat := githubv4.Float(v) - input.NumberValue = &gqlFloat - valueCount++ - } - } - if v, err := OptionalParam[string](fieldMap, "date_value"); err == nil && v != "" { - input.DateValue = githubv4.NewString(githubv4.String(v)) - valueCount++ - } - if v, err := OptionalParam[string](fieldMap, "single_select_option_id"); err == nil && v != "" { - optionID := githubv4.ID(v) - input.SingleSelectOptionID = &optionID - valueCount++ - } - if _, exists := fieldMap["delete"]; exists { - del, err := OptionalParam[bool](fieldMap, "delete") - if err == nil && del { - deleteVal := githubv4.Boolean(true) - input.Delete = &deleteVal - valueCount++ - } - } - - if valueCount == 0 { - return utils.NewToolResultError("each field must have a value (text_value, number_value, date_value, single_select_option_id) or delete: true"), nil, nil - } - if valueCount > 1 { - 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)) - } - } - - confidence, err := OptionalParam[string](fieldMap, "confidence") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - confidence = normalizeConfidence(confidence) - if confidence != "" && confidence != "LOW" && confidence != "MEDIUM" && confidence != "HIGH" { - return utils.NewToolResultError("confidence must be one of: LOW, MEDIUM, HIGH"), nil, nil - } - if confidence != "" { - input.Confidence = &confidence - } - - isSuggestion, err := OptionalParam[bool](fieldMap, "is_suggestion") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - if isSuggestion { - suggestVal := githubv4.Boolean(true) - input.Suggest = &suggestVal - } - - issueFields = append(issueFields, input) - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularSetIssueFieldsInput) (*mcp.CallToolResult, *MinimalResponse, error) { + owner, repo, issueNumber := args.Owner, args.Repo, args.IssueNumber + issueFields := granularIssueFieldMutation(args.Fields) gqlClient, err := deps.GetGQLClient(ctx) if err != nil { @@ -1573,12 +1085,9 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to set issue field values", err), nil, nil } - r, err := json.Marshal(response) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil - } - return utils.NewToolResultText(string(r)), nil, nil + return granularIssueMinimalResult(&response) }, + normalizeGranularSetIssueFieldsArguments, ) st.FeatureRule = issuesGranularFeatureRule return st @@ -1586,7 +1095,7 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv // GranularAddIssueReaction adds a reaction to an issue or pull request. func GranularAddIssueReaction(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := NewTool[GranularAddIssueReactionInput, *MinimalResponse]( ToolsetMetadataIssues, mcp.Tool{ Name: "add_issue_reaction", @@ -1623,23 +1132,8 @@ func GranularAddIssueReaction(t translations.TranslationHelperFunc) inventory.Se }, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - issueNumber, err := RequiredInt(args, "issue_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - content, err := RequiredParam[string](args, "content") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularAddIssueReactionInput) (*mcp.CallToolResult, *MinimalResponse, error) { + owner, repo, issueNumber, content := args.Owner, args.Repo, args.IssueNumber, args.Content client, err := deps.GetClient(ctx) if err != nil { @@ -1652,15 +1146,12 @@ func GranularAddIssueReaction(t translations.TranslationHelperFunc) inventory.Se } defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(MinimalResponse{ + return granularIssueMinimalResult(&MinimalResponse{ ID: fmt.Sprintf("%d", reaction.GetID()), URL: fmt.Sprintf("%srepos/%s/%s/issues/%d/reactions/%d", client.BaseURL(), owner, repo, issueNumber, reaction.GetID()), }) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil - } - return utils.NewToolResultText(string(r)), nil, nil }, + normalizeGranularAddIssueReactionArguments, ) st.FeatureRule = issuesGranularFeatureRule return st @@ -1668,7 +1159,7 @@ func GranularAddIssueReaction(t translations.TranslationHelperFunc) inventory.Se // GranularRemoveIssueReaction removes a reaction from an issue or pull request. func GranularRemoveIssueReaction(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := NewTool[GranularRemoveIssueReactionInput, *GranularIssueReactionMessage]( ToolsetMetadataIssues, mcp.Tool{ Name: "remove_issue_reaction", @@ -1705,23 +1196,8 @@ func GranularRemoveIssueReaction(t translations.TranslationHelperFunc) inventory }, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - owner, err := RequiredParam[string](args, "owner") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - repo, err := RequiredParam[string](args, "repo") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - issueNumber, err := RequiredInt(args, "issue_number") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - reactionID, err := RequiredBigInt(args, "reaction_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularRemoveIssueReactionInput) (*mcp.CallToolResult, *GranularIssueReactionMessage, error) { + owner, repo, issueNumber, reactionID := args.Owner, args.Repo, args.IssueNumber, args.ReactionID client, err := deps.GetClient(ctx) if err != nil { @@ -1736,8 +1212,9 @@ func GranularRemoveIssueReaction(t translations.TranslationHelperFunc) inventory return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to remove reaction from issue", resp, err), nil, nil } - return utils.NewToolResultText("reaction successfully removed from issue"), nil, nil + return utils.NewToolResultText("reaction successfully removed from issue"), &GranularIssueReactionMessage{Message: "reaction successfully removed from issue"}, nil }, + normalizeGranularRemoveIssueReactionArguments, ) st.FeatureRule = issuesGranularFeatureRule return st @@ -1745,7 +1222,7 @@ func GranularRemoveIssueReaction(t translations.TranslationHelperFunc) inventory // GranularAddIssueCommentReaction adds a reaction to an issue or pull request comment. func GranularAddIssueCommentReaction(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := NewTool[GranularAddIssueCommentReactionInput, *MinimalResponse]( ToolsetMetadataIssues, mcp.Tool{ Name: "add_issue_comment_reaction", @@ -1782,23 +1259,8 @@ func GranularAddIssueCommentReaction(t translations.TranslationHelperFunc) inven }, }, scopes.RequireAll(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 - } - commentID, err := RequiredBigInt(args, "comment_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - content, err := RequiredParam[string](args, "content") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularAddIssueCommentReactionInput) (*mcp.CallToolResult, *MinimalResponse, error) { + owner, repo, commentID, content := args.Owner, args.Repo, args.CommentID, args.Content client, err := deps.GetClient(ctx) if err != nil { @@ -1811,15 +1273,12 @@ func GranularAddIssueCommentReaction(t translations.TranslationHelperFunc) inven } defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(MinimalResponse{ + return granularIssueMinimalResult(&MinimalResponse{ ID: fmt.Sprintf("%d", reaction.GetID()), URL: fmt.Sprintf("%srepos/%s/%s/issues/comments/%d/reactions/%d", client.BaseURL(), owner, repo, commentID, reaction.GetID()), }) - if err != nil { - return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil - } - return utils.NewToolResultText(string(r)), nil, nil }, + normalizeGranularAddIssueCommentReactionArguments, ) st.FeatureRule = issuesGranularFeatureRule return st @@ -1827,7 +1286,7 @@ func GranularAddIssueCommentReaction(t translations.TranslationHelperFunc) inven // GranularRemoveIssueCommentReaction removes a reaction from an issue or pull request comment. func GranularRemoveIssueCommentReaction(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := NewTool[GranularRemoveIssueCommentReactionInput, *GranularIssueReactionMessage]( ToolsetMetadataIssues, mcp.Tool{ Name: "remove_issue_comment_reaction", @@ -1864,23 +1323,8 @@ func GranularRemoveIssueCommentReaction(t translations.TranslationHelperFunc) in }, }, scopes.RequireAll(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 - } - commentID, err := RequiredBigInt(args, "comment_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - reactionID, err := RequiredBigInt(args, "reaction_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularRemoveIssueCommentReactionInput) (*mcp.CallToolResult, *GranularIssueReactionMessage, error) { + owner, repo, commentID, reactionID := args.Owner, args.Repo, args.CommentID, args.ReactionID client, err := deps.GetClient(ctx) if err != nil { @@ -1895,8 +1339,9 @@ func GranularRemoveIssueCommentReaction(t translations.TranslationHelperFunc) in return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to remove reaction from issue comment", resp, err), nil, nil } - return utils.NewToolResultText("reaction successfully removed from issue comment"), nil, nil + return utils.NewToolResultText("reaction successfully removed from issue comment"), &GranularIssueReactionMessage{Message: "reaction successfully removed from issue comment"}, nil }, + normalizeGranularRemoveIssueCommentReactionArguments, ) st.FeatureRule = issuesGranularFeatureRule return st diff --git a/pkg/github/typed_granular_issue_outputs_test.go b/pkg/github/typed_granular_issue_outputs_test.go new file mode 100644 index 0000000000..259cfc32ee --- /dev/null +++ b/pkg/github/typed_granular_issue_outputs_test.go @@ -0,0 +1,466 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + "strings" + "testing" + + "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/inventory" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func granularIssueSession(t *testing.T, deps BaseDeps, protocol string) (*mcp.ClientSession, map[string]*jsonschema.Resolved) { + t.Helper() + tools := granularToolsForToolset(ToolsetMetadataIssues.ID, FeatureFlagIssuesGranular) + require.Len(t, tools, 18) + inv, err := inventory.NewBuilder().SetTools(tools).WithToolsets([]string{"all"}). + WithFeatureChecker(featureCheckerFor(inventory.FeatureFlag(FeatureFlagIssuesGranular))).Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "granular-issues", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + if protocol == "" { + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) { + switch req := request.(type) { + case *mcp.ListToolsRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + case *mcp.CallToolRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + } + return next(ctx, method, request) + } + }) + } + version := protocol + if version == "" { + version = inventory.ProtocolVersionMultiRoundTrip + } + session := connectCommentVisibilityClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, len(tools)) + schemas := make(map[string]*jsonschema.Resolved) + for _, tool := range list.Tools { + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Nil(t, tool.OutputSchema, tool.Name) + continue + } + require.NotNil(t, tool.OutputSchema, tool.Name) + require.NoError(t, toolsnaps.Test(tool.Name, *tool)) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, tool.OutputSchema)), &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err, tool.Name) + schemas[tool.Name] = resolved + if tool.Name == "add_sub_issue" { + schemaJSON := mustMarshalJSON(t, tool.OutputSchema) + for _, excluded := range []string{`"url"`, `"comments_url"`, `"events_url"`, `"labels_url"`, `"repository_url"`, `"profile_url"`, `"avatar_url"`, `"pinned_comment"`, `"performed_via_github_app"`} { + assert.NotContains(t, schemaJSON, excluded) + } + assert.Less(t, len(schemaJSON), 12000) + } + } + return session, schemas +} + +type granularIssueWireCase struct { + name string + args map[string]any + text string + structured string + payload string + path string + method string +} + +const granularMutationText = `{"id":"123","url":"https://github.com/owner/repo/issues/1"}` +const granularSubIssueAPIResponse = `{"id":7,"number":7,"title":"Child\u202e","body":"body\u202e","url":"https://api.github.com/repos/owner/repo/issues/7","html_url":"https://github.com/owner/repo/issues/7","comments_url":"https://api.github.com/repos/owner/repo/issues/7/comments","node_id":"I_7","issue_field_values":[{"issue_field_id":11,"node_id":"IF_11","data_type":"number","value":0},{"issue_field_id":12,"node_id":"IF_12","data_type":"text","value":""},{"issue_field_id":13,"node_id":"IF_13","data_type":"text","value":false},{"issue_field_id":14,"node_id":"IF_14","data_type":"text","value":null}]}` +const granularSubIssueText = `{"id":7,"number":7,"title":"Child","body":"body","url":"https://api.github.com/repos/owner/repo/issues/7","html_url":"https://github.com/owner/repo/issues/7","comments_url":"https://api.github.com/repos/owner/repo/issues/7/comments","node_id":"I_7","issue_field_values":[{"issue_field_id":11,"node_id":"IF_11","data_type":"number","value":0},{"issue_field_id":12,"node_id":"IF_12","data_type":"text","value":""},{"issue_field_id":13,"node_id":"IF_13","data_type":"text","value":false},{"issue_field_id":14,"node_id":"IF_14","data_type":"text","value":null}]}` + +const granularSubIssueStructured = `{"method":"add","issue":{"id":7,"number":7,"title":"Child","body":"body","html_url":"https://github.com/owner/repo/issues/7","assignees":[],"field_values":[{"issue_field_id":11,"data_type":"number","value":0},{"issue_field_id":12,"data_type":"text","value":""},{"issue_field_id":13,"data_type":"text","value":false},{"issue_field_id":14,"data_type":"text","value":null}]}}` + +func granularIssueWireCases() []granularIssueWireCase { + cases := []granularIssueWireCase{ + {"create_issue", map[string]any{"title": "Subject"}, granularMutationText, "", `{"title":"Subject"}`, "/repos/owner/repo/issues", "POST"}, + {"create_issue", map[string]any{"title": "Subject", "body": "Body"}, granularMutationText, "", `{"title":"Subject","body":"Body"}`, "/repos/owner/repo/issues", "POST"}, + {"create_issue", map[string]any{"title": "Subject", "body": ""}, granularMutationText, "", `{"title":"Subject"}`, "/repos/owner/repo/issues", "POST"}, + {"create_issue", map[string]any{"title": "Subject", "parent_issue_number": "7.0"}, granularMutationText, "", "", "", ""}, + {"create_issue", map[string]any{"title": "Subject", "body": "Body", "parent_issue_number": "7", "parent_owner": "other", "parent_repo": "parent"}, granularMutationText, "", "", "", ""}, + {"update_issue_title", map[string]any{"title": "Changed"}, granularMutationText, "", `{"title":"Changed"}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_body", map[string]any{"body": "Changed"}, granularMutationText, "", `{"body":"Changed"}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_assignees", map[string]any{"assignees": []string{}}, granularMutationText, "", `{"assignees":[]}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_assignees", map[string]any{"assignees": []any{"octocat", map[string]any{"login": "hubot"}}}, granularMutationText, "", `{"assignees":["octocat","hubot"]}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_assignees", map[string]any{"assignees": []any{"hubot", map[string]any{"login": "octocat", "rationale": " Authored file ", "confidence": " high\t", "is_suggestion": true}}}, granularMutationText, "", `{"assignees":["hubot",{"login":"octocat","rationale":"Authored file","confidence":"HIGH","suggest":true}]}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_labels", map[string]any{"labels": []string{}}, granularMutationText, "", `{"labels":[]}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_labels", map[string]any{"labels": []any{"bug", map[string]any{"name": "triage"}}}, granularMutationText, "", `{"labels":["bug","triage"]}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_labels", map[string]any{"labels": []any{"triage", map[string]any{"name": "bug", "rationale": " Crash ", "confidence": " medium ", "is_suggestion": true}}}, granularMutationText, "", `{"labels":["triage",{"name":"bug","rationale":"Crash","confidence":"MEDIUM","suggest":true}]}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_milestone", map[string]any{"milestone": "3e0"}, granularMutationText, "", `{"milestone":3}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_type", map[string]any{"issue_type": nil}, granularMutationText, "", `{"type":null}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_type", map[string]any{"issue_type": "Bug"}, granularMutationText, "", `{"type":"Bug"}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_type", map[string]any{"issue_type": "Bug", "rationale": " Crash ", "confidence": " low ", "is_suggestion": true}, granularMutationText, "", `{"type":{"value":"Bug","rationale":"Crash","confidence":"LOW","suggest":true}}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_state", map[string]any{"state": "open"}, granularMutationText, "", `{"state":"open"}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_state", map[string]any{"state": "closed", "state_reason": "completed"}, granularMutationText, "", `{"state":"closed","state_reason":"completed"}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_state", map[string]any{"state": "closed", "state_reason": "not_planned", "confidence": " high "}, granularMutationText, "", `{"state":{"value":"closed","confidence":"HIGH"},"state_reason":"not_planned"}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"update_issue_state", map[string]any{"state": "closed", "state_reason": "duplicate", "duplicate_of": "2.0", "is_suggestion": true}, granularMutationText, "", `{"state":{"value":"closed","suggest":true},"state_reason":"duplicate","duplicate_issue_id":456}`, "/repos/owner/repo/issues/1", "PATCH"}, + {"add_sub_issue", map[string]any{"sub_issue_id": "7", "replace_parent": true}, granularSubIssueText, granularSubIssueStructured, `{"sub_issue_id":7,"replace_parent":true}`, "/repos/owner/repo/issues/1/sub_issues", "POST"}, + {"remove_sub_issue", map[string]any{"sub_issue_id": "7"}, granularSubIssueText, granularSubIssueStructured, `{"sub_issue_id":7}`, "/repos/owner/repo/issues/1/sub_issue", "DELETE"}, + {"reprioritize_sub_issue", map[string]any{"sub_issue_id": "7", "after_id": "8.0"}, granularSubIssueText, granularSubIssueStructured, `{"sub_issue_id":7,"after_id":8}`, "/repos/owner/repo/issues/1/sub_issues/priority", "PATCH"}, + {"reprioritize_sub_issue", map[string]any{"sub_issue_id": "7", "before_id": "8"}, granularSubIssueText, granularSubIssueStructured, `{"sub_issue_id":7,"before_id":8}`, "/repos/owner/repo/issues/1/sub_issues/priority", "PATCH"}, + {"set_issue_fields", map[string]any{"fields": []any{map[string]any{"field_id": "IF_text", "text_value": "Value", "rationale": " Signal ", "confidence": " high ", "is_suggestion": true}, map[string]any{"field_id": "IF_number", "number_value": 0}, map[string]any{"field_id": "IF_date", "date_value": "2026-10-02"}, map[string]any{"field_id": "IF_select", "single_select_option_id": "OPT_1"}, map[string]any{"field_id": "IF_delete", "delete": true}}}, `{"id":"I_1","url":"https://github.com/owner/repo/issues/1"}`, "", "", "", ""}, + {"add_issue_reaction", map[string]any{"content": "+1"}, `{"id":"9","url":"https://api.github.com/repos/owner/repo/issues/1/reactions/9"}`, "", `{"content":"+1"}`, "/repos/owner/repo/issues/1/reactions", "POST"}, + {"remove_issue_reaction", map[string]any{"reaction_id": "9e0"}, "reaction successfully removed from issue", `{"message":"reaction successfully removed from issue"}`, "", "/repos/owner/repo/issues/1/reactions/9", "DELETE"}, + {"add_issue_comment_reaction", map[string]any{"comment_id": "42.0", "content": "-1"}, `{"id":"9","url":"https://api.github.com/repos/owner/repo/issues/comments/42/reactions/9"}`, "", `{"content":"-1"}`, "/repos/owner/repo/issues/comments/42/reactions", "POST"}, + {"remove_issue_comment_reaction", map[string]any{"comment_id": "42", "reaction_id": "9"}, "reaction successfully removed from issue comment", `{"message":"reaction successfully removed from issue comment"}`, "", "/repos/owner/repo/issues/comments/42/reactions/9", "DELETE"}, + {"hide_issue_comment", map[string]any{"comment_id": "42", "classifier": "oFf_ToPiC"}, `{"node_id":"NODE_1","is_minimized":true,"minimized_reason":"off-topic"}`, "", "", "", ""}, + {"unhide_issue_comment", map[string]any{"comment_id": "42"}, `{"node_id":"NODE_1","is_minimized":false}`, "", "", "", ""}, + } + for _, reaction := range []string{"+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"} { + cases = append(cases, + granularIssueWireCase{ + name: "add_issue_reaction", args: map[string]any{"content": reaction}, + text: `{"id":"9","url":"https://api.github.com/repos/owner/repo/issues/1/reactions/9"}`, + payload: fmt.Sprintf(`{"content":%q}`, reaction), path: "/repos/owner/repo/issues/1/reactions", method: "POST", + }, + granularIssueWireCase{ + name: "add_issue_comment_reaction", args: map[string]any{"comment_id": "42", "content": reaction}, + text: `{"id":"9","url":"https://api.github.com/repos/owner/repo/issues/comments/42/reactions/9"}`, + payload: fmt.Sprintf(`{"content":%q}`, reaction), path: "/repos/owner/repo/issues/comments/42/reactions", method: "POST", + }) + } + return cases +} + +func granularWireDeps(t *testing.T, current **granularIssueWireCase, fail bool) BaseDeps { + t.Helper() + rest := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tc := *current + if fail { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"Forbidden"}`)) + return + } + if r.Method == "GET" && r.URL.Path == "/repos/owner/repo/issues/2" { + _, _ = w.Write([]byte(`{"id":456,"number":2}`)) + return + } + if strings.HasSuffix(r.URL.Path, "/comments/42") { + assert.Equal(t, "GET", r.Method) + _, _ = w.Write([]byte(`{"node_id":"NODE_1"}`)) + return + } + require.Equal(t, tc.path, r.URL.Path) + require.Equal(t, tc.method, r.Method) + if tc.payload != "" { + var body json.RawMessage + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.JSONEq(t, tc.payload, string(body)) + } + if strings.Contains(tc.name, "remove_issue") { + w.WriteHeader(http.StatusNoContent) + return + } + if r.Method == "POST" { + w.WriteHeader(http.StatusCreated) + } + switch { + case strings.Contains(tc.name, "sub_issue"): + _, _ = w.Write([]byte(granularSubIssueAPIResponse)) + case strings.Contains(tc.name, "reaction"): + _, _ = w.Write([]byte(`{"id":9}`)) + default: + _, _ = w.Write([]byte(`{"id":123,"html_url":"https://github.com/owner/repo/issues/1"}`)) + } + })}} + gql := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if fail { + _, _ = w.Write([]byte(`{"errors":[{"message":"Forbidden"}]}`)) + return + } + tc := *current + var request struct { + Query string `json:"query"` + Variables map[string]json.RawMessage `json:"variables"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + switch { + case strings.Contains(request.Query, "parentRepository:"): + parentOwner, parentRepo := "owner", "repo" + if tc.args["parent_owner"] != nil { + parentOwner, parentRepo = tc.args["parent_owner"].(string), tc.args["parent_repo"].(string) + } + assert.JSONEq(t, fmt.Sprintf(`"%s"`, parentOwner), string(request.Variables["parentOwner"])) + assert.JSONEq(t, fmt.Sprintf(`"%s"`, parentRepo), string(request.Variables["parentRepo"])) + assert.JSONEq(t, `7`, string(request.Variables["parentIssueNumber"])) + _, _ = w.Write([]byte(`{"data":{"childRepository":{"id":"R_1","nameWithOwner":"owner/repo"},"parentRepository":{"issue":{"id":"I_parent","number":7}}}}`)) + case strings.Contains(request.Query, "createIssue("): + var input map[string]any + require.NoError(t, json.Unmarshal(request.Variables["input"], &input)) + assert.Equal(t, "R_1", input["repositoryId"]) + assert.Equal(t, "I_parent", input["parentIssueId"]) + assert.Equal(t, "Subject", input["title"]) + if tc.args["body"] != nil { + assert.Equal(t, tc.args["body"], input["body"]) + } + _, _ = w.Write([]byte(`{"data":{"createIssue":{"issue":{"fullDatabaseId":"123","url":"https://github.com/owner/repo/issues/1"}}}}`)) + case strings.Contains(request.Query, "setIssueFieldValue("): + assert.Contains(t, r.Header.Get(headers.GraphQLFeaturesHeader), "update_issue_suggestions") + assert.JSONEq(t, `{"issueId":"I_1","issueFields":[{"fieldId":"IF_text","textValue":"Value","rationale":"Signal","confidence":"HIGH","suggest":true},{"fieldId":"IF_number","numberValue":0},{"fieldId":"IF_date","dateValue":"2026-10-02"},{"fieldId":"IF_select","singleSelectOptionId":"OPT_1"},{"fieldId":"IF_delete","delete":true}]}`, string(request.Variables["input"])) + _, _ = w.Write([]byte(`{"data":{"setIssueFieldValue":{"issue":{"id":"I_1","url":"https://github.com/owner/repo/issues/1"}}}}`)) + case strings.Contains(request.Query, "unminimizeComment("): + _, _ = w.Write([]byte(`{"data":{"unminimizeComment":{"unminimizedComment":{"isMinimized":false}}}}`)) + case strings.Contains(request.Query, "minimizeComment("): + _, _ = w.Write([]byte(`{"data":{"minimizeComment":{"minimizedComment":{"isMinimized":true,"minimizedReason":"off-topic"}}}}`)) + case strings.Contains(request.Query, "issue(number:"): + _, _ = w.Write([]byte(`{"data":{"repository":{"issue":{"id":"I_1"}}}}`)) + default: + t.Errorf("unexpected GraphQL query: %s", request.Query) + } + })}} + gql.Transport = &transportpkg.GraphQLFeaturesTransport{Transport: gql.Transport} + return BaseDeps{Client: mustNewGHClient(t, rest), GQLClient: githubv4.NewClient(gql)} +} + +func TestTypedGranularIssueWireOutputs(t *testing.T) { + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", ""} { + t.Run("protocol="+protocol, func(t *testing.T) { + var current *granularIssueWireCase + session, schemas := granularIssueSession(t, granularWireDeps(t, ¤t, false), protocol) + for i, tc := range granularIssueWireCases() { + t.Run(fmt.Sprintf("%s/%d", tc.name, i), func(t *testing.T) { + current = &tc + args := map[string]any{"owner": "owner", "repo": "repo", "issue_number": "1.0"} + maps.Copy(args, tc.args) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: args}) + require.NoError(t, err) + require.False(t, result.IsError, mustMarshalJSON(t, result)) + require.Len(t, result.Content, 1) + expected := tc.text + if schema := schemas[tc.name]; schema != nil { + if tc.structured != "" { + expected = tc.structured + } else { + expected = tc.text + } + if strings.Contains(tc.name, "sub_issue") { + method := map[string]string{"add_sub_issue": "add", "remove_sub_issue": "remove", "reprioritize_sub_issue": "reprioritize"}[tc.name] + expected = strings.Replace(expected, `"method":"add"`, fmt.Sprintf(`"method":%q`, method), 1) + } + require.NotNil(t, result.StructuredContent) + assert.JSONEq(t, expected, mustMarshalJSON(t, result.StructuredContent)) + assert.JSONEq(t, expected, getTextResult(t, result).Text) + assert.JSONEq(t, getTextResult(t, result).Text, mustMarshalJSON(t, result.StructuredContent)) + require.NoError(t, schema.Validate(result.StructuredContent)) + if strings.Contains(tc.name, "sub_issue") { + var output map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + for _, excluded := range []string{"url", "comments_url", "events_url", "labels_url", "repository_url", "node_id"} { + assert.NotContains(t, output, excluded) + } + } + } else { + assert.Equal(t, expected, getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + } + }) + } + }) + } +} + +func TestGranularSubIssueStructuredSchema(t *testing.T) { + resolved, err := subIssueWriteOutputSchema().Resolve(nil) + require.NoError(t, err) + for _, raw := range []string{ + `{"method":"add","issue":null}`, + `{"method":"add","issue":{"id":7,"number":7,"state":"open","title":"Child","html_url":"https://github.com/owner/repo/issues/7","assignees":[],"field_values":[]}}`, + `{"method":"reprioritize","issue":{"id":7,"assignees":[],"field_values":[{"issue_field_id":11,"data_type":"number","value":0},{"issue_field_id":12,"data_type":"text","value":null}]}}`, + } { + var output any + require.NoError(t, json.Unmarshal([]byte(raw), &output)) + require.NoError(t, resolved.Validate(output), raw) + } + for _, raw := range []string{ + `{"method":"other","issue":null}`, + `{"method":"add","issue":{"state":"queued"}}`, + `{"method":"add","issue":{"url":"https://api.github.com/repos/owner/repo/issues/7"}}`, + `{"method":"add","issue":{"comments_url":"https://api.github.com/repos/owner/repo/issues/7/comments"}}`, + } { + var output any + require.NoError(t, json.Unmarshal([]byte(raw), &output)) + require.Error(t, resolved.Validate(output), raw) + } +} + +func TestTypedGranularIssueVisibilityGates(t *testing.T) { + tools := granularToolsForToolset(ToolsetMetadataIssues.ID, FeatureFlagIssuesGranular) + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25"} { + for _, gate := range []string{"feature disabled", "read only", "missing repo scope"} { + t.Run(protocol+"/"+gate, func(t *testing.T) { + builder := inventory.NewBuilder().SetTools(tools).WithToolsets([]string{"all"}). + WithFeatureChecker(func(context.Context, string) (bool, error) { return gate != "feature disabled", nil }) + switch gate { + case "read only": + builder.WithReadOnly(true) + case "missing repo scope": + builder.WithFilter(CreateToolScopeFilter([]string{"public_repo"})) + } + inv, err := builder.Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "granular-gates", Version: "v1"}, nil) + inv.RegisterTools(context.Background(), server, nil) + session := connectCommentVisibilityClient(t, server, protocol) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + assert.Empty(t, list.Tools) + for _, tool := range tools { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tool.Tool.Name}) + if err == nil { + require.NotNil(t, result) + assert.True(t, result.IsError, tool.Tool.Name) + assert.Nil(t, result.StructuredContent, tool.Tool.Name) + } + } + }) + } + } +} + +func TestTypedGranularIssueWireErrors(t *testing.T) { + cases := []struct { + name string + args map[string]any + text string + }{ + {"create_issue", map[string]any{}, "missing required parameter: title"}, + {"create_issue", map[string]any{"title": "Subject", "parent_issue_number": 0}, "parent_issue_number must be greater than 0"}, + {"create_issue", map[string]any{"title": "Subject", "parent_issue_number": nil}, "not a valid number"}, + {"create_issue", map[string]any{"title": "Subject", "parent_owner": "other"}, "can only be used"}, + {"create_issue", map[string]any{"title": "Subject", "parent_issue_number": 7, "parent_repo": "parent"}, "must be provided together"}, + {"update_issue_title", map[string]any{"title": ""}, "missing required parameter: title"}, + {"update_issue_body", map[string]any{"body": ""}, "missing required parameter: body"}, + {"update_issue_milestone", map[string]any{"milestone": "1.5"}, "non-integer"}, + {"update_issue_assignees", map[string]any{"assignees": []any{map[string]any{"login": "octo", "confidence": "maybe"}}}, "confidence must be one of"}, + {"update_issue_labels", map[string]any{"labels": []any{map[string]any{"name": "bug", "rationale": strings.Repeat("x", 281)}}}, "280 characters"}, + {"update_issue_type", map[string]any{}, "missing required parameter: issue_type"}, + {"update_issue_type", map[string]any{"issue_type": nil, "is_suggestion": true}, "suggestion metadata is not supported"}, + {"update_issue_type", map[string]any{"issue_type": ""}, "must not be empty"}, + {"update_issue_state", map[string]any{"state": "CLOSED"}, "closed"}, + {"update_issue_state", map[string]any{"state": "open", "state_reason": "completed"}, "state_reason can only"}, + {"update_issue_state", map[string]any{"state": "closed", "state_reason": "duplicate", "is_suggestion": true}, "duplicate_of is required"}, + {"update_issue_state", map[string]any{"state": "closed", "duplicate_of": 2}, "duplicate_of can only"}, + {"add_sub_issue", map[string]any{}, "missing required parameter: sub_issue_id"}, + {"remove_sub_issue", map[string]any{"sub_issue_id": "7.5"}, "non-integer"}, + {"reprioritize_sub_issue", map[string]any{"sub_issue_id": 7}, "either after_id or before_id"}, + {"reprioritize_sub_issue", map[string]any{"sub_issue_id": 7, "after_id": 8, "before_id": 9}, "only one"}, + {"set_issue_fields", map[string]any{"fields": []any{}}, "fields array must not be empty"}, + {"set_issue_fields", map[string]any{"fields": []any{map[string]any{"field_id": "IF_1", "text_value": ""}}}, "each field must have a value"}, + {"set_issue_fields", map[string]any{"fields": []any{map[string]any{"field_id": "IF_1", "text_value": "one", "number_value": 0}}}, "exactly one value"}, + {"set_issue_fields", map[string]any{"fields": []any{map[string]any{"field_id": "IF_1", "number_value": nil}}}, "each field must have a value"}, + {"add_issue_reaction", map[string]any{"content": "HEART"}, "heart"}, + {"remove_issue_reaction", map[string]any{"reaction_id": "1.5"}, "non-integer"}, + {"add_issue_comment_reaction", map[string]any{"comment_id": 42, "content": "invalid"}, "heart"}, + {"remove_issue_comment_reaction", map[string]any{"comment_id": "9223372036854775808", "reaction_id": 9}, "comment_id"}, + } + + testGranularNullResponses := func(t *testing.T) { + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", ""} { + for _, payload := range []string{`null`, `{}`} { + t.Run("protocol="+protocol+"/payload="+payload, func(t *testing.T) { + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "POST" { + w.WriteHeader(http.StatusCreated) + } + _, _ = w.Write([]byte(payload)) + })}} + session, schemas := granularIssueSession(t, BaseDeps{Client: mustNewGHClient(t, client)}, protocol) + for _, tc := range []struct { + name string + args map[string]any + text string + }{ + {"create_issue", map[string]any{"title": "Subject"}, `{"id":"0","url":""}`}, + {"update_issue_title", map[string]any{"title": "Subject"}, `{"id":"0","url":""}`}, + {"update_issue_body", map[string]any{"body": "Body"}, `{"id":"0","url":""}`}, + {"update_issue_assignees", map[string]any{"assignees": []string{}}, `{"id":"0","url":""}`}, + {"update_issue_labels", map[string]any{"labels": []string{}}, `{"id":"0","url":""}`}, + {"update_issue_milestone", map[string]any{"milestone": 3}, `{"id":"0","url":""}`}, + {"update_issue_type", map[string]any{"issue_type": nil}, `{"id":"0","url":""}`}, + {"update_issue_state", map[string]any{"state": "open"}, `{"id":"0","url":""}`}, + {"add_sub_issue", map[string]any{"sub_issue_id": 7}, payload}, + {"remove_sub_issue", map[string]any{"sub_issue_id": 7}, payload}, + {"reprioritize_sub_issue", map[string]any{"sub_issue_id": 7, "after_id": 8}, payload}, + {"add_issue_reaction", map[string]any{"content": "heart"}, `{"id":"0","url":"https://api.github.com/repos/owner/repo/issues/1/reactions/0"}`}, + {"add_issue_comment_reaction", map[string]any{"comment_id": 42, "content": "heart"}, `{"id":"0","url":"https://api.github.com/repos/owner/repo/issues/comments/42/reactions/0"}`}, + } { + args := map[string]any{"owner": "owner", "repo": "repo", "issue_number": 1} + maps.Copy(args, tc.args) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: args}) + require.NoError(t, err) + require.False(t, result.IsError, tc.name) + require.Len(t, result.Content, 1) + expectedText := tc.text + if strings.Contains(tc.name, "sub_issue") && schemas[tc.name] != nil { + method := map[string]string{"add_sub_issue": "add", "remove_sub_issue": "remove", "reprioritize_sub_issue": "reprioritize"}[tc.name] + expectedText = fmt.Sprintf(`{"method":%q,"issue":null}`, method) + if payload == `{}` { + expectedText = fmt.Sprintf(`{"method":%q,"issue":{"assignees":[]}}`, method) + } + } + if schemas[tc.name] != nil { + assert.JSONEq(t, expectedText, getTextResult(t, result).Text) + } else { + assert.Equal(t, expectedText, getTextResult(t, result).Text) + } + if schema := schemas[tc.name]; schema != nil { + assert.JSONEq(t, expectedText, mustMarshalJSON(t, result.StructuredContent)) + require.NoError(t, schema.Validate(result.StructuredContent)) + } else { + assert.Nil(t, result.StructuredContent) + } + } + + }) + } + } + } + t.Run("null-responses", testGranularNullResponses) + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", ""} { + t.Run("protocol="+protocol, func(t *testing.T) { + var current *granularIssueWireCase + session, _ := granularIssueSession(t, granularWireDeps(t, ¤t, true), protocol) + for _, tc := range cases { + t.Run(tc.name+"/"+tc.text, func(t *testing.T) { + args := map[string]any{"owner": "owner", "repo": "repo", "issue_number": "1"} + maps.Copy(args, tc.args) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: args}) + require.NoError(t, err) + require.True(t, result.IsError, mustMarshalJSON(t, result)) + assert.Nil(t, result.StructuredContent) + assert.Contains(t, getErrorResult(t, result).Text, tc.text) + }) + } + for _, tc := range granularIssueWireCases() { + current = &tc + args := map[string]any{"owner": "owner", "repo": "repo", "issue_number": "1"} + maps.Copy(args, tc.args) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: args}) + require.NoError(t, err) + require.True(t, result.IsError, tc.name) + assert.Nil(t, result.StructuredContent, tc.name) + assert.Contains(t, getErrorResult(t, result).Text, "Forbidden", tc.name) + } + }) + } +} From 15e4b322694196121651601c2b38cfc9e2965c48 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 7 Oct 2026 00:14:22 +0200 Subject: [PATCH 54/61] refactor(search): type repository, user, org and commit search tools (#3395) * feat(repos): complete typed repository tool outputs Migrate the remaining 17 repository tools to concrete inputs and outputs. Preserve existing content, mutations, scopes, filtering, and protocol gating; add wire and schema conformance coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore(repos): exclude unrelated snapshot formatting drift Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(repos): compact typed repository outputs Replace raw REST output schemas with compact repository DTOs, retain one canonical snapshot per tool, and align modern JSON text with structured output while pinning legacy content. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(issues): add typed consolidated issue tool contracts Preserve raw helper APIs and exact legacy text while exposing concrete output unions for modern protocols. Retain explicit awaiting statuses and real null responses without exposing SDK-generated error fallbacks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * refactor(issues): minimize consolidated typed outputs Project compact method-discriminated DTOs from API responses while preserving the separate legacy text formatter and explicit app-awaiting status. Cache strict schemas and keep only canonical snapshots. Strengthen null-member presence, schema size, and lockdown projection regressions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(issues): keep union property schemas portable Preserve forbidden variant properties as annotated schema objects rather than boolean schemas emitted by jsonschema-go. Cover Inspector's input paths and IFC labels on every issue-read method across protocols. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(issues): cover strict sub-issue output variants Reject unknown methods, states, field types, opaque values and API URL properties while covering nullable and empty method data. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(issues): pin compact comment user redaction Pin unchanged legacy profile/avatar fields separately from compact modern user projections and assert identical lockdown filtering and sanitization across protocols. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(issues): restore main-identical advertised input schema Remove added custom-field union constraints and sentinel enums from the advertised issue-write input. Preserve runtime exactly-one validation and private strict-schema tests. All three scoped input schemas compare exactly equal to main. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(issues): restore protocol output parity Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * refactor(issues): minimize granular structured outputs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> # Conflicts: # pkg/github/__toolsnaps__/add_sub_issue.snap # pkg/github/__toolsnaps__/remove_sub_issue.snap # pkg/github/__toolsnaps__/reprioritize_sub_issue.snap # pkg/github/granular_issue_types.go # pkg/github/issues_granular.go # pkg/github/typed_granular_issue_outputs_test.go # Please enter the commit message for your changes. Lines starting # with '#' will be ignored, and an empty message aborts the commit. # # interactive rebase in progress; onto 1973eeea # Last command done (1 command done): # pick 5b8f9d19 # refactor(issues): minimize granular structured outputs # No commands remaining. # You are currently rebasing. # # Changes to be committed: # modified: pkg/github/__toolsnaps__/add_issue_comment_reaction.snap # modified: pkg/github/__toolsnaps__/add_issue_reaction.snap # modified: pkg/github/__toolsnaps__/add_sub_issue.snap # modified: pkg/github/__toolsnaps__/create_issue.snap # modified: pkg/github/__toolsnaps__/hide_issue_comment.snap # modified: pkg/github/__toolsnaps__/remove_issue_comment_reaction.snap # modified: pkg/github/__toolsnaps__/remove_issue_reaction.snap # modified: pkg/github/__toolsnaps__/remove_sub_issue.snap # modified: pkg/github/__toolsnaps__/reprioritize_sub_issue.snap # modified: pkg/github/__toolsnaps__/set_issue_fields.snap # modified: pkg/github/__toolsnaps__/unhide_issue_comment.snap # modified: pkg/github/__toolsnaps__/update_issue_assignees.snap # modified: pkg/github/__toolsnaps__/update_issue_body.snap # modified: pkg/github/__toolsnaps__/update_issue_labels.snap # modified: pkg/github/__toolsnaps__/update_issue_milestone.snap # modified: pkg/github/__toolsnaps__/update_issue_state.snap # modified: pkg/github/__toolsnaps__/update_issue_title.snap # modified: pkg/github/__toolsnaps__/update_issue_type.snap # modified: pkg/github/comment_minimize_test.go # new file: pkg/github/granular_issue_types.go # modified: pkg/github/granular_tools_test.go # modified: pkg/github/issues_granular.go # new file: pkg/github/typed_granular_issue_outputs_test.go # * refactor(search): use typed account repository and commit tools Preserve legacy text and search semantics while publishing protocol-gated concrete structured outputs. Cover modern, legacy, and unknown clients, full repository JSON unions, defaults, IFC labels, and errors. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(search): compact typed repository search output Replace the full REST repository mirror with MinimalRepository-based structured items plus purpose-built full-mode details. Legacy text remains unchanged, and canonical snapshots now hold the modern output schema. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(search): expose repository visibility enum Remove the derivable node_id from compact full repository search output and publish the visibility enum in the static output schema. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(search): align snapshots with protocol wrapper Keep the modern text assertion aligned with structured content after the shared output wrapper change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] --- pkg/github/__toolsnaps__/search_commits.snap | 355 +++++++++++++++++- pkg/github/__toolsnaps__/search_orgs.snap | 119 +++++- .../__toolsnaps__/search_repositories.snap | 226 ++++++++++- pkg/github/__toolsnaps__/search_users.snap | 119 +++++- pkg/github/search.go | 154 +++----- pkg/github/search_test.go | 5 - pkg/github/search_tool_types.go | 157 ++++++++ .../typed_account_repository_search_test.go | 337 +++++++++++++++++ 8 files changed, 1363 insertions(+), 109 deletions(-) create mode 100644 pkg/github/search_tool_types.go create mode 100644 pkg/github/typed_account_repository_search_test.go diff --git a/pkg/github/__toolsnaps__/search_commits.snap b/pkg/github/__toolsnaps__/search_commits.snap index 8221dee480..b9be295c88 100644 --- a/pkg/github/__toolsnaps__/search_commits.snap +++ b/pkg/github/__toolsnaps__/search_commits.snap @@ -44,5 +44,358 @@ ], "type": "object" }, - "name": "search_commits" + "name": "search_commits", + "outputSchema": { + "additionalProperties": false, + "properties": { + "incomplete_results": { + "type": "boolean" + }, + "items": { + "items": { + "additionalProperties": false, + "properties": { + "author": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "commit": { + "additionalProperties": false, + "properties": { + "author": { + "additionalProperties": false, + "properties": { + "date": { + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + }, + "committer": { + "additionalProperties": false, + "properties": { + "date": { + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + }, + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + }, + "committer": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "files": { + "items": { + "additionalProperties": false, + "properties": { + "additions": { + "type": "integer" + }, + "changes": { + "type": "integer" + }, + "deletions": { + "type": "integer" + }, + "filename": { + "type": "string" + }, + "patch": { + "type": "string" + }, + "status": { + "type": "string" + } + }, + "required": [ + "filename" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "html_url": { + "type": "string" + }, + "repository": { + "additionalProperties": false, + "properties": { + "full_name": { + "type": "string" + }, + "html_url": { + "type": "string" + }, + "private": { + "type": "boolean" + } + }, + "required": [ + "full_name" + ], + "type": [ + "null", + "object" + ] + }, + "sha": { + "type": "string" + }, + "stats": { + "additionalProperties": false, + "properties": { + "additions": { + "type": "integer" + }, + "deletions": { + "type": "integer" + }, + "total": { + "type": "integer" + } + }, + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "sha", + "html_url" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "total_count", + "incomplete_results", + "items" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_orgs.snap b/pkg/github/__toolsnaps__/search_orgs.snap index 9796c6105d..d9d66a34e4 100644 --- a/pkg/github/__toolsnaps__/search_orgs.snap +++ b/pkg/github/__toolsnaps__/search_orgs.snap @@ -45,5 +45,122 @@ ], "type": "object" }, - "name": "search_orgs" + "name": "search_orgs", + "outputSchema": { + "additionalProperties": false, + "properties": { + "incomplete_results": { + "type": "boolean" + }, + "items": { + "items": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "total_count", + "incomplete_results", + "items" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_repositories.snap b/pkg/github/__toolsnaps__/search_repositories.snap index 23b1d5e839..06b5300fa2 100644 --- a/pkg/github/__toolsnaps__/search_repositories.snap +++ b/pkg/github/__toolsnaps__/search_repositories.snap @@ -51,5 +51,229 @@ ], "type": "object" }, - "name": "search_repositories" + "name": "search_repositories", + "outputSchema": { + "additionalProperties": false, + "properties": { + "incomplete_results": { + "type": "boolean" + }, + "items": { + "items": { + "additionalProperties": false, + "properties": { + "archived": { + "type": "boolean" + }, + "created_at": { + "type": "string" + }, + "default_branch": { + "type": "string" + }, + "description": { + "type": "string" + }, + "disabled": { + "description": "Whether the repository is disabled", + "type": [ + "null", + "boolean" + ] + }, + "fork": { + "type": "boolean" + }, + "forks_count": { + "type": "integer" + }, + "full_name": { + "type": "string" + }, + "has_discussions": { + "type": [ + "null", + "boolean" + ] + }, + "has_issues": { + "type": [ + "null", + "boolean" + ] + }, + "has_projects": { + "type": [ + "null", + "boolean" + ] + }, + "has_wiki": { + "type": [ + "null", + "boolean" + ] + }, + "homepage": { + "description": "Repository homepage URL", + "type": "string" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "is_template": { + "description": "Whether the repository is a template", + "type": [ + "null", + "boolean" + ] + }, + "language": { + "type": "string" + }, + "license": { + "additionalProperties": false, + "description": "Repository license", + "properties": { + "key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "spdx_id": { + "description": "SPDX license identifier", + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + }, + "name": { + "type": "string" + }, + "open_issues_count": { + "type": "integer" + }, + "permissions": { + "additionalProperties": false, + "description": "Authenticated user's repository permissions", + "properties": { + "admin": { + "type": [ + "null", + "boolean" + ] + }, + "maintain": { + "type": [ + "null", + "boolean" + ] + }, + "pull": { + "type": [ + "null", + "boolean" + ] + }, + "push": { + "type": [ + "null", + "boolean" + ] + }, + "triage": { + "type": [ + "null", + "boolean" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "private": { + "type": "boolean" + }, + "pushed_at": { + "description": "Last push time as an ISO 8601 UTC timestamp", + "type": "string" + }, + "size": { + "description": "Repository size in kilobytes", + "type": [ + "null", + "integer" + ] + }, + "stargazers_count": { + "type": "integer" + }, + "topics": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "updated_at": { + "type": "string" + }, + "visibility": { + "description": "Repository visibility", + "enum": [ + "public", + "private", + "internal" + ], + "type": "string" + }, + "watchers_count": { + "description": "Number of watchers", + "type": [ + "null", + "integer" + ] + } + }, + "required": [ + "id", + "name", + "full_name", + "html_url", + "stargazers_count", + "forks_count", + "open_issues_count", + "private", + "fork", + "archived" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "total_count", + "incomplete_results", + "items" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/search_users.snap b/pkg/github/__toolsnaps__/search_users.snap index 27bd2eb081..613d3d2d51 100644 --- a/pkg/github/__toolsnaps__/search_users.snap +++ b/pkg/github/__toolsnaps__/search_users.snap @@ -45,5 +45,122 @@ ], "type": "object" }, - "name": "search_users" + "name": "search_users", + "outputSchema": { + "additionalProperties": false, + "properties": { + "incomplete_results": { + "type": "boolean" + }, + "items": { + "items": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Account creation time in RFC3339 format.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Last profile update time in RFC3339 format.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "total_count", + "incomplete_results", + "items" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/search.go b/pkg/github/search.go index 80a7e577b5..87ab08b712 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -48,7 +48,7 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo } WithPagination(schema) - return NewTool( + return NewTool[SearchRepositoriesInput, *SearchRepositoriesOutput]( ToolsetMetadataRepos, mcp.Tool{ Name: "search_repositories", @@ -57,33 +57,21 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo Title: t("TOOL_SEARCH_REPOSITORIES_USER_TITLE", "Search repositories"), ReadOnlyHint: true, }, - InputSchema: schema, + InputSchema: schema, + OutputSchema: searchRepositoriesOutputSchema(), }, scopes.PublicRead(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 - } - minimalOutput, err := OptionalBoolParamWithDefault(args, "minimal_output", true) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input SearchRepositoriesInput) (*mcp.CallToolResult, *SearchRepositoriesOutput, error) { + var output *SearchRepositoriesOutput + if input.Query == "" { + return utils.NewToolResultError("missing required parameter: query"), output, nil } + query := input.Query + pagination := repositoryPagination(input.Page, input.PerPage) + minimalOutput := input.MinimalOutput == nil || *input.MinimalOutput opts := &github.SearchOptions{ - Sort: sort, - Order: order, + Sort: input.Sort, + Order: input.Order, ListOptions: github.ListOptions{ Page: pagination.Page, PerPage: pagination.PerPage, @@ -92,7 +80,7 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo client, err := deps.GetClient(ctx) if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), output, nil } result, resp, err := client.Search.Repositories(ctx, query, opts) if err != nil { @@ -100,50 +88,31 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo fmt.Sprintf("failed to search repositories with query '%s'", query), resp, err, - ), nil, nil + ), output, 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 utils.NewToolResultErrorFromErr("failed to read response body", err), output, nil } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to search repositories", resp, body), nil, nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to search repositories", resp, body), output, nil } // Return either minimal or full response based on parameter var r []byte + output = &SearchRepositoriesOutput{ + TotalCount: result.GetTotal(), + IncompleteResults: result.GetIncompleteResults(), + Items: make([]SearchRepository, 0, len(result.Repositories)), + } if minimalOutput { minimalRepos := make([]MinimalRepository, 0, len(result.Repositories)) for _, repo := range result.Repositories { - minimalRepo := MinimalRepository{ - ID: repo.GetID(), - Name: repo.GetName(), - FullName: repo.GetFullName(), - Description: repo.GetDescription(), - HTMLURL: repo.GetHTMLURL(), - Language: repo.GetLanguage(), - Stars: repo.GetStargazersCount(), - Forks: repo.GetForksCount(), - OpenIssues: repo.GetOpenIssuesCount(), - Private: repo.GetPrivate(), - Fork: repo.GetFork(), - Archived: repo.GetArchived(), - DefaultBranch: repo.GetDefaultBranch(), - } - - if repo.UpdatedAt != nil { - minimalRepo.UpdatedAt = repo.UpdatedAt.Format("2006-01-02T15:04:05Z") - } - if repo.CreatedAt != nil { - minimalRepo.CreatedAt = repo.CreatedAt.Format("2006-01-02T15:04:05Z") - } - if repo.Topics != nil { - minimalRepo.Topics = repo.Topics - } - + minimalRepo := minimalSearchRepository(repo) minimalRepos = append(minimalRepos, minimalRepo) + output.Items = append(output.Items, SearchRepository{MinimalRepository: minimalRepo}) } minimalResult := &MinimalSearchRepositoriesResult{ @@ -161,12 +130,16 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal full response", err), nil, nil } + for _, repo := range result.Repositories { + output.Items = append(output.Items, fullSearchRepository(repo)) + } } callResult := utils.NewToolResultText(string(r)) attachSearchRepositoriesIFCLabel(ctx, deps, result.Repositories, callResult) - return callResult, nil, nil + return callResult, output, nil }, + normalizeSearchArguments(true), ) } @@ -437,27 +410,16 @@ func recordSearchCodeFieldsUsage(ctx context.Context, deps ToolDependencies, ful recordFieldsUsageFor(ctx, deps, "search_code", full, filtered, sentBytes) } -func userOrOrgHandler(ctx context.Context, accountType string, deps ToolDependencies, args map[string]any) (*mcp.CallToolResult, any, error) { - 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 +func userOrOrgHandler(ctx context.Context, accountType string, deps ToolDependencies, input SearchAccountsInput) (*mcp.CallToolResult, *MinimalSearchUsersResult, error) { + if input.Query == "" { + return utils.NewToolResultError("missing required parameter: query"), nil, nil } + query := input.Query + pagination := repositoryPagination(input.Page, input.PerPage) opts := &github.SearchOptions{ - Sort: sort, - Order: order, + Sort: input.Sort, + Order: input.Order, ListOptions: github.ListOptions{ PerPage: pagination.PerPage, Page: pagination.Page, @@ -524,7 +486,7 @@ func userOrOrgHandler(ctx context.Context, accountType string, deps ToolDependen // User and organization search returns public profile information that is // authored by the account holders themselves, so it is public-untrusted. callResult = attachStaticIFCLabel(ctx, deps, callResult, ifc.PublicUntrusted()) - return callResult, nil, nil + return callResult, minimalResp, nil } // SearchUsers creates a tool to search for GitHub users. @@ -551,7 +513,7 @@ func SearchUsers(t translations.TranslationHelperFunc) inventory.ServerTool { } WithPagination(schema) - return NewTool( + return NewTool[SearchAccountsInput, *MinimalSearchUsersResult]( ToolsetMetadataUsers, mcp.Tool{ Name: "search_users", @@ -563,9 +525,10 @@ func SearchUsers(t translations.TranslationHelperFunc) inventory.ServerTool { InputSchema: schema, }, scopes.PublicRead(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - return userOrOrgHandler(ctx, "user", deps, args) + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input SearchAccountsInput) (*mcp.CallToolResult, *MinimalSearchUsersResult, error) { + return userOrOrgHandler(ctx, "user", deps, input) }, + normalizeSearchArguments(false), ) } @@ -593,7 +556,7 @@ func SearchOrgs(t translations.TranslationHelperFunc) inventory.ServerTool { } WithPagination(schema) - return NewTool( + return NewTool[SearchAccountsInput, *MinimalSearchUsersResult]( ToolsetMetadataOrgs, mcp.Tool{ Name: "search_orgs", @@ -605,9 +568,10 @@ func SearchOrgs(t translations.TranslationHelperFunc) inventory.ServerTool { InputSchema: schema, }, scopes.RequireAll(scopes.ReadOrg), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - return userOrOrgHandler(ctx, "org", deps, args) + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input SearchAccountsInput) (*mcp.CallToolResult, *MinimalSearchUsersResult, error) { + return userOrOrgHandler(ctx, "org", deps, input) }, + normalizeSearchArguments(false), ) } @@ -635,7 +599,7 @@ func SearchCommits(t translations.TranslationHelperFunc) inventory.ServerTool { } WithPagination(schema) - return NewTool( + return NewTool[SearchCommitsInput, *MinimalSearchCommitsResult]( ToolsetMetadataRepos, mcp.Tool{ Name: "search_commits", @@ -647,27 +611,16 @@ func SearchCommits(t translations.TranslationHelperFunc) inventory.ServerTool { InputSchema: schema, }, scopes.PublicRead(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 + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input SearchCommitsInput) (*mcp.CallToolResult, *MinimalSearchCommitsResult, error) { + if input.Query == "" { + return utils.NewToolResultError("missing required parameter: query"), nil, nil } + query := input.Query + pagination := repositoryPagination(input.Page, input.PerPage) opts := &github.SearchOptions{ - Sort: sort, - Order: order, + Sort: input.Sort, + Order: input.Order, ListOptions: github.ListOptions{ Page: pagination.Page, PerPage: pagination.PerPage, @@ -723,7 +676,8 @@ func SearchCommits(t translations.TranslationHelperFunc) inventory.ServerTool { } } callResult = attachJoinedIFCLabel(ctx, deps, callResult, visibilities, ifc.LabelSearchIssues) - return callResult, nil, nil + return callResult, minimalResult, nil }, + normalizeSearchArguments(false), ) } diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index f372de1bd7..c66bb36df0 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -19,7 +19,6 @@ func Test_SearchRepositories(t *testing.T) { // Verify tool definition once serverTool := SearchRepositories(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "search_repositories", tool.Name) assert.NotEmpty(t, tool.Description) @@ -640,7 +639,6 @@ func Test_SearchUsers(t *testing.T) { // Verify tool definition once serverTool := SearchUsers(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) assert.Equal(t, "search_users", tool.Name) assert.NotEmpty(t, tool.Description) @@ -827,8 +825,6 @@ func Test_SearchOrgs(t *testing.T) { serverTool := SearchOrgs(translations.NullTranslationHelper) tool := serverTool.Tool - require.NoError(t, toolsnaps.Test(tool.Name, tool)) - assert.Equal(t, "search_orgs", tool.Name) assert.NotEmpty(t, tool.Description) @@ -985,7 +981,6 @@ 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) diff --git a/pkg/github/search_tool_types.go b/pkg/github/search_tool_types.go new file mode 100644 index 0000000000..855841186e --- /dev/null +++ b/pkg/github/search_tool_types.go @@ -0,0 +1,157 @@ +package github + +import ( + "encoding/json" + + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" +) + +type SearchAccountsInput struct { + Query string `json:"query"` + Sort string `json:"sort,omitempty"` + Order string `json:"order,omitempty"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` +} + +type SearchRepositoriesInput struct { + SearchAccountsInput + MinimalOutput *bool `json:"minimal_output,omitempty"` +} + +type SearchCommitsInput = SearchAccountsInput + +// SearchRepositoriesOutput is the structured repository search result. Minimal +// output reuses MinimalRepository; full output adds a compact set of +// repository details instead of the complete REST API object. +type SearchRepositoriesOutput struct { + TotalCount int `json:"total_count"` + IncompleteResults bool `json:"incomplete_results"` + Items []SearchRepository `json:"items"` +} + +type SearchRepository struct { + MinimalRepository + Homepage string `json:"homepage,omitempty" jsonschema:"Repository homepage URL"` + Visibility string `json:"visibility,omitempty" jsonschema:"Repository visibility"` + PushedAt string `json:"pushed_at,omitempty" jsonschema:"Last push time as an ISO 8601 UTC timestamp"` + WatchersCount *int `json:"watchers_count,omitempty" jsonschema:"Number of watchers"` + Size *int `json:"size,omitempty" jsonschema:"Repository size in kilobytes"` + Disabled *bool `json:"disabled,omitempty" jsonschema:"Whether the repository is disabled"` + IsTemplate *bool `json:"is_template,omitempty" jsonschema:"Whether the repository is a template"` + HasIssues *bool `json:"has_issues,omitempty"` + HasProjects *bool `json:"has_projects,omitempty"` + HasWiki *bool `json:"has_wiki,omitempty"` + HasDiscussions *bool `json:"has_discussions,omitempty"` + License *SearchRepositoryLicense `json:"license,omitempty" jsonschema:"Repository license"` + Permissions *SearchRepositoryPermissions `json:"permissions,omitempty" jsonschema:"Authenticated user's repository permissions"` +} + +type SearchRepositoryLicense struct { + Key string `json:"key,omitempty"` + Name string `json:"name,omitempty"` + SPDXID string `json:"spdx_id,omitempty" jsonschema:"SPDX license identifier"` +} + +type SearchRepositoryPermissions struct { + Admin *bool `json:"admin,omitempty"` + Maintain *bool `json:"maintain,omitempty"` + Push *bool `json:"push,omitempty"` + Triage *bool `json:"triage,omitempty"` + Pull *bool `json:"pull,omitempty"` +} + +var searchRepositoryVisibilityEnum = []any{"public", "private", "internal"} + +func searchRepositoriesOutputSchema() *jsonschema.Schema { + schema, err := jsonschema.For[SearchRepositoriesOutput](nil) + if err != nil { + panic(err) + } + schema.Properties["items"].Items.Properties["visibility"].Enum = searchRepositoryVisibilityEnum + return schema +} + +func minimalSearchRepository(repo *github.Repository) MinimalRepository { + minimalRepo := MinimalRepository{ + ID: repo.GetID(), + Name: repo.GetName(), + FullName: repo.GetFullName(), + Description: repo.GetDescription(), + HTMLURL: repo.GetHTMLURL(), + Language: repo.GetLanguage(), + Stars: repo.GetStargazersCount(), + Forks: repo.GetForksCount(), + OpenIssues: repo.GetOpenIssuesCount(), + Private: repo.GetPrivate(), + Fork: repo.GetFork(), + Archived: repo.GetArchived(), + DefaultBranch: repo.GetDefaultBranch(), + } + if repo.UpdatedAt != nil { + minimalRepo.UpdatedAt = repo.UpdatedAt.Format("2006-01-02T15:04:05Z") + } + if repo.CreatedAt != nil { + minimalRepo.CreatedAt = repo.CreatedAt.Format("2006-01-02T15:04:05Z") + } + if repo.Topics != nil { + minimalRepo.Topics = repo.Topics + } + return minimalRepo +} + +func fullSearchRepository(repo *github.Repository) SearchRepository { + out := SearchRepository{ + MinimalRepository: minimalSearchRepository(repo), + Homepage: repo.GetHomepage(), + Visibility: repo.GetVisibility(), + WatchersCount: repo.WatchersCount, + Size: repo.Size, + Disabled: repo.Disabled, + IsTemplate: repo.IsTemplate, + HasIssues: repo.HasIssues, + HasProjects: repo.HasProjects, + HasWiki: repo.HasWiki, + HasDiscussions: repo.HasDiscussions, + } + if repo.PushedAt != nil { + out.PushedAt = repo.PushedAt.Format("2006-01-02T15:04:05Z") + } + if license := repo.License; license != nil { + out.License = &SearchRepositoryLicense{Key: license.GetKey(), Name: license.GetName(), SPDXID: license.GetSPDXID()} + } + if permissions := repo.Permissions; permissions != nil { + out.Permissions = &SearchRepositoryPermissions{ + Admin: permissions.Admin, Maintain: permissions.Maintain, Push: permissions.Push, + Triage: permissions.Triage, Pull: permissions.Pull, + } + } + return out +} + +func normalizeSearchArguments(minimalOutput bool) func(json.RawMessage) (json.RawMessage, error) { + return func(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + if _, err := RequiredParam[string](args, "query"); err != nil { + return nil, err + } + for _, field := range []string{"sort", "order"} { + if _, err := OptionalParam[string](args, field); err != nil { + return nil, err + } + } + if _, err := OptionalPaginationParams(args); err != nil { + return nil, err + } + if minimalOutput { + if _, err := OptionalBoolParamWithDefault(args, "minimal_output", true); err != nil { + return nil, err + } + } + return normalizeRepositoryArguments(raw) + } +} diff --git a/pkg/github/typed_account_repository_search_test.go b/pkg/github/typed_account_repository_search_test.go new file mode 100644 index 0000000000..3e33263201 --- /dev/null +++ b/pkg/github/typed_account_repository_search_test.go @@ -0,0 +1,337 @@ +package github + +import ( + "context" + "encoding/json" + "errors" + "maps" + "net/http" + "reflect" + "strings" + "testing" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func typedAccountRepositorySearchTools() []inventory.ServerTool { + return []inventory.ServerTool{ + SearchRepositories(translations.NullTranslationHelper), + SearchUsers(translations.NullTranslationHelper), + SearchOrgs(translations.NullTranslationHelper), + SearchCommits(translations.NullTranslationHelper), + } +} + +func connectTypedSearchClient(t *testing.T, tool inventory.ServerTool, deps ToolDependencies, protocol string) *mcp.ClientSession { + t.Helper() + server := mcp.NewServer(&mcp.Implementation{Name: "typed-search-test", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + tool.RegisterFunc(server, deps) + version := protocol + if protocol == "" { + version = inventory.ProtocolVersionMultiRoundTrip + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + switch request := req.(type) { + case *mcp.ListToolsRequest: + request.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + case *mcp.CallToolRequest: + request.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + } + return next(ctx, method, req) + } + }) + } + return connectCommentVisibilityClient(t, server, version) +} + +func TestTypedAccountRepositorySearchOutputs(t *testing.T) { + fixtures := map[string]struct { + path string + body string + text string + }{ + "search_repositories": { + GetSearchRepositories, + `{"total_count":1,"incomplete_results":false,"items":[{"id":7,"name":"repo","full_name":"owner/repo","description":"description","html_url":"repo-url","stargazers_count":0,"private":false,"fork":false,"archived":false,"created_at":"2026-01-02T03:04:05Z","topics":["go"],"owner":{"login":"owner"},"node_id":"R_7","homepage":"https://example.com","visibility":"public","pushed_at":"2026-01-03T03:04:05Z","watchers_count":4,"size":0,"disabled":false,"has_issues":true,"license":{"key":"mit","name":"MIT License","spdx_id":"MIT","url":"license-api-url"},"permissions":{"admin":false,"pull":true},"issues_url":"issues-api-url","parent":{"id":8},"custom_properties":{"text":"value"}}]}`, + `{"total_count":1,"incomplete_results":false,"items":[{"id":7,"name":"repo","full_name":"owner/repo","description":"\u003cb\u003edescription\u003c/b\u003e","html_url":"repo-url","stargazers_count":0,"forks_count":0,"open_issues_count":0,"created_at":"2026-01-02T03:04:05Z","topics":["go"],"private":false,"fork":false,"archived":false}]}`, + }, + "search_users": { + GetSearchUsers, + `{"total_count":3,"incomplete_results":true,"items":[{"id":7,"login":"octo","html_url":"profile","avatar_url":"avatar"},{"id":8},{"id":9,"login":""}]}`, + `{"total_count":3,"incomplete_results":true,"items":[{"login":"octo","id":7,"profile_url":"profile","avatar_url":"avatar"},{"login":"","id":9}]}`, + }, + "search_orgs": { + GetSearchUsers, + `{"total_count":3,"incomplete_results":true,"items":[{"id":7,"login":"octo","html_url":"profile","avatar_url":"avatar"},{"id":8},{"id":9,"login":""}]}`, + `{"total_count":3,"incomplete_results":true,"items":[{"login":"octo","id":7,"profile_url":"profile","avatar_url":"avatar"},{"login":"","id":9}]}`, + }, + "search_commits": { + GetSearchCommits, + `{"total_count":2,"incomplete_results":false,"items":[{"sha":"abc","html_url":"commit-url","commit":{"message":"fix","author":{"name":"Author","email":"a@example.com","date":"2026-01-02T03:04:05Z"}},"author":{"login":"octo","id":7,"html_url":"profile","avatar_url":"avatar"},"repository":{"full_name":"owner/repo","html_url":"repo-url","private":true}},{"sha":"def","html_url":""}]}`, + `{"total_count":2,"incomplete_results":false,"items":[{"sha":"abc","html_url":"commit-url","commit":{"message":"\u003cb\u003efix\u003c/b\u003e","author":{"name":"Author","email":"a@example.com","date":"2026-01-02T03:04:05Z"}},"author":{"login":"octo","id":7,"profile_url":"profile","avatar_url":"avatar"},"repository":{"full_name":"owner/repo","html_url":"repo-url","private":true}},{"sha":"def","html_url":""}]}`, + }, + } + for _, tool := range typedAccountRepositorySearchTools() { + fixture := fixtures[tool.Tool.Name] + var apiErrorText string + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip, ""} { + t.Run(tool.Tool.Name+"/"+protocol, func(t *testing.T) { + body := fixture.body + query := "repo:owner/repo fix" + page, perPage, sort, order := "1", "30", "", "" + calls := 0 + handler := func(w http.ResponseWriter, r *http.Request) { + calls++ + expectedQuery := query + if !hasTypeFilter(query) { + switch tool.Tool.Name { + case "search_users": + expectedQuery = "type:user " + query + case "search_orgs": + expectedQuery = "type:org " + query + } + } + assert.Equal(t, expectedQuery, r.URL.Query().Get("q")) + assert.Equal(t, page, r.URL.Query().Get("page")) + assert.Equal(t, perPage, r.URL.Query().Get("per_page")) + assert.Equal(t, sort, r.URL.Query().Get("sort")) + assert.Equal(t, order, r.URL.Query().Get("order")) + if body == "forbidden" { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"Forbidden"}`)) + return + } + _, _ = w.Write([]byte(body)) + } + deps := BaseDeps{ + Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{fixture.path: handler})), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + session := connectTypedSearchClient(t, tool, deps, protocol) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + listed := list.Tools[0] + assert.Equal(t, tool.Tool.Name, listed.Name) + var resolved *jsonschema.Resolved + if protocol == inventory.ProtocolVersionMultiRoundTrip { + require.NotNil(t, listed.OutputSchema) + canonical := *listed + canonical.Icons = nil // Registration adds toolset icons absent from canonical snapshots. + require.NoError(t, toolsnaps.Test(tool.Tool.Name, canonical)) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, listed.OutputSchema)), &schema)) + resolved, err = schema.Resolve(nil) + require.NoError(t, err) + } else { + assert.Nil(t, listed.OutputSchema) + } + checkStructured := func(args map[string]any, expected, structured string) { + t.Helper() + args = maps.Clone(args) + args["query"] = query + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tool.Tool.Name, Arguments: args}) + require.NoError(t, err) + require.False(t, result.IsError, "%+v", result) + require.Len(t, result.Content, 1) + if protocol == inventory.ProtocolVersionMultiRoundTrip { + assert.JSONEq(t, structured, getTextResult(t, result).Text) + } else { + assert.Equal(t, expected, getTextResult(t, result).Text) + } + require.NotNil(t, result.Meta) + label := unmarshalIFC(t, result.Meta["ifc"]) + if tool.Tool.Name == "search_commits" && body == fixture.body { + assert.Equal(t, "trusted", label["integrity"]) + assert.Equal(t, "private", label["confidentiality"]) + } else { + assert.Equal(t, "untrusted", label["integrity"]) + assert.Equal(t, "public", label["confidentiality"]) + } + if resolved == nil { + assert.Nil(t, result.StructuredContent) + } else { + require.NotNil(t, result.StructuredContent) + encoded := mustMarshalJSON(t, result.StructuredContent) + assert.JSONEq(t, structured, encoded) + assert.JSONEq(t, encoded, getTextResult(t, result).Text) + var output any + require.NoError(t, json.Unmarshal([]byte(encoded), &output)) + require.NoError(t, resolved.Validate(output)) + } + request := createMCPRequest(args) + direct, err := tool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, direct.IsError) + assert.Equal(t, expected, getTextResult(t, direct).Text) + } + check := func(args map[string]any, expected string) { + t.Helper() + checkStructured(args, expected, expected) + } + for _, args := range []map[string]any{ + {}, {"page": 0, "perPage": 0}, {"page": "0", "perPage": "0"}, + {"page": "0e0", "perPage": "-0"}, {"fields": []string{"id"}}, + } { + check(args, fixture.text) + } + page, perPage, order = "2", "7", "asc" + switch tool.Tool.Name { + case "search_repositories": + sort = "stars" + case "search_commits": + sort = "author-date" + default: + sort = "followers" + } + check(map[string]any{"page": "2e0", "perPage": "7.0", "sort": sort, "order": order}, fixture.text) + page, perPage, sort, order = "1", "30", "", "" + query = "type:org (location:seattle OR location:california)" + check(map[string]any{}, fixture.text) + if tool.Tool.Name == "search_repositories" { + var full github.RepositoriesSearchResult + require.NoError(t, json.Unmarshal([]byte(body), &full)) + checkStructured(map[string]any{"minimal_output": false}, mustMarshalJSON(t, full), + `{"total_count":1,"incomplete_results":false,"items":[{"id":7,"name":"repo","full_name":"owner/repo","description":"description","html_url":"repo-url","stargazers_count":0,"forks_count":0,"open_issues_count":0,"created_at":"2026-01-02T03:04:05Z","topics":["go"],"private":false,"fork":false,"archived":false,"homepage":"https://example.com","visibility":"public","pushed_at":"2026-01-03T03:04:05Z","watchers_count":4,"size":0,"disabled":false,"has_issues":true,"license":{"key":"mit","name":"MIT License","spdx_id":"MIT"},"permissions":{"admin":false,"pull":true}}]}`) + check(map[string]any{"minimal_output": true}, fixture.text) + } + for _, emptyBody := range []string{`{"total_count":0,"incomplete_results":false,"items":[]}`, `{"items":null}`, `{}`} { + body = emptyBody + check(map[string]any{}, `{"total_count":0,"incomplete_results":false,"items":[]}`) + if tool.Tool.Name == "search_repositories" { + expected := `{"total_count":0,"incomplete_results":false}` + if emptyBody != `{"total_count":0,"incomplete_results":false,"items":[]}` { + expected = "{}" + } + checkStructured(map[string]any{"minimal_output": false}, expected, `{"total_count":0,"incomplete_results":false,"items":[]}`) + } + } + before := calls + invalid := []struct { + args map[string]any + text string + }{ + {map[string]any{}, "missing required parameter: query"}, + {map[string]any{"query": ""}, "missing required parameter: query"}, + {map[string]any{"query": nil}, "parameter query is not of type string"}, + {map[string]any{"query": 1}, "parameter query is not of type string"}, + {map[string]any{"query": query, "sort": nil}, "parameter sort is not of type string"}, + {map[string]any{"query": query, "order": false}, "parameter order is not of type string"}, + {map[string]any{"query": query, "page": "1.5"}, "non-integer numeric value"}, + {map[string]any{"query": query, "page": 1.5}, "non-integer numeric value"}, + {map[string]any{"query": query, "perPage": false}, "expected number"}, + {map[string]any{"query": query, "perPage": nil}, "expected number"}, + {map[string]any{"query": query, "page": "NaN"}, "non-finite numeric value"}, + {map[string]any{"query": query, "page": "1e30"}, "numeric value out of int range"}, + {map[string]any{"query": query, "after": nil}, "parameter after is not of type string"}, + } + if tool.Tool.Name == "search_repositories" { + invalid = append(invalid, struct { + args map[string]any + text string + }{map[string]any{"query": query, "minimal_output": nil}, "parameter minimal_output is not of type bool"}) + } + for _, tc := range invalid { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tool.Tool.Name, Arguments: tc.args}) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, tc.text) + assert.Nil(t, result.StructuredContent) + request := createMCPRequest(tc.args) + direct, err := tool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, direct.IsError) + assert.Contains(t, getErrorResult(t, direct).Text, tc.text) + } + assert.Equal(t, before, calls, "invalid input must never reach GitHub") + body = "forbidden" + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: tool.Tool.Name, Arguments: map[string]any{"query": query}, + }) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "failed to search") + assert.Contains(t, getErrorResult(t, result).Text, query) + assert.Nil(t, result.StructuredContent) + if apiErrorText == "" { + apiErrorText = getErrorResult(t, result).Text + } + assert.Equal(t, apiErrorText, getErrorResult(t, result).Text, "API errors are identical across protocols") + }) + } + } +} + +func TestSearchRepositoryOutputSchemaIsCompact(t *testing.T) { + schema := searchRepositoriesOutputSchema() + assert.Equal(t, searchRepositoryVisibilityEnum, schema.Properties["items"].Items.Properties["visibility"].Enum) + encoded := mustMarshalJSON(t, schema) + for _, field := range []string{"node_id", "custom_properties", "owner", "parent", "issues_url", "clone_url", "url\""} { + assert.NotContains(t, encoded, `"`+field, field) + } + assert.Contains(t, encoded, `"html_url"`) + for _, tool := range typedAccountRepositorySearchTools() { + assert.True(t, tool.IsReadOnly()) + } +} + +type failingSearchClientDeps struct { + BaseDeps +} + +func (failingSearchClientDeps) GetClient(context.Context) (*github.Client, error) { + return nil, errors.New("client unavailable") +} + +func TestTypedAccountRepositorySearchClientErrors(t *testing.T) { + for _, tool := range typedAccountRepositorySearchTools() { + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip, ""} { + t.Run(tool.Tool.Name+"/"+protocol, func(t *testing.T) { + session := connectTypedSearchClient(t, tool, failingSearchClientDeps{}, protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: tool.Tool.Name, Arguments: map[string]any{"query": "query"}, + }) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Equal(t, "failed to get GitHub client: client unavailable", getErrorResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + }) + } + } +} + +func TestSearchOutputsHaveNoUntypedFields(t *testing.T) { + seen := make(map[reflect.Type]bool) + var check func(reflect.Type) + check = func(typ reflect.Type) { + if seen[typ] { + return + } + seen[typ] = true + switch typ.Kind() { + case reflect.Interface: + t.Errorf("untyped output field: %s", typ) + case reflect.Pointer, reflect.Slice, reflect.Array: + check(typ.Elem()) + case reflect.Map: + check(typ.Elem()) + case reflect.Struct: + for field := range typ.Fields() { + if field.IsExported() && !strings.HasPrefix(field.Tag.Get("json"), "-") { + check(field.Type) + } + } + } + } + check(reflect.TypeFor[SearchRepositoriesOutput]()) + check(reflect.TypeFor[MinimalSearchUsersResult]()) + check(reflect.TypeFor[MinimalSearchCommitsResult]()) +} From 867131bc12785ec9b6bc8b940755295990394bd8 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 7 Oct 2026 00:14:54 +0200 Subject: [PATCH 55/61] refactor(pull-requests): migrate consolidated PR tools to typed inputs and outputs (#3396) * feat(repos): complete typed repository tool outputs Migrate the remaining 17 repository tools to concrete inputs and outputs. Preserve existing content, mutations, scopes, filtering, and protocol gating; add wire and schema conformance coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore(repos): exclude unrelated snapshot formatting drift Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(repos): compact typed repository outputs Replace raw REST output schemas with compact repository DTOs, retain one canonical snapshot per tool, and align modern JSON text with structured output while pinning legacy content. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(issues): add typed consolidated issue tool contracts Preserve raw helper APIs and exact legacy text while exposing concrete output unions for modern protocols. Retain explicit awaiting statuses and real null responses without exposing SDK-generated error fallbacks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * refactor(issues): minimize consolidated typed outputs Project compact method-discriminated DTOs from API responses while preserving the separate legacy text formatter and explicit app-awaiting status. Cache strict schemas and keep only canonical snapshots. Strengthen null-member presence, schema size, and lockdown projection regressions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(issues): keep union property schemas portable Preserve forbidden variant properties as annotated schema objects rather than boolean schemas emitted by jsonschema-go. Cover Inspector's input paths and IFC labels on every issue-read method across protocols. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(issues): cover strict sub-issue output variants Reject unknown methods, states, field types, opaque values and API URL properties while covering nullable and empty method data. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(issues): pin compact comment user redaction Pin unchanged legacy profile/avatar fields separately from compact modern user projections and assert identical lockdown filtering and sanitization across protocols. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(issues): restore main-identical advertised input schema Remove added custom-field union constraints and sentinel enums from the advertised issue-write input. Preserve runtime exactly-one validation and private strict-schema tests. All three scoped input schemas compare exactly equal to main. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(issues): restore protocol output parity Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * refactor(issues): minimize granular structured outputs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> # Conflicts: # pkg/github/__toolsnaps__/add_sub_issue.snap # pkg/github/__toolsnaps__/remove_sub_issue.snap # pkg/github/__toolsnaps__/reprioritize_sub_issue.snap # pkg/github/granular_issue_types.go # pkg/github/issues_granular.go # pkg/github/typed_granular_issue_outputs_test.go # Please enter the commit message for your changes. Lines starting # with '#' will be ignored, and an empty message aborts the commit. # # interactive rebase in progress; onto 1973eeea # Last command done (1 command done): # pick 5b8f9d19 # refactor(issues): minimize granular structured outputs # No commands remaining. # You are currently rebasing. # # Changes to be committed: # modified: pkg/github/__toolsnaps__/add_issue_comment_reaction.snap # modified: pkg/github/__toolsnaps__/add_issue_reaction.snap # modified: pkg/github/__toolsnaps__/add_sub_issue.snap # modified: pkg/github/__toolsnaps__/create_issue.snap # modified: pkg/github/__toolsnaps__/hide_issue_comment.snap # modified: pkg/github/__toolsnaps__/remove_issue_comment_reaction.snap # modified: pkg/github/__toolsnaps__/remove_issue_reaction.snap # modified: pkg/github/__toolsnaps__/remove_sub_issue.snap # modified: pkg/github/__toolsnaps__/reprioritize_sub_issue.snap # modified: pkg/github/__toolsnaps__/set_issue_fields.snap # modified: pkg/github/__toolsnaps__/unhide_issue_comment.snap # modified: pkg/github/__toolsnaps__/update_issue_assignees.snap # modified: pkg/github/__toolsnaps__/update_issue_body.snap # modified: pkg/github/__toolsnaps__/update_issue_labels.snap # modified: pkg/github/__toolsnaps__/update_issue_milestone.snap # modified: pkg/github/__toolsnaps__/update_issue_state.snap # modified: pkg/github/__toolsnaps__/update_issue_title.snap # modified: pkg/github/__toolsnaps__/update_issue_type.snap # modified: pkg/github/comment_minimize_test.go # new file: pkg/github/granular_issue_types.go # modified: pkg/github/granular_tools_test.go # modified: pkg/github/issues_granular.go # new file: pkg/github/typed_granular_issue_outputs_test.go # * refactor(search): use typed account repository and commit tools Preserve legacy text and search semantics while publishing protocol-gated concrete structured outputs. Cover modern, legacy, and unknown clients, full repository JSON unions, defaults, IFC labels, and errors. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(search): compact typed repository search output Replace the full REST repository mirror with MinimalRepository-based structured items plus purpose-built full-mode details. Legacy text remains unchanged, and canonical snapshots now hold the modern output schema. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(search): expose repository visibility enum Remove the derivable node_id from compact full repository search output and publish the visibility enum in the static output schema. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(search): align snapshots with protocol wrapper Keep the modern text assertion aligned with structured content after the shared output wrapper change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(pull-requests): migrate consolidated PR tools to typed inputs and outputs Convert pull_request_read, create_pull_request, update_pull_request, merge_pull_request, update_pull_request_branch, pull_request_review_write (both feature variants), add_comment_to_pending_review and add_reply_to_pull_request_comment to NewTool with concrete inputs and output DTOs. Input normalizers replay legacy handler checks so legacy coercions and error text are preserved. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(pull-requests): align modern text with typed outputs Use curated output DTOs for modern JSON text while preserving legacy serialization. Cover successful null responses, empty check runs, and Link-header page traversal. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(pull-requests): preserve legacy validation order Replay parameter checks in handler order and scope cursor validation to review-thread reads. Keep commit message schema descriptions distinct from operation messages. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(pull-requests): simplify compatibility validation Remove normalization and test assertions that only pinned which invalid argument wins. Keep per-field validation, accepted coercions, and response-shape behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] --- .../add_comment_to_pending_review.snap | 15 +- .../add_reply_to_pull_request_comment.snap | 64 +- .../__toolsnaps__/create_pull_request.snap | 41 +- .../__toolsnaps__/merge_pull_request.snap | 34 +- .../__toolsnaps__/pull_request_read.snap | 942 +++++++++++++++++- .../pull_request_review_write.snap | 15 +- ...equest_review_write_resolution_reason.snap | 81 ++ .../__toolsnaps__/update_pull_request.snap | 41 +- .../update_pull_request_branch.snap | 18 +- pkg/github/consolidated_pull_request_types.go | 620 ++++++++++++ pkg/github/pullrequests.go | 390 +++++--- ..._consolidated_pull_request_outputs_test.go | 634 ++++++++++++ 12 files changed, 2745 insertions(+), 150 deletions(-) create mode 100644 pkg/github/__toolsnaps__/pull_request_review_write_resolution_reason.snap create mode 100644 pkg/github/consolidated_pull_request_types.go create mode 100644 pkg/github/typed_consolidated_pull_request_outputs_test.go diff --git a/pkg/github/__toolsnaps__/add_comment_to_pending_review.snap b/pkg/github/__toolsnaps__/add_comment_to_pending_review.snap index 4a6e6ca9ba..8296a90ccb 100644 --- a/pkg/github/__toolsnaps__/add_comment_to_pending_review.snap +++ b/pkg/github/__toolsnaps__/add_comment_to_pending_review.snap @@ -70,5 +70,18 @@ ], "type": "object" }, - "name": "add_comment_to_pending_review" + "name": "add_comment_to_pending_review", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "description": "Operation result message.", + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/add_reply_to_pull_request_comment.snap b/pkg/github/__toolsnaps__/add_reply_to_pull_request_comment.snap index a86ea3d4b5..8640ba2cf3 100644 --- a/pkg/github/__toolsnaps__/add_reply_to_pull_request_comment.snap +++ b/pkg/github/__toolsnaps__/add_reply_to_pull_request_comment.snap @@ -50,5 +50,67 @@ ], "type": "object" }, - "name": "add_reply_to_pull_request_comment" + "name": "add_reply_to_pull_request_comment", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "comment": { + "additionalProperties": false, + "properties": { + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "reaction": { + "additionalProperties": false, + "properties": { + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + } + }, + "required": [ + "comment", + "reaction" + ], + "type": "object" + } + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_pull_request.snap b/pkg/github/__toolsnaps__/create_pull_request.snap index acb74bcd7a..749065d788 100644 --- a/pkg/github/__toolsnaps__/create_pull_request.snap +++ b/pkg/github/__toolsnaps__/create_pull_request.snap @@ -65,5 +65,44 @@ ], "type": "object" }, - "name": "create_pull_request" + "name": "create_pull_request", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "reason": { + "type": "string" + }, + "status": { + "enum": [ + "awaiting_user_submission" + ], + "type": "string" + } + }, + "required": [ + "status", + "reason" + ], + "type": "object" + } + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/merge_pull_request.snap b/pkg/github/__toolsnaps__/merge_pull_request.snap index 35275cb0e8..4f570b1b12 100644 --- a/pkg/github/__toolsnaps__/merge_pull_request.snap +++ b/pkg/github/__toolsnaps__/merge_pull_request.snap @@ -60,5 +60,35 @@ ], "type": "object" }, - "name": "merge_pull_request" -} + "name": "merge_pull_request", + "outputSchema": { + "additionalProperties": false, + "properties": { + "merged": { + "description": "Whether the pull request was merged.", + "type": [ + "null", + "boolean" + ] + }, + "message": { + "description": "Operation result message.", + "type": [ + "null", + "string" + ] + }, + "sha": { + "description": "Git commit SHA.", + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "object", + "null" + ] + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/pull_request_read.snap b/pkg/github/__toolsnaps__/pull_request_read.snap index d518c7cad9..c99d4276e2 100644 --- a/pkg/github/__toolsnaps__/pull_request_read.snap +++ b/pkg/github/__toolsnaps__/pull_request_read.snap @@ -58,5 +58,945 @@ ], "type": "object" }, - "name": "pull_request_read" + "name": "pull_request_read", + "outputSchema": { + "oneOf": [ + { + "type": "null" + }, + { + "additionalProperties": false, + "properties": { + "additions": { + "description": "Number of lines.", + "type": "integer" + }, + "assignees": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "base": { + "additionalProperties": false, + "properties": { + "ref": { + "type": "string" + }, + "repo": { + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "full_name": { + "type": "string" + } + }, + "required": [ + "full_name" + ], + "type": [ + "null", + "object" + ] + }, + "sha": { + "description": "Git commit SHA.", + "type": "string" + } + }, + "required": [ + "ref", + "sha" + ], + "type": [ + "null", + "object" + ] + }, + "body": { + "type": "string" + }, + "changed_files": { + "type": "integer" + }, + "closed_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + }, + "comments": { + "type": "integer" + }, + "commits": { + "type": "integer" + }, + "created_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + }, + "deletions": { + "description": "Number of lines.", + "type": "integer" + }, + "draft": { + "type": "boolean" + }, + "head": { + "additionalProperties": false, + "properties": { + "ref": { + "type": "string" + }, + "repo": { + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "full_name": { + "type": "string" + } + }, + "required": [ + "full_name" + ], + "type": [ + "null", + "object" + ] + }, + "sha": { + "description": "Git commit SHA.", + "type": "string" + } + }, + "required": [ + "ref", + "sha" + ], + "type": [ + "null", + "object" + ] + }, + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "mergeable_state": { + "type": "string" + }, + "merged": { + "description": "Whether the pull request was merged.", + "type": "boolean" + }, + "merged_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + }, + "merged_by": { + "type": "string" + }, + "milestone": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "requested_reviewers": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "state": { + "description": "Pull request state; empty for a sparse response.", + "enum": [ + "open", + "closed", + "" + ], + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + }, + "user": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "number", + "title", + "state", + "draft", + "merged", + "html_url" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "diff": { + "description": "Unified pull request diff.", + "type": "string" + } + }, + "required": [ + "diff" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "sha": { + "description": "Git commit SHA.", + "type": "string" + }, + "state": { + "description": "Commit status; empty for a sparse response.", + "enum": [ + "error", + "failure", + "pending", + "success", + "" + ], + "type": "string" + }, + "statuses": { + "items": { + "additionalProperties": false, + "properties": { + "context": { + "type": "string" + }, + "created_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + }, + "description": { + "type": "string" + }, + "state": { + "description": "Commit status; empty for a sparse response.", + "enum": [ + "error", + "failure", + "pending", + "success", + "" + ], + "type": "string" + }, + "target_url": { + "type": "string" + }, + "updated_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + } + }, + "required": [ + "state", + "context" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "total_count": { + "description": "Total number of results.", + "type": "integer" + } + }, + "required": [ + "state", + "sha", + "total_count", + "statuses" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "pageInfo": { + "additionalProperties": false, + "properties": { + "endCursor": { + "type": "string" + }, + "hasNextPage": { + "type": "boolean" + }, + "hasPreviousPage": { + "type": "boolean" + }, + "startCursor": { + "type": "string" + } + }, + "required": [ + "hasNextPage", + "hasPreviousPage" + ], + "type": "object" + }, + "review_threads": { + "items": { + "additionalProperties": false, + "properties": { + "comments": { + "items": { + "additionalProperties": false, + "properties": { + "author": { + "type": "string" + }, + "body": { + "type": "string" + }, + "created_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + }, + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "line": { + "description": "One-based diff line number.", + "type": [ + "null", + "integer" + ] + }, + "original_line": { + "description": "One-based diff line number.", + "type": [ + "null", + "integer" + ] + }, + "original_start_line": { + "description": "One-based diff line number.", + "type": [ + "null", + "integer" + ] + }, + "path": { + "type": "string" + }, + "start_line": { + "description": "One-based diff line number.", + "type": [ + "null", + "integer" + ] + }, + "updated_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + } + }, + "required": [ + "path", + "html_url" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "id": { + "type": "string" + }, + "is_collapsed": { + "type": "boolean" + }, + "is_outdated": { + "type": "boolean" + }, + "is_resolved": { + "type": "boolean" + }, + "total_count": { + "description": "Total number of results.", + "type": "integer" + } + }, + "required": [ + "id", + "is_resolved", + "is_outdated", + "is_collapsed", + "comments", + "total_count" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "totalCount": { + "description": "Total number of results.", + "type": "integer" + } + }, + "required": [ + "review_threads", + "totalCount", + "pageInfo" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "check_runs": { + "items": { + "additionalProperties": false, + "properties": { + "completed_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + }, + "conclusion": { + "description": "Completed check outcome.", + "enum": [ + "success", + "failure", + "neutral", + "cancelled", + "skipped", + "timed_out", + "action_required", + "stale", + "startup_failure" + ], + "type": "string" + }, + "details_url": { + "type": "string" + }, + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "started_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + }, + "status": { + "description": "Check execution status; empty for a sparse response.", + "enum": [ + "queued", + "in_progress", + "completed", + "waiting", + "requested", + "pending", + "" + ], + "type": "string" + } + }, + "required": [ + "id", + "name", + "status" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "total_count": { + "description": "Total number of results.", + "type": "integer" + } + }, + "required": [ + "total_count", + "check_runs" + ], + "type": "object" + }, + { + "anyOf": [ + { + "items": { + "additionalProperties": false, + "properties": { + "additions": { + "description": "Number of lines.", + "type": "integer" + }, + "changes": { + "description": "Number of lines.", + "type": "integer" + }, + "deletions": { + "description": "Number of lines.", + "type": "integer" + }, + "filename": { + "type": "string" + }, + "patch": { + "type": "string" + }, + "previous_filename": { + "type": "string" + }, + "status": { + "description": "File change status.", + "enum": [ + "added", + "removed", + "modified", + "renamed", + "copied", + "changed", + "unchanged" + ], + "type": "string" + } + }, + "required": [ + "filename" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + { + "items": { + "additionalProperties": false, + "properties": { + "author": { + "additionalProperties": false, + "properties": { + "date": { + "type": "string" + }, + "email": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + }, + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "message": { + "type": "string" + }, + "sha": { + "description": "Git commit SHA.", + "type": "string" + } + }, + "required": [ + "sha" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + { + "items": { + "additionalProperties": false, + "properties": { + "author_association": { + "type": "string" + }, + "body": { + "type": "string" + }, + "commit_id": { + "description": "Git commit SHA.", + "type": "string" + }, + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "integer" + }, + "state": { + "description": "Review state; empty for a sparse response.", + "enum": [ + "APPROVED", + "CHANGES_REQUESTED", + "COMMENTED", + "DISMISSED", + "PENDING", + "" + ], + "type": "string" + }, + "submitted_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + }, + "user": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "id", + "state", + "html_url" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + { + "items": { + "additionalProperties": false, + "properties": { + "author_association": { + "type": "string" + }, + "body": { + "type": "string" + }, + "created_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + }, + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "integer" + }, + "reactions": { + "additionalProperties": false, + "properties": { + "+1": { + "type": "integer" + }, + "-1": { + "type": "integer" + }, + "confused": { + "type": "integer" + }, + "eyes": { + "type": "integer" + }, + "heart": { + "type": "integer" + }, + "hooray": { + "type": "integer" + }, + "laugh": { + "type": "integer" + }, + "rocket": { + "type": "integer" + }, + "total_count": { + "description": "Total number of results.", + "type": "integer" + } + }, + "required": [ + "total_count", + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "rocket", + "eyes" + ], + "type": [ + "null", + "object" + ] + }, + "updated_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + }, + "user": { + "additionalProperties": false, + "properties": { + "id": { + "type": "integer" + }, + "login": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + } + }, + "required": [ + "id", + "html_url" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } + ], + "type": "array" + } + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/pull_request_review_write.snap b/pkg/github/__toolsnaps__/pull_request_review_write.snap index 74ef808559..70cf6471c1 100644 --- a/pkg/github/__toolsnaps__/pull_request_review_write.snap +++ b/pkg/github/__toolsnaps__/pull_request_review_write.snap @@ -60,5 +60,18 @@ ], "type": "object" }, - "name": "pull_request_review_write" + "name": "pull_request_review_write", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "description": "Operation result message.", + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/pull_request_review_write_resolution_reason.snap b/pkg/github/__toolsnaps__/pull_request_review_write_resolution_reason.snap new file mode 100644 index 0000000000..45e19ca7bf --- /dev/null +++ b/pkg/github/__toolsnaps__/pull_request_review_write_resolution_reason.snap @@ -0,0 +1,81 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": false, + "title": "Write operations (create, submit, delete) on pull request reviews" + }, + "description": "Create and/or submit, delete review of a pull request.\n\nAvailable methods:\n- create: Create a new review of a pull request. If \"event\" parameter is provided, the review is submitted. If \"event\" is omitted, a pending review is created.\n- submit_pending: Submit an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request. The \"body\" and \"event\" parameters are used when submitting the review.\n- delete_pending: Delete an existing pending review of a pull request. This requires that a pending review exists for the current user on the specified pull request.\n- resolve_thread: Resolve a review thread. Requires only \"threadId\" parameter with the thread's node ID (e.g., PRRT_kwDOxxx). The owner, repo, and pullNumber parameters are not used for this method. Resolving an already-resolved thread is a no-op.\n- unresolve_thread: Unresolve a previously resolved review thread. Requires only \"threadId\" parameter. The owner, repo, and pullNumber parameters are not used for this method. Unresolving an already-unresolved thread is a no-op.\n", + "inputSchema": { + "properties": { + "body": { + "description": "Review comment text", + "type": "string" + }, + "commitID": { + "description": "SHA of commit to review", + "type": "string" + }, + "event": { + "description": "Review action to perform.", + "enum": [ + "APPROVE", + "REQUEST_CHANGES", + "COMMENT" + ], + "type": "string" + }, + "method": { + "description": "The write operation to perform on pull request review.", + "enum": [ + "create", + "submit_pending", + "delete_pending", + "resolve_thread", + "unresolve_thread" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "pullNumber": { + "description": "Pull request number", + "type": "number" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "resolutionReason": { + "description": "Optional reason for resolving a Copilot code review thread: addressed, wont-fix, or invalid.", + "type": "string" + }, + "threadId": { + "description": "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.", + "type": "string" + } + }, + "required": [ + "method", + "owner", + "repo", + "pullNumber" + ], + "type": "object" + }, + "name": "pull_request_review_write", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "description": "Operation result message.", + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_pull_request.snap b/pkg/github/__toolsnaps__/update_pull_request.snap index ae037a3e0d..bc61f3c520 100644 --- a/pkg/github/__toolsnaps__/update_pull_request.snap +++ b/pkg/github/__toolsnaps__/update_pull_request.snap @@ -71,5 +71,44 @@ ], "type": "object" }, - "name": "update_pull_request" + "name": "update_pull_request", + "outputSchema": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "reason": { + "type": "string" + }, + "status": { + "enum": [ + "awaiting_user_submission" + ], + "type": "string" + } + }, + "required": [ + "status", + "reason" + ], + "type": "object" + } + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_pull_request_branch.snap b/pkg/github/__toolsnaps__/update_pull_request_branch.snap index 633ce6fb88..83d75b4b37 100644 --- a/pkg/github/__toolsnaps__/update_pull_request_branch.snap +++ b/pkg/github/__toolsnaps__/update_pull_request_branch.snap @@ -31,5 +31,21 @@ ], "type": "object" }, - "name": "update_pull_request_branch" + "name": "update_pull_request_branch", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "description": "Operation result message.", + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "object", + "null" + ] + } } \ No newline at end of file diff --git a/pkg/github/consolidated_pull_request_types.go b/pkg/github/consolidated_pull_request_types.go new file mode 100644 index 0000000000..e32976ba50 --- /dev/null +++ b/pkg/github/consolidated_pull_request_types.go @@ -0,0 +1,620 @@ +package github + +import ( + "encoding/json" + "fmt" + "maps" + "slices" + "strings" + + "github.com/github/github-mcp-server/pkg/utils" + "github.com/go-viper/mapstructure/v2" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type PullRequestReadInput struct { + Method string `json:"method"` + Owner string `json:"owner"` + Repo string `json:"repo"` + PullNumber int `json:"pullNumber"` + Page *int `json:"page,omitempty"` + PerPage *int `json:"perPage,omitempty"` + After *string `json:"after,omitempty"` +} + +type CreatePullRequestInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Title *string `json:"title,omitempty"` + Body *string `json:"body,omitempty"` + Head *string `json:"head,omitempty"` + Base *string `json:"base,omitempty"` + Draft *bool `json:"draft,omitempty"` + MaintainerCanModify *bool `json:"maintainer_can_modify,omitempty"` + Reviewers *[]string `json:"reviewers,omitempty"` + UISubmitted *bool `json:"_ui_submitted,omitempty"` +} + +// UpdatePullRequestInput uses pointers because the handler distinguishes +// omitted fields from explicit zero values such as draft=false. +type UpdatePullRequestInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + PullNumber int `json:"pullNumber"` + Title *string `json:"title,omitempty"` + Body *string `json:"body,omitempty"` + State *string `json:"state,omitempty"` + Draft *bool `json:"draft,omitempty"` + Base *string `json:"base,omitempty"` + MaintainerCanModify *bool `json:"maintainer_can_modify,omitempty"` + Reviewers *[]string `json:"reviewers,omitempty"` + UISubmitted *bool `json:"_ui_submitted,omitempty"` +} + +type MergePullRequestInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + PullNumber int `json:"pullNumber"` + CommitTitle *string `json:"commit_title,omitempty"` + CommitMessage *string `json:"commit_message,omitempty"` + MergeMethod *string `json:"merge_method,omitempty"` + ExpectedHeadSHA *string `json:"expectedHeadSha,omitempty"` +} + +type UpdatePullRequestBranchInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + PullNumber int `json:"pullNumber"` + ExpectedHeadSHA *string `json:"expectedHeadSha,omitempty"` +} + +// PullRequestReviewWriteInput covers both feature variants; resolutionReason +// is advertised and used only by the resolution-reason variant. +type PullRequestReviewWriteInput struct { + Method string `json:"method"` + Owner string `json:"owner"` + Repo string `json:"repo"` + PullNumber int `json:"pullNumber"` + Body string `json:"body,omitempty"` + Event string `json:"event,omitempty"` + CommitID *string `json:"commitID,omitempty"` + ThreadID string `json:"threadId,omitempty"` + ResolutionReason *string `json:"resolutionReason,omitempty"` +} + +type AddCommentToPendingReviewInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + PullNumber int `json:"pullNumber"` + Path string `json:"path"` + Body string `json:"body"` + SubjectType string `json:"subjectType"` + Line *int `json:"line,omitempty"` + Side *string `json:"side,omitempty"` + StartLine *int `json:"startLine,omitempty"` + StartSide *string `json:"startSide,omitempty"` +} + +type AddReplyToPullRequestCommentInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + PullNumber *int `json:"pullNumber,omitempty"` + CommentID int64 `json:"commentId"` + Body *string `json:"body,omitempty"` + Reaction *string `json:"reaction,omitempty"` +} + +// PullRequestReadOutput mirrors the method-specific top-level shapes that +// pull_request_read has always returned as text. +type PullRequestReadOutput struct { + PullRequest *MinimalPullRequest + Diff *PullRequestDiffOutput + Status *MinimalCombinedStatus + Files *[]MinimalPRFile + Commits *[]MinimalPullRequestCommit + ReviewThreads *MinimalReviewThreadsResponse + Reviews *[]MinimalPullRequestReview + Comments *[]IssueReadCommentOutput + CheckRuns *MinimalCheckRunsResult +} + +// PullRequestDiffOutput wraps the raw diff, which the text content returns verbatim. +type PullRequestDiffOutput struct { + Diff string `json:"diff"` +} + +func (out PullRequestReadOutput) MarshalJSON() ([]byte, error) { + switch { + case out.PullRequest != nil: + return json.Marshal(out.PullRequest) + case out.Diff != nil: + return json.Marshal(out.Diff) + case out.Status != nil: + return json.Marshal(out.Status) + case out.Files != nil: + return json.Marshal(out.Files) + case out.Commits != nil: + return json.Marshal(out.Commits) + case out.ReviewThreads != nil: + return json.Marshal(out.ReviewThreads) + case out.Reviews != nil: + return json.Marshal(out.Reviews) + case out.Comments != nil: + return json.Marshal(out.Comments) + case out.CheckRuns != nil: + return json.Marshal(out.CheckRuns) + default: + // The SDK validates this zero value even for error results before the + // protocol middleware removes it from the wire result. + return []byte("null"), nil + } +} + +func pullRequestCommentsOutput(output *IssueReadOutput) *PullRequestReadOutput { + if output == nil || output.Comments == nil { + return nil + } + return &PullRequestReadOutput{Comments: output.Comments} +} + +func pullRequestReadOutputSchema() *jsonschema.Schema { + arrays := []*jsonschema.Schema{ + pullRequestOutputSchema[[]MinimalPRFile](), + pullRequestOutputSchema[[]MinimalPullRequestCommit](), + pullRequestOutputSchema[[]MinimalPullRequestReview](), + pullRequestOutputSchema[[]IssueReadCommentOutput](), + } + for _, array := range arrays { + array.Defs = nil + } + // An empty array is valid for every list method, so the list shapes form + // one anyOf array variant instead of ambiguous oneOf branches. + return repositoryUnionSchema( + &jsonschema.Schema{Type: "null"}, + pullRequestOutputSchema[MinimalPullRequest](), + pullRequestOutputSchema[PullRequestDiffOutput](), + pullRequestOutputSchema[MinimalCombinedStatus](), + pullRequestOutputSchema[MinimalReviewThreadsResponse](), + pullRequestOutputSchema[MinimalCheckRunsResult](), + &jsonschema.Schema{Type: "array", AnyOf: arrays}, + ) +} + +// PullRequestWriteOutput is either the created/updated pull request reference +// or the notice that an interactive form is awaiting user submission. +type PullRequestWriteOutput struct { + PullRequest *PullRequestMutationReference + Awaiting *IssueWriteAwaitingOutput +} + +func (out PullRequestWriteOutput) MarshalJSON() ([]byte, error) { + if out.Awaiting != nil { + return json.Marshal(out.Awaiting) + } + if out.PullRequest != nil { + return json.Marshal(out.PullRequest) + } + return json.Marshal(PullRequestMutationReference{}) +} + +func pullRequestWriteOutputSchema() *jsonschema.Schema { + awaiting := pullRequestOutputSchema[IssueWriteAwaitingOutput]() + awaiting.Properties["status"].Enum = []any{"awaiting_user_submission"} + return repositoryUnionSchema(pullRequestOutputSchema[PullRequestMutationReference](), awaiting) +} + +// PullRequestMutationReference includes a browser URL only when the API supplied one. +// Reactions have no browser URL; their API endpoint is not useful output. +type PullRequestMutationReference struct { + ID string `json:"id"` + HTMLURL string `json:"html_url,omitempty"` +} + +func pullRequestMutationReference(response MinimalResponse) *PullRequestMutationReference { + return &PullRequestMutationReference{ID: response.ID, HTMLURL: response.URL} +} + +func pullRequestFormArguments(req *mcp.CallToolRequest, args map[string]any) (map[string]any, error) { + // Form deferral depends on which parameters the caller actually sent, + // so inspect the raw request rather than the normalized typed input. + if req == nil || req.Params == nil || len(req.Params.Arguments) == 0 { + return args, nil + } + var formArgs map[string]any + if err := json.Unmarshal(req.Params.Arguments, &formArgs); err != nil { + return nil, err + } + return formArgs, nil +} + +func pullRequestAwaitingFormResult(message string) (*mcp.CallToolResult, *PullRequestWriteOutput, error) { + result := utils.NewToolResultAwaitingFormSubmission(message) + output := &IssueWriteAwaitingOutput{ + Status: "awaiting_user_submission", + Reason: "An interactive form is being shown to the user. The operation has not been performed.", + } + result.StructuredContent = output + return result, &PullRequestWriteOutput{Awaiting: output}, nil +} + +// PullRequestMergeOutput preserves a JSON null merge response rather than +// letting the SDK replace a nil pointer with an empty object. +type PullRequestMergeOutput struct { + Result *PullRequestMergeResult +} + +type PullRequestMergeResult struct { + SHA *string `json:"sha,omitempty"` + Merged *bool `json:"merged,omitempty"` + Message *string `json:"message,omitempty"` +} + +func (out PullRequestMergeOutput) MarshalJSON() ([]byte, error) { + return json.Marshal(out.Result) +} + +func pullRequestMergeOutputSchema() *jsonschema.Schema { + return nullableRepositoryOutputSchema[PullRequestMergeResult]() +} + +// PullRequestBranchUpdateOutput carries either the API response or, for an +// accepted asynchronous update, the in-progress message. +type PullRequestBranchUpdateOutput struct { + Result *PullRequestBranchUpdateResult +} + +type PullRequestBranchUpdateResult struct { + Message *string `json:"message,omitempty"` +} + +func (out PullRequestBranchUpdateOutput) MarshalJSON() ([]byte, error) { + return json.Marshal(out.Result) +} + +func pullRequestBranchUpdateOutputSchema() *jsonschema.Schema { + return nullableRepositoryOutputSchema[PullRequestBranchUpdateResult]() +} + +func nullableRepositoryOutputSchema[T any]() *jsonschema.Schema { + schema := pullRequestOutputSchema[T]() + schema.Type = "" + schema.Types = []string{"object", "null"} + return schema +} + +// PullRequestCommentReplyOutput is a single reply or reaction reference, or +// both when the caller requested a reply and a reaction together. +type PullRequestCommentReplyOutput struct { + Response *PullRequestMutationReference + ReplyAndReaction *PullRequestReplyAndReactionOutput +} + +// PullRequestReplyAndReactionOutput keeps the legacy map's sorted key order. +type PullRequestReplyAndReactionOutput struct { + Comment PullRequestMutationReference `json:"comment"` + Reaction PullRequestMutationReference `json:"reaction"` +} + +func (out PullRequestCommentReplyOutput) MarshalJSON() ([]byte, error) { + if out.ReplyAndReaction != nil { + return json.Marshal(out.ReplyAndReaction) + } + if out.Response != nil { + return json.Marshal(out.Response) + } + return json.Marshal(PullRequestMutationReference{}) +} + +func pullRequestCommentReplyOutputSchema() *jsonschema.Schema { + return repositoryUnionSchema( + pullRequestOutputSchema[PullRequestMutationReference](), + pullRequestOutputSchema[PullRequestReplyAndReactionOutput](), + ) +} + +func pullRequestOutputSchema[T any]() *jsonschema.Schema { + schema := repositoryOutputSchema[T]() + describePullRequestOutputSchema(schema) + return schema +} + +// Only newly inferred schemas are customized, once during tool construction. +func describePullRequestOutputSchema(schema *jsonschema.Schema) { + if schema == nil { + return + } + for name, field := range schema.Properties { + switch name { + case "html_url": + field.Description = "Browser URL." + case "sha", "commit_id": + field.Description = "Git commit SHA." + case "created_at", "updated_at", "closed_at", "merged_at", "submitted_at", "started_at", "completed_at": + field.Description = "RFC 3339 timestamp." + case "line", "original_line", "start_line", "original_start_line": + field.Description = "One-based diff line number." + case "additions", "deletions", "changes": + field.Description = "Number of lines." + case "total_count", "totalCount": + field.Description = "Total number of results." + case "message": + if schema.Properties["author"] == nil { + field.Description = "Operation result message." + } + case "merged": + field.Description = "Whether the pull request was merged." + case "diff": + field.Description = "Unified pull request diff." + case "state": + switch { + case schema.Properties["draft"] != nil: + field.Description = "Pull request state; empty for a sparse response." + field.Enum = []any{"open", "closed", ""} + case schema.Properties["statuses"] != nil || schema.Properties["context"] != nil: + field.Description = "Commit status; empty for a sparse response." + field.Enum = []any{"error", "failure", "pending", "success", ""} + case schema.Properties["commit_id"] != nil: + field.Description = "Review state; empty for a sparse response." + field.Enum = []any{"APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED", "PENDING", ""} + } + case "status": + switch { + case schema.Properties["filename"] != nil: + field.Description = "File change status." + field.Enum = []any{"added", "removed", "modified", "renamed", "copied", "changed", "unchanged"} + case schema.Properties["conclusion"] != nil: + field.Description = "Check execution status; empty for a sparse response." + field.Enum = []any{"queued", "in_progress", "completed", "waiting", "requested", "pending", ""} + } + case "conclusion": + field.Description = "Completed check outcome." + field.Enum = []any{"success", "failure", "neutral", "cancelled", "skipped", "timed_out", "action_required", "stale", "startup_failure"} + } + describePullRequestOutputSchema(field) + } + describePullRequestOutputSchema(schema.Items) + for _, definition := range schema.Defs { + describePullRequestOutputSchema(definition) + } + for _, variant := range append(slices.Clone(schema.OneOf), schema.AnyOf...) { + describePullRequestOutputSchema(variant) + } +} + +// pullRequestMessageResult exposes the success message of review mutations +// whose exported helpers only return a text result. +func pullRequestMessageResult(result *mcp.CallToolResult, err error) (*mcp.CallToolResult, *RepositoryMessageOutput, error) { + if err != nil || result == nil || result.IsError || len(result.Content) != 1 { + return result, nil, err + } + text, ok := result.Content[0].(*mcp.TextContent) + if !ok { + return result, nil, nil + } + return result, &RepositoryMessageOutput{Message: text.Text}, nil +} + +// pullRequestArgumentSpec lists parameters in the order the legacy handler +// read them, so normalization reports the same first error the handler did. +type pullRequestArgumentSpec struct { + required []string + requiredInts []string + ints []string + strings []string + bools []string + stringArrays []string + ignoredStrings []string + // deferred required strings are filled with "" so the handler can still + // show its interactive form before reporting them as missing. + deferred []string + methods []string +} + +var pullRequestArgumentSpecs = map[string]pullRequestArgumentSpec{ + "read": { + required: []string{"method", "owner", "repo"}, requiredInts: []string{"pullNumber"}, + ints: []string{"page", "perPage"}, + methods: []string{"get", "get_diff", "get_status", "get_files", "get_commits", "get_review_comments", "get_reviews", "get_comments", "get_check_runs"}, + }, + "create": { + required: []string{"owner", "repo"}, deferred: []string{"title", "head", "base"}, + strings: []string{"title", "head", "base", "body"}, bools: []string{"draft", "maintainer_can_modify"}, + stringArrays: []string{"reviewers"}, + }, + "update": { + required: []string{"owner", "repo"}, requiredInts: []string{"pullNumber"}, + strings: []string{"title", "body", "state", "base"}, bools: []string{"draft", "maintainer_can_modify"}, + stringArrays: []string{"reviewers"}, + }, + "merge": { + required: []string{"owner", "repo"}, requiredInts: []string{"pullNumber"}, + strings: []string{"commit_title", "commit_message", "merge_method", "expectedHeadSha"}, + }, + "branch": { + required: []string{"owner", "repo"}, requiredInts: []string{"pullNumber"}, strings: []string{"expectedHeadSha"}, + }, + "pending_comment": { + required: []string{"owner", "repo"}, requiredInts: []string{"pullNumber"}, + ints: []string{"line", "startLine"}, ignoredStrings: []string{"side", "startSide"}, + }, + "reply": { + required: []string{"owner", "repo"}, strings: []string{"body", "reaction"}, + }, +} + +// normalizePullRequestArguments replays the legacy handler's parameter +// checks before strict schema validation. Legacy handlers bypassed schema +// validation, so they accepted numeric strings and whole floats and reported +// their own error messages; those messages are preserved here. +func normalizePullRequestArguments(kind string) func(json.RawMessage) (json.RawMessage, error) { + spec, ok := pullRequestArgumentSpecs[kind] + if !ok { + panic("unknown pull request argument kind: " + kind) + } + return func(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + for _, field := range spec.required { + if _, err := RequiredParam[string](args, field); err != nil { + return nil, err + } + } + for _, field := range spec.requiredInts { + value, err := RequiredInt(args, field) + if err != nil { + return nil, err + } + args[field] = value + } + if kind == "pending_comment" { + for _, field := range []string{"path", "body", "subjectType"} { + if _, err := RequiredParam[string](args, field); err != nil { + return nil, err + } + } + } + if kind == "reply" { + commentID, err := RequiredBigInt(args, "commentId") + if err != nil { + return nil, err + } + if commentID < 1 { + return nil, fmt.Errorf("commentId must be greater than 0") + } + args["commentId"] = commentID + } + for _, field := range spec.deferred { + if _, exists := args[field]; !exists { + args[field] = "" + } + } + + stringFields := spec.strings + boolFields := spec.bools + stringArrayFields := spec.stringArrays + if kind == "reply" { + stringFields = []string{"body", "reaction"} + } + if kind == "create" || kind == "update" { + // This flag is inspected from the raw call only to control form + // deferral; legacy handlers never type-validated it. + if _, err := OptionalParam[bool](args, "_ui_submitted"); err != nil { + delete(args, "_ui_submitted") + } + } + for _, field := range stringFields { + if _, err := OptionalParam[string](args, field); err != nil { + return nil, err + } + } + for _, field := range boolFields { + if _, err := OptionalParam[bool](args, field); err != nil { + return nil, err + } + } + for _, field := range stringArrayFields { + if _, exists := args[field]; !exists { + continue + } + values, err := OptionalStringArrayParam(args, field) + if err != nil { + return nil, err + } + args[field] = values + } + for _, field := range spec.ints { + if _, exists := args[field]; !exists { + continue + } + value, err := OptionalIntParam(args, field) + if err != nil { + return nil, err + } + args[field] = value + } + if spec.methods != nil && !slices.Contains(spec.methods, args["method"].(string)) { + return nil, fmt.Errorf("unknown method: %s", args["method"]) + } + if kind == "reply" { + _, hasBody := args["body"] + _, hasReaction := args["reaction"] + if !hasBody && !hasReaction { + return nil, fmt.Errorf("at least one of body or reaction is required") + } + // pullNumber is only read, and validated, when a reply body is sent. + if value, exists := args["pullNumber"]; exists { + pullNumber, err := toInt(value) + switch { + case err == nil && hasBody: + args["pullNumber"] = pullNumber + case err != nil && hasBody: + return nil, fmt.Errorf("parameter pullNumber is not a valid number: %w", err) + default: + delete(args, "pullNumber") + } + } + if hasBody && args["body"] == "" { + return nil, fmt.Errorf("body cannot be empty when provided") + } + if hasReaction && args["reaction"] == "" { + return nil, fmt.Errorf("reaction cannot be empty when provided") + } + } + for _, field := range spec.ignoredStrings { + if _, isString := args[field].(string); !isString { + delete(args, field) + } + } + return json.Marshal(args) + } +} + +// normalizePullRequestReviewWriteArguments retains the legacy WeakDecode +// semantics: case-insensitive keys, numeric-string conversion and fractional +// truncation of pullNumber. +func normalizePullRequestReviewWriteArguments(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + var params PullRequestReviewWriteParams + if err := mapstructure.WeakDecode(args, ¶ms); err != nil { + return nil, err + } + if !slices.Contains([]string{"create", "submit_pending", "delete_pending", "resolve_thread", "unresolve_thread"}, params.Method) { + return nil, fmt.Errorf("unknown method: %s", params.Method) + } + canonical := map[string]any{ + "method": params.Method, "owner": params.Owner, "repo": params.Repo, "pullNumber": params.PullNumber, + } + // Empty optional strings are equivalent to omitted ones for the handler, + // but would fail the schema's enum for event. + for key, value := range map[string]string{"body": params.Body, "event": params.Event, "threadId": params.ThreadID} { + if value != "" { + canonical[key] = value + } + } + if params.CommitID != nil { + canonical["commitID"] = *params.CommitID + } + if params.ResolutionReason != nil { + canonical["resolutionReason"] = *params.ResolutionReason + } + for key := range args { + for name := range pullRequestReviewWriteFields { + if strings.EqualFold(key, name) { + delete(args, key) + } + } + } + maps.Copy(args, canonical) + return json.Marshal(args) +} + +var pullRequestReviewWriteFields = map[string]struct{}{ + "method": {}, "owner": {}, "repo": {}, "pullNumber": {}, "body": {}, "event": {}, + "commitID": {}, "threadId": {}, "resolutionReason": {}, +} diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index a8493bc521..e376eed5db 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -68,11 +68,12 @@ Possible options: 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( + return NewTool[PullRequestReadInput, *PullRequestReadOutput]( ToolsetMetadataPullRequests, mcp.Tool{ - Name: "pull_request_read", - Description: t("TOOL_PULL_REQUEST_READ_DESCRIPTION", "Get information on a specific pull request in GitHub repository."), + Name: "pull_request_read", + OutputSchema: pullRequestReadOutputSchema(), + Description: t("TOOL_PULL_REQUEST_READ_DESCRIPTION", "Get information on a specific pull request in GitHub repository."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_PULL_REQUEST_USER_TITLE", "Get details for a single pull request"), ReadOnlyHint: true, @@ -80,7 +81,11 @@ Possible options: InputSchema: schema, }, scopes.PublicRead(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input PullRequestReadInput) (*mcp.CallToolResult, *PullRequestReadOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } method, err := RequiredParam[string](args, "method") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -120,20 +125,20 @@ Possible options: switch method { case "get": - result, err := GetPullRequest(ctx, client, deps, owner, repo, pullNumber) - return attachIFC(result), nil, err + result, output, err := getPullRequest(ctx, client, deps, owner, repo, pullNumber) + return attachIFC(result), output, err case "get_diff": - result, err := GetPullRequestDiff(ctx, client, deps, owner, repo, pullNumber) - return attachIFC(result), nil, err + result, output, err := getPullRequestDiff(ctx, client, deps, owner, repo, pullNumber) + return attachIFC(result), output, err case "get_status": - result, err := GetPullRequestStatus(ctx, client, owner, repo, pullNumber) - return attachIFC(result), nil, err + result, output, err := getPullRequestStatus(ctx, client, owner, repo, pullNumber) + return attachIFC(result), output, err case "get_files": - result, err := GetPullRequestFiles(ctx, client, deps, owner, repo, pullNumber, pagination) - return attachIFC(result), nil, err + result, output, err := getPullRequestFiles(ctx, client, deps, owner, repo, pullNumber, pagination) + return attachIFC(result), output, err case "get_commits": - result, err := GetPullRequestCommits(ctx, client, deps, owner, repo, pullNumber, pagination) - return attachIFC(result), nil, err + result, output, err := getPullRequestCommits(ctx, client, deps, owner, repo, pullNumber, pagination) + return attachIFC(result), output, err case "get_review_comments": gqlClient, err := deps.GetGQLClient(ctx) if err != nil { @@ -143,27 +148,32 @@ Possible options: if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - result, err := GetPullRequestReviewComments(ctx, gqlClient, deps, owner, repo, pullNumber, cursorPagination) - return attachIFC(result), nil, err + result, output, err := getPullRequestReviewComments(ctx, gqlClient, deps, owner, repo, pullNumber, cursorPagination) + return attachIFC(result), output, err case "get_reviews": - result, err := GetPullRequestReviews(ctx, client, deps, owner, repo, pullNumber, pagination) - return attachIFC(result), nil, err + result, output, err := getPullRequestReviews(ctx, client, deps, owner, repo, pullNumber, pagination) + return attachIFC(result), output, err case "get_comments": - result, err := GetIssueComments(ctx, client, deps, owner, repo, pullNumber, pagination) - return attachIFC(result), nil, err + result, output, err := getIssueComments(ctx, client, deps, owner, repo, pullNumber, pagination) + return attachIFC(result), pullRequestCommentsOutput(output), err case "get_check_runs": - result, err := GetPullRequestCheckRuns(ctx, client, owner, repo, pullNumber, pagination) - return attachIFC(result), nil, err + result, output, err := getPullRequestCheckRuns(ctx, client, owner, repo, pullNumber, pagination) + return attachIFC(result), output, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } - }) + }, normalizePullRequestArguments("read")) } func GetPullRequest(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { + result, _, err := getPullRequest(ctx, client, deps, owner, repo, pullNumber) + return result, err +} + +func getPullRequest(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int) (*mcp.CallToolResult, *PullRequestReadOutput, error) { cache, err := deps.GetRepoAccessCache(ctx) if err != nil { - return nil, fmt.Errorf("failed to get repo access cache: %w", err) + return nil, nil, fmt.Errorf("failed to get repo access cache: %w", err) } ff := deps.GetFlags(ctx) @@ -173,27 +183,31 @@ func GetPullRequest(ctx context.Context, client *github.Client, deps ToolDepende "failed to get pull request", resp, err, - ), nil + ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request", resp, body), nil, nil + } + + if pr == nil { + return utils.NewToolResultText("null"), &PullRequestReadOutput{}, nil } if ff.LockdownMode { if restricted, err := authorLockdownResult(ctx, cache, owner, repo, pr.GetUser().GetLogin(), lockdownPullRequestRestrictedMessage); restricted != nil || err != nil { - return restricted, err + return restricted, nil, err } } minimalPR := convertToMinimalPullRequest(pr) - return MarshalledTextResult(minimalPR), nil + return MarshalledTextResult(minimalPR), &PullRequestReadOutput{PullRequest: &minimalPR}, nil } // enforcePullRequestLockdown returns a restricted tool result when lockdown mode is @@ -226,8 +240,13 @@ func enforcePullRequestLockdown(ctx context.Context, client *github.Client, deps } func GetPullRequestDiff(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { + result, _, err := getPullRequestDiff(ctx, client, deps, owner, repo, pullNumber) + return result, err +} + +func getPullRequestDiff(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int) (*mcp.CallToolResult, *PullRequestReadOutput, error) { if restricted, err := enforcePullRequestLockdown(ctx, client, deps, owner, repo, pullNumber); restricted != nil || err != nil { - return restricted, err + return restricted, nil, err } raw, resp, err := client.PullRequests.GetRaw( @@ -242,40 +261,45 @@ func GetPullRequestDiff(ctx context.Context, client *github.Client, deps ToolDep "failed to get pull request diff", resp, err, - ), nil + ), nil, nil } if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request diff", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request diff", resp, body), nil, nil } defer func() { _ = resp.Body.Close() }() // Return the raw response - return utils.NewToolResultText(string(raw)), nil + return utils.NewToolResultText(string(raw)), &PullRequestReadOutput{Diff: &PullRequestDiffOutput{Diff: string(raw)}}, nil } func GetPullRequestStatus(ctx context.Context, client *github.Client, owner, repo string, pullNumber int) (*mcp.CallToolResult, error) { + result, _, err := getPullRequestStatus(ctx, client, owner, repo, pullNumber) + return result, err +} + +func getPullRequestStatus(ctx context.Context, client *github.Client, owner, repo string, pullNumber int) (*mcp.CallToolResult, *PullRequestReadOutput, error) { pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get pull request", resp, err, - ), nil + ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request", resp, body), nil, nil } // Get combined status for the head SHA @@ -285,27 +309,33 @@ func GetPullRequestStatus(ctx context.Context, client *github.Client, owner, rep "failed to get combined status", resp, err, - ), nil + ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get combined status", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get combined status", resp, body), nil, nil } - r, err := json.Marshal(convertToMinimalCombinedStatus(status)) + minimalStatus := convertToMinimalCombinedStatus(status) + r, err := json.Marshal(minimalStatus) if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil + return utils.NewToolResultText(string(r)), &PullRequestReadOutput{Status: &minimalStatus}, nil } func GetPullRequestCheckRuns(ctx context.Context, client *github.Client, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { + result, _, err := getPullRequestCheckRuns(ctx, client, owner, repo, pullNumber, pagination) + return result, err +} + +func getPullRequestCheckRuns(ctx context.Context, client *github.Client, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, *PullRequestReadOutput, error) { // First get the PR to get the head SHA pr, resp, err := client.PullRequests.Get(ctx, owner, repo, pullNumber) if err != nil { @@ -313,16 +343,16 @@ func GetPullRequestCheckRuns(ctx context.Context, client *github.Client, owner, "failed to get pull request", resp, err, - ), nil + ), nil, nil } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request", resp, body), nil, nil } // Get check runs for the head SHA @@ -339,16 +369,16 @@ func GetPullRequestCheckRuns(ctx context.Context, client *github.Client, owner, "failed to get check runs", resp, err, - ), nil + ), nil, nil } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get check runs", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get check runs", resp, body), nil, nil } // Convert to minimal check runs to reduce context usage @@ -364,15 +394,20 @@ func GetPullRequestCheckRuns(ctx context.Context, client *github.Client, owner, r, err := json.Marshal(minimalResult) if err != nil { - return nil, fmt.Errorf("failed to marshal response: %w", err) + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil + return utils.NewToolResultText(string(r)), &PullRequestReadOutput{CheckRuns: &minimalResult}, nil } func GetPullRequestFiles(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { + result, _, err := getPullRequestFiles(ctx, client, deps, owner, repo, pullNumber, pagination) + return result, err +} + +func getPullRequestFiles(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, *PullRequestReadOutput, error) { if restricted, err := enforcePullRequestLockdown(ctx, client, deps, owner, repo, pullNumber); restricted != nil || err != nil { - return restricted, err + return restricted, nil, err } opts := &github.ListOptions{ @@ -385,29 +420,34 @@ func GetPullRequestFiles(ctx context.Context, client *github.Client, deps ToolDe "failed to get pull request files", resp, err, - ), nil + ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request files", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request files", resp, body), nil, nil } minimalFiles := convertToMinimalPRFiles(files) - return MarshalledTextResult(minimalFiles), nil + return MarshalledTextResult(minimalFiles), &PullRequestReadOutput{Files: &minimalFiles}, nil } // GetPullRequestCommits returns the commits on a pull request. Under lockdown // mode it checks the PR author once rather than per commit, since every // commit on the PR belongs to the same untrusted head branch. func GetPullRequestCommits(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { + result, _, err := getPullRequestCommits(ctx, client, deps, owner, repo, pullNumber, pagination) + return result, err +} + +func getPullRequestCommits(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, *PullRequestReadOutput, error) { if restricted, err := enforcePullRequestLockdown(ctx, client, deps, owner, repo, pullNumber); restricted != nil || err != nil { - return restricted, err + return restricted, nil, err } opts := &github.ListOptions{ @@ -420,21 +460,21 @@ func GetPullRequestCommits(ctx context.Context, client *github.Client, deps Tool "failed to get pull request commits", resp, err, - ), nil + ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request commits", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request commits", resp, body), nil, nil } minimalCommits := convertToMinimalPullRequestCommits(commits) - return MarshalledTextResult(minimalCommits), nil + return MarshalledTextResult(minimalCommits), &PullRequestReadOutput{Commits: &minimalCommits}, nil } // GraphQL types for review threads query @@ -485,16 +525,21 @@ type pageInfoFragment struct { } func GetPullRequestReviewComments(ctx context.Context, gqlClient *githubv4.Client, deps ToolDependencies, owner, repo string, pullNumber int, pagination CursorPaginationParams) (*mcp.CallToolResult, error) { + result, _, err := getPullRequestReviewComments(ctx, gqlClient, deps, owner, repo, pullNumber, pagination) + return result, err +} + +func getPullRequestReviewComments(ctx context.Context, gqlClient *githubv4.Client, deps ToolDependencies, owner, repo string, pullNumber int, pagination CursorPaginationParams) (*mcp.CallToolResult, *PullRequestReadOutput, error) { cache, err := deps.GetRepoAccessCache(ctx) if err != nil { - return nil, fmt.Errorf("failed to get repo access cache: %w", err) + return nil, nil, fmt.Errorf("failed to get repo access cache: %w", err) } ff := deps.GetFlags(ctx) // Convert pagination parameters to GraphQL format gqlParams, err := pagination.ToGraphQLParams() if err != nil { - return utils.NewToolResultError(fmt.Sprintf("invalid pagination parameters: %v", err)), nil + return utils.NewToolResultError(fmt.Sprintf("invalid pagination parameters: %v", err)), nil, nil } // Build variables for GraphQL query @@ -519,13 +564,13 @@ func GetPullRequestReviewComments(ctx context.Context, gqlClient *githubv4.Clien return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to get pull request review threads", err, - ), nil + ), nil, nil } // Lockdown mode filtering if ff.LockdownMode { if cache == nil { - return nil, fmt.Errorf("lockdown cache is not configured") + return nil, nil, fmt.Errorf("lockdown cache is not configured") } // Iterate through threads and filter comments @@ -538,7 +583,7 @@ func GetPullRequestReviewComments(ctx context.Context, gqlClient *githubv4.Clien if login != "" { isSafeContent, err := cache.IsSafeContent(ctx, login, owner, repo) if err != nil { - return nil, fmt.Errorf("failed to check lockdown mode: %w", err) + return nil, nil, fmt.Errorf("failed to check lockdown mode: %w", err) } if isSafeContent { filteredComments = append(filteredComments, comment) @@ -551,13 +596,19 @@ func GetPullRequestReviewComments(ctx context.Context, gqlClient *githubv4.Clien } } - return MarshalledTextResult(convertToMinimalReviewThreadsResponse(query)), nil + threads := convertToMinimalReviewThreadsResponse(query) + return MarshalledTextResult(threads), &PullRequestReadOutput{ReviewThreads: &threads}, nil } func GetPullRequestReviews(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { + result, _, err := getPullRequestReviews(ctx, client, deps, owner, repo, pullNumber, pagination) + return result, err +} + +func getPullRequestReviews(ctx context.Context, client *github.Client, deps ToolDependencies, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, *PullRequestReadOutput, error) { cache, err := deps.GetRepoAccessCache(ctx) if err != nil { - return nil, fmt.Errorf("failed to get repo access cache: %w", err) + return nil, nil, fmt.Errorf("failed to get repo access cache: %w", err) } ff := deps.GetFlags(ctx) @@ -570,21 +621,21 @@ func GetPullRequestReviews(ctx context.Context, client *github.Client, deps Tool "failed to get pull request reviews", resp, err, - ), nil + ), nil, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { body, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read response body: %w", err) + return nil, nil, fmt.Errorf("failed to read response body: %w", err) } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request reviews", resp, body), nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request reviews", resp, body), nil, nil } if ff.LockdownMode { if cache == nil { - return nil, fmt.Errorf("lockdown cache is not configured") + return nil, nil, fmt.Errorf("lockdown cache is not configured") } filteredReviews := make([]*github.PullRequestReview, 0, len(reviews)) for _, review := range reviews { @@ -594,7 +645,7 @@ func GetPullRequestReviews(ctx context.Context, client *github.Client, deps Tool } isSafeContent, err := cache.IsSafeContent(ctx, login, owner, repo) if err != nil { - return nil, fmt.Errorf("failed to check lockdown mode: %w", err) + return nil, nil, fmt.Errorf("failed to check lockdown mode: %w", err) } if isSafeContent { filteredReviews = append(filteredReviews, review) @@ -608,7 +659,7 @@ func GetPullRequestReviews(ctx context.Context, client *github.Client, deps Tool minimalReviews = append(minimalReviews, convertToMinimalPullRequestReview(review)) } - return MarshalledTextResult(minimalReviews), nil + return MarshalledTextResult(minimalReviews), &PullRequestReadOutput{Reviews: &minimalReviews}, nil } // PullRequestWriteUIResourceURI is the URI for the create_pull_request tool's MCP App UI resource. @@ -649,11 +700,12 @@ var pullRequestUpdateFormParams = map[string]struct{}{ // CreatePullRequest creates a tool to create a new pull request. func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + return NewTool[CreatePullRequestInput, *PullRequestWriteOutput]( ToolsetMetadataPullRequests, mcp.Tool{ - Name: "create_pull_request", - Description: t("TOOL_CREATE_PULL_REQUEST_DESCRIPTION", "Create a new pull request in a GitHub repository."), + Name: "create_pull_request", + OutputSchema: pullRequestWriteOutputSchema(), + Description: t("TOOL_CREATE_PULL_REQUEST_DESCRIPTION", "Create a new pull request in a GitHub repository."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_CREATE_PULL_REQUEST_USER_TITLE", "Open new pull request"), ReadOnlyHint: false, @@ -711,7 +763,11 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo }, }, publicRepositoryWriteScopeAccess(), - func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, input CreatePullRequestInput) (*mcp.CallToolResult, *PullRequestWriteOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -723,15 +779,19 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo // Hand off to the interactive MCP App form unless this call must // execute now (see shouldDeferToForm). - if shouldDeferToForm(ctx, deps, req, args, pullRequestWriteFormParams) { - return utils.NewToolResultAwaitingFormSubmission(fmt.Sprintf( + formArgs, err := pullRequestFormArguments(req, args) + if err != nil { + return nil, nil, err + } + if shouldDeferToForm(ctx, deps, req, formArgs, pullRequestWriteFormParams) { + return pullRequestAwaitingFormResult(fmt.Sprintf( "An interactive form has been shown to the user for creating a new pull request in %s/%s. "+ "STOP — do not call any other tools, do not respond as if the pull request was created, "+ "and do not claim the operation succeeded. The pull request has NOT been created yet; "+ "only the form was rendered. Wait silently for the user to review and click Submit. "+ "When they do, the real result will be delivered to your context automatically.", owner, repo, - )), nil, nil + )) } // When creating PR, title/head/base are required @@ -853,8 +913,8 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil - }) + return utils.NewToolResultText(string(r)), &PullRequestWriteOutput{PullRequest: pullRequestMutationReference(minimalResponse)}, nil + }, normalizePullRequestArguments("create")) } // UpdatePullRequest creates a tool to update an existing pull request. @@ -910,11 +970,12 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo Required: []string{"owner", "repo", "pullNumber"}, } - st := NewTool( + st := NewTool[UpdatePullRequestInput, *PullRequestWriteOutput]( ToolsetMetadataPullRequests, mcp.Tool{ - Name: "update_pull_request", - Description: t("TOOL_UPDATE_PULL_REQUEST_DESCRIPTION", "Update an existing pull request in a GitHub repository."), + Name: "update_pull_request", + OutputSchema: pullRequestWriteOutputSchema(), + Description: t("TOOL_UPDATE_PULL_REQUEST_DESCRIPTION", "Update an existing pull request in a GitHub repository."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_UPDATE_PULL_REQUEST_USER_TITLE", "Edit pull request"), ReadOnlyHint: false, @@ -928,7 +989,11 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo InputSchema: schema, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, input UpdatePullRequestInput) (*mcp.CallToolResult, *PullRequestWriteOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -944,15 +1009,19 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo // Hand off to the interactive MCP App form unless this call must // execute now (see shouldDeferToForm). - if shouldDeferToForm(ctx, deps, req, args, pullRequestUpdateFormParams) { - return utils.NewToolResultAwaitingFormSubmission(fmt.Sprintf( + formArgs, err := pullRequestFormArguments(req, args) + if err != nil { + return nil, nil, err + } + if shouldDeferToForm(ctx, deps, req, formArgs, pullRequestUpdateFormParams) { + return pullRequestAwaitingFormResult(fmt.Sprintf( "An interactive form has been shown to the user for editing pull request #%d in %s/%s. "+ "STOP — do not call any other tools, do not respond as if the pull request was updated, "+ "and do not claim the operation succeeded. The pull request has NOT been updated yet; "+ "only the form was rendered. Wait silently for the user to review and click Submit. "+ "When they do, the real result will be delivered to your context automatically.", pullNumber, owner, repo, - )), nil, nil + )) } _, draftProvided := args["draft"] @@ -1168,8 +1237,8 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultErrorFromErr("Failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil - }) + return utils.NewToolResultText(string(r)), &PullRequestWriteOutput{PullRequest: pullRequestMutationReference(minimalResponse)}, nil + }, normalizePullRequestArguments("update")) st.FeatureRule = pullRequestsConsolidatedRule return st } @@ -1209,11 +1278,12 @@ func AddReplyToPullRequestComment(t translations.TranslationHelperFunc) inventor Required: []string{"owner", "repo", "commentId"}, } - return NewTool( + return NewTool[AddReplyToPullRequestCommentInput, *PullRequestCommentReplyOutput]( ToolsetMetadataPullRequests, mcp.Tool{ - Name: "add_reply_to_pull_request_comment", - Description: t("TOOL_ADD_REPLY_TO_PULL_REQUEST_COMMENT_DESCRIPTION", "Add a reply and/or reaction to an existing pull request comment. This can create a new comment linked as a reply to the specified comment, add an emoji reaction to the specified comment, or do both. At least one of body or reaction is required."), + Name: "add_reply_to_pull_request_comment", + OutputSchema: pullRequestCommentReplyOutputSchema(), + Description: t("TOOL_ADD_REPLY_TO_PULL_REQUEST_COMMENT_DESCRIPTION", "Add a reply and/or reaction to an existing pull request comment. This can create a new comment linked as a reply to the specified comment, add an emoji reaction to the specified comment, or do both. At least one of body or reaction is required."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ADD_REPLY_TO_PULL_REQUEST_COMMENT_USER_TITLE", "Add reply to pull request comment"), ReadOnlyHint: false, @@ -1221,7 +1291,11 @@ func AddReplyToPullRequestComment(t translations.TranslationHelperFunc) inventor InputSchema: schema, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input AddReplyToPullRequestCommentInput) (*mcp.CallToolResult, *PullRequestCommentReplyOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -1303,26 +1377,29 @@ func AddReplyToPullRequestComment(t translations.TranslationHelperFunc) inventor } } - var result any + var output PullRequestCommentReplyOutput + var r []byte switch { case hasBody && hasReaction: - result = map[string]MinimalResponse{ - "comment": *commentResponse, - "reaction": *reactionResponse, + output.ReplyAndReaction = &PullRequestReplyAndReactionOutput{ + Comment: *pullRequestMutationReference(*commentResponse), + Reaction: PullRequestMutationReference{ID: reactionResponse.ID}, } + r, err = json.Marshal(map[string]*MinimalResponse{"comment": commentResponse, "reaction": reactionResponse}) case hasReaction: - result = reactionResponse + output.Response = &PullRequestMutationReference{ID: reactionResponse.ID} + r, err = json.Marshal(reactionResponse) default: - result = commentResponse + output.Response = pullRequestMutationReference(*commentResponse) + r, err = json.Marshal(commentResponse) } - r, err := json.Marshal(result) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil - }) + return utils.NewToolResultText(string(r)), &output, nil + }, normalizePullRequestArguments("reply")) } // ListPullRequests creates a tool to list pull requests in a GitHub repository. @@ -1632,12 +1709,13 @@ func MergePullRequest(t translations.TranslationHelperFunc) inventory.ServerTool Required: []string{"owner", "repo", "pullNumber"}, } - return NewTool( + return NewTool[MergePullRequestInput, *PullRequestMergeOutput]( ToolsetMetadataPullRequests, mcp.Tool{ - Name: "merge_pull_request", - Description: t("TOOL_MERGE_PULL_REQUEST_DESCRIPTION", "Merge a pull request in a GitHub repository."), - Icons: octicons.Icons("git-merge"), + Name: "merge_pull_request", + OutputSchema: pullRequestMergeOutputSchema(), + Description: t("TOOL_MERGE_PULL_REQUEST_DESCRIPTION", "Merge a pull request in a GitHub repository."), + Icons: octicons.Icons("git-merge"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_MERGE_PULL_REQUEST_USER_TITLE", "Merge pull request"), ReadOnlyHint: false, @@ -1645,7 +1723,11 @@ func MergePullRequest(t translations.TranslationHelperFunc) inventory.ServerTool InputSchema: schema, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input MergePullRequestInput) (*mcp.CallToolResult, *PullRequestMergeOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -1708,8 +1790,12 @@ func MergePullRequest(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil - }) + output := &PullRequestMergeOutput{} + if result != nil { + output.Result = &PullRequestMergeResult{SHA: result.SHA, Merged: result.Merged, Message: result.Message} + } + return utils.NewToolResultText(string(r)), output, nil + }, normalizePullRequestArguments("merge")) } // SearchPullRequests creates a tool to search for pull requests. @@ -1807,11 +1893,12 @@ func UpdatePullRequestBranch(t translations.TranslationHelperFunc) inventory.Ser Required: []string{"owner", "repo", "pullNumber"}, } - return NewTool( + return NewTool[UpdatePullRequestBranchInput, *PullRequestBranchUpdateOutput]( ToolsetMetadataPullRequests, mcp.Tool{ - Name: "update_pull_request_branch", - Description: t("TOOL_UPDATE_PULL_REQUEST_BRANCH_DESCRIPTION", "Update the branch of a pull request with the latest changes from the base branch."), + Name: "update_pull_request_branch", + OutputSchema: pullRequestBranchUpdateOutputSchema(), + Description: t("TOOL_UPDATE_PULL_REQUEST_BRANCH_DESCRIPTION", "Update the branch of a pull request with the latest changes from the base branch."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_UPDATE_PULL_REQUEST_BRANCH_USER_TITLE", "Update pull request branch"), ReadOnlyHint: false, @@ -1819,7 +1906,11 @@ func UpdatePullRequestBranch(t translations.TranslationHelperFunc) inventory.Ser InputSchema: schema, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input UpdatePullRequestBranchInput) (*mcp.CallToolResult, *PullRequestBranchUpdateOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -1850,7 +1941,8 @@ func UpdatePullRequestBranch(t translations.TranslationHelperFunc) inventory.Ser // Check if it's an acceptedError. An acceptedError indicates that the update is in progress, // and it's not a real error. if resp != nil && resp.StatusCode == http.StatusAccepted && isAcceptedError(err) { - return utils.NewToolResultText("Pull request branch update is in progress"), nil, nil + message := "Pull request branch update is in progress" + return utils.NewToolResultText(message), &PullRequestBranchUpdateOutput{Result: &PullRequestBranchUpdateResult{Message: &message}}, nil } return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to update pull request branch", @@ -1873,8 +1965,12 @@ func UpdatePullRequestBranch(t translations.TranslationHelperFunc) inventory.Ser return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil - }) + output := &PullRequestBranchUpdateOutput{} + if result != nil { + output.Result = &PullRequestBranchUpdateResult{Message: result.Message} + } + return utils.NewToolResultText(string(r)), output, nil + }, normalizePullRequestArguments("branch")) } type PullRequestReviewWriteParams struct { @@ -1956,10 +2052,11 @@ func pullRequestReviewWrite(t translations.TranslationHelperFunc, withResolution } } - st := NewTool( + st := NewTool[PullRequestReviewWriteInput, *RepositoryMessageOutput]( ToolsetMetadataPullRequests, mcp.Tool{ - Name: "pull_request_review_write", + Name: "pull_request_review_write", + OutputSchema: pullRequestOutputSchema[RepositoryMessageOutput](), Description: t("TOOL_PULL_REQUEST_REVIEW_WRITE_DESCRIPTION", `Create and/or submit, delete review of a pull request. Available methods: @@ -1976,7 +2073,11 @@ Available methods: InputSchema: schema, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input PullRequestReviewWriteInput) (*mcp.CallToolResult, *RepositoryMessageOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } var params PullRequestReviewWriteParams if err := mapstructure.WeakDecode(args, ¶ms); err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -1988,30 +2089,32 @@ Available methods: return utils.NewToolResultError(fmt.Sprintf("failed to get GitHub GQL client: %v", err)), nil, nil } + var result *mcp.CallToolResult + switch params.Method { case "create": - result, err := CreatePullRequestReview(ctx, client, params) - return result, nil, err + result, err = CreatePullRequestReview(ctx, client, params) + return pullRequestMessageResult(result, err) case "submit_pending": - result, err := SubmitPendingPullRequestReview(ctx, client, params) - return result, nil, err + result, err = SubmitPendingPullRequestReview(ctx, client, params) + return pullRequestMessageResult(result, err) case "delete_pending": - result, err := DeletePendingPullRequestReview(ctx, client, params) - return result, nil, err + result, err = DeletePendingPullRequestReview(ctx, client, params) + return pullRequestMessageResult(result, err) case "resolve_thread": if !withResolutionReason { - result, err := ResolveReviewThread(ctx, client, params.ThreadID, true) - return result, nil, err + result, err = ResolveReviewThread(ctx, client, params.ThreadID, true) + return pullRequestMessageResult(result, err) } - result, err := ResolveReviewThreadWithReason(ctx, client, params.ThreadID, params.ResolutionReason, true) - return result, nil, err + result, err = ResolveReviewThreadWithReason(ctx, client, params.ThreadID, params.ResolutionReason, true) + return pullRequestMessageResult(result, err) case "unresolve_thread": - result, err := ResolveReviewThread(ctx, client, params.ThreadID, false) - return result, nil, err + result, err = ResolveReviewThread(ctx, client, params.ThreadID, false) + return pullRequestMessageResult(result, err) default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", params.Method)), nil, nil } - }) + }, normalizePullRequestReviewWriteArguments) if withResolutionReason { st.FeatureRule = inventory.NewFeatureRule( []inventory.FeatureFlag{FeatureFlagThreadResolutionReason, inventory.FeatureFlag(FeatureFlagPullRequestsGranular)}, @@ -2443,11 +2546,12 @@ func AddCommentToPendingReview(t translations.TranslationHelperFunc) inventory.S Required: []string{"owner", "repo", "pullNumber", "path", "body", "subjectType"}, } - st := NewTool( + st := NewTool[AddCommentToPendingReviewInput, *RepositoryMessageOutput]( ToolsetMetadataPullRequests, mcp.Tool{ - Name: "add_comment_to_pending_review", - Description: t("TOOL_ADD_COMMENT_TO_PENDING_REVIEW_DESCRIPTION", "Add review comment to the requester's latest pending pull request review. A pending review needs to already exist to call this (check with the user if not sure)."), + Name: "add_comment_to_pending_review", + OutputSchema: pullRequestOutputSchema[RepositoryMessageOutput](), + Description: t("TOOL_ADD_COMMENT_TO_PENDING_REVIEW_DESCRIPTION", "Add review comment to the requester's latest pending pull request review. A pending review needs to already exist to call this (check with the user if not sure)."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ADD_COMMENT_TO_PENDING_REVIEW_USER_TITLE", "Add review comment to the requester's latest pending pull request review"), ReadOnlyHint: false, @@ -2455,7 +2559,11 @@ func AddCommentToPendingReview(t translations.TranslationHelperFunc) inventory.S InputSchema: schema, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input AddCommentToPendingReviewInput) (*mcp.CallToolResult, *RepositoryMessageOutput, error) { + args, err := discussionNotificationArguments(input) + if err != nil { + return nil, nil, err + } owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -2525,8 +2633,8 @@ func AddCommentToPendingReview(t translations.TranslationHelperFunc) inventory.S StartLine: startLinePtr, StartSide: startSidePtr, }) - return result, nil, err - }) + return pullRequestMessageResult(result, err) + }, normalizePullRequestArguments("pending_comment")) st.FeatureRule = pullRequestsConsolidatedRule return st } diff --git a/pkg/github/typed_consolidated_pull_request_outputs_test.go b/pkg/github/typed_consolidated_pull_request_outputs_test.go new file mode 100644 index 0000000000..082691bc20 --- /dev/null +++ b/pkg/github/typed_consolidated_pull_request_outputs_test.go @@ -0,0 +1,634 @@ +package github + +import ( + "context" + "encoding/json" + "maps" + "net/http" + "reflect" + "slices" + "strings" + "testing" + "time" + + "github.com/github/github-mcp-server/internal/toolsnaps" + ghcontext "github.com/github/github-mcp-server/pkg/context" + "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/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var typedPullRequestProtocols = []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", ""} + +func consolidatedPullRequestSession(t *testing.T, deps BaseDeps, protocol string, ui bool, flags ...inventory.FeatureFlag) (*mcp.ClientSession, map[string]*jsonschema.Resolved) { + t.Helper() + tr := translations.NullTranslationHelper + tools := []inventory.ServerTool{ + PullRequestRead(tr), CreatePullRequest(tr), UpdatePullRequest(tr), MergePullRequest(tr), + UpdatePullRequestBranch(tr), PullRequestReviewWrite(tr), PullRequestReviewWriteWithResolutionReason(tr), + AddCommentToPendingReview(tr), AddReplyToPullRequestComment(tr), + } + canonicalSchemas := make(map[string]string) + for _, tool := range tools { + if tool.Tool.Name == "pull_request_review_write" { + continue + } + canonicalSchemas[tool.Tool.Name] = mustMarshalJSON(t, tool.Tool.OutputSchema) + } + review := PullRequestReviewWrite(tr) + if slices.Contains(flags, FeatureFlagThreadResolutionReason) { + review = PullRequestReviewWriteWithResolutionReason(tr) + require.NoError(t, toolsnaps.Test("pull_request_review_write_resolution_reason", review.Tool)) + } + canonicalSchemas[review.Tool.Name] = mustMarshalJSON(t, review.Tool.OutputSchema) + inv, err := inventory.NewBuilder().SetTools(tools).WithToolsets([]string{"all"}). + WithFeatureChecker(featureCheckerFor(flags...)).Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "consolidated-pull-requests", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) { + if protocol == "" { + switch req := request.(type) { + case *mcp.ListToolsRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + case *mcp.CallToolRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + } + } + result, err := next(ghcontext.WithUISupport(ctx, ui), method, request) + if protocol == inventory.ProtocolVersionMultiRoundTrip && method == "tools/call" && err == nil { + if call, ok := result.(*mcp.CallToolResult); ok && !call.IsError && len(call.Content) == 1 { + if text, ok := call.Content[0].(*mcp.TextContent); ok && text.Text == "null" { + // The client decodes explicit null and an omitted field as nil. + // Check the serialized response to distinguish them. + var wire map[string]json.RawMessage + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, call)), &wire)) + structured, present := wire["structuredContent"] + assert.True(t, present, "successful nullable output must include structuredContent") + assert.Equal(t, "null", string(structured)) + } + } + } + return result, err + } + }) + version := protocol + if version == "" { + version = inventory.ProtocolVersionMultiRoundTrip + } + session := connectCommentVisibilityClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + // Exactly one pull_request_review_write variant is exposed for a feature set. + require.Len(t, list.Tools, len(tools)-1) + schemas := make(map[string]*jsonschema.Resolved) + for _, tool := range list.Tools { + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Nil(t, tool.OutputSchema, tool.Name) + continue + } + require.NotNil(t, tool.OutputSchema, tool.Name) + assert.JSONEq(t, canonicalSchemas[tool.Name], mustMarshalJSON(t, tool.OutputSchema), tool.Name) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, tool.OutputSchema)), &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + schemas[tool.Name] = resolved + } + return session, schemas +} + +const ( + typedPRJSON = `{"number":1,"title":"Subject","body":"body","state":"open","draft":false,"merged":false,"mergeable_state":"clean","html_url":"https://github.com/owner/repo/pull/1","user":{"login":"octocat"},"head":{"ref":"feature","sha":"abc"},"base":{"ref":"main","sha":"def"},"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}` + typedPRDiff = "diff --git a/file b/file\n+added\n" + typedPRReviewThreads = `{"data":{"repository":{"pullRequest":{"reviewThreads":{"nodes":[{"id":"T_1","isResolved":false,"isOutdated":false,"isCollapsed":false,"comments":{"nodes":[{"id":"C_1","body":"nit","path":"file","line":3,"originalLine":3,"startLine":null,"originalStartLine":null,"author":{"login":"octocat"},"createdAt":"2026-01-01T00:00:00Z","updatedAt":"2026-01-01T00:00:00Z","url":"https://github.com/owner/repo/pull/1#discussion_r1"}],"totalCount":1}}],"pageInfo":{"hasNextPage":false,"hasPreviousPage":false,"startCursor":"s","endCursor":"e"},"totalCount":1}}}}}` +) + +func consolidatedPullRequestDeps(t *testing.T) BaseDeps { + t.Helper() + rest := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/api/v3") + write := func(status int, body string) { + w.WriteHeader(status) + _, _ = w.Write([]byte(body)) + } + switch { + case path == "/repos/owner/repo/pulls/1" && strings.Contains(r.Header.Get("Accept"), "diff"): + write(http.StatusOK, typedPRDiff) + case path == "/repos/owner/repo/pulls/1" || path == "/repos/owner/repo/pulls" && r.Method == http.MethodPost: + status := http.StatusOK + if r.Method == http.MethodPost { + status = http.StatusCreated + } + write(status, typedPRJSON) + case path == "/repos/owner/repo/pulls/2": + // A sparse PR without a head SHA still yields a valid status payload. + write(http.StatusOK, `{"number":2,"head":{"sha":"abc"}}`) + case path == "/repos/owner/repo/pulls/3": + // A successful null API payload is normalized to the legacy zero-value PR. + write(http.StatusOK, `null`) + case path == "/repos/owner/repo/commits/abc/status": + write(http.StatusOK, `{"state":"success","sha":"abc","total_count":1,"statuses":[{"state":"success","context":"ci","target_url":"https://ci"}]}`) + case path == "/repos/owner/repo/commits/abc/check-runs": + if r.URL.Query().Get("page") == "2" { + write(http.StatusOK, `{"total_count":0,"check_runs":[]}`) + } else { + write(http.StatusOK, `{"total_count":1,"check_runs":[{"id":5,"name":"build","status":"completed","conclusion":"success"}]}`) + } + case path == "/repos/owner/repo/pulls/1/files": + write(http.StatusOK, `[{"filename":"file","status":"modified","additions":1,"changes":1,"patch":"+added"}]`) + case path == "/repos/owner/repo/pulls/2/files", path == "/repos/owner/repo/pulls/2/commits", + path == "/repos/owner/repo/pulls/2/reviews", path == "/repos/owner/repo/issues/2/comments": + write(http.StatusOK, `[]`) + case path == "/repos/owner/repo/pulls/1/commits": + write(http.StatusOK, `[{"sha":"abc","html_url":"https://github.com/owner/repo/commit/abc","commit":{"message":"msg","author":{"name":"Octo","email":"octo@example.com","date":"2026-01-01T00:00:00Z"}}}]`) + case path == "/repos/owner/repo/pulls/1/reviews": + write(http.StatusOK, `[{"id":9,"state":"APPROVED","body":"lgtm","html_url":"https://github.com/owner/repo/pull/1#pullrequestreview-9","user":{"login":"octocat"},"commit_id":"abc","author_association":"MEMBER"}]`) + case path == "/repos/owner/repo/pulls/3/reviews": + // A successful null list payload is normalized to an empty list. + write(http.StatusOK, `null`) + case path == "/repos/owner/repo/issues/1/comments": + write(http.StatusOK, `[{"id":42,"body":"hello","html_url":"https://github.com/owner/repo/pull/1#issuecomment-42"}]`) + case path == "/repos/owner/repo/pulls/1/merge": + write(http.StatusOK, `{"sha":"abc","merged":true,"message":"Pull Request successfully merged"}`) + case path == "/repos/owner/repo/pulls/2/merge": + write(http.StatusOK, `null`) + case path == "/repos/owner/repo/pulls/1/update-branch": + write(http.StatusAccepted, `{"message":"Updating pull request branch.","url":"https://github.com/owner/repo/pull/1"}`) + case path == "/repos/owner/repo/pulls/2/update-branch": + write(http.StatusUnprocessableEntity, `{"message":"merge conflict between base and head"}`) + case path == "/repos/owner/repo/pulls/comments/42/reactions": + write(http.StatusCreated, `{"id":77,"content":"heart"}`) + case path == "/repos/owner/repo/pulls/1/comments": + write(http.StatusCreated, `{"id":43,"body":"reply","html_url":"https://github.com/owner/repo/pull/1#discussion_r43"}`) + default: + t.Errorf("unexpected REST request: %s %s", r.Method, r.URL.Path) + write(http.StatusInternalServerError, `{"message":"unexpected"}`) + } + })}} + gql := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Query string `json:"query"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + var body string + switch q := req.Query; { + case strings.Contains(q, "addPullRequestReviewThread("): + body = `{"data":{"addPullRequestReviewThread":{"thread":{"id":"T_2"}}}}` + case strings.Contains(q, "addPullRequestReview("): + body = `{"data":{"addPullRequestReview":{"pullRequestReview":{"id":"R_1"}}}}` + case strings.Contains(q, "submitPullRequestReview("): + body = `{"data":{"submitPullRequestReview":{"pullRequestReview":{"id":"R_1"}}}}` + case strings.Contains(q, "deletePullRequestReview("): + body = `{"data":{"deletePullRequestReview":{"pullRequestReview":{"id":"R_1"}}}}` + case strings.Contains(q, "unresolveReviewThread("): + body = `{"data":{"unresolveReviewThread":{"thread":{"id":"T_1","isResolved":false}}}}` + case strings.Contains(q, "resolveReviewThread("): + body = `{"data":{"resolveReviewThread":{"thread":{"id":"T_1","isResolved":true}}}}` + case strings.Contains(q, "reviewThreads("): + body = typedPRReviewThreads + case strings.Contains(q, "reviews(first: 100"): + body = `{"data":{"repository":{"pullRequest":{"reviews":{"nodes":[{"id":"R_1","author":{"userId":"U_1"}}],"pageInfo":{"hasNextPage":false,"endCursor":""}}}}}}` + case strings.Contains(q, "viewer{"): + body = `{"data":{"viewer":{"id":"U_1"}}}` + case strings.Contains(q, "pullRequest(number: $prNum){id}"): + body = `{"data":{"repository":{"pullRequest":{"id":"PR_1"}}}}` + default: + t.Errorf("unexpected GraphQL query: %s", req.Query) + w.WriteHeader(http.StatusInternalServerError) + return + } + _, _ = w.Write([]byte(body)) + })}} + return BaseDeps{ + Client: mustNewGHClient(t, rest), GQLClient: githubv4.NewClient(gql), + RepoAccessCache: stubRepoAccessCache(nil, time.Minute), + } +} + +type typedPullRequestCase struct { + tool string + args map[string]any + text string +} + +func runTypedPullRequestCases(t *testing.T, session *mcp.ClientSession, schemas map[string]*jsonschema.Resolved, cases []typedPullRequestCase) { + t.Helper() + for _, tc := range cases { + args := map[string]any{"owner": "owner", "repo": "repo"} + maps.Copy(args, tc.args) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool, Arguments: args}) + require.NoError(t, err) + require.False(t, result.IsError, "%s %v: %s", tc.tool, tc.args, mustMarshalJSON(t, result)) + require.Len(t, result.Content, 1) + text := getTextResult(t, result).Text + schema := schemas[tc.tool] + if schema == nil { + assert.Equal(t, tc.text, text, "%s %v", tc.tool, tc.args) + assert.Nil(t, result.StructuredContent, "%s %v", tc.tool, tc.args) + continue + } + if tc.text == "null" { + assert.Equal(t, "null", text) + // Explicit wire presence is checked by the session middleware. + assert.Nil(t, result.StructuredContent, "%s %v", tc.tool, tc.args) + require.NoError(t, schema.Validate(nil)) + continue + } + require.NotNil(t, result.StructuredContent, "%s %v", tc.tool, tc.args) + var value any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &value)) + require.NoError(t, schema.Validate(value), "%s %v", tc.tool, tc.args) + // The foundation wrapper serializes the compact DTO once for both + // modern JSON text and structuredContent, including diff and message DTOs. + assert.JSONEq(t, mustMarshalJSON(t, value), text, "%s %v", tc.tool, tc.args) + switch { + case tc.args["method"] == "get_diff": + assert.JSONEq(t, mustMarshalJSON(t, PullRequestDiffOutput{Diff: tc.text}), mustMarshalJSON(t, value)) + case tc.tool == "pull_request_review_write" || tc.tool == "add_comment_to_pending_review": + assert.JSONEq(t, mustMarshalJSON(t, RepositoryMessageOutput{Message: tc.text}), mustMarshalJSON(t, value)) + case tc.tool == "update_pull_request_branch": + assert.JSONEq(t, `{"message":"Pull request branch update is in progress"}`, mustMarshalJSON(t, value)) + case tc.tool == "create_pull_request" || tc.tool == "update_pull_request": + assert.JSONEq(t, `{"id":"0","html_url":"https://github.com/owner/repo/pull/1"}`, mustMarshalJSON(t, value)) + case tc.tool == "add_reply_to_pull_request_comment": + switch { + case tc.args["body"] != nil && tc.args["reaction"] != nil: + assert.JSONEq(t, `{"comment":{"id":"43","html_url":"https://github.com/owner/repo/pull/1#discussion_r43"},"reaction":{"id":"77"}}`, mustMarshalJSON(t, value)) + case tc.args["body"] != nil: + assert.JSONEq(t, `{"id":"43","html_url":"https://github.com/owner/repo/pull/1#discussion_r43"}`, mustMarshalJSON(t, value)) + default: + assert.JSONEq(t, `{"id":"77"}`, mustMarshalJSON(t, value)) + } + default: + assert.JSONEq(t, tc.text, mustMarshalJSON(t, value)) + } + } +} + +func TestTypedConsolidatedPullRequestOutputs(t *testing.T) { + const minimalPR = `{"number":1,"title":"Subject","body":"body","state":"open","draft":false,"merged":false,"mergeable_state":"clean","html_url":"https://github.com/owner/repo/pull/1","user":{"login":"octocat"},"head":{"ref":"feature","sha":"abc"},"base":{"ref":"main","sha":"def"},"created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z"}` + const prRef = `{"id":"0","url":"https://github.com/owner/repo/pull/1"}` + cases := []typedPullRequestCase{ + {"pull_request_read", map[string]any{"method": "get", "pullNumber": "1"}, minimalPR}, + {"pull_request_read", map[string]any{"method": "get", "pullNumber": 3}, `null`}, + {"pull_request_read", map[string]any{"method": "get_diff", "pullNumber": 1.0}, typedPRDiff}, + {"pull_request_read", map[string]any{"method": "get_status", "pullNumber": 1}, `{"state":"success","sha":"abc","total_count":1,"statuses":[{"state":"success","context":"ci","target_url":"https://ci"}]}`}, + {"pull_request_read", map[string]any{"method": "get_files", "pullNumber": 1, "page": "1", "perPage": 5}, `[{"filename":"file","status":"modified","additions":1,"changes":1,"patch":"+added"}]`}, + {"pull_request_read", map[string]any{"method": "get_files", "pullNumber": 2}, `[]`}, + {"pull_request_read", map[string]any{"method": "get_commits", "pullNumber": 1}, `[{"sha":"abc","html_url":"https://github.com/owner/repo/commit/abc","message":"msg","author":{"name":"Octo","email":"octo@example.com","date":"2026-01-01T00:00:00Z"}}]`}, + {"pull_request_read", map[string]any{"method": "get_commits", "pullNumber": 2}, `[]`}, + {"pull_request_read", map[string]any{"method": "get_review_comments", "pullNumber": 1, "perPage": 10, "after": "cursor"}, `{"review_threads":[{"id":"T_1","is_resolved":false,"is_outdated":false,"is_collapsed":false,"comments":[{"body":"nit","path":"file","line":3,"original_line":3,"author":"octocat","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","html_url":"https://github.com/owner/repo/pull/1#discussion_r1"}],"total_count":1}],"totalCount":1,"pageInfo":{"hasNextPage":false,"hasPreviousPage":false,"startCursor":"s","endCursor":"e"}}`}, + {"pull_request_read", map[string]any{"method": "get_reviews", "pullNumber": 1}, `[{"id":9,"state":"APPROVED","body":"lgtm","html_url":"https://github.com/owner/repo/pull/1#pullrequestreview-9","user":{"login":"octocat"},"commit_id":"abc","author_association":"MEMBER"}]`}, + {"pull_request_read", map[string]any{"method": "get_reviews", "pullNumber": 2}, `[]`}, + {"pull_request_read", map[string]any{"method": "get_reviews", "pullNumber": 3}, `[]`}, + {"pull_request_read", map[string]any{"method": "get_comments", "pullNumber": 1}, `[{"id":42,"body":"hello","html_url":"https://github.com/owner/repo/pull/1#issuecomment-42"}]`}, + {"pull_request_read", map[string]any{"method": "get_comments", "pullNumber": 2}, `[]`}, + {"pull_request_read", map[string]any{"method": "get_check_runs", "pullNumber": 1}, `{"total_count":1,"check_runs":[{"id":5,"name":"build","status":"completed","conclusion":"success"}]}`}, + {"pull_request_read", map[string]any{"method": "get_check_runs", "pullNumber": 1, "page": 2}, `{"total_count":0,"check_runs":[]}`}, + {"create_pull_request", map[string]any{"title": "Subject", "head": "feature", "base": "main", "draft": false}, prRef}, + {"update_pull_request", map[string]any{"pullNumber": "1", "title": "Subject", "maintainer_can_modify": false}, prRef}, + {"merge_pull_request", map[string]any{"pullNumber": 1, "merge_method": "squash", "expectedHeadSha": "abc"}, `{"sha":"abc","merged":true,"message":"Pull Request successfully merged"}`}, + {"merge_pull_request", map[string]any{"pullNumber": 2}, `null`}, + {"merge_pull_request", map[string]any{"pullNumber": 1.0, "merge_method": "squash", "expectedHeadSha": "abc"}, `{"sha":"abc","merged":true,"message":"Pull Request successfully merged"}`}, + {"update_pull_request_branch", map[string]any{"pullNumber": "1", "expectedHeadSha": "abc"}, "Pull request branch update is in progress"}, + {"pull_request_review_write", map[string]any{"method": "create", "pullNumber": "1"}, "pending pull request created"}, + {"pull_request_review_write", map[string]any{"method": "create", "pullNumber": 1, "event": "APPROVE", "body": "lgtm", "commitID": "abc"}, "pull request review submitted successfully"}, + {"pull_request_review_write", map[string]any{"method": "submit_pending", "pullNumber": 1, "event": "COMMENT", "body": "done"}, "pending pull request review successfully submitted"}, + {"pull_request_review_write", map[string]any{"method": "delete_pending", "pullNumber": 1}, "pending pull request review successfully deleted"}, + {"pull_request_review_write", map[string]any{"method": "resolve_thread", "threadId": "T_1"}, "review thread resolved successfully"}, + {"pull_request_review_write", map[string]any{"method": "unresolve_thread", "threadId": "T_1"}, "review thread unresolved successfully"}, + {"add_comment_to_pending_review", map[string]any{"pullNumber": "1", "path": "file", "body": "nit", "subjectType": "LINE", "line": "3", "side": "RIGHT", "startLine": 2, "startSide": 7}, "pull request review comment successfully added to pending review"}, + {"add_reply_to_pull_request_comment", map[string]any{"pullNumber": "1", "commentId": "42", "body": "reply"}, `{"id":"43","url":"https://github.com/owner/repo/pull/1#discussion_r43"}`}, + {"add_reply_to_pull_request_comment", map[string]any{"commentId": 42, "pullNumber": "x", "reaction": "heart"}, `{"id":"77","url":"https://api.github.com/repos/owner/repo/pulls/comments/42/reactions/77"}`}, + {"add_reply_to_pull_request_comment", map[string]any{"commentId": 42, "reaction": "heart"}, `{"id":"77","url":"https://api.github.com/repos/owner/repo/pulls/comments/42/reactions/77"}`}, + {"add_reply_to_pull_request_comment", map[string]any{"pullNumber": 1, "commentId": 42.0, "body": "reply", "reaction": "heart"}, `{"comment":{"id":"43","url":"https://github.com/owner/repo/pull/1#discussion_r43"},"reaction":{"id":"77","url":"https://api.github.com/repos/owner/repo/pulls/comments/42/reactions/77"}}`}, + } + for _, protocol := range typedPullRequestProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + session, schemas := consolidatedPullRequestSession(t, consolidatedPullRequestDeps(t), protocol, false) + runTypedPullRequestCases(t, session, schemas, cases) + }) + } +} + +func TestTypedPullRequestFilesLinkPagination(t *testing.T) { + for _, protocol := range typedPullRequestProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + var requestedPages []string + rest := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/repos/owner/repo/pulls/1/files", strings.TrimPrefix(r.URL.Path, "/api/v3")) + require.Equal(t, "1", r.URL.Query().Get("per_page")) + page := r.URL.Query().Get("page") + requestedPages = append(requestedPages, page) + switch page { + case "1": + w.Header().Set("Link", `; rel="next"`) + _, _ = w.Write([]byte(`[{"filename":"first","status":"modified","additions":1,"changes":1}]`)) + case "2": + _, _ = w.Write([]byte(`[{"filename":"second","status":"added","additions":1,"changes":1}]`)) + default: + t.Errorf("unexpected page query: %q", page) + w.WriteHeader(http.StatusInternalServerError) + } + })}} + deps := BaseDeps{ + Client: mustNewGHClient(t, rest), GQLClient: githubv4.NewClient(rest), + RepoAccessCache: stubRepoAccessCache(nil, time.Minute), + } + session, schemas := consolidatedPullRequestSession(t, deps, protocol, false) + runTypedPullRequestCases(t, session, schemas, []typedPullRequestCase{ + {"pull_request_read", map[string]any{"method": "get_files", "pullNumber": 1, "page": 1, "perPage": 1}, `[{"filename":"first","status":"modified","additions":1,"changes":1}]`}, + {"pull_request_read", map[string]any{"method": "get_files", "pullNumber": 1, "page": 2, "perPage": 1}, `[{"filename":"second","status":"added","additions":1,"changes":1}]`}, + }) + assert.Equal(t, []string{"1", "2"}, requestedPages, "explicit page calls must follow the API Link header's next-page target") + }) + } +} + +func TestTypedPullRequestReviewWriteResolutionReasonVariant(t *testing.T) { + for _, protocol := range typedPullRequestProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + session, schemas := consolidatedPullRequestSession(t, consolidatedPullRequestDeps(t), protocol, false, FeatureFlagThreadResolutionReason) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + for _, tool := range list.Tools { + if tool.Name == "pull_request_review_write" { + assert.Contains(t, mustMarshalJSON(t, tool.InputSchema), "resolutionReason") + } + } + runTypedPullRequestCases(t, session, schemas, []typedPullRequestCase{ + {"pull_request_review_write", map[string]any{"Method": "resolve_thread", "ThreadID": "T_1", "resolutionReason": "OUTDATED"}, "review thread resolved successfully"}, + {"pull_request_review_write", map[string]any{"method": "create", "pullNumber": 1.5}, "pending pull request created"}, + }) + }) + } +} + +func TestTypedPullRequestWriteAwaitingForm(t *testing.T) { + for _, protocol := range typedPullRequestProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + session, schemas := consolidatedPullRequestSession(t, consolidatedPullRequestDeps(t), protocol, true) + for tool, args := range map[string]map[string]any{ + "create_pull_request": {"owner": "owner", "repo": "repo", "title": "Subject", "head": "feature", "base": "main"}, + "update_pull_request": {"owner": "owner", "repo": "repo", "pullNumber": 1, "title": "Subject"}, + } { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tool, Arguments: args}) + require.NoError(t, err) + require.True(t, result.IsError, tool) + assert.Contains(t, getErrorResult(t, result).Text, "interactive form") + if protocol == inventory.ProtocolVersionMultiRoundTrip { + require.NotNil(t, result.StructuredContent) + assert.JSONEq(t, `{"status":"awaiting_user_submission","reason":"An interactive form is being shown to the user. The operation has not been performed."}`, mustMarshalJSON(t, result.StructuredContent)) + var value any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &value)) + require.NoError(t, schemas[tool].Validate(value)) + } else { + assert.Nil(t, result.StructuredContent) + } + args["_ui_submitted"] = true + runTypedPullRequestCases(t, session, schemas, []typedPullRequestCase{{tool, args, `{"id":"0","url":"https://github.com/owner/repo/pull/1"}`}}) + } + }) + } +} + +func TestTypedConsolidatedPullRequestErrors(t *testing.T) { + cases := []typedPullRequestCase{ + {"pull_request_read", map[string]any{"pullNumber": 1}, "missing required parameter: method"}, + {"pull_request_read", map[string]any{"method": "get", "pullNumber": 0}, "missing required parameter: pullNumber"}, + {"pull_request_read", map[string]any{"method": "get", "pullNumber": "1.5"}, "parameter pullNumber is not a valid number: non-integer numeric value: 1.5"}, + {"pull_request_read", map[string]any{"method": "get_files", "pullNumber": 1, "page": "x"}, "parameter page is not a valid number"}, + {"pull_request_read", map[string]any{"method": "unknown", "pullNumber": 1}, "unknown method: unknown"}, + {"pull_request_read", map[string]any{"method": "get", "pullNumber": 1.5}, "parameter pullNumber is not a valid number: non-integer numeric value: 1.5"}, + {"create_pull_request", map[string]any{"title": "Subject", "head": "feature", "base": "main", "draft": "yes"}, "parameter draft is not of type bool, is string"}, + {"update_pull_request", map[string]any{"pullNumber": 0, "title": "Subject"}, "missing required parameter: pullNumber"}, + {"update_pull_request", map[string]any{"pullNumber": 1, "draft": "yes"}, "parameter draft is not of type bool, is string"}, + {"pull_request_review_write", map[string]any{"method": "nope", "pullNumber": 1}, "unknown method: nope"}, + {"add_comment_to_pending_review", map[string]any{"pullNumber": 1, "body": "nit", "subjectType": "LINE"}, "missing required parameter: path"}, + {"add_reply_to_pull_request_comment", map[string]any{"commentId": 0, "body": "reply"}, "missing required parameter: commentId"}, + {"add_reply_to_pull_request_comment", map[string]any{"commentId": -1, "body": "reply"}, "commentId must be greater than 0"}, + {"create_pull_request", map[string]any{"head": "feature", "base": "main"}, "missing required parameter: title"}, + {"update_pull_request", map[string]any{"pullNumber": 1}, "No update parameters provided"}, + {"merge_pull_request", map[string]any{"pullNumber": "abc"}, "parameter pullNumber is not a valid number: invalid numeric value: abc"}, + {"update_pull_request_branch", map[string]any{"pullNumber": 2}, "merge conflict between base and head"}, + {"pull_request_review_write", map[string]any{"method": "resolve_thread"}, "threadId is required for resolve_thread and unresolve_thread methods"}, + {"pull_request_review_write", map[string]any{"method": "create", "pullNumber": "x"}, "'PullNumber' cannot parse value as 'int32'"}, + {"add_comment_to_pending_review", map[string]any{"pullNumber": 1, "path": "file", "body": "nit", "subjectType": "LINE", "line": 1.5}, "parameter line is not a valid number"}, + {"add_reply_to_pull_request_comment", map[string]any{"commentId": 42}, "at least one of body or reaction is required"}, + {"add_reply_to_pull_request_comment", map[string]any{"commentId": 42, "body": "reply"}, "missing required parameter: pullNumber"}, + {"add_reply_to_pull_request_comment", map[string]any{"commentId": "1e30", "body": "reply"}, "parameter commentId is not a valid number: numeric value 1e+30 is too large to fit in int64"}, + {"add_reply_to_pull_request_comment", map[string]any{"commentId": 42, "pullNumber": "x", "body": "reply"}, "parameter pullNumber is not a valid number: invalid numeric value: x"}, + {"add_reply_to_pull_request_comment", map[string]any{"commentId": 42, "pullNumber": "x", "body": "reply", "reaction": 1}, "parameter reaction is not of type string, is float64"}, + } + for _, protocol := range typedPullRequestProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + session, _ := consolidatedPullRequestSession(t, consolidatedPullRequestDeps(t), protocol, false) + for _, tc := range cases { + args := map[string]any{"owner": "owner", "repo": "repo"} + maps.Copy(args, tc.args) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool, Arguments: args}) + require.NoError(t, err, "%s %v", tc.tool, tc.args) + require.True(t, result.IsError, "%s %v: %s", tc.tool, tc.args, mustMarshalJSON(t, result)) + assert.Contains(t, getErrorResult(t, result).Text, tc.text, "%s %v", tc.tool, tc.args) + assert.Nil(t, result.StructuredContent, "%s %v", tc.tool, tc.args) + } + }) + } +} + +func TestTypedConsolidatedPullRequestAPIErrors(t *testing.T) { + forbidden := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"Forbidden"}`)) + })}} + calls := []typedPullRequestCase{ + {"create_pull_request", map[string]any{"title": "Subject", "head": "feature", "base": "main"}, ""}, + {"update_pull_request", map[string]any{"pullNumber": 1, "title": "Subject"}, ""}, + {"merge_pull_request", map[string]any{"pullNumber": 1}, ""}, + {"update_pull_request_branch", map[string]any{"pullNumber": 1}, ""}, + {"pull_request_review_write", map[string]any{"method": "create", "pullNumber": 1}, ""}, + {"pull_request_review_write", map[string]any{"method": "submit_pending", "pullNumber": 1}, ""}, + {"pull_request_review_write", map[string]any{"method": "delete_pending", "pullNumber": 1}, ""}, + {"pull_request_review_write", map[string]any{"method": "resolve_thread", "threadId": "T_1"}, ""}, + {"pull_request_review_write", map[string]any{"method": "unresolve_thread", "threadId": "T_1"}, ""}, + {"add_comment_to_pending_review", map[string]any{"pullNumber": 1, "path": "file", "body": "nit", "subjectType": "FILE"}, ""}, + {"add_reply_to_pull_request_comment", map[string]any{"pullNumber": 1, "commentId": 42, "body": "reply"}, ""}, + } + for _, method := range []string{"get", "get_diff", "get_status", "get_files", "get_commits", "get_review_comments", "get_reviews", "get_comments", "get_check_runs"} { + calls = append(calls, typedPullRequestCase{"pull_request_read", map[string]any{"method": method, "pullNumber": 1}, ""}) + } + for _, protocol := range typedPullRequestProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, forbidden), GQLClient: githubv4.NewClient(forbidden), + RepoAccessCache: stubRepoAccessCache(nil, time.Minute), + } + session, _ := consolidatedPullRequestSession(t, deps, protocol, false) + for _, tc := range calls { + args := map[string]any{"owner": "owner", "repo": "repo"} + maps.Copy(args, tc.args) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool, Arguments: args}) + require.NoError(t, err, "%s %v", tc.tool, tc.args) + require.True(t, result.IsError, "%s %v: %s", tc.tool, tc.args, mustMarshalJSON(t, result)) + assert.Nil(t, result.StructuredContent, "%s %v", tc.tool, tc.args) + } + }) + } +} + +func TestTypedPullRequestContentParity(t *testing.T) { + for _, protocol := range typedPullRequestProtocols { + for _, permission := range []string{"read", "write"} { + t.Run(protocol+"/"+permission, func(t *testing.T) { + deps := consolidatedPullRequestDeps(t) + deps.Client = mustNewGHClient(t, &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch strings.TrimPrefix(r.URL.Path, "/api/v3") { + case "/repos/owner/repo": + _, _ = w.Write([]byte(`{"private":false}`)) + case "/repos/owner/repo/pulls/1": + _, _ = w.Write([]byte(`{"number":1,"title":"Subject","body":"bo\u200bdy","state":"open","html_url":"https://github.com/owner/repo/pull/1","user":{"login":"octocat"}}`)) + default: + t.Errorf("unexpected request: %s", r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + } + })}}) + deps.Flags = stubFeatureFlags(map[string]bool{"lockdown-mode": true}) + deps.RepoAccessCache = stubRepoAccessCache(mockRESTPermissionServer(t, permission, nil), time.Minute) + deps.featureChecker = featureCheckerFor(FeatureFlagIFCLabels) + session, schemas := consolidatedPullRequestSession(t, deps, protocol, false) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "pull_request_read", + Arguments: map[string]any{"method": "get", "owner": "owner", "repo": "repo", "pullNumber": 1}, + }) + require.NoError(t, err) + if permission == "read" { + require.True(t, result.IsError) + assert.Equal(t, "access to pull request is restricted by lockdown mode", getErrorResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + return + } + require.False(t, result.IsError) + text := getTextResult(t, result).Text + require.NotNil(t, result.Meta["ifc"]) + if protocol == inventory.ProtocolVersionMultiRoundTrip { + assert.JSONEq(t, mustMarshalJSON(t, result.StructuredContent), text) + require.NoError(t, schemas["pull_request_read"].Validate(result.StructuredContent)) + } else { + assert.Nil(t, result.StructuredContent) + assert.Equal(t, `{"number":1,"title":"Subject","body":"body","state":"open","draft":false,"merged":false,"html_url":"https://github.com/owner/repo/pull/1","user":{"login":"octocat"}}`, text) + } + }) + } + } +} + +func TestConsolidatedPullRequestOutputSchemas(t *testing.T) { + for _, tc := range []struct { + schema *jsonschema.Schema + valid []string + invalid []string + }{ + { + pullRequestReadOutputSchema(), + []string{`null`, `[]`, `{"diff":""}`, `{"number":1,"title":"","state":"","draft":false,"merged":false,"html_url":""}`, `{"state":"","sha":"","total_count":0,"statuses":[]}`, `{"total_count":0,"check_runs":[]}`, `{"review_threads":[],"totalCount":0,"pageInfo":{"hasNextPage":false,"hasPreviousPage":false}}`, `[{"filename":"f"}]`, `[{"id":1,"html_url":""}]`}, + []string{`{}`, `"diff"`, `{"diff":1}`, `[{"unknown":1}]`, `{"number":1}`, `{"number":1,"title":"","state":"unknown","draft":false,"merged":false,"html_url":""}`}, + }, + { + pullRequestWriteOutputSchema(), + []string{`{"id":"","html_url":""}`, `{"status":"awaiting_user_submission","reason":"wait"}`}, + []string{`{}`, `null`, `{"status":"created","reason":"wait"}`}, + }, + { + pullRequestCommentReplyOutputSchema(), + []string{`{"id":"1","html_url":"u"}`, `{"comment":{"id":"1","html_url":"u"},"reaction":{"id":"2"}}`}, + []string{`{}`, `{"comment":{"id":"1","html_url":"u"}}`, `null`, `{"id":"1","url":"https://api.github.com/derivable"}`}, + }, + { + pullRequestMergeOutputSchema(), + []string{`null`, `{}`, `{"sha":"abc","merged":true,"message":"ok"}`}, + []string{`[]`, `{"merged":"yes"}`}, + }, + { + pullRequestBranchUpdateOutputSchema(), + []string{`null`, `{"message":"m"}`}, + []string{`[]`, `{"message":1}`, `{"message":"m","url":"https://api.github.com/derivable"}`}, + }, + } { + resolved, err := tc.schema.Resolve(nil) + require.NoError(t, err) + for _, raw := range tc.valid { + var value any + require.NoError(t, json.Unmarshal([]byte(raw), &value)) + require.NoError(t, resolved.Validate(value), raw) + } + + for _, raw := range tc.invalid { + var value any + require.NoError(t, json.Unmarshal([]byte(raw), &value)) + require.Error(t, resolved.Validate(value), raw) + } + } + // Zero outputs are validated by the SDK even for errors, so they must conform. + for _, tc := range []struct { + schema *jsonschema.Schema + value any + }{ + {pullRequestReadOutputSchema(), PullRequestReadOutput{}}, + {pullRequestWriteOutputSchema(), PullRequestWriteOutput{}}, + {pullRequestCommentReplyOutputSchema(), PullRequestCommentReplyOutput{}}, + {pullRequestMergeOutputSchema(), PullRequestMergeOutput{}}, + {pullRequestBranchUpdateOutputSchema(), PullRequestBranchUpdateOutput{}}, + } { + resolved, err := tc.schema.Resolve(nil) + require.NoError(t, err) + var value any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, tc.value)), &value)) + require.NoError(t, resolved.Validate(value), mustMarshalJSON(t, tc.value)) + } +} + +func TestPullRequestOutputDescriptionsAreShapeAware(t *testing.T) { + commitSchema := pullRequestOutputSchema[MinimalPullRequestCommit]() + require.Contains(t, commitSchema.Properties, "message") + assert.Empty(t, commitSchema.Properties["message"].Description) + + branchUpdateSchema := pullRequestBranchUpdateOutputSchema() + require.Contains(t, branchUpdateSchema.Properties, "message") + assert.Equal(t, "Operation result message.", branchUpdateSchema.Properties["message"].Description) +} + +func TestConsolidatedPullRequestOutputTypesAreCurated(t *testing.T) { + visited := make(map[reflect.Type]bool) + var check func(reflect.Type) + check = func(typ reflect.Type) { + if visited[typ] { + return + } + visited[typ] = true + require.NotContains(t, typ.PkgPath(), "github.com/google/go-github", typ.String()) + switch typ.Kind() { + case reflect.Pointer, reflect.Slice, reflect.Array: + check(typ.Elem()) + case reflect.Struct: + for field := range typ.Fields() { + check(field.Type) + } + case reflect.Map, reflect.Interface: + t.Errorf("non-concrete output field: %s", typ) + } + } + for _, typ := range []reflect.Type{ + reflect.TypeFor[PullRequestReadOutput](), reflect.TypeFor[PullRequestWriteOutput](), + reflect.TypeFor[PullRequestMergeOutput](), reflect.TypeFor[PullRequestBranchUpdateOutput](), + reflect.TypeFor[PullRequestCommentReplyOutput](), + } { + check(typ) + } +} From da2cbc8d4f153312cfb57803d039d664159df167 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 7 Oct 2026 00:23:33 +0200 Subject: [PATCH 56/61] refactor(pull-requests): type granular MCP tools (#3397) * feat(inventory): add typed MCP tool registration foundation Use the MCP SDK's generic registration for concrete input/output types, with protocol-gated output schemas and structured results. Keep raw handlers compatible and apply shared middleware on both paths. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * test: cover legacy typed CSV results Exercise the typed array and object paths through an initialized 2025-11-25 MCP session. Verify CSV text is returned once without structured output or the SDK JSON fallback. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(inventory): normalize legacy typed arguments Add a raw JSON input normalizer that runs before SDK schema validation so migrated tools can retain legacy input coercions without widening their published schemas. Test case-folded issue states, numeric string IDs, validation failures and replacement registrations. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * test(inventory): preserve typed input optionality Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(inventory): stabilize typed schema compatibility Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(inventory): cover protocol isolation across clients Exercise simultaneous legacy and modern sessions against one typed tool, plus stateless HTTP requests with modern, legacy, and absent protocol headers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(inventory): harden typed schema registration contracts Cache immutable schema variants and enum overrides, preserve runtime input compatibility and request-era output behavior, and normalize dynamic scope challenges before dispatch. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(inventory): cache warm schemas and guard typed preflight Avoid repeated canonical schema serialization and header annotation for cache-owned immutable pointers while retaining content-based lookup for caller-owned schemas. Run typed authorization and availability middleware before preflight and preserve tool-result error semantics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(inventory): preserve schema metadata isolation Advertise the SDK-compatible object schema for typed any input and deep-clone mutable JSON Schema metadata before caching or deriving runtime variants. Add legacy/modern registration and mutation isolation coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(inventory): detach numeric schema bounds and dedupe default traversal Two Copilot findings on 431b7a21: - CloneSchemas only deep-copies subschema nodes, so pointer-valued numeric validation keywords (Minimum, Maximum, MinLength, MinItems, etc.) remained aliased with the caller-owned schema after CloneSchema/CachedSchema. A caller mutating one of those pointers after caching could race with concurrent cached-schema serialization or silently change the cached validation bounds. Clone every *int/*float64 keyword explicitly alongside the existing collection/metadata cloning. - CloneSchemaWithoutDefaults's removeSchemaDefaults visited every map/pointer child twice: once via two literal child-collection loops, and again via schemaChildren, which already covers the same fields. This doubled traversal at every nested level, making default removal exponential for deeply nested object/array schemas. Traverse only the single comprehensive schemaChildren list. Add regression coverage across CloneSchema, CachedSchema, schema inference option cloning, and CloneSchemaWithoutDefaults: every *int/*float64 keyword is isolated from caller mutation (including under concurrent marshaling), and a 48-level deep chain is used for every schema-child field kind to confirm linear, non-exponential default removal. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(inventory): gate typed output on supported protocol range Select modern typed output for SDK-supported ISO-date protocol versions from 2026-07-28 onward. Missing, malformed, and unsupported future versions retain legacy behavior. Cover a later supported version through the selector list seam and verify the public path against the SDK supported set. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(inventory): unwrap pointer inputs before schema inference Match SDK input inference by unwrapping one pointer level before the cached input key and inference. Preserve nullable output pointer schemas. Cover both public constructors with actual modern and legacy discovery and calls, including structured null output. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(inventory): reuse encoded schema cache for typed tools Key output encodings by their source definitions while preserving the SDK-registered schema guard. Keep header routing metadata available for legacy and unknown protocol clients. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(context): add typed tool schemas and outputs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(context): preserve legacy input acceptance Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(context): preserve input schema roots and optionality Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(context): validate direct team member calls Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(context): pin structured output and legacy text fixtures Describe profile timestamps and validate null and empty team outputs against modern schemas while retaining exact legacy text. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(context): emit structured empty team collections Keep legacy null text while returning nonnil typed slices so modern clients receive structured content for empty successful results. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(context): preserve legacy team input schemas Keep empty identifier rejection in the handler without tightening the published input schema. Pin modern text to structured output and preserve the legacy formatter fixtures. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(context): preserve advertised input schema compatibility Keep legacy-visible schemas byte-compatible while applying unknown-field acceptance only to the cached runtime validation schema. Verify modern DTO text parity and preserve legacy formatted text. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(context): reject explicit null team user arguments Preserve the legacy optional user type error before typed decoding, without changing omitted or empty user fallback. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(context): preserve exact routing argument keys Ignore case-variant routing aliases before typed decoding while retaining canonical fields and unrelated unknown arguments. Cover direct handlers and legacy/modern registered calls, authenticated fallback, canonical null rejection, and missing identifiers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(comments): add typed visibility tool inputs and outputs Preserve legacy argument normalization and text responses while exposing protocol-gated structured results for six hide/unhide tools. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * chore: regenerate license files Auto-generated by license-check workflow * test(comments): pin protocol output parity and reason metadata Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(comments): reuse cached visibility output schema Clarify supported negotiated protocol versions without expanding runtime support. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(search): add typed projected result outputs * fix(search): preserve field_values presence and add nested schema fixtures Address review findings on the typed search/list projected-output tools (search_code, search_issues, search_pull_requests, list_issues, list_pull_requests, list_branches, list_tags): - issues.go/pullrequests.go: change FieldValues to a pointer-to-slice (*[]MinimalFieldValue) so a successful-but-empty [] is preserved in structuredContent, matching legacy text output instead of being dropped by omitempty. - search.go: related adjustments to keep typed search outputs aligned with the field_values presence fix. - typed_search_outputs_test.go: add representative nested/non-empty projected item fixtures so schema-conformance tests actually exercise item shapes instead of only validating empty collections. - Add/update toolsnaps for list_branches, list_issues, list_pull_requests, list_tags, search_code, search_issues, and search_pull_requests to reflect the schema. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test: bound encoded schema parity registration work Reuse each immutable HTTP server across pagination requests and share the SDK schema cache across parity variants without changing wire comparisons. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(security): add typed alert outputs Add typed protocol-gated outputs for code quality, code and secret scanning, Dependabot, and security advisories while preserving existing text responses. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(security): preserve compatibility with compact typed outputs Keep advertised input defaults while disabling runtime default injection for security filters. Normalize null CWE filters, compact findings, and verify modern wire outputs alongside exact legacy formatting. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat: type discussion and notification tool outputs Migrate all eleven tools to typed registration and compact output DTOs. Preserve legacy text, parameter compatibility, pagination, and mutation validation while publishing honest modern output unions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix: tighten discussion and notification output contracts Trim notification hypermedia, preserve useful HTML links, and record all modern schemas in canonical snapshots. Preserve source presence during discussion read normalization and cover the Copilot review regression alongside empty/null outputs, IFC labels, sanitization, and output enums. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test: cover empty-object discussion read requests and modern-text==DTO expectations Add an explicit {} (fully-empty args) case to TestDiscussionReadRequiredArguments proving the normalizer never synthesizes placeholder owner/repo/discussionNumber values that would let an empty request satisfy SDK required-schema validation and reach GraphQL; verified both protocols reject it pre-handler with zero GraphQL calls (addresses readiness-audit re-flag of already-fixed Copilot review comment 4171698931 in 578e9929). Also record the pending modern-text==structuredContent DTO contract (2026-07-28 protocol) in test expectations; these assertions are intentionally red pending the coordinator's central once-marshal wrapper in pkg/inventory/typed_output.go, which this branch does not yet have. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(notifications): preserve plain status output content Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(repos): add typed commit tool outputs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(repos): restore commit pagination defaults Normalize omitted and explicit zero pagination to page 1 and perPage 30 for get_commit and list_commits. Cover legacy and modern wire calls and direct handlers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(repos): return typed projected commit lists Use an optional-field commit DTO for full and projected modern outputs while retaining exact legacy text. Cover SHA and nested commit projections, empty lists, and output schema conformance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * test(repos): snapshot modern commit tools and strengthen wire coverage Capture canonical modern schemas, exercise null lists and error suppression with IFC labels, and drop unrelated generated Linux license drift noted by Copilot review. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(repos): distinguish legacy and modern commit text Keep exact-main byte assertions for the legacy protocol and verify modern text represents the same typed DTO as structured content. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(repos): sync commit snapshots with parent DTO metadata Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(issues): add typed metadata comment and dependency outputs Preserve legacy text and argument coercions while exposing protocol-gated structured issue outputs. Add modern, legacy, unknown-version, union-schema and API-error contracts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * chore: regenerate license files Auto-generated by license-check workflow * fix(issues): preserve zero pagination for duplicate search Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(issues): preserve compact typed output contracts Cache DTO schema inference, retain byte-exact handler text, and cover protocol, pagination and schema contracts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(issues): bound encoded schema parity registration work Reuse immutable HTTP comparison servers and the SDK schema cache across the full parity matrix. Refresh inherited user timestamp descriptions in issue snapshots. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(repos): complete typed repository tool outputs Migrate the remaining 17 repository tools to concrete inputs and outputs. Preserve existing content, mutations, scopes, filtering, and protocol gating; add wire and schema conformance coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore(repos): exclude unrelated snapshot formatting drift Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(repos): compact typed repository outputs Replace raw REST output schemas with compact repository DTOs, retain one canonical snapshot per tool, and align modern JSON text with structured output while pinning legacy content. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(issues): add typed consolidated issue tool contracts Preserve raw helper APIs and exact legacy text while exposing concrete output unions for modern protocols. Retain explicit awaiting statuses and real null responses without exposing SDK-generated error fallbacks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * refactor(issues): minimize consolidated typed outputs Project compact method-discriminated DTOs from API responses while preserving the separate legacy text formatter and explicit app-awaiting status. Cache strict schemas and keep only canonical snapshots. Strengthen null-member presence, schema size, and lockdown projection regressions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(issues): keep union property schemas portable Preserve forbidden variant properties as annotated schema objects rather than boolean schemas emitted by jsonschema-go. Cover Inspector's input paths and IFC labels on every issue-read method across protocols. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(issues): cover strict sub-issue output variants Reject unknown methods, states, field types, opaque values and API URL properties while covering nullable and empty method data. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(issues): pin compact comment user redaction Pin unchanged legacy profile/avatar fields separately from compact modern user projections and assert identical lockdown filtering and sanitization across protocols. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(issues): restore main-identical advertised input schema Remove added custom-field union constraints and sentinel enums from the advertised issue-write input. Preserve runtime exactly-one validation and private strict-schema tests. All three scoped input schemas compare exactly equal to main. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(issues): restore protocol output parity Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * refactor(issues): minimize granular structured outputs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> # Conflicts: # pkg/github/__toolsnaps__/add_sub_issue.snap # pkg/github/__toolsnaps__/remove_sub_issue.snap # pkg/github/__toolsnaps__/reprioritize_sub_issue.snap # pkg/github/granular_issue_types.go # pkg/github/issues_granular.go # pkg/github/typed_granular_issue_outputs_test.go # Please enter the commit message for your changes. Lines starting # with '#' will be ignored, and an empty message aborts the commit. # # interactive rebase in progress; onto 1973eeea # Last command done (1 command done): # pick 5b8f9d19 # refactor(issues): minimize granular structured outputs # No commands remaining. # You are currently rebasing. # # Changes to be committed: # modified: pkg/github/__toolsnaps__/add_issue_comment_reaction.snap # modified: pkg/github/__toolsnaps__/add_issue_reaction.snap # modified: pkg/github/__toolsnaps__/add_sub_issue.snap # modified: pkg/github/__toolsnaps__/create_issue.snap # modified: pkg/github/__toolsnaps__/hide_issue_comment.snap # modified: pkg/github/__toolsnaps__/remove_issue_comment_reaction.snap # modified: pkg/github/__toolsnaps__/remove_issue_reaction.snap # modified: pkg/github/__toolsnaps__/remove_sub_issue.snap # modified: pkg/github/__toolsnaps__/reprioritize_sub_issue.snap # modified: pkg/github/__toolsnaps__/set_issue_fields.snap # modified: pkg/github/__toolsnaps__/unhide_issue_comment.snap # modified: pkg/github/__toolsnaps__/update_issue_assignees.snap # modified: pkg/github/__toolsnaps__/update_issue_body.snap # modified: pkg/github/__toolsnaps__/update_issue_labels.snap # modified: pkg/github/__toolsnaps__/update_issue_milestone.snap # modified: pkg/github/__toolsnaps__/update_issue_state.snap # modified: pkg/github/__toolsnaps__/update_issue_title.snap # modified: pkg/github/__toolsnaps__/update_issue_type.snap # modified: pkg/github/comment_minimize_test.go # new file: pkg/github/granular_issue_types.go # modified: pkg/github/granular_tools_test.go # modified: pkg/github/issues_granular.go # new file: pkg/github/typed_granular_issue_outputs_test.go # * refactor(search): use typed account repository and commit tools Preserve legacy text and search semantics while publishing protocol-gated concrete structured outputs. Cover modern, legacy, and unknown clients, full repository JSON unions, defaults, IFC labels, and errors. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(search): compact typed repository search output Replace the full REST repository mirror with MinimalRepository-based structured items plus purpose-built full-mode details. Legacy text remains unchanged, and canonical snapshots now hold the modern output schema. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(search): expose repository visibility enum Remove the derivable node_id from compact full repository search output and publish the visibility enum in the static output schema. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(search): align snapshots with protocol wrapper Keep the modern text assertion aligned with structured content after the shared output wrapper change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(pull-requests): migrate consolidated PR tools to typed inputs and outputs Convert pull_request_read, create_pull_request, update_pull_request, merge_pull_request, update_pull_request_branch, pull_request_review_write (both feature variants), add_comment_to_pending_review and add_reply_to_pull_request_comment to NewTool with concrete inputs and output DTOs. Input normalizers replay legacy handler checks so legacy coercions and error text are preserved. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(pull-requests): align modern text with typed outputs Use curated output DTOs for modern JSON text while preserving legacy serialization. Cover successful null responses, empty check runs, and Link-header page traversal. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(pull-requests): preserve legacy validation order Replay parameter checks in handler order and scope cursor validation to review-thread reads. Keep commit message schema descriptions distinct from operation messages. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(pull-requests): simplify compatibility validation Remove normalization and test assertions that only pinned which invalid argument wins. Keep per-field validation, accepted coercions, and response-shape behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(pull-requests): type granular MCP tools Preserve exact legacy validation and response text while advertising concrete protocol-gated outputs for all granular PR mutations and both thread-resolution variants. Make the inherited overflow assertion architecture-independent. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * fix(pull-requests): preserve granular tool compatibility Keep advertised schemas exact while applying legacy-compatible runtime validation, and verify protocol-era output parity. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * test(pull-requests): compare schema sets semantically Treat required properties and enum values as unordered sets in the schema audit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(pull-requests): retain granular schema descriptions Keep the original enum guidance in input descriptions while preserving actual JSON Schema enum constraints. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(repos): complete typed repository tool outputs Migrate the remaining 17 repository tools to concrete inputs and outputs. Preserve existing content, mutations, scopes, filtering, and protocol gating; add wire and schema conformance coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore(repos): exclude unrelated snapshot formatting drift Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(repos): compact typed repository outputs Replace raw REST output schemas with compact repository DTOs, retain one canonical snapshot per tool, and align modern JSON text with structured output while pinning legacy content. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(issues): add typed consolidated issue tool contracts Preserve raw helper APIs and exact legacy text while exposing concrete output unions for modern protocols. Retain explicit awaiting statuses and real null responses without exposing SDK-generated error fallbacks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * refactor(issues): minimize consolidated typed outputs Project compact method-discriminated DTOs from API responses while preserving the separate legacy text formatter and explicit app-awaiting status. Cache strict schemas and keep only canonical snapshots. Strengthen null-member presence, schema size, and lockdown projection regressions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(issues): keep union property schemas portable Preserve forbidden variant properties as annotated schema objects rather than boolean schemas emitted by jsonschema-go. Cover Inspector's input paths and IFC labels on every issue-read method across protocols. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(issues): cover strict sub-issue output variants Reject unknown methods, states, field types, opaque values and API URL properties while covering nullable and empty method data. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(issues): pin compact comment user redaction Pin unchanged legacy profile/avatar fields separately from compact modern user projections and assert identical lockdown filtering and sanitization across protocols. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(issues): restore main-identical advertised input schema Remove added custom-field union constraints and sentinel enums from the advertised issue-write input. Preserve runtime exactly-one validation and private strict-schema tests. All three scoped input schemas compare exactly equal to main. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(issues): restore protocol output parity Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * refactor(issues): minimize granular structured outputs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> # Conflicts: # pkg/github/__toolsnaps__/add_sub_issue.snap # pkg/github/__toolsnaps__/remove_sub_issue.snap # pkg/github/__toolsnaps__/reprioritize_sub_issue.snap # pkg/github/granular_issue_types.go # pkg/github/issues_granular.go # pkg/github/typed_granular_issue_outputs_test.go # Please enter the commit message for your changes. Lines starting # with '#' will be ignored, and an empty message aborts the commit. # # interactive rebase in progress; onto 1973eeea # Last command done (1 command done): # pick 5b8f9d19 # refactor(issues): minimize granular structured outputs # No commands remaining. # You are currently rebasing. # # Changes to be committed: # modified: pkg/github/__toolsnaps__/add_issue_comment_reaction.snap # modified: pkg/github/__toolsnaps__/add_issue_reaction.snap # modified: pkg/github/__toolsnaps__/add_sub_issue.snap # modified: pkg/github/__toolsnaps__/create_issue.snap # modified: pkg/github/__toolsnaps__/hide_issue_comment.snap # modified: pkg/github/__toolsnaps__/remove_issue_comment_reaction.snap # modified: pkg/github/__toolsnaps__/remove_issue_reaction.snap # modified: pkg/github/__toolsnaps__/remove_sub_issue.snap # modified: pkg/github/__toolsnaps__/reprioritize_sub_issue.snap # modified: pkg/github/__toolsnaps__/set_issue_fields.snap # modified: pkg/github/__toolsnaps__/unhide_issue_comment.snap # modified: pkg/github/__toolsnaps__/update_issue_assignees.snap # modified: pkg/github/__toolsnaps__/update_issue_body.snap # modified: pkg/github/__toolsnaps__/update_issue_labels.snap # modified: pkg/github/__toolsnaps__/update_issue_milestone.snap # modified: pkg/github/__toolsnaps__/update_issue_state.snap # modified: pkg/github/__toolsnaps__/update_issue_title.snap # modified: pkg/github/__toolsnaps__/update_issue_type.snap # modified: pkg/github/comment_minimize_test.go # new file: pkg/github/granular_issue_types.go # modified: pkg/github/granular_tools_test.go # modified: pkg/github/issues_granular.go # new file: pkg/github/typed_granular_issue_outputs_test.go # * refactor(search): use typed account repository and commit tools Preserve legacy text and search semantics while publishing protocol-gated concrete structured outputs. Cover modern, legacy, and unknown clients, full repository JSON unions, defaults, IFC labels, and errors. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(search): compact typed repository search output Replace the full REST repository mirror with MinimalRepository-based structured items plus purpose-built full-mode details. Legacy text remains unchanged, and canonical snapshots now hold the modern output schema. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(search): expose repository visibility enum Remove the derivable node_id from compact full repository search output and publish the visibility enum in the static output schema. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(search): align snapshots with protocol wrapper Keep the modern text assertion aligned with structured content after the shared output wrapper change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(pull-requests): migrate consolidated PR tools to typed inputs and outputs Convert pull_request_read, create_pull_request, update_pull_request, merge_pull_request, update_pull_request_branch, pull_request_review_write (both feature variants), add_comment_to_pending_review and add_reply_to_pull_request_comment to NewTool with concrete inputs and output DTOs. Input normalizers replay legacy handler checks so legacy coercions and error text are preserved. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(pull-requests): align modern text with typed outputs Use curated output DTOs for modern JSON text while preserving legacy serialization. Cover successful null responses, empty check runs, and Link-header page traversal. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(pull-requests): preserve legacy validation order Replay parameter checks in handler order and scope cursor validation to review-thread reads. Keep commit message schema descriptions distinct from operation messages. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(pull-requests): simplify compatibility validation Remove normalization and test assertions that only pinned which invalid argument wins. Keep per-field validation, accepted coercions, and response-shape behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(pull-requests): type granular MCP tools Preserve exact legacy validation and response text while advertising concrete protocol-gated outputs for all granular PR mutations and both thread-resolution variants. Make the inherited overflow assertion architecture-independent. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * fix(pull-requests): preserve granular tool compatibility Keep advertised schemas exact while applying legacy-compatible runtime validation, and verify protocol-era output parity. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * test(pull-requests): compare schema sets semantically Treat required properties and enum values as unordered sets in the schema audit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(pull-requests): retain granular schema descriptions Keep the original enum guidance in input descriptions while preserving actual JSON Schema enum constraints. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] --- docs/feature-flags.md | 14 +- .../add_pull_request_review_comment.snap | 41 +- ..._pull_request_review_comment_reaction.snap | 49 +- .../create_pull_request_review.snap | 37 +- .../delete_pending_pull_request_review.snap | 35 +- ..._pull_request_review_comment_reaction.snap | 35 +- .../request_pull_request_reviewers.snap | 39 +- .../__toolsnaps__/resolve_review_thread.snap | 29 +- ...solve_review_thread_resolution_reason.snap | 54 ++ .../submit_pending_pull_request_review.snap | 37 +- .../unresolve_review_thread.snap | 29 +- .../update_pull_request_body.snap | 39 +- .../update_pull_request_draft_state.snap | 35 +- .../update_pull_request_state.snap | 41 +- .../update_pull_request_title.snap | 39 +- pkg/github/granular_pull_request_types.go | 298 ++++++++++ pkg/github/granular_tools_test.go | 13 - pkg/github/pullrequests_granular.go | 399 +++++-------- .../typed_granular_issue_outputs_test.go | 1 + ...yped_granular_pull_request_outputs_test.go | 538 ++++++++++++++++++ pkg/inventory/input_error_test.go | 19 + 21 files changed, 1495 insertions(+), 326 deletions(-) create mode 100644 pkg/github/__toolsnaps__/resolve_review_thread_resolution_reason.snap create mode 100644 pkg/github/granular_pull_request_types.go create mode 100644 pkg/github/typed_granular_pull_request_outputs_test.go create mode 100644 pkg/inventory/input_error_test.go diff --git a/docs/feature-flags.md b/docs/feature-flags.md index 36e7460d34..ec370a442d 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -237,15 +237,15 @@ as output formatting) won't appear here. - `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) + - `side`: The side of the diff to comment on (LEFT or RIGHT, 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) + - `startSide`: The start side of a multi-line comment (LEFT or RIGHT, optional) (string, optional) + - `subjectType`: The subject type of the comment (FILE or LINE) (string, required) - **add_pull_request_review_comment_reaction** - Add Pull Request Review Comment Reaction - **OAuth Challenge Scopes**: `repo` - `comment_id`: The numeric pull request review comment ID. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...). (number, required) - - `content`: The emoji reaction type (string, required) + - `content`: The emoji reaction type (+1, -1, laugh, confused, heart, hooray, rocket, or eyes) (string, required) - `owner`: Repository owner (username or organization) (string, required) - `repo`: Repository name (string, required) @@ -253,7 +253,7 @@ as output formatting) won't appear here. - **OAuth Challenge 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) + - `event`: The review action to perform (APPROVE, REQUEST_CHANGES, or COMMENT). 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) @@ -300,7 +300,7 @@ as output formatting) won't appear here. - **submit_pending_pull_request_review** - Submit Pending Pull Request Review - **OAuth Challenge Scopes**: `repo` - `body`: The review body text (optional) (string, optional) - - `event`: The review action to perform (string, required) + - `event`: The review action to perform (APPROVE, REQUEST_CHANGES, or COMMENT) (string, required) - `owner`: Repository owner (username or organization) (string, required) - `pullNumber`: The pull request number (number, required) - `repo`: Repository name (string, required) @@ -341,7 +341,7 @@ as output formatting) won't appear here. - `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) + - `state`: The new state for the pull request (open or closed) (string, required) - **update_pull_request_title** - Update Pull Request Title - **OAuth Challenge Scopes**: `repo` diff --git a/pkg/github/__toolsnaps__/add_pull_request_review_comment.snap b/pkg/github/__toolsnaps__/add_pull_request_review_comment.snap index ae7450539d..ae60f45c05 100644 --- a/pkg/github/__toolsnaps__/add_pull_request_review_comment.snap +++ b/pkg/github/__toolsnaps__/add_pull_request_review_comment.snap @@ -7,6 +7,18 @@ "title": "Add Pull Request Review Comment" }, "description": "Add a review comment to the current user's pending pull request review.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABn0lEQVRIie2Vvy5EURDGvyMUkhUU/pS2Q2clwvIKqL0Cm9ArrBcQShJPoLFR2YaIhIjQ7a7E39IiCgqVn8KsPY697E3oTHPumTPf990z986M9Mfm/A0wKGlMUlnSlnPuOQQAE5LOnXOFWErAIvBK1S6BZBAzZ2fzcckHjXwVaAXSwD2wWYN8A2iKK1ABt3m+ZeDRnseIthdgDxioxd1o662tfZIO7Lnf8xcklST1StqRdORxNEualHQIDDvnTmvdIGE5vwdWgLy93bQX0w0UgSdgKMC3AdfA7ndpSgKbduUbYBoI/7Ju4BiYrYFfAl4iBbxAgOyPgV9xWYDQ3xCXKK79C/wL/KJZoeW8QpsJCy0C54AMULaGmQu7sIAW4MpaxTKwbQU3U4dAxmLzwLpxXAIJP2jKgkY8Xx4o1SFwBmx7+7RxTUnVb9Bpa9HDFiR1/SRgWH+6FT3/h2qK6sBpB0aBB7yB880NcpaWtGHXjCsVBmb5PDIvgJ46BJKW84q9AguV87Adp/Q+9O8UMfQjRBKSxiV1SNp3zp3Ug/sVewPruexhKwhGXQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], "inputSchema": { "properties": { "body": { @@ -19,7 +31,8 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "path": { "description": "The relative path of the file to comment on", @@ -32,10 +45,11 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "side": { - "description": "The side of the diff to comment on (optional)", + "description": "The side of the diff to comment on (LEFT or RIGHT, optional)", "enum": [ "LEFT", "RIGHT" @@ -47,7 +61,7 @@ "type": "number" }, "startSide": { - "description": "The start side of a multi-line comment (optional)", + "description": "The start side of a multi-line comment (LEFT or RIGHT, optional)", "enum": [ "LEFT", "RIGHT" @@ -55,7 +69,7 @@ "type": "string" }, "subjectType": { - "description": "The subject type of the comment", + "description": "The subject type of the comment (FILE or LINE)", "enum": [ "FILE", "LINE" @@ -73,5 +87,20 @@ ], "type": "object" }, - "name": "add_pull_request_review_comment" + "name": "add_pull_request_review_comment", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/add_pull_request_review_comment_reaction.snap b/pkg/github/__toolsnaps__/add_pull_request_review_comment_reaction.snap index aec4b38465..cd0f8641bc 100644 --- a/pkg/github/__toolsnaps__/add_pull_request_review_comment_reaction.snap +++ b/pkg/github/__toolsnaps__/add_pull_request_review_comment_reaction.snap @@ -7,6 +7,18 @@ "title": "Add Pull Request Review Comment Reaction" }, "description": "Add a reaction to a pull request review comment.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABn0lEQVRIie2Vvy5EURDGvyMUkhUU/pS2Q2clwvIKqL0Cm9ArrBcQShJPoLFR2YaIhIjQ7a7E39IiCgqVn8KsPY697E3oTHPumTPf990z986M9Mfm/A0wKGlMUlnSlnPuOQQAE5LOnXOFWErAIvBK1S6BZBAzZ2fzcckHjXwVaAXSwD2wWYN8A2iKK1ABt3m+ZeDRnseIthdgDxioxd1o662tfZIO7Lnf8xcklST1StqRdORxNEualHQIDDvnTmvdIGE5vwdWgLy93bQX0w0UgSdgKMC3AdfA7ndpSgKbduUbYBoI/7Ju4BiYrYFfAl4iBbxAgOyPgV9xWYDQ3xCXKK79C/wL/KJZoeW8QpsJCy0C54AMULaGmQu7sIAW4MpaxTKwbQU3U4dAxmLzwLpxXAIJP2jKgkY8Xx4o1SFwBmx7+7RxTUnVb9Bpa9HDFiR1/SRgWH+6FT3/h2qK6sBpB0aBB7yB880NcpaWtGHXjCsVBmb5PDIvgJ46BJKW84q9AguV87Adp/Q+9O8UMfQjRBKSxiV1SNp3zp3Ug/sVewPruexhKwhGXQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], "inputSchema": { "properties": { "comment_id": { @@ -15,7 +27,7 @@ "type": "number" }, "content": { - "description": "The emoji reaction type", + "description": "The emoji reaction type (+1, -1, laugh, confused, heart, hooray, rocket, or eyes)", "enum": [ "+1", "-1", @@ -30,11 +42,13 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -45,5 +59,32 @@ ], "type": "object" }, - "name": "add_pull_request_review_comment_reaction" + "name": "add_pull_request_review_comment_reaction", + "outputSchema": { + "additionalProperties": false, + "properties": { + "content": { + "description": "Reaction type", + "enum": [ + "+1", + "-1", + "laugh", + "confused", + "heart", + "hooray", + "rocket", + "eyes" + ], + "type": "string" + }, + "id": { + "description": "Reaction ID", + "type": "integer" + } + }, + "required": [ + "id" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_pull_request_review.snap b/pkg/github/__toolsnaps__/create_pull_request_review.snap index f8bf3f12a8..8a665ef458 100644 --- a/pkg/github/__toolsnaps__/create_pull_request_review.snap +++ b/pkg/github/__toolsnaps__/create_pull_request_review.snap @@ -7,6 +7,18 @@ "title": "Create Pull Request Review" }, "description": "Create a review on a pull request. If event is provided, the review is submitted immediately; otherwise a pending review is created.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABn0lEQVRIie2Vvy5EURDGvyMUkhUU/pS2Q2clwvIKqL0Cm9ArrBcQShJPoLFR2YaIhIjQ7a7E39IiCgqVn8KsPY697E3oTHPumTPf990z986M9Mfm/A0wKGlMUlnSlnPuOQQAE5LOnXOFWErAIvBK1S6BZBAzZ2fzcckHjXwVaAXSwD2wWYN8A2iKK1ABt3m+ZeDRnseIthdgDxioxd1o662tfZIO7Lnf8xcklST1StqRdORxNEualHQIDDvnTmvdIGE5vwdWgLy93bQX0w0UgSdgKMC3AdfA7ndpSgKbduUbYBoI/7Ju4BiYrYFfAl4iBbxAgOyPgV9xWYDQ3xCXKK79C/wL/KJZoeW8QpsJCy0C54AMULaGmQu7sIAW4MpaxTKwbQU3U4dAxmLzwLpxXAIJP2jKgkY8Xx4o1SFwBmx7+7RxTUnVb9Bpa9HDFiR1/SRgWH+6FT3/h2qK6sBpB0aBB7yB880NcpaWtGHXjCsVBmb5PDIvgJ46BJKW84q9AguV87Adp/Q+9O8UMfQjRBKSxiV1SNp3zp3Ug/sVewPruexhKwhGXQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], "inputSchema": { "properties": { "body": { @@ -18,7 +30,7 @@ "type": "string" }, "event": { - "description": "The review action to perform. If omitted, creates a pending review.", + "description": "The review action to perform (APPROVE, REQUEST_CHANGES, or COMMENT). If omitted, creates a pending review.", "enum": [ "APPROVE", "REQUEST_CHANGES", @@ -28,7 +40,8 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "pullNumber": { "description": "The pull request number", @@ -37,7 +50,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -47,5 +61,20 @@ ], "type": "object" }, - "name": "create_pull_request_review" + "name": "create_pull_request_review", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/delete_pending_pull_request_review.snap b/pkg/github/__toolsnaps__/delete_pending_pull_request_review.snap index f99f5d5632..157857d3d7 100644 --- a/pkg/github/__toolsnaps__/delete_pending_pull_request_review.snap +++ b/pkg/github/__toolsnaps__/delete_pending_pull_request_review.snap @@ -7,11 +7,24 @@ "title": "Delete Pending Pull Request Review" }, "description": "Delete a pending pull request review.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABn0lEQVRIie2Vvy5EURDGvyMUkhUU/pS2Q2clwvIKqL0Cm9ArrBcQShJPoLFR2YaIhIjQ7a7E39IiCgqVn8KsPY697E3oTHPumTPf990z986M9Mfm/A0wKGlMUlnSlnPuOQQAE5LOnXOFWErAIvBK1S6BZBAzZ2fzcckHjXwVaAXSwD2wWYN8A2iKK1ABt3m+ZeDRnseIthdgDxioxd1o662tfZIO7Lnf8xcklST1StqRdORxNEualHQIDDvnTmvdIGE5vwdWgLy93bQX0w0UgSdgKMC3AdfA7ndpSgKbduUbYBoI/7Ju4BiYrYFfAl4iBbxAgOyPgV9xWYDQ3xCXKK79C/wL/KJZoeW8QpsJCy0C54AMULaGmQu7sIAW4MpaxTKwbQU3U4dAxmLzwLpxXAIJP2jKgkY8Xx4o1SFwBmx7+7RxTUnVb9Bpa9HDFiR1/SRgWH+6FT3/h2qK6sBpB0aBB7yB880NcpaWtGHXjCsVBmb5PDIvgJ46BJKW84q9AguV87Adp/Q+9O8UMfQjRBKSxiV1SNp3zp3Ug/sVewPruexhKwhGXQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], "inputSchema": { "properties": { "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "pullNumber": { "description": "The pull request number", @@ -20,7 +33,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -30,5 +44,20 @@ ], "type": "object" }, - "name": "delete_pending_pull_request_review" + "name": "delete_pending_pull_request_review", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/remove_pull_request_review_comment_reaction.snap b/pkg/github/__toolsnaps__/remove_pull_request_review_comment_reaction.snap index 57514e6484..da5f8e89dc 100644 --- a/pkg/github/__toolsnaps__/remove_pull_request_review_comment_reaction.snap +++ b/pkg/github/__toolsnaps__/remove_pull_request_review_comment_reaction.snap @@ -7,6 +7,18 @@ "title": "Remove Pull Request Review Comment Reaction" }, "description": "Remove a reaction from a pull request review comment.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABn0lEQVRIie2Vvy5EURDGvyMUkhUU/pS2Q2clwvIKqL0Cm9ArrBcQShJPoLFR2YaIhIjQ7a7E39IiCgqVn8KsPY697E3oTHPumTPf990z986M9Mfm/A0wKGlMUlnSlnPuOQQAE5LOnXOFWErAIvBK1S6BZBAzZ2fzcckHjXwVaAXSwD2wWYN8A2iKK1ABt3m+ZeDRnseIthdgDxioxd1o662tfZIO7Lnf8xcklST1StqRdORxNEualHQIDDvnTmvdIGE5vwdWgLy93bQX0w0UgSdgKMC3AdfA7ndpSgKbduUbYBoI/7Ju4BiYrYFfAl4iBbxAgOyPgV9xWYDQ3xCXKK79C/wL/KJZoeW8QpsJCy0C54AMULaGmQu7sIAW4MpaxTKwbQU3U4dAxmLzwLpxXAIJP2jKgkY8Xx4o1SFwBmx7+7RxTUnVb9Bpa9HDFiR1/SRgWH+6FT3/h2qK6sBpB0aBB7yB880NcpaWtGHXjCsVBmb5PDIvgJ46BJKW84q9AguV87Adp/Q+9O8UMfQjRBKSxiV1SNp3zp3Ug/sVewPruexhKwhGXQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], "inputSchema": { "properties": { "comment_id": { @@ -16,7 +28,8 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "reaction_id": { "description": "The reaction ID to remove", @@ -25,7 +38,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -36,5 +50,20 @@ ], "type": "object" }, - "name": "remove_pull_request_review_comment_reaction" + "name": "remove_pull_request_review_comment_reaction", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap b/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap index 607f540d1b..56eb0bad90 100644 --- a/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap +++ b/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap @@ -7,11 +7,24 @@ "title": "Request Pull Request Reviewers" }, "description": "Request reviewers for a pull request.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABn0lEQVRIie2Vvy5EURDGvyMUkhUU/pS2Q2clwvIKqL0Cm9ArrBcQShJPoLFR2YaIhIjQ7a7E39IiCgqVn8KsPY697E3oTHPumTPf990z986M9Mfm/A0wKGlMUlnSlnPuOQQAE5LOnXOFWErAIvBK1S6BZBAzZ2fzcckHjXwVaAXSwD2wWYN8A2iKK1ABt3m+ZeDRnseIthdgDxioxd1o662tfZIO7Lnf8xcklST1StqRdORxNEualHQIDDvnTmvdIGE5vwdWgLy93bQX0w0UgSdgKMC3AdfA7ndpSgKbduUbYBoI/7Ju4BiYrYFfAl4iBbxAgOyPgV9xWYDQ3xCXKK79C/wL/KJZoeW8QpsJCy0C54AMULaGmQu7sIAW4MpaxTKwbQU3U4dAxmLzwLpxXAIJP2jKgkY8Xx4o1SFwBmx7+7RxTUnVb9Bpa9HDFiR1/SRgWH+6FT3/h2qK6sBpB0aBB7yB880NcpaWtGHXjCsVBmb5PDIvgJ46BJKW84q9AguV87Adp/Q+9O8UMfQjRBKSxiV1SNp3zp3Ug/sVewPruexhKwhGXQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], "inputSchema": { "properties": { "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "pullNumber": { "description": "The pull request number", @@ -20,7 +33,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "reviewers": { "description": "GitHub usernames or ORG/team-slug team reviewers to request reviews from", @@ -38,5 +52,24 @@ ], "type": "object" }, - "name": "request_pull_request_reviewers" + "name": "request_pull_request_reviewers", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/resolve_review_thread.snap b/pkg/github/__toolsnaps__/resolve_review_thread.snap index 5f2b21b36f..dc01720117 100644 --- a/pkg/github/__toolsnaps__/resolve_review_thread.snap +++ b/pkg/github/__toolsnaps__/resolve_review_thread.snap @@ -7,6 +7,18 @@ "title": "Resolve Review Thread" }, "description": "Resolve a review thread on a pull request. Resolving an already-resolved thread is a no-op.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABn0lEQVRIie2Vvy5EURDGvyMUkhUU/pS2Q2clwvIKqL0Cm9ArrBcQShJPoLFR2YaIhIjQ7a7E39IiCgqVn8KsPY697E3oTHPumTPf990z986M9Mfm/A0wKGlMUlnSlnPuOQQAE5LOnXOFWErAIvBK1S6BZBAzZ2fzcckHjXwVaAXSwD2wWYN8A2iKK1ABt3m+ZeDRnseIthdgDxioxd1o662tfZIO7Lnf8xcklST1StqRdORxNEualHQIDDvnTmvdIGE5vwdWgLy93bQX0w0UgSdgKMC3AdfA7ndpSgKbduUbYBoI/7Ju4BiYrYFfAl4iBbxAgOyPgV9xWYDQ3xCXKK79C/wL/KJZoeW8QpsJCy0C54AMULaGmQu7sIAW4MpaxTKwbQU3U4dAxmLzwLpxXAIJP2jKgkY8Xx4o1SFwBmx7+7RxTUnVb9Bpa9HDFiR1/SRgWH+6FT3/h2qK6sBpB0aBB7yB880NcpaWtGHXjCsVBmb5PDIvgJ46BJKW84q9AguV87Adp/Q+9O8UMfQjRBKSxiV1SNp3zp3Ug/sVewPruexhKwhGXQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], "inputSchema": { "properties": { "threadID": { @@ -19,5 +31,20 @@ ], "type": "object" }, - "name": "resolve_review_thread" + "name": "resolve_review_thread", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/resolve_review_thread_resolution_reason.snap b/pkg/github/__toolsnaps__/resolve_review_thread_resolution_reason.snap new file mode 100644 index 0000000000..b8bf2c8c7d --- /dev/null +++ b/pkg/github/__toolsnaps__/resolve_review_thread_resolution_reason.snap @@ -0,0 +1,54 @@ +{ + "annotations": { + "destructiveHint": false, + "idempotentHint": false, + "openWorldHint": true, + "readOnlyHint": false, + "title": "Resolve Review Thread" + }, + "description": "Resolve a review thread on a pull request. Resolving an already-resolved thread is a no-op.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABn0lEQVRIie2Vvy5EURDGvyMUkhUU/pS2Q2clwvIKqL0Cm9ArrBcQShJPoLFR2YaIhIjQ7a7E39IiCgqVn8KsPY697E3oTHPumTPf990z986M9Mfm/A0wKGlMUlnSlnPuOQQAE5LOnXOFWErAIvBK1S6BZBAzZ2fzcckHjXwVaAXSwD2wWYN8A2iKK1ABt3m+ZeDRnseIthdgDxioxd1o662tfZIO7Lnf8xcklST1StqRdORxNEualHQIDDvnTmvdIGE5vwdWgLy93bQX0w0UgSdgKMC3AdfA7ndpSgKbduUbYBoI/7Ju4BiYrYFfAl4iBbxAgOyPgV9xWYDQ3xCXKK79C/wL/KJZoeW8QpsJCy0C54AMULaGmQu7sIAW4MpaxTKwbQU3U4dAxmLzwLpxXAIJP2jKgkY8Xx4o1SFwBmx7+7RxTUnVb9Bpa9HDFiR1/SRgWH+6FT3/h2qK6sBpB0aBB7yB880NcpaWtGHXjCsVBmb5PDIvgJ46BJKW84q9AguV87Adp/Q+9O8UMfQjRBKSxiV1SNp3zp3Ug/sVewPruexhKwhGXQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], + "inputSchema": { + "properties": { + "resolutionReason": { + "description": "Optional reason for resolving a Copilot code review thread: addressed, wont-fix, or invalid.", + "type": "string" + }, + "threadID": { + "description": "The node ID of the review thread to resolve (e.g., PRRT_kwDOxxx)", + "type": "string" + } + }, + "required": [ + "threadID" + ], + "type": "object" + }, + "name": "resolve_review_thread", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/submit_pending_pull_request_review.snap b/pkg/github/__toolsnaps__/submit_pending_pull_request_review.snap index 12438ffe71..526d38b560 100644 --- a/pkg/github/__toolsnaps__/submit_pending_pull_request_review.snap +++ b/pkg/github/__toolsnaps__/submit_pending_pull_request_review.snap @@ -7,6 +7,18 @@ "title": "Submit Pending Pull Request Review" }, "description": "Submit a pending pull request review.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABn0lEQVRIie2Vvy5EURDGvyMUkhUU/pS2Q2clwvIKqL0Cm9ArrBcQShJPoLFR2YaIhIjQ7a7E39IiCgqVn8KsPY697E3oTHPumTPf990z986M9Mfm/A0wKGlMUlnSlnPuOQQAE5LOnXOFWErAIvBK1S6BZBAzZ2fzcckHjXwVaAXSwD2wWYN8A2iKK1ABt3m+ZeDRnseIthdgDxioxd1o662tfZIO7Lnf8xcklST1StqRdORxNEualHQIDDvnTmvdIGE5vwdWgLy93bQX0w0UgSdgKMC3AdfA7ndpSgKbduUbYBoI/7Ju4BiYrYFfAl4iBbxAgOyPgV9xWYDQ3xCXKK79C/wL/KJZoeW8QpsJCy0C54AMULaGmQu7sIAW4MpaxTKwbQU3U4dAxmLzwLpxXAIJP2jKgkY8Xx4o1SFwBmx7+7RxTUnVb9Bpa9HDFiR1/SRgWH+6FT3/h2qK6sBpB0aBB7yB880NcpaWtGHXjCsVBmb5PDIvgJ46BJKW84q9AguV87Adp/Q+9O8UMfQjRBKSxiV1SNp3zp3Ug/sVewPruexhKwhGXQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], "inputSchema": { "properties": { "body": { @@ -14,7 +26,7 @@ "type": "string" }, "event": { - "description": "The review action to perform", + "description": "The review action to perform (APPROVE, REQUEST_CHANGES, or COMMENT)", "enum": [ "APPROVE", "REQUEST_CHANGES", @@ -24,7 +36,8 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "pullNumber": { "description": "The pull request number", @@ -33,7 +46,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -44,5 +58,20 @@ ], "type": "object" }, - "name": "submit_pending_pull_request_review" + "name": "submit_pending_pull_request_review", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/unresolve_review_thread.snap b/pkg/github/__toolsnaps__/unresolve_review_thread.snap index fe40bab3e9..815ca048fb 100644 --- a/pkg/github/__toolsnaps__/unresolve_review_thread.snap +++ b/pkg/github/__toolsnaps__/unresolve_review_thread.snap @@ -7,6 +7,18 @@ "title": "Unresolve Review Thread" }, "description": "Unresolve a previously resolved review thread on a pull request. Unresolving an already-unresolved thread is a no-op.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABn0lEQVRIie2Vvy5EURDGvyMUkhUU/pS2Q2clwvIKqL0Cm9ArrBcQShJPoLFR2YaIhIjQ7a7E39IiCgqVn8KsPY697E3oTHPumTPf990z986M9Mfm/A0wKGlMUlnSlnPuOQQAE5LOnXOFWErAIvBK1S6BZBAzZ2fzcckHjXwVaAXSwD2wWYN8A2iKK1ABt3m+ZeDRnseIthdgDxioxd1o662tfZIO7Lnf8xcklST1StqRdORxNEualHQIDDvnTmvdIGE5vwdWgLy93bQX0w0UgSdgKMC3AdfA7ndpSgKbduUbYBoI/7Ju4BiYrYFfAl4iBbxAgOyPgV9xWYDQ3xCXKK79C/wL/KJZoeW8QpsJCy0C54AMULaGmQu7sIAW4MpaxTKwbQU3U4dAxmLzwLpxXAIJP2jKgkY8Xx4o1SFwBmx7+7RxTUnVb9Bpa9HDFiR1/SRgWH+6FT3/h2qK6sBpB0aBB7yB880NcpaWtGHXjCsVBmb5PDIvgJ46BJKW84q9AguV87Adp/Q+9O8UMfQjRBKSxiV1SNp3zp3Ug/sVewPruexhKwhGXQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], "inputSchema": { "properties": { "threadID": { @@ -19,5 +31,20 @@ ], "type": "object" }, - "name": "unresolve_review_thread" + "name": "unresolve_review_thread", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_pull_request_body.snap b/pkg/github/__toolsnaps__/update_pull_request_body.snap index 00b4fb978a..dec6052ae6 100644 --- a/pkg/github/__toolsnaps__/update_pull_request_body.snap +++ b/pkg/github/__toolsnaps__/update_pull_request_body.snap @@ -7,6 +7,18 @@ "title": "Update Pull Request Body" }, "description": "Update the body description of an existing pull request.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABn0lEQVRIie2Vvy5EURDGvyMUkhUU/pS2Q2clwvIKqL0Cm9ArrBcQShJPoLFR2YaIhIjQ7a7E39IiCgqVn8KsPY697E3oTHPumTPf990z986M9Mfm/A0wKGlMUlnSlnPuOQQAE5LOnXOFWErAIvBK1S6BZBAzZ2fzcckHjXwVaAXSwD2wWYN8A2iKK1ABt3m+ZeDRnseIthdgDxioxd1o662tfZIO7Lnf8xcklST1StqRdORxNEualHQIDDvnTmvdIGE5vwdWgLy93bQX0w0UgSdgKMC3AdfA7ndpSgKbduUbYBoI/7Ju4BiYrYFfAl4iBbxAgOyPgV9xWYDQ3xCXKK79C/wL/KJZoeW8QpsJCy0C54AMULaGmQu7sIAW4MpaxTKwbQU3U4dAxmLzwLpxXAIJP2jKgkY8Xx4o1SFwBmx7+7RxTUnVb9Bpa9HDFiR1/SRgWH+6FT3/h2qK6sBpB0aBB7yB880NcpaWtGHXjCsVBmb5PDIvgJ46BJKW84q9AguV87Adp/Q+9O8UMfQjRBKSxiV1SNp3zp3Ug/sVewPruexhKwhGXQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], "inputSchema": { "properties": { "body": { @@ -15,7 +27,8 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "pullNumber": { "description": "The pull request number", @@ -24,7 +37,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -35,5 +49,24 @@ ], "type": "object" }, - "name": "update_pull_request_body" + "name": "update_pull_request_body", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_pull_request_draft_state.snap b/pkg/github/__toolsnaps__/update_pull_request_draft_state.snap index 653fa3398f..d1ec856caa 100644 --- a/pkg/github/__toolsnaps__/update_pull_request_draft_state.snap +++ b/pkg/github/__toolsnaps__/update_pull_request_draft_state.snap @@ -7,6 +7,18 @@ "title": "Update Pull Request Draft State" }, "description": "Mark a pull request as draft or ready for review.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABn0lEQVRIie2Vvy5EURDGvyMUkhUU/pS2Q2clwvIKqL0Cm9ArrBcQShJPoLFR2YaIhIjQ7a7E39IiCgqVn8KsPY697E3oTHPumTPf990z986M9Mfm/A0wKGlMUlnSlnPuOQQAE5LOnXOFWErAIvBK1S6BZBAzZ2fzcckHjXwVaAXSwD2wWYN8A2iKK1ABt3m+ZeDRnseIthdgDxioxd1o662tfZIO7Lnf8xcklST1StqRdORxNEualHQIDDvnTmvdIGE5vwdWgLy93bQX0w0UgSdgKMC3AdfA7ndpSgKbduUbYBoI/7Ju4BiYrYFfAl4iBbxAgOyPgV9xWYDQ3xCXKK79C/wL/KJZoeW8QpsJCy0C54AMULaGmQu7sIAW4MpaxTKwbQU3U4dAxmLzwLpxXAIJP2jKgkY8Xx4o1SFwBmx7+7RxTUnVb9Bpa9HDFiR1/SRgWH+6FT3/h2qK6sBpB0aBB7yB880NcpaWtGHXjCsVBmb5PDIvgJ46BJKW84q9AguV87Adp/Q+9O8UMfQjRBKSxiV1SNp3zp3Ug/sVewPruexhKwhGXQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], "inputSchema": { "properties": { "draft": { @@ -15,7 +27,8 @@ }, "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "pullNumber": { "description": "The pull request number", @@ -24,7 +37,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" } }, "required": [ @@ -35,5 +49,20 @@ ], "type": "object" }, - "name": "update_pull_request_draft_state" + "name": "update_pull_request_draft_state", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_pull_request_state.snap b/pkg/github/__toolsnaps__/update_pull_request_state.snap index 440533e9b7..8a01c91530 100644 --- a/pkg/github/__toolsnaps__/update_pull_request_state.snap +++ b/pkg/github/__toolsnaps__/update_pull_request_state.snap @@ -7,11 +7,24 @@ "title": "Update Pull Request State" }, "description": "Update the state of an existing pull request (open or closed).", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABn0lEQVRIie2Vvy5EURDGvyMUkhUU/pS2Q2clwvIKqL0Cm9ArrBcQShJPoLFR2YaIhIjQ7a7E39IiCgqVn8KsPY697E3oTHPumTPf990z986M9Mfm/A0wKGlMUlnSlnPuOQQAE5LOnXOFWErAIvBK1S6BZBAzZ2fzcckHjXwVaAXSwD2wWYN8A2iKK1ABt3m+ZeDRnseIthdgDxioxd1o662tfZIO7Lnf8xcklST1StqRdORxNEualHQIDDvnTmvdIGE5vwdWgLy93bQX0w0UgSdgKMC3AdfA7ndpSgKbduUbYBoI/7Ju4BiYrYFfAl4iBbxAgOyPgV9xWYDQ3xCXKK79C/wL/KJZoeW8QpsJCy0C54AMULaGmQu7sIAW4MpaxTKwbQU3U4dAxmLzwLpxXAIJP2jKgkY8Xx4o1SFwBmx7+7RxTUnVb9Bpa9HDFiR1/SRgWH+6FT3/h2qK6sBpB0aBB7yB880NcpaWtGHXjCsVBmb5PDIvgJ46BJKW84q9AguV87Adp/Q+9O8UMfQjRBKSxiV1SNp3zp3Ug/sVewPruexhKwhGXQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], "inputSchema": { "properties": { "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "pullNumber": { "description": "The pull request number", @@ -20,10 +33,11 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "state": { - "description": "The new state for the pull request", + "description": "The new state for the pull request (open or closed)", "enum": [ "open", "closed" @@ -39,5 +53,24 @@ ], "type": "object" }, - "name": "update_pull_request_state" + "name": "update_pull_request_state", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_pull_request_title.snap b/pkg/github/__toolsnaps__/update_pull_request_title.snap index 1ac5455f77..7ecf5d4600 100644 --- a/pkg/github/__toolsnaps__/update_pull_request_title.snap +++ b/pkg/github/__toolsnaps__/update_pull_request_title.snap @@ -7,11 +7,24 @@ "title": "Update Pull Request Title" }, "description": "Update the title of an existing pull request.", + "icons": [ + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAACwUlEQVRIie2Vz28UZRjHP993pi0QIC3YahNjirtmd3bS3Q1eUHvQEPUiEv8A4kXjwRJ78MCFBLjBBRKCHowHE38cNCbGGx6IUoKiodtNpoNmTJp4oSJNQ3pw29l5POxus2wo3QTwxPc0887zfD7zvu9kXnjEUfdNrjj5vJOmMP4e9JrfR1G02tuQD8tvgpck0dxCPwK30ViqnJTcr4bOmfRlI/PrhUJ5313woDpDpu8ss7f6nYHrvDnGcYlPsoY/bKaXwHY3HWfvgmNnMX0zvMM7069A3c3pkEYWa7UVgFxQPSfs7SSeH3k2rEy5jMubMBoG1yQ+SBbm53of+gCybMkk/H8VAFdbZisZLAFsJ11oyL+BUURcwrjWAZixXeIwxs/5UuVAr0QAYRjubGR+HWy3mb6QCIBXQe8nce0jgIkwfMo3/xLG085x8I9ofkMyUa0O+w2rgS0mcf3lboEDiKJo1cvsIDDr4D1DhTb8407hYhTdTJW+AvrdMnuhG9Je1m9BBzbfjXbyQcXypeqJLQt7+0rVE/mgYr3j7l7FDzOPBY8FDx6vc1EolPeNjI5/Jpgw7Lm9o+Pry//c/K0PhnLFyrSMDxE79jwxvn9079gvt28vrUD7V1EoFHalbltd2C7DfS4sAF4DTSdx7cL96LliZVriPPADuL+geRh0Z8il5SiKVn2ATENvCCYw78U/b8xdBcgHlYuGHQXuK5A4ClxM4vnXW8Lqp5JdWWt6h4CvWnsgxgDSbRZ3Gg0tCJ7sY4nGwDZOt/WBZtzN9FswLgM2sGann5mcPDaw5pWEHQH7cUu86SdkR3LF6tfrA814MNVpwNrM1leUxPXrSKfMeHcwdctyNotY8c3NbMX3LJsB3ZHsymDqlkHvYHYyievXWxPpSj4o75eYIuPWZof+vRKG4c61pncIx6gZsx34/5L/ACy3ElqUYhuvAAAAAElFTkSuQmCC", + "theme": "light" + }, + { + "mimeType": "image/png", + "src": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAABmJLR0QA/wD/AP+gvaeTAAABn0lEQVRIie2Vvy5EURDGvyMUkhUU/pS2Q2clwvIKqL0Cm9ArrBcQShJPoLFR2YaIhIjQ7a7E39IiCgqVn8KsPY697E3oTHPumTPf990z986M9Mfm/A0wKGlMUlnSlnPuOQQAE5LOnXOFWErAIvBK1S6BZBAzZ2fzcckHjXwVaAXSwD2wWYN8A2iKK1ABt3m+ZeDRnseIthdgDxioxd1o662tfZIO7Lnf8xcklST1StqRdORxNEualHQIDDvnTmvdIGE5vwdWgLy93bQX0w0UgSdgKMC3AdfA7ndpSgKbduUbYBoI/7Ju4BiYrYFfAl4iBbxAgOyPgV9xWYDQ3xCXKK79C/wL/KJZoeW8QpsJCy0C54AMULaGmQu7sIAW4MpaxTKwbQU3U4dAxmLzwLpxXAIJP2jKgkY8Xx4o1SFwBmx7+7RxTUnVb9Bpa9HDFiR1/SRgWH+6FT3/h2qK6sBpB0aBB7yB880NcpaWtGHXjCsVBmb5PDIvgJ46BJKW84q9AguV87Adp/Q+9O8UMfQjRBKSxiV1SNp3zp3Ug/sVewPruexhKwhGXQAAAABJRU5ErkJggg==", + "theme": "dark" + } + ], "inputSchema": { "properties": { "owner": { "description": "Repository owner (username or organization)", - "type": "string" + "type": "string", + "x-mcp-header": "owner" }, "pullNumber": { "description": "The pull request number", @@ -20,7 +33,8 @@ }, "repo": { "description": "Repository name", - "type": "string" + "type": "string", + "x-mcp-header": "repo" }, "title": { "description": "The new title for the pull request", @@ -35,5 +49,24 @@ ], "type": "object" }, - "name": "update_pull_request_title" + "name": "update_pull_request_title", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": [ + "null", + "object" + ] + } } \ No newline at end of file diff --git a/pkg/github/granular_pull_request_types.go b/pkg/github/granular_pull_request_types.go new file mode 100644 index 0000000000..e11da7b1f0 --- /dev/null +++ b/pkg/github/granular_pull_request_types.go @@ -0,0 +1,298 @@ +package github + +import ( + "bytes" + "encoding/json" + "fmt" + "maps" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/google/jsonschema-go/jsonschema" +) + +type GranularPullRequestCoordinate struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + PullNumber int `json:"pullNumber"` +} + +func (in GranularPullRequestCoordinate) pullRequestCoordinate() GranularPullRequestCoordinate { + return in +} + +type GranularPullRequestTitleInput struct { + GranularPullRequestCoordinate + Title string `json:"title"` +} + +type GranularPullRequestBodyInput struct { + GranularPullRequestCoordinate + Body string `json:"body"` +} + +type GranularPullRequestStateInput struct { + GranularPullRequestCoordinate + State GranularPullRequestState `json:"state"` +} + +type GranularPullRequestDraftInput struct { + GranularPullRequestCoordinate + Draft bool `json:"draft"` +} + +type GranularPullRequestReviewersInput struct { + GranularPullRequestCoordinate + Reviewers []string `json:"reviewers"` +} + +type GranularCreatePullRequestReviewInput struct { + GranularPullRequestCoordinate + Body string `json:"body,omitempty"` + Event GranularPullRequestReviewEvent `json:"event,omitempty"` + CommitID string `json:"commitID,omitempty"` +} + +type GranularSubmitPullRequestReviewInput struct { + GranularPullRequestCoordinate + Event GranularPullRequestReviewEvent `json:"event"` + Body string `json:"body,omitempty"` +} + +type GranularPullRequestReviewCommentInput struct { + GranularPullRequestCoordinate + Path string `json:"path"` + Body string `json:"body"` + SubjectType GranularPullRequestCommentSubject `json:"subjectType"` + Line *int `json:"line,omitempty"` + Side *GranularPullRequestDiffSide `json:"side,omitempty"` + StartLine *int `json:"startLine,omitempty"` + StartSide *GranularPullRequestDiffSide `json:"startSide,omitempty"` +} + +type GranularReviewThreadInput struct { + ThreadID string `json:"threadID"` +} + +type GranularResolveReviewThreadInput struct { + GranularReviewThreadInput + ResolutionReason *string `json:"resolutionReason,omitempty"` +} + +type GranularAddPullRequestCommentReactionInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + CommentID int64 `json:"comment_id"` + Content PullRequestCommentReactionType `json:"content"` +} + +type GranularRemovePullRequestCommentReactionInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + CommentID int64 `json:"comment_id"` + ReactionID int64 `json:"reaction_id"` +} + +// MinimalPullRequestCommentReaction omits the derivable REST reaction URL. +type MinimalPullRequestCommentReaction struct { + ID int64 `json:"id"` + Content PullRequestCommentReactionType `json:"content,omitempty"` +} + +type PullRequestCommentReactionType string + +func (PullRequestCommentReactionType) Values() []string { + return []string{"+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"} +} + +func (value PullRequestCommentReactionType) JSONSchema() *jsonschema.Schema { + return granularPullRequestEnumSchema(value.Values()) +} + +type GranularPullRequestState string + +func (GranularPullRequestState) Values() []string { return []string{"open", "closed"} } +func (value GranularPullRequestState) JSONSchema() *jsonschema.Schema { + return granularPullRequestEnumSchema(value.Values()) +} + +type GranularPullRequestReviewEvent string + +func (GranularPullRequestReviewEvent) Values() []string { + return []string{"APPROVE", "REQUEST_CHANGES", "COMMENT"} +} +func (value GranularPullRequestReviewEvent) JSONSchema() *jsonschema.Schema { + return granularPullRequestEnumSchema(value.Values()) +} + +type GranularPullRequestCommentSubject string + +func (GranularPullRequestCommentSubject) Values() []string { return []string{"FILE", "LINE"} } +func (value GranularPullRequestCommentSubject) JSONSchema() *jsonschema.Schema { + return granularPullRequestEnumSchema(value.Values()) +} + +type GranularPullRequestDiffSide string + +func (GranularPullRequestDiffSide) Values() []string { return []string{"LEFT", "RIGHT"} } +func (value GranularPullRequestDiffSide) JSONSchema() *jsonschema.Schema { + return granularPullRequestEnumSchema(value.Values()) +} + +func granularPullRequestEnumSchema(values []string) *jsonschema.Schema { + enum := make([]any, len(values)) + for i, value := range values { + enum[i] = value + } + return &jsonschema.Schema{Type: "string", Enum: enum} +} + +func minimalPullRequestCommentReactionSchema() *jsonschema.Schema { + schema := repositoryOutputSchema[MinimalPullRequestCommentReaction]() + schema.Properties["id"].Description = "Reaction ID" + schema.Properties["content"].Description = "Reaction type" + schema.Properties["content"].Enum = PullRequestCommentReactionType("").JSONSchema().Enum + return schema +} + +// normalizeGranularPullRequestArguments replays the untyped parameter checks +// in their original order, including ignored optional-string type errors. +// The advertised enum/minimum constraints remain unchanged; the compatibility +// validation schema leaves these legacy-unvalidated checks to GitHub. +func normalizeGranularPullRequestArguments(kind string) inventory.InputNormalizer { + return func(raw json.RawMessage) (json.RawMessage, error) { + if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return nil, &inventory.ToolInputError{Message: "invalid arguments: arguments must be a JSON object"} + } + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, &inventory.ToolInputError{Message: "invalid arguments: " + err.Error()} + } + + if err := normalizeGranularPullRequestFields(args, kind); err != nil { + return nil, &inventory.ToolInputError{Message: err.Error()} + } + return json.Marshal(args) + } +} + +func granularPullRequestValidationSchema(advertised *jsonschema.Schema) *jsonschema.Schema { + validation := *advertised + validation.Properties = maps.Clone(advertised.Properties) + for name, property := range advertised.Properties { + if len(property.Enum) == 0 && property.Minimum == nil { + continue + } + validationProperty := *property + validationProperty.Enum = nil + validationProperty.Minimum = nil + validation.Properties[name] = &validationProperty + } + return &validation +} + +func normalizeGranularResolveReviewThreadArguments(withResolutionReason bool) inventory.InputNormalizer { + if withResolutionReason { + return normalizeGranularPullRequestArguments("resolve_reason") + } + return normalizeGranularPullRequestArguments("resolve") +} + +func normalizeGranularPullRequestFields(args map[string]any, kind string) error { + requiredString := func(field string) error { + _, err := RequiredParam[string](args, field) + return err + } + ignoredString := func(field string) { + if _, ok := args[field].(string); !ok { + delete(args, field) + } + } + switch kind { + case "resolve", "resolve_reason", "unresolve": + if err := requiredString("threadID"); err != nil { + return err + } + if kind == "resolve_reason" { + _, _, err := OptionalParamOK[string](args, "resolutionReason") + return err + } + delete(args, "resolutionReason") + return nil + } + for _, field := range []string{"owner", "repo"} { + if err := requiredString(field); err != nil { + return err + } + } + if kind == "add_reaction" || kind == "remove_reaction" { + commentID, err := RequiredBigInt(args, "comment_id") + if err != nil { + return err + } + args["comment_id"] = commentID + if kind == "add_reaction" { + return requiredString("content") + } + reactionID, err := RequiredBigInt(args, "reaction_id") + if err != nil { + return err + } + args["reaction_id"] = reactionID + return nil + } + pullNumber, err := RequiredInt(args, "pullNumber") + if err != nil { + return err + } + args["pullNumber"] = pullNumber + switch kind { + case "title", "body", "state": + return requiredString(kind) + case "draft": + if _, ok := args["draft"]; !ok { + return fmt.Errorf("missing required parameter: draft") + } + _, err := OptionalParam[bool](args, "draft") + return err + case "reviewers": + reviewers, err := OptionalStringArrayParam(args, "reviewers") + if err != nil { + return err + } + if len(reviewers) == 0 { + return fmt.Errorf("missing required parameter: reviewers") + } + args["reviewers"] = reviewers + case "create_review": + for _, field := range []string{"body", "event", "commitID"} { + ignoredString(field) + } + case "submit_review": + if err := requiredString("event"); err != nil { + return err + } + ignoredString("body") + case "delete_review": + case "comment": + for _, field := range []string{"path", "body", "subjectType"} { + if err := requiredString(field); err != nil { + return err + } + } + line, err := OptionalIntParam(args, "line") + if err != nil { + return err + } + args["line"] = line + ignoredString("side") + startLine, err := OptionalIntParam(args, "startLine") + if err != nil { + return err + } + args["startLine"] = startLine + ignoredString("startSide") + default: + panic("unknown granular pull request argument kind: " + kind) + } + return nil +} diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go index 977bbe9d4d..8cbdc51760 100644 --- a/pkg/github/granular_tools_test.go +++ b/pkg/github/granular_tools_test.go @@ -59,19 +59,6 @@ func TestGranularToolSnaps(t *testing.T) { GranularRemoveIssueCommentReaction, GranularHideIssueComment, GranularUnhideIssueComment, - GranularUpdatePullRequestTitle, - GranularUpdatePullRequestBody, - GranularUpdatePullRequestState, - GranularUpdatePullRequestDraftState, - GranularRequestPullRequestReviewers, - GranularCreatePullRequestReview, - GranularSubmitPendingPullRequestReview, - GranularDeletePendingPullRequestReview, - GranularAddPullRequestReviewComment, - GranularResolveReviewThread, - GranularUnresolveReviewThread, - GranularAddPullRequestReviewCommentReaction, - GranularRemovePullRequestReviewCommentReaction, GranularHidePullRequestReviewComment, GranularUnhidePullRequestReviewComment, GranularHidePullRequestReview, diff --git a/pkg/github/pullrequests_granular.go b/pkg/github/pullrequests_granular.go index c1276a6b86..c17faae050 100644 --- a/pkg/github/pullrequests_granular.go +++ b/pkg/github/pullrequests_granular.go @@ -18,13 +18,39 @@ import ( "github.com/shurcooL/githubv4" ) +func newGranularPullRequestTool[In, Out any]( + toolset inventory.ToolsetMetadata, + tool mcp.Tool, + scopeAccess inventory.ScopeAccess, + handler func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args In) (*mcp.CallToolResult, Out, error), + inputNormalizers ...inventory.InputNormalizer, +) inventory.ServerTool { + inputSchema, ok := tool.InputSchema.(*jsonschema.Schema) + if !ok { + panic("granular pull request tool input schema must be a JSON Schema") + } + return NewToolWithSchemaOptions( + toolset, + tool, + scopeAccess, + inventory.TypedSchemaOptions{ + ValidationInputSchema: granularPullRequestValidationSchema(inputSchema), + }, + handler, + inputNormalizers..., + ) +} + // prUpdateTool is a helper to create single-field pull request update tools via REST. -func prUpdateTool( +func prUpdateTool[In interface { + pullRequestCoordinate() GranularPullRequestCoordinate +}]( t translations.TranslationHelperFunc, name, description, title string, extraProps map[string]*jsonschema.Schema, extraRequired []string, - buildRequest func(args map[string]any) (*gogithub.PullRequest, error), + buildRequest func(args In) *gogithub.PullRequest, + normalizer inventory.InputNormalizer, ) inventory.ServerTool { props := map[string]*jsonschema.Schema{ "owner": { @@ -45,7 +71,7 @@ func prUpdateTool( required := append([]string{"owner", "repo", "pullNumber"}, extraRequired...) - st := NewTool( + st := newGranularPullRequestTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: name, @@ -63,24 +89,10 @@ func prUpdateTool( }, }, scopes.RequireAll(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 - } - pullNumber, err := RequiredInt(args, "pullNumber") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - prReq, err := buildRequest(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args In) (*mcp.CallToolResult, *MinimalResponse, error) { + coordinate := args.pullRequestCoordinate() + owner, repo, pullNumber := coordinate.Owner, coordinate.Repo, coordinate.PullNumber + prReq := buildRequest(args) client, err := deps.GetClient(ctx) if err != nil { @@ -93,15 +105,17 @@ func prUpdateTool( } defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(MinimalResponse{ + output := &MinimalResponse{ ID: fmt.Sprintf("%d", pr.GetID()), URL: pr.GetHTMLURL(), - }) + } + r, err := json.Marshal(output) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), output, nil }, + normalizer, ) st.FeatureRule = pullRequestsGranularFeatureRule return st @@ -117,13 +131,10 @@ func GranularUpdatePullRequestTitle(t translations.TranslationHelperFunc) invent "title": {Type: "string", Description: "The new title for the pull request"}, }, []string{"title"}, - func(args map[string]any) (*gogithub.PullRequest, error) { - title, err := RequiredParam[string](args, "title") - if err != nil { - return nil, err - } - return &gogithub.PullRequest{Title: &title}, nil + func(args GranularPullRequestTitleInput) *gogithub.PullRequest { + return &gogithub.PullRequest{Title: &args.Title} }, + normalizeGranularPullRequestArguments("title"), ) } @@ -137,13 +148,10 @@ func GranularUpdatePullRequestBody(t translations.TranslationHelperFunc) invento "body": {Type: "string", Description: "The new body content for the pull request"}, }, []string{"body"}, - func(args map[string]any) (*gogithub.PullRequest, error) { - body, err := RequiredParam[string](args, "body") - if err != nil { - return nil, err - } - return &gogithub.PullRequest{Body: &body}, nil + func(args GranularPullRequestBodyInput) *gogithub.PullRequest { + return &gogithub.PullRequest{Body: &args.Body} }, + normalizeGranularPullRequestArguments("body"), ) } @@ -156,24 +164,22 @@ func GranularUpdatePullRequestState(t translations.TranslationHelperFunc) invent map[string]*jsonschema.Schema{ "state": { Type: "string", - Description: "The new state for the pull request", - Enum: []any{"open", "closed"}, + Description: "The new state for the pull request (open or closed)", + Enum: GranularPullRequestState("").JSONSchema().Enum, }, }, []string{"state"}, - func(args map[string]any) (*gogithub.PullRequest, error) { - state, err := RequiredParam[string](args, "state") - if err != nil { - return nil, err - } - return &gogithub.PullRequest{State: &state}, nil + func(args GranularPullRequestStateInput) *gogithub.PullRequest { + state := string(args.State) + return &gogithub.PullRequest{State: &state} }, + normalizeGranularPullRequestArguments("state"), ) } // GranularUpdatePullRequestDraftState creates a tool to toggle draft state. func GranularUpdatePullRequestDraftState(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := newGranularPullRequestTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "update_pull_request_draft_state", @@ -196,27 +202,8 @@ func GranularUpdatePullRequestDraftState(t translations.TranslationHelperFunc) i }, }, scopes.RequireAll(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 - } - pullNumber, err := RequiredInt(args, "pullNumber") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - // Use presence check + OptionalParam since RequiredParam rejects false (zero-value for bool) - if _, ok := args["draft"]; !ok { - return utils.NewToolResultError("missing required parameter: draft"), nil, nil - } - draft, err := OptionalParam[bool](args, "draft") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularPullRequestDraftInput) (*mcp.CallToolResult, *RepositoryMessageOutput, error) { + owner, repo, pullNumber, draft := args.Owner, args.Repo, args.PullNumber, args.Draft gqlClient, err := deps.GetGQLClient(ctx) if err != nil { @@ -253,7 +240,7 @@ func GranularUpdatePullRequestDraftState(t translations.TranslationHelperFunc) i }, nil); err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to convert to draft", err), nil, nil } - return utils.NewToolResultText("pull request converted to draft"), nil, nil + return pullRequestMessageResult(utils.NewToolResultText("pull request converted to draft"), nil) } var mutation struct { @@ -269,8 +256,9 @@ func GranularUpdatePullRequestDraftState(t translations.TranslationHelperFunc) i }, nil); err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to mark ready for review", err), nil, nil } - return utils.NewToolResultText("pull request marked as ready for review"), nil, nil + return pullRequestMessageResult(utils.NewToolResultText("pull request marked as ready for review"), nil) }, + normalizeGranularPullRequestArguments("draft"), ) st.FeatureRule = pullRequestsGranularFeatureRule return st @@ -278,7 +266,7 @@ func GranularUpdatePullRequestDraftState(t translations.TranslationHelperFunc) i // GranularRequestPullRequestReviewers creates a tool to request reviewers. func GranularRequestPullRequestReviewers(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := newGranularPullRequestTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "request_pull_request_reviewers", @@ -305,27 +293,9 @@ func GranularRequestPullRequestReviewers(t translations.TranslationHelperFunc) i }, }, scopes.RequireAll(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 - } - pullNumber, err := RequiredInt(args, "pullNumber") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - reviewers, err := OptionalStringArrayParam(args, "reviewers") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - if len(reviewers) == 0 { - return utils.NewToolResultError("missing required parameter: reviewers"), nil, nil - } - userReviewers, teamReviewers := splitPullRequestReviewers(reviewers) + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularPullRequestReviewersInput) (*mcp.CallToolResult, *MinimalResponse, error) { + owner, repo, pullNumber := args.Owner, args.Repo, args.PullNumber + userReviewers, teamReviewers := splitPullRequestReviewers(args.Reviewers) client, err := deps.GetClient(ctx) if err != nil { @@ -341,15 +311,17 @@ func GranularRequestPullRequestReviewers(t translations.TranslationHelperFunc) i } defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(MinimalResponse{ + output := &MinimalResponse{ ID: fmt.Sprintf("%d", pr.GetID()), URL: pr.GetHTMLURL(), - }) + } + r, err := json.Marshal(output) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), output, nil }, + normalizeGranularPullRequestArguments("reviewers"), ) st.FeatureRule = pullRequestsGranularFeatureRule return st @@ -373,7 +345,7 @@ func splitPullRequestReviewers(reviewers []string) ([]string, []string) { // GranularCreatePullRequestReview creates a tool to create a PR review. func GranularCreatePullRequestReview(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := newGranularPullRequestTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "create_pull_request_review", @@ -391,29 +363,16 @@ func GranularCreatePullRequestReview(t translations.TranslationHelperFunc) inven "repo": {Type: "string", Description: "Repository name"}, "pullNumber": {Type: "number", Description: "The pull request number", Minimum: new(1.0)}, "body": {Type: "string", Description: "The review body text (optional)"}, - "event": {Type: "string", Description: "The review action to perform. If omitted, creates a pending review.", Enum: []any{"APPROVE", "REQUEST_CHANGES", "COMMENT"}}, + "event": {Type: "string", Description: "The review action to perform (APPROVE, REQUEST_CHANGES, or COMMENT). If omitted, creates a pending review.", Enum: GranularPullRequestReviewEvent("").JSONSchema().Enum}, "commitID": {Type: "string", Description: "The SHA of the commit to review (optional, defaults to latest)"}, }, Required: []string{"owner", "repo", "pullNumber"}, }, }, scopes.RequireAll(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 - } - pullNumber, err := RequiredInt(args, "pullNumber") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - body, _ := OptionalParam[string](args, "body") - event, _ := OptionalParam[string](args, "event") - commitID, _ := OptionalParam[string](args, "commitID") + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularCreatePullRequestReviewInput) (*mcp.CallToolResult, *RepositoryMessageOutput, error) { + owner, repo, pullNumber := args.Owner, args.Repo, args.PullNumber + body, event, commitID := args.Body, string(args.Event), args.CommitID gqlClient, err := deps.GetGQLClient(ctx) if err != nil { @@ -433,8 +392,9 @@ func GranularCreatePullRequestReview(t translations.TranslationHelperFunc) inven Event: event, CommitID: commitIDPtr, }) - return result, nil, err + return pullRequestMessageResult(result, err) }, + normalizeGranularPullRequestArguments("create_review"), ) st.FeatureRule = pullRequestsGranularFeatureRule return st @@ -442,7 +402,7 @@ func GranularCreatePullRequestReview(t translations.TranslationHelperFunc) inven // GranularSubmitPendingPullRequestReview creates a tool to submit a pending review. func GranularSubmitPendingPullRequestReview(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := newGranularPullRequestTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "submit_pending_pull_request_review", @@ -459,31 +419,15 @@ func GranularSubmitPendingPullRequestReview(t translations.TranslationHelperFunc "owner": {Type: "string", Description: "Repository owner (username or organization)"}, "repo": {Type: "string", Description: "Repository name"}, "pullNumber": {Type: "number", Description: "The pull request number", Minimum: new(1.0)}, - "event": {Type: "string", Description: "The review action to perform", Enum: []any{"APPROVE", "REQUEST_CHANGES", "COMMENT"}}, + "event": {Type: "string", Description: "The review action to perform (APPROVE, REQUEST_CHANGES, or COMMENT)", Enum: GranularPullRequestReviewEvent("").JSONSchema().Enum}, "body": {Type: "string", Description: "The review body text (optional)"}, }, Required: []string{"owner", "repo", "pullNumber", "event"}, }, }, scopes.RequireAll(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 - } - pullNumber, err := RequiredInt(args, "pullNumber") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - event, err := RequiredParam[string](args, "event") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - body, _ := OptionalParam[string](args, "body") + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularSubmitPullRequestReviewInput) (*mcp.CallToolResult, *RepositoryMessageOutput, error) { + owner, repo, pullNumber, event, body := args.Owner, args.Repo, args.PullNumber, string(args.Event), args.Body gqlClient, err := deps.GetGQLClient(ctx) if err != nil { @@ -497,8 +441,9 @@ func GranularSubmitPendingPullRequestReview(t translations.TranslationHelperFunc Event: event, Body: body, }) - return result, nil, err + return pullRequestMessageResult(result, err) }, + normalizeGranularPullRequestArguments("submit_review"), ) st.FeatureRule = pullRequestsGranularFeatureRule return st @@ -506,7 +451,7 @@ func GranularSubmitPendingPullRequestReview(t translations.TranslationHelperFunc // GranularDeletePendingPullRequestReview creates a tool to delete a pending review. func GranularDeletePendingPullRequestReview(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := newGranularPullRequestTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "delete_pending_pull_request_review", @@ -528,19 +473,8 @@ func GranularDeletePendingPullRequestReview(t translations.TranslationHelperFunc }, }, scopes.RequireAll(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 - } - pullNumber, err := RequiredInt(args, "pullNumber") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularPullRequestCoordinate) (*mcp.CallToolResult, *RepositoryMessageOutput, error) { + owner, repo, pullNumber := args.Owner, args.Repo, args.PullNumber gqlClient, err := deps.GetGQLClient(ctx) if err != nil { @@ -552,8 +486,9 @@ func GranularDeletePendingPullRequestReview(t translations.TranslationHelperFunc Repo: repo, PullNumber: int32(pullNumber), // #nosec G115 - PR numbers are always small positive integers }) - return result, nil, err + return pullRequestMessageResult(result, err) }, + normalizeGranularPullRequestArguments("delete_review"), ) st.FeatureRule = pullRequestsGranularFeatureRule return st @@ -561,7 +496,7 @@ func GranularDeletePendingPullRequestReview(t translations.TranslationHelperFunc // GranularAddPullRequestReviewComment creates a tool to add a review comment. func GranularAddPullRequestReviewComment(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := newGranularPullRequestTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "add_pull_request_review_comment", @@ -580,51 +515,18 @@ func GranularAddPullRequestReviewComment(t translations.TranslationHelperFunc) i "pullNumber": {Type: "number", Description: "The pull request number", Minimum: new(1.0)}, "path": {Type: "string", Description: "The relative path of the file to comment on"}, "body": {Type: "string", Description: "The comment body"}, - "subjectType": {Type: "string", Description: "The subject type of the comment", Enum: []any{"FILE", "LINE"}}, + "subjectType": {Type: "string", Description: "The subject type of the comment (FILE or LINE)", Enum: GranularPullRequestCommentSubject("").JSONSchema().Enum}, "line": {Type: "number", Description: "The line number in the diff to comment on (optional)"}, - "side": {Type: "string", Description: "The side of the diff to comment on (optional)", Enum: []any{"LEFT", "RIGHT"}}, + "side": {Type: "string", Description: "The side of the diff to comment on (LEFT or RIGHT, optional)", Enum: GranularPullRequestDiffSide("").JSONSchema().Enum}, "startLine": {Type: "number", Description: "The start line of a multi-line comment (optional)"}, - "startSide": {Type: "string", Description: "The start side of a multi-line comment (optional)", Enum: []any{"LEFT", "RIGHT"}}, + "startSide": {Type: "string", Description: "The start side of a multi-line comment (LEFT or RIGHT, optional)", Enum: GranularPullRequestDiffSide("").JSONSchema().Enum}, }, Required: []string{"owner", "repo", "pullNumber", "path", "body", "subjectType"}, }, }, scopes.RequireAll(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 - } - pullNumber, err := RequiredInt(args, "pullNumber") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - path, err := RequiredParam[string](args, "path") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - body, err := RequiredParam[string](args, "body") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - subjectType, err := RequiredParam[string](args, "subjectType") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - line, err := OptionalIntParam(args, "line") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - side, _ := OptionalParam[string](args, "side") - startLine, err := OptionalIntParam(args, "startLine") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - startSide, _ := OptionalParam[string](args, "startSide") + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularPullRequestReviewCommentInput) (*mcp.CallToolResult, *RepositoryMessageOutput, error) { + owner, repo, pullNumber := args.Owner, args.Repo, args.PullNumber gqlClient, err := deps.GetGQLClient(ctx) if err != nil { @@ -633,21 +535,23 @@ func GranularAddPullRequestReviewComment(t translations.TranslationHelperFunc) i // Convert optional int params to *int32 for the helper var linePtr, startLinePtr *int32 - if line != 0 { - l := int32(line) // #nosec G115 + if args.Line != nil && *args.Line != 0 { + l := int32(*args.Line) // #nosec G115 linePtr = &l } - if startLine != 0 { - sl := int32(startLine) // #nosec G115 + if args.StartLine != nil && *args.StartLine != 0 { + sl := int32(*args.StartLine) // #nosec G115 startLinePtr = &sl } // Convert optional string params: pass nil (not empty string) when absent var sidePtr, startSidePtr *string - if side != "" { + if args.Side != nil && *args.Side != "" { + side := string(*args.Side) sidePtr = &side } - if startSide != "" { + if args.StartSide != nil && *args.StartSide != "" { + startSide := string(*args.StartSide) startSidePtr = &startSide } @@ -655,16 +559,17 @@ func GranularAddPullRequestReviewComment(t translations.TranslationHelperFunc) i Owner: owner, Repo: repo, PullNumber: int32(pullNumber), // #nosec G115 - PR numbers are always small positive integers - Path: path, - Body: body, - SubjectType: subjectType, + Path: args.Path, + Body: args.Body, + SubjectType: string(args.SubjectType), Line: linePtr, Side: sidePtr, StartLine: startLinePtr, StartSide: startSidePtr, }) - return result, nil, err + return pullRequestMessageResult(result, err) }, + normalizeGranularPullRequestArguments("comment"), ) st.FeatureRule = pullRequestsGranularFeatureRule return st @@ -701,7 +606,7 @@ func granularResolveReviewThread(t translations.TranslationHelperFunc, withResol } } - st := NewTool( + st := newGranularPullRequestTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "resolve_review_thread", @@ -719,20 +624,11 @@ func granularResolveReviewThread(t translations.TranslationHelperFunc, withResol }, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - threadID, err := RequiredParam[string](args, "threadID") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularResolveReviewThreadInput) (*mcp.CallToolResult, *RepositoryMessageOutput, error) { + threadID := args.ThreadID var resolutionReasonPtr *string if withResolutionReason { - resolutionReason, hasResolutionReason, err := OptionalParamOK[string](args, "resolutionReason") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - if hasResolutionReason { - resolutionReasonPtr = &resolutionReason - } + resolutionReasonPtr = args.ResolutionReason } gqlClient, err := deps.GetGQLClient(ctx) @@ -742,11 +638,12 @@ func granularResolveReviewThread(t translations.TranslationHelperFunc, withResol if !withResolutionReason { result, err := ResolveReviewThread(ctx, gqlClient, threadID, true) - return result, nil, err + return pullRequestMessageResult(result, err) } result, err := ResolveReviewThreadWithReason(ctx, gqlClient, threadID, resolutionReasonPtr, true) - return result, nil, err + return pullRequestMessageResult(result, err) }, + normalizeGranularResolveReviewThreadArguments(withResolutionReason), ) switch { case withResolutionReason: @@ -773,7 +670,7 @@ func granularResolveReviewThread(t translations.TranslationHelperFunc, withResol // GranularUnresolveReviewThread creates a tool to unresolve a review thread. func GranularUnresolveReviewThread(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := newGranularPullRequestTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "unresolve_review_thread", @@ -796,11 +693,8 @@ func GranularUnresolveReviewThread(t translations.TranslationHelperFunc) invento }, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - threadID, err := RequiredParam[string](args, "threadID") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularReviewThreadInput) (*mcp.CallToolResult, *RepositoryMessageOutput, error) { + threadID := args.ThreadID gqlClient, err := deps.GetGQLClient(ctx) if err != nil { @@ -808,8 +702,9 @@ func GranularUnresolveReviewThread(t translations.TranslationHelperFunc) invento } result, err := ResolveReviewThread(ctx, gqlClient, threadID, false) - return result, nil, err + return pullRequestMessageResult(result, err) }, + normalizeGranularPullRequestArguments("unresolve"), ) st.FeatureRule = pullRequestsGranularFeatureRule return st @@ -817,11 +712,12 @@ func GranularUnresolveReviewThread(t translations.TranslationHelperFunc) invento // GranularAddPullRequestReviewCommentReaction adds a reaction to a pull request review comment. func GranularAddPullRequestReviewCommentReaction(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := newGranularPullRequestTool( ToolsetMetadataPullRequests, mcp.Tool{ - Name: "add_pull_request_review_comment_reaction", - Description: t("TOOL_ADD_PULL_REQUEST_REVIEW_COMMENT_REACTION_DESCRIPTION", "Add a reaction to a pull request review comment."), + Name: "add_pull_request_review_comment_reaction", + OutputSchema: minimalPullRequestCommentReactionSchema(), + Description: t("TOOL_ADD_PULL_REQUEST_REVIEW_COMMENT_REACTION_DESCRIPTION", "Add a reaction to a pull request review comment."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ADD_PULL_REQUEST_REVIEW_COMMENT_REACTION_USER_TITLE", "Add Pull Request Review Comment Reaction"), ReadOnlyHint: false, @@ -846,52 +742,41 @@ func GranularAddPullRequestReviewCommentReaction(t translations.TranslationHelpe }, "content": { Type: "string", - Description: "The emoji reaction type", - Enum: []any{"+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"}, + Description: "The emoji reaction type (+1, -1, laugh, confused, heart, hooray, rocket, or eyes)", + Enum: PullRequestCommentReactionType("").JSONSchema().Enum, }, }, Required: []string{"owner", "repo", "comment_id", "content"}, }, }, scopes.RequireAll(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 - } - commentID, err := RequiredBigInt(args, "comment_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - content, err := RequiredParam[string](args, "content") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularAddPullRequestCommentReactionInput) (*mcp.CallToolResult, *MinimalPullRequestCommentReaction, error) { + owner, repo, commentID, content := args.Owner, args.Repo, args.CommentID, args.Content client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } - reaction, resp, err := client.Reactions.CreatePullRequestCommentReaction(ctx, owner, repo, commentID, content) + reaction, resp, err := client.Reactions.CreatePullRequestCommentReaction(ctx, owner, repo, commentID, string(content)) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to add reaction to pull request review comment", resp, err), nil, nil } defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(MinimalResponse{ + output := &MinimalResponse{ ID: fmt.Sprintf("%d", reaction.GetID()), URL: fmt.Sprintf("%srepos/%s/%s/pulls/comments/%d/reactions/%d", client.BaseURL(), owner, repo, commentID, reaction.GetID()), - }) + } + r, err := json.Marshal(output) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &MinimalPullRequestCommentReaction{ + ID: reaction.GetID(), Content: PullRequestCommentReactionType(reaction.GetContent()), + }, nil }, + normalizeGranularPullRequestArguments("add_reaction"), ) st.FeatureRule = pullRequestsGranularFeatureRule return st @@ -899,7 +784,7 @@ func GranularAddPullRequestReviewCommentReaction(t translations.TranslationHelpe // GranularRemovePullRequestReviewCommentReaction removes a reaction from a pull request review comment. func GranularRemovePullRequestReviewCommentReaction(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := newGranularPullRequestTool( ToolsetMetadataPullRequests, mcp.Tool{ Name: "remove_pull_request_review_comment_reaction", @@ -936,23 +821,8 @@ func GranularRemovePullRequestReviewCommentReaction(t translations.TranslationHe }, }, scopes.RequireAll(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 - } - commentID, err := RequiredBigInt(args, "comment_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - reactionID, err := RequiredBigInt(args, "reaction_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args GranularRemovePullRequestCommentReactionInput) (*mcp.CallToolResult, *RepositoryMessageOutput, error) { + owner, repo, commentID, reactionID := args.Owner, args.Repo, args.CommentID, args.ReactionID client, err := deps.GetClient(ctx) if err != nil { @@ -967,8 +837,9 @@ func GranularRemovePullRequestReviewCommentReaction(t translations.TranslationHe return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to remove reaction from pull request review comment", resp, err), nil, nil } - return utils.NewToolResultText("reaction successfully removed from pull request review comment"), nil, nil + return pullRequestMessageResult(utils.NewToolResultText("reaction successfully removed from pull request review comment"), nil) }, + normalizeGranularPullRequestArguments("remove_reaction"), ) st.FeatureRule = pullRequestsGranularFeatureRule return st diff --git a/pkg/github/typed_granular_issue_outputs_test.go b/pkg/github/typed_granular_issue_outputs_test.go index 259cfc32ee..c5bdff08c2 100644 --- a/pkg/github/typed_granular_issue_outputs_test.go +++ b/pkg/github/typed_granular_issue_outputs_test.go @@ -371,6 +371,7 @@ func TestTypedGranularIssueWireErrors(t *testing.T) { {"add_issue_reaction", map[string]any{"content": "HEART"}, "heart"}, {"remove_issue_reaction", map[string]any{"reaction_id": "1.5"}, "non-integer"}, {"add_issue_comment_reaction", map[string]any{"comment_id": 42, "content": "invalid"}, "heart"}, + // Float-to-int overflow diagnostics differ across architectures. {"remove_issue_comment_reaction", map[string]any{"comment_id": "9223372036854775808", "reaction_id": 9}, "comment_id"}, } diff --git a/pkg/github/typed_granular_pull_request_outputs_test.go b/pkg/github/typed_granular_pull_request_outputs_test.go new file mode 100644 index 0000000000..465e7618df --- /dev/null +++ b/pkg/github/typed_granular_pull_request_outputs_test.go @@ -0,0 +1,538 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + "strings" + "testing" + "time" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" + gogithub "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func granularPullRequestTools() []inventory.ServerTool { + t := translations.NullTranslationHelper + return []inventory.ServerTool{ + GranularUpdatePullRequestTitle(t), GranularUpdatePullRequestBody(t), + GranularUpdatePullRequestState(t), GranularUpdatePullRequestDraftState(t), + GranularRequestPullRequestReviewers(t), GranularCreatePullRequestReview(t), + GranularSubmitPendingPullRequestReview(t), GranularDeletePendingPullRequestReview(t), + GranularAddPullRequestReviewComment(t), GranularResolveReviewThread(t), + GranularUnresolveReviewThread(t), GranularAddPullRequestReviewCommentReaction(t), + GranularRemovePullRequestReviewCommentReaction(t), + GranularResolveReviewThreadWithResolutionReason(t), + } +} + +type granularPRCase struct { + name string + args map[string]any + text string + payload string +} + +const granularPRReference = `{"id":"123","url":"https://github.com/owner/repo/pull/1"}` + +func granularPRCases() []granularPRCase { + return []granularPRCase{ + {"update_pull_request_title", map[string]any{"title": "Changed"}, granularPRReference, `{"title":"Changed"}`}, + {"update_pull_request_body", map[string]any{"body": "Body"}, granularPRReference, `{"body":"Body"}`}, + {"update_pull_request_state", map[string]any{"state": "closed"}, granularPRReference, `{"state":"closed"}`}, + {"update_pull_request_state", map[string]any{"state": "other"}, granularPRReference, `{"state":"other"}`}, + {"update_pull_request_draft_state", map[string]any{"draft": true}, "pull request converted to draft", `"pullRequestId":"PR_1"`}, + {"update_pull_request_draft_state", map[string]any{"draft": false}, "pull request marked as ready for review", `"pullRequestId":"PR_1"`}, + {"request_pull_request_reviewers", map[string]any{"reviewers": []string{"octocat", "org/team", "/bad", "org/a/b"}}, granularPRReference, `{"reviewers":["octocat","/bad","org/a/b"],"team_reviewers":["team"]}`}, + {"create_pull_request_review", map[string]any{}, "pending pull request created", `"pullRequestId":"PR_1"`}, + {"create_pull_request_review", map[string]any{"event": "APPROVE", "body": "LGTM", "commitID": "abc"}, "pull request review submitted successfully", `"event":"APPROVE"`}, + {"create_pull_request_review", map[string]any{"event": "COMMENT", "body": "Note"}, "pull request review submitted successfully", `"body":"Note"`}, + {"create_pull_request_review", map[string]any{"event": "REQUEST_CHANGES"}, "pull request review submitted successfully", `"event":"REQUEST_CHANGES"`}, + {"create_pull_request_review", map[string]any{"event": 3, "body": false, "commitID": []any{}}, "pending pull request created", `"pullRequestId":"PR_1"`}, + {"create_pull_request_review", map[string]any{"event": "", "body": "ignored"}, "pending pull request created", `"pullRequestId":"PR_1"`}, + {"submit_pending_pull_request_review", map[string]any{"event": "APPROVE", "body": "LGTM"}, "pending pull request review successfully submitted", `"pullRequestReviewId":"R_1"`}, + {"submit_pending_pull_request_review", map[string]any{"event": "COMMENT", "body": false}, "pending pull request review successfully submitted", `"body":""`}, + {"submit_pending_pull_request_review", map[string]any{"event": "REQUEST_CHANGES"}, "pending pull request review successfully submitted", `"event":"REQUEST_CHANGES"`}, + {"delete_pending_pull_request_review", map[string]any{}, "pending pull request review successfully deleted", `"pullRequestReviewId":"R_1"`}, + {"add_pull_request_review_comment", map[string]any{"path": "file", "body": "nit", "subjectType": "LINE", "line": "3e0", "side": "RIGHT", "startLine": "2.0", "startSide": "LEFT"}, "pull request review comment successfully added to pending review", `"line":3`}, + {"add_pull_request_review_comment", map[string]any{"path": "file", "body": "nit", "subjectType": "FILE", "line": 0, "startLine": 0, "side": 3, "startSide": false}, "pull request review comment successfully added to pending review", `"subjectType":"FILE"`}, + {"resolve_review_thread", map[string]any{"threadID": "T_1"}, "review thread resolved successfully", `"threadId":"T_1"`}, + {"resolve_review_thread", map[string]any{"threadID": "T_1", "resolutionReason": ""}, "review thread resolved successfully", `"threadId":"T_1"`}, + {"unresolve_review_thread", map[string]any{"threadID": "T_1"}, "review thread unresolved successfully", `"threadId":"T_1"`}, + {"add_pull_request_review_comment_reaction", map[string]any{"comment_id": "42.0", "content": "heart"}, `{"id":"77","url":"https://api.github.com/repos/owner/repo/pulls/comments/42/reactions/77"}`, `{"content":"heart"}`}, + {"remove_pull_request_review_comment_reaction", map[string]any{"comment_id": "42", "reaction_id": "77e0"}, "reaction successfully removed from pull request review comment", ""}, + } +} + +// These expectations were run against the untyped handlers at the exact +// parent commit before migrating them. Keep equality, not substring checks. +func granularPRValidationCases() []granularPRCase { + return []granularPRCase{ + {"update_pull_request_title", map[string]any{"title": ""}, "missing required parameter: title", ""}, + {"update_pull_request_body", map[string]any{"body": nil}, "parameter body is not of type string", ""}, + {"update_pull_request_state", map[string]any{}, "missing required parameter: state", ""}, + {"update_pull_request_draft_state", map[string]any{}, "missing required parameter: draft", ""}, + {"update_pull_request_draft_state", map[string]any{"draft": nil}, "parameter draft is not of type bool, is ", ""}, + {"request_pull_request_reviewers", map[string]any{"reviewers": nil}, "missing required parameter: reviewers", ""}, + {"request_pull_request_reviewers", map[string]any{"reviewers": []any{"octocat", false}}, "parameter reviewers is not of type string, is bool", ""}, + {"create_pull_request_review", map[string]any{"pullNumber": 1.5}, "parameter pullNumber is not a valid number: non-integer numeric value: 1.5", ""}, + {"submit_pending_pull_request_review", map[string]any{"event": ""}, "missing required parameter: event", ""}, + {"submit_pending_pull_request_review", map[string]any{"event": false}, "parameter event is not of type string", ""}, + {"delete_pending_pull_request_review", map[string]any{"owner": ""}, "missing required parameter: owner", ""}, + {"add_pull_request_review_comment", map[string]any{"path": "file", "body": "", "subjectType": false}, "missing required parameter: body", ""}, + {"add_pull_request_review_comment", map[string]any{"path": "file", "body": "nit", "subjectType": "LINE", "line": false, "startLine": 1.5}, "parameter line is not a valid number: expected number, got bool", ""}, + {"add_pull_request_review_comment", map[string]any{"path": "file", "body": "nit", "subjectType": "LINE", "startLine": nil}, "parameter startLine is not a valid number: expected number, got ", ""}, + {"resolve_review_thread", map[string]any{"threadID": ""}, "missing required parameter: threadID", ""}, + {"unresolve_review_thread", map[string]any{"threadID": 3}, "parameter threadID is not of type string", ""}, + {"add_pull_request_review_comment_reaction", map[string]any{"comment_id": 0, "content": nil}, "missing required parameter: comment_id", ""}, + {"add_pull_request_review_comment_reaction", map[string]any{"comment_id": "42", "content": false}, "parameter content is not of type string", ""}, + {"remove_pull_request_review_comment_reaction", map[string]any{"comment_id": "42", "reaction_id": "bad"}, "parameter reaction_id is not a valid number: invalid numeric value: bad", ""}, + } +} + +func granularPRArgs(tc granularPRCase) map[string]any { + args := map[string]any{"owner": "owner", "repo": "repo", "pullNumber": "1.0"} + maps.Copy(args, tc.args) + return args +} + +func granularPRDeps(t *testing.T, current **granularPRCase, outcome string) BaseDeps { + t.Helper() + rest := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if outcome == "error" { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"Forbidden"}`)) + return + } + if current != nil && *current != nil && (*current).payload != "" { + var payload any + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + assert.JSONEq(t, (*current).payload, mustMarshalJSON(t, payload)) + } + path := r.URL.Path + switch { + case r.Method == http.MethodDelete: + w.WriteHeader(http.StatusNoContent) + case outcome == "empty": + _, _ = w.Write([]byte(`null`)) + case strings.Contains(path, "/reactions"): + _, _ = w.Write([]byte(`{"id":77,"content":"heart"}`)) + default: + _, _ = w.Write([]byte(`{"id":123,"html_url":"https://github.com/owner/repo/pull/1"}`)) + } + })}} + gql := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Query string `json:"query"` + Variables json.RawMessage `json:"variables"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + if outcome == "error" || outcome == "mutation_error" && strings.HasPrefix(req.Query, "mutation") { + _, _ = w.Write([]byte(`{"errors":[{"message":"Forbidden"}]}`)) + return + } + if strings.HasPrefix(req.Query, "mutation") && current != nil && *current != nil { + assert.Contains(t, string(req.Variables), (*current).payload) + } + var body string + switch q := req.Query; { + case strings.Contains(q, "convertPullRequestToDraft("): + body = `{"data":{"convertPullRequestToDraft":{"pullRequest":{"id":"PR_1","isDraft":true}}}}` + case strings.Contains(q, "markPullRequestReadyForReview("): + body = `{"data":{"markPullRequestReadyForReview":{"pullRequest":{"id":"PR_1","isDraft":false}}}}` + case strings.Contains(q, "addPullRequestReviewThread("): + body = `{"data":{"addPullRequestReviewThread":{"thread":{"id":"T_2"}}}}` + case strings.Contains(q, "addPullRequestReview("): + body = `{"data":{"addPullRequestReview":{"pullRequestReview":{"id":"R_1"}}}}` + case strings.Contains(q, "submitPullRequestReview("): + body = `{"data":{"submitPullRequestReview":{"pullRequestReview":{"id":"R_1"}}}}` + case strings.Contains(q, "deletePullRequestReview("): + body = `{"data":{"deletePullRequestReview":{"pullRequestReview":{"id":"R_1"}}}}` + case strings.Contains(q, "unresolveReviewThread("): + body = `{"data":{"unresolveReviewThread":{"thread":{"id":"T_1","isResolved":false}}}}` + case strings.Contains(q, "resolveReviewThread("): + body = `{"data":{"resolveReviewThread":{"thread":{"id":"T_1","isResolved":true}}}}` + case strings.Contains(q, "reviews(first: 100"): + body = `{"data":{"repository":{"pullRequest":{"reviews":{"nodes":[{"id":"R_1","author":{"userId":"U_1"}}],"pageInfo":{"hasNextPage":false,"endCursor":""}}}}}}` + if outcome == "no_pending" { + body = `{"data":{"repository":{"pullRequest":{"reviews":{"nodes":[],"pageInfo":{"hasNextPage":false,"endCursor":""}}}}}}` + } + case strings.Contains(q, "viewer{"): + body = `{"data":{"viewer":{"id":"U_1"}}}` + default: + require.Contains(t, q, "pullRequest(number:") + body = `{"data":{"repository":{"pullRequest":{"id":"PR_1"}}}}` + } + if outcome == "empty" { + body = `{"data":{}}` + } + _, _ = w.Write([]byte(body)) + })}} + return BaseDeps{ + Client: mustNewGHClient(t, rest), GQLClient: githubv4.NewClient(gql), + RepoAccessCache: stubRepoAccessCache(nil, time.Minute), + } +} + +func granularPRSession(t *testing.T, deps ToolDependencies, protocol string, reason bool) (*mcp.ClientSession, map[string]*jsonschema.Resolved) { + t.Helper() + tools := granularPullRequestTools() + flags := []inventory.FeatureFlag{inventory.FeatureFlag(FeatureFlagPullRequestsGranular)} + if reason { + flags = append(flags, FeatureFlagThreadResolutionReason) + } + inv, err := inventory.NewBuilder().SetTools(tools).WithToolsets([]string{"all"}). + WithFeatureChecker(featureCheckerFor(flags...)).Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "granular-pr", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + if protocol == "" { + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + switch req := req.(type) { + case *mcp.ListToolsRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + case *mcp.CallToolRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: ""} + } + return next(ctx, method, req) + } + }) + } + version := protocol + if version == "" { + version = inventory.ProtocolVersionMultiRoundTrip + } + session := connectCommentVisibilityClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 13) + schemas := make(map[string]*jsonschema.Resolved) + for _, tool := range list.Tools { + name := tool.Name + if name == "resolve_review_thread" && reason { + name += "_resolution_reason" + } + // Resolve the advertised input schema as well as every modern output. + var inputSchema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, tool.InputSchema)), &inputSchema)) + _, err := inputSchema.Resolve(nil) + require.NoError(t, err, name) + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Nil(t, tool.OutputSchema, name) + continue + } + require.NotNil(t, tool.OutputSchema, name) + require.NoError(t, toolsnaps.Test(name, *tool)) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, tool.OutputSchema)), &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err, name) + schemas[tool.Name] = resolved + if tool.Name == "add_pull_request_review_comment_reaction" { + require.NoError(t, resolved.Validate(map[string]any{"id": float64(77), "content": "heart"})) + require.Error(t, resolved.Validate(map[string]any{"id": float64(77), "url": "https://api.github.com/reaction"})) + } + } + return session, schemas +} + +func TestGranularPullRequestCanonicalSnapshots(t *testing.T) { + for _, protocol := range typedPullRequestProtocols { + for _, reason := range []bool{false, true} { + granularPRSession(t, BaseDeps{}, protocol, reason) + } + } +} + +func assertGranularPRResult(t *testing.T, result *mcp.CallToolResult, schema *jsonschema.Resolved, text string, isError bool) { + t.Helper() + require.Equal(t, isError, result.IsError, mustMarshalJSON(t, result)) + require.Len(t, result.Content, 1) + wireText := result.Content[0].(*mcp.TextContent).Text + if schema == nil || isError { + require.Equal(t, text, wireText) + require.Nil(t, result.StructuredContent) + return + } + require.NotNil(t, result.StructuredContent) + var output any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, schema.Validate(output)) + switch { + case strings.Contains(text, "/reactions/"): + var reference MinimalResponse + require.NoError(t, json.Unmarshal([]byte(text), &reference)) + var reaction MinimalPullRequestCommentReaction + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, output)), &reaction)) + assert.Equal(t, reference.ID, fmt.Sprint(reaction.ID)) + if reaction.ID != 0 { + assert.Equal(t, PullRequestCommentReactionType("heart"), reaction.Content) + } + assert.NotContains(t, mustMarshalJSON(t, output), "url") + assert.JSONEq(t, mustMarshalJSON(t, output), wireText) + case strings.HasPrefix(text, "{"): + assert.JSONEq(t, mustMarshalJSON(t, output), wireText) + assert.JSONEq(t, text, mustMarshalJSON(t, output)) + default: + assert.JSONEq(t, mustMarshalJSON(t, RepositoryMessageOutput{Message: text}), mustMarshalJSON(t, output)) + assert.JSONEq(t, mustMarshalJSON(t, output), wireText) + } + for _, raw := range []string{`{}`, `[]`, `{"id":false}`, `{"message":1}`} { + var invalid any + require.NoError(t, json.Unmarshal([]byte(raw), &invalid)) + require.Error(t, schema.Validate(invalid), raw) + } +} + +func TestTypedGranularPullRequestWireOutputs(t *testing.T) { + for _, protocol := range typedPullRequestProtocols { + for _, reason := range []bool{false, true} { + t.Run("protocol="+protocol+"/reason="+mustMarshalJSON(t, reason), func(t *testing.T) { + var current *granularPRCase + deps := granularPRDeps(t, ¤t, "") + session, schemas := granularPRSession(t, deps, protocol, reason) + for _, tc := range granularPRCases() { + t.Run(tc.name+"/"+tc.text+"/"+mustMarshalJSON(t, tc.args), func(t *testing.T) { + current = &tc + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: granularPRArgs(tc)}) + require.NoError(t, err) + assertGranularPRResult(t, result, schemas[tc.name], tc.text, false) + }) + } + for _, value := range []any{"addressed", "wont-fix", "invalid", "", nil, false} { + tc := granularPRCase{name: "resolve_review_thread", args: map[string]any{"threadID": "T_1", "resolutionReason": value}, text: "review thread resolved successfully"} + current = &tc + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: tc.args}) + require.NoError(t, err) + if reason && (value == nil || value == false) { + typeName := "" + if value == false { + typeName = "bool" + } + assertGranularPRResult(t, result, schemas[tc.name], "parameter resolutionReason is not of type string, is "+typeName, true) + } else { + assertGranularPRResult(t, result, schemas[tc.name], tc.text, false) + } + } + // Legacy handlers sent negative numbers and non-enum strings to GitHub. + for _, tc := range []granularPRCase{ + {"update_pull_request_title", map[string]any{"pullNumber": -1, "title": "Title"}, granularPRReference, `{"title":"Title"}`}, + {"create_pull_request_review", map[string]any{"event": "other"}, "pull request review submitted successfully", `"event":"other"`}, + {"add_pull_request_review_comment", map[string]any{"path": "f", "body": "b", "subjectType": "other", "side": "other", "line": -1}, "pull request review comment successfully added to pending review", `"line":-1`}, + } { + current = &tc + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: granularPRArgs(tc)}) + require.NoError(t, err) + assertGranularPRResult(t, result, schemas[tc.name], tc.text, false) + } + }) + } + } +} + +func TestTypedGranularPullRequestErrorsAndEmptyResults(t *testing.T) { + for _, protocol := range typedPullRequestProtocols { + for _, reason := range []bool{false, true} { + t.Run("protocol="+protocol+"/reason="+mustMarshalJSON(t, reason), func(t *testing.T) { + session, schemas := granularPRSession(t, granularPRDeps(t, nil, "error"), protocol, reason) + for _, tc := range granularPRValidationCases() { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: granularPRArgs(tc)}) + require.NoError(t, err) + assertGranularPRResult(t, result, schemas[tc.name], tc.text, true) + } + for _, tc := range granularPRCases() { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: granularPRArgs(tc)}) + require.NoError(t, err) + require.True(t, result.IsError, tc.name) + require.Len(t, result.Content, 1) + assert.Contains(t, result.Content[0].(*mcp.TextContent).Text, "Forbidden") + assert.Nil(t, result.StructuredContent) + } + session, _ = granularPRSession(t, granularPRDeps(t, nil, "mutation_error"), protocol, reason) + for _, tc := range granularPRCases() { + if strings.HasPrefix(tc.text, "{") || tc.name == "remove_pull_request_review_comment_reaction" { + continue + } + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: granularPRArgs(tc)}) + require.NoError(t, err) + require.True(t, result.IsError, tc.name) + require.Len(t, result.Content, 1) + assert.Contains(t, result.Content[0].(*mcp.TextContent).Text, "Forbidden") + assert.Nil(t, result.StructuredContent) + } + session, schemas = granularPRSession(t, granularPRDeps(t, nil, "empty"), protocol, reason) + for _, tc := range granularPRCases() { + text := tc.text + isError := false + switch tc.name { + case "update_pull_request_title", "update_pull_request_body", "update_pull_request_state", "request_pull_request_reviewers": + text = `{"id":"0","url":""}` + case "add_pull_request_review_comment_reaction": + text = `{"id":"0","url":"https://api.github.com/repos/owner/repo/pulls/comments/42/reactions/0"}` + case "submit_pending_pull_request_review", "delete_pending_pull_request_review", "add_pull_request_review_comment": + text, isError = "No pending review found for the viewer", true + } + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: granularPRArgs(tc)}) + require.NoError(t, err) + assertGranularPRResult(t, result, schemas[tc.name], text, isError) + } + }) + } + } +} + +func TestGranularPullRequestFeatureVariants(t *testing.T) { + for _, tool := range granularPullRequestTools() { + assert.False(t, tool.IsReadOnly(), tool.Tool.Name) + assert.Equal(t, []string{"repo"}, tool.ScopeAccess.Scopes, tool.Tool.Name) + assert.False(t, tool.FeatureRule.Enabled(func(inventory.FeatureFlag) bool { return false }), tool.Tool.Name) + } + variant := GranularResolveReviewThreadWithResolutionReason(translations.NullTranslationHelper, WithHost(utils.HostTypeGHES)) + require.NotNil(t, variant.Enabled) + enabled, err := variant.Enabled(context.Background()) + require.NoError(t, err) + assert.False(t, enabled) +} + +func TestGranularPullRequestAdvertisedInputConstraints(t *testing.T) { + enumFields := map[string]map[string][]any{ + "update_pull_request_state": {"state": {"open", "closed"}}, + "create_pull_request_review": {"event": {"APPROVE", "REQUEST_CHANGES", "COMMENT"}}, + "submit_pending_pull_request_review": {"event": {"APPROVE", "REQUEST_CHANGES", "COMMENT"}}, + "add_pull_request_review_comment": { + "subjectType": {"FILE", "LINE"}, "side": {"LEFT", "RIGHT"}, "startSide": {"LEFT", "RIGHT"}, + }, + "add_pull_request_review_comment_reaction": {"content": {"+1", "-1", "laugh", "confused", "heart", "hooray", "rocket", "eyes"}}, + } + for _, tool := range granularPullRequestTools() { + schema, ok := tool.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, tool.Tool.Name) + validation := granularPullRequestValidationSchema(schema) + for _, field := range []string{"pullNumber", "comment_id", "reaction_id"} { + if property := schema.Properties[field]; property != nil { + require.NotNil(t, property.Minimum, tool.Tool.Name+"/"+field) + assert.Equal(t, 1.0, *property.Minimum, tool.Tool.Name+"/"+field) + assert.Nil(t, validation.Properties[field].Minimum) + } + } + for field, values := range enumFields[tool.Tool.Name] { + require.NotNil(t, schema.Properties[field], tool.Tool.Name+"/"+field) + assert.ElementsMatch(t, values, schema.Properties[field].Enum, tool.Tool.Name+"/"+field) + assert.Empty(t, validation.Properties[field].Enum) + } + assert.ElementsMatch(t, schema.Required, validation.Required) + } +} + +func TestGranularPullRequestReactionSchema(t *testing.T) { + schema, err := minimalPullRequestCommentReactionSchema().Resolve(nil) + require.NoError(t, err) + for _, content := range PullRequestCommentReactionType("").Values() { + require.NoError(t, schema.Validate(map[string]any{"id": float64(77), "content": content})) + } + require.NoError(t, schema.Validate(map[string]any{"id": float64(0)})) + for _, raw := range []string{ + `null`, `[]`, `{}`, `{"id":"77"}`, `{"id":77,"content":"other"}`, + `{"id":77,"content":null}`, `{"id":77,"url":"https://api.github.com/reaction"}`, + } { + var output any + require.NoError(t, json.Unmarshal([]byte(raw), &output)) + require.Error(t, schema.Validate(output), raw) + } +} + +func TestGranularPullRequestParentBehavior(t *testing.T) { + tools := granularPullRequestTools() + byName := make(map[string]inventory.ServerTool) + for _, tool := range tools[:len(tools)-1] { + byName[tool.Tool.Name] = tool + } + var current *granularPRCase + deps := granularPRDeps(t, ¤t, "") + ctx := ContextWithDeps(context.Background(), deps) + for _, tc := range append(granularPRCases(), granularPRValidationCases()...) { + t.Run(tc.name+"/"+tc.text, func(t *testing.T) { + current = &tc + tool := byName[tc.name] + result, err := tool.Handler(deps)(ctx, &mcp.CallToolRequest{Params: &mcp.CallToolParamsRaw{ + Name: tc.name, Arguments: json.RawMessage(mustMarshalJSON(t, granularPRArgs(tc))), + }}) + require.NoError(t, err) + require.Len(t, result.Content, 1) + assert.Equal(t, tc.text, result.Content[0].(*mcp.TextContent).Text) + }) + } +} + +type granularPRClientErrorDeps struct{ BaseDeps } + +func (granularPRClientErrorDeps) GetClient(context.Context) (*gogithub.Client, error) { + return nil, fmt.Errorf("client unavailable") +} + +func (granularPRClientErrorDeps) GetGQLClient(context.Context) (*githubv4.Client, error) { + return nil, fmt.Errorf("client unavailable") +} + +func TestTypedGranularPullRequestClientErrors(t *testing.T) { + for _, protocol := range typedPullRequestProtocols { + for _, reason := range []bool{false, true} { + session, schemas := granularPRSession(t, granularPRClientErrorDeps{}, protocol, reason) + for _, tc := range granularPRCases() { + message := "failed to get GitHub GraphQL client: client unavailable" + if strings.HasPrefix(tc.text, "{") || tc.name == "remove_pull_request_review_comment_reaction" { + message = "failed to get GitHub client: client unavailable" + } + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: granularPRArgs(tc)}) + require.NoError(t, err) + assertGranularPRResult(t, result, schemas[tc.name], message, true) + } + } + } +} + +func TestTypedGranularPullRequestResolutionReasonPayload(t *testing.T) { + for _, protocol := range typedPullRequestProtocols { + for _, reason := range []bool{false, true} { + for _, value := range []string{"addressed", "wont-fix", "invalid", ""} { + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + Variables struct { + Input struct { + ThreadID string `json:"threadId"` + ResolutionReason *string `json:"resolutionReason"` + } `json:"input"` + } `json:"variables"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&request)) + assert.Equal(t, "T_1", request.Variables.Input.ThreadID) + if reason { + require.NotNil(t, request.Variables.Input.ResolutionReason) + assert.Equal(t, value, *request.Variables.Input.ResolutionReason) + } else { + assert.Nil(t, request.Variables.Input.ResolutionReason) + } + _, _ = w.Write([]byte(`{"data":{"resolveReviewThread":{"thread":{"id":"T_1","isResolved":true}}}}`)) + })}} + session, schemas := granularPRSession(t, BaseDeps{GQLClient: githubv4.NewClient(client)}, protocol, reason) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "resolve_review_thread", Arguments: map[string]any{"threadID": "T_1", "resolutionReason": value}, + }) + require.NoError(t, err) + assertGranularPRResult(t, result, schemas["resolve_review_thread"], "review thread resolved successfully", false) + } + } + } +} diff --git a/pkg/inventory/input_error_test.go b/pkg/inventory/input_error_test.go new file mode 100644 index 0000000000..3300fb89ae --- /dev/null +++ b/pkg/inventory/input_error_test.go @@ -0,0 +1,19 @@ +package inventory + +import ( + "fmt" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" +) + +func TestToolInputErrorPreservesText(t *testing.T) { + result := invalidArgumentsResult(fmt.Errorf("normalize tool arguments: %w", &ToolInputError{Message: "missing required parameter: title"})) + assert.True(t, result.IsError) + assert.Equal(t, "missing required parameter: title", result.Content[0].(*mcp.TextContent).Text) + assert.Nil(t, result.StructuredContent) + + result = invalidArgumentsResult(fmt.Errorf("ordinary error")) + assert.Equal(t, "invalid arguments: ordinary error", result.Content[0].(*mcp.TextContent).Text) +} From 2d5ad6e76b30a7d754d698554c2fa3d87ca47f0e Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 7 Oct 2026 00:34:58 +0200 Subject: [PATCH 57/61] refactor(actions): type consolidated MCP tools (#3398) * feat(inventory): add typed MCP tool registration foundation Use the MCP SDK's generic registration for concrete input/output types, with protocol-gated output schemas and structured results. Keep raw handlers compatible and apply shared middleware on both paths. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * test: cover legacy typed CSV results Exercise the typed array and object paths through an initialized 2025-11-25 MCP session. Verify CSV text is returned once without structured output or the SDK JSON fallback. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(inventory): normalize legacy typed arguments Add a raw JSON input normalizer that runs before SDK schema validation so migrated tools can retain legacy input coercions without widening their published schemas. Test case-folded issue states, numeric string IDs, validation failures and replacement registrations. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * test(inventory): preserve typed input optionality Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(inventory): stabilize typed schema compatibility Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(inventory): cover protocol isolation across clients Exercise simultaneous legacy and modern sessions against one typed tool, plus stateless HTTP requests with modern, legacy, and absent protocol headers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(inventory): harden typed schema registration contracts Cache immutable schema variants and enum overrides, preserve runtime input compatibility and request-era output behavior, and normalize dynamic scope challenges before dispatch. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(inventory): cache warm schemas and guard typed preflight Avoid repeated canonical schema serialization and header annotation for cache-owned immutable pointers while retaining content-based lookup for caller-owned schemas. Run typed authorization and availability middleware before preflight and preserve tool-result error semantics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(inventory): preserve schema metadata isolation Advertise the SDK-compatible object schema for typed any input and deep-clone mutable JSON Schema metadata before caching or deriving runtime variants. Add legacy/modern registration and mutation isolation coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(inventory): detach numeric schema bounds and dedupe default traversal Two Copilot findings on 431b7a21: - CloneSchemas only deep-copies subschema nodes, so pointer-valued numeric validation keywords (Minimum, Maximum, MinLength, MinItems, etc.) remained aliased with the caller-owned schema after CloneSchema/CachedSchema. A caller mutating one of those pointers after caching could race with concurrent cached-schema serialization or silently change the cached validation bounds. Clone every *int/*float64 keyword explicitly alongside the existing collection/metadata cloning. - CloneSchemaWithoutDefaults's removeSchemaDefaults visited every map/pointer child twice: once via two literal child-collection loops, and again via schemaChildren, which already covers the same fields. This doubled traversal at every nested level, making default removal exponential for deeply nested object/array schemas. Traverse only the single comprehensive schemaChildren list. Add regression coverage across CloneSchema, CachedSchema, schema inference option cloning, and CloneSchemaWithoutDefaults: every *int/*float64 keyword is isolated from caller mutation (including under concurrent marshaling), and a 48-level deep chain is used for every schema-child field kind to confirm linear, non-exponential default removal. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(inventory): gate typed output on supported protocol range Select modern typed output for SDK-supported ISO-date protocol versions from 2026-07-28 onward. Missing, malformed, and unsupported future versions retain legacy behavior. Cover a later supported version through the selector list seam and verify the public path against the SDK supported set. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(inventory): unwrap pointer inputs before schema inference Match SDK input inference by unwrapping one pointer level before the cached input key and inference. Preserve nullable output pointer schemas. Cover both public constructors with actual modern and legacy discovery and calls, including structured null output. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(inventory): reuse encoded schema cache for typed tools Key output encodings by their source definitions while preserving the SDK-registered schema guard. Keep header routing metadata available for legacy and unknown protocol clients. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(context): add typed tool schemas and outputs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(context): preserve legacy input acceptance Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(context): preserve input schema roots and optionality Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(context): validate direct team member calls Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(context): pin structured output and legacy text fixtures Describe profile timestamps and validate null and empty team outputs against modern schemas while retaining exact legacy text. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(context): emit structured empty team collections Keep legacy null text while returning nonnil typed slices so modern clients receive structured content for empty successful results. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(context): preserve legacy team input schemas Keep empty identifier rejection in the handler without tightening the published input schema. Pin modern text to structured output and preserve the legacy formatter fixtures. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(context): preserve advertised input schema compatibility Keep legacy-visible schemas byte-compatible while applying unknown-field acceptance only to the cached runtime validation schema. Verify modern DTO text parity and preserve legacy formatted text. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(context): reject explicit null team user arguments Preserve the legacy optional user type error before typed decoding, without changing omitted or empty user fallback. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(context): preserve exact routing argument keys Ignore case-variant routing aliases before typed decoding while retaining canonical fields and unrelated unknown arguments. Cover direct handlers and legacy/modern registered calls, authenticated fallback, canonical null rejection, and missing identifiers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(comments): add typed visibility tool inputs and outputs Preserve legacy argument normalization and text responses while exposing protocol-gated structured results for six hide/unhide tools. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * chore: regenerate license files Auto-generated by license-check workflow * test(comments): pin protocol output parity and reason metadata Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(comments): reuse cached visibility output schema Clarify supported negotiated protocol versions without expanding runtime support. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(search): add typed projected result outputs * fix(search): preserve field_values presence and add nested schema fixtures Address review findings on the typed search/list projected-output tools (search_code, search_issues, search_pull_requests, list_issues, list_pull_requests, list_branches, list_tags): - issues.go/pullrequests.go: change FieldValues to a pointer-to-slice (*[]MinimalFieldValue) so a successful-but-empty [] is preserved in structuredContent, matching legacy text output instead of being dropped by omitempty. - search.go: related adjustments to keep typed search outputs aligned with the field_values presence fix. - typed_search_outputs_test.go: add representative nested/non-empty projected item fixtures so schema-conformance tests actually exercise item shapes instead of only validating empty collections. - Add/update toolsnaps for list_branches, list_issues, list_pull_requests, list_tags, search_code, search_issues, and search_pull_requests to reflect the schema. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test: bound encoded schema parity registration work Reuse each immutable HTTP server across pagination requests and share the SDK schema cache across parity variants without changing wire comparisons. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(security): add typed alert outputs Add typed protocol-gated outputs for code quality, code and secret scanning, Dependabot, and security advisories while preserving existing text responses. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(security): preserve compatibility with compact typed outputs Keep advertised input defaults while disabling runtime default injection for security filters. Normalize null CWE filters, compact findings, and verify modern wire outputs alongside exact legacy formatting. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat: type discussion and notification tool outputs Migrate all eleven tools to typed registration and compact output DTOs. Preserve legacy text, parameter compatibility, pagination, and mutation validation while publishing honest modern output unions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix: tighten discussion and notification output contracts Trim notification hypermedia, preserve useful HTML links, and record all modern schemas in canonical snapshots. Preserve source presence during discussion read normalization and cover the Copilot review regression alongside empty/null outputs, IFC labels, sanitization, and output enums. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test: cover empty-object discussion read requests and modern-text==DTO expectations Add an explicit {} (fully-empty args) case to TestDiscussionReadRequiredArguments proving the normalizer never synthesizes placeholder owner/repo/discussionNumber values that would let an empty request satisfy SDK required-schema validation and reach GraphQL; verified both protocols reject it pre-handler with zero GraphQL calls (addresses readiness-audit re-flag of already-fixed Copilot review comment 4171698931 in 578e9929). Also record the pending modern-text==structuredContent DTO contract (2026-07-28 protocol) in test expectations; these assertions are intentionally red pending the coordinator's central once-marshal wrapper in pkg/inventory/typed_output.go, which this branch does not yet have. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(notifications): preserve plain status output content Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(repos): add typed commit tool outputs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(repos): restore commit pagination defaults Normalize omitted and explicit zero pagination to page 1 and perPage 30 for get_commit and list_commits. Cover legacy and modern wire calls and direct handlers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(repos): return typed projected commit lists Use an optional-field commit DTO for full and projected modern outputs while retaining exact legacy text. Cover SHA and nested commit projections, empty lists, and output schema conformance. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * test(repos): snapshot modern commit tools and strengthen wire coverage Capture canonical modern schemas, exercise null lists and error suppression with IFC labels, and drop unrelated generated Linux license drift noted by Copilot review. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(repos): distinguish legacy and modern commit text Keep exact-main byte assertions for the legacy protocol and verify modern text represents the same typed DTO as structured content. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(repos): sync commit snapshots with parent DTO metadata Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(issues): add typed metadata comment and dependency outputs Preserve legacy text and argument coercions while exposing protocol-gated structured issue outputs. Add modern, legacy, unknown-version, union-schema and API-error contracts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * chore: regenerate license files Auto-generated by license-check workflow * fix(issues): preserve zero pagination for duplicate search Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(issues): preserve compact typed output contracts Cache DTO schema inference, retain byte-exact handler text, and cover protocol, pagination and schema contracts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(issues): bound encoded schema parity registration work Reuse immutable HTTP comparison servers and the SDK schema cache across the full parity matrix. Refresh inherited user timestamp descriptions in issue snapshots. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(repos): complete typed repository tool outputs Migrate the remaining 17 repository tools to concrete inputs and outputs. Preserve existing content, mutations, scopes, filtering, and protocol gating; add wire and schema conformance coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore(repos): exclude unrelated snapshot formatting drift Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(repos): compact typed repository outputs Replace raw REST output schemas with compact repository DTOs, retain one canonical snapshot per tool, and align modern JSON text with structured output while pinning legacy content. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(issues): add typed consolidated issue tool contracts Preserve raw helper APIs and exact legacy text while exposing concrete output unions for modern protocols. Retain explicit awaiting statuses and real null responses without exposing SDK-generated error fallbacks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * refactor(issues): minimize consolidated typed outputs Project compact method-discriminated DTOs from API responses while preserving the separate legacy text formatter and explicit app-awaiting status. Cache strict schemas and keep only canonical snapshots. Strengthen null-member presence, schema size, and lockdown projection regressions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(issues): keep union property schemas portable Preserve forbidden variant properties as annotated schema objects rather than boolean schemas emitted by jsonschema-go. Cover Inspector's input paths and IFC labels on every issue-read method across protocols. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(issues): cover strict sub-issue output variants Reject unknown methods, states, field types, opaque values and API URL properties while covering nullable and empty method data. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(issues): pin compact comment user redaction Pin unchanged legacy profile/avatar fields separately from compact modern user projections and assert identical lockdown filtering and sanitization across protocols. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(issues): restore main-identical advertised input schema Remove added custom-field union constraints and sentinel enums from the advertised issue-write input. Preserve runtime exactly-one validation and private strict-schema tests. All three scoped input schemas compare exactly equal to main. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(issues): restore protocol output parity Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * refactor(issues): minimize granular structured outputs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> # Conflicts: # pkg/github/__toolsnaps__/add_sub_issue.snap # pkg/github/__toolsnaps__/remove_sub_issue.snap # pkg/github/__toolsnaps__/reprioritize_sub_issue.snap # pkg/github/granular_issue_types.go # pkg/github/issues_granular.go # pkg/github/typed_granular_issue_outputs_test.go # Please enter the commit message for your changes. Lines starting # with '#' will be ignored, and an empty message aborts the commit. # # interactive rebase in progress; onto 1973eeea # Last command done (1 command done): # pick 5b8f9d19 # refactor(issues): minimize granular structured outputs # No commands remaining. # You are currently rebasing. # # Changes to be committed: # modified: pkg/github/__toolsnaps__/add_issue_comment_reaction.snap # modified: pkg/github/__toolsnaps__/add_issue_reaction.snap # modified: pkg/github/__toolsnaps__/add_sub_issue.snap # modified: pkg/github/__toolsnaps__/create_issue.snap # modified: pkg/github/__toolsnaps__/hide_issue_comment.snap # modified: pkg/github/__toolsnaps__/remove_issue_comment_reaction.snap # modified: pkg/github/__toolsnaps__/remove_issue_reaction.snap # modified: pkg/github/__toolsnaps__/remove_sub_issue.snap # modified: pkg/github/__toolsnaps__/reprioritize_sub_issue.snap # modified: pkg/github/__toolsnaps__/set_issue_fields.snap # modified: pkg/github/__toolsnaps__/unhide_issue_comment.snap # modified: pkg/github/__toolsnaps__/update_issue_assignees.snap # modified: pkg/github/__toolsnaps__/update_issue_body.snap # modified: pkg/github/__toolsnaps__/update_issue_labels.snap # modified: pkg/github/__toolsnaps__/update_issue_milestone.snap # modified: pkg/github/__toolsnaps__/update_issue_state.snap # modified: pkg/github/__toolsnaps__/update_issue_title.snap # modified: pkg/github/__toolsnaps__/update_issue_type.snap # modified: pkg/github/comment_minimize_test.go # new file: pkg/github/granular_issue_types.go # modified: pkg/github/granular_tools_test.go # modified: pkg/github/issues_granular.go # new file: pkg/github/typed_granular_issue_outputs_test.go # * refactor(search): use typed account repository and commit tools Preserve legacy text and search semantics while publishing protocol-gated concrete structured outputs. Cover modern, legacy, and unknown clients, full repository JSON unions, defaults, IFC labels, and errors. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(search): compact typed repository search output Replace the full REST repository mirror with MinimalRepository-based structured items plus purpose-built full-mode details. Legacy text remains unchanged, and canonical snapshots now hold the modern output schema. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(search): expose repository visibility enum Remove the derivable node_id from compact full repository search output and publish the visibility enum in the static output schema. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(search): align snapshots with protocol wrapper Keep the modern text assertion aligned with structured content after the shared output wrapper change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(pull-requests): migrate consolidated PR tools to typed inputs and outputs Convert pull_request_read, create_pull_request, update_pull_request, merge_pull_request, update_pull_request_branch, pull_request_review_write (both feature variants), add_comment_to_pending_review and add_reply_to_pull_request_comment to NewTool with concrete inputs and output DTOs. Input normalizers replay legacy handler checks so legacy coercions and error text are preserved. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(pull-requests): align modern text with typed outputs Use curated output DTOs for modern JSON text while preserving legacy serialization. Cover successful null responses, empty check runs, and Link-header page traversal. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(pull-requests): preserve legacy validation order Replay parameter checks in handler order and scope cursor validation to review-thread reads. Keep commit message schema descriptions distinct from operation messages. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(pull-requests): simplify compatibility validation Remove normalization and test assertions that only pinned which invalid argument wins. Keep per-field validation, accepted coercions, and response-shape behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(pull-requests): type granular MCP tools Preserve exact legacy validation and response text while advertising concrete protocol-gated outputs for all granular PR mutations and both thread-resolution variants. Make the inherited overflow assertion architecture-independent. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * fix(pull-requests): preserve granular tool compatibility Keep advertised schemas exact while applying legacy-compatible runtime validation, and verify protocol-era output parity. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * test(pull-requests): compare schema sets semantically Treat required properties and enum values as unordered sets in the schema audit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(pull-requests): retain granular schema descriptions Keep the original enum guidance in input descriptions while preserving actual JSON Schema enum constraints. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(actions): type consolidated MCP tools Preserve legacy Actions payloads, validation ordering, coercions, IFC labels, and scope annotations while exposing concrete method-specific output unions to supported clients. Reject unrecognized versions in the shared structured-output capability gate without changing tool availability rules. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(actions): add typed Actions tool outputs Expose compact method-discriminated outputs for Actions tools while preserving legacy behavior, input coercions, and pagination forwarding. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(repos): complete typed repository tool outputs Migrate the remaining 17 repository tools to concrete inputs and outputs. Preserve existing content, mutations, scopes, filtering, and protocol gating; add wire and schema conformance coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore(repos): exclude unrelated snapshot formatting drift Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(repos): compact typed repository outputs Replace raw REST output schemas with compact repository DTOs, retain one canonical snapshot per tool, and align modern JSON text with structured output while pinning legacy content. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(issues): add typed consolidated issue tool contracts Preserve raw helper APIs and exact legacy text while exposing concrete output unions for modern protocols. Retain explicit awaiting statuses and real null responses without exposing SDK-generated error fallbacks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * refactor(issues): minimize consolidated typed outputs Project compact method-discriminated DTOs from API responses while preserving the separate legacy text formatter and explicit app-awaiting status. Cache strict schemas and keep only canonical snapshots. Strengthen null-member presence, schema size, and lockdown projection regressions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(issues): keep union property schemas portable Preserve forbidden variant properties as annotated schema objects rather than boolean schemas emitted by jsonschema-go. Cover Inspector's input paths and IFC labels on every issue-read method across protocols. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(issues): cover strict sub-issue output variants Reject unknown methods, states, field types, opaque values and API URL properties while covering nullable and empty method data. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(issues): pin compact comment user redaction Pin unchanged legacy profile/avatar fields separately from compact modern user projections and assert identical lockdown filtering and sanitization across protocols. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(issues): restore main-identical advertised input schema Remove added custom-field union constraints and sentinel enums from the advertised issue-write input. Preserve runtime exactly-one validation and private strict-schema tests. All three scoped input schemas compare exactly equal to main. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(issues): restore protocol output parity Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * refactor(issues): minimize granular structured outputs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> # Conflicts: # pkg/github/__toolsnaps__/add_sub_issue.snap # pkg/github/__toolsnaps__/remove_sub_issue.snap # pkg/github/__toolsnaps__/reprioritize_sub_issue.snap # pkg/github/granular_issue_types.go # pkg/github/issues_granular.go # pkg/github/typed_granular_issue_outputs_test.go # Please enter the commit message for your changes. Lines starting # with '#' will be ignored, and an empty message aborts the commit. # # interactive rebase in progress; onto 1973eeea # Last command done (1 command done): # pick 5b8f9d19 # refactor(issues): minimize granular structured outputs # No commands remaining. # You are currently rebasing. # # Changes to be committed: # modified: pkg/github/__toolsnaps__/add_issue_comment_reaction.snap # modified: pkg/github/__toolsnaps__/add_issue_reaction.snap # modified: pkg/github/__toolsnaps__/add_sub_issue.snap # modified: pkg/github/__toolsnaps__/create_issue.snap # modified: pkg/github/__toolsnaps__/hide_issue_comment.snap # modified: pkg/github/__toolsnaps__/remove_issue_comment_reaction.snap # modified: pkg/github/__toolsnaps__/remove_issue_reaction.snap # modified: pkg/github/__toolsnaps__/remove_sub_issue.snap # modified: pkg/github/__toolsnaps__/reprioritize_sub_issue.snap # modified: pkg/github/__toolsnaps__/set_issue_fields.snap # modified: pkg/github/__toolsnaps__/unhide_issue_comment.snap # modified: pkg/github/__toolsnaps__/update_issue_assignees.snap # modified: pkg/github/__toolsnaps__/update_issue_body.snap # modified: pkg/github/__toolsnaps__/update_issue_labels.snap # modified: pkg/github/__toolsnaps__/update_issue_milestone.snap # modified: pkg/github/__toolsnaps__/update_issue_state.snap # modified: pkg/github/__toolsnaps__/update_issue_title.snap # modified: pkg/github/__toolsnaps__/update_issue_type.snap # modified: pkg/github/comment_minimize_test.go # new file: pkg/github/granular_issue_types.go # modified: pkg/github/granular_tools_test.go # modified: pkg/github/issues_granular.go # new file: pkg/github/typed_granular_issue_outputs_test.go # * refactor(search): use typed account repository and commit tools Preserve legacy text and search semantics while publishing protocol-gated concrete structured outputs. Cover modern, legacy, and unknown clients, full repository JSON unions, defaults, IFC labels, and errors. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(search): compact typed repository search output Replace the full REST repository mirror with MinimalRepository-based structured items plus purpose-built full-mode details. Legacy text remains unchanged, and canonical snapshots now hold the modern output schema. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(search): expose repository visibility enum Remove the derivable node_id from compact full repository search output and publish the visibility enum in the static output schema. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * test(search): align snapshots with protocol wrapper Keep the modern text assertion aligned with structured content after the shared output wrapper change. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(pull-requests): migrate consolidated PR tools to typed inputs and outputs Convert pull_request_read, create_pull_request, update_pull_request, merge_pull_request, update_pull_request_branch, pull_request_review_write (both feature variants), add_comment_to_pending_review and add_reply_to_pull_request_comment to NewTool with concrete inputs and output DTOs. Input normalizers replay legacy handler checks so legacy coercions and error text are preserved. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(pull-requests): align modern text with typed outputs Use curated output DTOs for modern JSON text while preserving legacy serialization. Cover successful null responses, empty check runs, and Link-header page traversal. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(pull-requests): preserve legacy validation order Replay parameter checks in handler order and scope cursor validation to review-thread reads. Keep commit message schema descriptions distinct from operation messages. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(pull-requests): simplify compatibility validation Remove normalization and test assertions that only pinned which invalid argument wins. Keep per-field validation, accepted coercions, and response-shape behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(pull-requests): type granular MCP tools Preserve exact legacy validation and response text while advertising concrete protocol-gated outputs for all granular PR mutations and both thread-resolution variants. Make the inherited overflow assertion architecture-independent. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * fix(pull-requests): preserve granular tool compatibility Keep advertised schemas exact while applying legacy-compatible runtime validation, and verify protocol-era output parity. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * chore: regenerate license files Auto-generated by license-check workflow * test(pull-requests): compare schema sets semantically Treat required properties and enum values as unordered sets in the schema audit. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * fix(pull-requests): retain granular schema descriptions Keep the original enum guidance in input descriptions while preserving actual JSON Schema enum constraints. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * refactor(actions): type consolidated MCP tools Preserve legacy Actions payloads, validation ordering, coercions, IFC labels, and scope annotations while exposing concrete method-specific output unions to supported clients. Reject unrecognized versions in the shared structured-output capability gate without changing tool availability rules. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * feat(actions): add typed Actions tool outputs Expose compact method-discriminated outputs for Actions tools while preserving legacy behavior, input coercions, and pagination forwarding. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] --- README.md | 8 +- docs/error-handling.md | 10 +- docs/feature-flags.md | 18 +- docs/typed-tool-schemas.md | 23 +- go.mod | 2 +- internal/ghmcp/oauth_typed_test.go | 308 ++++++ pkg/github/__toolsnaps__/actions_get.snap | 705 ++++++++++++- pkg/github/__toolsnaps__/actions_list.snap | 734 +++++++++++++- .../__toolsnaps__/actions_run_trigger.snap | 106 +- .../add_pull_request_review_comment.snap | 6 +- ..._pull_request_review_comment_reaction.snap | 2 +- .../assign_copilot_to_issue.snap | 57 +- .../assign_copilot_to_issue_with_intent.snap | 61 +- pkg/github/__toolsnaps__/create_gist.snap | 18 +- .../create_pull_request_review.snap | 2 +- .../create_repository_ruleset.snap | 272 ++++- .../__toolsnaps__/custom_properties_read.snap | 125 ++- .../custom_properties_write.snap | 95 +- ...find_duplicate_ff_duplicate_detection.snap | 4 +- pkg/github/__toolsnaps__/get_gist.snap | 88 +- .../__toolsnaps__/get_job_logs_actions.snap | 237 +++++ pkg/github/__toolsnaps__/get_label.snap | 23 +- .../__toolsnaps__/get_repository_tree.snap | 89 +- pkg/github/__toolsnaps__/label_write.snap | 23 +- pkg/github/__toolsnaps__/list_gists.snap | 94 +- pkg/github/__toolsnaps__/list_label.snap | 43 +- pkg/github/__toolsnaps__/projects_get.snap | 738 +++++++++++++- pkg/github/__toolsnaps__/projects_list.snap | 934 +++++++++++++++++- pkg/github/__toolsnaps__/projects_write.snap | 573 ++++++++++- .../repository_ruleset_read.snap | 423 +++++++- .../__toolsnaps__/request_copilot_review.snap | 15 +- .../submit_pending_pull_request_review.snap | 2 +- pkg/github/__toolsnaps__/ui_get.snap | 387 +++++++- pkg/github/__toolsnaps__/update_gist.snap | 18 +- .../update_pull_request_state.snap | 2 +- pkg/github/actions.go | 439 +++----- pkg/github/actions_test.go | 6 +- pkg/github/actions_types.go | 647 ++++++++++++ pkg/github/copilot.go | 106 +- pkg/github/custom_properties.go | 8 + pkg/github/custom_properties_test.go | 8 +- pkg/github/find_duplicate.go | 8 - pkg/github/find_duplicate_test.go | 8 +- pkg/github/gists.go | 70 +- pkg/github/git.go | 10 +- pkg/github/git_gist_types.go | 281 ++++++ pkg/github/labels.go | 14 + pkg/github/projects.go | 42 +- pkg/github/projects_test.go | 6 +- pkg/github/projects_types.go | 614 ++++++++++++ pkg/github/pullrequests_granular.go | 14 +- pkg/github/rulesets.go | 13 + pkg/github/rulesets_test.go | 56 +- pkg/github/typed_actions_outputs_test.go | 613 ++++++++++++ pkg/github/typed_copilot_ui_outputs.go | 390 ++++++++ pkg/github/typed_copilot_ui_outputs_test.go | 543 ++++++++++ pkg/github/typed_git_gist_outputs_test.go | 391 ++++++++ pkg/github/typed_governance_outputs.go | 497 ++++++++++ pkg/github/typed_governance_outputs_test.go | 780 +++++++++++++++ ...yped_granular_pull_request_outputs_test.go | 42 + pkg/github/typed_projects_outputs_test.go | 702 +++++++++++++ pkg/github/ui_tools.go | 37 +- pkg/inventory/server_tool.go | 18 +- pkg/inventory/typed_input_test.go | 96 ++ pkg/inventory/typed_output.go | 53 +- pkg/inventory/typed_schema.go | 35 + ui/package.json | 1 + ui/src/apps/issue-write/App.tsx | 15 +- ui/src/apps/pr-edit/App.tsx | 7 +- ui/src/apps/pr-write/App.tsx | 7 +- ui/src/lib/uiGetResult.test.ts | 56 ++ ui/src/lib/uiGetResult.ts | 52 + ui/tsconfig.json | 1 + 73 files changed, 12403 insertions(+), 528 deletions(-) create mode 100644 internal/ghmcp/oauth_typed_test.go create mode 100644 pkg/github/__toolsnaps__/get_job_logs_actions.snap create mode 100644 pkg/github/actions_types.go create mode 100644 pkg/github/git_gist_types.go create mode 100644 pkg/github/projects_types.go create mode 100644 pkg/github/typed_actions_outputs_test.go create mode 100644 pkg/github/typed_copilot_ui_outputs.go create mode 100644 pkg/github/typed_copilot_ui_outputs_test.go create mode 100644 pkg/github/typed_git_gist_outputs_test.go create mode 100644 pkg/github/typed_governance_outputs.go create mode 100644 pkg/github/typed_governance_outputs_test.go create mode 100644 pkg/github/typed_projects_outputs_test.go create mode 100644 pkg/inventory/typed_input_test.go create mode 100644 ui/src/lib/uiGetResult.test.ts create mode 100644 ui/src/lib/uiGetResult.ts diff --git a/README.md b/README.md index 254cc23d02..d2589357da 100644 --- a/README.md +++ b/README.md @@ -616,13 +616,17 @@ The following sets of tools are available: ## Tools -Repository tools expose output schemas and typed `structuredContent` to clients using MCP protocol `2026-07-28` or later. Older clients, or clients with an unknown protocol version, receive the existing content without an output schema or structured output. +Repository tools expose output schemas and typed `structuredContent` when the negotiated MCP protocol version is a supported version `2026-07-28` or newer. Older clients, clients with an unknown protocol version, and clients omitting the version receive the existing content without an output schema or structured output. Unknown versions are not treated as supported merely because they sort after `2026-07-28`. For `get_file_contents`, directory results (including field projections) are structured arrays. File downloads and metadata-only responses use a `content` array of typed text, embedded-resource, or resource-link blocks, preserving status messages, text, base64 blobs, empty files, and download links. Text-only repository mutations such as starring and deletion also return a structured `message`; fork responses distinguish a repository reference from an in-progress message. Repository deletion still requires its existing confirmation flow before returning a completed result. Structured repository results use compact DTOs rather than raw REST objects. Release lookups share the compact release shape with release listings; Git references retain the ref, object type, and SHA; annotated tags retain tag/message/tagger/target information; deletion commits retain SHA, message, authors, tree SHA, parent SHAs, and a human-facing `html_url` when available. Directory entries retain file metadata and useful HTML/download links, but omit API and hypermedia routing URLs even when those fields were requested in a projection. Legacy and unknown protocol versions retain the original text byte-for-byte; modern text-only JSON responses serialize the same compact DTO as `structuredContent`. Non-text resource blocks and tool errors retain their existing content behavior. Structured projections use the same sanitized and filtered source as the legacy formatter. -The issue metadata (`list_issue_types`, `list_issue_fields`), comment (`add_issue_comment`, `update_issue_comment`), dependency (`issue_dependency_read`, `issue_dependency_write`), and duplicate-detection (`find_duplicate`) tools provide typed `outputSchema` and `structuredContent` for protocol `2026-07-28` and newer. Older or unknown protocol versions retain the same text responses without these fields. Tool errors do not return structured content. Dependency and duplicate-detection tools retain their existing feature gates. +For `get_repository_tree`, `list_gists`, `get_gist`, `create_gist`, and `update_gist`, gist results retain file names, contents, sizes, language/type information, timestamps, visibility, and compact owner profiles, while omitting API/node and Git transport URLs. Tree entries omit their derivable API URLs. Create/update gist results contain the gist ID and HTML URL. Modern JSON text and structured content serialize the same compact result. Older or unknown protocol versions retain byte-for-byte legacy text without an output schema or structured content. + +The issue metadata (`list_issue_types`, `list_issue_fields`), comment (`add_issue_comment`, `update_issue_comment`), dependency (`issue_dependency_read`, `issue_dependency_write`), and duplicate-detection (`find_duplicate`) tools provide typed `outputSchema` and `structuredContent` for negotiated supported protocol versions `2026-07-28` or newer. Older or unknown protocol versions retain the same text responses without these fields. Tool errors do not return structured content. Dependency and duplicate-detection tools retain their existing feature gates. + +For negotiated supported protocol versions `2026-07-28` or newer, the consolidated Actions tools (`actions_list`, `actions_get`, `actions_run_trigger`) return structured objects with a `method` discriminant and a method-specific typed field; JSON text contains the same compact projection. Workflow, job, and artifact projections omit API/hypermedia URLs while retaining browser links and useful identifiers, states, timestamps, and usage durations. Run usage lists billable runner environments in deterministic name order. Older and unknown-protocol text responses remain unchanged; `get_job_logs` retains its typed content, URL, or failed-job collection variants.
diff --git a/docs/error-handling.md b/docs/error-handling.md index 7f62443483..d483ebdec2 100644 --- a/docs/error-handling.md +++ b/docs/error-handling.md @@ -60,10 +60,12 @@ return ghErrors.NewGitHubGraphQLErrorResponse(ctx, message, err), nil ### Typed Tool Results Tools registered with concrete `NewTool[In, Out]` types expose output schemas and -`structuredContent` only when the negotiated protocol is `2026-07-28` or newer. -Older or unknown protocols retain the original text responses without these -typed-output additions. Ordinary API and validation errors never expose the -SDK-generated zero-value structured output. +`structuredContent` when the negotiated protocol version is a supported version +`2026-07-28` or newer. Older, unknown, or omitted protocol versions retain the +original text responses without these typed-output additions. Unknown versions +are not treated as supported merely because they sort after `2026-07-28`. +Ordinary API and validation errors never expose the SDK-generated zero-value +structured output. Handler-provided structured statuses are distinct from generated error output. For example, `issue_write` can return `IsError: true` with diff --git a/docs/feature-flags.md b/docs/feature-flags.md index ec370a442d..0d32b3c33a 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -237,15 +237,15 @@ as output formatting) won't appear here. - `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 (LEFT or RIGHT, optional) (string, optional) + - `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 (LEFT or RIGHT, optional) (string, optional) - - `subjectType`: The subject type of the comment (FILE or LINE) (string, required) + - `startSide`: The start side of a multi-line comment (optional) (string, optional) + - `subjectType`: The subject type of the comment (string, required) - **add_pull_request_review_comment_reaction** - Add Pull Request Review Comment Reaction - **OAuth Challenge Scopes**: `repo` - `comment_id`: The numeric pull request review comment ID. Use the number from a #discussion_r... anchor, not the GraphQL thread node ID (PRRT_...). (number, required) - - `content`: The emoji reaction type (+1, -1, laugh, confused, heart, hooray, rocket, or eyes) (string, required) + - `content`: The emoji reaction type (string, required) - `owner`: Repository owner (username or organization) (string, required) - `repo`: Repository name (string, required) @@ -253,7 +253,7 @@ as output formatting) won't appear here. - **OAuth Challenge 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 (APPROVE, REQUEST_CHANGES, or COMMENT). If omitted, creates a pending review. (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) @@ -300,7 +300,7 @@ as output formatting) won't appear here. - **submit_pending_pull_request_review** - Submit Pending Pull Request Review - **OAuth Challenge Scopes**: `repo` - `body`: The review body text (optional) (string, optional) - - `event`: The review action to perform (APPROVE, REQUEST_CHANGES, or COMMENT) (string, required) + - `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) @@ -341,7 +341,7 @@ as output formatting) won't appear here. - `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 (open or closed) (string, required) + - `state`: The new state for the pull request (string, required) - **update_pull_request_title** - Update Pull Request Title - **OAuth Challenge Scopes**: `repo` @@ -402,8 +402,8 @@ as output formatting) won't appear here. - `confidence_threshold`: Minimum similarity threshold a candidate must meet to be returned; higher values are stricter. When omitted, the API's high-precision default is used. The scale is defined by the API, so no client-side bounds are enforced. (number, optional) - `issue_number`: The number of the existing issue to find duplicates for (number, required) - `owner`: The owner of the repository (string, required) - - `page`: Page number for pagination (min 1). An explicit 0 is forwarded to the API as-is rather than being replaced with 1. (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100). An explicit 0 is forwarded to the API as-is rather than being replaced with 30. (number, optional) + - `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) ### `thread_resolution_reason` diff --git a/docs/typed-tool-schemas.md b/docs/typed-tool-schemas.md index f783dd5720..2065872be0 100644 --- a/docs/typed-tool-schemas.md +++ b/docs/typed-tool-schemas.md @@ -1,9 +1,9 @@ # Typed tool schemas Typed tool registrations use concrete Go input and output types with the MCP -Go SDK's `mcp.AddTool` path. The SDK infers schemas when the tool definition -does not provide them, validates arguments before the handler runs, and -validates typed output. Keep business rules that JSON Schema cannot express +Go SDK's `mcp.AddTool` path. Inventory infers missing schemas and validates +arguments against the cached input schema before the handler runs; the SDK +adapts and validates typed output. Keep business rules that JSON Schema cannot express in the handler or a preflight callback. Input inference unwraps one pointer level, matching the SDK's object input @@ -31,13 +31,13 @@ schemas; do not mutate the returned pointers. When compatibility requires a broader runtime input contract than the one advertised to clients, provide `ValidationInputSchema`. The tool's declared -`InputSchema` remains visible while the SDK validates calls against the +`InputSchema` remains visible while inventory validates calls against the runtime-only schema. Use `Preflight` for checks that need raw arguments or request dependencies before typed decoding; it may return a derived context for the handler. Input normalizers are only for compatibility transformations, not a replacement for schema validation. -The SDK applies defaults from the runtime input schema before decoding. If an +Inventory applies defaults from the runtime input schema before decoding. If an omitted field must remain omitted, build the runtime schema with `inventory.CloneSchemaWithoutDefaults` before applying validation-only changes. Use `inventory.CloneSchema` when deriving other runtime-only schema @@ -46,6 +46,14 @@ subschemas. Default removal visits each child once per parent. The advertised schema can retain its defaults; the constructor caches the runtime schema without mutating either caller-owned schema. +Availability and authorization guards run before preflight, normalization, +and input validation. A guard or preflight result is passed through the +registered SDK handler without decoding the original arguments or invoking +user code, so the SDK still finalizes multi-round-trip results with +`resultType: "input_required"` (or `"complete"`). The permissive SDK input +envelope used for this handoff is never advertised and does not replace the +original validation schema for real calls. + The output schema and `structuredContent` are exposed only for a negotiated, SDK-supported protocol version `2026-07-28` or later. Unknown versions are treated as legacy, including unsupported future dates; older, absent, and @@ -58,4 +66,7 @@ request-scoped server. If a handler intentionally returns non-JSON text or content blocks, set `PreserveHandlerContent` in `TypedSchemaOptions`, or call `inventory.PreserveToolHandlerContent(ctx)` from the handler middleware for -request-dependent output such as CSV. +request-dependent output such as CSV. This is for intentional non-DTO responses +such as resources, errors, or CSV; ordinary declared structured success must +retain shared DTO serialization, with modern JSON text equal to +`structuredContent`, even when legacy handler text was a plain mutation message. diff --git a/go.mod b/go.mod index dade1dbe16..e4fc3c7975 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/microcosm-cc/bluemonday v1.0.27 github.com/modelcontextprotocol/go-sdk v1.8.0 github.com/muesli/cache2go v0.0.0-20221011235721-518229cd8021 + github.com/segmentio/encoding v0.5.4 github.com/shurcooL/githubv4 v0.0.0-20260209031235-2402fdf4a9ed github.com/shurcooL/graphql v0.0.0-20240915155400-7ee5256398cf github.com/spf13/cobra v1.10.2 @@ -32,7 +33,6 @@ require ( github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/segmentio/asm v1.1.3 // indirect - github.com/segmentio/encoding v0.5.4 // indirect github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect github.com/spf13/afero v1.15.0 // indirect github.com/spf13/cast v1.10.0 // indirect diff --git a/internal/ghmcp/oauth_typed_test.go b/internal/ghmcp/oauth_typed_test.go new file mode 100644 index 0000000000..19eb63e327 --- /dev/null +++ b/internal/ghmcp/oauth_typed_test.go @@ -0,0 +1,308 @@ +package ghmcp + +import ( + "context" + "encoding/json" + "testing" + + "github.com/github/github-mcp-server/internal/oauth" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type oauthProbeOutput struct { + Status string `json:"status"` +} + +func oauthRegisteredSession( + t *testing.T, + typed bool, + fake oauthAuthenticator, + protocol string, + options *mcp.ClientOptions, + toolCalls *int, + captures ...chan json.RawMessage, +) *mcp.ClientSession { + t.Helper() + server := mcp.NewServer(&mcp.Implementation{Name: "oauth-test", Version: "test"}, nil) + middleware := createOAuthToolMiddleware(fake, discardLogger()) + if typed { + tool := inventory.NewServerToolWithContextHandler( + mcp.Tool{Name: probeToolName}, + inventory.ToolsetMetadata{ID: "test"}, + func(context.Context, *mcp.CallToolRequest, struct{}) (*mcp.CallToolResult, oauthProbeOutput, error) { + (*toolCalls)++ + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "tool-ran"}}}, + oauthProbeOutput{Status: "tool-ran"}, nil + }, + ) + tool.RegisterFunc(server, nil, middleware) + } else { + server.AddTool(&mcp.Tool{Name: probeToolName, InputSchema: &jsonschema.Schema{Type: "object"}}, + middleware(func(context.Context, *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + (*toolCalls)++ + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "tool-ran"}}}, nil + })) + } + return connectOAuthRegisteredServer(t, server, protocol, options, captures...) +} + +func connectOAuthRegisteredServer(t *testing.T, server *mcp.Server, protocol string, options *mcp.ClientOptions, captures ...chan json.RawMessage) *mcp.ClientSession { + t.Helper() + st, ct := mcp.NewInMemoryTransports() + ss, err := server.Connect(context.Background(), st, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = ss.Close() }) + client := mcp.NewClient(&mcp.Implementation{Name: "oauth-client", Version: "test"}, options) + var transport mcp.Transport = ct + if len(captures) > 0 { + transport = oauthCaptureTransport{Transport: ct, responses: captures[0]} + } + cs, err := client.Connect(context.Background(), transport, &mcp.ClientSessionOptions{ProtocolVersion: protocol}) + require.NoError(t, err) + t.Cleanup(func() { _ = cs.Close() }) + return cs +} + +type oauthCaptureTransport struct { + mcp.Transport + responses chan json.RawMessage +} + +func (t oauthCaptureTransport) Connect(ctx context.Context) (mcp.Connection, error) { + connection, err := t.Transport.Connect(ctx) + if err != nil { + return nil, err + } + return oauthCaptureConnection{Connection: connection, responses: t.responses}, nil +} + +type oauthCaptureConnection struct { + mcp.Connection + responses chan json.RawMessage +} + +func (c oauthCaptureConnection) Read(ctx context.Context) (jsonrpc.Message, error) { + message, err := c.Connection.Read(ctx) + if err == nil { + if response, ok := message.(*jsonrpc.Response); ok && response.Result != nil { + c.responses <- append(json.RawMessage(nil), response.Result...) + } + } + return message, err +} + +func oauthPendingAuthenticator() *fakeAuthenticator { + return &fakeAuthenticator{ + outcome: &oauth.Outcome{ + UserAction: &oauth.UserAction{URL: "https://example.com/auth", Message: "Authorize, then retry."}, + FlowID: "flow-1", + }, + tokenAfterAwait: true, + cancelResult: true, + } +} + +func TestOAuthTypedRegistration(t *testing.T) { + for _, typed := range []bool{false, true} { + name := "untyped-control" + if typed { + name = "typed" + } + t.Run(name, func(t *testing.T) { + urlCaps := &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}}} + t.Run("wire-input-required", func(t *testing.T) { + fake := oauthPendingAuthenticator() + calls := 0 + captures := make(chan json.RawMessage, 8) + session := oauthRegisteredSession(t, typed, fake, "", &mcp.ClientOptions{ + Capabilities: urlCaps, MultiRoundTrip: &mcp.MultiRoundTripOptions{Disabled: true}, + }, &calls, captures) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: probeToolName}) + require.NoError(t, err) + <-captures // initialize + wire := <-captures + var fields map[string]json.RawMessage + require.NoError(t, json.Unmarshal(wire, &fields)) + assert.JSONEq(t, `"input_required"`, string(fields["resultType"]), "wire: %s", wire) + assert.True(t, result.NeedsInput()) + assert.Contains(t, result.InputRequests, oauthElicitIDPrefix+"flow-1") + assert.Nil(t, result.StructuredContent) + assert.Zero(t, calls) + t.Logf("raw tools/call response: %s", wire) + }) + for _, action := range []string{"accept", "decline"} { + t.Run(action, func(t *testing.T) { + fake := oauthPendingAuthenticator() + calls, prompts := 0, 0 + session := oauthRegisteredSession(t, typed, fake, "", &mcp.ClientOptions{ + Capabilities: urlCaps, + ElicitationHandler: func(_ context.Context, req *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + prompts++ + assert.Equal(t, "url", req.Params.Mode) + assert.Equal(t, "https://example.com/auth", req.Params.URL) + return &mcp.ElicitResult{Action: action}, nil + }, + }, &calls) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: probeToolName}) + require.NoError(t, err) + assert.Equal(t, 1, prompts) + require.Len(t, result.Content, 1) + assert.False(t, result.NeedsInput()) + wire, err := json.Marshal(result) + require.NoError(t, err) + assert.Contains(t, string(wire), `"resultType":"complete"`) + if action == "accept" { + assert.Equal(t, 1, calls) + assert.Equal(t, 1, fake.awaitCalls) + assert.Equal(t, "flow-1", fake.lastAwaitFlowID) + assert.Zero(t, fake.cancelCalls) + if typed { + output, err := json.Marshal(result.StructuredContent) + require.NoError(t, err) + assert.JSONEq(t, `{"status":"tool-ran"}`, string(output)) + } + } else { + assert.Zero(t, calls) + assert.Zero(t, fake.awaitCalls) + assert.Equal(t, 1, fake.cancelCalls) + assert.Contains(t, result.Content[0].(*mcp.TextContent).Text, "declined") + assert.Nil(t, result.StructuredContent) + } + }) + } + for _, protocol := range []string{"", "2025-11-25"} { + t.Run("manual-fallback/"+protocol, func(t *testing.T) { + fake := oauthPendingAuthenticator() + calls := 0 + session := oauthRegisteredSession(t, typed, fake, protocol, &mcp.ClientOptions{ + Capabilities: &mcp.ClientCapabilities{}, + }, &calls) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: probeToolName}) + require.NoError(t, err) + require.Len(t, result.Content, 1) + assert.Equal(t, "Authorize, then retry.", result.Content[0].(*mcp.TextContent).Text) + assert.False(t, result.NeedsInput()) + assert.Nil(t, result.StructuredContent) + assert.Zero(t, calls) + assert.Zero(t, fake.awaitCalls) + assert.Zero(t, fake.cancelCalls) + assert.Equal(t, protocol != "", fake.lastPrompter != nil) + }) + } + }) + } +} + +type legacyPromptAuthenticator struct { + fakeAuthenticator +} + +func (f *legacyPromptAuthenticator) Authenticate(ctx context.Context, prompter oauth.Prompter) (*oauth.Outcome, error) { + f.authCalls++ + f.lastPrompter = prompter + if err := prompter.PromptURL(ctx, oauth.Prompt{URL: "https://example.com/auth", Message: "Authorize"}); err != nil { + return nil, err + } + f.hasToken = true + return nil, nil +} + +func TestOAuthTypedRegistrationLegacyElicitation(t *testing.T) { + for _, typed := range []bool{false, true} { + t.Run(map[bool]string{false: "untyped-control", true: "typed"}[typed], func(t *testing.T) { + fake := &legacyPromptAuthenticator{} + calls, prompts := 0, 0 + session := oauthRegisteredSession(t, typed, fake, "2025-11-25", &mcp.ClientOptions{ + Capabilities: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}}}, + ElicitationHandler: func(context.Context, *mcp.ElicitRequest) (*mcp.ElicitResult, error) { + prompts++ + return &mcp.ElicitResult{Action: "accept"}, nil + }, + }, &calls) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: probeToolName}) + require.NoError(t, err) + assert.Equal(t, 1, prompts) + assert.Equal(t, 1, calls) + assert.Equal(t, 1, fake.authCalls) + assert.False(t, result.NeedsInput()) + assert.Nil(t, result.StructuredContent) + require.Len(t, result.Content, 1) + assert.Equal(t, "tool-ran", result.Content[0].(*mcp.TextContent).Text) + }) + } +} + +func TestOAuthTypedRegistrationGuardsInvalidArguments(t *testing.T) { + type input struct { + Mode string `json:"mode"` + } + for _, raw := range []string{ + `{}`, `{"mode":"invalid"}`, `{"mode":42}`, `[]`, `null`, `"not an object"`, + } { + t.Run(raw, func(t *testing.T) { + arguments := json.RawMessage(raw) + fake := oauthPendingAuthenticator() + handlerCalls, preflightCalls, normalizerCalls := 0, 0, 0 + tool := inventory.NewServerToolWithContextHandlerAndSchemaOptions( + mcp.Tool{ + Name: probeToolName, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{"mode": {Type: "string", Enum: []any{"valid"}}}, + Required: []string{"mode"}, + }, + }, + inventory.ToolsetMetadata{ID: "test"}, + func(context.Context, *mcp.CallToolRequest, input) (*mcp.CallToolResult, oauthProbeOutput, error) { + handlerCalls++ + return nil, oauthProbeOutput{Status: "unexpected"}, nil + }, + inventory.TypedSchemaOptions{ + Preflight: func(ctx context.Context, _ *mcp.CallToolRequest) (context.Context, *mcp.CallToolResult, error) { + preflightCalls++ + return ctx, nil, nil + }, + }, + func(raw json.RawMessage) (json.RawMessage, error) { + normalizerCalls++ + return raw, nil + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "test"}, nil) + tool.RegisterFunc(server, nil, createOAuthToolMiddleware(fake, discardLogger())) + session := connectOAuthRegisteredServer(t, server, "", &mcp.ClientOptions{ + Capabilities: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}}}, + MultiRoundTrip: &mcp.MultiRoundTripOptions{Disabled: true}, + }) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: probeToolName, Arguments: arguments}) + require.NoError(t, err) + assert.True(t, result.NeedsInput(), "auth must win over invalid arguments") + assert.Zero(t, handlerCalls) + assert.Zero(t, preflightCalls) + assert.Zero(t, normalizerCalls) + result, err = session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: probeToolName, Arguments: arguments, + InputResponses: mcp.InputResponseMap{oauthElicitIDPrefix + "flow-1": &mcp.ElicitResult{Action: "accept"}}, + }) + require.NoError(t, err) + assert.True(t, result.IsError, "authorized retries must enforce original input schema") + assert.False(t, result.NeedsInput()) + assert.Zero(t, handlerCalls) + assert.Equal(t, 1, preflightCalls) + assert.Equal(t, 1, normalizerCalls) + assert.Equal(t, 1, fake.awaitCalls) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + encoded, err := json.Marshal(list.Tools[0].InputSchema) + require.NoError(t, err) + assert.Contains(t, string(encoded), `"required":["mode"]`) + assert.Contains(t, string(encoded), `"enum":["valid"]`) + }) + } +} diff --git a/pkg/github/__toolsnaps__/actions_get.snap b/pkg/github/__toolsnaps__/actions_get.snap index 661f379f5f..404931f389 100644 --- a/pkg/github/__toolsnaps__/actions_get.snap +++ b/pkg/github/__toolsnaps__/actions_get.snap @@ -40,5 +40,708 @@ ], "type": "object" }, - "name": "actions_get" + "name": "actions_get", + "outputSchema": { + "additionalProperties": false, + "properties": { + "artifact": { + "additionalProperties": false, + "properties": { + "artifact_id": { + "type": "integer" + }, + "download_url": { + "type": "string" + }, + "message": { + "type": "string" + }, + "note": { + "type": "string" + } + }, + "required": [ + "artifact_id", + "download_url", + "message", + "note" + ], + "type": [ + "null", + "object" + ] + }, + "logs": { + "additionalProperties": false, + "properties": { + "logs_url": { + "type": "string" + }, + "message": { + "type": "string" + }, + "note": { + "type": "string" + }, + "optimization_tip": { + "type": "string" + }, + "warning": { + "type": "string" + } + }, + "required": [ + "logs_url", + "message", + "note", + "optimization_tip", + "warning" + ], + "type": [ + "null", + "object" + ] + }, + "method": { + "description": "Operation that produced this response.", + "enum": [ + "get_workflow", + "get_workflow_run", + "get_workflow_job", + "download_workflow_run_artifact", + "get_workflow_run_usage", + "get_workflow_run_logs_url" + ], + "type": "string" + }, + "usage": { + "additionalProperties": false, + "properties": { + "billable": { + "items": { + "additionalProperties": false, + "properties": { + "job_runs": { + "items": { + "additionalProperties": false, + "properties": { + "duration_ms": { + "description": "Duration in milliseconds.", + "type": [ + "null", + "integer" + ] + }, + "job_id": { + "type": [ + "null", + "integer" + ] + } + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "jobs": { + "type": [ + "null", + "integer" + ] + }, + "runner": { + "description": "Runner environment name; custom environments are supported.", + "type": "string" + }, + "total_ms": { + "description": "Duration in milliseconds.", + "type": [ + "null", + "integer" + ] + } + }, + "required": [ + "runner" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "run_duration_ms": { + "description": "Duration in milliseconds.", + "type": [ + "null", + "integer" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "workflow": { + "additionalProperties": false, + "properties": { + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "html_url": { + "description": "Browser URL.", + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "integer" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "path": { + "type": [ + "null", + "string" + ] + }, + "state": { + "description": "Workflow state, e.g. active, deleted, disabled_fork, disabled_inactivity, disabled_manually.", + "enum": [ + "active", + "deleted", + "disabled_fork", + "disabled_inactivity", + "disabled_manually" + ], + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + }, + "workflow_job": { + "additionalProperties": false, + "properties": { + "completed_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "conclusion": { + "description": "Completion outcome, e.g. success, failure, cancelled, skipped, timed_out, action_required.", + "enum": [ + "success", + "failure", + "neutral", + "cancelled", + "skipped", + "timed_out", + "action_required", + "stale", + "startup_failure" + ], + "type": "string" + }, + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "head_branch": { + "type": "string" + }, + "head_sha": { + "type": "string" + }, + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "integer" + }, + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "name": { + "type": "string" + }, + "run_attempt": { + "type": "integer" + }, + "run_id": { + "type": "integer" + }, + "runner_group_id": { + "type": "integer" + }, + "runner_group_name": { + "type": "string" + }, + "runner_id": { + "type": "integer" + }, + "runner_name": { + "type": "string" + }, + "started_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "status": { + "description": "Lifecycle status, e.g. queued, in_progress, completed, waiting.", + "enum": [ + "queued", + "in_progress", + "completed", + "requested", + "waiting", + "pending", + "" + ], + "type": "string" + }, + "steps": { + "items": { + "additionalProperties": false, + "properties": { + "completed_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "conclusion": { + "description": "Completion outcome, e.g. success, failure, cancelled, skipped, timed_out, action_required.", + "enum": [ + "success", + "failure", + "neutral", + "cancelled", + "skipped", + "timed_out", + "action_required", + "stale", + "startup_failure" + ], + "type": "string" + }, + "name": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "started_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "status": { + "description": "Lifecycle status, e.g. queued, in_progress, completed, waiting.", + "enum": [ + "queued", + "in_progress", + "completed", + "requested", + "waiting", + "pending", + "" + ], + "type": "string" + } + }, + "required": [ + "name", + "status", + "number" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "workflow_name": { + "type": "string" + } + }, + "required": [ + "id", + "run_id", + "name", + "status" + ], + "type": [ + "null", + "object" + ] + }, + "workflow_run": { + "additionalProperties": false, + "properties": { + "actor": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "conclusion": { + "description": "Completion outcome, e.g. success, failure, cancelled, skipped, timed_out, action_required.", + "enum": [ + "success", + "failure", + "neutral", + "cancelled", + "skipped", + "timed_out", + "action_required", + "stale", + "startup_failure" + ], + "type": "string" + }, + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "display_title": { + "type": "string" + }, + "event": { + "type": "string" + }, + "head_branch": { + "type": "string" + }, + "head_commit": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + }, + "head_sha": { + "type": "string" + }, + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "pull_requests": { + "items": { + "type": "integer" + }, + "type": [ + "null", + "array" + ] + }, + "referenced_workflows": { + "items": { + "additionalProperties": false, + "properties": { + "path": { + "type": "string" + }, + "ref": { + "type": "string" + }, + "sha": { + "type": "string" + } + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "run_attempt": { + "type": "integer" + }, + "run_number": { + "type": "integer" + }, + "run_started_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "status": { + "description": "Lifecycle status, e.g. queued, in_progress, completed, waiting.", + "enum": [ + "queued", + "in_progress", + "completed", + "requested", + "waiting", + "pending", + "" + ], + "type": "string" + }, + "triggering_actor": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "workflow_id": { + "type": "integer" + } + }, + "required": [ + "id", + "name", + "workflow_id", + "run_number", + "run_attempt", + "status" + ], + "type": [ + "null", + "object" + ] + } + }, + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/actions_list.snap b/pkg/github/__toolsnaps__/actions_list.snap index 4df628bf85..021c3da43f 100644 --- a/pkg/github/__toolsnaps__/actions_list.snap +++ b/pkg/github/__toolsnaps__/actions_list.snap @@ -125,5 +125,737 @@ ], "type": "object" }, - "name": "actions_list" + "name": "actions_list", + "outputSchema": { + "additionalProperties": false, + "properties": { + "artifacts": { + "additionalProperties": false, + "properties": { + "artifacts": { + "items": { + "additionalProperties": false, + "properties": { + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "digest": { + "type": [ + "null", + "string" + ] + }, + "expired": { + "type": [ + "null", + "boolean" + ] + }, + "expires_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "id": { + "type": [ + "null", + "integer" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "size_in_bytes": { + "description": "Artifact size in bytes.", + "type": [ + "null", + "integer" + ] + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "workflow_run": { + "additionalProperties": false, + "properties": { + "head_branch": { + "type": [ + "null", + "string" + ] + }, + "head_sha": { + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "integer" + ] + } + }, + "type": [ + "null", + "object" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + }, + "total_count": { + "type": [ + "null", + "integer" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "method": { + "description": "Operation that produced this response.", + "enum": [ + "list_workflows", + "list_workflow_runs", + "list_workflow_jobs", + "list_workflow_run_artifacts" + ], + "type": "string" + }, + "workflow_jobs": { + "additionalProperties": false, + "properties": { + "jobs": { + "items": { + "additionalProperties": false, + "properties": { + "completed_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "conclusion": { + "description": "Completion outcome, e.g. success, failure, cancelled, skipped, timed_out, action_required.", + "enum": [ + "success", + "failure", + "neutral", + "cancelled", + "skipped", + "timed_out", + "action_required", + "stale", + "startup_failure" + ], + "type": "string" + }, + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "head_branch": { + "type": "string" + }, + "head_sha": { + "type": "string" + }, + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "integer" + }, + "labels": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "name": { + "type": "string" + }, + "run_attempt": { + "type": "integer" + }, + "run_id": { + "type": "integer" + }, + "runner_group_id": { + "type": "integer" + }, + "runner_group_name": { + "type": "string" + }, + "runner_id": { + "type": "integer" + }, + "runner_name": { + "type": "string" + }, + "started_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "status": { + "description": "Lifecycle status, e.g. queued, in_progress, completed, waiting.", + "enum": [ + "queued", + "in_progress", + "completed", + "requested", + "waiting", + "pending", + "" + ], + "type": "string" + }, + "steps": { + "items": { + "additionalProperties": false, + "properties": { + "completed_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "conclusion": { + "description": "Completion outcome, e.g. success, failure, cancelled, skipped, timed_out, action_required.", + "enum": [ + "success", + "failure", + "neutral", + "cancelled", + "skipped", + "timed_out", + "action_required", + "stale", + "startup_failure" + ], + "type": "string" + }, + "name": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "started_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "status": { + "description": "Lifecycle status, e.g. queued, in_progress, completed, waiting.", + "enum": [ + "queued", + "in_progress", + "completed", + "requested", + "waiting", + "pending", + "" + ], + "type": "string" + } + }, + "required": [ + "name", + "status", + "number" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "workflow_name": { + "type": "string" + } + }, + "required": [ + "id", + "run_id", + "name", + "status" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "total_count", + "jobs" + ], + "type": [ + "null", + "object" + ] + }, + "workflow_runs": { + "additionalProperties": false, + "properties": { + "total_count": { + "type": "integer" + }, + "workflow_runs": { + "items": { + "additionalProperties": false, + "properties": { + "actor": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "conclusion": { + "description": "Completion outcome, e.g. success, failure, cancelled, skipped, timed_out, action_required.", + "enum": [ + "success", + "failure", + "neutral", + "cancelled", + "skipped", + "timed_out", + "action_required", + "stale", + "startup_failure" + ], + "type": "string" + }, + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "display_title": { + "type": "string" + }, + "event": { + "type": "string" + }, + "head_branch": { + "type": "string" + }, + "head_commit": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": [ + "null", + "object" + ] + }, + "head_sha": { + "type": "string" + }, + "html_url": { + "description": "Browser URL.", + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "path": { + "type": "string" + }, + "pull_requests": { + "items": { + "type": "integer" + }, + "type": [ + "null", + "array" + ] + }, + "referenced_workflows": { + "items": { + "additionalProperties": false, + "properties": { + "path": { + "type": "string" + }, + "ref": { + "type": "string" + }, + "sha": { + "type": "string" + } + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "run_attempt": { + "type": "integer" + }, + "run_number": { + "type": "integer" + }, + "run_started_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "status": { + "description": "Lifecycle status, e.g. queued, in_progress, completed, waiting.", + "enum": [ + "queued", + "in_progress", + "completed", + "requested", + "waiting", + "pending", + "" + ], + "type": "string" + }, + "triggering_actor": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": [ + "null", + "object" + ] + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": [ + "null", + "object" + ] + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "workflow_id": { + "type": "integer" + } + }, + "required": [ + "id", + "name", + "workflow_id", + "run_number", + "run_attempt", + "status" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } + }, + "required": [ + "total_count", + "workflow_runs" + ], + "type": [ + "null", + "object" + ] + }, + "workflows": { + "additionalProperties": false, + "properties": { + "total_count": { + "type": [ + "null", + "integer" + ] + }, + "workflows": { + "items": { + "additionalProperties": false, + "properties": { + "created_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "html_url": { + "description": "Browser URL.", + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "integer" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + }, + "path": { + "type": [ + "null", + "string" + ] + }, + "state": { + "description": "Workflow state, e.g. active, deleted, disabled_fork, disabled_inactivity, disabled_manually.", + "enum": [ + "active", + "deleted", + "disabled_fork", + "disabled_inactivity", + "disabled_manually" + ], + "type": [ + "null", + "string" + ] + }, + "updated_at": { + "description": "Timestamp in RFC3339 format.", + "format": "date-time", + "type": "string" + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "null", + "array" + ] + } + }, + "type": [ + "null", + "object" + ] + } + }, + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/actions_run_trigger.snap b/pkg/github/__toolsnaps__/actions_run_trigger.snap index 0c859b86ec..d7982a88ec 100644 --- a/pkg/github/__toolsnaps__/actions_run_trigger.snap +++ b/pkg/github/__toolsnaps__/actions_run_trigger.snap @@ -52,5 +52,109 @@ ], "type": "object" }, - "name": "actions_run_trigger" + "name": "actions_run_trigger", + "outputSchema": { + "$defs": { + "run_operation": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + }, + "run_id": { + "type": "integer" + }, + "status": { + "description": "HTTP response status.", + "type": "string" + }, + "status_code": { + "type": "integer" + } + }, + "required": [ + "message", + "run_id", + "status", + "status_code" + ], + "type": "object" + } + }, + "additionalProperties": false, + "properties": { + "cancel": { + "$ref": "#/$defs/run_operation" + }, + "delete_logs": { + "$ref": "#/$defs/run_operation" + }, + "dispatch": { + "additionalProperties": false, + "properties": { + "inputs": { + "additionalProperties": true, + "type": [ + "object", + "null" + ] + }, + "message": { + "type": "string" + }, + "ref": { + "type": "string" + }, + "status": { + "description": "HTTP response status.", + "type": "string" + }, + "status_code": { + "type": "integer" + }, + "workflow_id": { + "type": "string" + }, + "workflow_type": { + "enum": [ + "workflow_file", + "workflow_id" + ], + "type": "string" + } + }, + "required": [ + "inputs", + "message", + "ref", + "status", + "status_code", + "workflow_id", + "workflow_type" + ], + "type": [ + "null", + "object" + ] + }, + "method": { + "description": "Operation that produced this response.", + "enum": [ + "run_workflow", + "rerun_workflow_run", + "rerun_failed_jobs", + "cancel_workflow_run", + "delete_workflow_run_logs" + ], + "type": "string" + }, + "rerun": { + "$ref": "#/$defs/run_operation" + }, + "rerun_failed": { + "$ref": "#/$defs/run_operation" + } + }, + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/add_pull_request_review_comment.snap b/pkg/github/__toolsnaps__/add_pull_request_review_comment.snap index ae60f45c05..0ae86e5c1c 100644 --- a/pkg/github/__toolsnaps__/add_pull_request_review_comment.snap +++ b/pkg/github/__toolsnaps__/add_pull_request_review_comment.snap @@ -49,7 +49,7 @@ "x-mcp-header": "repo" }, "side": { - "description": "The side of the diff to comment on (LEFT or RIGHT, optional)", + "description": "The side of the diff to comment on (optional)", "enum": [ "LEFT", "RIGHT" @@ -61,7 +61,7 @@ "type": "number" }, "startSide": { - "description": "The start side of a multi-line comment (LEFT or RIGHT, optional)", + "description": "The start side of a multi-line comment (optional)", "enum": [ "LEFT", "RIGHT" @@ -69,7 +69,7 @@ "type": "string" }, "subjectType": { - "description": "The subject type of the comment (FILE or LINE)", + "description": "The subject type of the comment", "enum": [ "FILE", "LINE" diff --git a/pkg/github/__toolsnaps__/add_pull_request_review_comment_reaction.snap b/pkg/github/__toolsnaps__/add_pull_request_review_comment_reaction.snap index cd0f8641bc..bcf5f74e19 100644 --- a/pkg/github/__toolsnaps__/add_pull_request_review_comment_reaction.snap +++ b/pkg/github/__toolsnaps__/add_pull_request_review_comment_reaction.snap @@ -27,7 +27,7 @@ "type": "number" }, "content": { - "description": "The emoji reaction type (+1, -1, laugh, confused, heart, hooray, rocket, or eyes)", + "description": "The emoji reaction type", "enum": [ "+1", "-1", diff --git a/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap b/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap index 5f44b2c6c2..8c8d4dd4c3 100644 --- a/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap +++ b/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap @@ -47,5 +47,60 @@ ], "type": "object" }, - "name": "assign_copilot_to_issue" + "name": "assign_copilot_to_issue", + "outputSchema": { + "additionalProperties": false, + "properties": { + "issue_number": { + "description": "The assigned issue's number.", + "type": "integer" + }, + "message": { + "description": "Assignment outcome or tool error message.", + "type": "string" + }, + "note": { + "description": "Additional status detail when Copilot has not created a pull request yet.", + "type": "string" + }, + "owner": { + "type": "string" + }, + "pull_request": { + "additionalProperties": false, + "properties": { + "number": { + "type": "integer" + }, + "state": { + "enum": [ + "OPEN", + "CLOSED", + "MERGED" + ], + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "number", + "state", + "title" + ], + "type": "object" + }, + "repo": { + "type": "string" + } + }, + "required": [ + "issue_number", + "message", + "owner", + "repo" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/assign_copilot_to_issue_with_intent.snap b/pkg/github/__toolsnaps__/assign_copilot_to_issue_with_intent.snap index 956c2a8142..5f9af27c69 100644 --- a/pkg/github/__toolsnaps__/assign_copilot_to_issue_with_intent.snap +++ b/pkg/github/__toolsnaps__/assign_copilot_to_issue_with_intent.snap @@ -68,5 +68,64 @@ ], "type": "object" }, - "name": "assign_copilot_to_issue_with_intent" + "name": "assign_copilot_to_issue_with_intent", + "outputSchema": { + "additionalProperties": false, + "properties": { + "is_suggestion": { + "type": "boolean" + }, + "issue_number": { + "description": "The assigned issue's number.", + "type": "integer" + }, + "message": { + "description": "Assignment outcome or tool error message.", + "type": "string" + }, + "note": { + "description": "Additional status detail when Copilot has not created a pull request yet.", + "type": "string" + }, + "owner": { + "type": "string" + }, + "pull_request": { + "additionalProperties": false, + "properties": { + "number": { + "type": "integer" + }, + "state": { + "enum": [ + "OPEN", + "CLOSED", + "MERGED" + ], + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "number", + "state", + "title" + ], + "type": "object" + }, + "repo": { + "type": "string" + } + }, + "required": [ + "is_suggestion", + "issue_number", + "message", + "owner", + "repo" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_gist.snap b/pkg/github/__toolsnaps__/create_gist.snap index f91810bd00..ab94493f61 100644 --- a/pkg/github/__toolsnaps__/create_gist.snap +++ b/pkg/github/__toolsnaps__/create_gist.snap @@ -31,5 +31,21 @@ ], "type": "object" }, - "name": "create_gist" + "name": "create_gist", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/create_pull_request_review.snap b/pkg/github/__toolsnaps__/create_pull_request_review.snap index 8a665ef458..c75c290a1d 100644 --- a/pkg/github/__toolsnaps__/create_pull_request_review.snap +++ b/pkg/github/__toolsnaps__/create_pull_request_review.snap @@ -30,7 +30,7 @@ "type": "string" }, "event": { - "description": "The review action to perform (APPROVE, REQUEST_CHANGES, or COMMENT). If omitted, creates a pending review.", + "description": "The review action to perform. If omitted, creates a pending review.", "enum": [ "APPROVE", "REQUEST_CHANGES", diff --git a/pkg/github/__toolsnaps__/create_repository_ruleset.snap b/pkg/github/__toolsnaps__/create_repository_ruleset.snap index 81f2cadcb2..2172a0b9ae 100644 --- a/pkg/github/__toolsnaps__/create_repository_ruleset.snap +++ b/pkg/github/__toolsnaps__/create_repository_ruleset.snap @@ -131,5 +131,275 @@ ], "type": "object" }, - "name": "create_repository_ruleset" + "name": "create_repository_ruleset", + "outputSchema": { + "$defs": { + "idCondition": { + "additionalProperties": false, + "properties": { + "organization_ids": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "repository_ids": { + "items": { + "type": "integer" + }, + "type": "array" + } + }, + "type": "object" + }, + "nameCondition": { + "additionalProperties": false, + "properties": { + "exclude": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "include": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "protected": { + "type": "boolean" + } + }, + "required": [ + "include", + "exclude" + ], + "type": "object" + }, + "propertyCondition": { + "additionalProperties": false, + "properties": { + "exclude": { + "items": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "property_values": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "source": { + "type": "string" + } + }, + "required": [ + "name", + "property_values" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "include": { + "items": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "property_values": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "source": { + "type": "string" + } + }, + "required": [ + "name", + "property_values" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } + }, + "required": [ + "include", + "exclude" + ], + "type": "object" + }, + "rule": { + "additionalProperties": false, + "properties": { + "parameters": { + "description": "User-configured parameters for this rule type.", + "type": "object" + }, + "ruleset_id": { + "type": "integer" + }, + "ruleset_source": { + "type": "string" + }, + "ruleset_source_type": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + } + }, + "additionalProperties": false, + "properties": { + "bypass_actors": { + "items": { + "additionalProperties": false, + "properties": { + "actor_id": { + "type": "integer" + }, + "actor_type": { + "type": "string" + }, + "bypass_mode": { + "type": "string" + } + }, + "required": [ + "actor_type", + "bypass_mode" + ], + "type": "object" + }, + "type": "array" + }, + "conditions": { + "additionalProperties": false, + "properties": { + "organization_id": { + "$ref": "#/$defs/idCondition", + "type": "object" + }, + "organization_name": { + "$ref": "#/$defs/nameCondition", + "type": "object" + }, + "organization_property": { + "$ref": "#/$defs/propertyCondition", + "type": "object" + }, + "ref_name": { + "$ref": "#/$defs/nameCondition", + "type": "object" + }, + "repository_id": { + "$ref": "#/$defs/idCondition", + "type": "object" + }, + "repository_name": { + "$ref": "#/$defs/nameCondition", + "type": "object" + }, + "repository_property": { + "$ref": "#/$defs/propertyCondition", + "type": "object" + } + }, + "type": "object" + }, + "created_at": { + "description": "RFC3339 creation time.", + "format": "date-time", + "type": "string" + }, + "current_user_can_bypass": { + "type": "string" + }, + "enforcement": { + "enum": [ + "", + "disabled", + "active", + "evaluate" + ], + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "rules": { + "items": { + "$ref": "#/$defs/rule", + "type": "object" + }, + "type": "array" + }, + "source": { + "type": "string" + }, + "source_type": { + "enum": [ + "Repository", + "Organization", + "Enterprise" + ], + "type": "string" + }, + "target": { + "enum": [ + "branch", + "tag", + "push", + "repository" + ], + "type": "string" + }, + "updated_at": { + "description": "RFC3339 last update time.", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "name", + "source", + "enforcement" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/custom_properties_read.snap b/pkg/github/__toolsnaps__/custom_properties_read.snap index 9a7ff10e9b..505245ecd6 100644 --- a/pkg/github/__toolsnaps__/custom_properties_read.snap +++ b/pkg/github/__toolsnaps__/custom_properties_read.snap @@ -38,5 +38,128 @@ ], "type": "object" }, - "name": "custom_properties_read" + "name": "custom_properties_read", + "outputSchema": { + "additionalProperties": false, + "properties": { + "definitions": { + "items": { + "additionalProperties": false, + "properties": { + "allowed_values": { + "items": { + "type": "string" + }, + "type": "array" + }, + "default_value": { + "description": "Repository level only: the value to assign. A string, an array of strings, or null to clear the value.", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ] + }, + "description": { + "type": "string" + }, + "property_name": { + "type": "string" + }, + "require_explicit_values": { + "type": "boolean" + }, + "required": { + "type": "boolean" + }, + "value_type": { + "enum": [ + "", + "string", + "single_select", + "multi_select", + "true_false", + "url" + ], + "type": "string" + }, + "values_editable_by": { + "enum": [ + "org_actors", + "org_and_repo_actors" + ], + "type": "string" + } + }, + "required": [ + "property_name", + "value_type" + ], + "type": "object" + }, + "type": "array" + }, + "level": { + "enum": [ + "repository", + "organization", + "enterprise" + ], + "type": "string" + }, + "method": { + "enum": [ + "read" + ], + "type": "string" + }, + "values": { + "items": { + "additionalProperties": false, + "properties": { + "property_name": { + "type": "string" + }, + "value": { + "description": "Repository level only: the value to assign. A string, an array of strings, or null to clear the value.", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "property_name", + "value" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "method", + "level" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/custom_properties_write.snap b/pkg/github/__toolsnaps__/custom_properties_write.snap index 9c9df1f6ca..ee751606c2 100644 --- a/pkg/github/__toolsnaps__/custom_properties_write.snap +++ b/pkg/github/__toolsnaps__/custom_properties_write.snap @@ -170,5 +170,98 @@ ], "type": "object" }, - "name": "custom_properties_write" + "name": "custom_properties_write", + "outputSchema": { + "additionalProperties": false, + "properties": { + "definitions": { + "items": { + "additionalProperties": false, + "properties": { + "allowed_values": { + "items": { + "type": "string" + }, + "type": "array" + }, + "default_value": { + "description": "Repository level only: the value to assign. A string, an array of strings, or null to clear the value.", + "oneOf": [ + { + "type": "string" + }, + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ] + }, + "description": { + "type": "string" + }, + "property_name": { + "type": "string" + }, + "require_explicit_values": { + "type": "boolean" + }, + "required": { + "type": "boolean" + }, + "value_type": { + "enum": [ + "", + "string", + "single_select", + "multi_select", + "true_false", + "url" + ], + "type": "string" + }, + "values_editable_by": { + "enum": [ + "org_actors", + "org_and_repo_actors" + ], + "type": "string" + } + }, + "required": [ + "property_name", + "value_type" + ], + "type": "object" + }, + "type": "array" + }, + "level": { + "enum": [ + "repository", + "organization", + "enterprise" + ], + "type": "string" + }, + "message": { + "type": "string" + }, + "method": { + "enum": [ + "write" + ], + "type": "string" + } + }, + "required": [ + "method", + "level" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/find_duplicate_ff_duplicate_detection.snap b/pkg/github/__toolsnaps__/find_duplicate_ff_duplicate_detection.snap index 7aea7dc254..4490493922 100644 --- a/pkg/github/__toolsnaps__/find_duplicate_ff_duplicate_detection.snap +++ b/pkg/github/__toolsnaps__/find_duplicate_ff_duplicate_detection.snap @@ -20,12 +20,12 @@ "type": "string" }, "page": { - "description": "Page number for pagination (min 1). An explicit 0 is forwarded to the API as-is rather than being replaced with 1.", + "description": "Page number for pagination (min 1)", "minimum": 1, "type": "number" }, "perPage": { - "description": "Results per page for pagination (min 1, max 100). An explicit 0 is forwarded to the API as-is rather than being replaced with 30.", + "description": "Results per page for pagination (min 1, max 100)", "maximum": 100, "minimum": 1, "type": "number" diff --git a/pkg/github/__toolsnaps__/get_gist.snap b/pkg/github/__toolsnaps__/get_gist.snap index b4c053b549..dad5bcbfbe 100644 --- a/pkg/github/__toolsnaps__/get_gist.snap +++ b/pkg/github/__toolsnaps__/get_gist.snap @@ -17,5 +17,91 @@ ], "type": "object" }, - "name": "get_gist" + "name": "get_gist", + "outputSchema": { + "oneOf": [ + { + "type": "null" + }, + { + "additionalProperties": false, + "properties": { + "comments": { + "type": "integer" + }, + "created_at": { + "description": "Creation time in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "description": { + "type": "string" + }, + "files": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "content": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "language": { + "type": "string" + }, + "size": { + "description": "File size in bytes.", + "type": "integer" + }, + "type": { + "description": "File MIME type, when available.", + "type": "string" + } + }, + "type": "object" + }, + "type": "object" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "string" + }, + "owner": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": "object" + }, + "public": { + "description": "True for a public gist; false for a secret, URL-accessible gist.", + "type": "boolean" + }, + "updated_at": { + "description": "Last update time in RFC3339 format.", + "format": "date-time", + "type": "string" + } + }, + "type": "object" + } + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_job_logs_actions.snap b/pkg/github/__toolsnaps__/get_job_logs_actions.snap new file mode 100644 index 0000000000..b49924f2c0 --- /dev/null +++ b/pkg/github/__toolsnaps__/get_job_logs_actions.snap @@ -0,0 +1,237 @@ +{ + "annotations": { + "idempotentHint": false, + "readOnlyHint": true, + "title": "Get GitHub Actions workflow job logs" + }, + "description": "Get logs for GitHub Actions workflow jobs.\nUse this tool to retrieve logs for a specific job or all failed jobs in a workflow run.\nFor single job logs, provide job_id. For all failed jobs in a run, provide run_id with failed_only=true.\n", + "inputSchema": { + "properties": { + "failed_only": { + "description": "When true, gets logs for all failed jobs in the workflow run specified by run_id. Requires run_id to be provided.", + "type": "boolean" + }, + "job_id": { + "description": "The unique identifier of the workflow job. Required when getting logs for a single job.", + "type": "number" + }, + "owner": { + "description": "Repository owner", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "return_content": { + "description": "Returns actual log content instead of URLs", + "type": "boolean" + }, + "run_id": { + "description": "The unique identifier of the workflow run. Required when failed_only is true to get logs for all failed jobs in the run.", + "type": "number" + }, + "tail_lines": { + "default": 500, + "description": "Number of lines to return from the end of the log", + "type": "number" + } + }, + "required": [ + "owner", + "repo" + ], + "type": "object" + }, + "name": "get_job_logs", + "outputSchema": { + "oneOf": [ + { + "type": "null" + }, + { + "additionalProperties": false, + "properties": { + "job_id": { + "type": "integer" + }, + "job_name": { + "type": "string" + }, + "logs_content": { + "type": "string" + }, + "message": { + "type": "string" + }, + "original_length": { + "description": "Number of log lines before truncation.", + "type": "integer" + } + }, + "required": [ + "job_id", + "logs_content", + "message", + "original_length" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "job_id": { + "type": "integer" + }, + "job_name": { + "type": "string" + }, + "logs_url": { + "type": "string" + }, + "message": { + "type": "string" + }, + "note": { + "type": "string" + } + }, + "required": [ + "job_id", + "logs_url", + "message", + "note" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "failed_jobs": { + "type": "integer" + }, + "logs": { + "items": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "job_id": { + "type": "integer" + }, + "job_name": { + "type": "string" + }, + "logs_content": { + "type": "string" + }, + "message": { + "type": "string" + }, + "original_length": { + "description": "Number of log lines before truncation.", + "type": "integer" + } + }, + "required": [ + "job_id", + "logs_content", + "message", + "original_length" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "job_id": { + "type": "integer" + }, + "job_name": { + "type": "string" + }, + "logs_url": { + "type": "string" + }, + "message": { + "type": "string" + }, + "note": { + "type": "string" + } + }, + "required": [ + "job_id", + "logs_url", + "message", + "note" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "error": { + "type": "string" + }, + "job_id": { + "type": "integer" + }, + "job_name": { + "type": "string" + } + }, + "required": [ + "error", + "job_id", + "job_name" + ], + "type": "object" + } + ] + }, + "type": [ + "null", + "array" + ] + }, + "message": { + "type": "string" + }, + "return_format": { + "additionalProperties": false, + "properties": { + "content": { + "type": "boolean" + }, + "urls": { + "type": "boolean" + } + }, + "required": [ + "content", + "urls" + ], + "type": [ + "null", + "object" + ] + }, + "run_id": { + "type": "integer" + }, + "total_jobs": { + "type": "integer" + } + }, + "required": [ + "failed_jobs", + "message", + "run_id", + "total_jobs" + ], + "type": "object" + } + ] + } +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_label.snap b/pkg/github/__toolsnaps__/get_label.snap index b258038506..6cd1ddb6aa 100644 --- a/pkg/github/__toolsnaps__/get_label.snap +++ b/pkg/github/__toolsnaps__/get_label.snap @@ -27,5 +27,26 @@ ], "type": "object" }, - "name": "get_label" + "name": "get_label", + "outputSchema": { + "additionalProperties": false, + "properties": { + "color": { + "description": "Hex RGB without leading #.", + "type": "string" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "color", + "description" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/get_repository_tree.snap b/pkg/github/__toolsnaps__/get_repository_tree.snap index 6ba368f2f2..0a87d92ba2 100644 --- a/pkg/github/__toolsnaps__/get_repository_tree.snap +++ b/pkg/github/__toolsnaps__/get_repository_tree.snap @@ -35,5 +35,92 @@ ], "type": "object" }, - "name": "get_repository_tree" + "name": "get_repository_tree", + "outputSchema": { + "additionalProperties": false, + "properties": { + "count": { + "type": "integer" + }, + "owner": { + "type": "string" + }, + "recursive": { + "type": "boolean" + }, + "repo": { + "type": "string" + }, + "sha": { + "type": "string" + }, + "tree": { + "items": { + "additionalProperties": false, + "properties": { + "mode": { + "description": "Git file mode in octal; empty if absent in the API response.", + "enum": [ + "100644", + "100755", + "040000", + "160000", + "120000", + "" + ], + "type": "string" + }, + "path": { + "type": "string" + }, + "sha": { + "type": "string" + }, + "size": { + "description": "Blob size in bytes, when available.", + "type": "integer" + }, + "type": { + "description": "Git object type; empty if absent in the API response.", + "enum": [ + "blob", + "tree", + "commit", + "" + ], + "type": "string" + } + }, + "required": [ + "path", + "type", + "mode", + "sha" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "tree_sha": { + "type": "string" + }, + "truncated": { + "type": "boolean" + } + }, + "required": [ + "sha", + "truncated", + "tree", + "tree_sha", + "owner", + "repo", + "recursive", + "count" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/label_write.snap b/pkg/github/__toolsnaps__/label_write.snap index e9fdcf0d83..21bfd8f8fe 100644 --- a/pkg/github/__toolsnaps__/label_write.snap +++ b/pkg/github/__toolsnaps__/label_write.snap @@ -50,5 +50,26 @@ ], "type": "object" }, - "name": "label_write" + "name": "label_write", + "outputSchema": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + }, + "method": { + "enum": [ + "create", + "update", + "delete" + ], + "type": "string" + } + }, + "required": [ + "method", + "message" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_gists.snap b/pkg/github/__toolsnaps__/list_gists.snap index 8f09ff3880..185eb5b1f1 100644 --- a/pkg/github/__toolsnaps__/list_gists.snap +++ b/pkg/github/__toolsnaps__/list_gists.snap @@ -29,5 +29,97 @@ }, "type": "object" }, - "name": "list_gists" + "name": "list_gists", + "outputSchema": { + "items": { + "oneOf": [ + { + "type": "null" + }, + { + "additionalProperties": false, + "properties": { + "comments": { + "type": "integer" + }, + "created_at": { + "description": "Creation time in RFC3339 format.", + "format": "date-time", + "type": "string" + }, + "description": { + "type": "string" + }, + "files": { + "additionalProperties": { + "additionalProperties": false, + "properties": { + "content": { + "type": "string" + }, + "filename": { + "type": "string" + }, + "language": { + "type": "string" + }, + "size": { + "description": "File size in bytes.", + "type": "integer" + }, + "type": { + "description": "File MIME type, when available.", + "type": "string" + } + }, + "type": "object" + }, + "type": "object" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "string" + }, + "owner": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": "object" + }, + "public": { + "description": "True for a public gist; false for a secret, URL-accessible gist.", + "type": "boolean" + }, + "updated_at": { + "description": "Last update time in RFC3339 format.", + "format": "date-time", + "type": "string" + } + }, + "type": "object" + } + ] + }, + "type": [ + "array", + "null" + ] + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/list_label.snap b/pkg/github/__toolsnaps__/list_label.snap index 37e45b15fb..f85cd9a77d 100644 --- a/pkg/github/__toolsnaps__/list_label.snap +++ b/pkg/github/__toolsnaps__/list_label.snap @@ -22,5 +22,46 @@ ], "type": "object" }, - "name": "list_label" + "name": "list_label", + "outputSchema": { + "additionalProperties": false, + "properties": { + "labels": { + "items": { + "additionalProperties": false, + "properties": { + "color": { + "description": "Hex RGB without leading #.", + "type": "string" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "color", + "description" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "totalCount": { + "description": "Total labels in the repository.", + "type": "integer" + } + }, + "required": [ + "labels", + "totalCount" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/projects_get.snap b/pkg/github/__toolsnaps__/projects_get.snap index 1380e84d5d..087a59d8ee 100644 --- a/pkg/github/__toolsnaps__/projects_get.snap +++ b/pkg/github/__toolsnaps__/projects_get.snap @@ -70,5 +70,741 @@ ], "type": "object" }, - "name": "projects_get" + "name": "projects_get", + "outputSchema": { + "additionalProperties": false, + "properties": { + "field": { + "additionalProperties": false, + "properties": { + "configuration": { + "additionalProperties": false, + "properties": { + "duration": { + "description": "Iteration duration in days.", + "type": "integer" + }, + "iterations": { + "items": { + "additionalProperties": false, + "properties": { + "duration": { + "description": "Iteration duration in days.", + "type": "integer" + }, + "id": { + "type": "string" + }, + "start_date": { + "description": "Date in YYYY-MM-DD format.", + "type": "string" + }, + "title": { + "additionalProperties": false, + "properties": { + "html": { + "type": "string" + }, + "raw": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + }, + "start_day": { + "type": "integer" + } + }, + "type": "object" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "format": "date-time", + "type": "string" + }, + "data_type": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "options": { + "items": { + "additionalProperties": false, + "properties": { + "color": { + "type": "string" + }, + "description": { + "additionalProperties": false, + "properties": { + "html": { + "type": "string" + }, + "raw": { + "type": "string" + } + }, + "type": "object" + }, + "id": { + "type": "string" + }, + "name": { + "additionalProperties": false, + "properties": { + "html": { + "type": "string" + }, + "raw": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + }, + "updated_at": { + "description": "RFC3339 timestamp.", + "format": "date-time", + "type": "string" + } + }, + "type": "object" + }, + "item": { + "additionalProperties": false, + "properties": { + "archived_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "content": { + "additionalProperties": false, + "properties": { + "assignees": { + "items": { + "type": "string" + }, + "type": "array" + }, + "author": { + "type": "string" + }, + "closed_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "comments": { + "type": "integer" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "draft": { + "type": "boolean" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "labels": { + "items": { + "type": "string" + }, + "type": "array" + }, + "merged": { + "type": "boolean" + }, + "merged_at": { + "type": "string" + }, + "milestone": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "repository": { + "type": "string" + }, + "state": { + "type": "string" + }, + "state_reason": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "description": "RFC3339 timestamp.", + "type": "string" + } + }, + "type": "object" + }, + "content_type": { + "type": "string" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "creator": { + "type": "string" + }, + "fields": { + "items": { + "additionalProperties": false, + "properties": { + "data_type": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "value": { + "description": "Recursively compacted JSON field value; unknown field types retain arbitrary object keys." + } + }, + "type": "object" + }, + "type": "array" + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "updated_at": { + "description": "RFC3339 timestamp.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "method": { + "enum": [ + "get_project", + "get_project_field", + "get_project_item", + "get_project_status_update", + "get_project_view" + ], + "type": "string" + }, + "project": { + "additionalProperties": false, + "properties": { + "closed_at": { + "description": "RFC3339 timestamp.", + "format": "date-time", + "type": "string" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "format": "date-time", + "type": "string" + }, + "creator": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "RFC3339 timestamp.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": "object" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": "object" + }, + "deleted_at": { + "description": "RFC3339 timestamp.", + "format": "date-time", + "type": "string" + }, + "deleted_by": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "RFC3339 timestamp.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": "object" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": "object" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "owner": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "RFC3339 timestamp.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": "object" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": "object" + }, + "owner_type": { + "type": "string" + }, + "public": { + "type": "boolean" + }, + "short_description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "description": "RFC3339 timestamp.", + "format": "date-time", + "type": "string" + } + }, + "type": "object" + }, + "status_update": { + "additionalProperties": false, + "properties": { + "body": { + "type": "string" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "creator": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "RFC3339 timestamp.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": "object" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": "object" + }, + "id": { + "type": "string" + }, + "start_date": { + "description": "Date in YYYY-MM-DD format.", + "type": "string" + }, + "status": { + "enum": [ + "INACTIVE", + "ON_TRACK", + "AT_RISK", + "OFF_TRACK", + "COMPLETE" + ], + "type": "string" + }, + "target_date": { + "description": "Date in YYYY-MM-DD format.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "view": { + "additionalProperties": false, + "properties": { + "filter": { + "type": "string" + }, + "id": { + "type": "string" + }, + "layout": { + "enum": [ + "board", + "table", + "roadmap" + ], + "type": "string" + }, + "name": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "visible_fields": { + "items": { + "type": "integer" + }, + "type": "array" + } + }, + "required": [ + "id", + "number", + "name", + "layout", + "filter", + "visible_fields" + ], + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/projects_list.snap b/pkg/github/__toolsnaps__/projects_list.snap index c64737900f..eb44bca6ae 100644 --- a/pkg/github/__toolsnaps__/projects_list.snap +++ b/pkg/github/__toolsnaps__/projects_list.snap @@ -71,5 +71,937 @@ ], "type": "object" }, - "name": "projects_list" + "name": "projects_list", + "outputSchema": { + "additionalProperties": false, + "properties": { + "fields": { + "additionalProperties": false, + "properties": { + "fields": { + "items": { + "additionalProperties": false, + "properties": { + "configuration": { + "additionalProperties": false, + "properties": { + "duration": { + "description": "Iteration duration in days.", + "type": "integer" + }, + "iterations": { + "items": { + "additionalProperties": false, + "properties": { + "duration": { + "description": "Iteration duration in days.", + "type": "integer" + }, + "id": { + "type": "string" + }, + "start_date": { + "description": "Date in YYYY-MM-DD format.", + "type": "string" + }, + "title": { + "additionalProperties": false, + "properties": { + "html": { + "type": "string" + }, + "raw": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + }, + "start_day": { + "type": "integer" + } + }, + "type": "object" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "format": "date-time", + "type": "string" + }, + "data_type": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "options": { + "items": { + "additionalProperties": false, + "properties": { + "color": { + "type": "string" + }, + "description": { + "additionalProperties": false, + "properties": { + "html": { + "type": "string" + }, + "raw": { + "type": "string" + } + }, + "type": "object" + }, + "id": { + "type": "string" + }, + "name": { + "additionalProperties": false, + "properties": { + "html": { + "type": "string" + }, + "raw": { + "type": "string" + } + }, + "type": "object" + } + }, + "type": "object" + }, + "type": "array" + }, + "updated_at": { + "description": "RFC3339 timestamp.", + "format": "date-time", + "type": "string" + } + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "pageInfo": { + "additionalProperties": false, + "properties": { + "hasNextPage": { + "type": "boolean" + }, + "hasPreviousPage": { + "type": "boolean" + }, + "nextCursor": { + "type": "string" + }, + "prevCursor": { + "type": "string" + } + }, + "required": [ + "hasNextPage", + "hasPreviousPage" + ], + "type": "object" + } + }, + "required": [ + "fields", + "pageInfo" + ], + "type": "object" + }, + "items": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "additionalProperties": false, + "properties": { + "archived_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "content": { + "additionalProperties": false, + "properties": { + "assignees": { + "items": { + "type": "string" + }, + "type": "array" + }, + "author": { + "type": "string" + }, + "closed_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "comments": { + "type": "integer" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "draft": { + "type": "boolean" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "labels": { + "items": { + "type": "string" + }, + "type": "array" + }, + "merged": { + "type": "boolean" + }, + "merged_at": { + "type": "string" + }, + "milestone": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "repository": { + "type": "string" + }, + "state": { + "type": "string" + }, + "state_reason": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "description": "RFC3339 timestamp.", + "type": "string" + } + }, + "type": "object" + }, + "content_type": { + "type": "string" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "creator": { + "type": "string" + }, + "fields": { + "items": { + "additionalProperties": false, + "properties": { + "data_type": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "value": { + "description": "Recursively compacted JSON field value; unknown field types retain arbitrary object keys." + } + }, + "type": "object" + }, + "type": "array" + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "updated_at": { + "description": "RFC3339 timestamp.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "pageInfo": { + "additionalProperties": false, + "properties": { + "hasNextPage": { + "type": "boolean" + }, + "hasPreviousPage": { + "type": "boolean" + }, + "nextCursor": { + "type": "string" + }, + "prevCursor": { + "type": "string" + } + }, + "required": [ + "hasNextPage", + "hasPreviousPage" + ], + "type": "object" + } + }, + "required": [ + "items", + "pageInfo" + ], + "type": "object" + }, + "method": { + "enum": [ + "list_projects", + "list_project_fields", + "list_project_items", + "list_project_status_updates", + "list_project_views" + ], + "type": "string" + }, + "projects": { + "additionalProperties": false, + "properties": { + "note": { + "type": "string" + }, + "pageInfo": { + "additionalProperties": false, + "properties": { + "hasNextPage": { + "type": "boolean" + }, + "hasPreviousPage": { + "type": "boolean" + }, + "nextCursor": { + "type": "string" + }, + "prevCursor": { + "type": "string" + } + }, + "required": [ + "hasNextPage", + "hasPreviousPage" + ], + "type": "object" + }, + "projects": { + "items": { + "additionalProperties": false, + "properties": { + "closed_at": { + "description": "RFC3339 timestamp.", + "format": "date-time", + "type": "string" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "format": "date-time", + "type": "string" + }, + "creator": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "RFC3339 timestamp.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": "object" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": "object" + }, + "deleted_at": { + "description": "RFC3339 timestamp.", + "format": "date-time", + "type": "string" + }, + "deleted_by": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "RFC3339 timestamp.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": "object" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": "object" + }, + "description": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "owner": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "RFC3339 timestamp.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": "object" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": "object" + }, + "owner_type": { + "type": "string" + }, + "public": { + "type": "boolean" + }, + "short_description": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "description": "RFC3339 timestamp.", + "format": "date-time", + "type": "string" + } + }, + "type": "object" + }, + "type": [ + "null", + "array" + ] + } + }, + "required": [ + "projects" + ], + "type": "object" + }, + "status_updates": { + "additionalProperties": false, + "properties": { + "pageInfo": { + "additionalProperties": false, + "properties": { + "hasNextPage": { + "type": "boolean" + }, + "hasPreviousPage": { + "type": "boolean" + }, + "nextCursor": { + "type": "string" + }, + "prevCursor": { + "type": "string" + } + }, + "required": [ + "hasNextPage", + "hasPreviousPage", + "nextCursor", + "prevCursor" + ], + "type": "object" + }, + "statusUpdates": { + "items": { + "additionalProperties": false, + "properties": { + "body": { + "type": "string" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "creator": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "RFC3339 timestamp.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": "object" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": "object" + }, + "id": { + "type": "string" + }, + "start_date": { + "description": "Date in YYYY-MM-DD format.", + "type": "string" + }, + "status": { + "enum": [ + "INACTIVE", + "ON_TRACK", + "AT_RISK", + "OFF_TRACK", + "COMPLETE" + ], + "type": "string" + }, + "target_date": { + "description": "Date in YYYY-MM-DD format.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } + }, + "required": [ + "statusUpdates", + "pageInfo" + ], + "type": "object" + }, + "views": { + "additionalProperties": false, + "properties": { + "pageInfo": { + "additionalProperties": false, + "properties": { + "hasNextPage": { + "type": "boolean" + }, + "hasPreviousPage": { + "type": "boolean" + }, + "nextCursor": { + "type": "string" + }, + "prevCursor": { + "type": "string" + } + }, + "required": [ + "hasNextPage", + "hasPreviousPage", + "nextCursor", + "prevCursor" + ], + "type": "object" + }, + "views": { + "items": { + "additionalProperties": false, + "properties": { + "filter": { + "type": "string" + }, + "id": { + "type": "string" + }, + "layout": { + "enum": [ + "board", + "table", + "roadmap" + ], + "type": "string" + }, + "name": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "visible_fields": { + "items": { + "type": "integer" + }, + "type": "array" + } + }, + "required": [ + "id", + "number", + "name", + "layout", + "filter", + "visible_fields" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } + }, + "required": [ + "views", + "pageInfo" + ], + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap index 0ea38c2b0a..fde6fa1a6f 100644 --- a/pkg/github/__toolsnaps__/projects_write.snap +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -277,5 +277,576 @@ ], "type": "object" }, - "name": "projects_write" + "name": "projects_write", + "outputSchema": { + "additionalProperties": false, + "properties": { + "added": { + "additionalProperties": false, + "properties": { + "full_database_id": { + "type": "string" + }, + "id": { + "type": [ + "null", + "string" + ] + }, + "item_id": { + "type": "integer" + }, + "message": { + "type": "string" + } + }, + "required": [ + "id", + "message" + ], + "type": "object" + }, + "batch": { + "additionalProperties": false, + "properties": { + "failed": { + "type": "integer" + }, + "results": { + "items": { + "additionalProperties": false, + "properties": { + "error": { + "additionalProperties": false, + "properties": { + "candidates": { + "items": { + "additionalProperties": false, + "properties": { + "data_type": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "type": "object" + }, + "type": "array" + }, + "code": { + "type": "string" + }, + "hint": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ], + "type": "object" + }, + "index": { + "type": "integer" + }, + "item": { + "additionalProperties": false, + "properties": { + "full_database_id": { + "type": "string" + }, + "item_id": { + "type": "integer" + }, + "node_id": { + "type": "string" + } + }, + "type": "object" + }, + "ref": { + "additionalProperties": true, + "description": "Original batch reference, including malformed or extra fields.", + "type": "object" + }, + "status": { + "enum": [ + "succeeded", + "failed", + "unknown" + ], + "type": "string" + } + }, + "required": [ + "index", + "status" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "succeeded": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "unknown": { + "type": "integer" + } + }, + "required": [ + "total", + "succeeded", + "failed", + "unknown", + "results" + ], + "type": "object" + }, + "deleted_item": { + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + }, + "required": [ + "message" + ], + "type": "object" + }, + "deleted_view": { + "additionalProperties": false, + "properties": { + "deleted_view_id": { + "type": "string" + } + }, + "required": [ + "deleted_view_id" + ], + "type": "object" + }, + "issue_fields": { + "additionalProperties": false, + "properties": { + "html_url": { + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "id", + "html_url" + ], + "type": "object" + }, + "item": { + "additionalProperties": false, + "properties": { + "archived_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "content": { + "additionalProperties": false, + "properties": { + "assignees": { + "items": { + "type": "string" + }, + "type": "array" + }, + "author": { + "type": "string" + }, + "closed_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "comments": { + "type": "integer" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "draft": { + "type": "boolean" + }, + "html_url": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "labels": { + "items": { + "type": "string" + }, + "type": "array" + }, + "merged": { + "type": "boolean" + }, + "merged_at": { + "type": "string" + }, + "milestone": { + "type": "string" + }, + "node_id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "repository": { + "type": "string" + }, + "state": { + "type": "string" + }, + "state_reason": { + "type": "string" + }, + "title": { + "type": "string" + }, + "updated_at": { + "description": "RFC3339 timestamp.", + "type": "string" + } + }, + "type": "object" + }, + "content_type": { + "type": "string" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "creator": { + "type": "string" + }, + "fields": { + "items": { + "additionalProperties": false, + "properties": { + "data_type": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "value": { + "description": "Recursively compacted JSON field value; unknown field types retain arbitrary object keys." + } + }, + "type": "object" + }, + "type": "array" + }, + "id": { + "type": "integer" + }, + "node_id": { + "type": "string" + }, + "updated_at": { + "description": "RFC3339 timestamp.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "iteration_field": { + "additionalProperties": false, + "properties": { + "configuration": { + "additionalProperties": false, + "properties": { + "iterations": { + "items": { + "additionalProperties": false, + "properties": { + "duration": { + "description": "Iteration duration in days.", + "type": "integer" + }, + "id": { + "type": "string" + }, + "start_date": { + "description": "Date in YYYY-MM-DD format.", + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "id", + "title", + "start_date", + "duration" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } + }, + "required": [ + "iterations" + ], + "type": "object" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "configuration" + ], + "type": "object" + }, + "method": { + "enum": [ + "add_project_item", + "update_project_item", + "update_project_items", + "delete_project_item", + "create_project_status_update", + "create_project_view", + "update_project_view", + "delete_project_view", + "create_project", + "create_iteration_field" + ], + "type": "string" + }, + "project": { + "additionalProperties": false, + "properties": { + "html_url": { + "type": "string" + }, + "id": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "title": { + "type": "string" + } + }, + "required": [ + "id", + "number", + "title", + "html_url" + ], + "type": "object" + }, + "status_update": { + "additionalProperties": false, + "properties": { + "body": { + "type": "string" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "creator": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "details": { + "additionalProperties": false, + "properties": { + "bio": { + "type": "string" + }, + "blog": { + "type": "string" + }, + "company": { + "type": "string" + }, + "created_at": { + "description": "RFC3339 timestamp.", + "type": "string" + }, + "email": { + "type": "string" + }, + "followers": { + "type": "integer" + }, + "following": { + "type": "integer" + }, + "hireable": { + "type": "boolean" + }, + "location": { + "type": "string" + }, + "name": { + "type": "string" + }, + "owned_private_repos": { + "type": "integer" + }, + "private_gists": { + "type": "integer" + }, + "public_gists": { + "type": "integer" + }, + "public_repos": { + "type": "integer" + }, + "total_private_repos": { + "type": "integer" + }, + "twitter_username": { + "type": "string" + }, + "updated_at": { + "description": "RFC3339 timestamp.", + "type": "string" + } + }, + "required": [ + "public_repos", + "public_gists", + "followers", + "following", + "created_at", + "updated_at" + ], + "type": "object" + }, + "id": { + "type": "integer" + }, + "login": { + "type": "string" + }, + "profile_url": { + "type": "string" + } + }, + "required": [ + "login" + ], + "type": "object" + }, + "id": { + "type": "string" + }, + "start_date": { + "description": "Date in YYYY-MM-DD format.", + "type": "string" + }, + "status": { + "enum": [ + "INACTIVE", + "ON_TRACK", + "AT_RISK", + "OFF_TRACK", + "COMPLETE" + ], + "type": "string" + }, + "target_date": { + "description": "Date in YYYY-MM-DD format.", + "type": "string" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "view": { + "additionalProperties": false, + "properties": { + "filter": { + "type": "string" + }, + "id": { + "type": "string" + }, + "layout": { + "enum": [ + "board", + "table", + "roadmap" + ], + "type": "string" + }, + "name": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "visible_fields": { + "items": { + "type": "integer" + }, + "type": "array" + } + }, + "required": [ + "id", + "number", + "name", + "layout", + "filter", + "visible_fields" + ], + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/repository_ruleset_read.snap b/pkg/github/__toolsnaps__/repository_ruleset_read.snap index 47b4c3742d..b1e887f90a 100644 --- a/pkg/github/__toolsnaps__/repository_ruleset_read.snap +++ b/pkg/github/__toolsnaps__/repository_ruleset_read.snap @@ -118,5 +118,426 @@ ], "type": "object" }, - "name": "repository_ruleset_read" + "name": "repository_ruleset_read", + "outputSchema": { + "$defs": { + "idCondition": { + "additionalProperties": false, + "properties": { + "organization_ids": { + "items": { + "type": "integer" + }, + "type": "array" + }, + "repository_ids": { + "items": { + "type": "integer" + }, + "type": "array" + } + }, + "type": "object" + }, + "nameCondition": { + "additionalProperties": false, + "properties": { + "exclude": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "include": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "protected": { + "type": "boolean" + } + }, + "required": [ + "include", + "exclude" + ], + "type": "object" + }, + "propertyCondition": { + "additionalProperties": false, + "properties": { + "exclude": { + "items": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "property_values": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "source": { + "type": "string" + } + }, + "required": [ + "name", + "property_values" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + }, + "include": { + "items": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "property_values": { + "items": { + "type": "string" + }, + "type": [ + "null", + "array" + ] + }, + "source": { + "type": "string" + } + }, + "required": [ + "name", + "property_values" + ], + "type": "object" + }, + "type": [ + "null", + "array" + ] + } + }, + "required": [ + "include", + "exclude" + ], + "type": "object" + }, + "rule": { + "additionalProperties": false, + "properties": { + "parameters": { + "description": "User-configured parameters for this rule type.", + "type": "object" + }, + "ruleset_id": { + "type": "integer" + }, + "ruleset_source": { + "type": "string" + }, + "ruleset_source_type": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type" + ], + "type": "object" + }, + "ruleSuite": { + "additionalProperties": false, + "properties": { + "actor_id": { + "type": "integer" + }, + "actor_name": { + "type": "string" + }, + "after_sha": { + "type": "string" + }, + "before_sha": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "pushed_at": { + "description": "RFC3339 push time.", + "format": "date-time", + "type": "string" + }, + "ref": { + "type": "string" + }, + "repository_id": { + "type": "integer" + }, + "repository_name": { + "type": "string" + }, + "result": { + "enum": [ + "pass", + "fail", + "bypass" + ], + "type": "string" + }, + "rule_evaluations": { + "items": { + "additionalProperties": false, + "properties": { + "details": { + "type": "string" + }, + "enforcement": { + "type": "string" + }, + "result": { + "type": "string" + }, + "rule_source": { + "additionalProperties": false, + "properties": { + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": [ + "type", + "id" + ], + "type": "object" + }, + "rule_type": { + "type": "string" + } + }, + "required": [ + "result", + "rule_type" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "id", + "result" + ], + "type": "object" + }, + "ruleset": { + "additionalProperties": false, + "properties": { + "bypass_actors": { + "items": { + "additionalProperties": false, + "properties": { + "actor_id": { + "type": "integer" + }, + "actor_type": { + "type": "string" + }, + "bypass_mode": { + "type": "string" + } + }, + "required": [ + "actor_type", + "bypass_mode" + ], + "type": "object" + }, + "type": "array" + }, + "conditions": { + "additionalProperties": false, + "properties": { + "organization_id": { + "$ref": "#/$defs/idCondition", + "type": "object" + }, + "organization_name": { + "$ref": "#/$defs/nameCondition", + "type": "object" + }, + "organization_property": { + "$ref": "#/$defs/propertyCondition", + "type": "object" + }, + "ref_name": { + "$ref": "#/$defs/nameCondition", + "type": "object" + }, + "repository_id": { + "$ref": "#/$defs/idCondition", + "type": "object" + }, + "repository_name": { + "$ref": "#/$defs/nameCondition", + "type": "object" + }, + "repository_property": { + "$ref": "#/$defs/propertyCondition", + "type": "object" + } + }, + "type": "object" + }, + "created_at": { + "description": "RFC3339 creation time.", + "format": "date-time", + "type": "string" + }, + "current_user_can_bypass": { + "type": "string" + }, + "enforcement": { + "enum": [ + "", + "disabled", + "active", + "evaluate" + ], + "type": "string" + }, + "id": { + "type": "integer" + }, + "name": { + "type": "string" + }, + "rules": { + "items": { + "$ref": "#/$defs/rule", + "type": "object" + }, + "type": "array" + }, + "source": { + "type": "string" + }, + "source_type": { + "enum": [ + "Repository", + "Organization", + "Enterprise" + ], + "type": "string" + }, + "target": { + "enum": [ + "branch", + "tag", + "push", + "repository" + ], + "type": "string" + }, + "updated_at": { + "description": "RFC3339 last update time.", + "format": "date-time", + "type": "string" + } + }, + "required": [ + "name", + "source", + "enforcement" + ], + "type": "object" + } + }, + "additionalProperties": false, + "properties": { + "level": { + "enum": [ + "repository", + "organization", + "enterprise" + ], + "type": "string" + }, + "method": { + "enum": [ + "get", + "list", + "get_rules_for_branch", + "list_rule_suites", + "get_rule_suite" + ], + "type": "string" + }, + "rule_suite": { + "$ref": "#/$defs/ruleSuite", + "type": "object" + }, + "rule_suites": { + "items": { + "$ref": "#/$defs/ruleSuite", + "type": "object" + }, + "type": "array" + }, + "rules": { + "items": { + "$ref": "#/$defs/rule", + "type": "object" + }, + "type": "array" + }, + "ruleset": { + "$ref": "#/$defs/ruleset", + "type": "object" + }, + "rulesets": { + "items": { + "$ref": "#/$defs/ruleset", + "type": "object" + }, + "type": "array" + }, + "total_count": { + "type": "integer" + } + }, + "required": [ + "method", + "level" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/request_copilot_review.snap b/pkg/github/__toolsnaps__/request_copilot_review.snap index 5e8ecdca19..2847b5f534 100644 --- a/pkg/github/__toolsnaps__/request_copilot_review.snap +++ b/pkg/github/__toolsnaps__/request_copilot_review.snap @@ -39,5 +39,18 @@ ], "type": "object" }, - "name": "request_copilot_review" + "name": "request_copilot_review", + "outputSchema": { + "additionalProperties": false, + "properties": { + "status": { + "description": "Outcome of the Copilot review request; requested on success.", + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/submit_pending_pull_request_review.snap b/pkg/github/__toolsnaps__/submit_pending_pull_request_review.snap index 526d38b560..818b6fd198 100644 --- a/pkg/github/__toolsnaps__/submit_pending_pull_request_review.snap +++ b/pkg/github/__toolsnaps__/submit_pending_pull_request_review.snap @@ -26,7 +26,7 @@ "type": "string" }, "event": { - "description": "The review action to perform (APPROVE, REQUEST_CHANGES, or COMMENT)", + "description": "The review action to perform", "enum": [ "APPROVE", "REQUEST_CHANGES", diff --git a/pkg/github/__toolsnaps__/ui_get.snap b/pkg/github/__toolsnaps__/ui_get.snap index 85b562156d..3ab16de343 100644 --- a/pkg/github/__toolsnaps__/ui_get.snap +++ b/pkg/github/__toolsnaps__/ui_get.snap @@ -42,5 +42,390 @@ ], "type": "object" }, - "name": "ui_get" + "name": "ui_get", + "outputSchema": { + "additionalProperties": false, + "properties": { + "assignees": { + "additionalProperties": false, + "properties": { + "assignees": { + "items": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "login": { + "type": "string" + } + }, + "required": [ + "login", + "avatar_url" + ], + "type": "object" + }, + "type": "array" + }, + "has_more": { + "description": "True when results were truncated at the pagination limit.", + "type": "boolean" + }, + "totalCount": { + "description": "Total number of matching assignees.", + "type": "integer" + } + }, + "required": [ + "assignees", + "totalCount", + "has_more" + ], + "type": "object" + }, + "branches": { + "additionalProperties": false, + "properties": { + "branches": { + "items": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "protected": { + "type": "boolean" + }, + "sha": { + "type": "string" + } + }, + "required": [ + "name", + "sha", + "protected" + ], + "type": "object" + }, + "type": "array" + }, + "has_more": { + "description": "True when results were truncated at the pagination limit.", + "type": "boolean" + }, + "totalCount": { + "description": "Number of branches returned.", + "type": "integer" + } + }, + "required": [ + "branches", + "totalCount", + "has_more" + ], + "type": "object" + }, + "issue_fields": { + "additionalProperties": false, + "properties": { + "fields": { + "items": { + "additionalProperties": false, + "properties": { + "data_type": { + "enum": [ + "text", + "number", + "date", + "single_select" + ], + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "options": { + "items": { + "additionalProperties": false, + "properties": { + "color": { + "type": "string" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "description", + "color" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "id", + "name", + "data_type", + "description" + ], + "type": "object" + }, + "type": "array" + }, + "totalCount": { + "description": "Number of supported issue fields returned.", + "type": "integer" + } + }, + "required": [ + "fields", + "totalCount" + ], + "type": "object" + }, + "issue_types": { + "items": { + "additionalProperties": false, + "properties": { + "color": { + "type": [ + "null", + "string" + ] + }, + "description": { + "type": [ + "null", + "string" + ] + }, + "id": { + "type": [ + "null", + "integer" + ] + }, + "is_enabled": { + "type": [ + "null", + "boolean" + ] + }, + "name": { + "type": [ + "null", + "string" + ] + } + }, + "type": [ + "null", + "object" + ] + }, + "type": [ + "array", + "null" + ] + }, + "labels": { + "additionalProperties": false, + "properties": { + "has_more": { + "description": "True when results were truncated at the pagination limit.", + "type": "boolean" + }, + "labels": { + "items": { + "additionalProperties": false, + "properties": { + "color": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "name", + "color", + "description" + ], + "type": "object" + }, + "type": "array" + }, + "totalCount": { + "description": "Total number of matching labels.", + "type": "integer" + } + }, + "required": [ + "labels", + "totalCount", + "has_more" + ], + "type": "object" + }, + "method": { + "description": "The method that produced the corresponding typed output.", + "enum": [ + "labels", + "assignees", + "milestones", + "issue_types", + "branches", + "issue_fields", + "reviewers" + ], + "type": "string" + }, + "milestones": { + "additionalProperties": false, + "properties": { + "has_more": { + "description": "True when results were truncated at the pagination limit.", + "type": "boolean" + }, + "milestones": { + "items": { + "additionalProperties": false, + "properties": { + "description": { + "type": "string" + }, + "due_on": { + "description": "Due date in YYYY-MM-DD form, or empty when no due date is set.", + "type": "string" + }, + "number": { + "description": "Milestone number.", + "type": "integer" + }, + "open_issues": { + "description": "Number of open issues assigned to the milestone.", + "type": "integer" + }, + "state": { + "enum": [ + "open", + "closed" + ], + "type": "string" + }, + "title": { + "type": "string" + } + }, + "required": [ + "number", + "title", + "description", + "state", + "open_issues", + "due_on" + ], + "type": "object" + }, + "type": "array" + }, + "totalCount": { + "description": "Total number of matching milestones.", + "type": "integer" + } + }, + "required": [ + "milestones", + "totalCount", + "has_more" + ], + "type": "object" + }, + "reviewers": { + "additionalProperties": false, + "properties": { + "has_more": { + "type": "boolean" + }, + "teams": { + "items": { + "additionalProperties": false, + "properties": { + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "slug", + "name", + "org" + ], + "type": "object" + }, + "type": "array" + }, + "totalCount": { + "description": "Total number of matching users and teams.", + "type": "integer" + }, + "users": { + "items": { + "additionalProperties": false, + "properties": { + "avatar_url": { + "type": "string" + }, + "login": { + "type": "string" + } + }, + "required": [ + "login", + "avatar_url" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "users", + "teams", + "totalCount", + "has_more" + ], + "type": "object" + } + }, + "required": [ + "method" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_gist.snap b/pkg/github/__toolsnaps__/update_gist.snap index c004a8bf14..ec3ba59278 100644 --- a/pkg/github/__toolsnaps__/update_gist.snap +++ b/pkg/github/__toolsnaps__/update_gist.snap @@ -31,5 +31,21 @@ ], "type": "object" }, - "name": "update_gist" + "name": "update_gist", + "outputSchema": { + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "url": { + "type": "string" + } + }, + "required": [ + "id", + "url" + ], + "type": "object" + } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/update_pull_request_state.snap b/pkg/github/__toolsnaps__/update_pull_request_state.snap index 8a01c91530..aefdde8c42 100644 --- a/pkg/github/__toolsnaps__/update_pull_request_state.snap +++ b/pkg/github/__toolsnaps__/update_pull_request_state.snap @@ -37,7 +37,7 @@ "x-mcp-header": "repo" }, "state": { - "description": "The new state for the pull request (open or closed)", + "description": "The new state for the pull request", "enum": [ "open", "closed" diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 970e9fa31a..0f22c3cc5f 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "net/http" + "reflect" "strconv" "strings" @@ -47,7 +48,7 @@ const ( ) // handleFailedJobLogs gets logs for all failed jobs in a workflow run -func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, any, error) { +func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, *ActionsJobLogsOutput, error) { // First, get all jobs for the workflow run jobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, runID, &github.ListWorkflowJobsOptions{ Filter: "latest", @@ -66,41 +67,38 @@ func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo } if len(failedJobs) == 0 { - result := map[string]any{ - "message": "No failed jobs found in this workflow run", - "run_id": runID, - "total_jobs": len(jobs.Jobs), - "failed_jobs": 0, + result := &ActionsFailedJobLogsOutput{ + Message: "No failed jobs found in this workflow run", + RunID: runID, + TotalJobs: len(jobs.Jobs), } r, _ := json.Marshal(result) - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsJobLogsOutput{Failed: result}, nil } // Collect logs for all failed jobs - var logResults []map[string]any + var logResults []ActionsJobLog for _, job := range failedJobs { jobResult, resp, err := getJobLogData(ctx, client, owner, repo, job.GetID(), job.GetName(), returnContent, tailLines, contentWindowSize) if err != nil { // Continue with other jobs even if one fails - jobResult = map[string]any{ - "job_id": job.GetID(), - "job_name": job.GetName(), - "error": err.Error(), - } + jobResult = &ActionsJobLog{Error: &ActionsJobLogError{ + JobID: job.GetID(), JobName: job.GetName(), Error: err.Error(), + }} // Enable reporting of status codes and error causes _, _ = ghErrors.NewGitHubAPIErrorToCtx(ctx, "failed to get job logs", resp, err) // Explicitly ignore error for graceful handling } - logResults = append(logResults, jobResult) + logResults = append(logResults, *jobResult) } - result := map[string]any{ - "message": fmt.Sprintf("Retrieved logs for %d failed jobs", len(failedJobs)), - "run_id": runID, - "total_jobs": len(jobs.Jobs), - "failed_jobs": len(failedJobs), - "logs": logResults, - "return_format": map[string]bool{"content": returnContent, "urls": !returnContent}, + result := &ActionsFailedJobLogsOutput{ + Message: fmt.Sprintf("Retrieved logs for %d failed jobs", len(failedJobs)), + RunID: runID, + TotalJobs: len(jobs.Jobs), + FailedJobs: len(failedJobs), + Logs: &logResults, + ReturnFormat: &ActionsLogsReturnFormat{Content: returnContent, URLs: !returnContent}, } r, err := json.Marshal(result) @@ -108,11 +106,11 @@ func handleFailedJobLogs(ctx context.Context, client *github.Client, owner, repo return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsJobLogsOutput{Failed: result}, nil } // handleSingleJobLogs gets logs for a single job -func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, any, error) { +func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo string, jobID int64, returnContent bool, tailLines int, contentWindowSize int) (*mcp.CallToolResult, *ActionsJobLogsOutput, error) { jobResult, resp, err := getJobLogData(ctx, client, owner, repo, jobID, "", returnContent, tailLines, contentWindowSize) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get job logs", resp, err), nil, nil @@ -123,11 +121,11 @@ func handleSingleJobLogs(ctx context.Context, client *github.Client, owner, repo return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsJobLogsOutput{Single: jobResult}, nil } // getJobLogData retrieves log data for a single job, either as URL or content -func getJobLogData(ctx context.Context, client *github.Client, owner, repo string, jobID int64, jobName string, returnContent bool, tailLines int, contentWindowSize int) (map[string]any, *github.Response, error) { +func getJobLogData(ctx context.Context, client *github.Client, owner, repo string, jobID int64, jobName string, returnContent bool, tailLines int, contentWindowSize int) (*ActionsJobLog, *github.Response, error) { // Get the download URL for the job logs url, resp, err := client.Actions.GetWorkflowJobLogs(ctx, owner, repo, jobID, 1) if err != nil { @@ -135,13 +133,7 @@ func getJobLogData(ctx context.Context, client *github.Client, owner, repo strin } defer func() { _ = resp.Body.Close() }() - result := map[string]any{ - "job_id": jobID, - } - if jobName != "" { - result["job_name"] = jobName - } - + result := &ActionsJobLog{} if returnContent { // Download and return the actual log content content, originalLength, httpResp, err := downloadLogContent(ctx, url.String(), tailLines, contentWindowSize) //nolint:bodyclose // Response body is closed in downloadLogContent, but we need to return httpResp @@ -152,14 +144,17 @@ func getJobLogData(ctx context.Context, client *github.Client, owner, repo strin } return nil, ghResp, fmt.Errorf("failed to download log content for job %d: %w", jobID, err) } - result["logs_content"] = content - result["message"] = "Job logs content retrieved successfully" - result["original_length"] = originalLength + result.Content = &ActionsJobLogContent{ + JobID: jobID, JobName: jobName, LogsContent: content, + Message: "Job logs content retrieved successfully", OriginalLength: originalLength, + } } else { // Return just the URL - result["logs_url"] = url.String() - result["message"] = "Job logs are available for download" - result["note"] = "The logs_url provides a download link for the individual job logs in plain text format. Use return_content=true to get the actual log content." + result.URL = &ActionsJobLogURL{ + JobID: jobID, JobName: jobName, LogsURL: url.String(), + Message: "Job logs are available for download", + Note: "The logs_url provides a download link for the individual job logs in plain text format. Use return_content=true to get the actual log content.", + } } return result, resp, nil @@ -199,10 +194,11 @@ func downloadLogContent(ctx context.Context, logURL string, tailLines int, maxLi // ActionsList returns the tool and handler for listing GitHub Actions resources. func ActionsList(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := newActionsTool( ToolsetMetadataActions, mcp.Tool{ - Name: "actions_list", + Name: "actions_list", + OutputSchema: actionsListOutputSchema(), Description: t("TOOL_ACTIONS_LIST_DESCRIPTION", `Tools for listing GitHub Actions resources. Use this tool to list workflows in a repository, or list workflow runs, jobs, and artifacts for a specific workflow or workflow run. @@ -217,12 +213,7 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an "method": { Type: "string", Description: "The action to perform", - Enum: []any{ - actionsMethodListWorkflows, - actionsMethodListWorkflowRuns, - actionsMethodListWorkflowJobs, - actionsMethodListWorkflowArtifacts, - }, + Enum: actionsEnumSchemas()[reflect.TypeFor[ActionsListMethod]()].Enum, }, "owner": { Type: "string", @@ -324,31 +315,9 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an }, }, scopes.PublicRead(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 - } - - method, err := RequiredParam[string](args, "method") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - resourceID, err := OptionalParam[string](args, "resource_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - pagination, err := OptionalPaginationParams(args) - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args ActionsListInput) (*mcp.CallToolResult, *ActionsListOutput, error) { + owner, repo, method, resourceID := args.Owner, args.Repo, args.Method, args.ResourceID + pagination := PaginationParams{Page: args.Page, PerPage: args.PerPage} client, err := deps.GetClient(ctx) if err != nil { @@ -388,10 +357,10 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an result, payload, err := listWorkflows(ctx, client, owner, repo, pagination) return attachIFC(result), payload, err case actionsMethodListWorkflowRuns: - result, payload, err := listWorkflowRuns(ctx, client, args, owner, repo, resourceID, pagination) + result, payload, err := listWorkflowRuns(ctx, client, args.WorkflowRunsFilter, owner, repo, resourceID, pagination) return attachIFC(result), payload, err case actionsMethodListWorkflowJobs: - result, payload, err := listWorkflowJobs(ctx, client, args, owner, repo, resourceIDInt, pagination) + result, payload, err := listWorkflowJobs(ctx, client, args.WorkflowJobsFilter, owner, repo, resourceIDInt, pagination) return attachIFC(result), payload, err case actionsMethodListWorkflowArtifacts: result, payload, err := listWorkflowArtifacts(ctx, client, owner, repo, resourceIDInt, pagination) @@ -400,16 +369,18 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }, + normalizeActionsArguments("list"), ) return tool } // ActionsGet returns the tool and handler for getting GitHub Actions resources. func ActionsGet(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := newActionsTool( ToolsetMetadataActions, mcp.Tool{ - Name: "actions_get", + Name: "actions_get", + OutputSchema: actionsGetOutputSchema(), Description: t("TOOL_ACTIONS_GET_DESCRIPTION", `Get details about specific GitHub Actions resources. Use this tool to get details about individual workflows, workflow runs, jobs, and artifacts by their unique IDs. `), @@ -423,14 +394,7 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an "method": { Type: "string", Description: "The method to execute", - Enum: []any{ - actionsMethodGetWorkflow, - actionsMethodGetWorkflowRun, - actionsMethodGetWorkflowJob, - actionsMethodDownloadWorkflowArtifact, - actionsMethodGetWorkflowRunUsage, - actionsMethodGetWorkflowRunLogsURL, - }, + Enum: actionsEnumSchemas()[reflect.TypeFor[ActionsGetMethod]()].Enum, }, "owner": { Type: "string", @@ -454,24 +418,8 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an }, }, scopes.PublicRead(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 - } - method, err := RequiredParam[string](args, "method") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - resourceID, err := RequiredParam[string](args, "resource_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args ActionsGetInput) (*mcp.CallToolResult, *ActionsGetOutput, error) { + owner, repo, method, resourceID := args.Owner, args.Repo, args.Method, args.ResourceID client, err := deps.GetClient(ctx) if err != nil { @@ -522,17 +470,19 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }, + normalizeActionsArguments("get"), ) return tool } // ActionsRunTrigger returns the tool and handler for triggering GitHub Actions workflows. func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := newActionsTool( ToolsetMetadataActions, mcp.Tool{ - Name: "actions_run_trigger", - Description: t("TOOL_ACTIONS_RUN_TRIGGER_DESCRIPTION", "Trigger GitHub Actions workflow operations, including running, re-running, cancelling workflow runs, and deleting workflow run logs."), + Name: "actions_run_trigger", + OutputSchema: actionsRunTriggerOutputSchema(), + Description: t("TOOL_ACTIONS_RUN_TRIGGER_DESCRIPTION", "Trigger GitHub Actions workflow operations, including running, re-running, cancelling workflow runs, and deleting workflow run logs."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ACTIONS_RUN_TRIGGER_USER_TITLE", "Trigger GitHub Actions workflow actions"), ReadOnlyHint: false, @@ -544,13 +494,7 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo "method": { Type: "string", Description: "The method to execute", - Enum: []any{ - actionsMethodRunWorkflow, - actionsMethodRerunWorkflowRun, - actionsMethodRerunFailedJobs, - actionsMethodCancelWorkflowRun, - actionsMethodDeleteWorkflowRunLogs, - }, + Enum: actionsEnumSchemas()[reflect.TypeFor[ActionsRunTriggerMethod]()].Enum, }, "owner": { Type: "string", @@ -582,30 +526,9 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo }, }, scopes.RequireAll(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 - } - method, err := RequiredParam[string](args, "method") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - // Get optional parameters - workflowID, _ := OptionalParam[string](args, "workflow_id") - ref, _ := OptionalParam[string](args, "ref") - runID, _ := OptionalIntParam(args, "run_id") - - // Get optional inputs parameter - inputs, err := OptionalParam[map[string]any](args, "inputs") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args ActionsRunTriggerInput) (*mcp.CallToolResult, *ActionsRunTriggerOutput, error) { + owner, repo, method := args.Owner, args.Repo, args.Method + workflowID, ref, runID, inputs := args.WorkflowID, args.Ref, args.RunID, args.Inputs // Validate required parameters based on action type if method == actionsMethodRunWorkflow { @@ -639,16 +562,18 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } }, + normalizeActionsArguments("trigger"), ) return tool } // ActionsGetJobLogs returns the tool and handler for getting workflow job logs. func ActionsGetJobLogs(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := newActionsTool( ToolsetMetadataActions, mcp.Tool{ - Name: "get_job_logs", + Name: "get_job_logs", + OutputSchema: actionsJobLogsOutputSchema(), Description: t("TOOL_GET_JOB_LOGS_CONSOLIDATED_DESCRIPTION", `Get logs for GitHub Actions workflow jobs. Use this tool to retrieve logs for a specific job or all failed jobs in a workflow run. For single job logs, provide job_id. For all failed jobs in a run, provide run_id with failed_only=true. @@ -694,44 +619,9 @@ For single job logs, provide job_id. For all failed jobs in a run, provide run_i }, }, scopes.PublicRead(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 - } - - jobID, err := OptionalIntParam(args, "job_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - runID, err := OptionalIntParam(args, "run_id") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - failedOnly, err := OptionalParam[bool](args, "failed_only") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - returnContent, err := OptionalParam[bool](args, "return_content") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - tailLines, err := OptionalIntParam(args, "tail_lines") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - // Default to 500 lines if not specified or invalid - if tailLines <= 0 { - tailLines = 500 - } + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args ActionsGetJobLogsInput) (*mcp.CallToolResult, *ActionsJobLogsOutput, error) { + owner, repo, jobID, runID := args.Owner, args.Repo, args.JobID, args.RunID + failedOnly, returnContent, tailLines := args.FailedOnly, args.ReturnContent, args.TailLines client, err := deps.GetClient(ctx) if err != nil { @@ -766,13 +656,14 @@ For single job logs, provide job_id. For all failed jobs in a run, provide run_i return utils.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), nil, nil }, + normalizeActionsArguments("logs"), ) return tool } // Helper functions for consolidated actions tools -func getWorkflow(ctx context.Context, client *github.Client, owner, repo, resourceID string) (*mcp.CallToolResult, any, error) { +func getWorkflow(ctx context.Context, client *github.Client, owner, repo, resourceID string) (*mcp.CallToolResult, *ActionsGetOutput, error) { var workflow *github.Workflow var resp *github.Response var err error @@ -793,23 +684,24 @@ func getWorkflow(ctx context.Context, client *github.Client, owner, repo, resour return nil, nil, fmt.Errorf("failed to marshal workflow: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsGetOutput{Method: actionsMethodGetWorkflow, Workflow: convertToActionsWorkflow(workflow)}, nil } -func getWorkflowRun(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, any, error) { +func getWorkflowRun(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, *ActionsGetOutput, error) { workflowRun, resp, err := client.Actions.GetWorkflowRunByID(ctx, owner, repo, resourceID) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run", resp, err), nil, nil } defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(convertToMinimalWorkflowRun(workflowRun)) + output := convertToMinimalWorkflowRun(workflowRun) + r, err := json.Marshal(output) if err != nil { return nil, nil, fmt.Errorf("failed to marshal workflow run: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsGetOutput{Method: actionsMethodGetWorkflowRun, Run: &output}, nil } -func getWorkflowJob(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, any, error) { +func getWorkflowJob(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, *ActionsGetOutput, error) { workflowJob, resp, err := client.Actions.GetWorkflowJobByID(ctx, owner, repo, resourceID) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow job", resp, err), nil, nil @@ -819,10 +711,15 @@ func getWorkflowJob(ctx context.Context, client *github.Client, owner, repo stri if err != nil { return nil, nil, fmt.Errorf("failed to marshal workflow job: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + var output *MinimalWorkflowJob + if workflowJob != nil { + job := convertToMinimalWorkflowJob(workflowJob) + output = &job + } + return utils.NewToolResultText(string(r)), &ActionsGetOutput{Method: actionsMethodGetWorkflowJob, Job: output}, nil } -func listWorkflows(ctx context.Context, client *github.Client, owner, repo string, pagination PaginationParams) (*mcp.CallToolResult, any, error) { +func listWorkflows(ctx context.Context, client *github.Client, owner, repo string, pagination PaginationParams) (*mcp.CallToolResult, *ActionsListOutput, error) { opts := &github.ListOptions{ PerPage: pagination.PerPage, Page: pagination.Page, @@ -839,29 +736,19 @@ func listWorkflows(ctx context.Context, client *github.Client, owner, repo strin return nil, nil, fmt.Errorf("failed to marshal workflows: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsListOutput{Method: actionsMethodListWorkflows, Workflows: convertToActionsWorkflows(workflows)}, nil } -func listWorkflowRuns(ctx context.Context, client *github.Client, args map[string]any, owner, repo, resourceID string, pagination PaginationParams) (*mcp.CallToolResult, any, error) { - filterArgs, err := OptionalParam[map[string]any](args, "workflow_runs_filter") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - filterArgsTyped := make(map[string]string) - for k, v := range filterArgs { - if strVal, ok := v.(string); ok { - filterArgsTyped[k] = strVal - } else { - filterArgsTyped[k] = "" - } +func listWorkflowRuns(ctx context.Context, client *github.Client, filter ActionsWorkflowRunsFilter, owner, repo, resourceID string, pagination PaginationParams) (*mcp.CallToolResult, *ActionsListOutput, error) { + if filter.validationError != "" { + return utils.NewToolResultError(filter.validationError), nil, nil } listWorkflowRunsOptions := &github.ListWorkflowRunsOptions{ - Actor: filterArgsTyped["actor"], - Branch: filterArgsTyped["branch"], - Event: filterArgsTyped["event"], - Status: filterArgsTyped["status"], + Actor: filter.Actor, + Branch: filter.Branch, + Event: filter.Event, + Status: filter.Status, ListOptions: github.ListOptions{ Page: pagination.Page, PerPage: pagination.PerPage, @@ -870,6 +757,7 @@ func listWorkflowRuns(ctx context.Context, client *github.Client, args map[strin var workflowRuns *github.WorkflowRuns var resp *github.Response + var err error if resourceID == "" { workflowRuns, resp, err = client.Actions.ListRepositoryWorkflowRuns(ctx, owner, repo, listWorkflowRunsOptions) @@ -884,31 +772,22 @@ func listWorkflowRuns(ctx context.Context, client *github.Client, args map[strin } defer func() { _ = resp.Body.Close() }() - r, err := json.Marshal(convertToMinimalWorkflowRuns(workflowRuns)) + output := convertToMinimalWorkflowRuns(workflowRuns) + r, err := json.Marshal(output) if err != nil { return nil, nil, fmt.Errorf("failed to marshal workflow runs: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsListOutput{Method: actionsMethodListWorkflowRuns, Runs: &output}, nil } -func listWorkflowJobs(ctx context.Context, client *github.Client, args map[string]any, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, any, error) { - filterArgs, err := OptionalParam[map[string]any](args, "workflow_jobs_filter") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - - filterArgsTyped := make(map[string]string) - for k, v := range filterArgs { - if strVal, ok := v.(string); ok { - filterArgsTyped[k] = strVal - } else { - filterArgsTyped[k] = "" - } +func listWorkflowJobs(ctx context.Context, client *github.Client, filter ActionsWorkflowJobsFilter, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, *ActionsListOutput, error) { + if filter.validationError != "" { + return utils.NewToolResultError(filter.validationError), nil, nil } workflowJobs, resp, err := client.Actions.ListWorkflowJobs(ctx, owner, repo, resourceID, &github.ListWorkflowJobsOptions{ - Filter: filterArgsTyped["filter"], + Filter: filter.Filter, ListOptions: github.ListOptions{ Page: pagination.Page, PerPage: pagination.PerPage, @@ -918,8 +797,8 @@ func listWorkflowJobs(ctx context.Context, client *github.Client, args map[strin return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list workflow jobs", resp, err), nil, nil } - response := map[string]any{ - "jobs": convertToMinimalWorkflowJobs(workflowJobs), + response := &ActionsJobsOutput{ + Jobs: convertToMinimalWorkflowJobs(workflowJobs), } defer func() { _ = resp.Body.Close() }() @@ -928,10 +807,10 @@ func listWorkflowJobs(ctx context.Context, client *github.Client, args map[strin return nil, nil, fmt.Errorf("failed to marshal workflow jobs: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsListOutput{Method: actionsMethodListWorkflowJobs, Jobs: &response.Jobs}, nil } -func listWorkflowArtifacts(ctx context.Context, client *github.Client, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, any, error) { +func listWorkflowArtifacts(ctx context.Context, client *github.Client, owner, repo string, resourceID int64, pagination PaginationParams) (*mcp.CallToolResult, *ActionsListOutput, error) { opts := &github.ListOptions{ PerPage: pagination.PerPage, Page: pagination.Page, @@ -948,10 +827,10 @@ func listWorkflowArtifacts(ctx context.Context, client *github.Client, owner, re return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsListOutput{Method: actionsMethodListWorkflowArtifacts, Artifacts: convertToActionsArtifacts(artifacts)}, nil } -func downloadWorkflowArtifact(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, any, error) { +func downloadWorkflowArtifact(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, *ActionsGetOutput, error) { // Get the download URL for the artifact url, resp, err := client.Actions.DownloadArtifact(ctx, owner, repo, resourceID, 1) if err != nil { @@ -960,11 +839,11 @@ func downloadWorkflowArtifact(ctx context.Context, client *github.Client, owner, defer func() { _ = resp.Body.Close() }() // Create response with the download URL and information - result := map[string]any{ - "download_url": url.String(), - "message": "Artifact is available for download", - "note": "The download_url provides a download link for the artifact as a ZIP archive. The link is temporary and expires after a short time.", - "artifact_id": resourceID, + result := &ActionsArtifactDownloadOutput{ + DownloadURL: url.String(), + Message: "Artifact is available for download", + Note: "The download_url provides a download link for the artifact as a ZIP archive. The link is temporary and expires after a short time.", + ArtifactID: resourceID, } r, err := json.Marshal(result) @@ -972,10 +851,10 @@ func downloadWorkflowArtifact(ctx context.Context, client *github.Client, owner, return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsGetOutput{Method: actionsMethodDownloadWorkflowArtifact, Artifact: result}, nil } -func getWorkflowRunLogsURL(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { +func getWorkflowRunLogsURL(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, *ActionsGetOutput, error) { // Get the download URL for the logs url, resp, err := client.Actions.GetWorkflowRunLogs(ctx, owner, repo, runID, 1) if err != nil { @@ -984,12 +863,12 @@ func getWorkflowRunLogsURL(ctx context.Context, client *github.Client, owner, re defer func() { _ = resp.Body.Close() }() // Create response with the logs URL and information - result := map[string]any{ - "logs_url": url.String(), - "message": "Workflow run logs are available for download", - "note": "The logs_url provides a download link for the complete workflow run logs as a ZIP archive. You can download this archive to extract and examine individual job logs.", - "warning": "This downloads ALL logs as a ZIP file which can be large and expensive. For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id instead.", - "optimization_tip": "Use: get_job_logs with parameters {run_id: " + fmt.Sprintf("%d", runID) + ", failed_only: true} for more efficient failed job debugging", + result := &ActionsRunLogsOutput{ + LogsURL: url.String(), + Message: "Workflow run logs are available for download", + Note: "The logs_url provides a download link for the complete workflow run logs as a ZIP archive. You can download this archive to extract and examine individual job logs.", + Warning: "This downloads ALL logs as a ZIP file which can be large and expensive. For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id instead.", + OptimizationTip: "Use: get_job_logs with parameters {run_id: " + fmt.Sprintf("%d", runID) + ", failed_only: true} for more efficient failed job debugging", } r, err := json.Marshal(result) @@ -997,10 +876,10 @@ func getWorkflowRunLogsURL(ctx context.Context, client *github.Client, owner, re return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsGetOutput{Method: actionsMethodGetWorkflowRunLogsURL, Logs: result}, nil } -func getWorkflowRunUsage(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, any, error) { +func getWorkflowRunUsage(ctx context.Context, client *github.Client, owner, repo string, resourceID int64) (*mcp.CallToolResult, *ActionsGetOutput, error) { usage, resp, err := client.Actions.GetWorkflowRunUsageByID(ctx, owner, repo, resourceID) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to get workflow run usage", resp, err), nil, nil @@ -1012,10 +891,10 @@ func getWorkflowRunUsage(ctx context.Context, client *github.Client, owner, repo return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsGetOutput{Method: actionsMethodGetWorkflowRunUsage, Usage: convertToActionsRunUsage(usage)}, nil } -func runWorkflow(ctx context.Context, client *github.Client, owner, repo, workflowID, ref string, inputs map[string]any) (*mcp.CallToolResult, any, error) { +func runWorkflow(ctx context.Context, client *github.Client, owner, repo, workflowID, ref string, inputs map[string]any) (*mcp.CallToolResult, *ActionsRunTriggerOutput, error) { event := github.CreateWorkflowDispatchEventRequest{ Ref: ref, Inputs: inputs, @@ -1023,7 +902,7 @@ func runWorkflow(ctx context.Context, client *github.Client, owner, repo, workfl var resp *github.Response var err error - var workflowType string + var workflowType ActionsWorkflowType if workflowIDInt, parseErr := strconv.ParseInt(workflowID, 10, 64); parseErr == nil { _, resp, err = client.Actions.CreateWorkflowDispatchEventByID(ctx, owner, repo, workflowIDInt, event) @@ -1038,14 +917,18 @@ func runWorkflow(ctx context.Context, client *github.Client, owner, repo, workfl } defer func() { _ = resp.Body.Close() }() - result := map[string]any{ - "message": "Workflow run has been queued", - "workflow_type": workflowType, - "workflow_id": workflowID, - "ref": ref, - "inputs": inputs, - "status": resp.Status, - "status_code": resp.StatusCode, + inputJSON, err := json.Marshal(inputs) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + result := &ActionsDispatchOutput{ + Message: "Workflow run has been queued", + WorkflowType: workflowType, + WorkflowID: workflowID, + Ref: ref, + Inputs: inputJSON, + Status: resp.Status, + StatusCode: resp.StatusCode, } r, err := json.Marshal(result) @@ -1053,21 +936,21 @@ func runWorkflow(ctx context.Context, client *github.Client, owner, repo, workfl return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsRunTriggerOutput{Method: actionsMethodRunWorkflow, Dispatch: result}, nil } -func rerunWorkflowRun(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { +func rerunWorkflowRun(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, *ActionsRunTriggerOutput, error) { resp, err := client.Actions.RerunWorkflowByID(ctx, owner, repo, runID) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to rerun workflow run", resp, err), nil, nil } defer func() { _ = resp.Body.Close() }() - result := map[string]any{ - "message": "Workflow run has been queued for re-run", - "run_id": runID, - "status": resp.Status, - "status_code": resp.StatusCode, + result := &ActionsRunOperationOutput{ + Message: "Workflow run has been queued for re-run", + RunID: runID, + Status: resp.Status, + StatusCode: resp.StatusCode, } r, err := json.Marshal(result) @@ -1075,21 +958,21 @@ func rerunWorkflowRun(ctx context.Context, client *github.Client, owner, repo st return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsRunTriggerOutput{Method: actionsMethodRerunWorkflowRun, Rerun: result}, nil } -func rerunFailedJobs(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { +func rerunFailedJobs(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, *ActionsRunTriggerOutput, error) { resp, err := client.Actions.RerunFailedJobsByID(ctx, owner, repo, runID) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to rerun failed jobs", resp, err), nil, nil } defer func() { _ = resp.Body.Close() }() - result := map[string]any{ - "message": "Failed jobs have been queued for re-run", - "run_id": runID, - "status": resp.Status, - "status_code": resp.StatusCode, + result := &ActionsRunOperationOutput{ + Message: "Failed jobs have been queued for re-run", + RunID: runID, + Status: resp.Status, + StatusCode: resp.StatusCode, } r, err := json.Marshal(result) @@ -1097,10 +980,10 @@ func rerunFailedJobs(ctx context.Context, client *github.Client, owner, repo str return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsRunTriggerOutput{Method: actionsMethodRerunFailedJobs, RerunFailed: result}, nil } -func cancelWorkflowRun(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { +func cancelWorkflowRun(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, *ActionsRunTriggerOutput, error) { resp, err := client.Actions.CancelWorkflowRunByID(ctx, owner, repo, runID) if err != nil { if _, ok := errors.AsType[*github.AcceptedError](err); !ok { @@ -1109,11 +992,11 @@ func cancelWorkflowRun(ctx context.Context, client *github.Client, owner, repo s } defer func() { _ = resp.Body.Close() }() - result := map[string]any{ - "message": "Workflow run has been cancelled", - "run_id": runID, - "status": resp.Status, - "status_code": resp.StatusCode, + result := &ActionsRunOperationOutput{ + Message: "Workflow run has been cancelled", + RunID: runID, + Status: resp.Status, + StatusCode: resp.StatusCode, } r, err := json.Marshal(result) @@ -1121,21 +1004,21 @@ func cancelWorkflowRun(ctx context.Context, client *github.Client, owner, repo s return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsRunTriggerOutput{Method: actionsMethodCancelWorkflowRun, Cancel: result}, nil } -func deleteWorkflowRunLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, any, error) { +func deleteWorkflowRunLogs(ctx context.Context, client *github.Client, owner, repo string, runID int64) (*mcp.CallToolResult, *ActionsRunTriggerOutput, error) { resp, err := client.Actions.DeleteWorkflowRunLogs(ctx, owner, repo, runID) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to delete workflow run logs", resp, err), nil, nil } defer func() { _ = resp.Body.Close() }() - result := map[string]any{ - "message": "Workflow run logs have been deleted", - "run_id": runID, - "status": resp.Status, - "status_code": resp.StatusCode, + result := &ActionsRunOperationOutput{ + Message: "Workflow run logs have been deleted", + RunID: runID, + Status: resp.Status, + StatusCode: resp.StatusCode, } r, err := json.Marshal(result) @@ -1143,5 +1026,5 @@ func deleteWorkflowRunLogs(ctx context.Context, client *github.Client, owner, re return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &ActionsRunTriggerOutput{Method: actionsMethodDeleteWorkflowRunLogs, DeleteLogs: result}, nil } diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 1490389a58..5caf7d269b 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -571,10 +571,8 @@ func Test_ActionsGetJobLogs(t *testing.T) { // Verify tool definition once toolDef := ActionsGetJobLogs(translations.NullTranslationHelper) - // Note: consolidated ActionsGetJobLogs has same tool name "get_job_logs" as the individual tool - // but with different descriptions. We skip toolsnap validation here since the individual - // tool's toolsnap already exists and is tested in Test_GetJobLogs. - // The functional feature rules ensure only one variant is active at a time. + // The legacy individual tool has a different contract under the same name. + require.NoError(t, toolsnaps.Test("get_job_logs_actions", toolDef.Tool)) assert.Equal(t, "get_job_logs", toolDef.Tool.Name) assert.NotEmpty(t, toolDef.Tool.Description) inputSchema := toolDef.Tool.InputSchema.(*jsonschema.Schema) diff --git a/pkg/github/actions_types.go b/pkg/github/actions_types.go new file mode 100644 index 0000000000..16c7a7fd63 --- /dev/null +++ b/pkg/github/actions_types.go @@ -0,0 +1,647 @@ +package github + +import ( + "context" + "encoding/json" + "reflect" + "slices" + "strings" + "sync" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type ActionsListMethod string +type ActionsGetMethod string +type ActionsRunTriggerMethod string +type ActionsWorkflowState string +type ActionsWorkflowType string + +var actionsEnumSchemas = sync.OnceValue(func() map[reflect.Type]*jsonschema.Schema { + return map[reflect.Type]*jsonschema.Schema{ + reflect.TypeFor[ActionsListMethod](): {Type: "string", Enum: []any{ + actionsMethodListWorkflows, actionsMethodListWorkflowRuns, + actionsMethodListWorkflowJobs, actionsMethodListWorkflowArtifacts, + }}, + reflect.TypeFor[ActionsGetMethod](): {Type: "string", Enum: []any{ + actionsMethodGetWorkflow, actionsMethodGetWorkflowRun, actionsMethodGetWorkflowJob, + actionsMethodDownloadWorkflowArtifact, actionsMethodGetWorkflowRunUsage, actionsMethodGetWorkflowRunLogsURL, + }}, + reflect.TypeFor[ActionsRunTriggerMethod](): {Type: "string", Enum: []any{ + actionsMethodRunWorkflow, actionsMethodRerunWorkflowRun, actionsMethodRerunFailedJobs, + actionsMethodCancelWorkflowRun, actionsMethodDeleteWorkflowRunLogs, + }}, + reflect.TypeFor[ActionsWorkflowState](): {Type: "string", Enum: []any{ + "active", "deleted", "disabled_fork", "disabled_inactivity", "disabled_manually", + }}, + reflect.TypeFor[ActionsWorkflowType](): {Type: "string", Enum: []any{"workflow_file", "workflow_id"}}, + } +}) + +type ActionsListInput struct { + Method string `json:"method"` + Owner string `json:"owner"` + Repo string `json:"repo"` + ResourceID string `json:"resource_id,omitempty"` + Page int `json:"page,omitempty"` + PerPage int `json:"perPage,omitempty"` + WorkflowRunsFilter ActionsWorkflowRunsFilter `json:"workflow_runs_filter"` + WorkflowJobsFilter ActionsWorkflowJobsFilter `json:"workflow_jobs_filter"` +} + +type ActionsWorkflowRunsFilter struct { + Actor string `json:"actor,omitempty"` + Branch string `json:"branch,omitempty"` + Event string `json:"event,omitempty"` + Status string `json:"status,omitempty"` + validationError string +} + +type ActionsWorkflowJobsFilter struct { + Filter string `json:"filter,omitempty"` + validationError string +} + +// The legacy handler validates filters only after client acquisition and +// resource-ID validation. Retain that ordering even for non-object filters. +func (filter *ActionsWorkflowRunsFilter) UnmarshalJSON(raw []byte) error { + type fields ActionsWorkflowRunsFilter + *filter = ActionsWorkflowRunsFilter{} + message, err := decodeActionsFilter(raw, "workflow_runs_filter", (*fields)(filter)) + filter.validationError = message + return err +} + +func (filter *ActionsWorkflowJobsFilter) UnmarshalJSON(raw []byte) error { + type fields ActionsWorkflowJobsFilter + *filter = ActionsWorkflowJobsFilter{} + message, err := decodeActionsFilter(raw, "workflow_jobs_filter", (*fields)(filter)) + filter.validationError = message + return err +} + +func decodeActionsFilter(raw []byte, field string, output any) (string, error) { + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return "", err + } + if _, err := OptionalParam[map[string]any](map[string]any{field: value}, field); err != nil { + return err.Error(), nil + } + return "", json.Unmarshal(raw, output) +} + +func newActionsTool[In, Out any]( + toolset inventory.ToolsetMetadata, + tool mcp.Tool, + scopeAccess inventory.ScopeAccess, + handler func(context.Context, ToolDependencies, *mcp.CallToolRequest, In) (*mcp.CallToolResult, Out, error), + normalizers ...inventory.InputNormalizer, +) inventory.ServerTool { + cached, _ := actionsValidationSchemas.LoadOrStore(tool.Name, sync.OnceValue(func() *jsonschema.Schema { + return actionsValidationSchema(tool.InputSchema) + })) + validation := cached.(func() *jsonschema.Schema)() + return NewToolWithSchemaOptions(toolset, tool, scopeAccess, + inventory.TypedSchemaOptions{ValidationInputSchema: validation}, handler, normalizers...) +} + +var actionsValidationSchemas sync.Map + +func actionsValidationSchema(inputSchema any) *jsonschema.Schema { + raw, err := json.Marshal(inputSchema) + if err != nil { + panic(err) + } + var schema jsonschema.Schema + if err := json.Unmarshal(raw, &schema); err != nil { + panic(err) + } + // The legacy raw handler treated enums and pagination bounds as metadata. + // Relax only SDK validation; leave the advertised contract unchanged. + for _, field := range []string{"method", "workflow_runs_filter", "workflow_jobs_filter"} { + property := schema.Properties[field] + if property == nil { + continue + } + if field == "method" { + property.Enum = nil + continue + } + for _, nested := range property.Properties { + nested.Enum = nil + } + schema.Properties[field] = &jsonschema.Schema{ + Description: property.Description, + AnyOf: []*jsonschema.Schema{ + property, + {Not: &jsonschema.Schema{Type: "object"}}, + }, + } + } + for _, field := range []string{"page", "perPage"} { + if property := schema.Properties[field]; property != nil { + property.Minimum, property.Maximum = nil, nil + } + } + return &schema +} + +type ActionsGetInput struct { + Method string `json:"method"` + Owner string `json:"owner"` + Repo string `json:"repo"` + ResourceID string `json:"resource_id"` +} + +type ActionsRunTriggerInput struct { + Method string `json:"method"` + Owner string `json:"owner"` + Repo string `json:"repo"` + WorkflowID string `json:"workflow_id,omitempty"` + Ref string `json:"ref,omitempty"` + RunID int `json:"run_id,omitempty"` + Inputs map[string]any `json:"inputs,omitempty"` +} + +type ActionsGetJobLogsInput struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + JobID int `json:"job_id,omitempty"` + RunID int `json:"run_id,omitempty"` + FailedOnly bool `json:"failed_only,omitempty"` + ReturnContent bool `json:"return_content,omitempty"` + TailLines int `json:"tail_lines,omitempty"` +} + +// Method is omitted only on SDK-validated error zero values, which the shared +// output middleware removes. Every successful handler sets its discriminant. +type ActionsListOutput struct { + Method ActionsListMethod `json:"method,omitempty"` + Workflows *ActionsWorkflowsOutput `json:"workflows,omitempty"` + Runs *MinimalWorkflowRunsResult `json:"workflow_runs,omitempty"` + Jobs *MinimalWorkflowJobsResult `json:"workflow_jobs,omitempty"` + Artifacts *ActionsArtifactsOutput `json:"artifacts,omitempty"` +} + +type ActionsWorkflow struct { + ID *int64 `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + Path *string `json:"path,omitempty"` + State *ActionsWorkflowState `json:"state,omitempty"` + HTMLURL *string `json:"html_url,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type ActionsWorkflowsOutput struct { + TotalCount *int `json:"total_count,omitempty"` + Workflows []*ActionsWorkflow `json:"workflows,omitempty"` +} + +type ActionsArtifact struct { + ID *int64 `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + SizeInBytes *int64 `json:"size_in_bytes,omitempty"` + Expired *bool `json:"expired,omitempty"` + Digest *string `json:"digest,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + ExpiresAt string `json:"expires_at,omitempty"` + WorkflowRun *ActionsArtifactWorkflowRun `json:"workflow_run,omitempty"` +} + +type ActionsArtifactWorkflowRun struct { + ID *int64 `json:"id,omitempty"` + HeadBranch *string `json:"head_branch,omitempty"` + HeadSHA *string `json:"head_sha,omitempty"` +} + +type ActionsArtifactsOutput struct { + TotalCount *int64 `json:"total_count,omitempty"` + Artifacts []*ActionsArtifact `json:"artifacts,omitempty"` +} + +type ActionsJobsOutput struct { + Jobs MinimalWorkflowJobsResult `json:"jobs"` +} + +var actionsListOutputSchema = sync.OnceValue(func() *jsonschema.Schema { + schema := actionsOutputSchema[ActionsListOutput]() + return schema +}) + +type ActionsGetOutput struct { + Method ActionsGetMethod `json:"method,omitempty"` + Workflow *ActionsWorkflow `json:"workflow,omitempty"` + Run *MinimalWorkflowRun `json:"workflow_run,omitempty"` + Job *MinimalWorkflowJob `json:"workflow_job,omitempty"` + Usage *ActionsRunUsageOutput `json:"usage,omitempty"` + Artifact *ActionsArtifactDownloadOutput `json:"artifact,omitempty"` + Logs *ActionsRunLogsOutput `json:"logs,omitempty"` +} + +type ActionsRunUsageOutput struct { + Billable []ActionsRunnerUsage `json:"billable,omitempty"` + RunDurationMS *int64 `json:"run_duration_ms,omitempty"` +} + +type ActionsRunnerUsage struct { + Runner string `json:"runner"` + TotalMS *int64 `json:"total_ms,omitempty"` + Jobs *int `json:"jobs,omitempty"` + JobRuns []ActionsJobRunUsage `json:"job_runs,omitempty"` +} + +type ActionsJobRunUsage struct { + JobID *int `json:"job_id,omitempty"` + DurationMS *int64 `json:"duration_ms,omitempty"` +} + +func convertToActionsWorkflow(workflow *github.Workflow) *ActionsWorkflow { + if workflow == nil { + return nil + } + var state *ActionsWorkflowState + if workflow.State != nil { + state = new(ActionsWorkflowState(*workflow.State)) + } + return &ActionsWorkflow{ + ID: workflow.ID, Name: workflow.Name, Path: workflow.Path, State: state, + HTMLURL: workflow.HTMLURL, CreatedAt: formatMinimalTimestamp(workflow.CreatedAt), + UpdatedAt: formatMinimalTimestamp(workflow.UpdatedAt), + } +} + +func convertToActionsWorkflows(workflows *github.Workflows) *ActionsWorkflowsOutput { + if workflows == nil { + return nil + } + out := &ActionsWorkflowsOutput{TotalCount: workflows.TotalCount} + for _, workflow := range workflows.Workflows { + out.Workflows = append(out.Workflows, convertToActionsWorkflow(workflow)) + } + return out +} + +func convertToActionsArtifacts(artifacts *github.ArtifactList) *ActionsArtifactsOutput { + if artifacts == nil { + return nil + } + out := &ActionsArtifactsOutput{TotalCount: artifacts.TotalCount} + for _, artifact := range artifacts.Artifacts { + if artifact == nil { + out.Artifacts = append(out.Artifacts, nil) + continue + } + item := &ActionsArtifact{ + ID: artifact.ID, Name: artifact.Name, SizeInBytes: artifact.SizeInBytes, + Expired: artifact.Expired, Digest: artifact.Digest, + CreatedAt: formatMinimalTimestamp(artifact.CreatedAt), + UpdatedAt: formatMinimalTimestamp(artifact.UpdatedAt), + ExpiresAt: formatMinimalTimestamp(artifact.ExpiresAt), + } + if run := artifact.WorkflowRun; run != nil { + item.WorkflowRun = &ActionsArtifactWorkflowRun{ + ID: run.ID, HeadBranch: run.HeadBranch, HeadSHA: run.HeadSHA, + } + } + out.Artifacts = append(out.Artifacts, item) + } + return out +} + +func convertToActionsRunUsage(usage *github.WorkflowRunUsage) *ActionsRunUsageOutput { + if usage == nil { + return nil + } + out := &ActionsRunUsageOutput{RunDurationMS: usage.RunDurationMS} + if usage.Billable == nil { + return out + } + for runner, bill := range *usage.Billable { + item := ActionsRunnerUsage{Runner: runner} + if bill != nil { + item.TotalMS, item.Jobs = bill.TotalMS, bill.Jobs + for _, job := range bill.JobRuns { + if job != nil { + item.JobRuns = append(item.JobRuns, ActionsJobRunUsage{ + JobID: job.JobID, DurationMS: job.DurationMS, + }) + } + } + } + out.Billable = append(out.Billable, item) + } + slices.SortFunc(out.Billable, func(a, b ActionsRunnerUsage) int { + return strings.Compare(a.Runner, b.Runner) + }) + return out +} + +type ActionsArtifactDownloadOutput struct { + ArtifactID int64 `json:"artifact_id"` + DownloadURL string `json:"download_url"` + Message string `json:"message"` + Note string `json:"note"` +} + +type ActionsRunLogsOutput struct { + LogsURL string `json:"logs_url"` + Message string `json:"message"` + Note string `json:"note"` + OptimizationTip string `json:"optimization_tip"` + Warning string `json:"warning"` +} + +var actionsGetOutputSchema = sync.OnceValue(func() *jsonschema.Schema { + schema := actionsOutputSchema[ActionsGetOutput]() + return schema +}) + +type ActionsRunTriggerOutput struct { + Method ActionsRunTriggerMethod `json:"method,omitempty"` + Dispatch *ActionsDispatchOutput `json:"dispatch,omitempty"` + Rerun *ActionsRunOperationOutput `json:"rerun,omitempty"` + RerunFailed *ActionsRunOperationOutput `json:"rerun_failed,omitempty"` + Cancel *ActionsRunOperationOutput `json:"cancel,omitempty"` + DeleteLogs *ActionsRunOperationOutput `json:"delete_logs,omitempty"` +} + +// Workflow dispatch inputs are arbitrary user JSON, echoed verbatim by the +// legacy response. This is the only open-ended output field. +type ActionsDispatchOutput struct { + Inputs json.RawMessage `json:"inputs"` + Message string `json:"message"` + Ref string `json:"ref"` + Status string `json:"status"` + StatusCode int `json:"status_code"` + WorkflowID string `json:"workflow_id"` + WorkflowType ActionsWorkflowType `json:"workflow_type"` +} + +type ActionsRunOperationOutput struct { + Message string `json:"message"` + RunID int64 `json:"run_id"` + Status string `json:"status"` + StatusCode int `json:"status_code"` +} + +var actionsRunTriggerOutputSchema = sync.OnceValue(func() *jsonschema.Schema { + schema := actionsOutputSchema[ActionsRunTriggerOutput]() + dispatch := schema.Properties["dispatch"] + dispatch.Properties["inputs"] = &jsonschema.Schema{ + Types: []string{"object", "null"}, + AdditionalProperties: &jsonschema.Schema{}, + } + schema.Defs["run_operation"] = actionsOutputSchema[ActionsRunOperationOutput]() + for _, field := range []string{"rerun", "rerun_failed", "cancel", "delete_logs"} { + schema.Properties[field] = &jsonschema.Schema{Ref: "#/$defs/run_operation"} + } + return schema +}) + +type ActionsJobLogsOutput struct { + Single *ActionsJobLog + Failed *ActionsFailedJobLogsOutput +} + +type ActionsJobLog struct { + Content *ActionsJobLogContent + URL *ActionsJobLogURL + Error *ActionsJobLogError +} + +type ActionsJobLogContent struct { + JobID int64 `json:"job_id"` + JobName string `json:"job_name,omitempty"` + LogsContent string `json:"logs_content"` + Message string `json:"message"` + OriginalLength int `json:"original_length"` +} + +type ActionsJobLogURL struct { + JobID int64 `json:"job_id"` + JobName string `json:"job_name,omitempty"` + LogsURL string `json:"logs_url"` + Message string `json:"message"` + Note string `json:"note"` +} + +type ActionsJobLogError struct { + Error string `json:"error"` + JobID int64 `json:"job_id"` + JobName string `json:"job_name"` +} + +func (out ActionsJobLog) MarshalJSON() ([]byte, error) { + switch { + case out.Content != nil: + return json.Marshal(out.Content) + case out.URL != nil: + return json.Marshal(out.URL) + default: + return json.Marshal(out.Error) + } +} + +type ActionsFailedJobLogsOutput struct { + FailedJobs int `json:"failed_jobs"` + Logs *[]ActionsJobLog `json:"logs,omitempty"` + Message string `json:"message"` + ReturnFormat *ActionsLogsReturnFormat `json:"return_format,omitempty"` + RunID int64 `json:"run_id"` + TotalJobs int `json:"total_jobs"` +} + +type ActionsLogsReturnFormat struct { + Content bool `json:"content"` + URLs bool `json:"urls"` +} + +func (out ActionsJobLogsOutput) MarshalJSON() ([]byte, error) { + if out.Single != nil { + return json.Marshal(out.Single) + } + return json.Marshal(out.Failed) +} + +func actionsJobLogSchema() *jsonschema.Schema { + return repositoryUnionSchema( + actionsOutputSchema[ActionsJobLogContent](), + actionsOutputSchema[ActionsJobLogURL](), + actionsOutputSchema[ActionsJobLogError](), + ) +} + +var actionsJobLogsOutputSchema = sync.OnceValue(func() *jsonschema.Schema { + failed := actionsOutputSchema[ActionsFailedJobLogsOutput]() + failed.Properties["logs"].Items = actionsJobLogSchema() + return repositoryUnionSchema( + &jsonschema.Schema{Type: "null"}, + actionsOutputSchema[ActionsJobLogContent](), + actionsOutputSchema[ActionsJobLogURL](), + failed, + ) +}) + +func actionsOutputSchema[T any]() *jsonschema.Schema { + schema, err := jsonschema.For[T](&jsonschema.ForOptions{TypeSchemas: actionsEnumSchemas()}) + if err != nil { + panic(err) + } + if schema.Defs == nil { + schema.Defs = make(map[string]*jsonschema.Schema) + } + describeActionsOutput(schema) + return schema +} + +func describeActionsOutput(schema *jsonschema.Schema) { + if schema == nil { + return + } + for name, property := range schema.Properties { + switch { + case strings.HasSuffix(name, "_at"): + property.Description = "Timestamp in RFC3339 format." + property.Format = "date-time" + case strings.HasSuffix(name, "_ms"): + property.Description = "Duration in milliseconds." + case name == "size_in_bytes": + property.Description = "Artifact size in bytes." + case name == "original_length": + property.Description = "Number of log lines before truncation." + case name == "state": + property.Description = "Workflow state, e.g. active, deleted, disabled_fork, disabled_inactivity, disabled_manually." + case name == "conclusion": + property.Description = "Completion outcome, e.g. success, failure, cancelled, skipped, timed_out, action_required." + property.Enum = inventory.EnumSchema(WorkflowConclusionValues()...).Enum + case name == "status" && schema.Properties["status_code"] != nil: + property.Description = "HTTP response status." + case name == "status": + property.Description = "Lifecycle status, e.g. queued, in_progress, completed, waiting." + // Sparse legacy minimal DTOs serialize an unknown status as "". + property.Enum = inventory.EnumSchema(append(WorkflowStatusValues(), "")...).Enum + case name == "method": + property.Description = "Operation that produced this response." + case name == "runner": + property.Description = "Runner environment name; custom environments are supported." + case name == "html_url": + property.Description = "Browser URL." + } + describeActionsOutput(property) + } + describeActionsOutput(schema.Items) + for _, variant := range schema.AnyOf { + describeActionsOutput(variant) + } + for _, variant := range schema.OneOf { + describeActionsOutput(variant) + } +} + +// Normalize only fields inspected by each legacy method. Ignored optional +// trigger values and non-string filter values retain their legacy defaults. +func normalizeActionsArguments(kind string) inventory.InputNormalizer { + return func(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, &inventory.ToolInputError{Message: "invalid arguments: " + err.Error()} + } + if args == nil { + return nil, &inventory.ToolInputError{Message: "invalid arguments: arguments must be a JSON object"} + } + if err := normalizeActionsFields(args, kind); err != nil { + return nil, &inventory.ToolInputError{Message: err.Error()} + } + return json.Marshal(args) + } +} + +func normalizeActionsFields(args map[string]any, kind string) error { + for _, field := range []string{"owner", "repo"} { + if _, err := RequiredParam[string](args, field); err != nil { + return err + } + } + if kind != "logs" { + if _, err := RequiredParam[string](args, "method"); err != nil { + return err + } + } + switch kind { + case "get": + _, err := RequiredParam[string](args, "resource_id") + return err + case "list": + if _, err := OptionalParam[string](args, "resource_id"); err != nil { + return err + } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return err + } + args["page"], args["perPage"] = pagination.Page, pagination.PerPage + method := args["method"] + for _, field := range []string{"workflow_runs_filter", "workflow_jobs_filter"} { + relevant := field == "workflow_runs_filter" && method == actionsMethodListWorkflowRuns || + field == "workflow_jobs_filter" && method == actionsMethodListWorkflowJobs + if !relevant { + delete(args, field) + continue + } + filter, err := OptionalParam[map[string]any](args, field) + if err != nil { + // The typed filter retains the error until its method runs. + continue + } + if filter == nil { + delete(args, field) + continue + } + for key, value := range filter { + if _, ok := value.(string); !ok { + filter[key] = "" + } + } + } + case "trigger": + for _, field := range []string{"workflow_id", "ref"} { + if _, ok := args[field].(string); !ok { + delete(args, field) + } + } + runID, _ := OptionalIntParam(args, "run_id") + args["run_id"] = runID + _, err := OptionalParam[map[string]any](args, "inputs") + return err + case "logs": + for _, field := range []string{"job_id", "run_id"} { + value, err := OptionalIntParam(args, field) + if err != nil { + return err + } + args[field] = value + } + for _, field := range []string{"failed_only", "return_content"} { + if _, err := OptionalParam[bool](args, field); err != nil { + return err + } + } + tailLines, err := OptionalIntParam(args, "tail_lines") + if err != nil { + return err + } + if tailLines <= 0 { + tailLines = 500 + } + args["tail_lines"] = tailLines + default: + panic("unknown Actions argument kind: " + kind) + } + return nil +} diff --git a/pkg/github/copilot.go b/pkg/github/copilot.go index 8e9af5aa68..e7a2d695fd 100644 --- a/pkg/github/copilot.go +++ b/pkg/github/copilot.go @@ -163,12 +163,13 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server }, } - return NewTool( + return NewTool[map[string]any, *AssignCopilotToIssueOutput]( ToolsetMetadataCopilot, mcp.Tool{ - Name: "assign_copilot_to_issue", - Description: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_DESCRIPTION", description.String()), - Icons: octicons.Icons("copilot"), + Name: "assign_copilot_to_issue", + OutputSchema: assignCopilotToIssueOutputSchema(), + Description: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_DESCRIPTION", description.String()), + Icons: octicons.Icons("copilot"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_USER_TITLE", "Assign Copilot to issue"), ReadOnlyHint: false, @@ -202,7 +203,7 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server }, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, request *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, request *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, *AssignCopilotToIssueOutput, error) { var params struct { Owner string `mapstructure:"owner"` Repo string `mapstructure:"repo"` @@ -445,8 +446,24 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server return utils.NewToolResultError(fmt.Sprintf("failed to marshal response: %s", err)), nil, nil } - return utils.NewToolResultText(string(r)), result, nil - }) + output := &AssignCopilotToIssueOutput{ + IssueNumber: int(updateIssueMutation.UpdateIssue.Issue.Number), + Message: result["message"].(string), + Owner: params.Owner, + Repo: params.Repo, + } + if note, ok := result["note"].(string); ok { + output.Note = note + } + if linkedPR != nil { + output.PullRequest = &CopilotPullRequestOutput{ + Number: linkedPR.Number, + State: linkedPR.State, + Title: linkedPR.Title, + } + } + return utils.NewToolResultText(string(r)), output, nil + }, normalizeAssignCopilotToIssueArguments) } // copilotBotAssignee is the minimal shape needed for the copilot-swe-agent bot @@ -525,12 +542,13 @@ func AssignCopilotToIssueWithIntent(t translations.TranslationHelperFunc) invent }, } - return NewTool( + return NewTool[map[string]any, *AssignCopilotToIssueWithIntentOutput]( ToolsetMetadataCopilotIssueIntents, mcp.Tool{ - Name: "assign_copilot_to_issue_with_intent", - Description: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_WITH_INTENT_DESCRIPTION", description.String()), - Icons: octicons.Icons("copilot"), + Name: "assign_copilot_to_issue_with_intent", + OutputSchema: assignCopilotToIssueWithIntentOutputSchema(), + Description: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_WITH_INTENT_DESCRIPTION", description.String()), + Icons: octicons.Icons("copilot"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_WITH_INTENT_USER_TITLE", "Assign Copilot to issue with intent"), ReadOnlyHint: false, @@ -579,13 +597,19 @@ func AssignCopilotToIssueWithIntent(t translations.TranslationHelperFunc) invent }, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, request *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, request *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, *AssignCopilotToIssueWithIntentOutput, error) { // Presence-check is_suggestion before decoding: mapstructure defaults a // missing bool to false, which would silently launch Copilot instead of // recording a suggestion. Require callers to make the choice explicit. if _, ok := args["is_suggestion"]; !ok { return utils.NewToolResultError("is_suggestion is required"), nil, nil } + if rationale, ok := args["_compat_rationale"]; ok { + args["rationale"] = rationale + } + if confidence, ok := args["_compat_confidence"]; ok { + args["confidence"] = confidence + } var params struct { Owner string `mapstructure:"owner"` @@ -755,7 +779,13 @@ func AssignCopilotToIssueWithIntent(t translations.TranslationHelperFunc) invent if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to marshal response: %s", err)), nil, nil } - return utils.NewToolResultText(string(r)), result, nil + return utils.NewToolResultText(string(r)), &AssignCopilotToIssueWithIntentOutput{ + IssueNumber: int(updateIssueMutation.UpdateIssue.Issue.Number), + IsSuggestion: params.IsSuggestion, + Message: result["message"].(string), + Owner: params.Owner, + Repo: params.Repo, + }, nil } // Direct-assignment path: poll for a linked PR created by Copilot after the assignment. @@ -810,8 +840,25 @@ func AssignCopilotToIssueWithIntent(t translations.TranslationHelperFunc) invent if err != nil { return utils.NewToolResultError(fmt.Sprintf("failed to marshal response: %s", err)), nil, nil } - return utils.NewToolResultText(string(r)), result, nil - }) + output := &AssignCopilotToIssueWithIntentOutput{ + IssueNumber: int(updateIssueMutation.UpdateIssue.Issue.Number), + IsSuggestion: params.IsSuggestion, + Message: result["message"].(string), + Owner: params.Owner, + Repo: params.Repo, + } + if note, ok := result["note"].(string); ok { + output.Note = note + } + if linkedPR != nil { + output.PullRequest = &CopilotPullRequestOutput{ + Number: linkedPR.Number, + State: linkedPR.State, + Title: linkedPR.Title, + } + } + return utils.NewToolResultText(string(r)), output, nil + }, normalizeAssignCopilotToIssueWithIntentArguments) } type ReplaceActorsForAssignableInput struct { @@ -881,12 +928,13 @@ func RequestCopilotReview(t translations.TranslationHelperFunc) inventory.Server Required: []string{"owner", "repo", "pullNumber"}, } - return NewTool( + return NewTool[map[string]any, CopilotReviewOutput]( ToolsetMetadataCopilot, mcp.Tool{ - Name: "request_copilot_review", - Description: t("TOOL_REQUEST_COPILOT_REVIEW_DESCRIPTION", "Request a GitHub Copilot code review for a pull request. Use this for automated feedback on pull requests, usually before requesting a human reviewer."), - Icons: octicons.Icons("copilot"), + Name: "request_copilot_review", + OutputSchema: copilotReviewOutputSchema(), + Description: t("TOOL_REQUEST_COPILOT_REVIEW_DESCRIPTION", "Request a GitHub Copilot code review for a pull request. Use this for automated feedback on pull requests, usually before requesting a human reviewer."), + Icons: octicons.Icons("copilot"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_REQUEST_COPILOT_REVIEW_USER_TITLE", "Request Copilot review"), ReadOnlyHint: false, @@ -894,25 +942,25 @@ func RequestCopilotReview(t translations.TranslationHelperFunc) inventory.Server InputSchema: schema, }, scopes.RequireAll(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, CopilotReviewOutput, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + return utils.NewToolResultError(err.Error()), CopilotReviewOutput{}, nil } repo, err := RequiredParam[string](args, "repo") if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + return utils.NewToolResultError(err.Error()), CopilotReviewOutput{}, nil } pullNumber, err := RequiredInt(args, "pullNumber") if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + return utils.NewToolResultError(err.Error()), CopilotReviewOutput{}, nil } client, err := deps.GetClient(ctx) if err != nil { - return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), CopilotReviewOutput{}, nil } _, resp, err := client.PullRequests.RequestReviewers( @@ -930,21 +978,21 @@ func RequestCopilotReview(t translations.TranslationHelperFunc) inventory.Server copilotReviewErrMsg(ctx, client, "failed to request copilot review", owner, repo, pullNumber, resp, err), resp, err, - ), nil, nil + ), CopilotReviewOutput{}, nil } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusCreated { bodyBytes, err := io.ReadAll(resp.Body) if err != nil { - return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil + return utils.NewToolResultErrorFromErr("failed to read response body", err), CopilotReviewOutput{}, nil } - return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to request copilot review", resp, bodyBytes), nil, nil + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to request copilot review", resp, bodyBytes), CopilotReviewOutput{}, nil } // Return nothing on success, as there's not much value in returning the Pull Request itself - return utils.NewToolResultText(""), nil, nil - }) + return utils.NewToolResultText(""), CopilotReviewOutput{Status: "requested"}, nil + }, normalizeRequestCopilotReviewArguments) } // copilotReviewErrMsg disambiguates the bare 404 this endpoint returns when the diff --git a/pkg/github/custom_properties.go b/pkg/github/custom_properties.go index 734fe176ce..89e8ee43d6 100644 --- a/pkg/github/custom_properties.go +++ b/pkg/github/custom_properties.go @@ -23,6 +23,10 @@ const customPropertiesLevelDescription = "The level at which custom properties a // CustomPropertiesRead creates the custom properties read tool. func CustomPropertiesRead(t translations.TranslationHelperFunc) inventory.ServerTool { + return typedGovernanceTool(customPropertiesReadLegacy(t), customPropertiesReadOutputSchema(), decodeCustomProperties("read"), normalizeGovernanceLevelRouting) +} + +func customPropertiesReadLegacy(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataGovernance, mcp.Tool{ @@ -137,6 +141,10 @@ func customPropertiesReadEnterprise(ctx context.Context, client *github.Client, // CustomPropertiesWrite creates the custom properties write tool. func CustomPropertiesWrite(t translations.TranslationHelperFunc) inventory.ServerTool { + return typedGovernanceTool(customPropertiesWriteLegacy(t), customPropertiesWriteOutputSchema(), decodeCustomProperties("write"), normalizeGovernanceLevelRouting) +} + +func customPropertiesWriteLegacy(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataGovernance, mcp.Tool{ diff --git a/pkg/github/custom_properties_test.go b/pkg/github/custom_properties_test.go index 394e183d9b..88af8114c3 100644 --- a/pkg/github/custom_properties_test.go +++ b/pkg/github/custom_properties_test.go @@ -26,7 +26,8 @@ func Test_CustomPropertiesRead(t *testing.T) { schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") - assert.ElementsMatch(t, schema.Required, []string{"level"}) + assert.ElementsMatch(t, []string{"level"}, schema.Required) + assert.Equal(t, "string", schema.Properties["level"].Type) t.Run("repository level: returns property values", func(t *testing.T) { mockValues := []*github.CustomPropertyValue{{PropertyName: "environment", Value: "production"}} @@ -137,10 +138,13 @@ func Test_CustomPropertiesWrite(t *testing.T) { schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") - assert.ElementsMatch(t, schema.Required, []string{"level", "properties"}) + assert.ElementsMatch(t, []string{"level", "properties"}, schema.Required) + assert.Equal(t, "string", schema.Properties["level"].Type) + assert.Equal(t, "array", schema.Properties["properties"].Type) t.Run("property items use level-specific schemas", func(t *testing.T) { itemSchema := schema.Properties["properties"].Items + require.NotNil(t, itemSchema) assert.Equal(t, "object", itemSchema.Type) require.Len(t, itemSchema.OneOf, 2) valueItemSchema := itemSchema.OneOf[0] diff --git a/pkg/github/find_duplicate.go b/pkg/github/find_duplicate.go index 6d50a3ab96..1dc43923f9 100644 --- a/pkg/github/find_duplicate.go +++ b/pkg/github/find_duplicate.go @@ -62,14 +62,6 @@ func FindDuplicate(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "issue_number"}, } WithPagination(schema) - // Unlike most tools, find_duplicate does not substitute its own default - // for an explicit 0: it forwards whatever page/perPage value is supplied - // (including 0) to the API unchanged, letting the API apply its own - // default. Document that exception here instead of relying solely on the - // generic WithPagination wording. - schema.Properties["page"].Description = "Page number for pagination (min 1). An explicit 0 is forwarded to the API as-is rather than being replaced with 1." - schema.Properties["perPage"].Description = "Results per page for pagination (min 1, max 100). An explicit 0 is forwarded to the API as-is rather than being replaced with 30." - st := NewToolWithSchemaOptions[FindDuplicateInput, []DuplicateCandidate]( ToolsetMetadataIssues, mcp.Tool{ diff --git a/pkg/github/find_duplicate_test.go b/pkg/github/find_duplicate_test.go index c68d66e55e..c31a53fd35 100644 --- a/pkg/github/find_duplicate_test.go +++ b/pkg/github/find_duplicate_test.go @@ -40,15 +40,11 @@ func Test_FindDuplicate(t *testing.T) { assert.Contains(t, schema.Properties, "perPage") assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "issue_number"}) - // Unlike most paginated tools, find_duplicate forwards an explicit 0 to - // the API unchanged instead of substituting the usual default. The - // advertised minimum stays canonical (matching WithPagination), but the - // description must say so to avoid misleading callers. assert.Equal(t, 1.0, *schema.Properties["page"].Minimum) assert.Equal(t, 1.0, *schema.Properties["perPage"].Minimum) assert.Equal(t, 100.0, *schema.Properties["perPage"].Maximum) - assert.Contains(t, schema.Properties["page"].Description, "An explicit 0 is forwarded to the API as-is") - assert.Contains(t, schema.Properties["perPage"].Description, "An explicit 0 is forwarded to the API as-is") + assert.Equal(t, "Page number for pagination (min 1)", schema.Properties["page"].Description) + assert.Equal(t, "Results per page for pagination (min 1, max 100)", schema.Properties["perPage"].Description) } func Test_FindDuplicate_RankedResults(t *testing.T) { diff --git a/pkg/github/gists.go b/pkg/github/gists.go index 44baba5c6d..3fcfffc883 100644 --- a/pkg/github/gists.go +++ b/pkg/github/gists.go @@ -20,31 +20,34 @@ import ( // ListGists creates a tool to list gists for a user func ListGists(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool( + inputSchema := WithPagination(&jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "username": { + Type: "string", + Description: "GitHub username (omit for authenticated user's gists)", + }, + "since": { + Type: "string", + Description: "Only gists updated after this time (ISO 8601 timestamp)", + }, + }, + }) + return NewToolWithSchemaOptions( ToolsetMetadataGists, mcp.Tool{ - Name: "list_gists", - Description: t("TOOL_LIST_GISTS_DESCRIPTION", "List gists for a user"), + Name: "list_gists", + OutputSchema: gistListOutputSchema(), + Description: t("TOOL_LIST_GISTS_DESCRIPTION", "List gists for a user"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_LIST_GISTS", "List Gists"), ReadOnlyHint: true, }, - InputSchema: WithPagination(&jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "username": { - Type: "string", - Description: "GitHub username (omit for authenticated user's gists)", - }, - "since": { - Type: "string", - Description: "Only gists updated after this time (ISO 8601 timestamp)", - }, - }, - }), + InputSchema: inputSchema, }, scopes.NoScopes(), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + inventory.TypedSchemaOptions{ValidationInputSchema: relaxedPaginationValidationSchema(inputSchema)}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, []*GistOutput, error) { username, err := OptionalParam[string](args, "username") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -102,8 +105,9 @@ func ListGists(t translations.TranslationHelperFunc) inventory.ServerTool { result := utils.NewToolResultText(string(r)) result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGistList()) - return result, nil, nil + return result, projectGists(gists), nil }, + gitGistNormalizer(validateListGistsArguments), ) } @@ -112,8 +116,9 @@ func GetGist(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataGists, mcp.Tool{ - Name: "get_gist", - Description: t("TOOL_GET_GIST_DESCRIPTION", "Get gist content of a particular gist, by gist ID"), + Name: "get_gist", + OutputSchema: gistOutputSchema(), + Description: t("TOOL_GET_GIST_DESCRIPTION", "Get gist content of a particular gist, by gist ID"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_GIST", "Get Gist Content"), ReadOnlyHint: true, @@ -130,7 +135,7 @@ func GetGist(t translations.TranslationHelperFunc) inventory.ServerTool { }, }, scopes.NoScopes(), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, *GistReadOutput, error) { gistID, err := RequiredParam[string](args, "gist_id") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -162,8 +167,9 @@ func GetGist(t translations.TranslationHelperFunc) inventory.ServerTool { result := utils.NewToolResultText(string(r)) result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGist()) - return result, nil, nil + return result, &GistReadOutput{Gist: projectGist(gist)}, nil }, + gitGistNormalizer(validateGetGistArguments), ) } @@ -172,8 +178,9 @@ func CreateGist(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataGists, mcp.Tool{ - Name: "create_gist", - Description: t("TOOL_CREATE_GIST_DESCRIPTION", "Create a new gist"), + Name: "create_gist", + OutputSchema: gistMutationOutputSchema(), + Description: t("TOOL_CREATE_GIST_DESCRIPTION", "Create a new gist"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_CREATE_GIST", "Create Gist"), ReadOnlyHint: false, @@ -203,7 +210,7 @@ func CreateGist(t translations.TranslationHelperFunc) inventory.ServerTool { }, }, scopes.RequireAll(scopes.Gist), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, *MinimalResponse, error) { description, err := OptionalParam[string](args, "description") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -264,8 +271,9 @@ func CreateGist(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &minimalResponse, nil }, + gitGistNormalizer(validateCreateGistArguments), ) } @@ -274,8 +282,9 @@ func UpdateGist(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataGists, mcp.Tool{ - Name: "update_gist", - Description: t("TOOL_UPDATE_GIST_DESCRIPTION", "Update an existing gist"), + Name: "update_gist", + OutputSchema: gistMutationOutputSchema(), + Description: t("TOOL_UPDATE_GIST_DESCRIPTION", "Update an existing gist"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_UPDATE_GIST", "Update Gist"), ReadOnlyHint: false, @@ -304,7 +313,7 @@ func UpdateGist(t translations.TranslationHelperFunc) inventory.ServerTool { }, }, scopes.RequireAll(scopes.Gist), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, *MinimalResponse, error) { gistID, err := RequiredParam[string](args, "gist_id") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -373,7 +382,8 @@ func UpdateGist(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + return utils.NewToolResultText(string(r)), &minimalResponse, nil }, + gitGistNormalizer(validateUpdateGistArguments), ) } diff --git a/pkg/github/git.go b/pkg/github/git.go index b066dfe757..a67e67df59 100644 --- a/pkg/github/git.go +++ b/pkg/github/git.go @@ -44,8 +44,9 @@ func GetRepositoryTree(t translations.TranslationHelperFunc) inventory.ServerToo return NewTool( ToolsetMetadataGit, mcp.Tool{ - Name: "get_repository_tree", - Description: t("TOOL_GET_REPOSITORY_TREE_DESCRIPTION", "Get the tree structure (files and directories) of a GitHub repository at a specific ref or SHA"), + Name: "get_repository_tree", + OutputSchema: treeOutputSchema(), + Description: t("TOOL_GET_REPOSITORY_TREE_DESCRIPTION", "Get the tree structure (files and directories) of a GitHub repository at a specific ref or SHA"), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_GET_REPOSITORY_TREE_USER_TITLE", "Get repository tree"), ReadOnlyHint: true, @@ -79,7 +80,7 @@ func GetRepositoryTree(t translations.TranslationHelperFunc) inventory.ServerToo }, }, scopes.PublicRead(scopes.Repo), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, *RepositoryTreeOutput, error) { owner, err := RequiredParam[string](args, "owner") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -178,7 +179,8 @@ func GetRepositoryTree(t translations.TranslationHelperFunc) inventory.ServerToo // repos only collaborators can (trusted). Confidentiality follows // repo visibility. result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelCommitContents) - return result, nil, nil + return result, projectRepositoryTree(response), nil }, + gitGistNormalizer(validateTreeArguments), ) } diff --git a/pkg/github/git_gist_types.go b/pkg/github/git_gist_types.go new file mode 100644 index 0000000000..e28f8782e9 --- /dev/null +++ b/pkg/github/git_gist_types.go @@ -0,0 +1,281 @@ +package github + +import ( + "encoding/json" + "slices" + "sync" + "time" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" +) + +// gitGistNormalizer applies the legacy argument validation, in legacy order and +// with legacy messages, before SDK schema validation can reject the request. +// Validators may also canonicalize accepted values by updating args. +func gitGistNormalizer(validate func(args map[string]any) error) inventory.InputNormalizer { + return func(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, &inventory.ToolInputError{Message: "invalid arguments: " + err.Error()} + } + if args == nil { + return nil, &inventory.ToolInputError{Message: "invalid arguments: arguments must be a JSON object"} + } + if err := validate(args); err != nil { + return nil, &inventory.ToolInputError{Message: err.Error()} + } + return json.Marshal(args) + } +} + +func validateTreeArguments(args map[string]any) error { + for _, field := range []string{"owner", "repo"} { + if _, err := RequiredParam[string](args, field); err != nil { + return err + } + } + if _, err := OptionalParam[string](args, "tree_sha"); err != nil { + return err + } + if _, err := OptionalBoolParamWithDefault(args, "recursive", false); err != nil { + return err + } + _, err := OptionalParam[string](args, "path_filter") + return err +} + +func validateListGistsArguments(args map[string]any) error { + if _, err := OptionalParam[string](args, "username"); err != nil { + return err + } + if _, err := OptionalParam[string](args, "since"); err != nil { + return err + } + pagination, err := OptionalPaginationParams(args) + if err != nil { + return err + } + args["page"], args["perPage"] = pagination.Page, pagination.PerPage + return nil +} + +func validateGetGistArguments(args map[string]any) error { + _, err := RequiredParam[string](args, "gist_id") + return err +} + +func validateCreateGistArguments(args map[string]any) error { + if _, err := OptionalParam[string](args, "description"); err != nil { + return err + } + for _, field := range []string{"filename", "content"} { + if _, err := RequiredParam[string](args, field); err != nil { + return err + } + } + _, err := OptionalParam[bool](args, "public") + return err +} + +func validateUpdateGistArguments(args map[string]any) error { + if _, err := RequiredParam[string](args, "gist_id"); err != nil { + return err + } + if _, err := OptionalParam[string](args, "description"); err != nil { + return err + } + for _, field := range []string{"filename", "content"} { + if _, err := RequiredParam[string](args, field); err != nil { + return err + } + } + return nil +} + +var treeOutputSchema = sync.OnceValue(func() *jsonschema.Schema { + schema := repositoryOutputSchema[RepositoryTreeOutput]() + entry := schema.Properties["tree"].Items + entry.Properties["type"].Enum = []any{"blob", "tree", "commit", ""} + entry.Properties["mode"].Enum = []any{"100644", "100755", "040000", "160000", "120000", ""} + entry.Properties["type"].Description = "Git object type; empty if absent in the API response." + entry.Properties["mode"].Description = "Git file mode in octal; empty if absent in the API response." + entry.Properties["size"].Description = "Blob size in bytes, when available." + // The SDK validates the zero output on error results, so tree stays nullable. + // Size is omitted, never null, for entries without a size. + size := entry.Properties["size"] + size.Types, size.Type = nil, "integer" + return schema +}) + +type RepositoryTreeOutput struct { + SHA string `json:"sha"` + Truncated bool `json:"truncated"` + Tree []RepositoryTreeEntryOutput `json:"tree"` + TreeSHA string `json:"tree_sha"` + Owner string `json:"owner"` + Repo string `json:"repo"` + Recursive bool `json:"recursive"` + Count int `json:"count"` +} + +type RepositoryTreeEntryOutput struct { + Path string `json:"path"` + Type string `json:"type"` + Size *int `json:"size,omitempty"` + Mode string `json:"mode"` + SHA string `json:"sha"` +} + +func projectRepositoryTree(tree TreeResponse) *RepositoryTreeOutput { + entries := make([]RepositoryTreeEntryOutput, len(tree.Tree)) + for i, entry := range tree.Tree { + entries[i] = RepositoryTreeEntryOutput{ + Path: entry.Path, Type: entry.Type, Size: entry.Size, Mode: entry.Mode, SHA: entry.SHA, + } + } + return &RepositoryTreeOutput{ + SHA: tree.SHA, Truncated: tree.Truncated, Tree: entries, + TreeSHA: tree.TreeSHA, Owner: tree.Owner, Repo: tree.Repo, + Recursive: tree.Recursive, Count: tree.Count, + } +} + +type GistOutput struct { + ID *string `json:"id,omitempty"` + Description *string `json:"description,omitempty"` + Public *bool `json:"public,omitempty"` + Owner *MinimalUser `json:"owner,omitempty"` + Files map[string]GistFileOutput `json:"files,omitempty"` + Comments *int `json:"comments,omitempty"` + HTMLURL *string `json:"html_url,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` +} + +type GistReadOutput struct { + Gist *GistOutput +} + +func (output GistReadOutput) MarshalJSON() ([]byte, error) { + return json.Marshal(output.Gist) +} + +type GistFileOutput struct { + Filename *string `json:"filename,omitempty"` + Content *string `json:"content,omitempty"` + Size *int `json:"size,omitempty"` + Language *string `json:"language,omitempty"` + Type *string `json:"type,omitempty"` +} + +func projectGist(gist *github.Gist) *GistOutput { + if gist == nil { + return nil + } + output := &GistOutput{ + ID: gist.ID, Description: gist.Description, Public: gist.Public, + Owner: convertToMinimalUser(gist.Owner), Comments: gist.Comments, HTMLURL: gist.HTMLURL, + } + if len(gist.Files) != 0 { + output.Files = make(map[string]GistFileOutput, len(gist.Files)) + for name, file := range gist.Files { + output.Files[string(name)] = GistFileOutput{ + Filename: file.Filename, Content: file.Content, Size: file.Size, Language: file.Language, Type: file.Type, + } + } + } + if gist.CreatedAt != nil { + output.CreatedAt = gist.CreatedAt.Format(time.RFC3339Nano) + } + if gist.UpdatedAt != nil { + output.UpdatedAt = gist.UpdatedAt.Format(time.RFC3339Nano) + } + return output +} + +func projectGists(gists []*github.Gist) []*GistOutput { + if gists == nil { + return nil + } + output := make([]*GistOutput, len(gists)) + for i, gist := range gists { + output[i] = projectGist(gist) + } + return output +} + +var gistOutputSchema = sync.OnceValue(func() *jsonschema.Schema { + return repositoryUnionSchema(&jsonschema.Schema{Type: "null"}, gistObjectSchema()) +}) + +var gistObjectSchema = sync.OnceValue(func() *jsonschema.Schema { + schema := forbidOmittedNulls(repositoryOutputSchema[GistOutput]()) + schema.Properties["created_at"].Format = "date-time" + schema.Properties["updated_at"].Format = "date-time" + schema.Properties["created_at"].Description = "Creation time in RFC3339 format." + schema.Properties["updated_at"].Description = "Last update time in RFC3339 format." + schema.Properties["public"].Description = "True for a public gist; false for a secret, URL-accessible gist." + schema.Properties["files"].AdditionalProperties.Properties["size"].Description = "File size in bytes." + schema.Properties["files"].AdditionalProperties.Properties["type"].Description = "File MIME type, when available." + // convertToMinimalUser never populates Details. + delete(schema.Properties["owner"].Properties, "details") + return schema +}) + +var gistListOutputSchema = sync.OnceValue(func() *jsonschema.Schema { + return &jsonschema.Schema{ + Types: []string{"array", "null"}, + Items: gistOutputSchema(), + } +}) + +// relaxedPaginationValidationSchema clones a WithPagination-derived input +// schema and clears the page/perPage bounds in the clone only. The advertised +// InputSchema (passed in) is left untouched, so tools/list still documents the +// min/max metadata; only the runtime ValidationInputSchema is relaxed, so the +// SDK continues accepting legacy out-of-range values (e.g. page=-1, +// perPage=101) that the handler has always forwarded unmodified to the +// GitHub API. +func relaxedPaginationValidationSchema(schema *jsonschema.Schema) *jsonschema.Schema { + cloned := inventory.CloneSchema(schema) + if page := cloned.Properties["page"]; page != nil { + page.Minimum = nil + } + if perPage := cloned.Properties["perPage"]; perPage != nil { + perPage.Minimum = nil + perPage.Maximum = nil + } + return cloned +} + +// Optional pointer fields are omitted rather than serialized as null. +func forbidOmittedNulls(schema *jsonschema.Schema) *jsonschema.Schema { + if schema == nil { + return nil + } + required := make(map[string]bool, len(schema.Required)) + for _, name := range schema.Required { + required[name] = true + } + for name, property := range schema.Properties { + if !required[name] { + property.Types = slices.DeleteFunc(slices.Clone(property.Types), func(t string) bool { return t == "null" }) + if len(property.Types) == 1 { + property.Type, property.Types = property.Types[0], nil + } + } + forbidOmittedNulls(property) + } + forbidOmittedNulls(schema.Items) + forbidOmittedNulls(schema.AdditionalProperties) + for _, definition := range schema.Defs { + forbidOmittedNulls(definition) + } + return schema +} + +var gistMutationOutputSchema = sync.OnceValue(func() *jsonschema.Schema { + return repositoryOutputSchema[MinimalResponse]() +}) diff --git a/pkg/github/labels.go b/pkg/github/labels.go index c6fecfc21f..689ca2af16 100644 --- a/pkg/github/labels.go +++ b/pkg/github/labels.go @@ -24,6 +24,10 @@ const labelOrderFieldIssueCount githubv4.LabelOrderField = "ISSUE_COUNT" // GetLabel retrieves a specific label by name from a GitHub repository func GetLabel(t translations.TranslationHelperFunc) inventory.ServerTool { + return typedGovernanceTool(getLabelLegacy(t), labelOutputSchema(), decodeGovernanceJSON[MinimalLabel]) +} + +func getLabelLegacy(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataIssues, mcp.Tool{ @@ -130,6 +134,10 @@ func GetLabelForLabelsToolset(t translations.TranslationHelperFunc) inventory.Se // ListLabels lists labels from a repository func ListLabels(t translations.TranslationHelperFunc) inventory.ServerTool { + return typedGovernanceTool(listLabelsLegacy(t), listLabelsOutputSchema(), decodeGovernanceJSON[ListLabelsOutput]) +} + +func listLabelsLegacy(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetLabels, mcp.Tool{ @@ -228,6 +236,12 @@ func ListLabels(t translations.TranslationHelperFunc) inventory.ServerTool { // LabelWrite handles create, update, and delete operations for GitHub labels func LabelWrite(t translations.TranslationHelperFunc) inventory.ServerTool { + return typedGovernanceTool(labelWriteLegacy(t), labelWriteOutputSchema(), func(raw []byte, args map[string]json.RawMessage) (LabelWriteOutput, error) { + return LabelWriteOutput{Method: strings.ToLower(governanceArgument(args, "method")), Message: string(raw)}, nil + }) +} + +func labelWriteLegacy(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetLabels, mcp.Tool{ diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 1f55182160..4915373d12 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -284,10 +284,11 @@ func derefString(s *githubv4.String) string { // ProjectsList returns the tool and handler for listing GitHub Projects resources. func ProjectsList(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewToolWithSchemaOptions[ProjectsListInput, *ProjectsListOutput]( ToolsetMetadataProjects, mcp.Tool{ - Name: "projects_list", + Name: "projects_list", + OutputSchema: projectsListOutputSchema(), Description: t("TOOL_PROJECTS_LIST_DESCRIPTION", `Tools for listing GitHub Projects resources. Use this tool to list projects for a user or organization, or list project fields, items, views, and status updates for a specific project. @@ -358,7 +359,8 @@ Use this tool to list projects for a user or organization, or list project field }, }, scopes.RequireAll(scopes.ReadProject), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + projectsSchemaOptions(), + projectsTypedHandler[ProjectsListInput](func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { method, err := RequiredParam[string](args, "method") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -442,17 +444,19 @@ Use this tool to list projects for a user or organization, or list project field default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } - }, + }, decodeProjectsListOutput), + normalizeProjectsRouting("list"), ) return tool } // ProjectsGet returns the tool and handler for getting GitHub Projects resources. func ProjectsGet(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewToolWithSchemaOptions[ProjectsGetInput, *ProjectsGetOutput]( ToolsetMetadataProjects, mcp.Tool{ - Name: "projects_get", + Name: "projects_get", + OutputSchema: projectsGetOutputSchema(), Description: t("TOOL_PROJECTS_GET_DESCRIPTION", `Get details about specific GitHub Projects resources. Use this tool to get details about individual projects, project fields, project items, and project views by their unique IDs. `), @@ -522,7 +526,8 @@ Use this tool to get details about individual projects, project fields, project }, }, scopes.RequireAll(scopes.ReadProject), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + projectsSchemaOptions(), + projectsTypedHandler[ProjectsGetInput](func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { method, err := RequiredParam[string](args, "method") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -643,7 +648,8 @@ Use this tool to get details about individual projects, project fields, project default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } - }, + }, decodeProjectsGetOutput), + normalizeProjectsRouting("get"), ) return tool } @@ -692,11 +698,10 @@ func updateProjectItemsItemSchema() *jsonschema.Schema { } func projectUpdatedFieldSchema() *jsonschema.Schema { - value := &jsonschema.Schema{ - Description: "The value to apply. Any JSON value is accepted; use null to clear the field.", - } variant := func(required []string, properties map[string]*jsonschema.Schema) *jsonschema.Schema { - properties["value"] = value + properties["value"] = &jsonschema.Schema{ + Description: "The value to apply. Any JSON value is accepted; use null to clear the field.", + } return &jsonschema.Schema{ Type: "object", AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, @@ -727,11 +732,12 @@ func projectUpdatedFieldSchema() *jsonschema.Schema { // ProjectsWrite returns the tool and handler for modifying GitHub Projects resources. func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { - tool := NewTool( + tool := NewToolWithSchemaOptions[ProjectsWriteInput, *ProjectsWriteOutput]( ToolsetMetadataProjects, mcp.Tool{ - Name: "projects_write", - Description: t("TOOL_PROJECTS_WRITE_DESCRIPTION", "Create and manage GitHub Projects: create projects, add/update/delete items, bulk-update many items at once, manage views, create status updates, and add iteration fields."), + Name: "projects_write", + OutputSchema: projectsWriteOutputSchema(), + Description: t("TOOL_PROJECTS_WRITE_DESCRIPTION", "Create and manage GitHub Projects: create projects, add/update/delete items, bulk-update many items at once, manage views, create status updates, and add iteration fields."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_PROJECTS_WRITE_USER_TITLE", "Manage GitHub Projects"), ReadOnlyHint: false, @@ -891,7 +897,8 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { }, }, scopes.RequireAll(scopes.Project), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + projectsSchemaOptions(), + projectsTypedHandler[ProjectsWriteInput](func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { method, err := RequiredParam[string](args, "method") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -1037,7 +1044,8 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } - }, + }, decodeProjectsWriteOutput), + normalizeProjectsRouting("write"), ) return tool } diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 6305569c3a..4c1aa3af46 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -37,7 +37,7 @@ func Test_ProjectsList(t *testing.T) { assert.Contains(t, inputSchema.Properties, "query") assert.Contains(t, inputSchema.Properties, "fields") assert.Contains(t, inputSchema.Properties["method"].Enum, projectsMethodListProjectViews) - assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner"}) + assert.ElementsMatch(t, []string{"method", "owner"}, inputSchema.Required) } func Test_ProjectsList_ListProjects(t *testing.T) { @@ -638,7 +638,7 @@ func Test_ProjectsGet(t *testing.T) { assert.Contains(t, inputSchema.Properties["method"].Enum, projectsMethodGetProjectView) assert.Contains(t, inputSchema.Properties, "field_id") assert.Contains(t, inputSchema.Properties, "item_id") - assert.ElementsMatch(t, inputSchema.Required, []string{"method"}) + assert.ElementsMatch(t, []string{"method"}, inputSchema.Required) } func Test_ProjectsGet_GetProject(t *testing.T) { @@ -934,7 +934,7 @@ func Test_ProjectsWrite(t *testing.T) { assert.Contains(t, inputSchema.Properties["method"].Enum, projectsMethodCreateProjectView) assert.Contains(t, inputSchema.Properties["method"].Enum, projectsMethodUpdateProjectView) assert.Contains(t, inputSchema.Properties["method"].Enum, projectsMethodDeleteProjectView) - assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner"}) + assert.ElementsMatch(t, []string{"method", "owner"}, inputSchema.Required) // Verify DestructiveHint is set assert.NotNil(t, toolDef.Tool.Annotations) diff --git a/pkg/github/projects_types.go b/pkg/github/projects_types.go new file mode 100644 index 0000000000..6d8fded25e --- /dev/null +++ b/pkg/github/projects_types.go @@ -0,0 +1,614 @@ +package github + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "reflect" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// ProjectParameter retains the original JSON for the legacy method-specific +// validators. In particular, invalid or unused arguments must not fail before +// client acquisition, and numeric strings must retain their original coercion. +type ProjectParameter[T any] struct { + Value T + raw json.RawMessage +} + +func (p *ProjectParameter[T]) UnmarshalJSON(raw []byte) error { + p.raw = append(p.raw[:0], raw...) + // A value of the wrong type is reported by the selected method, not here. + var value T + if err := json.Unmarshal(raw, &value); err == nil { + p.Value = value + } + return nil +} + +func (p ProjectParameter[T]) MarshalJSON() ([]byte, error) { + if p.raw != nil { + return p.raw, nil + } + return json.Marshal(p.Value) +} + +type ProjectsListInput struct { + Method ProjectParameter[string] `json:"method"` + Owner ProjectParameter[string] `json:"owner"` + OwnerType ProjectParameter[string] `json:"owner_type"` + ProjectNumber ProjectParameter[int] `json:"project_number"` + Query ProjectParameter[string] `json:"query"` + Fields ProjectParameter[[]string] `json:"fields"` + FieldNames ProjectParameter[[]string] `json:"field_names"` + PerPage ProjectParameter[int] `json:"perPage"` + LegacyPerPage ProjectParameter[int] `json:"per_page"` + After ProjectParameter[string] `json:"after"` + Before ProjectParameter[string] `json:"before"` +} + +type ProjectsGetInput struct { + Method ProjectParameter[string] `json:"method"` + Owner ProjectParameter[string] `json:"owner"` + OwnerType ProjectParameter[string] `json:"owner_type"` + ProjectNumber ProjectParameter[int] `json:"project_number"` + FieldID ProjectParameter[int64] `json:"field_id"` + ItemID ProjectParameter[int64] `json:"item_id"` + Fields ProjectParameter[[]string] `json:"fields"` + FieldNames ProjectParameter[[]string] `json:"field_names"` + StatusUpdateID ProjectParameter[string] `json:"status_update_id"` + ViewID ProjectParameter[string] `json:"view_id"` +} + +type ProjectsWriteInput struct { + Method ProjectParameter[string] `json:"method"` + Owner ProjectParameter[string] `json:"owner"` + OwnerType ProjectParameter[string] `json:"owner_type"` + ProjectNumber ProjectParameter[int] `json:"project_number"` + Title ProjectParameter[string] `json:"title"` + ViewID ProjectParameter[string] `json:"view_id"` + Name ProjectParameter[string] `json:"name"` + Layout ProjectParameter[string] `json:"layout"` + Filter ProjectParameter[*string] `json:"filter"` + VisibleFields ProjectParameter[[]string] `json:"visible_fields"` + VisibleFieldNames ProjectParameter[[]string] `json:"visible_field_names"` + ItemID ProjectParameter[int64] `json:"item_id"` + ItemType ProjectParameter[string] `json:"item_type"` + ItemOwner ProjectParameter[string] `json:"item_owner"` + ItemRepo ProjectParameter[string] `json:"item_repo"` + IssueNumber ProjectParameter[int] `json:"issue_number"` + PullRequestNumber ProjectParameter[int] `json:"pull_request_number"` + UpdatedField ProjectParameter[ProjectUpdatedFieldInput] `json:"updated_field"` + Items ProjectParameter[[]ProjectItemReference] `json:"items"` + Body ProjectParameter[string] `json:"body"` + Status ProjectParameter[string] `json:"status"` + StartDate ProjectParameter[string] `json:"start_date"` + TargetDate ProjectParameter[string] `json:"target_date"` + FieldName ProjectParameter[string] `json:"field_name"` + IterationDuration ProjectParameter[int] `json:"iteration_duration"` + Iterations ProjectParameter[[]ProjectIterationInput] `json:"iterations"` +} + +func (input ProjectsListInput) MarshalJSON() ([]byte, error) { + return marshalProjectInput(input) +} + +func (input ProjectsGetInput) MarshalJSON() ([]byte, error) { + return marshalProjectInput(input) +} + +func (input ProjectsWriteInput) MarshalJSON() ([]byte, error) { + return marshalProjectInput(input) +} + +func marshalProjectInput(input any) ([]byte, error) { + args, err := projectArguments(input) + if err != nil { + return nil, err + } + return json.Marshal(args) +} + +func (input *ProjectsListInput) UnmarshalJSON(raw []byte) error { + return unmarshalProjectInput(raw, input) +} + +func (input *ProjectsGetInput) UnmarshalJSON(raw []byte) error { + return unmarshalProjectInput(raw, input) +} + +func (input *ProjectsWriteInput) UnmarshalJSON(raw []byte) error { + return unmarshalProjectInput(raw, input) +} + +// Legacy map handlers recognize only exact JSON keys; encoding/json's default +// case-insensitive struct matching must not turn ignored keys into write targets. +func unmarshalProjectInput(raw []byte, input any) error { + var args map[string]json.RawMessage + if err := json.Unmarshal(raw, &args); err != nil { + return err + } + value := reflect.ValueOf(input).Elem() + value.SetZero() + inputType := value.Type() + for i := range value.NumField() { + if argument, ok := args[inputType.Field(i).Tag.Get("json")]; ok { + if err := json.Unmarshal(argument, value.Field(i).Addr().Interface()); err != nil { + return err + } + } + } + return nil +} + +type ProjectUpdatedFieldInput struct { + ID *int64 `json:"id,omitempty"` + Name *string `json:"name,omitempty"` + Value json.RawMessage `json:"value"` +} + +type ProjectItemReference struct { + NodeID string `json:"node_id,omitempty"` + ItemID int64 `json:"item_id,omitempty"` + ItemOwner string `json:"item_owner,omitempty"` + ItemRepo string `json:"item_repo,omitempty"` + IssueNumber int `json:"issue_number,omitempty"` +} + +type ProjectIterationInput struct { + Title string `json:"title"` + StartDate string `json:"start_date"` + Duration float64 `json:"duration"` +} + +// The raw map is confined to the compatibility boundary with the existing +// validators and API helpers. Omitted parameters must stay omitted. +func projectArguments(input any) (map[string]any, error) { + value := reflect.ValueOf(input) + inputType := value.Type() + args := make(map[string]any, value.NumField()) + for i := range value.NumField() { + parameter := value.Field(i).Interface().(interface { + argumentJSON() (json.RawMessage, error) + }) + raw, err := parameter.argumentJSON() + if err != nil { + return nil, err + } + if raw == nil { + continue + } + var argument any + if err := json.Unmarshal(raw, &argument); err != nil { + return nil, err + } + args[inputType.Field(i).Tag.Get("json")] = argument + } + return args, nil +} + +func (p ProjectParameter[T]) argumentJSON() (json.RawMessage, error) { + if p.raw != nil { + return p.raw, nil + } + if reflect.ValueOf(&p.Value).Elem().IsZero() { + return nil, nil + } + return json.Marshal(p.Value) +} + +type ProjectsListOutput struct { + Method string `json:"method"` + Projects *ProjectListOutput `json:"projects,omitempty"` + Fields *ProjectFieldListOutput `json:"fields,omitempty"` + Items *ProjectItemListOutput `json:"items,omitempty"` + StatusUpdates *ProjectStatusUpdateListOutput `json:"status_updates,omitempty"` + Views *ProjectViewListOutput `json:"views,omitempty"` +} + +type ProjectListOutput struct { + Projects []MinimalProject `json:"projects"` + PageInfo *pageInfo `json:"pageInfo,omitempty"` + Note string `json:"note,omitempty"` +} + +type ProjectFieldListOutput struct { + Fields []ProjectFieldOutput `json:"fields"` + PageInfo pageInfo `json:"pageInfo"` +} + +// ProjectFieldOutput omits the derivable project API URL from REST fields. +type ProjectFieldOutput struct { + ID *int64 `json:"id,omitempty"` + NodeID *string `json:"node_id,omitempty"` + Name *string `json:"name,omitempty"` + DataType *string `json:"data_type,omitempty"` + Options []ProjectFieldOptionOutput `json:"options,omitempty"` + Configuration *ProjectFieldConfigurationOutput `json:"configuration,omitempty"` + CreatedAt *github.Timestamp `json:"created_at,omitempty"` + UpdatedAt *github.Timestamp `json:"updated_at,omitempty"` +} + +type ProjectFieldOptionOutput struct { + ID *string `json:"id,omitempty"` + Color *string `json:"color,omitempty"` + Description *ProjectFieldTextOutput `json:"description,omitempty"` + Name *ProjectFieldTextOutput `json:"name,omitempty"` +} + +type ProjectFieldTextOutput struct { + HTML *string `json:"html,omitempty"` + Raw *string `json:"raw,omitempty"` +} + +type ProjectFieldConfigurationOutput struct { + Duration *int `json:"duration,omitempty"` + StartDay *int `json:"start_day,omitempty"` + Iterations []ProjectFieldIterationOutput `json:"iterations,omitempty"` +} + +type ProjectFieldIterationOutput struct { + ID *string `json:"id,omitempty"` + Title *ProjectFieldTextOutput `json:"title,omitempty"` + StartDate *string `json:"start_date,omitempty"` + Duration *int `json:"duration,omitempty"` +} + +type ProjectItemListOutput struct { + Items []ProjectItemOutput `json:"items"` + PageInfo pageInfo `json:"pageInfo"` +} + +type ProjectStatusUpdateListOutput struct { + StatusUpdates []MinimalProjectStatusUpdate `json:"statusUpdates"` + PageInfo ProjectGraphQLPageInfo `json:"pageInfo"` +} + +type ProjectViewListOutput struct { + Views []MinimalProjectView `json:"views"` + PageInfo ProjectGraphQLPageInfo `json:"pageInfo"` +} + +type ProjectGraphQLPageInfo struct { + HasNextPage bool `json:"hasNextPage"` + HasPreviousPage bool `json:"hasPreviousPage"` + NextCursor string `json:"nextCursor"` + PrevCursor string `json:"prevCursor"` +} + +type ProjectsGetOutput struct { + Method string `json:"method"` + Project *MinimalProject `json:"project,omitempty"` + Field *ProjectFieldOutput `json:"field,omitempty"` + Item *ProjectItemOutput `json:"item,omitempty"` + StatusUpdate *MinimalProjectStatusUpdate `json:"status_update,omitempty"` + View *MinimalProjectView `json:"view,omitempty"` +} + +type ProjectItemOutput struct { + ID int64 `json:"id"` + NodeID string `json:"node_id,omitempty"` + ContentType string `json:"content_type,omitempty"` + Content *MinimalProjectItemContent `json:"content,omitempty"` + Fields []ProjectFieldValueOutput `json:"fields,omitempty"` + ArchivedAt string `json:"archived_at,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + Creator string `json:"creator,omitempty"` +} + +type ProjectFieldValueOutput struct { + ID int64 `json:"id,omitempty"` + Name string `json:"name,omitempty"` + DataType string `json:"data_type,omitempty"` + // Unknown field types are recursively compacted by the legacy projection, + // including arbitrary object keys. A closed value union would lose data. + Value json.RawMessage `json:"value,omitempty"` +} + +type ProjectsWriteOutput struct { + Method string `json:"method"` + Added *ProjectAddedItemOutput `json:"added,omitempty"` + Item *ProjectItemOutput `json:"item,omitempty"` + IssueFields *ProjectIssueOutput `json:"issue_fields,omitempty"` + Batch *ProjectBatchOutput `json:"batch,omitempty"` + DeletedItem *RepositoryMessageOutput `json:"deleted_item,omitempty"` + StatusUpdate *MinimalProjectStatusUpdate `json:"status_update,omitempty"` + View *MinimalProjectView `json:"view,omitempty"` + DeletedView *ProjectDeletedViewOutput `json:"deleted_view,omitempty"` + Project *ProjectCreatedOutput `json:"project,omitempty"` + IterationField *ProjectIterationFieldOutput `json:"iteration_field,omitempty"` +} + +type ProjectIssueOutput struct { + ID string `json:"id"` + HTMLURL string `json:"html_url"` +} + +type ProjectAddedItemOutput struct { + ID *string `json:"id"` + Message string `json:"message"` + FullDatabaseID string `json:"full_database_id,omitempty"` + ItemID *int64 `json:"item_id,omitempty"` +} + +type ProjectDeletedViewOutput struct { + DeletedViewID string `json:"deleted_view_id"` +} + +type ProjectCreatedOutput struct { + ID string `json:"id"` + Number int `json:"number"` + Title string `json:"title"` + HTMLURL string `json:"html_url"` +} + +type ProjectIterationFieldOutput struct { + ID string `json:"id"` + Name string `json:"name"` + Configuration struct { + Iterations []ProjectIterationOutput `json:"iterations"` + } `json:"configuration"` +} + +type ProjectIterationOutput struct { + ID string `json:"id"` + Title string `json:"title"` + StartDate string `json:"start_date"` + Duration int `json:"duration"` +} + +type ProjectBatchOutput struct { + Total int `json:"total"` + Succeeded int `json:"succeeded"` + Failed int `json:"failed"` + Unknown int `json:"unknown"` + Results []ProjectBatchItemOutput `json:"results"` +} + +type ProjectBatchItemOutput struct { + Index int `json:"index"` + Status batchItemStatus `json:"status"` + Item *batchItemIdentity `json:"item,omitempty"` + Error *ProjectBatchError `json:"error,omitempty"` + // Batch entries echo user-supplied reference objects, including malformed + // references and extra keys, verbatim. + Ref json.RawMessage `json:"ref,omitempty"` +} + +type ProjectBatchError struct { + Code string `json:"code"` + Message string `json:"message"` + Candidates []ProjectResolutionCandidate `json:"candidates,omitempty"` + Hint string `json:"hint,omitempty"` +} + +type ProjectResolutionCandidate struct { + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + DataType string `json:"data_type,omitempty"` +} + +func projectsListOutputSchema() *jsonschema.Schema { + schema := projectOutputSchema[ProjectsListOutput]() + schema.Properties["method"].Enum = []any{projectsMethodListProjects, projectsMethodListProjectFields, projectsMethodListProjectItems, projectsMethodListProjectStatusUpdates, projectsMethodListProjectViews} + return schema +} + +func projectsGetOutputSchema() *jsonschema.Schema { + schema := projectOutputSchema[ProjectsGetOutput]() + schema.Properties["method"].Enum = []any{projectsMethodGetProject, projectsMethodGetProjectField, projectsMethodGetProjectItem, projectsMethodGetProjectStatusUpdate, projectsMethodGetProjectView} + return schema +} + +func projectsWriteOutputSchema() *jsonschema.Schema { + schema := projectOutputSchema[ProjectsWriteOutput]() + schema.Properties["method"].Enum = []any{projectsMethodAddProjectItem, projectsMethodUpdateProjectItem, projectsMethodUpdateProjectItems, projectsMethodDeleteProjectItem, projectsMethodCreateProjectStatusUpdate, projectsMethodCreateProjectView, projectsMethodUpdateProjectView, projectsMethodDeleteProjectView, projectsMethodCreateProject, projectsMethodCreateIterationField} + return schema +} + +func projectOutputSchema[T any]() *jsonschema.Schema { + generated, err := inventory.CachedSchemaFor[T](&jsonschema.ForOptions{ + TypeSchemas: map[reflect.Type]*jsonschema.Schema{ + reflect.TypeFor[github.Timestamp](): {Type: "string", Format: "date-time"}, + }, + }) + if err != nil { + panic(fmt.Sprintf("failed to generate project output schema: %v", err)) + } + schema := forbidOmittedNulls(inventory.CloneSchema(generated)) + var patch func(*jsonschema.Schema) + patch = func(schema *jsonschema.Schema) { + if schema == nil { + return + } + for name, property := range schema.Properties { + switch name { + case "value": + schema.Properties[name] = &jsonschema.Schema{Description: "Recursively compacted JSON field value; unknown field types retain arbitrary object keys."} + case "ref": + schema.Properties[name] = &jsonschema.Schema{Type: "object", Description: "Original batch reference, including malformed or extra fields.", AdditionalProperties: &jsonschema.Schema{}} + case "visible_fields": + property.Type, property.Types = "array", nil + patch(property) + case "created_at", "updated_at", "closed_at", "deleted_at", "archived_at": + property.Description = "RFC3339 timestamp." + case "start_date", "target_date": + property.Description = "Date in YYYY-MM-DD format." + case "duration": + property.Description = "Iteration duration in days." + case "layout": + property.Enum = []any{"board", "table", "roadmap"} + case "status": + if _, batch := schema.Properties["index"]; batch { + property.Enum = []any{"succeeded", "failed", "unknown"} + } else { + property.Enum = []any{"INACTIVE", "ON_TRACK", "AT_RISK", "OFF_TRACK", "COMPLETE"} + } + default: + patch(property) + } + } + patch(schema.Items) + for _, definition := range schema.Defs { + patch(definition) + } + } + patch(schema) + return schema +} + +func decodeProjectsListOutput(method string, raw []byte) (*ProjectsListOutput, error) { + out := &ProjectsListOutput{Method: method} + switch method { + case projectsMethodListProjects: + return out, json.Unmarshal(raw, &out.Projects) + case projectsMethodListProjectFields: + return out, json.Unmarshal(raw, &out.Fields) + case projectsMethodListProjectItems: + return out, json.Unmarshal(raw, &out.Items) + case projectsMethodListProjectStatusUpdates: + return out, json.Unmarshal(raw, &out.StatusUpdates) + case projectsMethodListProjectViews: + return out, json.Unmarshal(raw, &out.Views) + default: + return nil, fmt.Errorf("unexpected Projects list output method %q", method) + } +} + +func decodeProjectsGetOutput(method string, raw []byte) (*ProjectsGetOutput, error) { + out := &ProjectsGetOutput{Method: method} + switch method { + case projectsMethodGetProject: + return out, json.Unmarshal(raw, &out.Project) + case projectsMethodGetProjectField: + return out, json.Unmarshal(raw, &out.Field) + case projectsMethodGetProjectItem: + return out, json.Unmarshal(raw, &out.Item) + case projectsMethodGetProjectStatusUpdate: + return out, json.Unmarshal(raw, &out.StatusUpdate) + case projectsMethodGetProjectView: + return out, json.Unmarshal(raw, &out.View) + default: + return nil, fmt.Errorf("unexpected Projects get output method %q", method) + } +} + +func decodeProjectsWriteOutput(method string, raw []byte) (*ProjectsWriteOutput, error) { + out := &ProjectsWriteOutput{Method: method} + if bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + return out, nil + } + switch method { + case projectsMethodAddProjectItem: + return out, json.Unmarshal(raw, &out.Added) + case projectsMethodUpdateProjectItem: + var discriminator struct { + URL *string `json:"url"` + } + if err := json.Unmarshal(raw, &discriminator); err != nil { + return nil, err + } + if discriminator.URL != nil { + var issue MinimalResponse + if err := json.Unmarshal(raw, &issue); err != nil { + return nil, err + } + out.IssueFields = &ProjectIssueOutput{ID: issue.ID, HTMLURL: issue.URL} + return out, nil + } + return out, json.Unmarshal(raw, &out.Item) + case projectsMethodUpdateProjectItems: + return out, json.Unmarshal(raw, &out.Batch) + case projectsMethodDeleteProjectItem: + out.DeletedItem = &RepositoryMessageOutput{Message: string(raw)} + return out, nil + case projectsMethodCreateProjectStatusUpdate: + return out, json.Unmarshal(raw, &out.StatusUpdate) + case projectsMethodCreateProjectView, projectsMethodUpdateProjectView: + return out, json.Unmarshal(raw, &out.View) + case projectsMethodDeleteProjectView: + return out, json.Unmarshal(raw, &out.DeletedView) + case projectsMethodCreateProject: + var project struct { + ID string `json:"id"` + Number int `json:"number"` + Title string `json:"title"` + URL string `json:"url"` + } + if err := json.Unmarshal(raw, &project); err != nil { + return nil, err + } + out.Project = &ProjectCreatedOutput{ID: project.ID, Number: project.Number, Title: project.Title, HTMLURL: project.URL} + return out, nil + case projectsMethodCreateIterationField: + return out, json.Unmarshal(raw, &out.IterationField) + default: + return nil, fmt.Errorf("unexpected Projects write output method %q", method) + } +} + +func projectsTypedHandler[In, Out any]( + handler func(context.Context, ToolDependencies, *mcp.CallToolRequest, map[string]any) (*mcp.CallToolResult, any, error), + decode func(string, []byte) (Out, error), +) func(context.Context, ToolDependencies, *mcp.CallToolRequest, In) (*mcp.CallToolResult, Out, error) { + return func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, input In) (*mcp.CallToolResult, Out, error) { + var zero Out + args, err := projectArguments(input) + if err != nil { + return nil, zero, fmt.Errorf("decode Projects arguments: %w", err) + } + result, _, err := handler(ctx, deps, req, args) + if err != nil || result == nil || result.IsError { + return result, zero, err + } + if len(result.Content) != 1 { + return nil, zero, fmt.Errorf("expected one Projects text result") + } + text, ok := result.Content[0].(*mcp.TextContent) + if !ok { + return nil, zero, fmt.Errorf("expected Projects text content") + } + method, err := RequiredParam[string](args, "method") + if err != nil { + return nil, zero, err + } + output, err := decode(method, []byte(text.Text)) + if err != nil { + return nil, zero, fmt.Errorf("decode %s output: %w", method, err) + } + return result, output, nil + } +} + +func projectsSchemaOptions() inventory.TypedSchemaOptions { + // Legacy Projects validators inspect only arguments consumed by the selected + // method, after routing and dependency acquisition. Discovery stays strict. + return inventory.TypedSchemaOptions{ + ValidationInputSchema: &jsonschema.Schema{Type: "object"}, + } +} + +func normalizeProjectsRouting(kind string) inventory.InputNormalizer { + return func(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, &inventory.ToolInputError{Message: "invalid arguments: " + err.Error()} + } + method, err := RequiredParam[string](args, "method") + if err != nil { + return nil, &inventory.ToolInputError{Message: err.Error()} + } + if kind == "get" && (method == projectsMethodGetProjectStatusUpdate || method == projectsMethodGetProjectView) { + delete(args, "owner") + } else if _, err := RequiredParam[string](args, "owner"); err != nil { + return nil, &inventory.ToolInputError{Message: err.Error()} + } + return json.Marshal(args) + } +} diff --git a/pkg/github/pullrequests_granular.go b/pkg/github/pullrequests_granular.go index c17faae050..a49789ca3e 100644 --- a/pkg/github/pullrequests_granular.go +++ b/pkg/github/pullrequests_granular.go @@ -164,7 +164,7 @@ func GranularUpdatePullRequestState(t translations.TranslationHelperFunc) invent map[string]*jsonschema.Schema{ "state": { Type: "string", - Description: "The new state for the pull request (open or closed)", + Description: "The new state for the pull request", Enum: GranularPullRequestState("").JSONSchema().Enum, }, }, @@ -363,7 +363,7 @@ func GranularCreatePullRequestReview(t translations.TranslationHelperFunc) inven "repo": {Type: "string", Description: "Repository name"}, "pullNumber": {Type: "number", Description: "The pull request number", Minimum: new(1.0)}, "body": {Type: "string", Description: "The review body text (optional)"}, - "event": {Type: "string", Description: "The review action to perform (APPROVE, REQUEST_CHANGES, or COMMENT). If omitted, creates a pending review.", Enum: GranularPullRequestReviewEvent("").JSONSchema().Enum}, + "event": {Type: "string", Description: "The review action to perform. If omitted, creates a pending review.", Enum: GranularPullRequestReviewEvent("").JSONSchema().Enum}, "commitID": {Type: "string", Description: "The SHA of the commit to review (optional, defaults to latest)"}, }, Required: []string{"owner", "repo", "pullNumber"}, @@ -419,7 +419,7 @@ func GranularSubmitPendingPullRequestReview(t translations.TranslationHelperFunc "owner": {Type: "string", Description: "Repository owner (username or organization)"}, "repo": {Type: "string", Description: "Repository name"}, "pullNumber": {Type: "number", Description: "The pull request number", Minimum: new(1.0)}, - "event": {Type: "string", Description: "The review action to perform (APPROVE, REQUEST_CHANGES, or COMMENT)", Enum: GranularPullRequestReviewEvent("").JSONSchema().Enum}, + "event": {Type: "string", Description: "The review action to perform", Enum: GranularPullRequestReviewEvent("").JSONSchema().Enum}, "body": {Type: "string", Description: "The review body text (optional)"}, }, Required: []string{"owner", "repo", "pullNumber", "event"}, @@ -515,11 +515,11 @@ func GranularAddPullRequestReviewComment(t translations.TranslationHelperFunc) i "pullNumber": {Type: "number", Description: "The pull request number", Minimum: new(1.0)}, "path": {Type: "string", Description: "The relative path of the file to comment on"}, "body": {Type: "string", Description: "The comment body"}, - "subjectType": {Type: "string", Description: "The subject type of the comment (FILE or LINE)", Enum: GranularPullRequestCommentSubject("").JSONSchema().Enum}, + "subjectType": {Type: "string", Description: "The subject type of the comment", Enum: GranularPullRequestCommentSubject("").JSONSchema().Enum}, "line": {Type: "number", Description: "The line number in the diff to comment on (optional)"}, - "side": {Type: "string", Description: "The side of the diff to comment on (LEFT or RIGHT, optional)", Enum: GranularPullRequestDiffSide("").JSONSchema().Enum}, + "side": {Type: "string", Description: "The side of the diff to comment on (optional)", Enum: GranularPullRequestDiffSide("").JSONSchema().Enum}, "startLine": {Type: "number", Description: "The start line of a multi-line comment (optional)"}, - "startSide": {Type: "string", Description: "The start side of a multi-line comment (LEFT or RIGHT, optional)", Enum: GranularPullRequestDiffSide("").JSONSchema().Enum}, + "startSide": {Type: "string", Description: "The start side of a multi-line comment (optional)", Enum: GranularPullRequestDiffSide("").JSONSchema().Enum}, }, Required: []string{"owner", "repo", "pullNumber", "path", "body", "subjectType"}, }, @@ -742,7 +742,7 @@ func GranularAddPullRequestReviewCommentReaction(t translations.TranslationHelpe }, "content": { Type: "string", - Description: "The emoji reaction type (+1, -1, laugh, confused, heart, hooray, rocket, or eyes)", + Description: "The emoji reaction type", Enum: PullRequestCommentReactionType("").JSONSchema().Enum, }, }, diff --git a/pkg/github/rulesets.go b/pkg/github/rulesets.go index 2e856b5dd7..d6beef64a5 100644 --- a/pkg/github/rulesets.go +++ b/pkg/github/rulesets.go @@ -93,6 +93,10 @@ func rulesetWriteScopeAccess() inventory.ScopeAccess { // RepositoryRulesetRead creates a tool for ruleset and rule-suite reads. func RepositoryRulesetRead(t translations.TranslationHelperFunc) inventory.ServerTool { + return typedGovernanceTool(repositoryRulesetReadLegacy(t), rulesetReadOutputSchema(), decodeRulesetRead, normalizeGovernanceLevelRouting) +} + +func repositoryRulesetReadLegacy(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( ToolsetMetadataGovernance, mcp.Tool{ @@ -639,6 +643,15 @@ func ListEnterpriseRepositoryRulesets(ctx context.Context, client *github.Client // at the repository, organization, or enterprise level. The level is // selected with the "level" parameter. func CreateRepositoryRuleset(t translations.TranslationHelperFunc) inventory.ServerTool { + return typedGovernanceTool( + createRepositoryRulesetLegacy(t), + createdRulesetOutputSchema(), + decodeGovernanceJSON[RulesetOutput], + normalizeGovernanceLevelRouting, + ) +} + +func createRepositoryRulesetLegacy(t translations.TranslationHelperFunc) inventory.ServerTool { properties := rulesetWriteProperties() properties["level"] = &jsonschema.Schema{ Type: "string", diff --git a/pkg/github/rulesets_test.go b/pkg/github/rulesets_test.go index 659efc671f..f849687673 100644 --- a/pkg/github/rulesets_test.go +++ b/pkg/github/rulesets_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "io" - "maps" "net/http" "net/url" "testing" @@ -29,7 +28,9 @@ func Test_RepositoryRulesetRead(t *testing.T) { schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema) require.True(t, ok, "InputSchema should be *jsonschema.Schema") - assert.ElementsMatch(t, schema.Required, []string{"level", "method"}) + assert.ElementsMatch(t, []string{"level", "method"}, schema.Required) + assert.Equal(t, "string", schema.Properties["level"].Type) + assert.Equal(t, "string", schema.Properties["method"].Type) t.Run("repository level: get defaults includes_parents to true", func(t *testing.T) { var capturedQuery url.Values @@ -540,9 +541,9 @@ func Test_CreateRepositoryRuleset(t *testing.T) { schema, ok := toolDef.Tool.InputSchema.(*jsonschema.Schema) require.True(t, ok) - assert.ElementsMatch(t, schema.Required, []string{"level", "name", "enforcement", "rules"}) + assert.ElementsMatch(t, []string{"level", "name", "enforcement", "rules"}, schema.Required) require.NotNil(t, schema.AdditionalProperties) - require.NotNil(t, schema.AdditionalProperties.Not) + assert.NotNil(t, schema.AdditionalProperties.Not) propertyNames := make([]string, 0, len(schema.Properties)) for name := range schema.Properties { @@ -562,53 +563,14 @@ func Test_CreateRepositoryRuleset(t *testing.T) { "bypass_actors", }, propertyNames) - resolvedSchema, err := schema.Resolve(nil) - require.NoError(t, err) bypassActorSchema := schema.Properties["bypass_actors"].Items require.NotNil(t, bypassActorSchema) assert.ElementsMatch(t, []string{"actor_type", "bypass_mode"}, bypassActorSchema.Required) assert.ElementsMatch(t, []any{"always", "pull_request", "exempt"}, bypassActorSchema.Properties["bypass_mode"].Enum) - - validArgs := map[string]any{ - "level": "repository", - "owner": "owner", - "repo": "repo", - "org": "org", - "enterprise": "enterprise", - "name": "main protection", - "enforcement": "active", - "target": "branch", - "rules": []any{map[string]any{"type": "creation"}}, - "conditions": map[string]any{"ref_name": map[string]any{"include": []any{"refs/heads/main"}}}, - "bypass_actors": []any{ - map[string]any{"actor_type": "OrganizationAdmin", "bypass_mode": "always"}, - }, - } - require.NoError(t, resolvedSchema.Validate(validArgs)) - - t.Run("bypass actor requires an explicit documented mode", func(t *testing.T) { - args := maps.Clone(validArgs) - args["bypass_actors"] = []any{map[string]any{"actor_type": "OrganizationAdmin"}} - require.Error(t, resolvedSchema.Validate(args)) - - args["bypass_actors"] = []any{map[string]any{"actor_type": "OrganizationAdmin", "bypass_mode": "never"}} - require.Error(t, resolvedSchema.Validate(args)) - }) - - for _, test := range []struct { - field string - typo string - }{ - {field: "target", typo: "targte"}, - {field: "conditions", typo: "conditons"}, - } { - t.Run("rejects misspelled "+test.field, func(t *testing.T) { - args := maps.Clone(validArgs) - args[test.typo] = args[test.field] - delete(args, test.field) - require.Error(t, resolvedSchema.Validate(args)) - }) - } + resolvedActorSchema, err := bypassActorSchema.Resolve(nil) + require.NoError(t, err) + assert.Error(t, resolvedActorSchema.Validate(map[string]any{"actor_type": "OrganizationAdmin"})) + assert.Error(t, resolvedActorSchema.Validate(map[string]any{"actor_type": "OrganizationAdmin", "bypass_mode": "never"})) t.Run("repository level", func(t *testing.T) { var capturedBody github.RepositoryRuleset diff --git a/pkg/github/typed_actions_outputs_test.go b/pkg/github/typed_actions_outputs_test.go new file mode 100644 index 0000000000..dadae042c5 --- /dev/null +++ b/pkg/github/typed_actions_outputs_test.go @@ -0,0 +1,613 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + "net/http/httptest" + "os" + "reflect" + "strings" + "testing" + "time" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func actionsTypedSession(t *testing.T, deps ToolDependencies, protocol string) (*mcp.ClientSession, map[string]*jsonschema.Resolved) { + t.Helper() + tr := translations.NullTranslationHelper + tools := []inventory.ServerTool{ActionsList(tr), ActionsGet(tr), ActionsRunTrigger(tr), ActionsGetJobLogs(tr)} + inv, err := inventory.NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "actions", Version: "v1"}, nil) + inv.RegisterTools(context.Background(), server, deps) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + if protocol == "" || protocol == "unknown" { + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + switch req := req.(type) { + case *mcp.ListToolsRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: protocol} + case *mcp.CallToolRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: protocol} + } + return next(ctx, method, req) + } + }) + } + version := protocol + if version == "" || version == "unknown" { + version = inventory.ProtocolVersionMultiRoundTrip + } + session := connectCommentVisibilityClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 4) + schemas := make(map[string]*jsonschema.Resolved) + for _, tool := range list.Tools { + if os.Getenv("ACTIONS_LEGACY_BASELINE") == "true" { + continue + } + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Nil(t, tool.OutputSchema, tool.Name) + continue + } + require.NotNil(t, tool.OutputSchema) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, tool.OutputSchema)), &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + schemas[tool.Name] = resolved + } + return session, schemas +} + +var typedActionsProtocols = []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", "", "unknown"} + +type actionsWireCase struct { + name, method string + args map[string]any + path, query string + body, text string + status int +} + +func actionsWireCases() []actionsWireCase { + run := `{"id":7,"name":"CI","workflow_id":2,"run_number":3,"run_attempt":1,"status":"completed","conclusion":"failure","head_branch":"main","head_commit":{"message":"change"}}` + return []actionsWireCase{ + {"actions_list", "list_workflows", nil, "/repos/owner/repo/actions/workflows", "page=1&per_page=30", `{"total_count":1,"workflows":[{"id":2,"name":"CI","created_at":"2026-01-01T00:00:00Z"}]}`, `{"total_count":1,"workflows":[{"id":2,"name":"CI","created_at":"2026-01-01T00:00:00Z"}]}`, 200}, + {"actions_list", "list_workflow_runs", map[string]any{"workflow_runs_filter": map[string]any{"actor": "octocat", "branch": false, "event": 1}}, "/repos/owner/repo/actions/runs", "actor=octocat&page=1&per_page=30", `{"total_count":1,"workflow_runs":[` + run + `]}`, `{"total_count":1,"workflow_runs":[` + run + `]}`, 200}, + {"actions_list", "list_workflow_runs", map[string]any{"resource_id": "ci.yml", "page": "2e0", "perPage": "5.0"}, "/repos/owner/repo/actions/workflows/ci.yml/runs", "page=2&per_page=5", `{"total_count":0,"workflow_runs":[]}`, `{"total_count":0,"workflow_runs":[]}`, 200}, + {"actions_list", "list_workflow_runs", map[string]any{"resource_id": "2"}, "/repos/owner/repo/actions/workflows/2/runs", "page=1&per_page=30", `null`, `{"total_count":0,"workflow_runs":[]}`, 200}, + {"actions_list", "list_workflow_jobs", map[string]any{"resource_id": "7", "workflow_jobs_filter": map[string]any{"filter": "all"}}, "/repos/owner/repo/actions/runs/7/jobs", "filter=all&page=1&per_page=30", `{"total_count":1,"jobs":[{"id":8,"run_id":7,"name":"test","status":"completed"}]}`, `{"jobs":{"total_count":1,"jobs":[{"id":8,"run_id":7,"name":"test","status":"completed"}]}}`, 200}, + {"actions_list", "list_workflow_run_artifacts", map[string]any{"resource_id": "7", "workflow_jobs_filter": false}, "/repos/owner/repo/actions/runs/7/artifacts", "page=1&per_page=30", `{"total_count":1,"artifacts":[{"id":9,"digest":"sha256:abc","workflow_run":{"id":7}}]}`, `{"total_count":1,"artifacts":[{"id":9,"digest":"sha256:abc","workflow_run":{"id":7}}]}`, 200}, + {"actions_get", "get_workflow", map[string]any{"resource_id": "ci.yml"}, "/repos/owner/repo/actions/workflows/ci.yml", "", `{"id":2,"name":"CI"}`, `{"id":2,"name":"CI"}`, 200}, + {"actions_get", "get_workflow_run", map[string]any{"resource_id": "7"}, "/repos/owner/repo/actions/runs/7", "", run, run, 200}, + {"actions_get", "get_workflow_job", map[string]any{"resource_id": "8"}, "/repos/owner/repo/actions/jobs/8", "", `{"id":8,"steps":[{"name":"test","number":1,"started_at":"2026-01-01T00:00:00Z"}],"labels":["linux"]}`, `{"id":8,"steps":[{"name":"test","number":1,"started_at":"2026-01-01T00:00:00Z"}],"labels":["linux"]}`, 200}, + {"actions_get", "get_workflow_run_usage", map[string]any{"resource_id": "7"}, "/repos/owner/repo/actions/runs/7/timing", "", `{"billable":{"CUSTOM":{"total_ms":42,"jobs":1,"job_runs":[{"job_id":8,"duration_ms":42}]}},"run_duration_ms":42}`, `{"billable":{"CUSTOM":{"total_ms":42,"jobs":1,"job_runs":[{"job_id":8,"duration_ms":42}]}},"run_duration_ms":42}`, 200}, + {"actions_get", "get_workflow", map[string]any{"resource_id": "-2"}, "/repos/owner/repo/actions/workflows/-2", "", `{}`, `{}`, 200}, + {"actions_get", "get_workflow_job", map[string]any{"resource_id": "8"}, "/repos/owner/repo/actions/jobs/8", "", `null`, `null`, 200}, + {"actions_get", "get_workflow_run_usage", map[string]any{"resource_id": "7"}, "/repos/owner/repo/actions/runs/7/timing", "", `{}`, `{}`, 200}, + {"actions_list", "list_workflows", map[string]any{"page": 0, "perPage": 0, "workflow_runs_filter": nil}, "/repos/owner/repo/actions/workflows", "page=1&per_page=30", `{}`, `{}`, 200}, + {"actions_list", "list_workflow_run_artifacts", map[string]any{"resource_id": "7"}, "/repos/owner/repo/actions/runs/7/artifacts", "page=1&per_page=30", `{}`, `{}`, 200}, + {"actions_list", "list_workflow_runs", map[string]any{"workflow_runs_filter": map[string]any{"event": "custom", "status": "custom"}}, "/repos/owner/repo/actions/runs", "event=custom&page=1&per_page=30&status=custom", `{}`, `{"total_count":0,"workflow_runs":[]}`, 200}, + {"actions_get", "download_workflow_run_artifact", map[string]any{"resource_id": "9"}, "/repos/owner/repo/actions/artifacts/9/zip", "", "", `{"artifact_id":9,"download_url":"https://logs.example/archive","message":"Artifact is available for download","note":"The download_url provides a download link for the artifact as a ZIP archive. The link is temporary and expires after a short time."}`, 302}, + {"actions_get", "get_workflow_run_logs_url", map[string]any{"resource_id": "7"}, "/repos/owner/repo/actions/runs/7/logs", "", "", `{"logs_url":"https://logs.example/archive","message":"Workflow run logs are available for download","note":"The logs_url provides a download link for the complete workflow run logs as a ZIP archive. You can download this archive to extract and examine individual job logs.","optimization_tip":"Use: get_job_logs with parameters {run_id: 7, failed_only: true} for more efficient failed job debugging","warning":"This downloads ALL logs as a ZIP file which can be large and expensive. For debugging failed jobs, consider using get_job_logs with failed_only=true and run_id instead."}`, 302}, + {"actions_run_trigger", "run_workflow", map[string]any{"workflow_id": "ci.yml", "ref": "main"}, "/repos/owner/repo/actions/workflows/ci.yml/dispatches", "", "", `{"inputs":null,"message":"Workflow run has been queued","ref":"main","status":"204 No Content","status_code":204,"workflow_id":"ci.yml","workflow_type":"workflow_file"}`, 204}, + {"actions_run_trigger", "run_workflow", map[string]any{"workflow_id": "2", "ref": "main", "inputs": map[string]any{"flag": true, "nested": []any{nil, 42, map[string]any{"s": "x"}}}}, "/repos/owner/repo/actions/workflows/2/dispatches", "", "", `{"inputs":{"flag":true,"nested":[null,42,{"s":"x"}]},"message":"Workflow run has been queued","ref":"main","status":"204 No Content","status_code":204,"workflow_id":"2","workflow_type":"workflow_id"}`, 204}, + {"actions_run_trigger", "rerun_workflow_run", map[string]any{"run_id": "7.0", "workflow_id": false, "ref": nil}, "/repos/owner/repo/actions/runs/7/rerun", "", "", `{"message":"Workflow run has been queued for re-run","run_id":7,"status":"201 Created","status_code":201}`, 201}, + {"actions_run_trigger", "rerun_failed_jobs", map[string]any{"run_id": "7e0"}, "/repos/owner/repo/actions/runs/7/rerun-failed-jobs", "", "", `{"message":"Failed jobs have been queued for re-run","run_id":7,"status":"201 Created","status_code":201}`, 201}, + {"actions_run_trigger", "cancel_workflow_run", map[string]any{"run_id": 7}, "/repos/owner/repo/actions/runs/7/cancel", "", `{}`, `{"message":"Workflow run has been cancelled","run_id":7,"status":"202 Accepted","status_code":202}`, 202}, + {"actions_run_trigger", "delete_workflow_run_logs", map[string]any{"run_id": 7}, "/repos/owner/repo/actions/runs/7/logs", "", "", `{"message":"Workflow run logs have been deleted","run_id":7,"status":"204 No Content","status_code":204}`, 204}, + } +} + +func actionsCaseArgs(tc actionsWireCase) map[string]any { + args := map[string]any{"owner": "owner", "repo": "repo"} + if tc.method != "" { + args["method"] = tc.method + } + maps.Copy(args, tc.args) + return args +} + +func actionsCaseDeps(t *testing.T, current **actionsWireCase, apiError bool) BaseDeps { + t.Helper() + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Host == "logs.example" { + w.Header().Set("Location", "https://logs.example/archive") + w.WriteHeader(http.StatusFound) + return + } + tc := *current + assert.Equal(t, tc.path, r.URL.Path) + assert.Equal(t, tc.query, r.URL.RawQuery) + if apiError { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"Forbidden"}`)) + return + } + if tc.method == "run_workflow" { + var payload map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + assert.Equal(t, tc.args["ref"], payload["ref"]) + assert.JSONEq(t, mustMarshalJSON(t, tc.args["inputs"]), mustMarshalJSON(t, payload["inputs"])) + } + if tc.status == 302 { + w.Header().Set("Location", "https://logs.example/archive") + } + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })}} + return BaseDeps{Client: mustNewGHClient(t, client), ContentWindowSize: 100, + RepoAccessCache: stubRepoAccessCache(nil, time.Minute)} +} + +func assertActionsWireResult(t *testing.T, result *mcp.CallToolResult, schema *jsonschema.Resolved, text string, isError bool, projected ...string) { + t.Helper() + require.Equal(t, isError, result.IsError, mustMarshalJSON(t, result)) + require.Len(t, result.Content, 1) + if schema == nil || isError { + assert.Equal(t, text, getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + return + } + if text != "null" { + require.NotNil(t, result.StructuredContent) + } + var output any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, schema.Validate(output)) + expected := text + if len(projected) != 0 { + expected = projected[0] + } + assert.JSONEq(t, expected, mustMarshalJSON(t, output)) + assert.Equal(t, mustMarshalJSON(t, output), getTextResult(t, result).Text) +} + +func TestTypedActionsWireOutputs(t *testing.T) { + cases := append(actionsWireCases(), + actionsWireCase{name: "actions_list", method: "list_workflows", path: "/repos/owner/repo/actions/workflows", query: "page=1&per_page=30", body: "null", text: "null", status: 200}, + actionsWireCase{name: "actions_list", method: "list_workflows", path: "/repos/owner/repo/actions/workflows", query: "page=1&per_page=30", body: `{"total_count":0,"workflows":[]}`, text: `{"total_count":0}`, status: 200}, + actionsWireCase{name: "actions_list", method: "list_workflow_run_artifacts", args: map[string]any{"resource_id": "7"}, path: "/repos/owner/repo/actions/runs/7/artifacts", query: "page=1&per_page=30", body: "null", text: "null", status: 200}, + actionsWireCase{name: "actions_list", method: "list_workflow_run_artifacts", args: map[string]any{"resource_id": "7"}, path: "/repos/owner/repo/actions/runs/7/artifacts", query: "page=1&per_page=30", body: `{"total_count":0,"artifacts":[]}`, text: `{"total_count":0}`, status: 200}, + actionsWireCase{name: "actions_get", method: "get_workflow_job", args: map[string]any{"resource_id": "8"}, path: "/repos/owner/repo/actions/jobs/8", body: `{}`, text: `{}`, status: 200}, + actionsWireCase{name: "actions_get", method: "get_workflow", args: map[string]any{"resource_id": "ci.yml"}, path: "/repos/owner/repo/actions/workflows/ci.yml", body: `null`, text: `null`, status: 200}, + actionsWireCase{name: "actions_get", method: "get_workflow_run_usage", args: map[string]any{"resource_id": "7"}, path: "/repos/owner/repo/actions/runs/7/timing", body: `null`, text: `null`, status: 200}, + actionsWireCase{name: "actions_list", method: "list_workflows", args: map[string]any{"page": -1, "perPage": 101}, path: "/repos/owner/repo/actions/workflows", query: "page=-1&per_page=101", body: `{}`, text: `{}`, status: 200}, + actionsWireCase{name: "actions_list", method: "list_workflows", args: map[string]any{"page": 1, "perPage": -1}, path: "/repos/owner/repo/actions/workflows", query: "page=1&per_page=-1", body: `{}`, text: `{}`, status: 200}, + ) + for _, protocol := range typedActionsProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + var current *actionsWireCase + session, schemas := actionsTypedSession(t, actionsCaseDeps(t, ¤t, false), protocol) + for _, tc := range cases { + t.Run(tc.name+"/"+tc.method+"/"+mustMarshalJSON(t, tc.args), func(t *testing.T) { + current = &tc + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: actionsCaseArgs(tc)}) + require.NoError(t, err) + projected := tc.text + switch { + case tc.method == "list_workflow_jobs": + var legacy ActionsJobsOutput + require.NoError(t, json.Unmarshal([]byte(tc.text), &legacy)) + projected = mustMarshalJSON(t, legacy.Jobs) + case tc.method == "get_workflow_job" && tc.body == "{}": + projected = `{"id":0,"run_id":0,"name":"","status":""}` + case tc.method == "get_workflow_job" && tc.body != "null": + projected = `{"id":8,"run_id":0,"name":"","status":"","steps":[{"name":"test","number":1,"status":"","started_at":"2026-01-01T00:00:00Z"}],"labels":["linux"]}` + case tc.method == "get_workflow_run_usage" && tc.body != "{}" && tc.body != "null": + projected = `{"billable":[{"runner":"CUSTOM","total_ms":42,"jobs":1,"job_runs":[{"job_id":8,"duration_ms":42}]}],"run_duration_ms":42}` + } + projected = actionsProjectedEnvelope(t, tc.method, projected) + assertActionsWireResult(t, result, schemas[tc.name], tc.text, false, projected) + }) + } + }) + } +} + +func actionsProjectedEnvelope(t *testing.T, method, projected string) string { + t.Helper() + field := map[string]string{ + "list_workflows": "workflows", "list_workflow_runs": "workflow_runs", + "list_workflow_jobs": "workflow_jobs", "list_workflow_run_artifacts": "artifacts", + "get_workflow": "workflow", "get_workflow_run": "workflow_run", "get_workflow_job": "workflow_job", + "get_workflow_run_usage": "usage", "download_workflow_run_artifact": "artifact", "get_workflow_run_logs_url": "logs", + "run_workflow": "dispatch", "rerun_workflow_run": "rerun", "rerun_failed_jobs": "rerun_failed", + "cancel_workflow_run": "cancel", "delete_workflow_run_logs": "delete_logs", + }[method] + require.NotEmpty(t, field) + envelope := map[string]any{"method": method} + if projected != "null" { + envelope[field] = json.RawMessage(projected) + } + return mustMarshalJSON(t, envelope) +} + +func TestTypedActionsWireErrors(t *testing.T) { + cases := []actionsWireCase{ + {name: "actions_list", args: map[string]any{"owner": "", "method": false}, text: "missing required parameter: owner"}, + {name: "actions_list", method: "list_workflows", args: map[string]any{"resource_id": 7}, text: "parameter resource_id is not of type string, is float64"}, + {name: "actions_list", method: "list_workflows", args: map[string]any{"page": "bad"}, text: "parameter page is not a valid number: invalid numeric value: bad"}, + {name: "actions_list", method: "list_workflow_jobs", text: "missing required parameter for method list_workflow_jobs: resource_id"}, + {name: "actions_list", method: "list_workflow_jobs", args: map[string]any{"resource_id": "bad", "workflow_jobs_filter": false}, text: `invalid resource_id, must be an integer for method list_workflow_jobs: strconv.ParseInt: parsing "bad": invalid syntax`}, + {name: "actions_list", method: "list_workflow_runs", args: map[string]any{"workflow_runs_filter": false}, text: "parameter workflow_runs_filter is not of type map[string]interface {}, is bool"}, + {name: "actions_list", method: "list_workflow_jobs", args: map[string]any{"resource_id": "7", "workflow_jobs_filter": nil}, text: "parameter workflow_jobs_filter is not of type map[string]interface {}, is "}, + {name: "actions_list", method: "list_workflow_jobs", args: map[string]any{"workflow_jobs_filter": false}, text: "missing required parameter for method list_workflow_jobs: resource_id"}, + {name: "actions_list", method: "unknown", args: map[string]any{"resource_id": "7"}, text: "unknown method: unknown"}, + {name: "actions_get", method: "get_workflow", text: "missing required parameter: resource_id"}, + {name: "actions_get", method: "get_workflow_job", args: map[string]any{"resource_id": "7.0"}, text: `invalid resource_id, must be an integer for method get_workflow_job: strconv.ParseInt: parsing "7.0": invalid syntax`}, + {name: "actions_get", method: "unknown", args: map[string]any{"resource_id": "7"}, text: "unknown method: unknown"}, + {name: "actions_run_trigger", method: "run_workflow", args: map[string]any{"workflow_id": false}, text: "workflow_id is required for run_workflow action"}, + {name: "actions_run_trigger", method: "run_workflow", args: map[string]any{"workflow_id": "ci.yml", "ref": false}, text: "ref is required for run_workflow action"}, + {name: "actions_run_trigger", method: "rerun_workflow_run", args: map[string]any{"run_id": "bad"}, text: "missing required parameter: run_id"}, + {name: "actions_run_trigger", method: "run_workflow", args: map[string]any{"inputs": nil}, text: "parameter inputs is not of type map[string]interface {}, is "}, + {name: "get_job_logs", args: map[string]any{"job_id": 1.5}, text: "parameter job_id is not a valid number: non-integer numeric value: 1.5"}, + {name: "get_job_logs", args: map[string]any{"job_id": "8", "failed_only": nil}, text: "parameter failed_only is not of type bool, is "}, + {name: "get_job_logs", args: map[string]any{"failed_only": true}, text: "run_id is required when failed_only is true"}, + {name: "get_job_logs", args: map[string]any{"job_id": -1}, text: "Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"}, + } + + for _, protocol := range typedActionsProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + var current *actionsWireCase + session, schemas := actionsTypedSession(t, actionsCaseDeps(t, ¤t, false), protocol) + for _, tc := range cases { + t.Run(tc.name+"/"+tc.text, func(t *testing.T) { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: actionsCaseArgs(tc)}) + require.NoError(t, err) + assertActionsWireResult(t, result, schemas[tc.name], tc.text, true) + }) + } + session, _ = actionsTypedSession(t, actionsCaseDeps(t, ¤t, true), protocol) + for _, tc := range actionsWireCases() { + current = &tc + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: actionsCaseArgs(tc)}) + require.NoError(t, err) + require.True(t, result.IsError) + require.Len(t, result.Content, 1) + assert.Contains(t, getTextResult(t, result).Text, "Forbidden") + assert.Nil(t, result.StructuredContent) + } + }) + } +} + +func TestTypedActionsClientErrorPrecedence(t *testing.T) { + for _, protocol := range typedActionsProtocols { + session, _ := actionsTypedSession(t, failingSearchClientDeps{}, protocol) + for _, tc := range []actionsWireCase{ + {name: "actions_list", method: "list_workflow_jobs", args: map[string]any{"resource_id": "bad", "workflow_jobs_filter": false}}, + {name: "actions_list", method: "list_workflow_runs", args: map[string]any{"workflow_runs_filter": nil}}, + {name: "actions_get", method: "unknown", args: map[string]any{"resource_id": "bad"}}, + {name: "get_job_logs", args: map[string]any{"failed_only": true}}, + {name: "actions_run_trigger", method: "unknown", args: map[string]any{"run_id": 7}}, + } { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: actionsCaseArgs(tc)}) + if os.Getenv("ACTIONS_LEGACY_BASELINE") == "true" { + require.EqualError(t, err, `calling "tools/call": failed to get GitHub client: client unavailable`) + continue + } + require.NoError(t, err) + assertActionsWireResult(t, result, nil, "failed to get GitHub client: client unavailable", true) + } + } +} + +func TestTypedActionsIFCAndAnnotations(t *testing.T) { + for _, protocol := range typedActionsProtocols { + for _, private := range []bool{false, true} { + for _, enabled := range []bool{false, true} { + for _, tc := range []actionsWireCase{ + {name: "actions_list", method: "list_workflows"}, + {name: "actions_get", method: "get_workflow", args: map[string]any{"resource_id": "ci.yml"}}, + {name: "get_job_logs", args: map[string]any{"run_id": "7e0", "failed_only": true}}, + {name: "actions_run_trigger", method: "delete_workflow_run_logs", args: map[string]any{"run_id": 7}}, + } { + t.Run(fmt.Sprintf("%s/%s/private=%t/enabled=%t", tc.name, protocol, private, enabled), func(t *testing.T) { + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/repos/owner/repo" { + _, _ = fmt.Fprintf(w, `{"private":%t}`, private) + return + } + if r.Method == http.MethodDelete { + w.WriteHeader(http.StatusNoContent) + return + } + _, _ = w.Write([]byte(`{}`)) + })}} + deps := BaseDeps{Client: mustNewGHClient(t, client), + RepoAccessCache: stubRepoAccessCache(nil, time.Minute)} + if enabled { + deps.featureChecker = featureCheckerFor(FeatureFlagIFCLabels) + } + session, schemas := actionsTypedSession(t, deps, protocol) + listed, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + for _, tool := range listed.Tools { + require.NotNil(t, tool.Annotations) + assert.Equal(t, tool.Name != "actions_run_trigger", tool.Annotations.ReadOnlyHint) + if tool.Name == "actions_run_trigger" { + require.NotNil(t, tool.Annotations.DestructiveHint) + assert.True(t, *tool.Annotations.DestructiveHint) + } + } + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: actionsCaseArgs(tc)}) + require.NoError(t, err) + text := getTextResult(t, result).Text + projected := text + if tc.method != "" { + legacy := "{}" + if tc.method == "delete_workflow_run_logs" { + legacy = `{"message":"Workflow run logs have been deleted","run_id":7,"status":"204 No Content","status_code":204}` + } + projected = actionsProjectedEnvelope(t, tc.method, legacy) + text = legacy + } + assertActionsWireResult(t, result, schemas[tc.name], text, false, projected) + if !enabled || tc.name == "actions_run_trigger" { + assert.Nil(t, result.Meta["ifc"]) + return + } + label := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "untrusted", label["integrity"]) + confidentiality := "public" + if private { + confidentiality = "private" + } + assert.Equal(t, confidentiality, label["confidentiality"]) + }) + } + } + } + } +} + +func TestTypedActionsMinimalProjection(t *testing.T) { + tests := []struct { + actionsWireCase + raw any + projected string + }{ + { + actionsWireCase: actionsWireCase{name: "actions_get", method: "get_workflow", args: map[string]any{"resource_id": "ci.yml"}, path: "/repos/owner/repo/actions/workflows/ci.yml", status: 200}, + raw: &github.Workflow{ + ID: new(int64(2)), Name: new("CI"), State: new("active"), Path: new(".github/workflows/ci.yml"), + NodeID: new("node"), URL: new("https://api.github.com/workflows/2"), + HTMLURL: new("https://github.com/owner/repo/actions/workflows/ci.yml"), BadgeURL: new("https://badge"), + }, + projected: `{"id":2,"name":"CI","state":"active","path":".github/workflows/ci.yml","html_url":"https://github.com/owner/repo/actions/workflows/ci.yml"}`, + }, + { + actionsWireCase: actionsWireCase{name: "actions_list", method: "list_workflows", path: "/repos/owner/repo/actions/workflows", query: "page=1&per_page=30", status: 200}, + raw: &github.Workflows{TotalCount: new(1), Workflows: []*github.Workflow{{ + ID: new(int64(2)), Name: new("CI"), URL: new("https://api.github.com/workflows/2"), BadgeURL: new("https://badge"), + }}}, + projected: `{"total_count":1,"workflows":[{"id":2,"name":"CI"}]}`, + }, + { + actionsWireCase: actionsWireCase{name: "actions_list", method: "list_workflow_run_artifacts", args: map[string]any{"resource_id": "7"}, path: "/repos/owner/repo/actions/runs/7/artifacts", query: "page=1&per_page=30", status: 200}, + raw: &github.ArtifactList{TotalCount: new(int64(1)), Artifacts: []*github.Artifact{{ + ID: new(int64(9)), Name: new("build"), SizeInBytes: new(int64(0)), Expired: new(false), + Digest: new("sha256:abc"), NodeID: new("node"), URL: new("https://api.github.com/artifacts/9"), + ArchiveDownloadURL: new("https://api.github.com/artifacts/9/zip"), + WorkflowRun: &github.ArtifactWorkflowRun{ID: new(int64(7)), HeadBranch: new("main"), HeadSHA: new("abc")}, + }}}, + projected: `{"total_count":1,"artifacts":[{"id":9,"name":"build","size_in_bytes":0,"expired":false,"digest":"sha256:abc","workflow_run":{"id":7,"head_branch":"main","head_sha":"abc"}}]}`, + }, + { + actionsWireCase: actionsWireCase{name: "actions_get", method: "get_workflow_job", args: map[string]any{"resource_id": "8"}, path: "/repos/owner/repo/actions/jobs/8", status: 200}, + raw: &github.WorkflowJob{ + ID: new(int64(8)), RunID: new(int64(7)), Name: new("test"), Status: new("completed"), Conclusion: new("failure"), + NodeID: new("node"), URL: new("https://api.github.com/jobs/8"), RunURL: new("https://api.github.com/runs/7"), + HTMLURL: new("https://github.com/owner/repo/actions/runs/7/job/8"), + }, + projected: `{"id":8,"run_id":7,"name":"test","status":"completed","conclusion":"failure","html_url":"https://github.com/owner/repo/actions/runs/7/job/8"}`, + }, + { + actionsWireCase: actionsWireCase{name: "actions_get", method: "get_workflow_run_usage", args: map[string]any{"resource_id": "7"}, path: "/repos/owner/repo/actions/runs/7/timing", status: 200}, + raw: &github.WorkflowRunUsage{RunDurationMS: new(int64(42)), Billable: &github.WorkflowRunBillMap{ + "CUSTOM": {TotalMS: new(int64(42)), Jobs: new(1), JobRuns: []*github.WorkflowRunJobRun{{JobID: new(8), DurationMS: new(int64(42))}}}, + "ANOTHER": nil, + }}, + projected: `{"billable":[{"runner":"ANOTHER"},{"runner":"CUSTOM","total_ms":42,"jobs":1,"job_runs":[{"job_id":8,"duration_ms":42}]}],"run_duration_ms":42}`, + }, + } + for _, protocol := range typedActionsProtocols { + for _, tc := range tests { + t.Run(protocol+"/"+tc.method, func(t *testing.T) { + current := tc.actionsWireCase + current.body = mustMarshalJSON(t, tc.raw) + current.text = current.body + currentCase := ¤t + session, schemas := actionsTypedSession(t, actionsCaseDeps(t, ¤tCase, false), protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: current.name, Arguments: actionsCaseArgs(current)}) + require.NoError(t, err) + assertActionsWireResult(t, result, schemas[current.name], current.text, false, actionsProjectedEnvelope(t, current.method, tc.projected)) + }) + } + } +} + +func TestActionsConcreteSchemas(t *testing.T) { + for name, schema := range map[string]*jsonschema.Schema{ + "list": actionsListOutputSchema(), "get": actionsGetOutputSchema(), + "trigger": actionsRunTriggerOutputSchema(), "logs": actionsJobLogsOutputSchema(), + } { + t.Run(name, func(t *testing.T) { + if name != "logs" { + assert.Equal(t, "object", schema.Type) + assert.Empty(t, schema.AnyOf) + assert.Empty(t, schema.OneOf) + require.NotEmpty(t, schema.Properties["method"].Enum) + } + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + if name != "logs" { + require.Error(t, resolved.Validate(nil)) + } + for _, raw := range []string{`[]`, `42`, `{"unknown":true}`, `{"id":"wrong"}`, `{"jobs":"wrong"}`, `{"logs_url":false}`, `{"method":"unknown"}`} { + var invalid any + require.NoError(t, json.Unmarshal([]byte(raw), &invalid)) + require.Error(t, resolved.Validate(invalid), raw) + } + }) + } + seen := make(map[reflect.Type]bool) + var check func(reflect.Type) + check = func(typ reflect.Type) { + if seen[typ] || typ == reflect.TypeFor[json.RawMessage]() { + return + } + seen[typ] = true + switch typ.Kind() { + case reflect.Interface: + t.Errorf("untyped Actions output: %s", typ) + case reflect.Map: + t.Errorf("map Actions output: %s", typ) + case reflect.Array, reflect.Slice, reflect.Pointer: + check(typ.Elem()) + case reflect.Struct: + assert.NotEqual(t, "github.com/google/go-github/v92/github", typ.PkgPath(), "raw GitHub output: %s", typ) + for field := range typ.Fields() { + if field.IsExported() { + check(field.Type) + } + } + } + } + check(reflect.TypeFor[ActionsListOutput]()) + check(reflect.TypeFor[ActionsGetOutput]()) + check(reflect.TypeFor[ActionsRunTriggerOutput]()) + check(reflect.TypeFor[ActionsJobLogsOutput]()) +} + +func TestActionsOutputSchemasCached(t *testing.T) { + for _, schema := range []func() *jsonschema.Schema{ + actionsListOutputSchema, actionsGetOutputSchema, actionsRunTriggerOutputSchema, actionsJobLogsOutputSchema, + } { + assert.Same(t, schema(), schema()) + } +} + +func TestActionsAdvertisedInputMetadata(t *testing.T) { + tr := translations.NullTranslationHelper + list := ActionsList(tr).Tool.InputSchema.(*jsonschema.Schema) + get := ActionsGet(tr).Tool.InputSchema.(*jsonschema.Schema) + trigger := ActionsRunTrigger(tr).Tool.InputSchema.(*jsonschema.Schema) + assert.Equal(t, []any{"list_workflows", "list_workflow_runs", "list_workflow_jobs", "list_workflow_run_artifacts"}, list.Properties["method"].Enum) + assert.Equal(t, []any{"get_workflow", "get_workflow_run", "get_workflow_job", "download_workflow_run_artifact", "get_workflow_run_usage", "get_workflow_run_logs_url"}, get.Properties["method"].Enum) + assert.Equal(t, []any{"run_workflow", "rerun_workflow_run", "rerun_failed_jobs", "cancel_workflow_run", "delete_workflow_run_logs"}, trigger.Properties["method"].Enum) + runs := list.Properties["workflow_runs_filter"] + assert.Equal(t, "object", runs.Type) + assert.Empty(t, runs.AnyOf) + assert.Len(t, runs.Properties["event"].Enum, 32) + assert.Equal(t, []any{"queued", "in_progress", "completed", "requested", "waiting"}, runs.Properties["status"].Enum) + assert.Equal(t, []any{"latest", "all"}, list.Properties["workflow_jobs_filter"].Properties["filter"].Enum) + require.NotNil(t, list.Properties["page"].Minimum) + require.NotNil(t, list.Properties["perPage"].Minimum) + require.NotNil(t, list.Properties["perPage"].Maximum) + assert.Equal(t, 1.0, *list.Properties["page"].Minimum) + assert.Equal(t, 1.0, *list.Properties["perPage"].Minimum) + assert.Equal(t, 100.0, *list.Properties["perPage"].Maximum) + assert.Equal(t, "The action to perform", list.Properties["method"].Description) +} + +func TestActionsOutputEnumValues(t *testing.T) { + get := actionsGetOutputSchema() + assert.Equal(t, []any{"active", "deleted", "disabled_fork", "disabled_inactivity", "disabled_manually"}, get.Properties["workflow"].Properties["state"].Enum) + assert.Equal(t, inventory.EnumSchema(append(WorkflowStatusValues(), "")...).Enum, get.Properties["workflow_job"].Properties["status"].Enum) + assert.Equal(t, inventory.EnumSchema(WorkflowConclusionValues()...).Enum, get.Properties["workflow_run"].Properties["conclusion"].Enum) + for _, name := range []string{"workflow", "workflow_run", "workflow_job"} { + schema := get.Properties[name] + assert.Equal(t, "date-time", schema.Properties["created_at"].Format) + assert.Equal(t, "Timestamp in RFC3339 format.", schema.Properties["created_at"].Description) + } + usage := get.Properties["usage"] + assert.Equal(t, "Duration in milliseconds.", usage.Properties["run_duration_ms"].Description) + assert.Equal(t, "Duration in milliseconds.", usage.Properties["billable"].Items.Properties["total_ms"].Description) +} + +func TestTypedActionsJobLogWireOutputs(t *testing.T) { + logServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte("first\nsecond\nthird")) + })) + defer logServer.Close() + const note = "The logs_url provides a download link for the individual job logs in plain text format. Use return_content=true to get the actual log content." + for _, protocol := range typedActionsProtocols { + for _, outcome := range []string{"single", "failed", "partial", "none"} { + for _, content := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/%s/content=%t", protocol, outcome, content), func(t *testing.T) { + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/jobs"): + body := `{"total_count":2,"jobs":[{"id":8,"name":"test","conclusion":"failure"},{"id":9,"name":"build","conclusion":"failure"}]}` + if outcome == "none" { + body = `{"total_count":1,"jobs":[{"id":8,"conclusion":"success"}]}` + } + _, _ = w.Write([]byte(body)) + case outcome == "partial" && strings.HasSuffix(r.URL.Path, "/9/logs"): + w.WriteHeader(403) + _, _ = w.Write([]byte(`{"message":"Forbidden"}`)) + default: + w.Header().Set("Location", logServer.URL) + w.WriteHeader(302) + } + })}} + deps := BaseDeps{Client: mustNewGHClient(t, client), ContentWindowSize: 100, + RepoAccessCache: stubRepoAccessCache(nil, time.Minute)} + session, schemas := actionsTypedSession(t, deps, protocol) + args := map[string]any{"owner": "owner", "repo": "repo", "return_content": content, "tail_lines": "2.0"} + if outcome == "single" { + args["job_id"] = "8e0" + } else { + args["run_id"], args["failed_only"] = "7", true + } + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "get_job_logs", Arguments: args}) + require.NoError(t, err) + text := getTextResult(t, result).Text + assertActionsWireResult(t, result, schemas["get_job_logs"], text, false) + var payload map[string]any + require.NoError(t, json.Unmarshal([]byte(text), &payload)) + switch { + case outcome == "none": + assertActionsLogText(t, schemas["get_job_logs"], `{"failed_jobs":0,"message":"No failed jobs found in this workflow run","run_id":7,"total_jobs":1}`, text) + case outcome == "single" && content: + assertActionsLogText(t, schemas["get_job_logs"], `{"job_id":8,"logs_content":"second\nthird","message":"Job logs content retrieved successfully","original_length":3}`, text) + case outcome == "single": + assertActionsLogText(t, schemas["get_job_logs"], fmt.Sprintf(`{"job_id":8,"logs_url":%q,"message":"Job logs are available for download","note":%q}`, logServer.URL, note), text) + default: + logs := payload["logs"].([]any) + require.Len(t, logs, 2) + if outcome == "partial" { + assert.Contains(t, logs[1].(map[string]any)["error"], "Forbidden") + } + + require.Equal(t, float64(2), payload["failed_jobs"]) + require.Equal(t, "test", logs[0].(map[string]any)["job_name"]) + } + }) + } + } + } +} + +func assertActionsLogText(t *testing.T, schema *jsonschema.Resolved, expected, actual string) { + t.Helper() + if schema == nil { + require.Equal(t, expected, actual) + } else { + require.JSONEq(t, expected, actual) + } +} diff --git a/pkg/github/typed_copilot_ui_outputs.go b/pkg/github/typed_copilot_ui_outputs.go new file mode 100644 index 0000000000..d9dd03f9d3 --- /dev/null +++ b/pkg/github/typed_copilot_ui_outputs.go @@ -0,0 +1,390 @@ +package github + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/go-viper/mapstructure/v2" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type CopilotPullRequestOutput struct { + Number int `json:"number"` + State string `json:"state"` + Title string `json:"title"` +} + +type AssignCopilotToIssueOutput struct { + IssueNumber int `json:"issue_number"` + Message string `json:"message"` + Note string `json:"note,omitempty"` + Owner string `json:"owner"` + PullRequest *CopilotPullRequestOutput `json:"pull_request,omitempty"` + Repo string `json:"repo"` +} + +type AssignCopilotToIssueWithIntentOutput struct { + IsSuggestion bool `json:"is_suggestion"` + IssueNumber int `json:"issue_number"` + Message string `json:"message"` + Note string `json:"note,omitempty"` + Owner string `json:"owner"` + PullRequest *CopilotPullRequestOutput `json:"pull_request,omitempty"` + Repo string `json:"repo"` +} + +func assignCopilotToIssueOutputSchema() *jsonschema.Schema { + schema := forbidOmittedNulls(repositoryOutputSchema[AssignCopilotToIssueOutput]()) + schema.Properties["issue_number"].Description = "The assigned issue's number." + schema.Properties["message"].Description = "Assignment outcome or tool error message." + schema.Properties["note"].Description = "Additional status detail when Copilot has not created a pull request yet." + schema.Properties["pull_request"].Properties["state"].Enum = []any{"OPEN", "CLOSED", "MERGED"} + return schema +} + +func assignCopilotToIssueWithIntentOutputSchema() *jsonschema.Schema { + schema := forbidOmittedNulls(repositoryOutputSchema[AssignCopilotToIssueWithIntentOutput]()) + schema.Properties["issue_number"].Description = "The assigned issue's number." + schema.Properties["message"].Description = "Assignment outcome or tool error message." + schema.Properties["note"].Description = "Additional status detail when Copilot has not created a pull request yet." + schema.Properties["pull_request"].Properties["state"].Enum = []any{"OPEN", "CLOSED", "MERGED"} + return schema +} + +type CopilotReviewOutput struct { + Status string `json:"status"` +} + +func copilotReviewOutputSchema() *jsonschema.Schema { + schema := forbidOmittedNulls(repositoryOutputSchema[CopilotReviewOutput]()) + schema.Properties["status"].Description = "Outcome of the Copilot review request; requested on success." + return schema +} + +func normalizeAssignCopilotToIssueArguments(raw json.RawMessage) (json.RawMessage, error) { + args, err := rawArgumentMap(raw) + if err != nil || args == nil { + return raw, err + } + var input struct { + Owner string `mapstructure:"owner"` + Repo string `mapstructure:"repo"` + IssueNumber int32 `mapstructure:"issue_number"` + BaseRef string `mapstructure:"base_ref"` + CustomInstructions string `mapstructure:"custom_instructions"` + } + if err := mapstructure.WeakDecode(args, &input); err != nil { + return nil, &inventory.ToolInputError{Message: err.Error()} + } + return json.Marshal(map[string]any{ + "owner": input.Owner, + "repo": input.Repo, + "issue_number": input.IssueNumber, + "base_ref": input.BaseRef, + "custom_instructions": input.CustomInstructions, + }) +} + +func normalizeAssignCopilotToIssueWithIntentArguments(raw json.RawMessage) (json.RawMessage, error) { + args, err := rawArgumentMap(raw) + if err != nil || args == nil { + return raw, err + } + if _, ok := args["is_suggestion"]; !ok { + return nil, &inventory.ToolInputError{Message: "is_suggestion is required"} + } + var input struct { + Owner string `mapstructure:"owner"` + Repo string `mapstructure:"repo"` + IssueNumber int32 `mapstructure:"issue_number"` + BaseRef string `mapstructure:"base_ref"` + CustomInstructions string `mapstructure:"custom_instructions"` + Rationale string `mapstructure:"rationale"` + Confidence string `mapstructure:"confidence"` + IsSuggestion bool `mapstructure:"is_suggestion"` + } + if err := mapstructure.WeakDecode(args, &input); err != nil { + return nil, &inventory.ToolInputError{Message: err.Error()} + } + rationale := strings.TrimSpace(input.Rationale) + if rationale == "" || len([]rune(rationale)) > 280 { + rationale = "compatibility" + } + confidence := normalizeConfidence(input.Confidence) + switch confidence { + case "LOW", "MEDIUM", "HIGH": + default: + confidence = "LOW" + } + return json.Marshal(map[string]any{ + "owner": input.Owner, + "repo": input.Repo, + "issue_number": input.IssueNumber, + "base_ref": input.BaseRef, + "custom_instructions": input.CustomInstructions, + "rationale": rationale, + "_compat_rationale": input.Rationale, + "confidence": confidence, + "_compat_confidence": input.Confidence, + "is_suggestion": input.IsSuggestion, + }) +} + +func normalizeRequestCopilotReviewArguments(raw json.RawMessage) (json.RawMessage, error) { + args, err := rawArgumentMap(raw) + if err != nil || args == nil { + return raw, err + } + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return nil, &inventory.ToolInputError{Message: err.Error()} + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return nil, &inventory.ToolInputError{Message: err.Error()} + } + pullNumber, err := RequiredInt(args, "pullNumber") + if err != nil { + return nil, &inventory.ToolInputError{Message: err.Error()} + } + return json.Marshal(map[string]any{"owner": owner, "repo": repo, "pullNumber": pullNumber}) +} + +func normalizeUIGetArguments(raw json.RawMessage) (json.RawMessage, error) { + args, err := rawArgumentMap(raw) + if err != nil || args == nil { + return raw, err + } + delete(args, "_compat_method") + method, err := RequiredParam[string](args, "method") + if err != nil { + return nil, &inventory.ToolInputError{Message: err.Error()} + } + if _, err := RequiredParam[string](args, "owner"); err != nil { + return nil, &inventory.ToolInputError{Message: err.Error()} + } + switch method { + case "labels", "assignees", "milestones", "branches", "issue_fields", "reviewers": + if _, err := RequiredParam[string](args, "repo"); err != nil { + if _, exists := args["repo"]; exists { + return nil, &inventory.ToolInputError{Message: err.Error()} + } + } + case "issue_types": + if _, exists := args["repo"]; exists { + if _, err := RequiredParam[string](args, "repo"); err != nil { + args["repo"] = "" + } + } + default: + return nil, &inventory.ToolInputError{Message: fmt.Sprintf("unknown method: %s", method)} + } + return json.Marshal(args) +} + +func rawArgumentMap(raw json.RawMessage) (map[string]any, error) { + var args map[string]any + if err := json.Unmarshal(raw, &args); err != nil { + return nil, err + } + return args, nil +} + +type UIGetLabelOutput struct { + ID string `json:"id"` + Name string `json:"name"` + Color string `json:"color"` + Description string `json:"description"` +} + +type UIGetAssigneeOutput struct { + Login string `json:"login"` + AvatarURL string `json:"avatar_url"` +} + +type UIGetMilestoneOutput struct { + Number int `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + State string `json:"state"` + OpenIssues int `json:"open_issues"` + DueOn string `json:"due_on"` +} + +type UIGetBranchesOutput struct { + Branches []MinimalBranch `json:"branches"` + TotalCount int `json:"totalCount"` + HasMore bool `json:"has_more"` +} + +type UIGetIssueFieldOptionOutput struct { + Name string `json:"name"` + Description string `json:"description"` + Color string `json:"color"` +} + +type UIGetIssueFieldOutput struct { + ID string `json:"id"` + Name string `json:"name"` + DataType string `json:"data_type"` + Description string `json:"description"` + Options *[]UIGetIssueFieldOptionOutput `json:"options,omitempty"` +} + +type UIGetReviewersOutput struct { + Users []UIGetAssigneeOutput `json:"users"` + Teams []UIGetReviewerTeam `json:"teams"` + TotalCount int `json:"totalCount"` + HasMore bool `json:"has_more"` +} + +type UIGetReviewerTeam struct { + Slug string `json:"slug"` + Name string `json:"name"` + Org string `json:"org"` +} + +type UIGetLabelsOutput struct { + Labels []UIGetLabelOutput `json:"labels"` + TotalCount int `json:"totalCount"` + HasMore bool `json:"has_more"` +} + +type UIGetAssigneesOutput struct { + Assignees []UIGetAssigneeOutput `json:"assignees"` + TotalCount int `json:"totalCount"` + HasMore bool `json:"has_more"` +} + +type UIGetMilestonesOutput struct { + Milestones []UIGetMilestoneOutput `json:"milestones"` + TotalCount int `json:"totalCount"` + HasMore bool `json:"has_more"` +} + +type UIGetIssueFieldsOutput struct { + Fields []UIGetIssueFieldOutput `json:"fields"` + TotalCount int `json:"totalCount"` +} + +type UIGetOutput struct { + Method string `json:"method"` + Labels *UIGetLabelsOutput `json:"labels,omitempty"` + Assignees *UIGetAssigneesOutput `json:"assignees,omitempty"` + Milestones *UIGetMilestonesOutput `json:"milestones,omitempty"` + IssueTypes *[]*IssueTypeOutput `json:"issue_types,omitempty"` + Branches *UIGetBranchesOutput `json:"branches,omitempty"` + IssueFields *UIGetIssueFieldsOutput `json:"issue_fields,omitempty"` + Reviewers *UIGetReviewersOutput `json:"reviewers,omitempty"` +} + +func decodeUIGetOutput(method string, content []byte) (*UIGetOutput, error) { + output := &UIGetOutput{Method: method} + switch method { + case "labels": + output.Labels = new(UIGetLabelsOutput) + err := json.Unmarshal(content, output.Labels) + return output, err + case "assignees": + output.Assignees = new(UIGetAssigneesOutput) + err := json.Unmarshal(content, output.Assignees) + return output, err + case "milestones": + output.Milestones = new(UIGetMilestonesOutput) + err := json.Unmarshal(content, output.Milestones) + return output, err + case "issue_types": + var issueTypes []*IssueTypeOutput + err := json.Unmarshal(content, &issueTypes) + output.IssueTypes = &issueTypes + return output, err + case "branches": + output.Branches = new(UIGetBranchesOutput) + err := json.Unmarshal(content, output.Branches) + return output, err + case "issue_fields": + output.IssueFields = new(UIGetIssueFieldsOutput) + err := json.Unmarshal(content, output.IssueFields) + return output, err + case "reviewers": + output.Reviewers = new(UIGetReviewersOutput) + err := json.Unmarshal(content, output.Reviewers) + return output, err + default: + return nil, fmt.Errorf("unknown ui_get output method: %s", method) + } +} + +func uiGetOutputSchema() *jsonschema.Schema { + schema := forbidOmittedNulls(repositoryOutputSchema[UIGetOutput]()) + schema.Properties["method"].Enum = []any{"labels", "assignees", "milestones", "issue_types", "branches", "issue_fields", "reviewers"} + schema.Properties["method"].Description = "The method that produced the corresponding typed output." + schema.Required = []string{"method"} + schema.Properties["issue_types"].Type = "" + schema.Properties["issue_types"].Types = []string{"array", "null"} + schema.Properties["issue_types"].Items = repositoryOutputSchema[[]*IssueTypeOutput]().Items + schema.Properties["labels"].Properties["totalCount"].Description = "Total number of matching labels." + schema.Properties["labels"].Properties["has_more"].Description = "True when results were truncated at the pagination limit." + schema.Properties["assignees"].Properties["totalCount"].Description = "Total number of matching assignees." + schema.Properties["assignees"].Properties["has_more"].Description = "True when results were truncated at the pagination limit." + schema.Properties["milestones"].Properties["totalCount"].Description = "Total number of matching milestones." + schema.Properties["milestones"].Properties["has_more"].Description = "True when results were truncated at the pagination limit." + schema.Properties["reviewers"].Properties["totalCount"].Description = "Total number of matching users and teams." + typedArrays := map[string][]string{ + "labels": {"labels"}, + "assignees": {"assignees"}, + "milestones": {"milestones"}, + "branches": {"branches"}, + "issue_fields": {"fields"}, + "reviewers": {"users", "teams"}, + } + for property, arrays := range typedArrays { + nested := schema.Properties[property] + for _, name := range arrays { + array := nested.Properties[name] + array.Type, array.Types = "array", nil + } + } + schema.Properties["milestones"].Properties["milestones"].Items.Properties["number"].Description = "Milestone number." + schema.Properties["milestones"].Properties["milestones"].Items.Properties["open_issues"].Description = "Number of open issues assigned to the milestone." + schema.Properties["milestones"].Properties["milestones"].Items.Properties["due_on"].Description = "Due date in YYYY-MM-DD form, or empty when no due date is set." + schema.Properties["milestones"].Properties["milestones"].Items.Properties["state"].Enum = []any{"open", "closed"} + schema.Properties["issue_fields"].Properties["fields"].Items.Properties["data_type"].Enum = []any{"text", "number", "date", "single_select"} + schema.Properties["issue_fields"].Properties["totalCount"].Description = "Number of supported issue fields returned." + schema.Properties["branches"].Properties["totalCount"].Description = "Number of branches returned." + schema.Properties["branches"].Properties["has_more"].Description = "True when results were truncated at the pagination limit." + return schema +} + +func uiGetTypedResult(method string, result *mcp.CallToolResult) (*mcp.CallToolResult, *UIGetOutput, error) { + if result == nil { + return nil, nil, fmt.Errorf("ui_get %s returned no result", method) + } + if result.IsError { + return result, &UIGetOutput{Method: uiGetSchemaMethod(method)}, nil + } + if len(result.Content) != 1 { + return nil, nil, fmt.Errorf("ui_get %s returned %d content blocks; expected one", method, len(result.Content)) + } + text, ok := result.Content[0].(*mcp.TextContent) + if !ok { + return nil, nil, fmt.Errorf("ui_get %s returned non-text content", method) + } + output, err := decodeUIGetOutput(method, []byte(text.Text)) + if err != nil { + return nil, nil, fmt.Errorf("failed to decode ui_get %s output: %w", method, err) + } + return result, output, nil +} + +func uiGetSchemaMethod(method string) string { + switch method { + case "labels", "assignees", "milestones", "issue_types", "branches", "issue_fields", "reviewers": + return method + default: + return "labels" + } +} diff --git a/pkg/github/typed_copilot_ui_outputs_test.go b/pkg/github/typed_copilot_ui_outputs_test.go new file mode 100644 index 0000000000..8dbfce7e22 --- /dev/null +++ b/pkg/github/typed_copilot_ui_outputs_test.go @@ -0,0 +1,543 @@ +package github + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + ghcontext "github.com/github/github-mcp-server/pkg/context" + "github.com/github/github-mcp-server/pkg/http/middleware" + "github.com/github/github-mcp-server/pkg/http/oauth" + "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" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type typedOutputRoundTripper func(*http.Request) (*http.Response, error) + +func (roundTripper typedOutputRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + return roundTripper(request) +} + +func typedCopilotUIClient(t *testing.T, fixture ...string) ToolDependencies { + t.Helper() + fixtureMode := "" + if len(fixture) > 0 { + fixtureMode = fixture[0] + } + httpClient := &http.Client{Transport: typedOutputRoundTripper(func(request *http.Request) (*http.Response, error) { + body := `[]` + status := http.StatusOK + switch request.URL.Path { + case "/graphql": + query, err := io.ReadAll(request.Body) + require.NoError(t, err) + switch { + case strings.Contains(string(query), "suggestedActors"): + body = `{"data":{"repository":{"suggestedActors":{"nodes":[{"id":"BOT","login":"copilot-swe-agent","__typename":"Bot"}],"pageInfo":{"hasNextPage":false,"endCursor":""}}}}}` + case strings.Contains(string(query), "issue(number:"): + body = `{"data":{"repository":{"id":"REPO","issue":{"id":"ISSUE","assignees":{"nodes":[]}}}}}` + case strings.Contains(string(query), "updateIssue(input:"): + body = `{"data":{"updateIssue":{"issue":{"id":"ISSUE","number":7,"url":"https://github.com/owner/repo/issues/7"}}}}` + case strings.Contains(string(query), "issueFields"): + if fixtureMode == "nonempty" { + body = `{"data":{"repository":{"issueFields":{"nodes":[{"__typename":"IssueFieldText","id":"FIELD_1","name":"Priority","description":"Issue priority","dataType":"text","visibility":"ALL"}]}}}}` + } else { + body = `{"data":{"repository":{"issueFields":{"nodes":[]}}}}` + } + case strings.Contains(string(query), "labels"): + if fixtureMode == "nonempty" { + body = `{"data":{"repository":{"labels":{"nodes":[{"id":"LABEL_1","name":"bug","color":"d73a4a","description":"Something isn't working"}],"totalCount":1,"pageInfo":{"hasNextPage":false,"endCursor":""}}}}}` + } else { + body = `{"data":{"repository":{"labels":{"nodes":[],"totalCount":0,"pageInfo":{"hasNextPage":false,"endCursor":""}}}}}` + } + default: + return nil, assert.AnError + } + case "/orgs/owner/issue-types": + switch fixtureMode { + case "nonempty": + body = `[{"id":12,"name":"Bug","description":"A bug","color":"red"},null]` + case "issue-types-null": + body = `null` + } + case "/repos/owner/repo/assignees": + if fixtureMode == "nonempty" { + body = `[{"login":"octocat","avatar_url":"https://example.com/avatar.png"}]` + } + case "/repos/owner/repo/branches": + if fixtureMode == "nonempty" { + body = `[{"name":"main","protected":true,"commit":{"sha":"abc123"}}]` + } + case "/repos/owner/repo/milestones": + if fixtureMode == "nonempty" { + body = `[{"number":4,"title":"v1","description":"First release","state":"open","open_issues":2,"due_on":"2026-12-31T00:00:00Z"}]` + } + case "/repos/owner/repo/collaborators": + if fixtureMode == "nonempty" { + body = `[{"login":"octocat","avatar_url":"https://example.com/avatar.png"}]` + } + case "/repos/owner/repo/teams": + if fixtureMode == "nonempty" { + body = `[{"slug":"docs","name":"Docs"}]` + } + case "/repos/owner/repo/pulls/7/requested_reviewers": + status = http.StatusCreated + body = `{}` + } + return &http.Response{ + StatusCode: status, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: io.NopCloser(strings.NewReader(body)), + Request: request, + }, nil + })} + client := mustNewGHClient(t, httpClient) + return BaseDeps{ + Client: client, + GQLClient: githubv4.NewEnterpriseClient("https://api.github.com/graphql", httpClient), + } +} + +func typedCopilotUISession(t *testing.T, deps ToolDependencies, protocol string) (*mcp.ClientSession, map[string]*jsonschema.Resolved) { + t.Helper() + translation := translations.NullTranslationHelper + tools := []inventory.ServerTool{ + AssignCopilotToIssue(translation), + AssignCopilotToIssueWithIntent(translation), + RequestCopilotReview(translation), + UIGet(translation), + } + registry, err := inventory.NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "copilot-ui", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + registry.RegisterTools(context.Background(), server, deps) + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, request mcp.Request) (mcp.Result, error) { + switch request := request.(type) { + case *mcp.ListToolsRequest: + if request.Params == nil { + request.Params = &mcp.ListToolsParams{} + } + request.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: protocol} + case *mcp.CallToolRequest: + if request.Params == nil { + request.Params = &mcp.CallToolParamsRaw{} + } + request.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: protocol} + ctx = ContextWithPollConfig(ctx, PollConfig{}) + ctx = ghcontext.WithMCPMethodInfo(ctx, &ghcontext.MCPMethodInfo{ProtocolVersion: protocol}) + } + return next(ctx, method, request) + } + }) + version := protocol + if version == "" || version == "unknown" { + version = inventory.ProtocolVersionMultiRoundTrip + } + session := connectCommentVisibilityClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, len(tools)) + schemas := make(map[string]*jsonschema.Resolved) + for _, tool := range list.Tools { + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Nil(t, tool.OutputSchema, tool.Name) + continue + } + require.NotNil(t, tool.OutputSchema, tool.Name) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, tool.OutputSchema)), &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + schemas[tool.Name] = resolved + } + return session, schemas +} + +func TestTypedCopilotAndUIWireOutputs(t *testing.T) { + protocols := []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", "", "unknown"} + tests := []struct { + name string + args map[string]any + text string + structured string + }{ + { + name: "assign_copilot_to_issue", + args: map[string]any{"owner": "owner", "repo": "repo", "issue_number": "7"}, + text: `{"issue_number":7,"issue_url":"https://github.com/owner/repo/issues/7","message":"successfully assigned copilot to issue - pull request pending","note":"The pull request may still be in progress. Once created, the PR number can be used to check job status, or check the issue timeline for updates.","owner":"owner","repo":"repo"}`, + structured: `{"issue_number":7,"message":"successfully assigned copilot to issue - pull request pending","note":"The pull request may still be in progress. Once created, the PR number can be used to check job status, or check the issue timeline for updates.","owner":"owner","repo":"repo"}`, + }, + { + name: "assign_copilot_to_issue_with_intent", + args: map[string]any{"owner": "owner", "repo": "repo", "issue_number": "7", "rationale": " Clear acceptance criteria ", "confidence": "medium", "is_suggestion": "true"}, + text: `{"is_suggestion":true,"issue_number":7,"issue_url":"https://github.com/owner/repo/issues/7","message":"recorded pending copilot assignment suggestion","owner":"owner","repo":"repo"}`, + structured: `{"is_suggestion":true,"issue_number":7,"message":"recorded pending copilot assignment suggestion","owner":"owner","repo":"repo"}`, + }, + { + name: "assign_copilot_to_issue_with_intent", + args: map[string]any{"owner": "owner", "repo": "repo", "issue_number": "7", "rationale": "Clear acceptance criteria", "confidence": "HIGH", "is_suggestion": false}, + text: `{"is_suggestion":false,"issue_number":7,"issue_url":"https://github.com/owner/repo/issues/7","message":"successfully assigned copilot to issue - pull request pending","note":"The pull request may still be in progress. Once created, the PR number can be used to check job status, or check the issue timeline for updates.","owner":"owner","repo":"repo"}`, + structured: `{"is_suggestion":false,"issue_number":7,"message":"successfully assigned copilot to issue - pull request pending","note":"The pull request may still be in progress. Once created, the PR number can be used to check job status, or check the issue timeline for updates.","owner":"owner","repo":"repo"}`, + }, + { + name: "request_copilot_review", + args: map[string]any{"owner": "owner", "repo": "repo", "pullNumber": "7"}, + text: "", + structured: `{"status":"requested"}`, + }, + { + name: "ui_get", + args: map[string]any{"method": "labels", "owner": "owner", "repo": "repo"}, + text: `{"has_more":false,"labels":[],"totalCount":0}`, + structured: `{"method":"labels","labels":{"has_more":false,"labels":[],"totalCount":0}}`, + }, + { + name: "ui_get", + args: map[string]any{"method": "assignees", "owner": "owner", "repo": "repo"}, + text: `{"assignees":[],"has_more":false,"totalCount":0}`, + structured: `{"method":"assignees","assignees":{"assignees":[],"has_more":false,"totalCount":0}}`, + }, + { + name: "ui_get", + args: map[string]any{"method": "milestones", "owner": "owner", "repo": "repo"}, + text: `{"has_more":false,"milestones":[],"totalCount":0}`, + structured: `{"method":"milestones","milestones":{"has_more":false,"milestones":[],"totalCount":0}}`, + }, + { + name: "ui_get", + args: map[string]any{"method": "issue_types", "owner": "owner"}, + text: `[]`, + structured: `{"method":"issue_types","issue_types":[]}`, + }, + { + name: "ui_get", + args: map[string]any{"method": "branches", "owner": "owner", "repo": "repo"}, + text: `{"branches":[],"has_more":false,"totalCount":0}`, + structured: `{"method":"branches","branches":{"branches":[],"has_more":false,"totalCount":0}}`, + }, + { + name: "ui_get", + args: map[string]any{"method": "issue_fields", "owner": "owner", "repo": "repo"}, + text: `{"fields":[],"totalCount":0}`, + structured: `{"method":"issue_fields","issue_fields":{"fields":[],"totalCount":0}}`, + }, + { + name: "ui_get", + args: map[string]any{"method": "reviewers", "owner": "owner", "repo": "repo"}, + text: `{"has_more":false,"teams":[],"totalCount":0,"users":[]}`, + structured: `{"method":"reviewers","reviewers":{"has_more":false,"teams":[],"totalCount":0,"users":[]}}`, + }, + } + + for _, protocol := range protocols { + t.Run("protocol="+protocol, func(t *testing.T) { + session, schemas := typedCopilotUISession(t, typedCopilotUIClient(t), protocol) + for _, tc := range tests { + t.Run(tc.name+"/"+mustMarshalJSON(t, tc.args), func(t *testing.T) { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: tc.args}) + require.NoError(t, err) + require.False(t, result.IsError, mustMarshalJSON(t, result)) + require.Len(t, result.Content, 1) + schema := schemas[tc.name] + if schema == nil { + assert.Equal(t, tc.text, getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + return + } + var output any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, schema.Validate(output)) + assert.JSONEq(t, tc.structured, mustMarshalJSON(t, output)) + text := getTextResult(t, result).Text + assert.Equal(t, mustMarshalJSON(t, result.StructuredContent), text, + "modern success text should be the compact structured output") + }) + } + }) + } +} + +func TestTypedUIGetNonemptyWireOutputs(t *testing.T) { + session, schemas := typedCopilotUISession(t, typedCopilotUIClient(t, "nonempty"), inventory.ProtocolVersionMultiRoundTrip) + tests := []struct { + method string + args map[string]any + structured string + }{ + {method: "labels", args: map[string]any{"method": "labels", "owner": "owner", "repo": "repo"}, structured: `{"method":"labels","labels":{"labels":[{"id":"LABEL_1","name":"bug","color":"d73a4a","description":"Something isn't working"}],"totalCount":1,"has_more":false}}`}, + {method: "assignees", args: map[string]any{"method": "assignees", "owner": "owner", "repo": "repo"}, structured: `{"method":"assignees","assignees":{"assignees":[{"login":"octocat","avatar_url":"https://example.com/avatar.png"}],"totalCount":1,"has_more":false}}`}, + {method: "milestones", args: map[string]any{"method": "milestones", "owner": "owner", "repo": "repo"}, structured: `{"method":"milestones","milestones":{"milestones":[{"number":4,"title":"v1","description":"First release","state":"open","open_issues":2,"due_on":"2026-12-31"}],"totalCount":1,"has_more":false}}`}, + {method: "issue_types", args: map[string]any{"method": "issue_types", "owner": "owner"}, structured: `{"method":"issue_types","issue_types":[{"id":12,"name":"Bug","description":"A bug","color":"red"},null]}`}, + {method: "branches", args: map[string]any{"method": "branches", "owner": "owner", "repo": "repo"}, structured: `{"method":"branches","branches":{"branches":[{"name":"main","sha":"abc123","protected":true}],"totalCount":1,"has_more":false}}`}, + {method: "issue_fields", args: map[string]any{"method": "issue_fields", "owner": "owner", "repo": "repo"}, structured: `{"method":"issue_fields","issue_fields":{"fields":[{"id":"FIELD_1","name":"Priority","data_type":"text","description":"Issue priority"}],"totalCount":1}}`}, + {method: "reviewers", args: map[string]any{"method": "reviewers", "owner": "owner", "repo": "repo"}, structured: `{"method":"reviewers","reviewers":{"users":[{"login":"octocat","avatar_url":"https://example.com/avatar.png"}],"teams":[{"slug":"docs","name":"Docs","org":"owner"}],"totalCount":2,"has_more":false}}`}, + } + for _, tc := range tests { + t.Run(tc.method, func(t *testing.T) { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: "ui_get", Arguments: tc.args}) + require.NoError(t, err) + require.False(t, result.IsError, mustMarshalJSON(t, result)) + schema := schemas["ui_get"] + require.NotNil(t, result.StructuredContent) + var output any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, schema.Validate(output)) + assert.JSONEq(t, tc.structured, mustMarshalJSON(t, output)) + assert.Equal(t, mustMarshalJSON(t, result.StructuredContent), getTextResult(t, result).Text) + }) + } +} + +func TestTypedUIGetNullIssueTypesWireOutput(t *testing.T) { + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25"} { + t.Run(protocol, func(t *testing.T) { + session, schemas := typedCopilotUISession(t, typedCopilotUIClient(t, "issue-types-null"), protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "ui_get", + Arguments: map[string]any{"method": "issue_types", "owner": "owner"}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + if protocol == inventory.ProtocolVersionMultiRoundTrip { + require.NotNil(t, result.StructuredContent) + var output any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, schemas["ui_get"].Validate(output)) + assert.JSONEq(t, `{"method":"issue_types","issue_types":null}`, mustMarshalJSON(t, output)) + assert.Equal(t, mustMarshalJSON(t, result.StructuredContent), getTextResult(t, result).Text) + } else { + assert.Equal(t, "null", getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + } + }) + } +} + +func TestTypedCopilotUIErrorsPreserveLegacyText(t *testing.T) { + protocols := []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", "", "unknown"} + tests := []struct { + name string + args map[string]any + text string + }{ + {name: "assign_copilot_to_issue", args: map[string]any{"repo": "repo", "issue_number": 7}, text: "missing required parameter: owner"}, + {name: "assign_copilot_to_issue_with_intent", args: map[string]any{"owner": "owner", "repo": "repo"}, text: "is_suggestion is required"}, + {name: "assign_copilot_to_issue_with_intent", args: map[string]any{"repo": "repo", "issue_number": 7, "rationale": strings.Repeat("x", 281), "confidence": "LOW", "is_suggestion": false}, text: "missing required parameter: owner"}, + {name: "assign_copilot_to_issue_with_intent", args: map[string]any{"owner": "owner", "repo": "repo", "issue_number": 7, "rationale": strings.Repeat("x", 281), "confidence": "LOW", "is_suggestion": false}, text: "rationale must be 280 characters or less"}, + {name: "assign_copilot_to_issue_with_intent", args: map[string]any{"owner": "owner", "repo": "repo", "issue_number": 7, "rationale": "valid", "confidence": "BAD", "is_suggestion": false}, text: "confidence must be one of: LOW, MEDIUM, HIGH"}, + {name: "request_copilot_review", args: map[string]any{"repo": "repo", "pullNumber": 7}, text: "missing required parameter: owner"}, + {name: "ui_get", args: map[string]any{"method": "labels", "owner": "owner"}, text: "missing required parameter: repo"}, + {name: "ui_get", args: map[string]any{"method": "unknown", "owner": "owner"}, text: "unknown method: unknown"}, + {name: "ui_get", args: map[string]any{"method": "unknown", "owner": "owner", "repo": true}, text: "unknown method: unknown"}, + {name: "ui_get", args: map[string]any{"method": "unknown"}, text: "missing required parameter: owner"}, + {name: "ui_get", args: map[string]any{"method": true, "owner": "owner"}, text: "parameter method is not of type string"}, + } + for _, protocol := range protocols { + t.Run("protocol="+protocol, func(t *testing.T) { + session, _ := typedCopilotUISession(t, typedCopilotUIClient(t), protocol) + for _, tc := range tests { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: tc.args}) + require.NoError(t, err) + require.True(t, result.IsError) + require.Len(t, result.Content, 1) + assert.Equal(t, tc.text, getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + } + }) + } +} + +func TestTypedUIGetHTTPScopesAndDispatch(t *testing.T) { + registry, err := inventory.NewBuilder().SetTools([]inventory.ServerTool{ + UIGet(translations.NullTranslationHelper), + }).WithToolsets([]string{"all"}).Build() + require.NoError(t, err) + scopes.SetToolScopeMapFromInventory(registry) + t.Cleanup(func() { scopes.SetGlobalToolScopeMap(nil) }) + + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", "", "unknown"} { + t.Run("protocol="+protocol, func(t *testing.T) { + session, _ := typedCopilotUISession(t, typedCopilotUIClient(t), protocol) + for _, tc := range []struct { + name string + args map[string]any + scopes []string + wantStatus int + wantError string + }{ + {name: "unknown method does not challenge", args: map[string]any{"method": "unknown", "owner": "owner", "repo": "repo"}, scopes: []string{"gist"}, wantStatus: http.StatusOK, wantError: "unknown method: unknown"}, + {name: "unknown method validates owner first", args: map[string]any{"method": "unknown", "repo": "repo"}, scopes: []string{"gist"}, wantStatus: http.StatusOK, wantError: "missing required parameter: owner"}, + {name: "labels cannot be overridden to issue types", args: map[string]any{"method": "labels", "owner": "owner", "repo": "repo", "_compat_method": "issue_types"}, scopes: []string{"repo"}, wantStatus: http.StatusOK}, + {name: "labels cannot be overridden to reviewers", args: map[string]any{"method": "labels", "owner": "owner", "repo": "repo", "_compat_method": "reviewers"}, scopes: []string{"repo"}, wantStatus: http.StatusOK}, + {name: "issue types still require read org", args: map[string]any{"method": "issue_types", "owner": "owner", "_compat_method": "labels"}, scopes: []string{"repo"}, wantStatus: http.StatusForbidden}, + {name: "labels still require repo", args: map[string]any{"method": "labels", "owner": "owner", "repo": "repo"}, scopes: []string{"gist"}, wantStatus: http.StatusForbidden}, + } { + t.Run(tc.name, func(t *testing.T) { + raw, err := json.Marshal(tc.args) + require.NoError(t, err) + called := false + handler := middleware.WithScopeChallenge(&oauth.Config{}, nil)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + result, err := session.CallTool(r.Context(), &mcp.CallToolParams{Name: "ui_get", Arguments: tc.args}) + require.NoError(t, err) + if tc.wantError != "" { + require.True(t, result.IsError) + assert.Equal(t, tc.wantError, getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + } else { + require.False(t, result.IsError) + if protocol == inventory.ProtocolVersionMultiRoundTrip { + assert.JSONEq(t, `{"method":"labels","labels":{"labels":[],"totalCount":0,"has_more":false}}`, getTextResult(t, result).Text) + } else { + assert.Equal(t, `{"has_more":false,"labels":[],"totalCount":0}`, getTextResult(t, result).Text) + } + } + require.NoError(t, json.NewEncoder(w).Encode(result)) + })) + request := httptest.NewRequest(http.MethodPost, "/mcp", nil) + ctx := ghcontext.WithTokenInfo(request.Context(), &ghcontext.TokenInfo{TokenType: utils.TokenTypeOAuthAccessToken}) + ctx = ghcontext.WithTokenScopes(ctx, tc.scopes) + ctx = ghcontext.WithMCPMethodInfo(ctx, &ghcontext.MCPMethodInfo{ + Method: "tools/call", ItemName: "ui_get", RawArguments: raw, ProtocolVersion: protocol, + }) + response := httptest.NewRecorder() + handler.ServeHTTP(response, request.WithContext(ctx)) + assert.Equal(t, tc.wantStatus, response.Code) + assert.Equal(t, tc.wantStatus == http.StatusOK, called) + if tc.wantStatus == http.StatusOK { + assert.Empty(t, response.Header().Get("WWW-Authenticate")) + } else { + assert.Contains(t, response.Header().Get("WWW-Authenticate"), "insufficient_scope") + } + }) + } + }) + } +} + +func TestTypedUIGetUnionOutputs(t *testing.T) { + cases := []struct { + method string + raw string + typed string + }{ + {method: "labels", raw: `{"labels":[{"id":"L","name":"bug","color":"red","description":""}],"totalCount":1,"has_more":false}`, typed: `{"method":"labels","labels":{"labels":[{"id":"L","name":"bug","color":"red","description":""}],"totalCount":1,"has_more":false}}`}, + {method: "assignees", raw: `{"assignees":[{"login":"octocat","avatar_url":"https://example.com/avatar.png"}],"totalCount":1,"has_more":false}`, typed: `{"method":"assignees","assignees":{"assignees":[{"login":"octocat","avatar_url":"https://example.com/avatar.png"}],"totalCount":1,"has_more":false}}`}, + {method: "milestones", raw: `{"milestones":[{"number":4,"title":"v1","description":"","state":"open","open_issues":2,"due_on":""}],"totalCount":1,"has_more":false}`, typed: `{"method":"milestones","milestones":{"milestones":[{"number":4,"title":"v1","description":"","state":"open","open_issues":2,"due_on":""}],"totalCount":1,"has_more":false}}`}, + {method: "issue_types", raw: `[{"id":1,"name":"Bug"}]`, typed: `{"method":"issue_types","issue_types":[{"id":1,"name":"Bug"}]}`}, + {method: "issue_types", raw: `null`, typed: `{"method":"issue_types","issue_types":null}`}, + {method: "branches", raw: `{"branches":[{"name":"main","sha":"abc","protected":true}],"totalCount":1,"has_more":false}`, typed: `{"method":"branches","branches":{"branches":[{"name":"main","sha":"abc","protected":true}],"totalCount":1,"has_more":false}}`}, + {method: "issue_fields", raw: `{"fields":[{"id":"F","name":"Priority","data_type":"single_select","description":"","options":[]}],"totalCount":1}`, typed: `{"method":"issue_fields","issue_fields":{"fields":[{"id":"F","name":"Priority","data_type":"single_select","description":"","options":[]}],"totalCount":1}}`}, + {method: "reviewers", raw: `{"users":[{"login":"octocat","avatar_url":""}],"teams":[{"slug":"docs","name":"Docs","org":"owner"}],"totalCount":2,"has_more":false}`, typed: `{"method":"reviewers","reviewers":{"users":[{"login":"octocat","avatar_url":""}],"teams":[{"slug":"docs","name":"Docs","org":"owner"}],"totalCount":2,"has_more":false}}`}, + } + schema, err := uiGetOutputSchema().Resolve(nil) + require.NoError(t, err) + for _, tc := range cases { + t.Run(tc.method, func(t *testing.T) { + output, err := decodeUIGetOutput(tc.method, []byte(tc.raw)) + require.NoError(t, err) + encoded, err := json.Marshal(output) + require.NoError(t, err) + assert.JSONEq(t, tc.typed, string(encoded)) + var value any + require.NoError(t, json.Unmarshal(encoded, &value)) + require.NoError(t, schema.Validate(value)) + }) + } +} + +func TestToolDefinitionsUseConcreteOutputTypes(t *testing.T) { + for _, tool := range AllTools(translations.NullTranslationHelper) { + registerTyped := reflect.ValueOf(tool).FieldByName("registerTyped") + require.True(t, registerTyped.IsValid(), "tool %q has no output registration metadata", tool.Tool.Name) + assert.False(t, registerTyped.IsNil(), "tool %q uses an untyped output registration", tool.Tool.Name) + } +} + +func TestTypedCopilotOutputSchemas(t *testing.T) { + cases := []struct { + name string + schema *jsonschema.Schema + raw string + }{ + { + name: "assign_copilot_to_issue", + schema: assignCopilotToIssueOutputSchema(), + raw: `{"issue_number":7,"message":"successfully assigned copilot to issue - pull request pending","note":"pending","owner":"owner","pull_request":{"number":8,"state":"OPEN","title":"Fix"},"repo":"repo"}`, + }, + { + name: "assign_copilot_to_issue_with_intent", + schema: assignCopilotToIssueWithIntentOutputSchema(), + raw: `{"is_suggestion":true,"issue_number":7,"message":"suggested","owner":"owner","repo":"repo"}`, + }, + { + name: "request_copilot_review", + schema: copilotReviewOutputSchema(), + raw: `{"status":"requested"}`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, "object", tc.schema.Type) + require.NotEmpty(t, tc.schema.Properties) + assert.Nil(t, tc.schema.AnyOf) + assert.Nil(t, tc.schema.OneOf) + resolved, err := tc.schema.Resolve(nil) + require.NoError(t, err) + var value any + require.NoError(t, json.Unmarshal([]byte(tc.raw), &value)) + require.NoError(t, resolved.Validate(value)) + }) + } + assert.NotContains(t, mustMarshalJSON(t, assignCopilotToIssueOutputSchema()), `"url"`) + assert.NotContains(t, mustMarshalJSON(t, assignCopilotToIssueWithIntentOutputSchema()), `"url"`) + reviewSchema, err := copilotReviewOutputSchema().Resolve(nil) + require.NoError(t, err) + require.NoError(t, reviewSchema.Validate(map[string]any{"status": "requested"})) + require.NoError(t, reviewSchema.Validate(map[string]any{"status": ""})) + assert.Error(t, reviewSchema.Validate(map[string]any{})) +} + +func TestTypedUIGetOutputSchemaDiscriminator(t *testing.T) { + schema := uiGetOutputSchema() + require.Equal(t, "object", schema.Type) + require.Equal(t, []string{"method"}, schema.Required) + assert.Nil(t, schema.AnyOf) + assert.Nil(t, schema.OneOf) + require.Contains(t, schema.Properties, "method") + assert.Equal(t, []any{"labels", "assignees", "milestones", "issue_types", "branches", "issue_fields", "reviewers"}, schema.Properties["method"].Enum) + for _, property := range []string{"labels", "assignees", "milestones", "issue_types", "branches", "issue_fields", "reviewers"} { + require.Contains(t, schema.Properties, property) + assert.NotEqual(t, `{}`, mustMarshalJSON(t, schema.Properties[property]), property) + } + assert.NotContains(t, mustMarshalJSON(t, schema), `"url"`) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + for _, raw := range []string{ + `{"method":"labels","labels":{"labels":[],"totalCount":0,"has_more":false}}`, + `{"method":"issue_types","issue_types":[]}`, + } { + var value any + require.NoError(t, json.Unmarshal([]byte(raw), &value)) + require.NoError(t, resolved.Validate(value)) + } + for _, raw := range []string{`{}`, `{"method":"unsupported"}`, `{"method":"labels","labels":true}`} { + var value any + require.NoError(t, json.Unmarshal([]byte(raw), &value)) + assert.Error(t, resolved.Validate(value), raw) + } +} diff --git a/pkg/github/typed_git_gist_outputs_test.go b/pkg/github/typed_git_gist_outputs_test.go new file mode 100644 index 0000000000..631c9309b7 --- /dev/null +++ b/pkg/github/typed_git_gist_outputs_test.go @@ -0,0 +1,391 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + "testing" + "time" + + "github.com/github/github-mcp-server/pkg/ifc" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var typedGitGistProtocols = []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", "", "unknown"} + +func gitGistTypedSession(t *testing.T, deps ToolDependencies, protocol string) (*mcp.ClientSession, map[string]*jsonschema.Resolved) { + t.Helper() + tr := translations.NullTranslationHelper + tools := []inventory.ServerTool{GetRepositoryTree(tr), ListGists(tr), GetGist(tr), CreateGist(tr), UpdateGist(tr)} + inv, err := inventory.NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "git-gist", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + if protocol == "" || protocol == "unknown" { + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + switch req := req.(type) { + case *mcp.ListToolsRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: protocol} + case *mcp.CallToolRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: protocol} + } + return next(ctx, method, req) + } + }) + } + version := protocol + if version == "" || version == "unknown" { + version = inventory.ProtocolVersionMultiRoundTrip + } + session := connectCommentVisibilityClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, len(tools)) + schemas := make(map[string]*jsonschema.Resolved) + for _, tool := range list.Tools { + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Nil(t, tool.OutputSchema, tool.Name) + continue + } + require.NotNil(t, tool.OutputSchema, tool.Name) + for _, canonical := range tools { + if canonical.Tool.Name == tool.Name { + assert.JSONEq(t, mustMarshalJSON(t, canonical.Tool.OutputSchema), mustMarshalJSON(t, tool.OutputSchema)) + } + } + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, tool.OutputSchema)), &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + schemas[tool.Name] = resolved + } + return session, schemas +} + +type gitGistCase struct { + name string + args map[string]any + method string + path string + query string + body string + status int + text string + structured string +} + +func gitGistDeps(t *testing.T, current **gitGistCase, apiError bool, private ...bool) BaseDeps { + t.Helper() + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + tc := *current + if r.URL.Path == "/repos/owner/repo" { + _, _ = fmt.Fprintf(w, `{"default_branch":"main","private":%t}`, len(private) > 0 && private[0]) + return + } + assert.Equal(t, tc.method, r.Method) + assert.Equal(t, tc.path, r.URL.Path) + assert.Equal(t, tc.query, r.URL.RawQuery) + switch tc.name { + case "create_gist": + var payload struct { + Description string `json:"description"` + Public bool `json:"public"` + Files map[string]struct { + Content string `json:"content"` + } `json:"files"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + assert.Equal(t, tc.args["description"], payload.Description) + assert.Equal(t, tc.args["public"], payload.Public) + assert.Equal(t, tc.args["content"], payload.Files[tc.args["filename"].(string)].Content) + case "update_gist": + var payload struct { + Description *string `json:"description"` + Files map[string]struct { + Filename string `json:"filename"` + Content string `json:"content"` + } `json:"files"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&payload)) + if description, present := tc.args["description"]; present { + require.NotNil(t, payload.Description) + assert.Equal(t, description, *payload.Description) + } else { + assert.Nil(t, payload.Description) + } + file := payload.Files[tc.args["filename"].(string)] + assert.Equal(t, tc.args["filename"], file.Filename) + assert.Equal(t, tc.args["content"], file.Content) + } + if apiError { + w.WriteHeader(http.StatusForbidden) + _, _ = w.Write([]byte(`{"message":"Forbidden"}`)) + return + } + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.body)) + })}} + return BaseDeps{Client: mustNewGHClient(t, client), ContentWindowSize: 100, RepoAccessCache: stubRepoAccessCache(nil, time.Minute)} +} + +const ( + gistBody = `{"id":"abc","description":"d","public":true,"comments":2,"html_url":"https://gist.github.com/abc","created_at":"2026-01-01T00:00:00Z","owner":{"login":"octocat","id":1,"url":"https://api.github.com/users/octocat","html_url":"https://github.com/octocat","node_id":"user-node"},"files":{"a.txt":{"filename":"a.txt","size":3,"content":"hey","raw_url":"https://gist.githubusercontent.com/raw"}},"node_id":"gist-node","git_pull_url":"https://gist.github.com/abc.git","git_push_url":"https://gist.github.com/abc.git"}` + gistText = `{"id":"abc","description":"d","public":true,"owner":{"login":"octocat","id":1,"node_id":"user-node","html_url":"https://github.com/octocat","url":"https://api.github.com/users/octocat"},"files":{"a.txt":{"size":3,"filename":"a.txt","raw_url":"https://gist.githubusercontent.com/raw","content":"hey"}},"comments":2,"html_url":"https://gist.github.com/abc","git_pull_url":"https://gist.github.com/abc.git","git_push_url":"https://gist.github.com/abc.git","created_at":"2026-01-01T00:00:00Z","node_id":"gist-node"}` + gistStructured = `{"id":"abc","description":"d","public":true,"owner":{"login":"octocat","id":1,"profile_url":"https://github.com/octocat"},"files":{"a.txt":{"size":3,"filename":"a.txt","content":"hey"}},"comments":2,"html_url":"https://gist.github.com/abc","created_at":"2026-01-01T00:00:00Z"}` + treeBody = `{"sha":"t1","truncated":false,"tree":[{"path":"src/a.go","mode":"100644","type":"blob","sha":"s1","size":5,"url":"u1"},{"path":"src","mode":"040000","type":"tree","sha":"s2","url":"u2"},{"path":"README.md","mode":"100644","type":"blob","sha":"s3","size":1,"url":"u3"}]}` + minimalOK = `{"id":"abc","url":"https://gist.github.com/abc"}` +) + +func gitGistWireCases() []gitGistCase { + return []gitGistCase{ + {name: "get_repository_tree", args: map[string]any{"owner": "owner", "repo": "repo", "recursive": true, "path_filter": "src"}, method: "GET", path: "/repos/owner/repo/git/trees/main", query: "recursive=1", body: treeBody, status: 200, + text: `{"sha":"t1","truncated":false,"tree":[{"path":"src/a.go","type":"blob","size":5,"mode":"100644","sha":"s1","url":"u1"},{"path":"src","type":"tree","mode":"040000","sha":"s2","url":"u2"}],"tree_sha":"main","owner":"owner","repo":"repo","recursive":true,"count":2}`, + structured: `{"sha":"t1","truncated":false,"tree":[{"path":"src/a.go","type":"blob","size":5,"mode":"100644","sha":"s1"},{"path":"src","type":"tree","mode":"040000","sha":"s2"}],"tree_sha":"main","owner":"owner","repo":"repo","recursive":true,"count":2}`}, + {name: "get_repository_tree", args: map[string]any{"owner": "owner", "repo": "repo", "tree_sha": "abc", "path_filter": "none"}, method: "GET", path: "/repos/owner/repo/git/trees/abc", body: treeBody, status: 200, + text: `{"sha":"t1","truncated":false,"tree":[],"tree_sha":"abc","owner":"owner","repo":"repo","recursive":false,"count":0}`}, + {name: "list_gists", args: map[string]any{"username": "octocat", "since": "2026-01-01T00:00:00Z", "page": "2e0", "perPage": "5"}, method: "GET", path: "/users/octocat/gists", query: "page=2&per_page=5&since=2026-01-01T00%3A00%3A00Z", body: `[` + gistBody + `]`, status: 200, text: `[` + gistText + `]`, structured: `[` + gistStructured + `]`}, + {name: "list_gists", args: map[string]any{"page": 0, "perPage": 0}, method: "GET", path: "/gists", query: "page=1&per_page=30", body: `[]`, status: 200, text: `[]`}, + {name: "list_gists", args: map[string]any{"page": -1, "perPage": 101}, method: "GET", path: "/gists", query: "page=-1&per_page=101", body: `[]`, status: 200, text: `[]`}, + {name: "list_gists", args: map[string]any{"page": "-1", "perPage": "-2"}, method: "GET", path: "/gists", query: "page=-1&per_page=-2", body: `[]`, status: 200, text: `[]`}, + {name: "list_gists", args: nil, method: "GET", path: "/gists", query: "page=1&per_page=30", body: `null`, status: 200, text: `null`}, + {name: "get_gist", args: map[string]any{"gist_id": "abc"}, method: "GET", path: "/gists/abc", body: gistBody, status: 200, text: gistText, structured: gistStructured}, + {name: "get_gist", args: map[string]any{"gist_id": "abc"}, method: "GET", path: "/gists/abc", body: `{"public":false,"description":"","files":{"empty":{"filename":"","content":"","size":0,"language":"","type":""}}}`, status: 200, + text: `{"description":"","public":false,"files":{"empty":{"size":0,"filename":"","language":"","type":"","content":""}}}`}, + {name: "get_gist", args: map[string]any{"gist_id": "abc"}, method: "GET", path: "/gists/abc", body: `{}`, status: 200, text: `{}`}, + {name: "get_gist", args: map[string]any{"gist_id": "abc"}, method: "GET", path: "/gists/abc", body: `null`, status: 200, text: `null`}, + {name: "get_gist", args: map[string]any{"gist_id": "abc"}, method: "GET", path: "/gists/abc", + body: `{"created_at":"2026-01-01T00:00:00.123456789+02:00","updated_at":"2026-01-02T12:00:00Z"}`, status: 200, + text: `{"created_at":"2026-01-01T00:00:00.123456789+02:00","updated_at":"2026-01-02T12:00:00Z"}`}, + {name: "list_gists", method: "GET", path: "/gists", query: "page=1&per_page=30", body: `[null,{}]`, status: 200, text: `[null,{}]`}, + {name: "create_gist", args: map[string]any{"filename": "a.txt", "content": "hey", "description": "d", "public": true}, method: "POST", path: "/gists", body: gistBody, status: 201, text: minimalOK}, + {name: "update_gist", args: map[string]any{"gist_id": "abc", "filename": "a.txt", "content": "hey"}, method: "PATCH", path: "/gists/abc", body: gistBody, status: 200, text: minimalOK}, + {name: "update_gist", args: map[string]any{"gist_id": "abc", "filename": "a.txt", "content": "hey", "description": ""}, method: "PATCH", path: "/gists/abc", body: gistBody, status: 200, text: minimalOK}, + } +} + +func assertGitGistResult(t *testing.T, result *mcp.CallToolResult, schema *jsonschema.Resolved, text string, isError bool, structured ...string) { + t.Helper() + require.Equal(t, isError, result.IsError, mustMarshalJSON(t, result)) + require.Len(t, result.Content, 1) + actual := getTextResult(t, result).Text + if schema == nil || isError { + assert.Equal(t, text, actual) + assert.Nil(t, result.StructuredContent) + return + } + var output any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, schema.Validate(output)) + expected := text + if len(structured) > 0 && structured[0] != "" { + expected = structured[0] + } + assert.JSONEq(t, expected, mustMarshalJSON(t, output)) + assert.JSONEq(t, expected, actual) +} + +func TestTypedGitGistWireOutputs(t *testing.T) { + for _, protocol := range typedGitGistProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + var current *gitGistCase + session, schemas := gitGistTypedSession(t, gitGistDeps(t, ¤t, false), protocol) + for _, tc := range gitGistWireCases() { + t.Run(tc.name+"/"+mustMarshalJSON(t, tc.args), func(t *testing.T) { + current = &tc + args := map[string]any{} + if tc.name == "get_repository_tree" { + args["owner"], args["repo"] = "owner", "repo" + } + maps.Copy(args, tc.args) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: args}) + require.NoError(t, err) + assertGitGistResult(t, result, schemas[tc.name], tc.text, false, tc.structured) + }) + } + }) + } +} + +func TestTypedGitGistWireErrors(t *testing.T) { + cases := []gitGistCase{ + {name: "get_repository_tree", args: map[string]any{"owner": "", "repo": "r"}, text: "missing required parameter: owner"}, + {name: "get_repository_tree", args: map[string]any{"owner": "o", "repo": "r", "recursive": "true"}, text: "parameter recursive is not of type bool, is string"}, + {name: "get_repository_tree", args: map[string]any{"owner": "o", "repo": "r", "tree_sha": 1}, text: "parameter tree_sha is not of type string, is float64"}, + {name: "list_gists", args: map[string]any{"username": 1}, text: "parameter username is not of type string, is float64"}, + {name: "list_gists", args: map[string]any{"since": "bad"}, text: `invalid since timestamp: invalid ISO 8601 timestamp: bad (supported formats: YYYY-MM-DDThh:mm:ssZ or YYYY-MM-DD)`}, + {name: "list_gists", args: map[string]any{"page": "bad"}, text: "parameter page is not a valid number: invalid numeric value: bad"}, + {name: "get_gist", args: map[string]any{}, text: "missing required parameter: gist_id"}, + {name: "create_gist", args: map[string]any{"content": "x"}, text: "missing required parameter: filename"}, + {name: "create_gist", args: map[string]any{"filename": "f", "content": "x", "public": "yes"}, text: "parameter public is not of type bool, is string"}, + {name: "update_gist", args: map[string]any{"gist_id": "g", "description": false, "filename": "f", "content": "c"}, text: "parameter description is not of type string, is bool"}, + } + for _, protocol := range typedGitGistProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + var current *gitGistCase + session, _ := gitGistTypedSession(t, gitGistDeps(t, ¤t, false), protocol) + for _, tc := range cases { + t.Run(tc.name+"/"+tc.text, func(t *testing.T) { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: tc.args}) + require.NoError(t, err) + assertGitGistResult(t, result, nil, tc.text, true) + }) + } + session, _ = gitGistTypedSession(t, gitGistDeps(t, ¤t, true), protocol) + for _, tc := range gitGistWireCases() { + current = &tc + args := map[string]any{"owner": "owner", "repo": "repo"} + if tc.name != "get_repository_tree" { + args = map[string]any{} + } + maps.Copy(args, tc.args) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: args}) + require.NoError(t, err, tc.name+mustMarshalJSON(t, tc.args)) + require.True(t, result.IsError, tc.name) + assert.Contains(t, getTextResult(t, result).Text, "Forbidden") + assert.Nil(t, result.StructuredContent) + } + }) + } +} + +func TestGitGistOutputSchemasRejectMismatches(t *testing.T) { + for name, tc := range map[string]struct { + schema *jsonschema.Schema + bad string + }{ + "tree": {treeOutputSchema(), `{"sha":"s","truncated":false,"tree":[{"path":"p","type":"blob","mode":"m","sha":"s","url":"u","size":null}],"tree_sha":"t","owner":"o","repo":"r","recursive":false,"count":1}`}, + "gist": {gistOutputSchema(), `{"id":null}`}, + "list": {gistListOutputSchema(), `[{"files":{"a":{"size":"3"}}}]`}, + "create": {gistMutationOutputSchema(), `{"id":"a"}`}, + "api_url": {gistOutputSchema(), `{"git_pull_url":"https://gist.github.com/abc.git"}`}, + "node_id": {gistOutputSchema(), `{"owner":{"login":"octocat","node_id":"node"}}`}, + "raw_url": {gistOutputSchema(), `{"files":{"a":{"raw_url":"https://gist.githubusercontent.com/raw"}}}`}, + "timestamp": {gistOutputSchema(), `{"created_at":42}`}, + "tree_type": {treeOutputSchema(), `{"sha":"s","truncated":false,"tree":[{"path":"p","type":"invalid","mode":"100644","sha":"s"}],"tree_sha":"t","owner":"o","repo":"r","recursive":false,"count":1}`}, + } { + t.Run(name, func(t *testing.T) { + resolved, err := tc.schema.Resolve(nil) + require.NoError(t, err) + var value any + require.NoError(t, json.Unmarshal([]byte(tc.bad), &value)) + require.Error(t, resolved.Validate(value)) + }) + } +} + +func TestGitGistInputMetadata(t *testing.T) { + tool := ListGists(translations.NullTranslationHelper) + schema := tool.Tool.InputSchema.(*jsonschema.Schema) + require.Equal(t, new(1.0), schema.Properties["page"].Minimum) + require.Equal(t, new(1.0), schema.Properties["perPage"].Minimum) + require.Equal(t, new(100.0), schema.Properties["perPage"].Maximum) + assert.Equal(t, "Page number for pagination (min 1)", schema.Properties["page"].Description) + assert.Equal(t, "Results per page for pagination (min 1, max 100)", schema.Properties["perPage"].Description) + for _, schemaFor := range []func() *jsonschema.Schema{ + treeOutputSchema, gistOutputSchema, gistListOutputSchema, gistMutationOutputSchema, + } { + assert.Same(t, schemaFor(), schemaFor()) + } +} + +func TestListGistsLegacyPaginationBounds(t *testing.T) { + for _, tc := range gitGistWireCases() { + if tc.name != "list_gists" || (tc.query != "page=-1&per_page=101" && tc.query != "page=-1&per_page=-2") { + continue + } + t.Run(tc.query, func(t *testing.T) { + current := &tc + deps := gitGistDeps(t, ¤t, false) + tool := ListGists(translations.NullTranslationHelper) + ctx := ContextWithDeps(context.Background(), deps) + request := createMCPRequest(tc.args) + result, err := tool.HandlerFunc(deps)(ctx, &request) + require.NoError(t, err) + assertGitGistResult(t, result, nil, "[]", false) + }) + } +} + +func TestGitGistIFCScopesAndLockdown(t *testing.T) { + tr := translations.NullTranslationHelper + for _, tc := range []struct { + tool inventory.ServerTool + scope inventory.ScopeAccess + }{ + {GetRepositoryTree(tr), scopes.PublicRead(scopes.Repo)}, + {ListGists(tr), scopes.NoScopes()}, + {GetGist(tr), scopes.NoScopes()}, + {CreateGist(tr), scopes.RequireAll(scopes.Gist)}, + {UpdateGist(tr), scopes.RequireAll(scopes.Gist)}, + } { + actual := tc.tool.ScopeAccess + assert.Equal(t, tc.scope.Scopes, actual.Scopes, tc.tool.Tool.Name) + assert.Equal(t, tc.scope.Dynamic, actual.Dynamic) + for _, active := range [][]string{nil, {"gist"}, {"repo"}} { + if tc.scope.Visible == nil { + assert.Nil(t, actual.Visible) + } else { + require.NotNil(t, actual.Visible) + assert.Equal(t, tc.scope.Visible(active), actual.Visible(active)) + } + if tc.scope.Challenge == nil { + assert.Nil(t, actual.Challenge) + } else { + require.NotNil(t, actual.Challenge) + args := map[string]any{"owner": "owner", "repo": "repo"} + assert.Equal(t, tc.scope.Challenge(args, active), actual.Challenge(args, active)) + } + } + } + + for _, protocol := range typedGitGistProtocols { + for _, private := range []bool{false, true} { + for _, enabled := range []bool{false, true} { + for _, lockdown := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/private=%t/ifc=%t/lockdown=%t", protocol, private, enabled, lockdown), func(t *testing.T) { + var current *gitGistCase + deps := gitGistDeps(t, ¤t, false, private) + deps.Flags = stubFeatureFlags(map[string]bool{"lockdown-mode": lockdown}) + if enabled { + deps.featureChecker = featureCheckerFor(FeatureFlagIFCLabels) + } + session, schemas := gitGistTypedSession(t, deps, protocol) + seen := make(map[string]bool) + for _, tc := range gitGistWireCases() { + if seen[tc.name] { + continue + } + seen[tc.name] = true + current = &tc + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: tc.args}) + require.NoError(t, err) + assertGitGistResult(t, result, schemas[tc.name], tc.text, false, tc.structured) + if !enabled || tc.name == "create_gist" || tc.name == "update_gist" { + assert.Nil(t, result.Meta["ifc"]) + continue + } + expected := ifc.LabelGist() + switch tc.name { + case "get_repository_tree": + expected = ifc.LabelCommitContents(private) + case "list_gists": + expected = ifc.LabelGistList() + } + assert.JSONEq(t, mustMarshalJSON(t, expected), mustMarshalJSON(t, result.Meta["ifc"])) + } + }) + } + } + } + } +} diff --git a/pkg/github/typed_governance_outputs.go b/pkg/github/typed_governance_outputs.go new file mode 100644 index 0000000000..94529c26f1 --- /dev/null +++ b/pkg/github/typed_governance_outputs.go @@ -0,0 +1,497 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "reflect" + "strings" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +type MinimalLabel struct { + Name string `json:"name"` + Color string `json:"color" jsonschema:"Hex RGB without leading #."` + Description string `json:"description"` +} + +type ListLabelsOutput struct { + Labels []MinimalLabel `json:"labels"` + TotalCount int `json:"totalCount" jsonschema:"Total labels in the repository."` +} + +type LabelWriteOutput struct { + Method string `json:"method"` + Message string `json:"message"` +} + +// CustomPropertyOutputValue is the API's string/string-array/null union. +type CustomPropertyOutputValue struct { + raw json.RawMessage +} + +func (v *CustomPropertyOutputValue) UnmarshalJSON(raw []byte) error { + schema, err := customPropertyValueSchema().Properties["value"].Resolve(nil) + if err != nil { + return err + } + var value any + if err := json.Unmarshal(raw, &value); err != nil { + return err + } + if err := schema.Validate(value); err != nil { + return err + } + v.raw = append(json.RawMessage(nil), raw...) + return nil +} + +func (v CustomPropertyOutputValue) MarshalJSON() ([]byte, error) { + if len(v.raw) == 0 { + return []byte("null"), nil + } + return v.raw, nil +} + +func (v CustomPropertyOutputValue) IsZero() bool { + return len(v.raw) == 0 +} + +type CustomPropertyValueOutput struct { + PropertyName string `json:"property_name"` + Value CustomPropertyOutputValue `json:"value"` +} + +type CustomPropertyDefinitionOutput struct { + PropertyName string `json:"property_name"` + ValueType string `json:"value_type"` + Required *bool `json:"required,omitempty"` + DefaultValue CustomPropertyOutputValue `json:"default_value,omitzero"` + Description *string `json:"description,omitempty"` + AllowedValues []string `json:"allowed_values,omitzero"` + ValuesEditableBy *string `json:"values_editable_by,omitempty"` + RequireExplicitValues *bool `json:"require_explicit_values,omitempty"` +} + +type CustomPropertiesOutput struct { + Method string `json:"method"` + Level string `json:"level"` + Values *[]CustomPropertyValueOutput `json:"values,omitempty"` + Definitions *[]CustomPropertyDefinitionOutput `json:"definitions,omitempty"` + Message string `json:"message,omitempty"` +} + +type RulesetBypassActorOutput struct { + ActorID *int64 `json:"actor_id,omitempty"` + ActorType string `json:"actor_type"` + BypassMode string `json:"bypass_mode"` +} + +type RulesetNameConditionOutput struct { + Include []string `json:"include"` + Exclude []string `json:"exclude"` + Protected *bool `json:"protected,omitempty"` +} + +type RulesetIDConditionOutput struct { + RepositoryIDs []int64 `json:"repository_ids,omitempty"` + OrganizationIDs []int64 `json:"organization_ids,omitempty"` +} + +type RulesetPropertyTargetOutput struct { + Name string `json:"name"` + PropertyValues []string `json:"property_values"` + Source *string `json:"source,omitempty"` +} + +type RulesetPropertyConditionOutput struct { + Include []RulesetPropertyTargetOutput `json:"include"` + Exclude []RulesetPropertyTargetOutput `json:"exclude"` +} + +type RulesetConditionsOutput struct { + RefName *RulesetNameConditionOutput `json:"ref_name,omitempty"` + RepositoryID *RulesetIDConditionOutput `json:"repository_id,omitempty"` + RepositoryName *RulesetNameConditionOutput `json:"repository_name,omitempty"` + RepositoryProperty *RulesetPropertyConditionOutput `json:"repository_property,omitempty"` + OrganizationID *RulesetIDConditionOutput `json:"organization_id,omitempty"` + OrganizationName *RulesetNameConditionOutput `json:"organization_name,omitempty"` + OrganizationProperty *RulesetPropertyConditionOutput `json:"organization_property,omitempty"` +} + +type GovernanceRuleOutput struct { + Type string `json:"type"` + // Parameters are the only passthrough: rule-specific, user-configured JSON. + Parameters json.RawMessage `json:"parameters,omitempty"` + RulesetID *int64 `json:"ruleset_id,omitempty"` + RulesetSource string `json:"ruleset_source,omitempty"` + RulesetSourceType string `json:"ruleset_source_type,omitempty"` +} + +type RulesetOutput struct { + ID *int64 `json:"id,omitempty"` + Name string `json:"name"` + Target string `json:"target,omitempty"` + SourceType string `json:"source_type,omitempty"` + Source string `json:"source"` + Enforcement string `json:"enforcement"` + BypassActors []RulesetBypassActorOutput `json:"bypass_actors,omitzero"` + CurrentUserCanBypass string `json:"current_user_can_bypass,omitempty"` + Conditions *RulesetConditionsOutput `json:"conditions,omitempty"` + Rules []GovernanceRuleOutput `json:"rules,omitzero"` + CreatedAt string `json:"created_at,omitempty" jsonschema:"RFC3339 creation time."` + UpdatedAt string `json:"updated_at,omitempty" jsonschema:"RFC3339 last update time."` +} + +type RuleEvaluationOutput struct { + RuleSource *RuleSourceOutput `json:"rule_source,omitempty"` + Enforcement string `json:"enforcement,omitempty"` + Result string `json:"result"` + RuleType string `json:"rule_type"` + Details string `json:"details,omitempty"` +} + +type RuleSourceOutput struct { + Type string `json:"type"` + ID int64 `json:"id"` + Name string `json:"name,omitempty"` +} + +type RuleSuiteOutput struct { + ID int64 `json:"id"` + ActorID *int64 `json:"actor_id,omitempty"` + ActorName string `json:"actor_name,omitempty"` + BeforeSHA string `json:"before_sha,omitempty"` + AfterSHA string `json:"after_sha,omitempty"` + Ref string `json:"ref,omitempty"` + PushedAt string `json:"pushed_at,omitempty" jsonschema:"RFC3339 push time."` + Result string `json:"result"` + RepositoryID *int64 `json:"repository_id,omitempty"` + RepositoryName string `json:"repository_name,omitempty"` + Evaluations []RuleEvaluationOutput `json:"rule_evaluations,omitempty"` +} + +type RulesetReadOutput struct { + Method string `json:"method"` + Level string `json:"level"` + Ruleset *RulesetOutput `json:"ruleset,omitempty"` + Rulesets *[]RulesetOutput `json:"rulesets,omitempty"` + TotalCount *int `json:"total_count,omitempty"` + Rules *[]GovernanceRuleOutput `json:"rules,omitempty"` + RuleSuites *[]RuleSuiteOutput `json:"rule_suites,omitempty"` + RuleSuite *RuleSuiteOutput `json:"rule_suite,omitempty"` +} + +func typedGovernanceSchema[T any]() *jsonschema.Schema { + definitions := map[string]reflect.Type{ + "ruleset": reflect.TypeFor[RulesetOutput](), + "rule": reflect.TypeFor[GovernanceRuleOutput](), + "ruleSuite": reflect.TypeFor[RuleSuiteOutput](), + "nameCondition": reflect.TypeFor[RulesetNameConditionOutput](), + "idCondition": reflect.TypeFor[RulesetIDConditionOutput](), + "propertyCondition": reflect.TypeFor[RulesetPropertyConditionOutput](), + } + options := &jsonschema.ForOptions{ + TypeSchemas: map[reflect.Type]*jsonschema.Schema{ + reflect.TypeFor[CustomPropertyOutputValue](): customPropertyValueSchema().Properties["value"], + reflect.TypeFor[json.RawMessage](): { + Type: "object", + Description: "User-configured parameters for this rule type.", + }, + }, + } + for name, typ := range definitions { + if typ != reflect.TypeFor[T]() { + options.TypeSchemas[typ] = &jsonschema.Schema{Type: "object", Ref: "#/$defs/" + name} + } + } + schema, err := jsonschema.For[T](options) + if err != nil { + panic(err) + } + governanceStateSchema(schema, reflect.TypeFor[T]()) + schema.Defs = make(map[string]*jsonschema.Schema) + for { + added := false + encoded, err := json.Marshal(schema) + if err != nil { + panic(err) + } + for name, typ := range definitions { + if schema.Defs[name] != nil || !strings.Contains(string(encoded), `#/$defs/`+name) { + continue + } + definitionOptions := &jsonschema.ForOptions{TypeSchemas: maps.Clone(options.TypeSchemas)} + delete(definitionOptions.TypeSchemas, typ) + definition, err := jsonschema.ForType(typ, definitionOptions) + if err != nil { + panic(err) + } + governanceStateSchema(definition, typ) + schema.Defs[name] = definition + added = true + } + if !added { + break + } + } + return forbidOmittedNulls(schema) +} + +func governanceStateSchema(schema *jsonschema.Schema, typ reflect.Type) { + switch typ { + case reflect.TypeFor[RulesetOutput](): + schema.Properties["enforcement"].Enum = []any{"", "disabled", "active", "evaluate"} + schema.Properties["target"].Enum = []any{"branch", "tag", "push", "repository"} + schema.Properties["source_type"].Enum = []any{"Repository", "Organization", "Enterprise"} + for _, name := range []string{"created_at", "updated_at"} { + schema.Properties[name].Format = "date-time" + } + case reflect.TypeFor[RuleSuiteOutput](): + schema.Properties["result"].Enum = []any{"pass", "fail", "bypass"} + schema.Properties["pushed_at"].Format = "date-time" + } +} + +func labelOutputSchema() *jsonschema.Schema { return typedGovernanceSchema[MinimalLabel]() } +func listLabelsOutputSchema() *jsonschema.Schema { return typedGovernanceSchema[ListLabelsOutput]() } +func labelWriteOutputSchema() *jsonschema.Schema { + schema := typedGovernanceSchema[LabelWriteOutput]() + schema.Properties["method"].Enum = []any{"create", "update", "delete"} + return schema +} +func customPropertiesReadOutputSchema() *jsonschema.Schema { + schema := typedGovernanceSchema[CustomPropertiesOutput]() + schema.Properties["method"].Enum = []any{"read"} + schema.Properties["level"].Enum = []any{"repository", "organization", "enterprise"} + delete(schema.Properties, "message") + customPropertyOutputStates(schema) + return schema +} +func customPropertiesWriteOutputSchema() *jsonschema.Schema { + schema := typedGovernanceSchema[CustomPropertiesOutput]() + schema.Properties["method"].Enum = []any{"write"} + schema.Properties["level"].Enum = []any{"repository", "organization", "enterprise"} + delete(schema.Properties, "values") + customPropertyOutputStates(schema) + return schema +} + +func customPropertyOutputStates(schema *jsonschema.Schema) { + definition := schema.Properties["definitions"].Items + definition.Properties["value_type"].Enum = []any{"", "string", "single_select", "multi_select", "true_false", "url"} + definition.Properties["values_editable_by"].Enum = []any{"org_actors", "org_and_repo_actors"} +} +func rulesetReadOutputSchema() *jsonschema.Schema { + schema := typedGovernanceSchema[RulesetReadOutput]() + schema.Properties["method"].Enum = []any{"get", "list", "get_rules_for_branch", "list_rule_suites", "get_rule_suite"} + schema.Properties["level"].Enum = []any{"repository", "organization", "enterprise"} + return schema +} +func createdRulesetOutputSchema() *jsonschema.Schema { return typedGovernanceSchema[RulesetOutput]() } + +func decodeGovernanceJSON[T any](raw []byte, _ map[string]json.RawMessage) (T, error) { + var output T + err := json.Unmarshal(raw, &output) + return output, err +} + +func governanceArgument(args map[string]json.RawMessage, name string) string { + var value string + _ = json.Unmarshal(args[name], &value) + return value +} + +func decodeCustomProperties(method string) func([]byte, map[string]json.RawMessage) (CustomPropertiesOutput, error) { + return func(raw []byte, args map[string]json.RawMessage) (CustomPropertiesOutput, error) { + out := CustomPropertiesOutput{Method: method, Level: governanceArgument(args, "level")} + if out.Level == "repository" { + if method == "write" { + out.Message = string(raw) + return out, nil + } + values := []CustomPropertyValueOutput{} + err := json.Unmarshal(raw, &values) + if values == nil { + values = []CustomPropertyValueOutput{} + } + out.Values = new(values) + return out, err + } + definitions := []CustomPropertyDefinitionOutput{} + err := json.Unmarshal(raw, &definitions) + if definitions == nil { + definitions = []CustomPropertyDefinitionOutput{} + } + out.Definitions = new(definitions) + return out, err + } +} + +func decodeRulesetRead(raw []byte, args map[string]json.RawMessage) (RulesetReadOutput, error) { + out := RulesetReadOutput{ + Method: strings.ToLower(governanceArgument(args, "method")), + Level: governanceArgument(args, "level"), + } + var err error + switch out.Method { + case "get": + err = json.Unmarshal(raw, &out.Ruleset) + case "list": + if out.Level == "enterprise" { + err = json.Unmarshal(raw, &out) + if out.Rulesets == nil || *out.Rulesets == nil { + out.Rulesets = new([]RulesetOutput{}) + } + } else { + rulesets := []RulesetOutput{} + err = json.Unmarshal(raw, &rulesets) + if rulesets == nil { + rulesets = []RulesetOutput{} + } + out.Rulesets = new(rulesets) + } + case "get_rules_for_branch": + // go-github's legacy BranchRules text groups rules by Go field name. + var groups map[string][]GovernanceRuleOutput + err = json.Unmarshal(raw, &groups) + if err == nil { + rules := []GovernanceRuleOutput{} + for _, name := range governanceBranchRuleNames { + for _, rule := range groups[name.field] { + rule.Type = name.kind + rules = append(rules, rule) + } + } + out.Rules = new(rules) + } + case "list_rule_suites": + suites := []RuleSuiteOutput{} + err = json.Unmarshal(raw, &suites) + if suites == nil { + suites = []RuleSuiteOutput{} + } + out.RuleSuites = new(suites) + case "get_rule_suite": + err = json.Unmarshal(raw, &out.RuleSuite) + default: + err = fmt.Errorf("unexpected successful ruleset method %q", out.Method) + } + return out, err +} + +var governanceBranchRuleNames = []struct{ field, kind string }{ + {"Creation", "creation"}, {"Update", "update"}, {"Deletion", "deletion"}, + {"RequiredLinearHistory", "required_linear_history"}, {"MergeQueue", "merge_queue"}, + {"RequiredDeployments", "required_deployments"}, {"RequiredSignatures", "required_signatures"}, + {"PullRequest", "pull_request"}, {"RequiredStatusChecks", "required_status_checks"}, + {"NonFastForward", "non_fast_forward"}, {"CommitMessagePattern", "commit_message_pattern"}, + {"CommitAuthorEmailPattern", "commit_author_email_pattern"}, {"CommitterEmailPattern", "committer_email_pattern"}, + {"BranchNamePattern", "branch_name_pattern"}, {"TagNamePattern", "tag_name_pattern"}, + {"Workflows", "workflows"}, {"CodeScanning", "code_scanning"}, {"CopilotCodeReview", "copilot_code_review"}, + {"FileExtensionRestriction", "file_extension_restriction"}, {"FilePathRestriction", "file_path_restriction"}, + {"MaxFilePathLength", "max_file_path_length"}, {"MaxFileSize", "max_file_size"}, +} + +// governanceLevelRoutingFields maps each "level" value accepted by the +// level-routed governance tools (custom_properties_read/write, +// repository_ruleset_read, create_repository_ruleset) to the input field(s) +// the legacy handler reads only at that level. Fields unused at the call's +// level are legacy no-ops: the handler never looks at them, so a malformed +// (wrong-typed) unused field must not fail validation before level routing +// even runs. +var governanceLevelRoutingFields = map[string][]string{ + "repository": {"owner", "repo"}, + "organization": {"org"}, + "enterprise": {"enterprise"}, +} + +// normalizeGovernanceLevelRouting strips level-routing fields that are unused +// for the call's "level" before SDK schema validation runs, so an unused +// field's type (or presence) can never produce a validation error the legacy +// handler itself would never raise. It never touches used fields, and it +// leaves arguments untouched whenever "level" is missing, non-string, or not +// a recognized value, so the legacy handler's own "unknown level" error still +// fires exactly as before. +func normalizeGovernanceLevelRouting(raw json.RawMessage) (json.RawMessage, error) { + var args map[string]json.RawMessage + if err := json.Unmarshal(raw, &args); err != nil { + return raw, nil + } + levelRaw, ok := args["level"] + if !ok { + return raw, nil + } + var level string + if err := json.Unmarshal(levelRaw, &level); err != nil { + return raw, nil + } + used, known := governanceLevelRoutingFields[level] + if !known { + return raw, nil + } + keep := make(map[string]bool, len(used)) + for _, field := range used { + keep[field] = true + } + changed := false + for _, fields := range governanceLevelRoutingFields { + for _, field := range fields { + if keep[field] { + continue + } + if _, present := args[field]; present { + delete(args, field) + changed = true + } + } + } + if !changed { + return raw, nil + } + return json.Marshal(args) +} + +func typedGovernanceTool[Out any]( + legacy inventory.ServerTool, + outputSchema *jsonschema.Schema, + decode func([]byte, map[string]json.RawMessage) (Out, error), + normalizers ...inventory.InputNormalizer, +) inventory.ServerTool { + tool := legacy.Tool + tool.OutputSchema = outputSchema + // Legacy handlers validate only the fields consumed by the selected route. + // Keep discovery unchanged while preserving their accepted argument forms. + typed := NewToolWithSchemaOptions[map[string]json.RawMessage, *Out]( + legacy.Toolset, tool, legacy.ScopeAccess, + inventory.TypedSchemaOptions{ValidationInputSchema: &jsonschema.Schema{Type: "object"}}, + func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args map[string]json.RawMessage) (*mcp.CallToolResult, *Out, error) { + result, err := legacy.Handler(deps)(ctx, req) + if err != nil || result == nil || result.IsError { + return result, nil, err + } + if len(result.Content) != 1 { + return nil, nil, fmt.Errorf("expected one legacy %s text result", tool.Name) + } + content, ok := result.Content[0].(*mcp.TextContent) + if !ok { + return nil, nil, fmt.Errorf("expected legacy %s text content", tool.Name) + } + output, err := decode([]byte(content.Text), args) + if err != nil { + return nil, nil, fmt.Errorf("decode %s output: %w", tool.Name, err) + } + return result, new(output), nil + }, + normalizers..., + ) + typed.FeatureRule = legacy.FeatureRule + typed.Enabled = legacy.Enabled + typed.MinimumProtocolVersion = legacy.MinimumProtocolVersion + typed.RequiredElicitationMode = legacy.RequiredElicitationMode + return typed +} diff --git a/pkg/github/typed_governance_outputs_test.go b/pkg/github/typed_governance_outputs_test.go new file mode 100644 index 0000000000..463638bceb --- /dev/null +++ b/pkg/github/typed_governance_outputs_test.go @@ -0,0 +1,780 @@ +package github + +import ( + "context" + "encoding/json" + "io" + "net/http" + "testing" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type governanceWireCase struct { + tool inventory.ServerTool + args map[string]any + status int + body string + text string + output string + contains string + route func(*testing.T, *http.Request) string +} + +func governanceLegacyTool(name string) inventory.ServerTool { + tr := translations.NullTranslationHelper + switch name { + case "get_label": + return getLabelLegacy(tr) + case "list_label": + return listLabelsLegacy(tr) + case "label_write": + return labelWriteLegacy(tr) + case "custom_properties_read": + return customPropertiesReadLegacy(tr) + case "custom_properties_write": + return customPropertiesWriteLegacy(tr) + case "repository_ruleset_read": + return repositoryRulesetReadLegacy(tr) + case "create_repository_ruleset": + return createRepositoryRulesetLegacy(tr) + default: + panic("unexpected governance tool: " + name) + } +} + +func assertGovernanceWireText(t *testing.T, modern bool, legacyText string, result *mcp.CallToolResult) { + t.Helper() + actual := getTextResult(t, result).Text + if modern && !result.IsError { + require.NotNil(t, result.StructuredContent) + assert.JSONEq(t, mustMarshalJSON(t, result.StructuredContent), actual) + return + } + assert.Equal(t, legacyText, actual) +} + +func assertGovernanceProjection(t *testing.T, tc governanceWireCase, output any) { + t.Helper() + object, ok := output.(map[string]any) + require.True(t, ok, "modern output must have an object root") + if tc.output != "" { + assert.JSONEq(t, tc.output, mustMarshalJSON(t, output)) + } + switch tc.tool.Tool.Name { + case "get_label": + assert.JSONEq(t, `{"name":"bug","color":"ff0000","description":"desc"}`, mustMarshalJSON(t, output)) + case "list_label": + assert.JSONEq(t, `{"labels":[{"name":"bug","color":"ff0000","description":"desc"}],"totalCount":1}`, mustMarshalJSON(t, output)) + case "label_write": + assert.Equal(t, tc.args["method"], object["method"]) + assert.Equal(t, tc.text, object["message"]) + case "custom_properties_read", "custom_properties_write": + assert.Equal(t, tc.args["level"], object["level"]) + if tc.tool.Tool.Name == "custom_properties_write" { + assert.Equal(t, "write", object["method"]) + } else { + assert.Equal(t, "read", object["method"]) + } + switch { + case tc.args["level"] == "repository" && tc.tool.Tool.Name == "custom_properties_write": + assert.Equal(t, tc.text, object["message"]) + case tc.args["level"] == "repository": + assert.JSONEq(t, tc.text, mustMarshalJSON(t, object["values"])) + default: + var expected []map[string]any + require.NoError(t, json.Unmarshal([]byte(tc.text), &expected)) + for _, definition := range expected { + delete(definition, "custom_field") + } + assert.JSONEq(t, mustMarshalJSON(t, expected), mustMarshalJSON(t, object["definitions"])) + } + case "repository_ruleset_read": + assert.Equal(t, tc.args["level"], object["level"]) + assert.Equal(t, tc.args["method"], object["method"]) + switch tc.args["method"] { + case "get": + assert.Equal(t, float64(7), object["ruleset"].(map[string]any)["id"]) + case "list": + require.Len(t, object["rulesets"], 1) + if tc.args["level"] == "enterprise" { + assert.Equal(t, float64(1), object["total_count"]) + } + case "get_rules_for_branch": + assert.JSONEq(t, `[{"type":"creation","ruleset_id":0}]`, mustMarshalJSON(t, object["rules"])) + case "get_rule_suite": + assert.JSONEq(t, `{"id":11,"result":"pass"}`, mustMarshalJSON(t, object["rule_suite"])) + } + case "create_repository_ruleset": + assert.JSONEq(t, tc.text, mustMarshalJSON(t, output)) + } + for _, excluded := range []string{"url", "html_url", "_links", "node_id", "custom", "custom_field"} { + assert.NotContains(t, mustMarshalJSON(t, output), `"`+excluded+`":`) + } +} + +func governanceTypedSession(t *testing.T, tool inventory.ServerTool, deps ToolDependencies, protocol string) (*mcp.ClientSession, *jsonschema.Resolved) { + t.Helper() + server := mcp.NewServer(&mcp.Implementation{Name: "governance", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + tool.RegisterFunc(server, deps) + if protocol == "" || protocol == "unknown" { + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + switch request := req.(type) { + case *mcp.ListToolsRequest: + request.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: protocol} + case *mcp.CallToolRequest: + request.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: protocol} + } + return next(ctx, method, req) + } + }) + } + version := protocol + if version == "" || version == "unknown" { + version = inventory.ProtocolVersionMultiRoundTrip + } + session := connectCommentVisibilityClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Nil(t, list.Tools[0].OutputSchema) + return session, nil + } + require.NotNil(t, list.Tools[0].OutputSchema) + require.NoError(t, toolsnaps.Test(tool.Tool.Name, tool.Tool)) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, list.Tools[0].OutputSchema)), &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + return session, resolved +} + +func governanceWireDeps(t *testing.T, tc governanceWireCase) BaseDeps { + t.Helper() + var graphqlCalls int + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if tc.route != nil { + body := tc.route(t, r) + if body == "" { + w.WriteHeader(http.StatusNoContent) + return + } + _, _ = io.WriteString(w, body) + return + } + if r.URL.Path == "/repos/o/r" { + _, _ = io.WriteString(w, `{"private":false}`) + return + } + if r.URL.Path == "/graphql" { + graphqlCalls++ + var body string + switch tc.tool.Tool.Name { + case "get_label": + body = `{"data":{"repository":{"label":{"id":"L1","name":"bug","color":"ff0000","description":"desc"}}}}` + case "list_label": + body = `{"data":{"repository":{"labels":{"nodes":[{"id":"L1","name":"bug","color":"ff0000","description":"desc"}],"totalCount":1}}}}` + case "label_write": + switch tc.args["method"] { + case "create": + if graphqlCalls == 1 { + body = `{"data":{"repository":{"id":"R1"}}}` + } else { + body = `{"data":{"createLabel":{"label":{"id":"L2","name":"feature"}}}}` + } + case "update": + if graphqlCalls == 1 { + body = `{"data":{"repository":{"label":{"id":"L1","name":"bug"}}}}` + } else { + body = `{"data":{"updateLabel":{"label":{"id":"L1","name":"defect"}}}}` + } + case "delete": + if graphqlCalls == 1 { + body = `{"data":{"repository":{"label":{"id":"L1","name":"bug"}}}}` + } else { + body = `{"data":{"deleteLabel":{"clientMutationId":"mutation-1"}}}` + } + } + } + _, _ = io.WriteString(w, body) + return + } + w.WriteHeader(tc.status) + _, _ = io.WriteString(w, tc.body) + })}} + deps := BaseDeps{Client: mustNewGHClient(t, client)} + if tc.tool.Tool.Name == "get_label" || tc.tool.Tool.Name == "list_label" || tc.tool.Tool.Name == "label_write" { + deps.GQLClient = githubv4.NewEnterpriseClient("https://api.github.com/graphql", client) + } + return deps +} + +func governanceWireCases(t *testing.T) []governanceWireCase { + t.Helper() + rest := func(method, path, body string) func(*testing.T, *http.Request) string { + return func(t *testing.T, request *http.Request) string { + t.Helper() + assert.Equal(t, method, request.Method) + assert.Equal(t, path, request.URL.Path) + if request.Body != nil { + _, _ = io.Copy(io.Discard, request.Body) + } + return body + } + } + tr := translations.NullTranslationHelper + return []governanceWireCase{ + {tool: GetLabel(tr), args: map[string]any{"owner": "o", "repo": "r", "name": "bug"}, text: `{"id":"L1","name":"bug","color":"ff0000","description":"desc"}`}, + {tool: ListLabels(tr), args: map[string]any{"owner": "o", "repo": "r"}, text: `{"labels":[{"id":"L1","name":"bug","color":"ff0000","description":"desc"}],"totalCount":1}`}, + {tool: LabelWrite(tr), args: map[string]any{"method": "create", "owner": "o", "repo": "r", "name": "feature", "color": "abcdef"}, text: "label 'feature' created successfully"}, + {tool: LabelWrite(tr), args: map[string]any{"method": "update", "owner": "o", "repo": "r", "name": "bug", "new_name": "defect"}, text: "label 'defect' updated successfully"}, + {tool: LabelWrite(tr), args: map[string]any{"method": "delete", "owner": "o", "repo": "r", "name": "bug"}, text: "label 'bug' deleted successfully"}, + {tool: CustomPropertiesRead(tr), args: map[string]any{"level": "repository", "owner": "o", "repo": "r"}, status: http.StatusOK, body: `[{"property_name":"environment","value":"prod"}]`, text: `[{"property_name":"environment","value":"prod"}]`, route: rest(http.MethodGet, "/repos/o/r/properties/values", `[{"property_name":"environment","value":"prod"}]`)}, + {tool: CustomPropertiesRead(tr), args: map[string]any{"level": "organization", "org": "o"}, status: http.StatusOK, body: `[{"property_name":"environment","value_type":"string","custom_field":true}]`, text: `[{"custom_field":true,"property_name":"environment","value_type":"string"}]`, route: rest(http.MethodGet, "/orgs/o/properties/schema", `[{"property_name":"environment","value_type":"string","custom_field":true}]`)}, + {tool: CustomPropertiesRead(tr), args: map[string]any{"level": "enterprise", "enterprise": "e"}, status: http.StatusOK, body: `[{"property_name":"classification","value_type":"single_select"}]`, text: `[{"property_name":"classification","value_type":"single_select"}]`, route: rest(http.MethodGet, "/enterprises/e/properties/schema", `[{"property_name":"classification","value_type":"single_select"}]`)}, + {tool: CustomPropertiesWrite(tr), args: map[string]any{"level": "repository", "owner": "o", "repo": "r", "properties": []any{map[string]any{"property_name": "environment", "value": "prod"}}}, text: "Repository custom property values updated successfully", route: rest(http.MethodPatch, "/repos/o/r/properties/values", "")}, + {tool: CustomPropertiesWrite(tr), args: map[string]any{"level": "organization", "org": "o", "properties": []any{map[string]any{"property_name": "classification", "value_type": "string"}}}, text: `[{"property_name":"classification","value_type":"string"}]`, route: func(t *testing.T, request *http.Request) string { + t.Helper() + assert.Equal(t, "/orgs/o/properties/schema", request.URL.Path) + switch request.Method { + case http.MethodGet: + return `[{"property_name":"classification","value_type":"string"}]` + case http.MethodPatch: + var body map[string]json.RawMessage + require.NoError(t, json.NewDecoder(request.Body).Decode(&body)) + assert.Contains(t, string(body["properties"]), `"classification"`) + return `[{"property_name":"classification","value_type":"string"}]` + default: + t.Fatalf("unexpected request method %s", request.Method) + return "" + } + }}, + {tool: CustomPropertiesWrite(tr), args: map[string]any{"level": "enterprise", "enterprise": "e", "properties": []any{map[string]any{"property_name": "classification", "value_type": "single_select"}}}, text: `[{"property_name":"classification","value_type":"single_select"}]`, route: func(t *testing.T, request *http.Request) string { + t.Helper() + assert.Equal(t, "/enterprises/e/properties/schema", request.URL.Path) + switch request.Method { + case http.MethodGet: + return `[]` + case http.MethodPatch: + var body map[string]json.RawMessage + require.NoError(t, json.NewDecoder(request.Body).Decode(&body)) + assert.Contains(t, string(body["properties"]), `"classification"`) + return `[{"property_name":"classification","value_type":"single_select"}]` + default: + t.Fatalf("unexpected request method %s", request.Method) + return "" + } + }}, + {tool: CustomPropertiesWrite(tr), args: map[string]any{"level": "organization", "org": "o", "properties": []any{}}, text: `[]`, route: func(t *testing.T, request *http.Request) string { + t.Helper() + assert.Equal(t, "/orgs/o/properties/schema", request.URL.Path) + switch request.Method { + case http.MethodGet: + return `[]` + case http.MethodPatch: + var body map[string]json.RawMessage + require.NoError(t, json.NewDecoder(request.Body).Decode(&body)) + assert.JSONEq(t, `[]`, string(body["properties"])) + return `[]` + default: + t.Fatalf("unexpected request method %s", request.Method) + return "" + } + }}, + {tool: RepositoryRulesetRead(tr), args: map[string]any{"level": "repository", "method": "get", "owner": "o", "repo": "r", "ruleset_id": 7}, status: http.StatusOK, body: `{"id":7,"name":"main","target":"branch","source":"repo:o/r","enforcement":"active","rules":[]}`, text: `{"id":7,"name":"main","target":"branch","source":"repo:o/r","enforcement":"active","rules":[]}`, route: rest(http.MethodGet, "/repos/o/r/rulesets/7", `{"id":7,"name":"main","target":"branch","source":"repo:o/r","enforcement":"active","rules":[]}`)}, + {tool: RepositoryRulesetRead(tr), args: map[string]any{"level": "organization", "method": "get", "org": "o", "ruleset_id": 7}, status: http.StatusOK, body: `{"id":7,"name":"org-main","target":"branch","source":"org:o","enforcement":"active","rules":[]}`, text: `{"id":7,"name":"org-main","target":"branch","source":"org:o","enforcement":"active","rules":[]}`, output: `{"method":"get","level":"organization","ruleset":{"id":7,"name":"org-main","target":"branch","source":"org:o","enforcement":"active","rules":[]}}`, route: rest(http.MethodGet, "/orgs/o/rulesets/7", `{"id":7,"name":"org-main","target":"branch","source":"org:o","enforcement":"active","rules":[]}`)}, + {tool: RepositoryRulesetRead(tr), args: map[string]any{"level": "enterprise", "method": "get", "enterprise": "e", "ruleset_id": 7}, status: http.StatusOK, body: `{"id":7,"name":"enterprise-main","target":"branch","source":"enterprise:e","enforcement":"active","rules":[]}`, text: `{"id":7,"name":"enterprise-main","target":"branch","source":"enterprise:e","enforcement":"active","rules":[]}`, output: `{"method":"get","level":"enterprise","ruleset":{"id":7,"name":"enterprise-main","target":"branch","source":"enterprise:e","enforcement":"active","rules":[]}}`, route: rest(http.MethodGet, "/enterprises/e/rulesets/7", `{"id":7,"name":"enterprise-main","target":"branch","source":"enterprise:e","enforcement":"active","rules":[]}`)}, + {tool: RepositoryRulesetRead(tr), args: map[string]any{"level": "repository", "method": "list", "owner": "o", "repo": "r"}, status: http.StatusOK, body: `[{"id":7,"name":"main","target":"branch","source":"repo:o/r","enforcement":"active","rules":[]}]`, text: `[{"id":7,"name":"main","target":"branch","source":"repo:o/r","enforcement":"active","rules":[]}]`, route: rest(http.MethodGet, "/repos/o/r/rulesets", `[{"id":7,"name":"main","target":"branch","source":"repo:o/r","enforcement":"active","rules":[]}]`)}, + {tool: RepositoryRulesetRead(tr), args: map[string]any{"level": "repository", "method": "get_rules_for_branch", "owner": "o", "repo": "r", "branch": "main"}, status: http.StatusOK, body: `[{"type":"creation"}]`, contains: "Creation", route: rest(http.MethodGet, "/repos/o/r/rules/branches/main", `[{"type":"creation"}]`)}, + {tool: RepositoryRulesetRead(tr), args: map[string]any{"level": "organization", "method": "get_rule_suite", "org": "o", "rule_suite_id": 11}, status: http.StatusOK, body: `{"id":11,"result":"pass","custom":{"retained":true}}`, text: `{"custom":{"retained":true},"id":11,"result":"pass"}`, route: rest(http.MethodGet, "/orgs/o/rulesets/rule-suites/11", `{"id":11,"result":"pass","custom":{"retained":true}}`)}, + {tool: RepositoryRulesetRead(tr), args: map[string]any{"level": "organization", "method": "list", "org": "o"}, status: http.StatusOK, body: `[{"id":8,"name":"org rules"}]`, text: `[{"id":8,"name":"org rules","enforcement":"","source":""}]`, route: rest(http.MethodGet, "/orgs/o/rulesets", `[{"id":8,"name":"org rules"}]`)}, + {tool: RepositoryRulesetRead(tr), args: map[string]any{"level": "enterprise", "method": "list", "enterprise": "e"}, status: http.StatusOK, body: `{"total_count":1,"rulesets":[{"id":9,"name":"enterprise rules"}]}`, text: `{"rulesets":[{"id":9,"name":"enterprise rules","enforcement":"","source":""}],"total_count":1}`, route: rest(http.MethodGet, "/enterprises/e/rulesets", `{"total_count":1,"rulesets":[{"id":9,"name":"enterprise rules"}]}`)}, + {tool: CreateRepositoryRuleset(tr), args: map[string]any{"level": "repository", "owner": "o", "repo": "r", "name": "main", "enforcement": "active", "rules": []any{map[string]any{"type": "creation"}}}, status: http.StatusCreated, body: `{"id":8,"name":"main","target":"branch","source":"repo:o/r","enforcement":"active","rules":[{"type":"creation"}]}`, text: `{"id":8,"name":"main","target":"branch","source":"repo:o/r","enforcement":"active","rules":[{"type":"creation"}]}`, route: rest(http.MethodPost, "/repos/o/r/rulesets", `{"id":8,"name":"main","target":"branch","source":"repo:o/r","enforcement":"active","rules":[{"type":"creation"}]}`)}, + {tool: CreateRepositoryRuleset(tr), args: map[string]any{"level": "organization", "org": "o", "name": "org-main", "enforcement": "active", "rules": []any{map[string]any{"type": "creation"}}}, status: http.StatusCreated, body: `{"id":8,"name":"org-main","target":"branch","source":"org:o","enforcement":"active","rules":[{"type":"creation"}]}`, text: `{"id":8,"name":"org-main","target":"branch","source":"org:o","enforcement":"active","rules":[{"type":"creation"}]}`, output: `{"id":8,"name":"org-main","target":"branch","source":"org:o","enforcement":"active","rules":[{"type":"creation"}]}`, route: rest(http.MethodPost, "/orgs/o/rulesets", `{"id":8,"name":"org-main","target":"branch","source":"org:o","enforcement":"active","rules":[{"type":"creation"}]}`)}, + {tool: CreateRepositoryRuleset(tr), args: map[string]any{"level": "enterprise", "enterprise": "e", "name": "enterprise-main", "enforcement": "active", "rules": []any{map[string]any{"type": "creation"}}}, status: http.StatusCreated, body: `{"id":8,"name":"enterprise-main","target":"branch","source":"enterprise:e","enforcement":"active","rules":[{"type":"creation"}]}`, text: `{"id":8,"name":"enterprise-main","target":"branch","source":"enterprise:e","enforcement":"active","rules":[{"type":"creation"}]}`, output: `{"id":8,"name":"enterprise-main","target":"branch","source":"enterprise:e","enforcement":"active","rules":[{"type":"creation"}]}`, route: rest(http.MethodPost, "/enterprises/e/rulesets", `{"id":8,"name":"enterprise-main","target":"branch","source":"enterprise:e","enforcement":"active","rules":[{"type":"creation"}]}`)}, + } +} + +func TestTypedGovernanceWireOutputs(t *testing.T) { + protocols := []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", "", "unknown"} + for _, protocol := range protocols { + t.Run("protocol="+protocol, func(t *testing.T) { + for _, tc := range governanceWireCases(t) { + t.Run(tc.tool.Tool.Name+"/"+tc.text, func(t *testing.T) { + session, schema := governanceTypedSession(t, tc.tool, governanceWireDeps(t, tc), protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool.Tool.Name, Arguments: tc.args}) + require.NoError(t, err) + require.False(t, result.IsError, mustMarshalJSON(t, result)) + require.Len(t, result.Content, 1) + legacy := governanceLegacyTool(tc.tool.Tool.Name) + legacyDeps := governanceWireDeps(t, tc) + legacyResult, err := legacy.Handler(legacyDeps)(ContextWithDeps(context.Background(), legacyDeps), new(createMCPRequest(tc.args))) + require.NoError(t, err) + legacyText := getTextResult(t, legacyResult).Text + assertGovernanceWireText(t, schema != nil, legacyText, result) + switch { + case tc.contains != "": + assert.Contains(t, legacyText, tc.contains) + case json.Valid([]byte(tc.text)): + assert.JSONEq(t, tc.text, legacyText) + default: + assert.Equal(t, tc.text, legacyText) + } + if schema == nil { + assert.Nil(t, result.StructuredContent) + return + } + require.NotNil(t, result.StructuredContent) + var output any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, schema.Validate(output)) + assertGovernanceProjection(t, tc, output) + }) + } + }) + } +} + +func TestTypedGovernanceLegacyInputErrors(t *testing.T) { + cases := []struct { + tool inventory.ServerTool + args map[string]any + text string + }{ + {tool: GetLabel(translations.NullTranslationHelper), args: map[string]any{"repo": "r", "name": "bug"}, text: "missing required parameter: owner"}, + {tool: GetLabel(translations.NullTranslationHelper), args: map[string]any{"owner": false, "repo": "r", "name": "bug"}, text: "parameter owner is not of type string"}, + {tool: ListLabels(translations.NullTranslationHelper), args: map[string]any{"owner": "o"}, text: "missing required parameter: repo"}, + {tool: LabelWrite(translations.NullTranslationHelper), args: map[string]any{"method": false}, text: "parameter method is not of type string"}, + {tool: LabelWrite(translations.NullTranslationHelper), args: map[string]any{"method": false, "owner": 42}, text: "parameter method is not of type string"}, + {tool: RepositoryRulesetRead(translations.NullTranslationHelper), args: map[string]any{"level": "repository"}, text: "missing required parameter: method"}, + {tool: CustomPropertiesRead(translations.NullTranslationHelper), args: map[string]any{"level": false}, text: "parameter level is not of type string"}, + {tool: CreateRepositoryRuleset(translations.NullTranslationHelper), args: map[string]any{"level": "repository"}, text: "missing required parameter: name"}, + {tool: CreateRepositoryRuleset(translations.NullTranslationHelper), args: map[string]any{"level": "repository", "name": false, "owner": false}, text: "parameter name is not of type string"}, + } + for _, tc := range cases { + t.Run("legacy/"+tc.tool.Tool.Name+"/"+tc.text, func(t *testing.T) { + deps := BaseDeps{} + legacy := governanceLegacyTool(tc.tool.Tool.Name) + request := createMCPRequest(tc.args) + result, err := legacy.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Equal(t, tc.text, getTextResult(t, result).Text) + }) + } + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", "", "unknown"} { + t.Run("protocol="+protocol, func(t *testing.T) { + for _, tc := range cases { + t.Run(tc.tool.Tool.Name+"/"+tc.text, func(t *testing.T) { + session, _ := governanceTypedSession(t, tc.tool, BaseDeps{}, protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool.Tool.Name, Arguments: tc.args}) + require.NoError(t, err) + require.True(t, result.IsError) + require.NotEmpty(t, getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + }) + } + }) + } +} + +func TestNormalizeGovernanceLevelRouting(t *testing.T) { + cases := []struct { + name string + in string + out string + }{ + { + name: "organization level drops repository-only owner/repo", + in: `{"level":"organization","org":"o","owner":42,"repo":false}`, + out: `{"level":"organization","org":"o"}`, + }, + { + name: "enterprise level drops repository and organization fields", + in: `{"level":"enterprise","enterprise":"e","owner":42,"repo":false,"org":123}`, + out: `{"level":"enterprise","enterprise":"e"}`, + }, + { + name: "repository level keeps owner/repo and drops org/enterprise", + in: `{"level":"repository","owner":"o","repo":"r","org":42,"enterprise":false}`, + out: `{"level":"repository","owner":"o","repo":"r"}`, + }, + { + name: "repository level leaves a wrong-typed used field for the handler to reject", + in: `{"level":"repository","owner":42,"repo":"r"}`, + out: `{"level":"repository","owner":42,"repo":"r"}`, + }, + { + name: "no change when nothing unused is present", + in: `{"level":"organization","org":"o"}`, + out: `{"level":"organization","org":"o"}`, + }, + { + name: "missing level is passed through untouched", + in: `{"owner":42,"org":123}`, + out: `{"owner":42,"org":123}`, + }, + { + name: "non-string level is passed through untouched", + in: `{"level":false,"owner":42,"org":123}`, + out: `{"level":false,"owner":42,"org":123}`, + }, + { + name: "unrecognized level value is passed through untouched", + in: `{"level":"nonsense","owner":42,"org":123}`, + out: `{"level":"nonsense","owner":42,"org":123}`, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + out, err := normalizeGovernanceLevelRouting(json.RawMessage(tc.in)) + require.NoError(t, err) + assert.JSONEq(t, tc.out, string(out)) + }) + } +} + +func TestTypedGovernanceLevelRoutingNormalization(t *testing.T) { + tr := translations.NullTranslationHelper + rest := func(method, path, body string) func(*testing.T, *http.Request) string { + return func(t *testing.T, request *http.Request) string { + t.Helper() + assert.Equal(t, method, request.Method) + assert.Equal(t, path, request.URL.Path) + if request.Body != nil { + _, _ = io.Copy(io.Discard, request.Body) + } + return body + } + } + cases := []struct { + name string + tc governanceWireCase + }{ + { + name: "custom_properties_read organization ignores wrong-typed owner/repo", + tc: governanceWireCase{ + tool: CustomPropertiesRead(tr), + args: map[string]any{"level": "organization", "org": "o", "owner": 42, "repo": false}, + status: http.StatusOK, + body: `[{"property_name":"environment","value_type":"string"}]`, + text: `[{"property_name":"environment","value_type":"string"}]`, + route: rest(http.MethodGet, "/orgs/o/properties/schema", `[{"property_name":"environment","value_type":"string"}]`), + }, + }, + { + name: "custom_properties_read enterprise ignores wrong-typed owner/repo/org", + tc: governanceWireCase{ + tool: CustomPropertiesRead(tr), + args: map[string]any{"level": "enterprise", "enterprise": "e", "owner": 42, "repo": false, "org": 7}, + status: http.StatusOK, + body: `[{"property_name":"classification","value_type":"single_select"}]`, + text: `[{"property_name":"classification","value_type":"single_select"}]`, + route: rest(http.MethodGet, "/enterprises/e/properties/schema", `[{"property_name":"classification","value_type":"single_select"}]`), + }, + }, + { + name: "repository_ruleset_read organization ignores wrong-typed owner/repo", + tc: governanceWireCase{ + tool: RepositoryRulesetRead(tr), + args: map[string]any{"level": "organization", "method": "list", "org": "o", "owner": 42, "repo": false}, + status: http.StatusOK, + body: `[{"id":8,"name":"org rules"}]`, + text: `[{"id":8,"name":"org rules","enforcement":"","source":""}]`, + route: rest(http.MethodGet, "/orgs/o/rulesets", `[{"id":8,"name":"org rules"}]`), + }, + }, + { + name: "create_repository_ruleset organization ignores wrong-typed owner/repo", + tc: governanceWireCase{ + tool: CreateRepositoryRuleset(tr), + args: map[string]any{ + "level": "organization", "org": "o", "owner": 42, "repo": false, + "name": "main", "enforcement": "active", "rules": []any{map[string]any{"type": "creation"}}, + }, + status: http.StatusCreated, + body: `{"id":8,"name":"main","source":"org:o","enforcement":"active","rules":[{"type":"creation"}]}`, + text: `{"id":8,"name":"main","source":"org:o","enforcement":"active","rules":[{"type":"creation"}]}`, + route: rest(http.MethodPost, "/orgs/o/rulesets", `{"id":8,"name":"main","source":"org:o","enforcement":"active","rules":[{"type":"creation"}]}`), + }, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + session, _ := governanceTypedSession(t, tc.tc.tool, governanceWireDeps(t, tc.tc), inventory.ProtocolVersionMultiRoundTrip) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tc.tool.Tool.Name, Arguments: tc.tc.args}) + require.NoError(t, err) + require.False(t, result.IsError, mustMarshalJSON(t, result)) + + legacy := governanceLegacyTool(tc.tc.tool.Tool.Name) + legacyDeps := governanceWireDeps(t, tc.tc) + legacyResult, err := legacy.Handler(legacyDeps)(ContextWithDeps(context.Background(), legacyDeps), new(createMCPRequest(tc.tc.args))) + require.NoError(t, err) + require.False(t, legacyResult.IsError, mustMarshalJSON(t, legacyResult)) + assert.JSONEq(t, getTextResult(t, legacyResult).Text, tc.tc.text) + }) + } +} + +func TestTypedGovernanceLabelRetainsIFCMetadata(t *testing.T) { + for _, tc := range governanceWireCases(t)[:2] { + t.Run(tc.tool.Tool.Name, func(t *testing.T) { + deps := governanceWireDeps(t, tc) + deps.featureChecker = featureCheckerFor(FeatureFlagIFCLabels) + session, _ := governanceTypedSession(t, tc.tool, deps, inventory.ProtocolVersionMultiRoundTrip) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool.Tool.Name, Arguments: tc.args}) + require.NoError(t, err) + require.False(t, result.IsError) + legacyDeps := governanceWireDeps(t, tc) + legacyDeps.featureChecker = featureCheckerFor(FeatureFlagIFCLabels) + legacy := governanceLegacyTool(tc.tool.Tool.Name) + before, err := legacy.Handler(legacyDeps)(ContextWithDeps(context.Background(), legacyDeps), new(createMCPRequest(tc.args))) + require.NoError(t, err) + require.NotNil(t, result.Meta["ifc"]) + assert.JSONEq(t, mustMarshalJSON(t, before.Meta["ifc"]), mustMarshalJSON(t, result.Meta["ifc"])) + assertGovernanceWireText(t, true, getTextResult(t, before).Text, result) + }) + } +} + +func TestTypedGetLabelToolsetVariant(t *testing.T) { + issuesTool := GetLabel(translations.NullTranslationHelper) + labelsTool := GetLabelForLabelsToolset(translations.NullTranslationHelper) + assert.Equal(t, ToolsetMetadataIssues.ID, issuesTool.Toolset.ID) + assert.Equal(t, ToolsetLabels.ID, labelsTool.Toolset.ID) + assert.Equal(t, issuesTool.Tool.Name, labelsTool.Tool.Name) + require.NotNil(t, issuesTool.Tool.OutputSchema) + require.NotNil(t, labelsTool.Tool.OutputSchema) + assert.JSONEq(t, mustMarshalJSON(t, issuesTool.Tool.OutputSchema), mustMarshalJSON(t, labelsTool.Tool.OutputSchema)) +} + +func TestTypedGovernanceOutputSchemasRejectMismatches(t *testing.T) { + for _, tc := range []struct { + name string + schema *jsonschema.Schema + bad string + }{ + {"get label", labelOutputSchema(), `{"name":1,"color":"red","description":"desc"}`}, + {"list labels", listLabelsOutputSchema(), `{"labels":[],"totalCount":"1"}`}, + {"label write", labelWriteOutputSchema(), `{"message":1}`}, + {"custom properties read", customPropertiesReadOutputSchema(), `"not an array"`}, + {"property name", customPropertiesReadOutputSchema(), `{"method":"read","level":"repository","values":[{"property_name":123,"value":"prod"}]}`}, + {"property value", customPropertiesReadOutputSchema(), `{"method":"read","level":"repository","values":[{"property_name":"environment","value":false}]}`}, + {"property definition", customPropertiesReadOutputSchema(), `{"method":"read","level":"organization","definitions":[{"property_name":"environment","value_type":false}]}`}, + {"written definition", customPropertiesWriteOutputSchema(), `{"method":"write","level":"organization","definitions":[{"property_name":123,"value_type":"string"}]}`}, + {"custom properties write", customPropertiesWriteOutputSchema(), `1`}, + {"ruleset read", rulesetReadOutputSchema(), `true`}, + {"ruleset fields", rulesetReadOutputSchema(), `{"method":"get","level":"repository","ruleset":{"id":"invalid","rules":false}}`}, + {"ruleset list", rulesetReadOutputSchema(), `{"method":"list","level":"repository","rulesets":[{"id":"invalid","name":"main","source":"","enforcement":"active"}]}`}, + {"enterprise count", rulesetReadOutputSchema(), `{"method":"list","level":"enterprise","total_count":"invalid","rulesets":[]}`}, + {"suite fields", rulesetReadOutputSchema(), `{"method":"get_rule_suite","level":"repository","rule_suite":{"id":"invalid","result":"pass"}}`}, + {"branch fields", rulesetReadOutputSchema(), `{"method":"get_rules_for_branch","level":"repository","rules":[{"type":false}]}`}, + {"create ruleset", createdRulesetOutputSchema(), `{"rules":false}`}, + } { + t.Run(tc.name, func(t *testing.T) { + resolved, err := tc.schema.Resolve(nil) + require.NoError(t, err) + var value any + require.NoError(t, json.Unmarshal([]byte(tc.bad), &value)) + assert.Error(t, resolved.Validate(value)) + }) + } +} + +func TestTypedGovernanceEmptyNullAndValueUnions(t *testing.T) { + tr := translations.NullTranslationHelper + cases := []struct { + tool inventory.ServerTool + args map[string]any + body string + field string + expected string + }{ + {CustomPropertiesRead(tr), map[string]any{"level": "repository", "owner": "o", "repo": "r"}, "[]", "values", "[]"}, + {CustomPropertiesRead(tr), map[string]any{"level": "repository", "owner": "o", "repo": "r"}, "null", "values", "[]"}, + {CustomPropertiesRead(tr), map[string]any{"level": "repository", "owner": "o", "repo": "r"}, `[{"property_name":"cleared","value":null},{"property_name":"multi","value":["a","b"]}]`, "values", `[{"property_name":"cleared","value":null},{"property_name":"multi","value":["a","b"]}]`}, + {CustomPropertiesRead(tr), map[string]any{"level": "organization", "org": "o", "owner": false}, "[]", "definitions", "[]"}, + {CustomPropertiesRead(tr), map[string]any{"level": "enterprise", "enterprise": "e"}, "null", "definitions", "[]"}, + {CustomPropertiesRead(tr), map[string]any{"level": "organization", "org": "o"}, `[{"property_name":"classification","value_type":"multi_select","default_value":null,"allowed_values":[]}]`, "definitions", `[{"property_name":"classification","value_type":"multi_select","default_value":null,"allowed_values":[]}]`}, + {CustomPropertiesWrite(tr), map[string]any{"level": "organization", "org": "o", "properties": []any{}}, "[]", "definitions", "[]"}, + {CustomPropertiesWrite(tr), map[string]any{"level": "enterprise", "enterprise": "e", "properties": []any{}}, "null", "definitions", "[]"}, + {CustomPropertiesWrite(tr), map[string]any{"level": "enterprise", "enterprise": "e", "properties": []any{map[string]any{"property_name": "classification", "value_type": "string"}}}, `[{"property_name":"classification","value_type":"string","default_value":null,"allowed_values":[]}]`, "definitions", `[{"property_name":"classification","value_type":"string","default_value":null,"allowed_values":[]}]`}, + {RepositoryRulesetRead(tr), map[string]any{"level": "repository", "method": "LIST", "owner": "o", "repo": "r"}, "[]", "rulesets", "[]"}, + {RepositoryRulesetRead(tr), map[string]any{"level": "organization", "method": "list", "org": "o"}, "null", "rulesets", "[]"}, + {RepositoryRulesetRead(tr), map[string]any{"level": "enterprise", "method": "list", "enterprise": "e"}, `{"total_count":0,"rulesets":null}`, "rulesets", "[]"}, + {RepositoryRulesetRead(tr), map[string]any{"level": "repository", "method": "list_rule_suites", "owner": "o", "repo": "r"}, "[]", "rule_suites", "[]"}, + {RepositoryRulesetRead(tr), map[string]any{"level": "organization", "method": "list_rule_suites", "org": "o"}, "null", "rule_suites", "[]"}, + {RepositoryRulesetRead(tr), map[string]any{"level": "organization", "method": "get_rule_suite", "org": "o", "rule_suite_id": 11}, "null", "", ""}, + } + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", "", "unknown"} { + for _, tc := range cases { + t.Run(protocol+"/"+tc.tool.Tool.Name+"/"+tc.body, func(t *testing.T) { + wire := governanceWireCase{tool: tc.tool, args: tc.args, route: func(_ *testing.T, _ *http.Request) string { return tc.body }} + session, schema := governanceTypedSession(t, tc.tool, governanceWireDeps(t, wire), protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool.Tool.Name, Arguments: tc.args}) + require.NoError(t, err) + require.False(t, result.IsError) + legacyDeps := governanceWireDeps(t, wire) + legacy := governanceLegacyTool(tc.tool.Tool.Name) + before, err := legacy.Handler(legacyDeps)(ContextWithDeps(context.Background(), legacyDeps), new(createMCPRequest(tc.args))) + require.NoError(t, err) + assertGovernanceWireText(t, schema != nil, getTextResult(t, before).Text, result) + if schema == nil { + assert.Nil(t, result.StructuredContent) + return + } + var output map[string]any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, schema.Validate(output)) + if tc.field != "" { + assert.JSONEq(t, tc.expected, mustMarshalJSON(t, output[tc.field])) + } + }) + } + } +} + +func TestTypedGovernanceAPIErrors(t *testing.T) { + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25", "", "unknown"} { + for _, tc := range governanceWireCases(t) { + t.Run(protocol+"/"+tc.tool.Tool.Name, func(t *testing.T) { + tc.status = http.StatusForbidden + tc.body = `{"message":"Forbidden"}` + tc.route = nil + if tc.tool.Tool.Name == "get_label" || tc.tool.Tool.Name == "list_label" || tc.tool.Tool.Name == "label_write" { + tc.route = func(_ *testing.T, _ *http.Request) string { return `{"errors":[{"message":"Forbidden"}]}` } + } + session, _ := governanceTypedSession(t, tc.tool, governanceWireDeps(t, tc), protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool.Tool.Name, Arguments: tc.args}) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Nil(t, result.StructuredContent) + legacyDeps := governanceWireDeps(t, tc) + legacy := governanceLegacyTool(tc.tool.Tool.Name) + before, err := legacy.Handler(legacyDeps)(ContextWithDeps(context.Background(), legacyDeps), new(createMCPRequest(tc.args))) + require.NoError(t, err) + assert.Equal(t, getTextResult(t, before).Text, getTextResult(t, result).Text) + }) + } + } +} + +func TestTypedGovernanceCompactContracts(t *testing.T) { + for _, tool := range []inventory.ServerTool{ + GetLabel(translations.NullTranslationHelper), GetLabelForLabelsToolset(translations.NullTranslationHelper), + ListLabels(translations.NullTranslationHelper), LabelWrite(translations.NullTranslationHelper), + CustomPropertiesRead(translations.NullTranslationHelper), CustomPropertiesWrite(translations.NullTranslationHelper), + RepositoryRulesetRead(translations.NullTranslationHelper), CreateRepositoryRuleset(translations.NullTranslationHelper), + } { + t.Run(tool.Tool.Name, func(t *testing.T) { + schema := tool.Tool.OutputSchema.(*jsonschema.Schema) + assert.Equal(t, "object", schema.Type) + assert.Empty(t, schema.AnyOf) + assert.Empty(t, schema.OneOf) + if schema.Properties["method"] != nil { + assert.Contains(t, schema.Required, "method") + assert.NotEmpty(t, schema.Properties["method"].Enum) + } + _, err := schema.Resolve(nil) + require.NoError(t, err) + }) + } + legacyJSON := `{"id":7,"name":"main","source":"o/r","enforcement":"active","node_id":"NODE","_links":{"self":{"href":"https://api.github.com/repos/o/r/rulesets/7"}},"rules":[{"type":"commit_message_pattern","parameters":{"operator":"regex","pattern":"^ship","custom":{"nested":[1,true,null]}}}],"conditions":{"ref_name":{"include":["refs/heads/main"],"exclude":[]}}}` + output, err := decodeGovernanceJSON[RulesetOutput]([]byte(legacyJSON), nil) + require.NoError(t, err) + encoded := mustMarshalJSON(t, output) + assert.NotContains(t, encoded, "https://") + assert.NotContains(t, encoded, "node_id") + assert.Contains(t, encoded, `"custom":{"nested":[1,true,null]}`) + assert.Less(t, len(encoded), len(legacyJSON)) + resolved, err := createdRulesetOutputSchema().Resolve(nil) + require.NoError(t, err) + var value any + require.NoError(t, json.Unmarshal([]byte(encoded), &value)) + require.NoError(t, resolved.Validate(value)) + t.Logf("representative ruleset payload: legacy=%d bytes, modern=%d bytes", len(legacyJSON), len(encoded)) +} + +func TestTypedGovernancePropertyValueContracts(t *testing.T) { + for _, raw := range []string{`"prod"`, `["prod","staging"]`, `[]`, `null`} { + t.Run(raw, func(t *testing.T) { + var value CustomPropertyOutputValue + require.NoError(t, json.Unmarshal([]byte(raw), &value)) + assert.JSONEq(t, raw, mustMarshalJSON(t, value)) + }) + } + for _, raw := range []string{`false`, `42`, `{}`, `["prod",42]`} { + t.Run("invalid/"+raw, func(t *testing.T) { + var value CustomPropertyOutputValue + require.Error(t, json.Unmarshal([]byte(raw), &value)) + }) + } + for _, raw := range []string{ + `{"property_name":"classification","value_type":"multi_select","default_value":null,"allowed_values":[]}`, + `{"property_name":"classification","value_type":"multi_select","default_value":["a"],"allowed_values":["a"]}`, + `{"property_name":"environment","value_type":"string","required":false,"require_explicit_values":false}`, + } { + t.Run(raw, func(t *testing.T) { + var definition CustomPropertyDefinitionOutput + require.NoError(t, json.Unmarshal([]byte(raw), &definition)) + assert.JSONEq(t, raw, mustMarshalJSON(t, definition)) + schema, err := customPropertiesReadOutputSchema().Resolve(nil) + require.NoError(t, err) + var value any + require.NoError(t, json.Unmarshal([]byte(`{"method":"read","level":"organization","definitions":[`+mustMarshalJSON(t, definition)+`]}`), &value)) + require.NoError(t, schema.Validate(value)) + }) + } +} + +func TestTypedGovernanceNullableProjectionContracts(t *testing.T) { + for _, tc := range []struct { + method string + level string + raw string + expected string + }{ + {"get", "repository", "null", `{"method":"get","level":"repository"}`}, + {"list", "repository", "null", `{"method":"list","level":"repository","rulesets":[]}`}, + {"list", "enterprise", `{"total_count":0,"rulesets":null}`, `{"method":"list","level":"enterprise","total_count":0,"rulesets":[]}`}, + {"get_rules_for_branch", "repository", "null", `{"method":"get_rules_for_branch","level":"repository","rules":[]}`}, + {"list_rule_suites", "organization", "null", `{"method":"list_rule_suites","level":"organization","rule_suites":[]}`}, + {"get_rule_suite", "organization", "null", `{"method":"get_rule_suite","level":"organization"}`}, + } { + t.Run(tc.method+"/"+tc.level, func(t *testing.T) { + args := map[string]json.RawMessage{"method": json.RawMessage(`"` + tc.method + `"`), "level": json.RawMessage(`"` + tc.level + `"`)} + output, err := decodeRulesetRead([]byte(tc.raw), args) + require.NoError(t, err) + assert.JSONEq(t, tc.expected, mustMarshalJSON(t, output)) + resolved, err := rulesetReadOutputSchema().Resolve(nil) + require.NoError(t, err) + var value any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, output)), &value)) + require.NoError(t, resolved.Validate(value)) + }) + } + for _, level := range []string{"repository", "organization", "enterprise"} { + for _, raw := range []string{"null", "[]"} { + t.Run("properties/"+level+"/"+raw, func(t *testing.T) { + output, err := decodeCustomProperties("read")([]byte(raw), map[string]json.RawMessage{"level": json.RawMessage(`"` + level + `"`)}) + require.NoError(t, err) + expected := `{"method":"read","level":"` + level + `","definitions":[]}` + if level == "repository" { + expected = `{"method":"read","level":"repository","values":[]}` + } + assert.JSONEq(t, expected, mustMarshalJSON(t, output)) + }) + } + } +} diff --git a/pkg/github/typed_granular_pull_request_outputs_test.go b/pkg/github/typed_granular_pull_request_outputs_test.go index 465e7618df..bf2a4d86f0 100644 --- a/pkg/github/typed_granular_pull_request_outputs_test.go +++ b/pkg/github/typed_granular_pull_request_outputs_test.go @@ -436,6 +436,48 @@ func TestGranularPullRequestAdvertisedInputConstraints(t *testing.T) { } } +func TestTypedInputDescriptionsMatchMain(t *testing.T) { + descriptions := map[string]map[string]string{ + "add_pull_request_review_comment": { + "side": "The side of the diff to comment on (optional)", + "startSide": "The start side of a multi-line comment (optional)", + "subjectType": "The subject type of the comment", + }, + "add_pull_request_review_comment_reaction": { + "content": "The emoji reaction type", + }, + "create_pull_request_review": { + "event": "The review action to perform. If omitted, creates a pending review.", + }, + "submit_pending_pull_request_review": { + "event": "The review action to perform", + }, + "update_pull_request_state": { + "state": "The new state for the pull request", + }, + "find_duplicate": { + "page": "Page number for pagination (min 1)", + "perPage": "Results per page for pagination (min 1, max 100)", + }, + } + tools := append(granularPullRequestTools(), FindDuplicate(translations.NullTranslationHelper)) + for _, tool := range tools { + for field, description := range descriptions[tool.Tool.Name] { + schema, ok := tool.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, tool.Tool.Name) + property := schema.Properties[field] + require.NotNil(t, property, tool.Tool.Name+"/"+field) + assert.Equal(t, description, property.Description, tool.Tool.Name+"/"+field) + } + } + + findDuplicate, ok := FindDuplicate(translations.NullTranslationHelper).Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok) + assert.Equal(t, 1.0, *findDuplicate.Properties["page"].Minimum) + assert.Equal(t, 1.0, *findDuplicate.Properties["perPage"].Minimum) + assert.Equal(t, 100.0, *findDuplicate.Properties["perPage"].Maximum) +} + func TestGranularPullRequestReactionSchema(t *testing.T) { schema, err := minimalPullRequestCommentReactionSchema().Resolve(nil) require.NoError(t, err) diff --git a/pkg/github/typed_projects_outputs_test.go b/pkg/github/typed_projects_outputs_test.go new file mode 100644 index 0000000000..6a97e17380 --- /dev/null +++ b/pkg/github/typed_projects_outputs_test.go @@ -0,0 +1,702 @@ +package github + +import ( + "context" + "encoding/json" + "maps" + "net/http" + "testing" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func projectsTypedSession(t *testing.T, deps ToolDependencies, protocol string) (*mcp.ClientSession, map[string]*jsonschema.Resolved) { + t.Helper() + tr := translations.NullTranslationHelper + tools := []inventory.ServerTool{ProjectsList(tr), ProjectsGet(tr), ProjectsWrite(tr)} + inv, err := inventory.NewBuilder().SetTools(tools).WithToolsets([]string{"all"}).Build() + require.NoError(t, err) + server := mcp.NewServer(&mcp.Implementation{Name: "projects", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + inv.RegisterTools(context.Background(), server, deps) + if protocol == "" || protocol == "unknown" { + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + switch req := req.(type) { + case *mcp.ListToolsRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: protocol} + case *mcp.CallToolRequest: + req.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: protocol} + } + return next(ctx, method, req) + } + }) + } + version := protocol + if version == "" || version == "unknown" { + version = inventory.ProtocolVersionMultiRoundTrip + } + session := connectCommentVisibilityClient(t, server, version) + list, err := session.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 3) + schemas := make(map[string]*jsonschema.Resolved) + for _, tool := range list.Tools { + definition := map[string]inventory.ServerTool{ + "projects_list": tools[0], "projects_get": tools[1], "projects_write": tools[2], + }[tool.Name] + inventory.AnnotateHeaderParams(&definition.Tool) + assert.JSONEq(t, mustMarshalJSON(t, definition.Tool.InputSchema), mustMarshalJSON(t, tool.InputSchema)) + if protocol != inventory.ProtocolVersionMultiRoundTrip { + assert.Nil(t, tool.OutputSchema, tool.Name) + continue + } + require.NotNil(t, tool.OutputSchema, tool.Name) + assert.JSONEq(t, mustMarshalJSON(t, definition.Tool.OutputSchema), mustMarshalJSON(t, tool.OutputSchema)) + var schema jsonschema.Schema + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, tool.OutputSchema)), &schema)) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + schemas[tool.Name] = resolved + } + return session, schemas +} + +type projectWireStep struct { + method string + path string + query string + body string + status int + header http.Header +} + +type projectWireCase struct { + tool string + method string + args map[string]any + steps []projectWireStep + text string +} + +const ( + projectWireProject = `{"id":1,"node_id":"P","title":"Roadmap","public":true,"number":2}` + projectWireProjectText = `{"id":1,"node_id":"P","title":"Roadmap","description":"","public":true,"closed_at":"0001-01-01T00:00:00Z","created_at":"0001-01-01T00:00:00Z","updated_at":"0001-01-01T00:00:00Z","deleted_at":"0001-01-01T00:00:00Z","number":2,"short_description":""}` + projectWireField = `{"id":3,"node_id":"F","name":"Status","data_type":"SINGLE_SELECT","options":[{"id":"o","name":{"raw":"Ready","html":"Ready"},"color":"GREEN"}]}` + projectWireFieldText = `{"id":3,"node_id":"F","name":"Status","data_type":"SINGLE_SELECT","options":[{"id":"o","color":"GREEN","name":{"html":"Ready","raw":"Ready"}}]}` + projectWireItem = `{"id":4,"node_id":"I","content_type":"Issue","fields":[{"id":3,"name":"Custom","data_type":"CUSTOM","value":{"custom":{"a":7},"flag":true}}]}` + projectWireItemText = `{"id":4,"node_id":"I","content_type":"Issue","fields":[{"id":3,"name":"Custom","data_type":"CUSTOM","value":{"custom":{"a":7},"flag":true}}]}` + projectWireView = `{"id":"V","number":1,"name":"Board","layout":"BOARD_LAYOUT","filter":null,"configuration":{"visibleFields":{"nodes":[]}}}` + projectWireViewText = `{"id":"V","number":1,"name":"Board","layout":"board","filter":"","visible_fields":[]}` + projectWireStatus = `{"id":"S","body":"Update","status":"ON_TRACK","createdAt":"2026-01-01T00:00:00Z","startDate":null,"targetDate":null,"creator":{"login":"octocat"}}` + projectWireStatusText = `{"id":"S","body":"Update","status":"ON_TRACK","created_at":"2026-01-01T00:00:00Z","creator":{"login":"octocat"}}` + projectWireResolve = `{"data":{"organization":{"projectV2":{"id":"P"}}}}` + projectWireParent = `{"data":{"node":{"id":"V","layout":"BOARD_LAYOUT","project":{"id":"P"}}}}` +) + +func projectWireCases() []projectWireCase { + rest := func(method, path, query, body string) projectWireStep { + return projectWireStep{method: method, path: path, query: query, body: body} + } + gql := func(body string) projectWireStep { + return rest("POST", "/graphql", "", body) + } + return []projectWireCase{ + {tool: "projects_list", method: projectsMethodListProjects, args: map[string]any{"perPage": "2e0", "after": "next", "query": "is:open", "project_number": false}, + steps: []projectWireStep{rest("GET", "/orgs/o/projectsV2", "after=next&per_page=2&q=is%3Aopen", "["+projectWireProject+"]")}, + text: `{"pageInfo":{"hasNextPage":false,"hasPreviousPage":false},"projects":[` + projectWireProjectText[:len(projectWireProjectText)-1] + `,"owner_type":"org"}]}`}, + {tool: "projects_list", method: projectsMethodListProjectFields, args: map[string]any{"perPage": 0}, + steps: []projectWireStep{rest("GET", "/orgs/o/projectsV2/2/fields", "per_page=50", "["+projectWireField+"]")}, + text: `{"fields":[` + projectWireFieldText + `],"pageInfo":{"hasNextPage":false,"hasPreviousPage":false}}`}, + {tool: "projects_list", method: projectsMethodListProjectItems, args: map[string]any{"fields": []any{"3", "5"}, "before": "prev"}, + steps: []projectWireStep{rest("GET", "/orgs/o/projectsV2/2/items", "before=prev&fields=3%2C5&per_page=50", "["+projectWireItem+"]")}, + text: `{"items":[` + projectWireItemText + `],"pageInfo":{"hasNextPage":false,"hasPreviousPage":false}}`}, + {tool: "projects_list", method: projectsMethodListProjectStatusUpdates, steps: []projectWireStep{gql(`{"data":{"organization":{"projectV2":{"public":true,"statusUpdates":{"nodes":[` + projectWireStatus + `],"pageInfo":{"hasNextPage":true,"hasPreviousPage":false,"endCursor":"next","startCursor":""}}}}}}`)}, + text: `{"pageInfo":{"hasNextPage":true,"hasPreviousPage":false,"nextCursor":"next","prevCursor":""},"statusUpdates":[` + projectWireStatusText + `]}`}, + {tool: "projects_list", method: projectsMethodListProjectViews, steps: []projectWireStep{gql(`{"data":{"organization":{"projectV2":{"id":"P","public":true,"views":{"nodes":[` + projectWireView + `],"pageInfo":{"hasNextPage":false,"hasPreviousPage":false,"endCursor":"","startCursor":""}}}}}}`)}, + text: `{"pageInfo":{"hasNextPage":false,"hasPreviousPage":false,"nextCursor":"","prevCursor":""},"views":[` + projectWireViewText + `]}`}, + {tool: "projects_get", method: projectsMethodGetProject, steps: []projectWireStep{rest("GET", "/orgs/o/projectsV2/2", "", projectWireProject)}, text: projectWireProjectText}, + {tool: "projects_get", method: projectsMethodGetProjectField, args: map[string]any{"field_id": "3e0"}, steps: []projectWireStep{rest("GET", "/orgs/o/projectsV2/2/fields/3", "", projectWireField)}, text: projectWireFieldText}, + {tool: "projects_get", method: projectsMethodGetProjectItem, args: map[string]any{"item_id": "4", "fields": []any{"3"}}, + steps: []projectWireStep{rest("GET", "/orgs/o/projectsV2/2/items/4", "fields=3", projectWireItem)}, text: projectWireItemText}, + {tool: "projects_get", method: projectsMethodGetProjectStatusUpdate, args: map[string]any{"status_update_id": "S", "owner": false, "project_number": nil}, + steps: []projectWireStep{gql(`{"data":{"node":` + projectWireStatus[:len(projectWireStatus)-1] + `,"project":{"public":true}}}}`)}, text: projectWireStatusText}, + {tool: "projects_get", method: projectsMethodGetProjectView, args: map[string]any{"view_id": "V"}, + steps: []projectWireStep{gql(`{"data":{"node":` + projectWireView[:len(projectWireView)-1] + `,"project":{"public":true}}}}`)}, text: projectWireViewText}, + {tool: "projects_write", method: projectsMethodAddProjectItem, args: map[string]any{"item_type": "issue", "item_owner": "o", "item_repo": "r", "issue_number": "7"}, + steps: []projectWireStep{gql(`{"data":{"repository":{"issue":{"id":"ISSUE"}}}}`), gql(projectWireResolve), gql(`{"data":{"addProjectV2ItemById":{"item":{"id":"I","fullDatabaseId":"4"}}}}`)}, + text: `{"full_database_id":"4","id":"I","item_id":4,"message":"Successfully added issue o/r#7 to project o/2"}`}, + {tool: "projects_write", method: projectsMethodDeleteProjectItem, args: map[string]any{"item_id": "4"}, + steps: []projectWireStep{rest("DELETE", "/orgs/o/projectsV2/2/items/4", "", "")}, text: "project item successfully deleted"}, + {tool: "projects_write", method: projectsMethodUpdateProjectItem, args: map[string]any{"item_id": "4", "updated_field": map[string]any{"id": 3, "value": nil}}, + steps: []projectWireStep{rest("PATCH", "/orgs/o/projectsV2/2/items/4", "", projectWireItem)}, text: projectWireItemText}, + {tool: "projects_write", method: projectsMethodUpdateProjectItem, args: map[string]any{"item_id": "4", "updated_field": map[string]any{"name": "Customer", "value": "Acme"}}, + steps: []projectWireStep{ + gql(`{"data":{"organization":{"projectV2":{"fields":{"nodes":[{"id":"F","databaseId":3,"name":"Customer","dataType":"TEXT"}],"pageInfo":{"hasNextPage":false}}}}}}`), + gql(`{"data":{"organization":{"projectV2":{"fields":{"nodes":[{"__typename":"ProjectV2Field","databaseId":3,"isIssueField":true,"issueField":{"id":"IF"}}],"pageInfo":{"hasNextPage":false}}}}}}`), + rest("GET", "/orgs/o/projectsV2/2/items/4", "", `{"id":4,"content_type":"Issue","content":{"node_id":"ISSUE"}}`), + gql(`{"data":{"setIssueFieldValue":{"issue":{"id":"ISSUE","url":"https://github.com/o/r/issues/7"}}}}`), + }, text: `{"id":"ISSUE","url":"https://github.com/o/r/issues/7"}`}, + {tool: "projects_write", method: projectsMethodUpdateProjectItems, args: map[string]any{"items": []any{map[string]any{"node_id": "I"}, map[string]any{"node_id": "I"}}, "updated_field": map[string]any{"name": "Notes", "value": "hello"}}, + steps: []projectWireStep{ + gql(projectWireResolve), + gql(`{"data":{"organization":{"projectV2":{"fields":{"nodes":[{"id":"F","databaseId":3,"name":"Notes","dataType":"TEXT"}],"pageInfo":{"hasNextPage":false}}}}}}`), + gql(`{"data":{"item0":{"projectV2Item":{"id":"I","fullDatabaseId":"4"}}}}`), + }, text: `{"failed":1,"results":[{"index":0,"status":"succeeded","item":{"node_id":"I","full_database_id":"4"},"ref":{"node_id":"I"}},{"index":1,"status":"failed","error":{"code":"duplicate_target","message":"items[1] targets the same project item as items[0]; each item may only be written once per call"},"ref":{"node_id":"I"}}],"succeeded":1,"total":2,"unknown":0}`}, + {tool: "projects_write", method: projectsMethodCreateProjectStatusUpdate, args: map[string]any{"body": "Update", "status": "ON_TRACK"}, + steps: []projectWireStep{gql(projectWireResolve), gql(`{"data":{"createProjectV2StatusUpdate":{"statusUpdate":` + projectWireStatus + `}}}`)}, text: projectWireStatusText}, + {tool: "projects_write", method: projectsMethodCreateProjectView, args: map[string]any{"name": "Board", "layout": "board", "filter": nil, "visible_fields": []any{}}, + steps: []projectWireStep{gql(projectWireResolve), gql(`{"data":{"createProjectV2View":{"projectV2View":` + projectWireView + `}}}`)}, text: projectWireViewText}, + {tool: "projects_write", method: projectsMethodUpdateProjectView, args: map[string]any{"view_id": "V", "filter": nil, "visible_fields": []any{}}, + steps: []projectWireStep{gql(projectWireResolve), gql(projectWireParent), gql(`{"data":{"updateProjectV2View":{"projectV2View":` + projectWireView + `}}}`)}, text: projectWireViewText}, + {tool: "projects_write", method: projectsMethodDeleteProjectView, args: map[string]any{"view_id": "V"}, + steps: []projectWireStep{gql(projectWireResolve), gql(projectWireParent), gql(`{"data":{"deleteProjectV2View":{"projectV2View":{"id":"V"}}}}`)}, text: `{"deleted_view_id":"V"}`}, + {tool: "projects_write", method: projectsMethodCreateProject, args: map[string]any{"title": "Roadmap", "project_number": false}, + steps: []projectWireStep{gql(`{"data":{"organization":{"id":"O"}}}`), gql(`{"data":{"createProjectV2":{"projectV2":{"id":"P","number":2,"title":"Roadmap","url":"https://github.com/orgs/o/projects/2"}}}}`)}, + text: `{"id":"P","number":2,"title":"Roadmap","url":"https://github.com/orgs/o/projects/2"}`}, + {tool: "projects_write", method: projectsMethodCreateIterationField, args: map[string]any{"field_name": "Sprint", "start_date": "2026-01-01", "iteration_duration": "7"}, + steps: []projectWireStep{gql(projectWireResolve), gql(`{"data":{"createProjectV2Field":{"projectV2Field":{"id":"F","name":"Sprint"}}}}`), gql(`{"data":{"updateProjectV2Field":{"projectV2Field":{"id":"F","name":"Sprint","configuration":{"iterations":[{"id":"ITER","title":"Week","startDate":"2026-01-01","duration":7}]}}}}}`)}, + text: `{"configuration":{"iterations":[{"duration":7,"id":"ITER","start_date":"2026-01-01","title":"Week"}]},"id":"F","name":"Sprint"}`}, + } + +} + +func TestTypedProjectsOutputSchemaContracts(t *testing.T) { + for _, tc := range []struct { + name string + schema *jsonschema.Schema + bad string + }{ + {"list", projectsListOutputSchema(), `{"method":"list_project_items","items":{"items":[{"id":"bad"}],"pageInfo":{"hasNextPage":false,"hasPreviousPage":false}}}`}, + {"get", projectsGetOutputSchema(), `{"method":"get_project_item","item":{"id":4,"fields":[{"name":5}]}}`}, + {"write", projectsWriteOutputSchema(), `{"method":"delete_project_view","deleted_view":{"deleted_view_id":123}}`}, + {"view", projectOutputSchema[MinimalProjectView](), `{"id":"V","number":1,"name":"Board","layout":"board","filter":"","visible_fields":null}`}, + {"optional", projectOutputSchema[MinimalProject](), `{"id":null}`}, + {"batch", projectOutputSchema[ProjectBatchOutput](), `{"total":1,"succeeded":1,"failed":0,"unknown":0,"results":[{"index":0,"status":"succeeded","item":{"node_id":false}}]}`}, + } { + t.Run(tc.name, func(t *testing.T) { + schema, err := tc.schema.Resolve(nil) + require.NoError(t, err) + var value any + require.NoError(t, json.Unmarshal([]byte(tc.bad), &value)) + assert.Error(t, schema.Validate(value)) + }) + } + for _, raw := range []string{`{"id":null,"message":"added"}`, `{"id":"I","message":"added","item_id":0}`, `{"id":"I","message":"added","full_database_id":"not-numeric"}`} { + out, err := decodeProjectsWriteOutput(projectsMethodAddProjectItem, []byte(raw)) + require.NoError(t, err) + assert.JSONEq(t, `{"method":"add_project_item","added":`+raw+`}`, mustMarshalJSON(t, out)) + } + raw := `{"total":2,"succeeded":1,"failed":1,"unknown":0,"results":[{"index":0,"status":"succeeded","item":{"node_id":"I"}},{"index":1,"status":"failed","ref":{"unexpected":[1,true,null]},"error":{"code":"ambiguous","message":"many","candidates":[{"id":"I","name":"A"}]}}]}` + out, err := decodeProjectsWriteOutput(projectsMethodUpdateProjectItems, []byte(raw)) + require.NoError(t, err) + assert.JSONEq(t, `{"method":"update_project_items","batch":`+raw+`}`, mustMarshalJSON(t, out)) + schema, err := projectsWriteOutputSchema().Resolve(nil) + require.NoError(t, err) + var value any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, out)), &value)) + require.NoError(t, schema.Validate(value)) +} + +func projectWireDeps(t *testing.T, tc projectWireCase) BaseDeps { + t.Helper() + index := 0 + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Less(t, index, len(tc.steps), "unexpected request %s %s", r.Method, r.URL) + step := tc.steps[index] + index++ + assert.Equal(t, step.method, r.Method) + assert.Equal(t, step.path, r.URL.Path) + assert.Equal(t, step.query, r.URL.RawQuery) + for name, values := range step.header { + for _, value := range values { + w.Header().Add(name, value) + } + } + if step.status != 0 { + w.WriteHeader(step.status) + } + if step.method == "DELETE" { + if step.status == 0 { + w.WriteHeader(http.StatusNoContent) + } + } + _, _ = w.Write([]byte(step.body)) + })}} + t.Cleanup(func() { assert.Equal(t, len(tc.steps), index, tc.method) }) + return BaseDeps{Client: mustNewGHClient(t, client), GQLClient: githubv4.NewEnterpriseClient("https://api.github.com/graphql", client)} +} + +func projectWireArgs(tc projectWireCase) map[string]any { + args := map[string]any{"method": tc.method, "owner": "o", "owner_type": "org", "project_number": "2e0"} + maps.Copy(args, tc.args) + return args +} + +func TestTypedProjectsWireOutputs(t *testing.T) { + for _, protocol := range typedGitGistProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + for _, tc := range projectWireCases() { + t.Run(tc.method, func(t *testing.T) { + session, schemas := projectsTypedSession(t, projectWireDeps(t, tc), protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool, Arguments: projectWireArgs(tc)}) + require.NoError(t, err) + require.False(t, result.IsError, mustMarshalJSON(t, result)) + require.Len(t, result.Content, 1) + if schema := schemas[tc.tool]; schema != nil { + var output any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, schema.Validate(output)) + assert.JSONEq(t, projectWireStructured(t, tc), mustMarshalJSON(t, output)) + assert.Equal(t, mustMarshalJSON(t, output), getTextResult(t, result).Text) + } else { + assert.Equal(t, tc.text, getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + } + }) + } + }) + } +} + +func projectWireStructured(t *testing.T, tc projectWireCase) string { + t.Helper() + fields := map[string]string{ + projectsMethodListProjects: "projects", projectsMethodListProjectFields: "fields", + projectsMethodListProjectItems: "items", projectsMethodListProjectStatusUpdates: "status_updates", + projectsMethodListProjectViews: "views", projectsMethodGetProject: "project", + projectsMethodGetProjectField: "field", projectsMethodGetProjectItem: "item", + projectsMethodGetProjectStatusUpdate: "status_update", projectsMethodGetProjectView: "view", + projectsMethodAddProjectItem: "added", projectsMethodUpdateProjectItem: "item", + projectsMethodUpdateProjectItems: "batch", projectsMethodDeleteProjectItem: "deleted_item", + projectsMethodCreateProjectStatusUpdate: "status_update", projectsMethodCreateProjectView: "view", + projectsMethodUpdateProjectView: "view", projectsMethodDeleteProjectView: "deleted_view", + projectsMethodCreateProject: "project", projectsMethodCreateIterationField: "iteration_field", + } + var payload map[string]any + if tc.method == projectsMethodDeleteProjectItem { + payload = map[string]any{"message": tc.text} + } else { + require.NoError(t, json.Unmarshal([]byte(tc.text), &payload)) + } + if url, ok := payload["url"]; ok { + payload["html_url"] = url + delete(payload, "url") + if tc.method == projectsMethodUpdateProjectItem { + fields[tc.method] = "issue_fields" + } + } + return mustMarshalJSON(t, map[string]any{"method": tc.method, fields[tc.method]: payload}) +} + +func TestTypedProjectsObjectRoots(t *testing.T) { + for name, schema := range map[string]*jsonschema.Schema{ + "projects_list": projectsListOutputSchema(), + "projects_get": projectsGetOutputSchema(), + "projects_write": projectsWriteOutputSchema(), + } { + t.Run(name, func(t *testing.T) { + assert.Equal(t, "object", schema.Type) + assert.Empty(t, schema.AnyOf) + assert.Contains(t, schema.Required, "method") + assert.NotEmpty(t, schema.Properties["method"].Enum) + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + assert.Error(t, resolved.Validate(nil)) + assert.Error(t, resolved.Validate([]any{})) + assert.Error(t, resolved.Validate(map[string]any{})) + assert.Error(t, resolved.Validate(map[string]any{"method": "unknown"})) + require.NoError(t, toolsnaps.Test(name, map[string]inventory.ServerTool{ + "projects_list": ProjectsList(translations.NullTranslationHelper), + "projects_get": ProjectsGet(translations.NullTranslationHelper), + "projects_write": ProjectsWrite(translations.NullTranslationHelper), + }[name].Tool)) + }) + } +} + +func TestTypedProjectsFieldProjection(t *testing.T) { + raw := `{"id":3,"node_id":"F","name":"Status","data_type":"SINGLE_SELECT","project_url":"https://api.github.com/orgs/o/projectsV2/2","options":[{"id":"o","name":{"raw":"Ready","html":"Ready"},"color":"GREEN"}],"configuration":{"duration":7,"start_day":1,"iterations":[{"id":"iter","title":{"raw":"Sprint","html":"Sprint"},"duration":7,"start_date":"2026-01-01"}]}}` + out, err := decodeProjectsGetOutput(projectsMethodGetProjectField, []byte(raw)) + require.NoError(t, err) + assert.NotContains(t, mustMarshalJSON(t, out), "project_url") + assert.Equal(t, "Ready", *out.Field.Options[0].Name.Raw) + assert.Equal(t, "Sprint", *out.Field.Configuration.Iterations[0].Title.Raw) + schema, err := projectsGetOutputSchema().Resolve(nil) + require.NoError(t, err) + var value any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, out)), &value)) + require.NoError(t, schema.Validate(value)) +} + +func TestTypedProjectsEmptyOutputs(t *testing.T) { + _, err := decodeProjectsListOutput(projectsMethodListProjectItems, nil) + require.Error(t, err, "empty legacy JSON must not produce a fabricated success") + for _, tc := range []struct { + name string + text string + }{ + {"empty", `{"items":[],"pageInfo":{"hasNextPage":false,"hasPreviousPage":false}}`}, + {"null_items", `{"items":null,"pageInfo":{"hasNextPage":false,"hasPreviousPage":false}}`}, + {"null_payload", `null`}, + } { + t.Run(tc.name, func(t *testing.T) { + handler := projectsTypedHandler[ProjectsListInput]( + func(context.Context, ToolDependencies, *mcp.CallToolRequest, map[string]any) (*mcp.CallToolResult, any, error) { + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: tc.text}}}, nil, nil + }, decodeProjectsListOutput) + result, out, err := handler(context.Background(), BaseDeps{}, nil, ProjectsListInput{ + Method: ProjectParameter[string]{Value: projectsMethodListProjectItems}, + }) + require.NoError(t, err) + assert.Equal(t, tc.text, getTextResult(t, result).Text) + schema, err := projectsListOutputSchema().Resolve(nil) + require.NoError(t, err) + var value map[string]any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, out)), &value)) + require.NoError(t, schema.Validate(value)) + assert.Equal(t, projectsMethodListProjectItems, value["method"]) + if tc.name == "null_payload" { + assert.NotContains(t, value, "items", "a null legacy payload must not become a fake populated result") + } + }) + } + get, err := decodeProjectsGetOutput(projectsMethodGetProject, []byte("null")) + require.NoError(t, err) + assert.JSONEq(t, `{"method":"get_project"}`, mustMarshalJSON(t, get)) + get, err = decodeProjectsGetOutput(projectsMethodGetProjectItem, []byte(`{"id":4}`)) + require.NoError(t, err) + assert.JSONEq(t, `{"method":"get_project_item","item":{"id":4}}`, mustMarshalJSON(t, get)) + getSchema, err := projectsGetOutputSchema().Resolve(nil) + require.NoError(t, err) + var getValue any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, get)), &getValue)) + require.NoError(t, getSchema.Validate(getValue), "optional fields array may be absent") + for _, method := range []string{projectsMethodCreateProject, projectsMethodUpdateProjectItem, projectsMethodAddProjectItem} { + out, err := decodeProjectsWriteOutput(method, []byte("null")) + require.NoError(t, err) + assert.JSONEq(t, `{"method":"`+method+`"}`, mustMarshalJSON(t, out)) + } +} + +func TestTypedProjectsRESTListPageShapes(t *testing.T) { + nextLink := http.Header{"Link": {`; rel="next"`}} + cases := []struct { + name string + tc projectWireCase + }{ + { + name: "projects_empty_final_page", + tc: projectWireCase{ + tool: "projects_list", method: projectsMethodListProjects, + steps: []projectWireStep{{method: "GET", path: "/orgs/o/projectsV2", query: "per_page=2", body: `[]`}}, + text: `{"pageInfo":{"hasNextPage":false,"hasPreviousPage":false},"projects":[]}`, + }, + }, + { + name: "fields_empty_final_page", + tc: projectWireCase{ + tool: "projects_list", method: projectsMethodListProjectFields, + steps: []projectWireStep{{method: "GET", path: "/orgs/o/projectsV2/2/fields", query: "per_page=2", body: `[]`}}, + text: `{"fields":[],"pageInfo":{"hasNextPage":false,"hasPreviousPage":false}}`, + }, + }, + { + name: "items_empty_final_page", + tc: projectWireCase{ + tool: "projects_list", method: projectsMethodListProjectItems, + steps: []projectWireStep{{method: "GET", path: "/orgs/o/projectsV2/2/items", query: "per_page=2", body: `[]`}}, + text: `{"items":[],"pageInfo":{"hasNextPage":false,"hasPreviousPage":false}}`, + }, + }, + { + name: "status_updates_empty_final_page", + tc: projectWireCase{ + tool: "projects_list", method: projectsMethodListProjectStatusUpdates, + steps: []projectWireStep{{method: "POST", path: "/graphql", body: `{"data":{"organization":{"projectV2":{"public":true,"statusUpdates":{"nodes":[],"pageInfo":{"hasNextPage":false,"hasPreviousPage":false,"endCursor":"","startCursor":""}}}}}}`}}, + text: `{"pageInfo":{"hasNextPage":false,"hasPreviousPage":false,"nextCursor":"","prevCursor":""},"statusUpdates":[]}`, + }, + }, + { + name: "views_empty_final_page", + tc: projectWireCase{ + tool: "projects_list", method: projectsMethodListProjectViews, + steps: []projectWireStep{{method: "POST", path: "/graphql", body: `{"data":{"organization":{"projectV2":{"id":"P","public":true,"views":{"nodes":[],"pageInfo":{"hasNextPage":false,"hasPreviousPage":false,"endCursor":"","startCursor":""}}}}}}`}}, + text: `{"pageInfo":{"hasNextPage":false,"hasPreviousPage":false,"nextCursor":"","prevCursor":""},"views":[]}`, + }, + }, + { + name: "projects_empty_next_page", + tc: projectWireCase{ + tool: "projects_list", method: projectsMethodListProjects, + steps: []projectWireStep{{method: "GET", path: "/orgs/o/projectsV2", query: "per_page=2", body: `[]`, header: nextLink}}, + text: `{"pageInfo":{"hasNextPage":true,"hasPreviousPage":false,"nextCursor":"next"},"projects":[]}`, + }, + }, + { + name: "fields_empty_next_page", + tc: projectWireCase{ + tool: "projects_list", method: projectsMethodListProjectFields, + steps: []projectWireStep{{ + method: "GET", path: "/orgs/o/projectsV2/2/fields", query: "per_page=2", body: `[]`, + header: http.Header{"Link": []string{`; rel="next"`}}, + }}, + text: `{"fields":[],"pageInfo":{"hasNextPage":true,"hasPreviousPage":false,"nextCursor":"next"}}`, + }, + }, + { + name: "items_empty_next_page", + tc: projectWireCase{ + tool: "projects_list", method: projectsMethodListProjectItems, + steps: []projectWireStep{{ + method: "GET", path: "/orgs/o/projectsV2/2/items", query: "per_page=2", body: `[]`, + header: http.Header{"Link": []string{`; rel="next"`}}, + }}, + text: `{"items":[],"pageInfo":{"hasNextPage":true,"hasPreviousPage":false,"nextCursor":"next"}}`, + }, + }, + } + + for _, protocol := range typedGitGistProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + tc.tc.args = map[string]any{"perPage": 2} + session, schemas := projectsTypedSession(t, projectWireDeps(t, tc.tc), protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: tc.tc.tool, Arguments: projectWireArgs(tc.tc), + }) + require.NoError(t, err) + require.False(t, result.IsError, mustMarshalJSON(t, result)) + if schema := schemas[tc.tc.tool]; schema != nil { + var output any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, schema.Validate(output)) + assert.JSONEq(t, projectWireStructured(t, tc.tc), mustMarshalJSON(t, output)) + assert.Equal(t, mustMarshalJSON(t, output), getTextResult(t, result).Text) + } else { + assert.Equal(t, tc.tc.text, getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + } + }) + } + }) + } +} + +func TestTypedProjectsSequentialPagination(t *testing.T) { + for _, protocol := range typedGitGistProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + tc := projectWireCase{ + tool: "projects_list", method: projectsMethodListProjectItems, + args: map[string]any{"perPage": 2}, + steps: []projectWireStep{ + {method: "GET", path: "/orgs/o/projectsV2/2/items", query: "per_page=2", body: `[]`, + header: http.Header{"Link": {`; rel="next"`}}}, + {method: "GET", path: "/orgs/o/projectsV2/2/items", query: "after=next&per_page=2", body: `[]`}, + }, + } + session, schemas := projectsTypedSession(t, projectWireDeps(t, tc), protocol) + args := projectWireArgs(tc) + for page, text := range []string{ + `{"items":[],"pageInfo":{"hasNextPage":true,"hasPreviousPage":false,"nextCursor":"next"}}`, + `{"items":[],"pageInfo":{"hasNextPage":false,"hasPreviousPage":false}}`, + } { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool, Arguments: args}) + require.NoError(t, err) + require.False(t, result.IsError) + var payload struct { + PageInfo pageInfo `json:"pageInfo"` + } + if schema := schemas[tc.tool]; schema != nil { + var output map[string]any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, schema.Validate(output)) + tc.text = text + assert.JSONEq(t, projectWireStructured(t, tc), mustMarshalJSON(t, output)) + assert.Equal(t, mustMarshalJSON(t, output), getTextResult(t, result).Text) + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, output["items"])), &payload)) + } else { + assert.Equal(t, text, getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &payload)) + } + assert.Equal(t, page == 0, payload.PageInfo.HasNextPage) + if page == 0 { + require.Equal(t, "next", payload.PageInfo.NextCursor) + args["after"] = payload.PageInfo.NextCursor + } else { + assert.Empty(t, payload.PageInfo.NextCursor) + } + } + }) + } +} + +func TestTypedProjectsWriteBackendErrors(t *testing.T) { + cases := []struct { + tc projectWireCase + message string + }{{ + tc: projectWireCase{ + tool: "projects_write", method: projectsMethodUpdateProjectItem, + args: map[string]any{"item_id": "4", "updated_field": map[string]any{"id": 3, "value": "Updated"}}, + steps: []projectWireStep{{ + method: "PATCH", path: "/orgs/o/projectsV2/2/items/4", + body: `{"message":"server error"}`, status: http.StatusInternalServerError, + }}, + }, message: ProjectUpdateFailedError, + }, { + tc: projectWireCase{ + tool: "projects_write", method: projectsMethodDeleteProjectItem, + args: map[string]any{"item_id": 4}, + steps: []projectWireStep{{method: "DELETE", path: "/orgs/o/projectsV2/2/items/4", + status: http.StatusInternalServerError, body: `{"message":"server error"}`}}, + }, message: ProjectDeleteFailedError, + }, { + tc: projectWireCase{ + tool: "projects_write", method: projectsMethodCreateProjectStatusUpdate, + args: map[string]any{"body": "Update", "status": "ON_TRACK"}, + steps: []projectWireStep{ + {method: "POST", path: "/graphql", body: projectWireResolve}, + {method: "POST", path: "/graphql", body: `{"errors":[{"message":"mutation denied"}]}`}, + }, + }, message: ProjectStatusUpdateCreateFailedError, + }} + for _, protocol := range typedGitGistProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + for _, fixture := range cases { + t.Run(fixture.tc.method, func(t *testing.T) { + tc := fixture.tc + session, _ := projectsTypedSession(t, projectWireDeps(t, tc), protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: tc.tool, Arguments: projectWireArgs(tc), + }) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getTextResult(t, result).Text, fixture.message) + assert.Nil(t, result.StructuredContent) + }) + } + }) + } +} + +func TestTypedProjectsWireIFC(t *testing.T) { + for _, protocol := range typedGitGistProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + tc := projectWireCases()[5] + tc.steps[0].body = `{"id":1,"node_id":"P","title":"Roadmap","public":false,"number":2}` + tc.text = `{"id":1,"node_id":"P","title":"Roadmap","description":"","public":false,"closed_at":"0001-01-01T00:00:00Z","created_at":"0001-01-01T00:00:00Z","updated_at":"0001-01-01T00:00:00Z","deleted_at":"0001-01-01T00:00:00Z","number":2,"short_description":""}` + deps := projectWireDeps(t, tc) + deps.featureChecker = featureCheckerFor(FeatureFlagIFCLabels) + session, schemas := projectsTypedSession(t, deps, protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool, Arguments: projectWireArgs(tc)}) + require.NoError(t, err) + require.False(t, result.IsError) + label := unmarshalIFC(t, result.Meta["ifc"]) + assert.Equal(t, "trusted", label["integrity"]) + assert.Equal(t, "private", label["confidentiality"]) + if schemas[tc.tool] != nil { + assert.JSONEq(t, projectWireStructured(t, tc), mustMarshalJSON(t, result.StructuredContent)) + assert.Equal(t, mustMarshalJSON(t, result.StructuredContent), getTextResult(t, result).Text) + } else { + assert.Equal(t, tc.text, getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + } + }) + } +} + +func TestTypedProjectsWireErrors(t *testing.T) { + cases := []struct { + tool string + args map[string]any + text string + }{ + {"projects_list", map[string]any{}, "missing required parameter: method"}, + {"projects_list", map[string]any{"method": true}, "parameter method is not of type string"}, + {"projects_list", map[string]any{"method": "unknown", "owner": "o"}, "unknown method: unknown"}, + {"projects_list", map[string]any{"method": "list_projects", "owner": "o", "owner_type": nil}, "parameter owner_type is not of type string, is "}, + {"projects_get", map[string]any{"method": "get_project", "owner": "o", "owner_type": "org", "project_number": "1.5"}, "parameter project_number is not a valid number: non-integer numeric value: 1.5"}, + {"projects_get", map[string]any{"method": "get_project_view"}, "missing required parameter: view_id"}, + {"projects_get", map[string]any{"method": "get_project_item", "owner": "o", "owner_type": "org", "project_number": 2, "item_id": 4, "fields": []any{true}}, "parameter fields is not of type string, is bool"}, + {"projects_write", map[string]any{"method": "update_project_item", "owner": "o", "owner_type": "org", "project_number": 2, "item_id": 4, "updated_field": false}, "updated_field must be an object"}, + {"projects_write", map[string]any{"method": "create_project", "owner": "o"}, "owner_type is required for create_project"}, + {"projects_write", map[string]any{"method": "create_project_status_update", "owner": "o", "owner_type": "org", "project_number": 2, "status": "BAD"}, `invalid status "BAD": must be one of INACTIVE, ON_TRACK, AT_RISK, OFF_TRACK, COMPLETE`}, + {"projects_write", map[string]any{"method": "update_project_items", "owner": "o", "owner_type": "org", "project_number": 2, "items": []any{}}, "items must contain at least one entry"}, + {"projects_write", map[string]any{"method": "delete_project_item", "owner": "o", "owner_type": "org", "project_number": 2, "ITEM_ID": 4}, "missing required parameter: item_id"}, + } + for _, protocol := range typedGitGistProtocols { + t.Run("protocol="+protocol, func(t *testing.T) { + session, _ := projectsTypedSession(t, projectWireDeps(t, projectWireCase{}), protocol) + for _, tc := range cases { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool, Arguments: tc.args}) + require.NoError(t, err) + require.True(t, result.IsError, mustMarshalJSON(t, result)) + require.Len(t, result.Content, 1) + assert.Equal(t, tc.text, getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + } + }) + } +} + +func TestTypedProjectsInputRoundtrip(t *testing.T) { + for _, tc := range []struct { + name string + input any + want string + }{ + {"list", ProjectsListInput{Method: ProjectParameter[string]{Value: "list_projects"}, Owner: ProjectParameter[string]{Value: "o"}}, `{"method":"list_projects","owner":"o"}`}, + {"get", ProjectsGetInput{Method: ProjectParameter[string]{Value: "get_project_view"}, ViewID: ProjectParameter[string]{Value: "V"}}, `{"method":"get_project_view","view_id":"V"}`}, + {"issue_reference", ProjectsWriteInput{ + Method: ProjectParameter[string]{Value: "update_project_item"}, + ItemOwner: ProjectParameter[string]{Value: "o"}, ItemRepo: ProjectParameter[string]{Value: "r"}, + IssueNumber: ProjectParameter[int]{Value: 7}, + }, `{"method":"update_project_item","item_owner":"o","item_repo":"r","issue_number":7}`}, + {"view_name_only", ProjectsWriteInput{Method: ProjectParameter[string]{Value: "update_project_view"}, Name: ProjectParameter[string]{Value: "New name"}}, `{"method":"update_project_view","name":"New name"}`}, + } { + t.Run(tc.name, func(t *testing.T) { + assert.JSONEq(t, tc.want, mustMarshalJSON(t, tc.input)) + }) + } + for _, input := range []any{&ProjectsListInput{}, &ProjectsGetInput{}, &ProjectsWriteInput{}} { + raw := `{"method":"example","owner":"o","project_number":0}` + require.NoError(t, json.Unmarshal([]byte(raw), input)) + assert.JSONEq(t, raw, mustMarshalJSON(t, input)) + } + raw := `{"method":"update_project_view","filter":null,"layout":"","item_id":0}` + var input ProjectsWriteInput + require.NoError(t, json.Unmarshal([]byte(raw), &input)) + assert.JSONEq(t, raw, mustMarshalJSON(t, input)) + require.NoError(t, json.Unmarshal([]byte(`{"method":"delete_project_item","ITEM_ID":4,"ItemID":5}`), &input)) + args, err := projectArguments(input) + require.NoError(t, err) + assert.NotContains(t, args, "item_id") + assert.JSONEq(t, `{"method":"delete_project_item"}`, mustMarshalJSON(t, input)) + require.NoError(t, json.Unmarshal([]byte(`{"method":"delete_project_item","item_id":3,"ITEM_ID":4}`), &input)) + assert.JSONEq(t, `{"method":"delete_project_item","item_id":3}`, mustMarshalJSON(t, input)) +} + +func TestTypedProjectsCaseSensitiveMutationTarget(t *testing.T) { + deps := projectWireDeps(t, projectWireCase{}) + request := createMCPRequest(map[string]any{ + "method": "delete_project_item", "owner": "o", "owner_type": "org", + "project_number": 2, "ITEM_ID": 4, + }) + tool := ProjectsWrite(translations.NullTranslationHelper) + result, err := tool.Handler(deps)( + ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Equal(t, "missing required parameter: item_id", getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) +} diff --git a/pkg/github/ui_tools.go b/pkg/github/ui_tools.go index a1a4179858..ab6ba6ccb4 100644 --- a/pkg/github/ui_tools.go +++ b/pkg/github/ui_tools.go @@ -30,11 +30,12 @@ const uiGetMaxPages = 10 // UIGet creates a tool to fetch UI data for MCP Apps. func UIGet(t translations.TranslationHelperFunc) inventory.ServerTool { - st := NewTool( + st := NewTool[map[string]any, *UIGetOutput]( ToolsetMetadataContext, // Use context toolset so it's always available mcp.Tool{ - Name: "ui_get", - Description: t("TOOL_UI_GET_DESCRIPTION", "Fetch UI data for MCP Apps (labels, assignees, milestones, issue types, branches, issue fields, reviewers)."), + Name: "ui_get", + OutputSchema: uiGetOutputSchema(), + Description: t("TOOL_UI_GET_DESCRIPTION", "Fetch UI data for MCP Apps (labels, assignees, milestones, issue types, branches, issue fields, reviewers)."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_UI_GET_USER_TITLE", "Get UI data"), ReadOnlyHint: true, @@ -68,36 +69,40 @@ func UIGet(t translations.TranslationHelperFunc) inventory.ServerTool { }, }, uiGetScopeAccess(), - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, *UIGetOutput, error) { method, err := RequiredParam[string](args, "method") if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + return utils.NewToolResultError(err.Error()), &UIGetOutput{Method: "labels"}, nil } - owner, err := RequiredParam[string](args, "owner") if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil + return utils.NewToolResultError(err.Error()), &UIGetOutput{Method: uiGetSchemaMethod(method)}, nil } + var result *mcp.CallToolResult switch method { case "labels": - return uiGetLabels(ctx, deps, args, owner) + result, _, err = uiGetLabels(ctx, deps, args, owner) case "assignees": - return uiGetAssignees(ctx, deps, args, owner) + result, _, err = uiGetAssignees(ctx, deps, args, owner) case "milestones": - return uiGetMilestones(ctx, deps, args, owner) + result, _, err = uiGetMilestones(ctx, deps, args, owner) case "issue_types": - return uiGetIssueTypes(ctx, deps, owner) + result, _, err = uiGetIssueTypes(ctx, deps, owner) case "branches": - return uiGetBranches(ctx, deps, args, owner) + result, _, err = uiGetBranches(ctx, deps, args, owner) case "issue_fields": - return uiGetIssueFields(ctx, deps, args, owner) + result, _, err = uiGetIssueFields(ctx, deps, args, owner) case "reviewers": - return uiGetReviewers(ctx, deps, args, owner) + result, _, err = uiGetReviewers(ctx, deps, args, owner) default: - return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), &UIGetOutput{Method: "labels"}, nil } - }) + if err != nil { + return nil, nil, err + } + return uiGetTypedResult(method, result) + }, normalizeUIGetArguments) return st } diff --git a/pkg/inventory/server_tool.go b/pkg/inventory/server_tool.go index 3546c250b9..d633c97938 100644 --- a/pkg/inventory/server_tool.go +++ b/pkg/inventory/server_tool.go @@ -465,13 +465,19 @@ func NewServerToolWithContextHandlerAndSchemaOptions[In any, Out any]( preserveContent: schemaOptions.PreserveHandlerContent, } } - serverTool.registerTyped = func(server *mcp.Server, registration *typedToolRegistration, era ProtocolEra, middleware ...ToolHandlerMiddleware) { - switch era { - case ProtocolEraLegacy: - mcp.AddTool[In, any](server, registration.legacyRuntimeTool, wrapTypedHandler(handler, middleware...)) - default: - mcp.AddTool[In, any](server, registration.modernRuntimeTool, wrapTypedHandler(handler, middleware...)) + serverTool.registerTyped = func(server *mcp.Server, registration *typedToolRegistration, era ProtocolEra, _ ...ToolHandlerMiddleware) { + tool := *registration.runtimeTool(era) + inputSchema, err := cachedResolvedInputSchema(tool.InputSchema) + if err != nil { + panic(fmt.Sprintf("failed to resolve input schema for tool %q: %v", tool.Name, err)) } + // Input validation is deferred until guards have allowed the call. + // The advertised and validation schemas remain the original schemas. + tool.InputSchema, err = cachedObjectInputSchema() + if err != nil { + panic(fmt.Sprintf("failed to prepare input schema for tool %q: %v", tool.Name, err)) + } + mcp.AddTool[any, any](server, &tool, wrapTypedHandler(handler, inputSchema)) } } return serverTool diff --git a/pkg/inventory/typed_input_test.go b/pkg/inventory/typed_input_test.go new file mode 100644 index 0000000000..e0924f9b77 --- /dev/null +++ b/pkg/inventory/typed_input_test.go @@ -0,0 +1,96 @@ +package inventory + +import ( + "context" + "encoding/json" + "testing" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type typedValidationInput struct { + Mode string `json:"mode"` + Count int `json:"count"` +} + +func compareTypedInputWithSDK[In any](t *testing.T, advertised, validation *jsonschema.Schema) { + t.Helper() + type output struct { + Input In `json:"input"` + } + handler := func(_ context.Context, _ *mcp.CallToolRequest, input In) (*mcp.CallToolResult, output, error) { + return nil, output{Input: input}, nil + } + tool := NewServerToolWithContextHandlerAndSchemaOptions( + mcp.Tool{Name: "validation", InputSchema: advertised, OutputSchema: &jsonschema.Schema{Type: "object"}}, + testToolsetMetadata("test"), + handler, + TypedSchemaOptions{ValidationInputSchema: validation}, + ) + typedServer := mcp.NewServer(&mcp.Implementation{Name: "typed", Version: "test"}, nil) + tool.RegisterFunc(typedServer, nil) + typedSession := connectTypedTestClient(t, typedServer, "") + sdkServer := mcp.NewServer(&mcp.Implementation{Name: "sdk", Version: "test"}, nil) + mcp.AddTool(sdkServer, &mcp.Tool{ + Name: "validation", InputSchema: validation, OutputSchema: &jsonschema.Schema{Type: "object"}, + }, handler) + sdkSession := connectTypedTestClient(t, sdkServer, "") + for _, raw := range []string{ + `{"mode":"valid"}`, `{"mode":"valid","count":9}`, `{"mode":"valid","Count":9}`, + `{"mode":"invalid"}`, `{"mode":42}`, `{}`, `{"mode":"valid","count":"bad"}`, + `[]`, `"not an object"`, + } { + t.Run(raw, func(t *testing.T) { + params := &mcp.CallToolParams{Name: "validation", Arguments: json.RawMessage(raw)} + control, err := sdkSession.CallTool(context.Background(), params) + require.NoError(t, err) + result, err := typedSession.CallTool(context.Background(), params) + require.NoError(t, err) + assert.Equal(t, control.IsError, result.IsError) + assert.JSONEq(t, mustMarshalJSON(t, control.StructuredContent), mustMarshalJSON(t, result.StructuredContent)) + assert.Equal(t, control.Content, result.Content) + if !result.IsError { + assert.Contains(t, mustMarshalJSON(t, result.StructuredContent), `"count":`) + } + }) + } + list, err := typedSession.ListTools(context.Background(), nil) + require.NoError(t, err) + require.Len(t, list.Tools, 1) + assert.JSONEq(t, mustMarshalJSON(t, advertised), mustMarshalJSON(t, list.Tools[0].InputSchema)) +} + +func TestTypedInputValidationMatchesSDK(t *testing.T) { + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "mode": {Type: "string", Enum: []any{"valid"}}, + "count": {Type: "integer", Default: json.RawMessage(`2`)}, + }, + Required: []string{"mode"}, + } + t.Run("struct", func(t *testing.T) { compareTypedInputWithSDK[typedValidationInput](t, schema, schema) }) + t.Run("pointer", func(t *testing.T) { compareTypedInputWithSDK[*typedValidationInput](t, schema, schema) }) + t.Run("any", func(t *testing.T) { + compareTypedInputWithSDK[any](t, schema, schema) + }) + t.Run("runtime-schema", func(t *testing.T) { + runtime := CloneSchema(schema) + runtime.Properties["mode"].Enum = nil + runtime.Properties["count"].Default = json.RawMessage(`3`) + compareTypedInputWithSDK[typedValidationInput](t, schema, runtime) + }) +} + +func TestResolvedTypedInputSchemaCache(t *testing.T) { + schema := &jsonschema.Schema{Type: "object", Properties: map[string]*jsonschema.Schema{"value": {Type: "string"}}} + first, err := cachedResolvedInputSchema(schema) + require.NoError(t, err) + second, err := cachedResolvedInputSchema(CloneSchema(schema)) + require.NoError(t, err) + assert.Same(t, first, second) + assert.Equal(t, "string", schema.Properties["value"].Type) +} diff --git a/pkg/inventory/typed_output.go b/pkg/inventory/typed_output.go index 159dd262a9..854ee7a77e 100644 --- a/pkg/inventory/typed_output.go +++ b/pkg/inventory/typed_output.go @@ -11,6 +11,7 @@ import ( "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/modelcontextprotocol/go-sdk/mcp" + sdkjson "github.com/segmentio/encoding/json" ) type inputNormalizationContextKey struct{} @@ -29,6 +30,8 @@ type typedCallState struct { contentLengthBeforeSDK int preserveContent bool structuredOutput any + sdkCalled bool + shortCircuit *mcp.CallToolResult } type typedToolRegistration struct { @@ -45,8 +48,27 @@ type typedToolRegistration struct { handlerMiddleware []ToolHandlerMiddleware } -func wrapTypedHandler[In, Out any](handler mcp.ToolHandlerFor[In, Out], middleware ...ToolHandlerMiddleware) mcp.ToolHandlerFor[In, any] { - return func(ctx context.Context, req *mcp.CallToolRequest, input In) (*mcp.CallToolResult, any, error) { +func wrapTypedHandler[In, Out any](handler mcp.ToolHandlerFor[In, Out], inputSchema *jsonschema.Resolved) mcp.ToolHandlerFor[any, any] { + return func(ctx context.Context, req *mcp.CallToolRequest, value any) (*mcp.CallToolResult, any, error) { + if state, ok := ctx.Value(typedCallStateKey{}).(*typedCallState); ok && state.shortCircuit != nil { + return state.shortCircuit, nil, nil + } + if err := inputSchema.ApplyDefaults(&value); err != nil { + return typedInputErrorResult(fmt.Errorf("validating \"arguments\": applying schema defaults:\n%w", err)), nil, nil + } + if err := inputSchema.Validate(&value); err != nil { + return typedInputErrorResult(fmt.Errorf("validating \"arguments\": %w", err)), nil, nil + } + encoded, err := json.Marshal(value) + if err != nil { + return typedInputErrorResult(fmt.Errorf("validating \"arguments\": marshalling with defaults: %w", err)), nil, nil + } + var input In + decoder := sdkjson.NewDecoder(bytes.NewReader(encoded)) + decoder.DontMatchCaseInsensitiveStructFields() + if err := decoder.Decode(&input); err != nil { + return typedInputErrorResult(err), nil, nil + } var output any handlerCalled := false next := func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -57,8 +79,6 @@ func wrapTypedHandler[In, Out any](handler mcp.ToolHandlerFor[In, Out], middlewa } return result, err } - next = applyToolHandlerMiddleware(next, middleware...) - result, err := next(ctx, req) if err != nil { if rpcErr, ok := errors.AsType[*jsonrpc.Error](err); ok { @@ -81,6 +101,12 @@ func wrapTypedHandler[In, Out any](handler mcp.ToolHandlerFor[In, Out], middlewa } } +func typedInputErrorResult(err error) *mcp.CallToolResult { + result := &mcp.CallToolResult{} + result.SetError(err) + return result +} + // typedOutputMiddleware selects immutable advertised schema variants and // handles request-era compatibility without mutating registered tools. func typedOutputMiddleware(registrationSet *typedToolRegistrationSet) mcp.Middleware { @@ -196,6 +222,7 @@ func typedOutputMiddleware(registrationSet *typedToolRegistrationSet) mcp.Middle ctx = context.WithValue(ctx, inputNormalizationContextKey{}, req.Params.Name) } + state.sdkCalled = true result, err := next(ctx, method, request) if err != nil { return nil, err @@ -232,6 +259,24 @@ func typedOutputMiddleware(registrationSet *typedToolRegistrationSet) mcp.Middle if err != nil { return nil, err } + if registration.hasTypedOutput && !state.sdkCalled && callResult != nil { + // Guards and preflight run before decoding, but their results + // must still pass through the SDK's tool-call finalization. + state.shortCircuit = callResult + requestCopy := *req + paramsCopy := *req.Params + paramsCopy.Arguments = json.RawMessage(`{}`) + requestCopy.Params = ¶msCopy + result, err := next(ctx, method, &requestCopy) + if err != nil { + return nil, err + } + var ok bool + callResult, ok = result.(*mcp.CallToolResult) + if !ok { + return nil, fmt.Errorf("unexpected tools/call result type %T", result) + } + } era := requestEra(ctx, requestVersion) if registration.fixedEra != ProtocolEraDynamic { era = registration.fixedEra diff --git a/pkg/inventory/typed_schema.go b/pkg/inventory/typed_schema.go index 664db38f11..f473df7638 100644 --- a/pkg/inventory/typed_schema.go +++ b/pkg/inventory/typed_schema.go @@ -113,6 +113,41 @@ var ownedSchemaPointers sync.Map var annotatedSchemaCache sync.Map var defaultObjectInputSchemaEntry inferredSchemaEntry +type resolvedInputSchemaEntry struct { + once sync.Once + resolved *jsonschema.Resolved + err error +} + +var resolvedInputSchemaCache sync.Map + +func cachedResolvedInputSchema(value any) (*jsonschema.Resolved, error) { + schema, ok := value.(*jsonschema.Schema) + if !ok { + encoded, err := json.Marshal(value) + if err != nil { + return nil, err + } + schema = new(jsonschema.Schema) + if err := json.Unmarshal(encoded, schema); err != nil { + return nil, err + } + } + schema, err := CachedSchema(schema) + if err != nil { + return nil, err + } + if schema.Type != "object" { + return nil, fmt.Errorf("input schema must have type \"object\"") + } + entryValue, _ := resolvedInputSchemaCache.LoadOrStore(schema, &resolvedInputSchemaEntry{}) + entry := entryValue.(*resolvedInputSchemaEntry) + entry.once.Do(func() { + entry.resolved, entry.err = schema.Resolve(&jsonschema.ResolveOptions{ValidateDefaults: true}) + }) + return entry.resolved, entry.err +} + func cachedObjectInputSchema() (*jsonschema.Schema, error) { defaultObjectInputSchemaEntry.once.Do(func() { defaultObjectInputSchemaEntry.schema, defaultObjectInputSchemaEntry.err = CachedSchema(&jsonschema.Schema{Type: "object"}) diff --git a/ui/package.json b/ui/package.json index 9507f13600..039cd12b0c 100644 --- a/ui/package.json +++ b/ui/package.json @@ -11,6 +11,7 @@ "build": "node scripts/build.mjs", "dev": "npm run build", "typecheck": "tsc --noEmit", + "test": "node --experimental-strip-types --test src/lib/*.test.ts", "clean": "rm -rf dist" }, "dependencies": { diff --git a/ui/src/apps/issue-write/App.tsx b/ui/src/apps/issue-write/App.tsx index 7c14a29ffc..5cfb5d83a4 100644 --- a/ui/src/apps/issue-write/App.tsx +++ b/ui/src/apps/issue-write/App.tsx @@ -25,6 +25,7 @@ import { import { AppProvider } from "../../components/AppProvider"; import { useMcpApp } from "../../hooks/useMcpApp"; import { completedToolResult } from "../../lib/toolResult"; +import { parseUIGetResult } from "../../lib/uiGetResult"; import { MarkdownEditor } from "../../components/MarkdownEditor"; interface IssueResult { @@ -552,7 +553,7 @@ function CreateIssueApp() { (c: { type: string }) => c.type === "text" ); if (textContent && "text" in textContent) { - const data = JSON.parse(textContent.text as string); + const data = parseUIGetResult("labels", textContent.text as string); const labels = (data.labels || []).map( (l: { name: string; color: string; id: string }) => ({ id: l.id || l.name, @@ -579,7 +580,7 @@ function CreateIssueApp() { (c: { type: string }) => c.type === "text" ); if (textContent && "text" in textContent) { - const data = JSON.parse(textContent.text as string); + const data = parseUIGetResult("assignees", textContent.text as string); const assignees = (data.assignees || []).map( (a: { login: string }) => ({ id: a.login, @@ -605,7 +606,7 @@ function CreateIssueApp() { (c: { type: string }) => c.type === "text" ); if (textContent && "text" in textContent) { - const data = JSON.parse(textContent.text as string); + const data = parseUIGetResult("milestones", textContent.text as string); const milestones = (data.milestones || []).map( (m: { number: number; title: string; description: string }) => ({ id: String(m.number), @@ -633,10 +634,8 @@ function CreateIssueApp() { (c: { type: string }) => c.type === "text" ); if (textContent && "text" in textContent) { - const data = JSON.parse(textContent.text as string); - // ui_get returns array directly or wrapped in issue_types/types - const typesArray = Array.isArray(data) ? data : (data.issue_types || data.types || []); - const types = typesArray.map( + const typesArray = parseUIGetResult("issue_types", textContent.text as string); + const types = typesArray.filter(t => t !== null).map( (t: { id: number; name: string; description?: string } | string) => { if (typeof t === "string") { return { id: t, text: t }; @@ -663,7 +662,7 @@ function CreateIssueApp() { (c: { type: string }) => c.type === "text" ); if (textContent && "text" in textContent) { - const data = JSON.parse(textContent.text as string); + const data = parseUIGetResult("issue_fields", textContent.text as string); const fields = (data.fields || []) .map( (field: { diff --git a/ui/src/apps/pr-edit/App.tsx b/ui/src/apps/pr-edit/App.tsx index 791217f33a..59e4e5ca09 100644 --- a/ui/src/apps/pr-edit/App.tsx +++ b/ui/src/apps/pr-edit/App.tsx @@ -26,6 +26,7 @@ import { import { AppProvider } from "../../components/AppProvider"; import { useMcpApp } from "../../hooks/useMcpApp"; import { completedToolResult } from "../../lib/toolResult"; +import { parseUIGetResult } from "../../lib/uiGetResult"; import { MarkdownEditor } from "../../components/MarkdownEditor"; interface PRResult { @@ -392,8 +393,8 @@ function EditPRApp() { if (result && !result.isError && result.content) { const textContent = result.content.find((c: { type: string }) => c.type === "text"); if (textContent && "text" in textContent) { - const data = JSON.parse(textContent.text as string); - const branches = (data.branches || data || []).map( + const data = parseUIGetResult("branches", textContent.text as string); + const branches = (data.branches || []).map( (b: { name: string; protected?: boolean }) => ({ name: b.name, protected: b.protected || false }) ); setAvailableBranches(branches); @@ -416,7 +417,7 @@ function EditPRApp() { if (result && !result.isError && result.content) { const textContent = result.content.find((c: { type: string }) => c.type === "text"); if (textContent && "text" in textContent) { - const data = JSON.parse(textContent.text as string); + const data = parseUIGetResult("reviewers", textContent.text as string); const users = (data.users || []).map( (u: { login: string; avatar_url?: string }) => ({ kind: "user" as const, diff --git a/ui/src/apps/pr-write/App.tsx b/ui/src/apps/pr-write/App.tsx index a7014a544e..c800ae0cdc 100644 --- a/ui/src/apps/pr-write/App.tsx +++ b/ui/src/apps/pr-write/App.tsx @@ -28,6 +28,7 @@ import { import { AppProvider } from "../../components/AppProvider"; import { useMcpApp } from "../../hooks/useMcpApp"; import { completedToolResult } from "../../lib/toolResult"; +import { parseUIGetResult } from "../../lib/uiGetResult"; import { MarkdownEditor } from "../../components/MarkdownEditor"; interface PRResult { @@ -335,8 +336,8 @@ function CreatePRApp() { if (result && !result.isError && result.content) { const textContent = result.content.find((c: { type: string }) => c.type === "text"); if (textContent && "text" in textContent) { - const data = JSON.parse(textContent.text as string); - const branches = (data.branches || data || []).map( + const data = parseUIGetResult("branches", textContent.text as string); + const branches = (data.branches || []).map( (b: { name: string; protected?: boolean }) => ({ name: b.name, protected: b.protected || false }) ); setAvailableBranches(branches); @@ -363,7 +364,7 @@ function CreatePRApp() { if (result && !result.isError && result.content) { const textContent = result.content.find((c: { type: string }) => c.type === "text"); if (textContent && "text" in textContent) { - const data = JSON.parse(textContent.text as string); + const data = parseUIGetResult("reviewers", textContent.text as string); const users = (data.users || []).map( (u: { login: string; avatar_url?: string }) => ({ kind: "user" as const, diff --git a/ui/src/lib/uiGetResult.test.ts b/ui/src/lib/uiGetResult.test.ts new file mode 100644 index 0000000000..570b4d7e25 --- /dev/null +++ b/ui/src/lib/uiGetResult.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { parseUIGetResult } from "./uiGetResult.ts"; + +const fixtures = { + labels: { labels: [{ id: "L", name: "bug", color: "red" }] }, + assignees: { assignees: [{ login: "octocat", avatar_url: "avatar.png" }] }, + milestones: { milestones: [{ number: 1, title: "v1", description: "Release" }] }, + issue_types: [{ id: 1, name: "Bug" }, null], + branches: { branches: [{ name: "main", protected: true }] }, + issue_fields: { fields: [{ id: "F", name: "Priority", data_type: "text" }] }, + reviewers: { + users: [{ login: "octocat", avatar_url: "avatar.png" }], + teams: [{ slug: "docs", org: "owner" }], + }, +}; + +for (const method of Object.keys(fixtures) as Array) { + test(`${method}: modern and legacy payloads reach the same picker data`, () => { + const legacy = parseUIGetResult(method, JSON.stringify(fixtures[method])); + const modern = parseUIGetResult(method, JSON.stringify({ method, [method]: fixtures[method] })); + assert.deepEqual(modern, legacy); + assert.deepEqual(modern, fixtures[method]); + }); +} + +test("picker arrays and reviewer avatars survive modern decoding", () => { + assert.equal(parseUIGetResult("labels", JSON.stringify({ method: "labels", labels: fixtures.labels })).labels.map(l => l.name)[0], "bug"); + assert.equal(parseUIGetResult("branches", JSON.stringify({ method: "branches", branches: fixtures.branches })).branches.map(b => b.name)[0], "main"); + const reviewers = parseUIGetResult("reviewers", JSON.stringify({ method: "reviewers", reviewers: fixtures.reviewers })); + assert.equal(reviewers.users.map(u => u.avatar_url)[0], "avatar.png"); + assert.equal(reviewers.teams.map(t => `${t.org}/${t.slug}`)[0], "owner/docs"); + assert.equal(parseUIGetResult("issue_fields", JSON.stringify({ method: "issue_fields", issue_fields: fixtures.issue_fields })).fields.map(f => f.name)[0], "Priority"); +}); + +test("issue types accept empty, null, wrapped, and nullable-item payloads", () => { + for (const value of [null, [], { method: "issue_types", issue_types: null }, { method: "issue_types", issue_types: [] }]) { + assert.deepEqual(parseUIGetResult("issue_types", JSON.stringify(value)), []); + } + for (const value of [{ issue_types: fixtures.issue_types }, { types: fixtures.issue_types }]) { + assert.deepEqual(parseUIGetResult("issue_types", JSON.stringify(value)), fixtures.issue_types); + } + const types = parseUIGetResult("issue_types", JSON.stringify({ method: "issue_types", issue_types: fixtures.issue_types })); + assert.equal(types.filter(t => t !== null).map(t => typeof t === "string" ? t : t.name)[0], "Bug"); +}); + +test("legacy bare branches and empty picker arrays remain supported", () => { + assert.deepEqual(parseUIGetResult("branches", JSON.stringify(fixtures.branches.branches)), fixtures.branches); + assert.deepEqual(parseUIGetResult("labels", '{"labels":[]}').labels, []); +}); + +test("malformed JSON and mismatched modern envelopes fail explicitly", () => { + assert.throws(() => parseUIGetResult("labels", "not JSON")); + assert.throws(() => parseUIGetResult("labels", '{"method":"branches","branches":{}}'), /Invalid ui_get/); + assert.throws(() => parseUIGetResult("labels", '{"method":"labels"}'), /Invalid ui_get/); +}); diff --git a/ui/src/lib/uiGetResult.ts b/ui/src/lib/uiGetResult.ts new file mode 100644 index 0000000000..8d07c3061d --- /dev/null +++ b/ui/src/lib/uiGetResult.ts @@ -0,0 +1,52 @@ +interface UIUser { + login: string; + avatar_url?: string; +} + +interface UIIssueType { + id: number; + name: string; + description?: string; +} + +interface UIGetPayloads { + labels: { labels: Array<{ id: string; name: string; color: string }> }; + assignees: { assignees: UIUser[] }; + milestones: { milestones: Array<{ number: number; title: string; description: string }> }; + issue_types: Array; + branches: { branches: Array<{ name: string; protected?: boolean }> }; + issue_fields: { fields: Array<{ + id?: string; + name?: string; + data_type?: string; + description?: string; + options?: Array<{ id?: string; name?: string; description?: string; color?: string }>; + }> }; + reviewers: { + users: UIUser[]; + teams: Array<{ slug: string; name?: string; org: string }>; + }; +} + +export function parseUIGetResult( + method: M, + text: string, +): UIGetPayloads[M] { + let payload: unknown = JSON.parse(text); + if (payload !== null && typeof payload === "object" && "method" in payload) { + if (payload.method !== method || !(method in payload)) { + throw new Error(`Invalid ui_get ${method} response envelope`); + } + payload = (payload as Record)[method]; + } + if (method === "issue_types") { + if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) { + const wrapped = payload as Record; + payload = wrapped.issue_types ?? wrapped.types ?? []; + } + payload ??= []; + } else if (method === "branches" && Array.isArray(payload)) { + payload = { branches: payload }; + } + return payload as UIGetPayloads[M]; +} diff --git a/ui/tsconfig.json b/ui/tsconfig.json index 209c885166..3fe9878cfb 100644 --- a/ui/tsconfig.json +++ b/ui/tsconfig.json @@ -11,6 +11,7 @@ "esModuleInterop": true, "resolveJsonModule": true, "isolatedModules": true, + "allowImportingTsExtensions": true, "types": ["node"] }, "include": ["src", "vite.config.ts"] From cb290407e20d9cc4a7c338179f4a30287f4e5040 Mon Sep 17 00:00:00 2001 From: Roberto Nacu Date: Wed, 7 Oct 2026 00:20:34 +0100 Subject: [PATCH 58/61] Address typed output compatibility gaps (#3433) * preserve typed tool compatibility * retain typed output fidelity * tighten typed output contracts --- .github/actions/build-ui/action.yml | 4 +- README.md | 2 +- docs/feature-flags.md | 4 +- pkg/github/__toolsnaps__/actions_get.snap | 218 +++++++++++++ pkg/github/__toolsnaps__/actions_list.snap | 107 +++++++ .../__toolsnaps__/actions_run_trigger.snap | 165 ++++++++++ ..._pull_request_review_comment_reaction.snap | 23 +- .../assign_copilot_to_issue.snap | 12 +- .../assign_copilot_to_issue_with_intent.snap | 12 +- ...find_duplicate_ff_duplicate_detection.snap | 8 +- .../__toolsnaps__/get_dependabot_alert.snap | 7 + .../__toolsnaps__/get_file_contents.snap | 12 + pkg/github/__toolsnaps__/get_gist.snap | 6 + .../get_notification_details.snap | 36 +-- .../get_secret_scanning_alert.snap | 14 + .../__toolsnaps__/list_dependabot_alerts.snap | 7 + pkg/github/__toolsnaps__/list_gists.snap | 6 + .../__toolsnaps__/list_notifications.snap | 36 +-- .../list_secret_scanning_alerts.snap | 14 + pkg/github/__toolsnaps__/projects_get.snap | 268 ---------------- pkg/github/__toolsnaps__/projects_list.snap | 268 ---------------- pkg/github/__toolsnaps__/projects_write.snap | 67 ---- .../repository_ruleset_read.snap | 9 +- .../__toolsnaps__/search_repositories.snap | 2 +- pkg/github/__toolsnaps__/ui_get.snap | 296 ++++++++++++++++++ pkg/github/actions_types.go | 14 + pkg/github/consolidated_pull_request_types.go | 7 + pkg/github/copilot.go | 5 + .../discussion_notification_contracts_test.go | 28 +- pkg/github/discussion_notification_outputs.go | 17 +- pkg/github/find_duplicate.go | 4 + pkg/github/find_duplicate_test.go | 57 +++- pkg/github/git_gist_types.go | 6 +- pkg/github/granular_issue_types.go | 19 ++ pkg/github/granular_pull_request_types.go | 9 +- pkg/github/issues.go | 4 +- pkg/github/issues_granular.go | 32 +- pkg/github/method_output_schema.go | 26 ++ .../notification_output_fidelity_test.go | 77 +++++ pkg/github/projects_types.go | 3 + pkg/github/pullrequests_granular.go | 4 +- pkg/github/repositories.go | 67 ++-- pkg/github/repository_tool_types.go | 4 +- pkg/github/search.go | 2 +- pkg/github/security_outputs.go | 22 +- pkg/github/typed_actions_outputs_test.go | 2 +- pkg/github/typed_compatibility_test.go | 288 +++++++++++++++++ pkg/github/typed_contracts_test.go | 117 +++++++ pkg/github/typed_copilot_ui_outputs.go | 11 + pkg/github/typed_copilot_ui_outputs_test.go | 74 ++++- pkg/github/typed_git_gist_outputs_test.go | 6 +- pkg/github/typed_governance_outputs.go | 25 +- pkg/github/typed_governance_outputs_test.go | 50 +++ ...yped_granular_pull_request_outputs_test.go | 29 +- pkg/github/typed_repository_outputs_test.go | 6 +- pkg/github/typed_search_outputs_test.go | 2 +- pkg/github/typed_security_outputs_test.go | 57 +++- script/build-ui | 3 + 58 files changed, 1852 insertions(+), 828 deletions(-) create mode 100644 pkg/github/method_output_schema.go create mode 100644 pkg/github/notification_output_fidelity_test.go create mode 100644 pkg/github/typed_compatibility_test.go create mode 100644 pkg/github/typed_contracts_test.go diff --git a/.github/actions/build-ui/action.yml b/.github/actions/build-ui/action.yml index a7c73aea10..d45360ae14 100644 --- a/.github/actions/build-ui/action.yml +++ b/.github/actions/build-ui/action.yml @@ -1,5 +1,5 @@ name: Build UI -description: Restore cached UI HTML artifacts, or set up Node and run script/build-ui on cache miss. +description: Restore tested UI HTML artifacts, or set up Node and run script/build-ui on cache miss. runs: using: composite @@ -13,7 +13,7 @@ runs: pkg/github/ui_dist/issue-write.html pkg/github/ui_dist/pr-write.html pkg/github/ui_dist/pr-edit.html - key: ui-dist-v2-${{ hashFiles('ui/package-lock.json', 'ui/package.json', 'ui/index.html', 'ui/tsconfig*.json', 'ui/vite.config.ts', 'ui/src/**', 'ui/scripts/**') }} + key: ui-dist-v3-${{ hashFiles('.github/actions/build-ui/action.yml', 'script/build-ui', '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 diff --git a/README.md b/README.md index d2589357da..1409f73735 100644 --- a/README.md +++ b/README.md @@ -1518,7 +1518,7 @@ For negotiated supported protocol versions `2026-07-28` or newer, the consolidat - **search_repositories** - Search repositories - **OAuth Challenge Scopes**: `repo` - - `minimal_output`: Return minimal repository information (default: true). When false, returns full GitHub API repository objects. (boolean, optional) + - `minimal_output`: Return minimal repository information (default: true). When false, modern clients receive additional curated repository details, not the complete GitHub API object. Legacy clients retain full GitHub API repository objects. (boolean, optional) - `order`: Sort order (string, optional) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) diff --git a/docs/feature-flags.md b/docs/feature-flags.md index 0d32b3c33a..2924b9f3d7 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -402,8 +402,8 @@ as output formatting) won't appear here. - `confidence_threshold`: Minimum similarity threshold a candidate must meet to be returned; higher values are stricter. When omitted, the API's high-precision default is used. The scale is defined by the API, so no client-side bounds are enforced. (number, optional) - `issue_number`: The number of the existing issue to find duplicates for (number, required) - `owner`: The owner of the repository (string, required) - - `page`: Page number for pagination (min 1) (number, optional) - - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `page`: Page number for pagination (min 0). Zero is forwarded for the GitHub API default. (number, optional) + - `perPage`: Results per page for pagination (min 0, max 100). Zero is forwarded for the GitHub API default. (number, optional) - `repo`: The name of the repository (string, required) ### `thread_resolution_reason` diff --git a/pkg/github/__toolsnaps__/actions_get.snap b/pkg/github/__toolsnaps__/actions_get.snap index 404931f389..8e87fadf6b 100644 --- a/pkg/github/__toolsnaps__/actions_get.snap +++ b/pkg/github/__toolsnaps__/actions_get.snap @@ -43,6 +43,221 @@ "name": "actions_get", "outputSchema": { "additionalProperties": false, + "oneOf": [ + { + "properties": { + "artifact": { + "not": { + "description": "Any value." + } + }, + "logs": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "get_workflow" + ] + }, + "usage": { + "not": { + "description": "Any value." + } + }, + "workflow_job": { + "not": { + "description": "Any value." + } + }, + "workflow_run": { + "not": { + "description": "Any value." + } + } + } + }, + { + "properties": { + "artifact": { + "not": { + "description": "Any value." + } + }, + "logs": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "get_workflow_run" + ] + }, + "usage": { + "not": { + "description": "Any value." + } + }, + "workflow": { + "not": { + "description": "Any value." + } + }, + "workflow_job": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "workflow_run" + ] + }, + { + "properties": { + "artifact": { + "not": { + "description": "Any value." + } + }, + "logs": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "get_workflow_job" + ] + }, + "usage": { + "not": { + "description": "Any value." + } + }, + "workflow": { + "not": { + "description": "Any value." + } + }, + "workflow_run": { + "not": { + "description": "Any value." + } + } + } + }, + { + "properties": { + "logs": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "download_workflow_run_artifact" + ] + }, + "usage": { + "not": { + "description": "Any value." + } + }, + "workflow": { + "not": { + "description": "Any value." + } + }, + "workflow_job": { + "not": { + "description": "Any value." + } + }, + "workflow_run": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "artifact" + ] + }, + { + "properties": { + "artifact": { + "not": { + "description": "Any value." + } + }, + "logs": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "get_workflow_run_usage" + ] + }, + "workflow": { + "not": { + "description": "Any value." + } + }, + "workflow_job": { + "not": { + "description": "Any value." + } + }, + "workflow_run": { + "not": { + "description": "Any value." + } + } + } + }, + { + "properties": { + "artifact": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "get_workflow_run_logs_url" + ] + }, + "usage": { + "not": { + "description": "Any value." + } + }, + "workflow": { + "not": { + "description": "Any value." + } + }, + "workflow_job": { + "not": { + "description": "Any value." + } + }, + "workflow_run": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "logs" + ] + } + ], "properties": { "artifact": { "additionalProperties": false, @@ -742,6 +957,9 @@ ] } }, + "required": [ + "method" + ], "type": "object" } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/actions_list.snap b/pkg/github/__toolsnaps__/actions_list.snap index 021c3da43f..28b7a789fc 100644 --- a/pkg/github/__toolsnaps__/actions_list.snap +++ b/pkg/github/__toolsnaps__/actions_list.snap @@ -128,6 +128,110 @@ "name": "actions_list", "outputSchema": { "additionalProperties": false, + "oneOf": [ + { + "properties": { + "artifacts": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "list_workflows" + ] + }, + "workflow_jobs": { + "not": { + "description": "Any value." + } + }, + "workflow_runs": { + "not": { + "description": "Any value." + } + } + } + }, + { + "properties": { + "artifacts": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "list_workflow_runs" + ] + }, + "workflow_jobs": { + "not": { + "description": "Any value." + } + }, + "workflows": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "workflow_runs" + ] + }, + { + "properties": { + "artifacts": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "list_workflow_jobs" + ] + }, + "workflow_runs": { + "not": { + "description": "Any value." + } + }, + "workflows": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "workflow_jobs" + ] + }, + { + "properties": { + "method": { + "enum": [ + "list_workflow_run_artifacts" + ] + }, + "workflow_jobs": { + "not": { + "description": "Any value." + } + }, + "workflow_runs": { + "not": { + "description": "Any value." + } + }, + "workflows": { + "not": { + "description": "Any value." + } + } + } + } + ], "properties": { "artifacts": { "additionalProperties": false, @@ -856,6 +960,9 @@ ] } }, + "required": [ + "method" + ], "type": "object" } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/actions_run_trigger.snap b/pkg/github/__toolsnaps__/actions_run_trigger.snap index d7982a88ec..21387727ee 100644 --- a/pkg/github/__toolsnaps__/actions_run_trigger.snap +++ b/pkg/github/__toolsnaps__/actions_run_trigger.snap @@ -82,6 +82,168 @@ } }, "additionalProperties": false, + "oneOf": [ + { + "properties": { + "cancel": { + "not": { + "description": "Any value." + } + }, + "delete_logs": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "run_workflow" + ] + }, + "rerun": { + "not": { + "description": "Any value." + } + }, + "rerun_failed": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "dispatch" + ] + }, + { + "properties": { + "cancel": { + "not": { + "description": "Any value." + } + }, + "delete_logs": { + "not": { + "description": "Any value." + } + }, + "dispatch": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "rerun_workflow_run" + ] + }, + "rerun_failed": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "rerun" + ] + }, + { + "properties": { + "cancel": { + "not": { + "description": "Any value." + } + }, + "delete_logs": { + "not": { + "description": "Any value." + } + }, + "dispatch": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "rerun_failed_jobs" + ] + }, + "rerun": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "rerun_failed" + ] + }, + { + "properties": { + "delete_logs": { + "not": { + "description": "Any value." + } + }, + "dispatch": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "cancel_workflow_run" + ] + }, + "rerun": { + "not": { + "description": "Any value." + } + }, + "rerun_failed": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "cancel" + ] + }, + { + "properties": { + "cancel": { + "not": { + "description": "Any value." + } + }, + "dispatch": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "delete_workflow_run_logs" + ] + }, + "rerun": { + "not": { + "description": "Any value." + } + }, + "rerun_failed": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "delete_logs" + ] + } + ], "properties": { "cancel": { "$ref": "#/$defs/run_operation" @@ -155,6 +317,9 @@ "$ref": "#/$defs/run_operation" } }, + "required": [ + "method" + ], "type": "object" } } \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/add_pull_request_review_comment_reaction.snap b/pkg/github/__toolsnaps__/add_pull_request_review_comment_reaction.snap index bcf5f74e19..73ea0f877c 100644 --- a/pkg/github/__toolsnaps__/add_pull_request_review_comment_reaction.snap +++ b/pkg/github/__toolsnaps__/add_pull_request_review_comment_reaction.snap @@ -63,27 +63,18 @@ "outputSchema": { "additionalProperties": false, "properties": { - "content": { - "description": "Reaction type", - "enum": [ - "+1", - "-1", - "laugh", - "confused", - "heart", - "hooray", - "rocket", - "eyes" - ], - "type": "string" - }, "id": { "description": "Reaction ID", - "type": "integer" + "type": "string" + }, + "url": { + "description": "Reaction URL", + "type": "string" } }, "required": [ - "id" + "id", + "url" ], "type": "object" } diff --git a/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap b/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap index 8c8d4dd4c3..1e0f50413f 100644 --- a/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap +++ b/pkg/github/__toolsnaps__/assign_copilot_to_issue.snap @@ -55,6 +55,10 @@ "description": "The assigned issue's number.", "type": "integer" }, + "issue_url": { + "description": "Canonical browser URL of the assigned issue.", + "type": "string" + }, "message": { "description": "Assignment outcome or tool error message.", "type": "string" @@ -82,12 +86,17 @@ }, "title": { "type": "string" + }, + "url": { + "description": "Canonical browser URL of the linked pull request.", + "type": "string" } }, "required": [ "number", "state", - "title" + "title", + "url" ], "type": "object" }, @@ -97,6 +106,7 @@ }, "required": [ "issue_number", + "issue_url", "message", "owner", "repo" diff --git a/pkg/github/__toolsnaps__/assign_copilot_to_issue_with_intent.snap b/pkg/github/__toolsnaps__/assign_copilot_to_issue_with_intent.snap index 5f9af27c69..4b51b74a91 100644 --- a/pkg/github/__toolsnaps__/assign_copilot_to_issue_with_intent.snap +++ b/pkg/github/__toolsnaps__/assign_copilot_to_issue_with_intent.snap @@ -79,6 +79,10 @@ "description": "The assigned issue's number.", "type": "integer" }, + "issue_url": { + "description": "Canonical browser URL of the assigned issue.", + "type": "string" + }, "message": { "description": "Assignment outcome or tool error message.", "type": "string" @@ -106,12 +110,17 @@ }, "title": { "type": "string" + }, + "url": { + "description": "Canonical browser URL of the linked pull request.", + "type": "string" } }, "required": [ "number", "state", - "title" + "title", + "url" ], "type": "object" }, @@ -122,6 +131,7 @@ "required": [ "is_suggestion", "issue_number", + "issue_url", "message", "owner", "repo" diff --git a/pkg/github/__toolsnaps__/find_duplicate_ff_duplicate_detection.snap b/pkg/github/__toolsnaps__/find_duplicate_ff_duplicate_detection.snap index 4490493922..c83c253812 100644 --- a/pkg/github/__toolsnaps__/find_duplicate_ff_duplicate_detection.snap +++ b/pkg/github/__toolsnaps__/find_duplicate_ff_duplicate_detection.snap @@ -20,14 +20,14 @@ "type": "string" }, "page": { - "description": "Page number for pagination (min 1)", - "minimum": 1, + "description": "Page number for pagination (min 0). Zero is forwarded for the GitHub API default.", + "minimum": 0, "type": "number" }, "perPage": { - "description": "Results per page for pagination (min 1, max 100)", + "description": "Results per page for pagination (min 0, max 100). Zero is forwarded for the GitHub API default.", "maximum": 100, - "minimum": 1, + "minimum": 0, "type": "number" }, "repo": { diff --git a/pkg/github/__toolsnaps__/get_dependabot_alert.snap b/pkg/github/__toolsnaps__/get_dependabot_alert.snap index 35f5418eed..a4fa34feae 100644 --- a/pkg/github/__toolsnaps__/get_dependabot_alert.snap +++ b/pkg/github/__toolsnaps__/get_dependabot_alert.snap @@ -100,6 +100,13 @@ "security_advisory": { "additionalProperties": false, "properties": { + "classification": { + "description": "GitHub advisory classification, such as malware or general.", + "type": [ + "null", + "string" + ] + }, "cve_id": { "type": [ "null", diff --git a/pkg/github/__toolsnaps__/get_file_contents.snap b/pkg/github/__toolsnaps__/get_file_contents.snap index 8fb4f4297e..fa845c7d50 100644 --- a/pkg/github/__toolsnaps__/get_file_contents.snap +++ b/pkg/github/__toolsnaps__/get_file_contents.snap @@ -83,6 +83,12 @@ "string" ] }, + "git_url": { + "type": [ + "null", + "string" + ] + }, "html_url": { "type": [ "null", @@ -132,6 +138,12 @@ "null", "string" ] + }, + "url": { + "type": [ + "null", + "string" + ] } }, "type": [ diff --git a/pkg/github/__toolsnaps__/get_gist.snap b/pkg/github/__toolsnaps__/get_gist.snap index dad5bcbfbe..61a80ccee6 100644 --- a/pkg/github/__toolsnaps__/get_gist.snap +++ b/pkg/github/__toolsnaps__/get_gist.snap @@ -50,6 +50,9 @@ "language": { "type": "string" }, + "raw_url": { + "type": "string" + }, "size": { "description": "File size in bytes.", "type": "integer" @@ -63,6 +66,9 @@ }, "type": "object" }, + "git_pull_url": { + "type": "string" + }, "html_url": { "type": "string" }, diff --git a/pkg/github/__toolsnaps__/get_notification_details.snap b/pkg/github/__toolsnaps__/get_notification_details.snap index b2c8a3e4bc..333d05dfed 100644 --- a/pkg/github/__toolsnaps__/get_notification_details.snap +++ b/pkg/github/__toolsnaps__/get_notification_details.snap @@ -35,23 +35,7 @@ ] }, "reason": { - "enum": [ - "approval_requested", - "assign", - "author", - "ci_activity", - "comment", - "invitation", - "manual", - "member_feature_requested", - "mention", - "review_requested", - "security_alert", - "security_advisory_credit", - "state_change", - "subscribed", - "team_mention" - ], + "description": "Notification reason reported by GitHub; new provider values are preserved.", "type": [ "null", "string" @@ -106,16 +90,14 @@ ] }, "type": { - "enum": [ - "CheckSuite", - "Commit", - "Discussion", - "Issue", - "PullRequest", - "Release", - "RepositoryInvitation", - "SecurityAdvisory" - ], + "description": "Subject type reported by GitHub; new provider values are preserved.", + "type": [ + "null", + "string" + ] + }, + "url": { + "description": "API reference to the underlying notification subject.", "type": [ "null", "string" diff --git a/pkg/github/__toolsnaps__/get_secret_scanning_alert.snap b/pkg/github/__toolsnaps__/get_secret_scanning_alert.snap index 435dd76656..81bcd0bdd0 100644 --- a/pkg/github/__toolsnaps__/get_secret_scanning_alert.snap +++ b/pkg/github/__toolsnaps__/get_secret_scanning_alert.snap @@ -76,12 +76,26 @@ "object" ] }, + "has_more_locations": { + "description": "Whether additional secret locations exist beyond the first location.", + "type": [ + "null", + "boolean" + ] + }, "html_url": { "type": [ "null", "string" ] }, + "is_base64_encoded": { + "description": "Whether the detected secret is base64 encoded.", + "type": [ + "null", + "boolean" + ] + }, "number": { "type": [ "null", diff --git a/pkg/github/__toolsnaps__/list_dependabot_alerts.snap b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap index 04fb6f95fa..1a27f1297b 100644 --- a/pkg/github/__toolsnaps__/list_dependabot_alerts.snap +++ b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap @@ -130,6 +130,13 @@ "security_advisory": { "additionalProperties": false, "properties": { + "classification": { + "description": "GitHub advisory classification, such as malware or general.", + "type": [ + "null", + "string" + ] + }, "cve_id": { "type": [ "null", diff --git a/pkg/github/__toolsnaps__/list_gists.snap b/pkg/github/__toolsnaps__/list_gists.snap index 185eb5b1f1..f4a7c4ea23 100644 --- a/pkg/github/__toolsnaps__/list_gists.snap +++ b/pkg/github/__toolsnaps__/list_gists.snap @@ -63,6 +63,9 @@ "language": { "type": "string" }, + "raw_url": { + "type": "string" + }, "size": { "description": "File size in bytes.", "type": "integer" @@ -76,6 +79,9 @@ }, "type": "object" }, + "git_pull_url": { + "type": "string" + }, "html_url": { "type": "string" }, diff --git a/pkg/github/__toolsnaps__/list_notifications.snap b/pkg/github/__toolsnaps__/list_notifications.snap index 9b275ed753..7d768117e4 100644 --- a/pkg/github/__toolsnaps__/list_notifications.snap +++ b/pkg/github/__toolsnaps__/list_notifications.snap @@ -65,23 +65,7 @@ ] }, "reason": { - "enum": [ - "approval_requested", - "assign", - "author", - "ci_activity", - "comment", - "invitation", - "manual", - "member_feature_requested", - "mention", - "review_requested", - "security_alert", - "security_advisory_credit", - "state_change", - "subscribed", - "team_mention" - ], + "description": "Notification reason reported by GitHub; new provider values are preserved.", "type": [ "null", "string" @@ -136,16 +120,14 @@ ] }, "type": { - "enum": [ - "CheckSuite", - "Commit", - "Discussion", - "Issue", - "PullRequest", - "Release", - "RepositoryInvitation", - "SecurityAdvisory" - ], + "description": "Subject type reported by GitHub; new provider values are preserved.", + "type": [ + "null", + "string" + ] + }, + "url": { + "description": "API reference to the underlying notification subject.", "type": [ "null", "string" diff --git a/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap b/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap index b662784681..2bceafa03d 100644 --- a/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap +++ b/pkg/github/__toolsnaps__/list_secret_scanning_alerts.snap @@ -107,12 +107,26 @@ "object" ] }, + "has_more_locations": { + "description": "Whether additional secret locations exist beyond the first location.", + "type": [ + "null", + "boolean" + ] + }, "html_url": { "type": [ "null", "string" ] }, + "is_base64_encoded": { + "description": "Whether the detected secret is base64 encoded.", + "type": [ + "null", + "boolean" + ] + }, "number": { "type": [ "null", diff --git a/pkg/github/__toolsnaps__/projects_get.snap b/pkg/github/__toolsnaps__/projects_get.snap index 087a59d8ee..1996d199ce 100644 --- a/pkg/github/__toolsnaps__/projects_get.snap +++ b/pkg/github/__toolsnaps__/projects_get.snap @@ -341,73 +341,6 @@ "avatar_url": { "type": "string" }, - "details": { - "additionalProperties": false, - "properties": { - "bio": { - "type": "string" - }, - "blog": { - "type": "string" - }, - "company": { - "type": "string" - }, - "created_at": { - "description": "RFC3339 timestamp.", - "type": "string" - }, - "email": { - "type": "string" - }, - "followers": { - "type": "integer" - }, - "following": { - "type": "integer" - }, - "hireable": { - "type": "boolean" - }, - "location": { - "type": "string" - }, - "name": { - "type": "string" - }, - "owned_private_repos": { - "type": "integer" - }, - "private_gists": { - "type": "integer" - }, - "public_gists": { - "type": "integer" - }, - "public_repos": { - "type": "integer" - }, - "total_private_repos": { - "type": "integer" - }, - "twitter_username": { - "type": "string" - }, - "updated_at": { - "description": "RFC3339 timestamp.", - "type": "string" - } - }, - "required": [ - "public_repos", - "public_gists", - "followers", - "following", - "created_at", - "updated_at" - ], - "type": "object" - }, "id": { "type": "integer" }, @@ -434,73 +367,6 @@ "avatar_url": { "type": "string" }, - "details": { - "additionalProperties": false, - "properties": { - "bio": { - "type": "string" - }, - "blog": { - "type": "string" - }, - "company": { - "type": "string" - }, - "created_at": { - "description": "RFC3339 timestamp.", - "type": "string" - }, - "email": { - "type": "string" - }, - "followers": { - "type": "integer" - }, - "following": { - "type": "integer" - }, - "hireable": { - "type": "boolean" - }, - "location": { - "type": "string" - }, - "name": { - "type": "string" - }, - "owned_private_repos": { - "type": "integer" - }, - "private_gists": { - "type": "integer" - }, - "public_gists": { - "type": "integer" - }, - "public_repos": { - "type": "integer" - }, - "total_private_repos": { - "type": "integer" - }, - "twitter_username": { - "type": "string" - }, - "updated_at": { - "description": "RFC3339 timestamp.", - "type": "string" - } - }, - "required": [ - "public_repos", - "public_gists", - "followers", - "following", - "created_at", - "updated_at" - ], - "type": "object" - }, "id": { "type": "integer" }, @@ -534,73 +400,6 @@ "avatar_url": { "type": "string" }, - "details": { - "additionalProperties": false, - "properties": { - "bio": { - "type": "string" - }, - "blog": { - "type": "string" - }, - "company": { - "type": "string" - }, - "created_at": { - "description": "RFC3339 timestamp.", - "type": "string" - }, - "email": { - "type": "string" - }, - "followers": { - "type": "integer" - }, - "following": { - "type": "integer" - }, - "hireable": { - "type": "boolean" - }, - "location": { - "type": "string" - }, - "name": { - "type": "string" - }, - "owned_private_repos": { - "type": "integer" - }, - "private_gists": { - "type": "integer" - }, - "public_gists": { - "type": "integer" - }, - "public_repos": { - "type": "integer" - }, - "total_private_repos": { - "type": "integer" - }, - "twitter_username": { - "type": "string" - }, - "updated_at": { - "description": "RFC3339 timestamp.", - "type": "string" - } - }, - "required": [ - "public_repos", - "public_gists", - "followers", - "following", - "created_at", - "updated_at" - ], - "type": "object" - }, "id": { "type": "integer" }, @@ -652,73 +451,6 @@ "avatar_url": { "type": "string" }, - "details": { - "additionalProperties": false, - "properties": { - "bio": { - "type": "string" - }, - "blog": { - "type": "string" - }, - "company": { - "type": "string" - }, - "created_at": { - "description": "RFC3339 timestamp.", - "type": "string" - }, - "email": { - "type": "string" - }, - "followers": { - "type": "integer" - }, - "following": { - "type": "integer" - }, - "hireable": { - "type": "boolean" - }, - "location": { - "type": "string" - }, - "name": { - "type": "string" - }, - "owned_private_repos": { - "type": "integer" - }, - "private_gists": { - "type": "integer" - }, - "public_gists": { - "type": "integer" - }, - "public_repos": { - "type": "integer" - }, - "total_private_repos": { - "type": "integer" - }, - "twitter_username": { - "type": "string" - }, - "updated_at": { - "description": "RFC3339 timestamp.", - "type": "string" - } - }, - "required": [ - "public_repos", - "public_gists", - "followers", - "following", - "created_at", - "updated_at" - ], - "type": "object" - }, "id": { "type": "integer" }, diff --git a/pkg/github/__toolsnaps__/projects_list.snap b/pkg/github/__toolsnaps__/projects_list.snap index eb44bca6ae..49f741369a 100644 --- a/pkg/github/__toolsnaps__/projects_list.snap +++ b/pkg/github/__toolsnaps__/projects_list.snap @@ -447,73 +447,6 @@ "avatar_url": { "type": "string" }, - "details": { - "additionalProperties": false, - "properties": { - "bio": { - "type": "string" - }, - "blog": { - "type": "string" - }, - "company": { - "type": "string" - }, - "created_at": { - "description": "RFC3339 timestamp.", - "type": "string" - }, - "email": { - "type": "string" - }, - "followers": { - "type": "integer" - }, - "following": { - "type": "integer" - }, - "hireable": { - "type": "boolean" - }, - "location": { - "type": "string" - }, - "name": { - "type": "string" - }, - "owned_private_repos": { - "type": "integer" - }, - "private_gists": { - "type": "integer" - }, - "public_gists": { - "type": "integer" - }, - "public_repos": { - "type": "integer" - }, - "total_private_repos": { - "type": "integer" - }, - "twitter_username": { - "type": "string" - }, - "updated_at": { - "description": "RFC3339 timestamp.", - "type": "string" - } - }, - "required": [ - "public_repos", - "public_gists", - "followers", - "following", - "created_at", - "updated_at" - ], - "type": "object" - }, "id": { "type": "integer" }, @@ -540,73 +473,6 @@ "avatar_url": { "type": "string" }, - "details": { - "additionalProperties": false, - "properties": { - "bio": { - "type": "string" - }, - "blog": { - "type": "string" - }, - "company": { - "type": "string" - }, - "created_at": { - "description": "RFC3339 timestamp.", - "type": "string" - }, - "email": { - "type": "string" - }, - "followers": { - "type": "integer" - }, - "following": { - "type": "integer" - }, - "hireable": { - "type": "boolean" - }, - "location": { - "type": "string" - }, - "name": { - "type": "string" - }, - "owned_private_repos": { - "type": "integer" - }, - "private_gists": { - "type": "integer" - }, - "public_gists": { - "type": "integer" - }, - "public_repos": { - "type": "integer" - }, - "total_private_repos": { - "type": "integer" - }, - "twitter_username": { - "type": "string" - }, - "updated_at": { - "description": "RFC3339 timestamp.", - "type": "string" - } - }, - "required": [ - "public_repos", - "public_gists", - "followers", - "following", - "created_at", - "updated_at" - ], - "type": "object" - }, "id": { "type": "integer" }, @@ -640,73 +506,6 @@ "avatar_url": { "type": "string" }, - "details": { - "additionalProperties": false, - "properties": { - "bio": { - "type": "string" - }, - "blog": { - "type": "string" - }, - "company": { - "type": "string" - }, - "created_at": { - "description": "RFC3339 timestamp.", - "type": "string" - }, - "email": { - "type": "string" - }, - "followers": { - "type": "integer" - }, - "following": { - "type": "integer" - }, - "hireable": { - "type": "boolean" - }, - "location": { - "type": "string" - }, - "name": { - "type": "string" - }, - "owned_private_repos": { - "type": "integer" - }, - "private_gists": { - "type": "integer" - }, - "public_gists": { - "type": "integer" - }, - "public_repos": { - "type": "integer" - }, - "total_private_repos": { - "type": "integer" - }, - "twitter_username": { - "type": "string" - }, - "updated_at": { - "description": "RFC3339 timestamp.", - "type": "string" - } - }, - "required": [ - "public_repos", - "public_gists", - "followers", - "following", - "created_at", - "updated_at" - ], - "type": "object" - }, "id": { "type": "integer" }, @@ -797,73 +596,6 @@ "avatar_url": { "type": "string" }, - "details": { - "additionalProperties": false, - "properties": { - "bio": { - "type": "string" - }, - "blog": { - "type": "string" - }, - "company": { - "type": "string" - }, - "created_at": { - "description": "RFC3339 timestamp.", - "type": "string" - }, - "email": { - "type": "string" - }, - "followers": { - "type": "integer" - }, - "following": { - "type": "integer" - }, - "hireable": { - "type": "boolean" - }, - "location": { - "type": "string" - }, - "name": { - "type": "string" - }, - "owned_private_repos": { - "type": "integer" - }, - "private_gists": { - "type": "integer" - }, - "public_gists": { - "type": "integer" - }, - "public_repos": { - "type": "integer" - }, - "total_private_repos": { - "type": "integer" - }, - "twitter_username": { - "type": "string" - }, - "updated_at": { - "description": "RFC3339 timestamp.", - "type": "string" - } - }, - "required": [ - "public_repos", - "public_gists", - "followers", - "following", - "created_at", - "updated_at" - ], - "type": "object" - }, "id": { "type": "integer" }, diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap index fde6fa1a6f..9644750bf1 100644 --- a/pkg/github/__toolsnaps__/projects_write.snap +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -694,73 +694,6 @@ "avatar_url": { "type": "string" }, - "details": { - "additionalProperties": false, - "properties": { - "bio": { - "type": "string" - }, - "blog": { - "type": "string" - }, - "company": { - "type": "string" - }, - "created_at": { - "description": "RFC3339 timestamp.", - "type": "string" - }, - "email": { - "type": "string" - }, - "followers": { - "type": "integer" - }, - "following": { - "type": "integer" - }, - "hireable": { - "type": "boolean" - }, - "location": { - "type": "string" - }, - "name": { - "type": "string" - }, - "owned_private_repos": { - "type": "integer" - }, - "private_gists": { - "type": "integer" - }, - "public_gists": { - "type": "integer" - }, - "public_repos": { - "type": "integer" - }, - "total_private_repos": { - "type": "integer" - }, - "twitter_username": { - "type": "string" - }, - "updated_at": { - "description": "RFC3339 timestamp.", - "type": "string" - } - }, - "required": [ - "public_repos", - "public_gists", - "followers", - "following", - "created_at", - "updated_at" - ], - "type": "object" - }, "id": { "type": "integer" }, diff --git a/pkg/github/__toolsnaps__/repository_ruleset_read.snap b/pkg/github/__toolsnaps__/repository_ruleset_read.snap index b1e887f90a..c3f24b219b 100644 --- a/pkg/github/__toolsnaps__/repository_ruleset_read.snap +++ b/pkg/github/__toolsnaps__/repository_ruleset_read.snap @@ -282,6 +282,10 @@ "before_sha": { "type": "string" }, + "evaluation_result": { + "description": "Result of rules evaluated without enforcement, distinct from the enforced result.", + "type": "string" + }, "id": { "type": "integer" }, @@ -324,7 +328,10 @@ "additionalProperties": false, "properties": { "id": { - "type": "integer" + "type": [ + "null", + "integer" + ] }, "name": { "type": "string" diff --git a/pkg/github/__toolsnaps__/search_repositories.snap b/pkg/github/__toolsnaps__/search_repositories.snap index 06b5300fa2..6c64889860 100644 --- a/pkg/github/__toolsnaps__/search_repositories.snap +++ b/pkg/github/__toolsnaps__/search_repositories.snap @@ -9,7 +9,7 @@ "properties": { "minimal_output": { "default": true, - "description": "Return minimal repository information (default: true). When false, returns full GitHub API repository objects.", + "description": "Return minimal repository information (default: true). When false, modern clients receive additional curated repository details, not the complete GitHub API object. Legacy clients retain full GitHub API repository objects.", "type": "boolean" }, "order": { diff --git a/pkg/github/__toolsnaps__/ui_get.snap b/pkg/github/__toolsnaps__/ui_get.snap index 3ab16de343..012089497f 100644 --- a/pkg/github/__toolsnaps__/ui_get.snap +++ b/pkg/github/__toolsnaps__/ui_get.snap @@ -45,6 +45,302 @@ "name": "ui_get", "outputSchema": { "additionalProperties": false, + "oneOf": [ + { + "properties": { + "assignees": { + "not": { + "description": "Any value." + } + }, + "branches": { + "not": { + "description": "Any value." + } + }, + "issue_fields": { + "not": { + "description": "Any value." + } + }, + "issue_types": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "labels" + ] + }, + "milestones": { + "not": { + "description": "Any value." + } + }, + "reviewers": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "labels" + ] + }, + { + "properties": { + "branches": { + "not": { + "description": "Any value." + } + }, + "issue_fields": { + "not": { + "description": "Any value." + } + }, + "issue_types": { + "not": { + "description": "Any value." + } + }, + "labels": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "assignees" + ] + }, + "milestones": { + "not": { + "description": "Any value." + } + }, + "reviewers": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "assignees" + ] + }, + { + "properties": { + "assignees": { + "not": { + "description": "Any value." + } + }, + "branches": { + "not": { + "description": "Any value." + } + }, + "issue_fields": { + "not": { + "description": "Any value." + } + }, + "issue_types": { + "not": { + "description": "Any value." + } + }, + "labels": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "milestones" + ] + }, + "reviewers": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "milestones" + ] + }, + { + "properties": { + "assignees": { + "not": { + "description": "Any value." + } + }, + "branches": { + "not": { + "description": "Any value." + } + }, + "issue_fields": { + "not": { + "description": "Any value." + } + }, + "labels": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "issue_types" + ] + }, + "milestones": { + "not": { + "description": "Any value." + } + }, + "reviewers": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "issue_types" + ] + }, + { + "properties": { + "assignees": { + "not": { + "description": "Any value." + } + }, + "issue_fields": { + "not": { + "description": "Any value." + } + }, + "issue_types": { + "not": { + "description": "Any value." + } + }, + "labels": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "branches" + ] + }, + "milestones": { + "not": { + "description": "Any value." + } + }, + "reviewers": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "branches" + ] + }, + { + "properties": { + "assignees": { + "not": { + "description": "Any value." + } + }, + "branches": { + "not": { + "description": "Any value." + } + }, + "issue_types": { + "not": { + "description": "Any value." + } + }, + "labels": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "issue_fields" + ] + }, + "milestones": { + "not": { + "description": "Any value." + } + }, + "reviewers": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "issue_fields" + ] + }, + { + "properties": { + "assignees": { + "not": { + "description": "Any value." + } + }, + "branches": { + "not": { + "description": "Any value." + } + }, + "issue_fields": { + "not": { + "description": "Any value." + } + }, + "issue_types": { + "not": { + "description": "Any value." + } + }, + "labels": { + "not": { + "description": "Any value." + } + }, + "method": { + "enum": [ + "reviewers" + ] + }, + "milestones": { + "not": { + "description": "Any value." + } + } + }, + "required": [ + "reviewers" + ] + } + ], "properties": { "assignees": { "additionalProperties": false, diff --git a/pkg/github/actions_types.go b/pkg/github/actions_types.go index 16c7a7fd63..ca16cfbc52 100644 --- a/pkg/github/actions_types.go +++ b/pkg/github/actions_types.go @@ -231,6 +231,10 @@ type ActionsJobsOutput struct { var actionsListOutputSchema = sync.OnceValue(func() *jsonschema.Schema { schema := actionsOutputSchema[ActionsListOutput]() + constrainMethodOutput(schema, map[string]string{ + actionsMethodListWorkflows: "workflows", actionsMethodListWorkflowRuns: "workflow_runs", + actionsMethodListWorkflowJobs: "workflow_jobs", actionsMethodListWorkflowArtifacts: "artifacts", + }, "workflows", "artifacts") return schema }) @@ -359,6 +363,11 @@ type ActionsRunLogsOutput struct { var actionsGetOutputSchema = sync.OnceValue(func() *jsonschema.Schema { schema := actionsOutputSchema[ActionsGetOutput]() + constrainMethodOutput(schema, map[string]string{ + actionsMethodGetWorkflow: "workflow", actionsMethodGetWorkflowRun: "workflow_run", + actionsMethodGetWorkflowJob: "workflow_job", actionsMethodGetWorkflowRunUsage: "usage", + actionsMethodDownloadWorkflowArtifact: "artifact", actionsMethodGetWorkflowRunLogsURL: "logs", + }, "workflow", "workflow_job", "usage") return schema }) @@ -401,6 +410,11 @@ var actionsRunTriggerOutputSchema = sync.OnceValue(func() *jsonschema.Schema { for _, field := range []string{"rerun", "rerun_failed", "cancel", "delete_logs"} { schema.Properties[field] = &jsonschema.Schema{Ref: "#/$defs/run_operation"} } + constrainMethodOutput(schema, map[string]string{ + actionsMethodRunWorkflow: "dispatch", actionsMethodRerunWorkflowRun: "rerun", + actionsMethodRerunFailedJobs: "rerun_failed", actionsMethodCancelWorkflowRun: "cancel", + actionsMethodDeleteWorkflowRunLogs: "delete_logs", + }) return schema }) diff --git a/pkg/github/consolidated_pull_request_types.go b/pkg/github/consolidated_pull_request_types.go index e32976ba50..e5b48e7964 100644 --- a/pkg/github/consolidated_pull_request_types.go +++ b/pkg/github/consolidated_pull_request_types.go @@ -538,6 +538,13 @@ func normalizePullRequestArguments(kind string) func(json.RawMessage) (json.RawM if spec.methods != nil && !slices.Contains(spec.methods, args["method"].(string)) { return nil, fmt.Errorf("unknown method: %s", args["method"]) } + if kind == "read" { + if args["method"] != "get_review_comments" { + delete(args, "after") + } else if _, err := OptionalParam[string](args, "after"); err != nil { + return nil, err + } + } if kind == "reply" { _, hasBody := args["body"] _, hasReaction := args["reaction"] diff --git a/pkg/github/copilot.go b/pkg/github/copilot.go index e7a2d695fd..cc49042c9f 100644 --- a/pkg/github/copilot.go +++ b/pkg/github/copilot.go @@ -448,6 +448,7 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server output := &AssignCopilotToIssueOutput{ IssueNumber: int(updateIssueMutation.UpdateIssue.Issue.Number), + IssueURL: string(updateIssueMutation.UpdateIssue.Issue.URL), Message: result["message"].(string), Owner: params.Owner, Repo: params.Repo, @@ -460,6 +461,7 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) inventory.Server Number: linkedPR.Number, State: linkedPR.State, Title: linkedPR.Title, + URL: linkedPR.URL, } } return utils.NewToolResultText(string(r)), output, nil @@ -781,6 +783,7 @@ func AssignCopilotToIssueWithIntent(t translations.TranslationHelperFunc) invent } return utils.NewToolResultText(string(r)), &AssignCopilotToIssueWithIntentOutput{ IssueNumber: int(updateIssueMutation.UpdateIssue.Issue.Number), + IssueURL: string(updateIssueMutation.UpdateIssue.Issue.URL), IsSuggestion: params.IsSuggestion, Message: result["message"].(string), Owner: params.Owner, @@ -842,6 +845,7 @@ func AssignCopilotToIssueWithIntent(t translations.TranslationHelperFunc) invent } output := &AssignCopilotToIssueWithIntentOutput{ IssueNumber: int(updateIssueMutation.UpdateIssue.Issue.Number), + IssueURL: string(updateIssueMutation.UpdateIssue.Issue.URL), IsSuggestion: params.IsSuggestion, Message: result["message"].(string), Owner: params.Owner, @@ -855,6 +859,7 @@ func AssignCopilotToIssueWithIntent(t translations.TranslationHelperFunc) invent Number: linkedPR.Number, State: linkedPR.State, Title: linkedPR.Title, + URL: linkedPR.URL, } } return utils.NewToolResultText(string(r)), output, nil diff --git a/pkg/github/discussion_notification_contracts_test.go b/pkg/github/discussion_notification_contracts_test.go index a9002eb457..88586c4914 100644 --- a/pkg/github/discussion_notification_contracts_test.go +++ b/pkg/github/discussion_notification_contracts_test.go @@ -173,10 +173,9 @@ func TestNotificationProjectionTrimsHypermedia(t *testing.T) { LatestCommentURL: new("https://api.github.com/repos/owner/repo/issues/comments/2"), }, } - // The pre-quality DTO exposed these API links; legacy output still does. before := `{"id":"123","subject":{"title":"Title","url":"https://api.github.com/repos/owner/repo/issues/1","latest_comment_url":"https://api.github.com/repos/owner/repo/issues/comments/2","type":"Issue"},"url":"https://api.github.com/notifications/threads/123"}` after := mustMarshalJSON(t, notificationOutput(notification)) - assert.JSONEq(t, `{"id":"123","subject":{"title":"Title","type":"Issue"}}`, after) + assert.JSONEq(t, `{"id":"123","subject":{"title":"Title","type":"Issue","url":"https://api.github.com/repos/owner/repo/issues/1"}}`, after) assert.Less(t, len(after), len(before)) assert.Contains(t, mustMarshalJSON(t, notification), "latest_comment_url", "legacy formatter must retain its existing links") t.Logf("populated notification projection: %d -> %d JSON bytes", len(before), len(after)) @@ -240,17 +239,32 @@ func TestDiscussionEmptyCollections(t *testing.T) { } } -func TestNotificationOutputEnumsAndHypermediaSchemas(t *testing.T) { +func TestNotificationOutputProviderValuesAndReferenceSchema(t *testing.T) { schema := notificationOutputSchema(false) assert.NotContains(t, schema.Properties, "url") - assert.NotContains(t, schema.Properties["subject"].Properties, "url") + assert.Contains(t, schema.Properties["subject"].Properties, "url") assert.NotContains(t, schema.Properties["subject"].Properties, "latest_comment_url") assert.Contains(t, schema.Properties["repository"].Properties, "html_url") resolved, err := schema.Resolve(nil) require.NoError(t, err) - assert.NoError(t, resolved.Validate(map[string]any{"reason": "mention", "subject": map[string]any{"type": "Discussion"}})) - assert.Error(t, resolved.Validate(map[string]any{"reason": "invalid"})) - assert.Error(t, resolved.Validate(map[string]any{"subject": map[string]any{"type": "invalid"}})) + assert.Empty(t, schema.Properties["reason"].Enum) + assert.Empty(t, schema.Properties["subject"].Properties["type"].Enum) + for _, reason := range []string{ + "agent_session_finished", "approval_requested", "assign", "author", "ci_activity", + "comment", "invitation", "manual", "member_feature_requested", "mention", + "review_requested", "security_advisory_credit", "security_alert", + "state_change", "subscribed", "team_mention", "future_reason", + } { + assert.NoError(t, resolved.Validate(map[string]any{"reason": reason}), reason) + } + for _, subjectType := range []string{ + "CheckSuite", "Commit", "Discussion", "Issue", "PullRequest", "Release", + "RepositoryInvitation", "SecurityAdvisory", "FutureSubject", + } { + assert.NoError(t, resolved.Validate(map[string]any{"subject": map[string]any{"type": subjectType}}), subjectType) + } + assert.Error(t, resolved.Validate(map[string]any{"reason": 42})) + assert.Error(t, resolved.Validate(map[string]any{"subject": map[string]any{"type": 42}})) subscription := discussionNotificationOutputSchema[NotificationSubscriptionOutput]() for _, field := range []string{"url", "thread_url", "repository_url"} { assert.NotContains(t, subscription.Properties, field) diff --git a/pkg/github/discussion_notification_outputs.go b/pkg/github/discussion_notification_outputs.go index 47f8f47344..ebe4e4c8ad 100644 --- a/pkg/github/discussion_notification_outputs.go +++ b/pkg/github/discussion_notification_outputs.go @@ -10,7 +10,8 @@ import ( type NotificationSubjectOutput struct { Title *string `json:"title,omitempty"` - Type *string `json:"type,omitempty"` + Type *string `json:"type,omitempty" jsonschema:"Subject type reported by GitHub; new provider values are preserved."` + URL *string `json:"url,omitempty" jsonschema:"API reference to the underlying notification subject."` } type NotificationRepositoryOutput struct { @@ -25,7 +26,7 @@ type NotificationOutput struct { ID *string `json:"id,omitempty"` Repository *NotificationRepositoryOutput `json:"repository,omitempty"` Subject *NotificationSubjectOutput `json:"subject,omitempty"` - Reason *string `json:"reason,omitempty"` + Reason *string `json:"reason,omitempty" jsonschema:"Notification reason reported by GitHub; new provider values are preserved."` Unread *bool `json:"unread,omitempty"` UpdatedAt *time.Time `json:"updated_at,omitempty" jsonschema:"Last update time (RFC3339)."` LastReadAt *time.Time `json:"last_read_at,omitempty" jsonschema:"Last acknowledgement time (RFC3339)."` @@ -46,7 +47,7 @@ func notificationOutput(n *github.Notification) *NotificationOutput { } if n.Subject != nil { out.Subject = &NotificationSubjectOutput{ - Title: n.Subject.Title, Type: n.Subject.Type, + Title: n.Subject.Title, Type: n.Subject.Type, URL: n.Subject.URL, } } if n.Repository != nil { @@ -128,16 +129,6 @@ func discussionNotificationOutputSchema[Out any]() *jsonschema.Schema { func notificationOutputSchema(list bool) *jsonschema.Schema { schema := discussionNotificationOutputSchema[NotificationOutput]() - schema.Properties["subject"].Properties["type"].Enum = []any{ - "CheckSuite", "Commit", "Discussion", "Issue", "PullRequest", "Release", - "RepositoryInvitation", "SecurityAdvisory", - } - schema.Properties["reason"].Enum = []any{ - "approval_requested", "assign", "author", "ci_activity", "comment", - "invitation", "manual", "member_feature_requested", "mention", - "review_requested", "security_alert", "security_advisory_credit", - "state_change", "subscribed", "team_mention", - } if list { schema.Type = "" schema.Types = []string{"object", "null"} diff --git a/pkg/github/find_duplicate.go b/pkg/github/find_duplicate.go index 1dc43923f9..2077bf7933 100644 --- a/pkg/github/find_duplicate.go +++ b/pkg/github/find_duplicate.go @@ -62,6 +62,10 @@ func FindDuplicate(t translations.TranslationHelperFunc) inventory.ServerTool { Required: []string{"owner", "repo", "issue_number"}, } WithPagination(schema) + schema.Properties["page"].Minimum = new(0.0) + schema.Properties["page"].Description = "Page number for pagination (min 0). Zero is forwarded for the GitHub API default." + schema.Properties["perPage"].Minimum = new(0.0) + schema.Properties["perPage"].Description = "Results per page for pagination (min 0, max 100). Zero is forwarded for the GitHub API default." st := NewToolWithSchemaOptions[FindDuplicateInput, []DuplicateCandidate]( ToolsetMetadataIssues, mcp.Tool{ diff --git a/pkg/github/find_duplicate_test.go b/pkg/github/find_duplicate_test.go index c31a53fd35..640141a924 100644 --- a/pkg/github/find_duplicate_test.go +++ b/pkg/github/find_duplicate_test.go @@ -3,6 +3,7 @@ package github import ( "context" "encoding/json" + "maps" "net/http" "net/url" "testing" @@ -40,11 +41,11 @@ func Test_FindDuplicate(t *testing.T) { assert.Contains(t, schema.Properties, "perPage") assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "issue_number"}) - assert.Equal(t, 1.0, *schema.Properties["page"].Minimum) - assert.Equal(t, 1.0, *schema.Properties["perPage"].Minimum) + assert.Equal(t, 0.0, *schema.Properties["page"].Minimum) + assert.Equal(t, 0.0, *schema.Properties["perPage"].Minimum) assert.Equal(t, 100.0, *schema.Properties["perPage"].Maximum) - assert.Equal(t, "Page number for pagination (min 1)", schema.Properties["page"].Description) - assert.Equal(t, "Results per page for pagination (min 1, max 100)", schema.Properties["perPage"].Description) + assert.Equal(t, "Page number for pagination (min 0). Zero is forwarded for the GitHub API default.", schema.Properties["page"].Description) + assert.Equal(t, "Results per page for pagination (min 0, max 100). Zero is forwarded for the GitHub API default.", schema.Properties["perPage"].Description) } func Test_FindDuplicate_RankedResults(t *testing.T) { @@ -156,9 +157,7 @@ func Test_FindDuplicate_ZeroPaginationIsForwarded(t *testing.T) { assert.Equal(t, "page=0&per_page=0&threshold=0", capturedRawQuery) advertised := serverTool.Tool.InputSchema.(*jsonschema.Schema) - assert.Equal(t, 1.0, *advertised.Properties["page"].Minimum) - assert.Equal(t, 1.0, *advertised.Properties["perPage"].Minimum) - validation, err := issuePaginationValidationSchema(advertised).Resolve(nil) + validation, err := advertised.Resolve(nil) require.NoError(t, err) require.NoError(t, validation.Validate(map[string]any{ "owner": "owner", "repo": "repo", "issue_number": float64(123), @@ -167,6 +166,50 @@ func Test_FindDuplicate_ZeroPaginationIsForwarded(t *testing.T) { })) } +func Test_FindDuplicate_PaginationContracts(t *testing.T) { + serverTool := FindDuplicate(translations.NullTranslationHelper) + advertised := serverTool.Tool.InputSchema.(*jsonschema.Schema) + for name, schema := range map[string]*jsonschema.Schema{ + "advertised": advertised, + "runtime": issuePaginationValidationSchema(advertised), + } { + t.Run(name, func(t *testing.T) { + resolved, err := schema.Resolve(nil) + require.NoError(t, err) + for _, tc := range []struct { + name string + params map[string]any + valid bool + }{ + {"omitted", map[string]any{}, true}, + {"zero", map[string]any{"page": float64(0), "perPage": float64(0)}, true}, + {"positive", map[string]any{"page": float64(1), "perPage": float64(30)}, true}, + {"maximum perPage", map[string]any{"page": float64(101), "perPage": float64(100)}, true}, + {"negative page", map[string]any{"page": float64(-1)}, false}, + {"negative perPage", map[string]any{"perPage": float64(-1)}, false}, + {"excessive perPage", map[string]any{"perPage": float64(101)}, false}, + {"invalid page type", map[string]any{"page": "invalid"}, false}, + {"invalid perPage type", map[string]any{"perPage": "invalid"}, false}, + } { + t.Run(tc.name, func(t *testing.T) { + args := map[string]any{"owner": "owner", "repo": "repo", "issue_number": float64(123)} + maps.Copy(args, tc.params) + if tc.valid { + assert.NoError(t, resolved.Validate(args)) + } else { + assert.Error(t, resolved.Validate(args)) + } + }) + } + for _, field := range schema.Required { + args := map[string]any{"owner": "owner", "repo": "repo", "issue_number": float64(123)} + delete(args, field) + assert.Error(t, resolved.Validate(args), field) + } + }) + } +} + // Test_FindDuplicate_SanitizesIssueTitle asserts that candidate issue titles, which are // user-authored content from an arbitrary repository, are sanitized before being returned. // Without this the tool would forward hidden-instruction payloads straight to the model. diff --git a/pkg/github/git_gist_types.go b/pkg/github/git_gist_types.go index e28f8782e9..5fdc6bab89 100644 --- a/pkg/github/git_gist_types.go +++ b/pkg/github/git_gist_types.go @@ -150,6 +150,7 @@ type GistOutput struct { Files map[string]GistFileOutput `json:"files,omitempty"` Comments *int `json:"comments,omitempty"` HTMLURL *string `json:"html_url,omitempty"` + GitPullURL *string `json:"git_pull_url,omitempty"` CreatedAt string `json:"created_at,omitempty"` UpdatedAt string `json:"updated_at,omitempty"` } @@ -168,6 +169,7 @@ type GistFileOutput struct { Size *int `json:"size,omitempty"` Language *string `json:"language,omitempty"` Type *string `json:"type,omitempty"` + RawURL *string `json:"raw_url,omitempty"` } func projectGist(gist *github.Gist) *GistOutput { @@ -176,13 +178,13 @@ func projectGist(gist *github.Gist) *GistOutput { } output := &GistOutput{ ID: gist.ID, Description: gist.Description, Public: gist.Public, - Owner: convertToMinimalUser(gist.Owner), Comments: gist.Comments, HTMLURL: gist.HTMLURL, + Owner: convertToMinimalUser(gist.Owner), Comments: gist.Comments, HTMLURL: gist.HTMLURL, GitPullURL: gist.GitPullURL, } if len(gist.Files) != 0 { output.Files = make(map[string]GistFileOutput, len(gist.Files)) for name, file := range gist.Files { output.Files[string(name)] = GistFileOutput{ - Filename: file.Filename, Content: file.Content, Size: file.Size, Language: file.Language, Type: file.Type, + Filename: file.Filename, Content: file.Content, Size: file.Size, Language: file.Language, Type: file.Type, RawURL: file.RawURL, } } } diff --git a/pkg/github/granular_issue_types.go b/pkg/github/granular_issue_types.go index 3b1d544cb5..9b39158d62 100644 --- a/pkg/github/granular_issue_types.go +++ b/pkg/github/granular_issue_types.go @@ -728,6 +728,9 @@ func normalizeGranularUpdateIssueStateArguments(raw json.RawMessage) (json.RawMe if isSuggestion && stateReason == "duplicate" && duplicateOf == 0 { return nil, fmt.Errorf("duplicate_of is required when suggesting a close as duplicate") } + if duplicateOf == 0 { + delete(args, "duplicate_of") + } return marshalGranularIssueArguments(args, GranularIssueStateInput{GranularIssueCoordinate: GranularIssueCoordinate{Owner: owner, Repo: repo, IssueNumber: issueNumber}, State: state, StateReason: stateReason, Rationale: rationale, Confidence: confidence, IsSuggestion: isSuggestion, DuplicateOf: duplicateOf}) } @@ -819,10 +822,26 @@ func normalizeGranularReprioritizeSubIssueArguments(raw json.RawMessage) (json.R if err != nil { return nil, err } + if afterID == 0 { + delete(args, "after_id") + } + if beforeID == 0 { + delete(args, "before_id") + } return marshalGranularIssueArguments(args, GranularReprioritizeSubIssueInput{GranularIssueCoordinate: GranularIssueCoordinate{Owner: owner, Repo: repo, IssueNumber: issueNumber}, SubIssueID: subIssueID, AfterID: afterID, BeforeID: beforeID}) } +func granularIssueInputNormalizer(normalize inventory.InputNormalizer) inventory.InputNormalizer { + return func(raw json.RawMessage) (json.RawMessage, error) { + normalized, err := normalize(raw) + if err != nil { + return nil, &inventory.ToolInputError{Message: err.Error()} + } + return normalized, nil + } +} + func normalizeGranularSetIssueFieldsArguments(raw json.RawMessage) (json.RawMessage, error) { var args map[string]any if err := json.Unmarshal(raw, &args); err != nil { diff --git a/pkg/github/granular_pull_request_types.go b/pkg/github/granular_pull_request_types.go index e11da7b1f0..c07c2319d2 100644 --- a/pkg/github/granular_pull_request_types.go +++ b/pkg/github/granular_pull_request_types.go @@ -92,11 +92,7 @@ type GranularRemovePullRequestCommentReactionInput struct { ReactionID int64 `json:"reaction_id"` } -// MinimalPullRequestCommentReaction omits the derivable REST reaction URL. -type MinimalPullRequestCommentReaction struct { - ID int64 `json:"id"` - Content PullRequestCommentReactionType `json:"content,omitempty"` -} +type MinimalPullRequestCommentReaction = MinimalResponse type PullRequestCommentReactionType string @@ -149,8 +145,7 @@ func granularPullRequestEnumSchema(values []string) *jsonschema.Schema { func minimalPullRequestCommentReactionSchema() *jsonschema.Schema { schema := repositoryOutputSchema[MinimalPullRequestCommentReaction]() schema.Properties["id"].Description = "Reaction ID" - schema.Properties["content"].Description = "Reaction type" - schema.Properties["content"].Enum = PullRequestCommentReactionType("").JSONSchema().Enum + schema.Properties["url"].Description = "Reaction URL" return schema } diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 86fb714c6c..c19c668e32 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -2228,7 +2228,7 @@ type SearchIssueOutputItem struct { Type *string `json:"type,omitempty"` RepositoryURL *string `json:"repository_url,omitempty"` PullRequest *SearchIssuePullRequest `json:"pull_request,omitempty"` - FieldValues []MinimalFieldValue `json:"field_values,omitempty"` + FieldValues *[]MinimalFieldValue `json:"field_values,omitempty"` } type SearchIssuePullRequest struct { @@ -2337,7 +2337,7 @@ func searchIssueOutputItem(result SearchIssueResult, fields []string) SearchIssu } } if selected("field_values") && result.FieldValues != nil { - output.FieldValues = result.FieldValues + output.FieldValues = &result.FieldValues } return output } diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go index da9a0d186a..af26c82f69 100644 --- a/pkg/github/issues_granular.go +++ b/pkg/github/issues_granular.go @@ -185,7 +185,7 @@ func GranularCreateIssue(t translations.TranslationHelperFunc) inventory.ServerT URL: issue.GetHTMLURL(), }) }, - normalizeGranularCreateIssueArguments, + granularIssueInputNormalizer(normalizeGranularCreateIssueArguments), ) st.FeatureRule = issuesGranularFeatureRule return st @@ -204,7 +204,7 @@ func GranularUpdateIssueTitle(t translations.TranslationHelperFunc) inventory.Se func(args GranularIssueTitleInput) github.UpdateIssueRequest { return github.UpdateIssueRequest{Title: &args.Title} }, - normalizeGranularIssueUpdateArguments("title"), + granularIssueInputNormalizer(normalizeGranularIssueUpdateArguments("title")), ) } @@ -221,7 +221,7 @@ func GranularUpdateIssueBody(t translations.TranslationHelperFunc) inventory.Ser func(args GranularIssueBodyInput) github.UpdateIssueRequest { return github.UpdateIssueRequest{Body: &args.Body} }, - normalizeGranularIssueUpdateArguments("body"), + granularIssueInputNormalizer(normalizeGranularIssueUpdateArguments("body")), ) } @@ -322,7 +322,7 @@ func GranularUpdateIssueAssignees(t translations.TranslationHelperFunc) inventor URL: issue.GetHTMLURL(), }) }, - normalizeGranularUpdateIssueAssigneesArguments, + granularIssueInputNormalizer(normalizeGranularUpdateIssueAssigneesArguments), ) st.FeatureRule = issuesGranularFeatureRule return st @@ -457,7 +457,7 @@ func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.S URL: issue.GetHTMLURL(), }) }, - normalizeGranularUpdateIssueLabelsArguments, + granularIssueInputNormalizer(normalizeGranularUpdateIssueLabelsArguments), ) st.FeatureRule = issuesGranularFeatureRule return st @@ -480,7 +480,7 @@ func GranularUpdateIssueMilestone(t translations.TranslationHelperFunc) inventor func(args GranularIssueMilestoneInput) github.UpdateIssueRequest { return github.UpdateIssueRequest{Milestone: &args.Milestone} }, - normalizeGranularIssueUpdateArguments("milestone"), + granularIssueInputNormalizer(normalizeGranularIssueUpdateArguments("milestone")), ) } @@ -600,7 +600,7 @@ func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.Ser URL: issue.GetHTMLURL(), }) }, - normalizeGranularUpdateIssueTypeArguments, + granularIssueInputNormalizer(normalizeGranularUpdateIssueTypeArguments), ) st.FeatureRule = issuesGranularFeatureRule return st @@ -745,7 +745,7 @@ func GranularUpdateIssueState(t translations.TranslationHelperFunc) inventory.Se URL: issue.GetHTMLURL(), }) }, - normalizeGranularUpdateIssueStateArguments, + granularIssueInputNormalizer(normalizeGranularUpdateIssueStateArguments), ) st.FeatureRule = issuesGranularFeatureRule return st @@ -805,7 +805,7 @@ func GranularAddSubIssue(t translations.TranslationHelperFunc) inventory.ServerT result, output, err := addSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, replaceParent) return subIssueWriteResult("add")(result, output, err) }, - normalizeGranularAddSubIssueArguments, + granularIssueInputNormalizer(normalizeGranularAddSubIssueArguments), ) st.FeatureRule = issuesGranularFeatureRule return st @@ -861,7 +861,7 @@ func GranularRemoveSubIssue(t translations.TranslationHelperFunc) inventory.Serv result, output, err := removeSubIssue(ctx, client, owner, repo, issueNumber, subIssueID) return subIssueWriteResult("remove")(result, output, err) }, - normalizeGranularRemoveSubIssueArguments, + granularIssueInputNormalizer(normalizeGranularRemoveSubIssueArguments), ) st.FeatureRule = issuesGranularFeatureRule return st @@ -925,7 +925,7 @@ func GranularReprioritizeSubIssue(t translations.TranslationHelperFunc) inventor result, output, err := reprioritizeSubIssue(ctx, client, owner, repo, issueNumber, subIssueID, afterID, beforeID) return subIssueWriteResult("reprioritize")(result, output, err) }, - normalizeGranularReprioritizeSubIssueArguments, + granularIssueInputNormalizer(normalizeGranularReprioritizeSubIssueArguments), ) st.FeatureRule = issuesGranularFeatureRule return st @@ -1087,7 +1087,7 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv return granularIssueMinimalResult(&response) }, - normalizeGranularSetIssueFieldsArguments, + granularIssueInputNormalizer(normalizeGranularSetIssueFieldsArguments), ) st.FeatureRule = issuesGranularFeatureRule return st @@ -1151,7 +1151,7 @@ func GranularAddIssueReaction(t translations.TranslationHelperFunc) inventory.Se URL: fmt.Sprintf("%srepos/%s/%s/issues/%d/reactions/%d", client.BaseURL(), owner, repo, issueNumber, reaction.GetID()), }) }, - normalizeGranularAddIssueReactionArguments, + granularIssueInputNormalizer(normalizeGranularAddIssueReactionArguments), ) st.FeatureRule = issuesGranularFeatureRule return st @@ -1214,7 +1214,7 @@ func GranularRemoveIssueReaction(t translations.TranslationHelperFunc) inventory return utils.NewToolResultText("reaction successfully removed from issue"), &GranularIssueReactionMessage{Message: "reaction successfully removed from issue"}, nil }, - normalizeGranularRemoveIssueReactionArguments, + granularIssueInputNormalizer(normalizeGranularRemoveIssueReactionArguments), ) st.FeatureRule = issuesGranularFeatureRule return st @@ -1278,7 +1278,7 @@ func GranularAddIssueCommentReaction(t translations.TranslationHelperFunc) inven URL: fmt.Sprintf("%srepos/%s/%s/issues/comments/%d/reactions/%d", client.BaseURL(), owner, repo, commentID, reaction.GetID()), }) }, - normalizeGranularAddIssueCommentReactionArguments, + granularIssueInputNormalizer(normalizeGranularAddIssueCommentReactionArguments), ) st.FeatureRule = issuesGranularFeatureRule return st @@ -1341,7 +1341,7 @@ func GranularRemoveIssueCommentReaction(t translations.TranslationHelperFunc) in return utils.NewToolResultText("reaction successfully removed from issue comment"), &GranularIssueReactionMessage{Message: "reaction successfully removed from issue comment"}, nil }, - normalizeGranularRemoveIssueCommentReactionArguments, + granularIssueInputNormalizer(normalizeGranularRemoveIssueCommentReactionArguments), ) st.FeatureRule = issuesGranularFeatureRule return st diff --git a/pkg/github/method_output_schema.go b/pkg/github/method_output_schema.go new file mode 100644 index 0000000000..c6b99cc458 --- /dev/null +++ b/pkg/github/method_output_schema.go @@ -0,0 +1,26 @@ +package github + +import ( + "slices" + + "github.com/google/jsonschema-go/jsonschema" +) + +func constrainMethodOutput(schema *jsonschema.Schema, payloadByMethod map[string]string, optionalPayloads ...string) { + schema.Required = []string{"method"} + for _, method := range schema.Properties["method"].Enum { + payload := payloadByMethod[method.(string)] + variant := &jsonschema.Schema{Properties: map[string]*jsonschema.Schema{ + "method": {Enum: []any{method}}, + }} + if !slices.Contains(optionalPayloads, payload) { + variant.Required = []string{payload} + } + for _, other := range payloadByMethod { + if other != payload { + variant.Properties[other] = forbiddenIssueVariantProperty() + } + } + schema.OneOf = append(schema.OneOf, variant) + } +} diff --git a/pkg/github/notification_output_fidelity_test.go b/pkg/github/notification_output_fidelity_test.go new file mode 100644 index 0000000000..35f29738c2 --- /dev/null +++ b/pkg/github/notification_output_fidelity_test.go @@ -0,0 +1,77 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v92/github" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNotificationOutputFidelity(t *testing.T) { + for _, subjectType := range []string{"Issue", "PullRequest", "Discussion", "FutureSubject"} { + t.Run(subjectType, func(t *testing.T) { + notification := &github.Notification{ + ID: new("123"), Reason: new("agent_session_finished"), + Subject: &github.NotificationSubject{ + Type: new(subjectType), + URL: new("https://github.example/api/v3/repos/owner/repo/issues/42"), + LatestCommentURL: new("legacy-only-comment-url"), + }, + URL: new("legacy-only-thread-url"), + } + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetNotifications: mockResponse(t, http.StatusOK, []*github.Notification{notification}), + GetReposNotificationsByOwnerByRepo: mockResponse(t, http.StatusOK, []*github.Notification{notification}), + GetNotificationsThreadsByThreadID: mockResponse(t, http.StatusOK, notification), + }))} + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + server := mcp.NewServer(&mcp.Implementation{Name: "notification-fidelity", Version: "v1"}, nil) + server.AddReceivingMiddleware(InjectDepsMiddleware(deps)) + for _, tool := range []inventory.ServerTool{ + ListNotifications(translations.NullTranslationHelper), + GetNotificationDetails(translations.NullTranslationHelper), + } { + tool.RegisterFunc(server, deps) + } + client := connectCommentVisibilityClient(t, server, protocol) + for _, call := range []mcp.CallToolParams{ + {Name: "list_notifications", Arguments: map[string]any{}}, + {Name: "list_notifications", Arguments: map[string]any{"owner": "owner", "repo": "repo"}}, + {Name: "get_notification_details", Arguments: map[string]any{"notificationID": "123"}}, + } { + result, err := client.CallTool(context.Background(), &call) + require.NoError(t, err) + require.False(t, result.IsError, "%s", getTextResult(t, result).Text) + if protocol == "2025-11-25" { + var expected any = notification + if call.Name == "list_notifications" { + expected = []*github.Notification{notification} + } + assert.Equal(t, mustMarshalJSON(t, expected), getTextResult(t, result).Text) + assert.Nil(t, result.StructuredContent) + continue + } + require.NotNil(t, result.StructuredContent) + text := mustMarshalJSON(t, result.StructuredContent) + assert.JSONEq(t, text, getTextResult(t, result).Text) + assert.Contains(t, text, `"reason":"agent_session_finished"`) + assert.Contains(t, text, `"type":"`+subjectType+`"`) + assert.Contains(t, text, `"url":"`+notification.Subject.GetURL()+`"`) + assert.NotContains(t, text, "legacy-only") + resolved, err := notificationOutputSchema(call.Name == "list_notifications").Resolve(nil) + require.NoError(t, err) + var output any + require.NoError(t, json.Unmarshal([]byte(text), &output)) + require.NoError(t, resolved.Validate(output)) + } + } + }) + } +} diff --git a/pkg/github/projects_types.go b/pkg/github/projects_types.go index 6d8fded25e..ecd92c4b48 100644 --- a/pkg/github/projects_types.go +++ b/pkg/github/projects_types.go @@ -413,9 +413,12 @@ func projectsWriteOutputSchema() *jsonschema.Schema { } func projectOutputSchema[T any]() *jsonschema.Schema { + user := repositoryOutputSchema[MinimalUser]() + delete(user.Properties, "details") generated, err := inventory.CachedSchemaFor[T](&jsonschema.ForOptions{ TypeSchemas: map[reflect.Type]*jsonschema.Schema{ reflect.TypeFor[github.Timestamp](): {Type: "string", Format: "date-time"}, + reflect.TypeFor[MinimalUser](): user, }, }) if err != nil { diff --git a/pkg/github/pullrequests_granular.go b/pkg/github/pullrequests_granular.go index a49789ca3e..0ebd93b797 100644 --- a/pkg/github/pullrequests_granular.go +++ b/pkg/github/pullrequests_granular.go @@ -772,9 +772,7 @@ func GranularAddPullRequestReviewCommentReaction(t translations.TranslationHelpe if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), &MinimalPullRequestCommentReaction{ - ID: reaction.GetID(), Content: PullRequestCommentReactionType(reaction.GetContent()), - }, nil + return utils.NewToolResultText(string(r)), output, nil }, normalizeGranularPullRequestArguments("add_reaction"), ) diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index f374713a01..d0fcdd8299 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -36,7 +36,31 @@ type GetCommitInput struct { } func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { - return NewTool[GetCommitInput, MinimalCommit]( + schema := WithPagination(&jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "sha": { + Type: "string", + Description: "Commit SHA, branch name, or tag name", + }, + "detail": { + Type: "string", + Enum: []any{"none", "stats", "full_patch"}, + Description: "Level of detail to include for changed files. \"none\" omits stats and files entirely. \"stats\" (default) includes per-file metadata: filename, status, and lines-of-code counts (additions, deletions, changes), with no patch content. \"full_patch\" additionally includes the unified diff content for each file and can be very large.", + Default: json.RawMessage(`"stats"`), + }, + }, + Required: []string{"owner", "repo", "sha"}, + }) + return NewToolWithSchemaOptions[GetCommitInput, MinimalCommit]( ToolsetMetadataRepos, mcp.Tool{ Name: "get_commit", @@ -45,32 +69,10 @@ func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { Title: t("TOOL_GET_COMMITS_USER_TITLE", "Get commit details"), ReadOnlyHint: true, }, - InputSchema: WithPagination(&jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "owner": { - Type: "string", - Description: "Repository owner", - }, - "repo": { - Type: "string", - Description: "Repository name", - }, - "sha": { - Type: "string", - Description: "Commit SHA, branch name, or tag name", - }, - "detail": { - Type: "string", - Enum: []any{"none", "stats", "full_patch"}, - Description: "Level of detail to include for changed files. \"none\" omits stats and files entirely. \"stats\" (default) includes per-file metadata: filename, status, and lines-of-code counts (additions, deletions, changes), with no patch content. \"full_patch\" additionally includes the unified diff content for each file and can be very large.", - Default: json.RawMessage(`"stats"`), - }, - }, - Required: []string{"owner", "repo", "sha"}, - }), + InputSchema: schema, }, scopes.PublicRead(scopes.Repo), + inventory.TypedSchemaOptions{ValidationInputSchema: relaxedPaginationValidationSchema(schema)}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input GetCommitInput) (*mcp.CallToolResult, MinimalCommit, error) { if input.Owner == "" { return utils.NewToolResultError("missing required parameter: owner"), MinimalCommit{}, nil @@ -231,7 +233,7 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { ) WithPagination(schema) - return NewTool[ListCommitsInput, []ListCommitOutput]( + return NewToolWithSchemaOptions[ListCommitsInput, []ListCommitOutput]( ToolsetMetadataRepos, mcp.Tool{ Name: "list_commits", @@ -243,6 +245,7 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { InputSchema: schema, }, scopes.PublicRead(scopes.Repo), + inventory.TypedSchemaOptions{ValidationInputSchema: relaxedPaginationValidationSchema(schema)}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, input ListCommitsInput) (*mcp.CallToolResult, []ListCommitOutput, error) { if input.Owner == "" { return utils.NewToolResultError("missing required parameter: owner"), nil, nil @@ -340,6 +343,7 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { return result, structuredListCommitsOutput(minimalCommits, input.Fields), nil }, normalizeTypedReadArguments(nil, false), + normalizeRepositoryFieldsArguments, ) } @@ -1210,6 +1214,13 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool if err := json.Unmarshal(r, &output); err != nil { return nil, nil, fmt.Errorf("failed to decode typed directory contents: %w", err) } + if len(fields) == 0 { + for _, entry := range output { + if entry != nil { + entry.URL, entry.GitURL = nil, nil + } + } + } return attachIFC(utils.NewToolResultText(string(r))), &RepositoryContentsOutput{Directory: output}, nil } @@ -3039,7 +3050,9 @@ func GetFileBlame(t translations.TranslationHelperFunc) inventory.ServerTool { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(payload)), &RepositoryBlameOutput{Result: &result}, nil + callResult := utils.NewToolResultText(string(payload)) + callResult = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, callResult, ifc.LabelCommitContents) + return callResult, &RepositoryBlameOutput{Result: &result}, nil }, normalizeRepositoryBlameArguments, ) diff --git a/pkg/github/repository_tool_types.go b/pkg/github/repository_tool_types.go index 022fb0e47e..2a6239a0c4 100644 --- a/pkg/github/repository_tool_types.go +++ b/pkg/github/repository_tool_types.go @@ -220,7 +220,7 @@ type RepositoryContentsOutput struct { } // Optional pointer fields retain projected zero values without reinstating -// omitted fields. API routing/hypermedia URLs never enter structured output. +// omitted fields. API URLs are included only when explicitly projected. type RepositoryDirectoryEntryOutput struct { Type *string `json:"type,omitempty"` Name *string `json:"name,omitempty"` @@ -229,6 +229,8 @@ type RepositoryDirectoryEntryOutput struct { Size *int `json:"size,omitempty" jsonschema:"File size in bytes."` HTMLURL *string `json:"html_url,omitempty"` DownloadURL *string `json:"download_url,omitempty"` + URL *string `json:"url,omitempty"` + GitURL *string `json:"git_url,omitempty"` Target *string `json:"target,omitempty"` SubmoduleGitURL *string `json:"submodule_git_url,omitempty" jsonschema:"Git remote URL for a submodule."` } diff --git a/pkg/github/search.go b/pkg/github/search.go index 87ab08b712..7d86f0a5e3 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -40,7 +40,7 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo }, "minimal_output": { Type: "boolean", - Description: "Return minimal repository information (default: true). When false, returns full GitHub API repository objects.", + Description: "Return minimal repository information (default: true). When false, modern clients receive additional curated repository details, not the complete GitHub API object. Legacy clients retain full GitHub API repository objects.", Default: json.RawMessage(`true`), }, }, diff --git a/pkg/github/security_outputs.go b/pkg/github/security_outputs.go index 5c25d86ccb..def9716271 100644 --- a/pkg/github/security_outputs.go +++ b/pkg/github/security_outputs.go @@ -175,6 +175,8 @@ type SecretScanningAlertOutput struct { Validity *string `json:"validity,omitempty" jsonschema:"Credential validity reported by GitHub: active, inactive, or unknown."` ResolutionComment *string `json:"resolution_comment,omitempty"` PushProtectionBypassed *bool `json:"push_protection_bypassed,omitempty" jsonschema:"Whether push protection was bypassed for this secret."` + IsBase64Encoded *bool `json:"is_base64_encoded,omitempty" jsonschema:"Whether the detected secret is base64 encoded."` + HasMoreLocations *bool `json:"has_more_locations,omitempty" jsonschema:"Whether additional secret locations exist beyond the first location."` } type SecurityPackageOutput struct { @@ -196,11 +198,12 @@ type DependabotDependencyOutput struct { } type DependabotAdvisoryOutput struct { - GHSAID *string `json:"ghsa_id,omitempty"` - CVEID *string `json:"cve_id,omitempty"` - Summary *string `json:"summary,omitempty"` - Description *string `json:"description,omitempty"` - Severity *string `json:"severity,omitempty"` + GHSAID *string `json:"ghsa_id,omitempty"` + CVEID *string `json:"cve_id,omitempty"` + Summary *string `json:"summary,omitempty"` + Description *string `json:"description,omitempty"` + Severity *string `json:"severity,omitempty"` + Classification *string `json:"classification,omitempty" jsonschema:"GitHub advisory classification, such as malware or general."` } type DependabotAlertOutput struct { @@ -381,6 +384,7 @@ func secretScanningAlertOutput(alert *github.SecretScanningAlert) *SecretScannin Secret: alert.Secret, HTMLURL: alert.HTMLURL, Validity: alert.Validity, ResolutionComment: alert.ResolutionComment, PushProtectionBypassed: alert.PushProtectionBypassed, + IsBase64Encoded: alert.IsBase64Encoded, HasMoreLocations: alert.HasMoreLocations, } if location := alert.FirstLocationDetected; location != nil { output.FirstLocationDetected = &SecretScanningLocationOutput{ @@ -430,6 +434,7 @@ func dependabotAlertOutput(alert *github.DependabotAlert) *DependabotAlertOutput output.SecurityAdvisory = &DependabotAdvisoryOutput{ GHSAID: advisory.GHSAID, CVEID: advisory.CVEID, Summary: advisory.Summary, Description: advisory.Description, Severity: advisory.Severity, + Classification: advisory.Classification, } } output.SecurityVulnerability = securityVulnerabilityOutput(alert.SecurityVulnerability) @@ -476,6 +481,13 @@ func globalSecurityAdvisoryOutput(advisory *github.GlobalSecurityAdvisory) *Glob SecurityAdvisoryOutput: *securityAdvisoryOutput(&advisory.SecurityAdvisory), Type: advisory.Type, SourceCodeLocation: advisory.SourceCodeLocation, } + if len(output.CWEIDs) == 0 { + for _, cwe := range advisory.CWEs { + if cwe != nil && cwe.CWEID != nil { + output.CWEIDs = append(output.CWEIDs, *cwe.CWEID) + } + } + } if advisory.Vulnerabilities != nil { output.Vulnerabilities = mapSecurityOutputs(advisory.Vulnerabilities, globalAdvisoryVulnerabilityOutput) } diff --git a/pkg/github/typed_actions_outputs_test.go b/pkg/github/typed_actions_outputs_test.go index dadae042c5..599eca1feb 100644 --- a/pkg/github/typed_actions_outputs_test.go +++ b/pkg/github/typed_actions_outputs_test.go @@ -449,7 +449,7 @@ func TestActionsConcreteSchemas(t *testing.T) { if name != "logs" { assert.Equal(t, "object", schema.Type) assert.Empty(t, schema.AnyOf) - assert.Empty(t, schema.OneOf) + assert.Len(t, schema.OneOf, len(schema.Properties["method"].Enum)) require.NotEmpty(t, schema.Properties["method"].Enum) } resolved, err := schema.Resolve(nil) diff --git a/pkg/github/typed_compatibility_test.go b/pkg/github/typed_compatibility_test.go new file mode 100644 index 0000000000..d89836b9ce --- /dev/null +++ b/pkg/github/typed_compatibility_test.go @@ -0,0 +1,288 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "maps" + "net/http" + "testing" + + "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/pkg/ifc" + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestTypedCompatibilityCommitInputs(t *testing.T) { + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + for _, tool := range []inventory.ServerTool{GetCommit(translations.NullTranslationHelper), ListCommits(translations.NullTranslationHelper)} { + for _, pagination := range []map[string]any{ + {"page": -1, "perPage": 101}, + {"page": "-1", "perPage": "-2"}, + {"page": 2, "perPage": 101}, + {"page": "2", "perPage": "-2"}, + {"fields": nil}, + } { + t.Run(protocol+"/"+tool.Tool.Name+"/"+mustMarshalJSON(t, pagination), func(t *testing.T) { + calls := 0 + handler := func(w http.ResponseWriter, r *http.Request) { + calls++ + if pagination["page"] != nil { + assert.Equal(t, fmt.Sprint(pagination["page"]), r.URL.Query().Get("page")) + assert.Equal(t, fmt.Sprint(pagination["perPage"]), r.URL.Query().Get("per_page")) + } + var response any = &github.RepositoryCommit{SHA: new("abc")} + if tool.Tool.Name == "list_commits" { + response = []*github.RepositoryCommit{{SHA: new("abc")}} + } + mockResponse(t, http.StatusOK, response)(w, r) + } + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepoByRef: handler, + GetReposCommitsByOwnerByRepo: handler, + }))} + args := map[string]any{"owner": "owner", "repo": "repo", "sha": "abc"} + maps.Copy(args, pagination) + session := connectTypedSearchClient(t, tool, deps, protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tool.Tool.Name, Arguments: args}) + require.NoError(t, err) + require.False(t, result.IsError, mustMarshalJSON(t, result)) + assert.Equal(t, 1, calls) + if protocol == "2025-11-25" { + assert.Nil(t, result.StructuredContent) + } else { + assert.JSONEq(t, getTextResult(t, result).Text, mustMarshalJSON(t, result.StructuredContent)) + } + request := createMCPRequest(args) + direct, err := tool.Handler(deps)(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, direct.IsError, mustMarshalJSON(t, direct)) + assert.Equal(t, 2, calls) + }) + } + } + } +} +func TestTypedCompatibilityGranularOptionalZero(t *testing.T) { + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + for _, tc := range []struct { + tool inventory.ServerTool + args map[string]any + body string + }{ + {GranularReprioritizeSubIssue(translations.NullTranslationHelper), map[string]any{"sub_issue_id": 7, "after_id": "0", "before_id": 8}, `{"sub_issue_id":7,"before_id":8}`}, + {GranularReprioritizeSubIssue(translations.NullTranslationHelper), map[string]any{"sub_issue_id": 7, "after_id": 8, "before_id": "0"}, `{"sub_issue_id":7,"after_id":8}`}, + {GranularUpdateIssueState(translations.NullTranslationHelper), map[string]any{"state": "open", "duplicate_of": "0"}, `{"state":"open"}`}, + } { + t.Run(protocol+"/"+tc.tool.Tool.Name+"/"+tc.body, func(t *testing.T) { + calls := 0 + client := &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls++ + var body any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.JSONEq(t, tc.body, mustMarshalJSON(t, body)) + _, _ = w.Write([]byte(`{"id":1,"number":1}`)) + })}} + deps := BaseDeps{Client: mustNewGHClient(t, client), featureChecker: featureCheckerFor(inventory.FeatureFlag(FeatureFlagIssuesGranular))} + args := map[string]any{"owner": "owner", "repo": "repo", "issue_number": 1} + maps.Copy(args, tc.args) + session := connectTypedSearchClient(t, tc.tool, deps, protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool.Tool.Name, Arguments: args}) + require.NoError(t, err) + require.False(t, result.IsError, mustMarshalJSON(t, result)) + assert.Equal(t, 1, calls) + }) + } + } +} + +func TestTypedCompatibilityGranularInputDiagnostics(t *testing.T) { + for _, tool := range granularToolsForToolset(ToolsetMetadataIssues.ID, FeatureFlagIssuesGranular) { + if tool.Tool.Name == "hide_issue_comment" || tool.Tool.Name == "unhide_issue_comment" { + continue + } + _, err := tool.GetInputNormalizer()(json.RawMessage(`{}`)) + var inputError *inventory.ToolInputError + require.ErrorAs(t, err, &inputError, tool.Tool.Name) + assert.Equal(t, "missing required parameter: owner", inputError.Message, tool.Tool.Name) + } + tool := GranularUpdateIssueState(translations.NullTranslationHelper) + deps := BaseDeps{featureChecker: featureCheckerFor(inventory.FeatureFlag(FeatureFlagIssuesGranular))} + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + session := connectTypedSearchClient(t, tool, deps, protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: tool.Tool.Name, Arguments: map[string]any{"owner": "owner", "repo": "repo", "issue_number": 1, "state": "open", "state_reason": "completed"}, + }) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Equal(t, "state_reason can only be used when state is 'closed'", getErrorResult(t, result).Text) + } +} + +func TestTypedCompatibilityPullRequestAfter(t *testing.T) { + tool := PullRequestRead(translations.NullTranslationHelper) + for _, method := range pullRequestArgumentSpecs["read"].methods { + args := map[string]any{"method": method, "owner": "owner", "repo": "repo", "pullNumber": 1, "after": 42} + normalized, err := tool.GetInputNormalizer()(json.RawMessage(mustMarshalJSON(t, args))) + if method == "get_review_comments" { + require.ErrorContains(t, err, "after") + } else { + require.NoError(t, err) + var parsed map[string]any + require.NoError(t, json.Unmarshal(normalized, &parsed)) + assert.NotContains(t, parsed, "after", method) + } + } + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsFilesByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, []*github.CommitFile{}), + }))} + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + session := connectTypedSearchClient(t, tool, deps, protocol) + for _, method := range []string{"get_files", "get_review_comments"} { + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: tool.Tool.Name, Arguments: map[string]any{"method": method, "owner": "owner", "repo": "repo", "pullNumber": 1, "after": 42}, + }) + require.NoError(t, err) + if method == "get_review_comments" { + assert.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, "after") + } else { + require.False(t, result.IsError, mustMarshalJSON(t, result)) + assert.Equal(t, "[]", getTextResult(t, result).Text) + } + + } + } +} + +func TestTypedCompatibilitySearchFieldValuePresence(t *testing.T) { + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + for _, tool := range []inventory.ServerTool{SearchIssues(translations.NullTranslationHelper), SearchPullRequests(translations.NullTranslationHelper)} { + for _, available := range []bool{false, true} { + if available && tool.Tool.Name == "search_pull_requests" { + continue // Pull request search does not enrich custom issue fields. + } + t.Run(fmt.Sprintf("%s/%s/available=%t", protocol, tool.Tool.Name, available), func(t *testing.T) { + issue := &github.Issue{Number: new(1), Title: new("Subject"), NodeID: new("I_1")} + response := githubv4mock.ErrorResponse("Field 'issueFieldValues' doesn't exist on type 'Issue'") + if available { + response = githubv4mock.DataResponse(map[string]any{"nodes": []any{ + map[string]any{"id": "I_1", "issueFieldValues": map[string]any{"nodes": []any{}}}, + }}) + } + matcher := githubv4mock.NewQueryMatcher(searchIssueFieldValuesQueryString, map[string]any{"ids": []any{"I_1"}}, response) + deps := BaseDeps{ + Client: mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetSearchIssues: mockResponse(t, http.StatusOK, &github.IssuesSearchResult{Total: new(1), IncompleteResults: new(false), Issues: []*github.Issue{issue}}), + })), + GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matcher)), + } + session := connectTypedSearchClient(t, tool, deps, protocol) + for _, fields := range [][]string{nil, {"field_values"}, {"title"}} { + if tool.Tool.Name == "search_pull_requests" && len(fields) > 0 && fields[0] == "field_values" { + continue + } + args := map[string]any{"query": "repo:owner/repo"} + if fields != nil { + args["fields"] = fields + } + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tool.Tool.Name, Arguments: args}) + require.NoError(t, err) + require.False(t, result.IsError, mustMarshalJSON(t, result)) + var output struct { + Items []map[string]json.RawMessage `json:"items"` + } + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &output)) + require.Len(t, output.Items, 1) + if available && (fields == nil || fields[0] == "field_values") { + assert.Equal(t, "[]", string(output.Items[0]["field_values"])) + } else { + assert.NotContains(t, output.Items[0], "field_values") + } + if protocol == inventory.ProtocolVersionMultiRoundTrip { + assert.JSONEq(t, getTextResult(t, result).Text, mustMarshalJSON(t, result.StructuredContent)) + } else { + assert.Nil(t, result.StructuredContent) + } + } + }) + } + } + } +} + +func TestTypedCompatibilityRepositorySearchContract(t *testing.T) { + schema := SearchRepositories(translations.NullTranslationHelper).Tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, schema.Properties["minimal_output"].Description, "modern clients receive additional curated repository details, not the complete GitHub API object") + assert.Contains(t, schema.Properties["minimal_output"].Description, "Legacy clients retain full GitHub API repository objects") +} + +func TestTypedCompatibilityGistRecoveryHandles(t *testing.T) { + var gist github.Gist + require.NoError(t, json.Unmarshal([]byte(gistBody), &gist)) + output := mustMarshalJSON(t, projectGist(&gist)) + assert.Contains(t, output, `"raw_url":"https://gist.githubusercontent.com/raw"`) + assert.Contains(t, output, `"git_pull_url":"https://gist.github.com/abc.git"`) +} + +func TestTypedCompatibilityReviewReactionParity(t *testing.T) { + tool := GranularAddPullRequestReviewCommentReaction(translations.NullTranslationHelper) + deps := BaseDeps{ + featureChecker: featureCheckerFor(inventory.FeatureFlag(FeatureFlagPullRequestsGranular)), + Client: mustNewGHClient(t, &http.Client{Transport: recorderTransport{handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"id":77,"content":"heart"}`)) + })}}), + } + expected := `{"id":"77","url":"https://api.github.com/repos/owner/repo/pulls/comments/42/reactions/77"}` + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + session := connectTypedSearchClient(t, tool, deps, protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: tool.Tool.Name, Arguments: map[string]any{"owner": "owner", "repo": "repo", "comment_id": 42, "content": "heart"}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.JSONEq(t, expected, getTextResult(t, result).Text) + if protocol == inventory.ProtocolVersionMultiRoundTrip { + assert.JSONEq(t, expected, mustMarshalJSON(t, result.StructuredContent)) + } + } +} + +func TestTypedCompatibilityDirectoryProjection(t *testing.T) { + tool := GetFileContents(translations.NullTranslationHelper) + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + session := connectTypedSearchClient(t, tool, typedRepositoryDeps(t), protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: tool.Tool.Name, Arguments: map[string]any{"owner": "owner", "repo": "repo", "path": "directory", "fields": []string{"url", "git_url"}}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.JSONEq(t, `[{"url":"api-content-url","git_url":"api-git-url"}]`, getTextResult(t, result).Text) + } +} + +func TestTypedCompatibilityBlameIFC(t *testing.T) { + for _, private := range []bool{false, true} { + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + deps := typedRepositoryDeps(t) + deps.featureChecker = featureCheckerFor(FeatureFlagIFCLabels) + deps.Client = mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, &github.Repository{Private: &private}), + })) + session := connectTypedSearchClient(t, GetFileBlame(translations.NullTranslationHelper), deps, protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "get_file_blame", Arguments: map[string]any{"owner": "owner", "repo": "repo", "path": "file"}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + assert.JSONEq(t, mustMarshalJSON(t, ifc.LabelCommitContents(private)), mustMarshalJSON(t, result.Meta["ifc"])) + } + } +} diff --git a/pkg/github/typed_contracts_test.go b/pkg/github/typed_contracts_test.go new file mode 100644 index 0000000000..bcb89e0f1e --- /dev/null +++ b/pkg/github/typed_contracts_test.go @@ -0,0 +1,117 @@ +package github + +import ( + "encoding/json" + "testing" + + "github.com/google/go-github/v92/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestActionsOutputMethodContracts(t *testing.T) { + for _, tc := range []struct { + name string + schema *jsonschema.Schema + method string + field string + other string + value string + optional bool + }{ + {"list/workflows", actionsListOutputSchema(), "list_workflows", "workflows", "artifacts", `{}`, true}, + {"list/runs", actionsListOutputSchema(), "list_workflow_runs", "workflow_runs", "workflows", `{"total_count":0,"workflow_runs":[]}`, false}, + {"list/jobs", actionsListOutputSchema(), "list_workflow_jobs", "workflow_jobs", "workflows", `{"total_count":0,"jobs":[]}`, false}, + {"list/artifacts", actionsListOutputSchema(), "list_workflow_run_artifacts", "artifacts", "workflows", `{}`, true}, + {"get/workflow", actionsGetOutputSchema(), "get_workflow", "workflow", "workflow_run", `{}`, true}, + {"get/run", actionsGetOutputSchema(), "get_workflow_run", "workflow_run", "workflow", `{"id":7,"name":"","status":"","workflow_id":0,"head_branch":"","run_number":0,"run_attempt":0}`, false}, + {"get/job", actionsGetOutputSchema(), "get_workflow_job", "workflow_job", "workflow", `{"id":8,"run_id":7,"name":"","status":""}`, true}, + {"get/usage", actionsGetOutputSchema(), "get_workflow_run_usage", "usage", "workflow", `{}`, true}, + {"get/artifact", actionsGetOutputSchema(), "download_workflow_run_artifact", "artifact", "workflow", `{"artifact_id":9,"download_url":"","message":"","note":""}`, false}, + {"get/logs", actionsGetOutputSchema(), "get_workflow_run_logs_url", "logs", "workflow", `{"logs_url":"","message":"","note":"","optimization_tip":"","warning":""}`, false}, + {"trigger/dispatch", actionsRunTriggerOutputSchema(), "run_workflow", "dispatch", "cancel", `{"inputs":null,"message":"","ref":"main","status":"","status_code":204,"workflow_id":"ci.yml","workflow_type":"workflow_file"}`, false}, + {"trigger/rerun", actionsRunTriggerOutputSchema(), "rerun_workflow_run", "rerun", "cancel", `{"message":"","run_id":7,"status":"","status_code":201}`, false}, + {"trigger/rerun_failed", actionsRunTriggerOutputSchema(), "rerun_failed_jobs", "rerun_failed", "cancel", `{"message":"","run_id":7,"status":"","status_code":201}`, false}, + {"trigger/cancel", actionsRunTriggerOutputSchema(), "cancel_workflow_run", "cancel", "rerun", `{"message":"","run_id":7,"status":"","status_code":202}`, false}, + {"trigger/delete_logs", actionsRunTriggerOutputSchema(), "delete_workflow_run_logs", "delete_logs", "rerun", `{"message":"","run_id":7,"status":"","status_code":204}`, false}, + } { + t.Run(tc.name, func(t *testing.T) { + schema, err := tc.schema.Resolve(nil) + require.NoError(t, err) + valid := []string{`{"method":"` + tc.method + `","` + tc.field + `":` + tc.value + `}`} + if tc.optional { + valid = append(valid, `{"method":"`+tc.method+`"}`, `{"method":"`+tc.method+`","`+tc.field+`":null}`) + } + for _, raw := range valid { + var value any + require.NoError(t, json.Unmarshal([]byte(raw), &value)) + assert.NoError(t, schema.Validate(value), raw) + } + invalid := []string{ + `{}`, + `{"` + tc.field + `":` + tc.value + `}`, + `{"method":"` + tc.method + `","` + tc.other + `":` + tc.value + `}`, + `{"method":"` + tc.method + `","` + tc.field + `":` + tc.value + `,"` + tc.other + `":` + tc.value + `}`, + `{"method":"` + tc.method + `","` + tc.other + `":null}`, + `{"method":"` + tc.method + `","` + tc.field + `":` + tc.value + `,"` + tc.other + `":null}`, + } + if !tc.optional { + invalid = append(invalid, `{"method":"`+tc.method+`"}`) + } + for _, raw := range invalid { + var value any + require.NoError(t, json.Unmarshal([]byte(raw), &value)) + assert.Error(t, schema.Validate(value), raw) + } + }) + } +} + +func TestUIGetOutputRejectsMismatchedPayloads(t *testing.T) { + schema, err := uiGetOutputSchema().Resolve(nil) + require.NoError(t, err) + for _, method := range []string{"labels", "assignees", "milestones", "issue_types", "branches", "issue_fields", "reviewers"} { + t.Run(method, func(t *testing.T) { + payload := "labels" + if method == payload { + payload = "assignees" + } + for _, value := range []map[string]any{ + {"method": method}, + {"method": method, payload: map[string]any{payload: []any{}, "totalCount": 0, "has_more": false}}, + } { + assert.Error(t, schema.Validate(value), "%v", value) + } + }) + } + assert.Error(t, schema.Validate(map[string]any{ + "method": "labels", + "labels": map[string]any{"labels": []any{}, "totalCount": 0, "has_more": false}, + "issue_types": []any{}, + })) +} + +func TestProjectOutputUsersExcludeDetails(t *testing.T) { + user := &github.User{Login: new("octocat"), Email: new("private@example.com")} + project := convertToMinimalProject(&github.ProjectV2{Owner: user, Creator: user, DeletedBy: user}) + require.Nil(t, project.Owner.Details) + require.Nil(t, project.Creator.Details) + require.Nil(t, project.DeletedBy.Details) + var status statusUpdateNode + status.Creator.Login = "octocat" + require.Nil(t, convertToMinimalStatusUpdate(status).Creator.Details) + for name, schema := range map[string]*jsonschema.Schema{ + "list": projectsListOutputSchema(), "get": projectsGetOutputSchema(), "write": projectsWriteOutputSchema(), + } { + t.Run(name, func(t *testing.T) { + assert.NotContains(t, mustMarshalJSON(t, schema), `"details"`) + }) + } + schema, err := projectOutputSchema[MinimalProject]().Resolve(nil) + require.NoError(t, err) + for _, field := range []string{"owner", "creator", "deleted_by"} { + assert.NoError(t, schema.Validate(map[string]any{field: map[string]any{"login": "octocat"}})) + assert.Error(t, schema.Validate(map[string]any{field: map[string]any{"login": "octocat", "details": map[string]any{}}})) + } +} diff --git a/pkg/github/typed_copilot_ui_outputs.go b/pkg/github/typed_copilot_ui_outputs.go index d9dd03f9d3..88c4aec5e2 100644 --- a/pkg/github/typed_copilot_ui_outputs.go +++ b/pkg/github/typed_copilot_ui_outputs.go @@ -15,10 +15,12 @@ type CopilotPullRequestOutput struct { Number int `json:"number"` State string `json:"state"` Title string `json:"title"` + URL string `json:"url"` } type AssignCopilotToIssueOutput struct { IssueNumber int `json:"issue_number"` + IssueURL string `json:"issue_url"` Message string `json:"message"` Note string `json:"note,omitempty"` Owner string `json:"owner"` @@ -29,6 +31,7 @@ type AssignCopilotToIssueOutput struct { type AssignCopilotToIssueWithIntentOutput struct { IsSuggestion bool `json:"is_suggestion"` IssueNumber int `json:"issue_number"` + IssueURL string `json:"issue_url"` Message string `json:"message"` Note string `json:"note,omitempty"` Owner string `json:"owner"` @@ -39,18 +42,22 @@ type AssignCopilotToIssueWithIntentOutput struct { func assignCopilotToIssueOutputSchema() *jsonschema.Schema { schema := forbidOmittedNulls(repositoryOutputSchema[AssignCopilotToIssueOutput]()) schema.Properties["issue_number"].Description = "The assigned issue's number." + schema.Properties["issue_url"].Description = "Canonical browser URL of the assigned issue." schema.Properties["message"].Description = "Assignment outcome or tool error message." schema.Properties["note"].Description = "Additional status detail when Copilot has not created a pull request yet." schema.Properties["pull_request"].Properties["state"].Enum = []any{"OPEN", "CLOSED", "MERGED"} + schema.Properties["pull_request"].Properties["url"].Description = "Canonical browser URL of the linked pull request." return schema } func assignCopilotToIssueWithIntentOutputSchema() *jsonschema.Schema { schema := forbidOmittedNulls(repositoryOutputSchema[AssignCopilotToIssueWithIntentOutput]()) schema.Properties["issue_number"].Description = "The assigned issue's number." + schema.Properties["issue_url"].Description = "Canonical browser URL of the assigned issue." schema.Properties["message"].Description = "Assignment outcome or tool error message." schema.Properties["note"].Description = "Additional status detail when Copilot has not created a pull request yet." schema.Properties["pull_request"].Properties["state"].Enum = []any{"OPEN", "CLOSED", "MERGED"} + schema.Properties["pull_request"].Properties["url"].Description = "Canonical browser URL of the linked pull request." return schema } @@ -356,6 +363,10 @@ func uiGetOutputSchema() *jsonschema.Schema { schema.Properties["issue_fields"].Properties["totalCount"].Description = "Number of supported issue fields returned." schema.Properties["branches"].Properties["totalCount"].Description = "Number of branches returned." schema.Properties["branches"].Properties["has_more"].Description = "True when results were truncated at the pagination limit." + constrainMethodOutput(schema, map[string]string{ + "labels": "labels", "assignees": "assignees", "milestones": "milestones", + "issue_types": "issue_types", "branches": "branches", "issue_fields": "issue_fields", "reviewers": "reviewers", + }) return schema } diff --git a/pkg/github/typed_copilot_ui_outputs_test.go b/pkg/github/typed_copilot_ui_outputs_test.go index 8dbfce7e22..a3c2fed021 100644 --- a/pkg/github/typed_copilot_ui_outputs_test.go +++ b/pkg/github/typed_copilot_ui_outputs_test.go @@ -9,6 +9,7 @@ import ( "reflect" "strings" "testing" + "time" ghcontext "github.com/github/github-mcp-server/pkg/context" "github.com/github/github-mcp-server/pkg/http/middleware" @@ -44,12 +45,17 @@ func typedCopilotUIClient(t *testing.T, fixture ...string) ToolDependencies { query, err := io.ReadAll(request.Body) require.NoError(t, err) switch { + case strings.Contains(string(query), "timelineItems"): + body = `{"data":{"repository":{"issue":{"timelineItems":{"nodes":[{"__typename":"CrossReferencedEvent","source":{"number":8,"url":"https://github.enterprise.example/owner/repo/pull/8","title":"Fix","state":"OPEN","createdAt":"` + time.Now().Add(time.Minute).UTC().Format(time.RFC3339) + `","author":{"login":"copilot-swe-agent"}}}]}}}}}` case strings.Contains(string(query), "suggestedActors"): body = `{"data":{"repository":{"suggestedActors":{"nodes":[{"id":"BOT","login":"copilot-swe-agent","__typename":"Bot"}],"pageInfo":{"hasNextPage":false,"endCursor":""}}}}}` case strings.Contains(string(query), "issue(number:"): body = `{"data":{"repository":{"id":"REPO","issue":{"id":"ISSUE","assignees":{"nodes":[]}}}}}` case strings.Contains(string(query), "updateIssue(input:"): body = `{"data":{"updateIssue":{"issue":{"id":"ISSUE","number":7,"url":"https://github.com/owner/repo/issues/7"}}}}` + if fixtureMode == "linked-enterprise" { + body = strings.ReplaceAll(body, "https://github.com/", "https://github.enterprise.example/") + } case strings.Contains(string(query), "issueFields"): if fixtureMode == "nonempty" { body = `{"data":{"repository":{"issueFields":{"nodes":[{"__typename":"IssueFieldText","id":"FIELD_1","name":"Priority","description":"Issue priority","dataType":"text","visibility":"ALL"}]}}}}` @@ -110,7 +116,7 @@ func typedCopilotUIClient(t *testing.T, fixture ...string) ToolDependencies { } } -func typedCopilotUISession(t *testing.T, deps ToolDependencies, protocol string) (*mcp.ClientSession, map[string]*jsonschema.Resolved) { +func typedCopilotUISession(t *testing.T, deps ToolDependencies, protocol string, pollConfigs ...PollConfig) (*mcp.ClientSession, map[string]*jsonschema.Resolved) { t.Helper() translation := translations.NullTranslationHelper tools := []inventory.ServerTool{ @@ -137,7 +143,11 @@ func typedCopilotUISession(t *testing.T, deps ToolDependencies, protocol string) request.Params = &mcp.CallToolParamsRaw{} } request.Params.Meta = mcp.Meta{mcp.MetaKeyProtocolVersion: protocol} - ctx = ContextWithPollConfig(ctx, PollConfig{}) + pollConfig := PollConfig{} + if len(pollConfigs) > 0 { + pollConfig = pollConfigs[0] + } + ctx = ContextWithPollConfig(ctx, pollConfig) ctx = ghcontext.WithMCPMethodInfo(ctx, &ghcontext.MCPMethodInfo{ProtocolVersion: protocol}) } return next(ctx, method, request) @@ -179,19 +189,19 @@ func TestTypedCopilotAndUIWireOutputs(t *testing.T) { name: "assign_copilot_to_issue", args: map[string]any{"owner": "owner", "repo": "repo", "issue_number": "7"}, text: `{"issue_number":7,"issue_url":"https://github.com/owner/repo/issues/7","message":"successfully assigned copilot to issue - pull request pending","note":"The pull request may still be in progress. Once created, the PR number can be used to check job status, or check the issue timeline for updates.","owner":"owner","repo":"repo"}`, - structured: `{"issue_number":7,"message":"successfully assigned copilot to issue - pull request pending","note":"The pull request may still be in progress. Once created, the PR number can be used to check job status, or check the issue timeline for updates.","owner":"owner","repo":"repo"}`, + structured: `{"issue_number":7,"issue_url":"https://github.com/owner/repo/issues/7","message":"successfully assigned copilot to issue - pull request pending","note":"The pull request may still be in progress. Once created, the PR number can be used to check job status, or check the issue timeline for updates.","owner":"owner","repo":"repo"}`, }, { name: "assign_copilot_to_issue_with_intent", args: map[string]any{"owner": "owner", "repo": "repo", "issue_number": "7", "rationale": " Clear acceptance criteria ", "confidence": "medium", "is_suggestion": "true"}, text: `{"is_suggestion":true,"issue_number":7,"issue_url":"https://github.com/owner/repo/issues/7","message":"recorded pending copilot assignment suggestion","owner":"owner","repo":"repo"}`, - structured: `{"is_suggestion":true,"issue_number":7,"message":"recorded pending copilot assignment suggestion","owner":"owner","repo":"repo"}`, + structured: `{"is_suggestion":true,"issue_number":7,"issue_url":"https://github.com/owner/repo/issues/7","message":"recorded pending copilot assignment suggestion","owner":"owner","repo":"repo"}`, }, { name: "assign_copilot_to_issue_with_intent", args: map[string]any{"owner": "owner", "repo": "repo", "issue_number": "7", "rationale": "Clear acceptance criteria", "confidence": "HIGH", "is_suggestion": false}, text: `{"is_suggestion":false,"issue_number":7,"issue_url":"https://github.com/owner/repo/issues/7","message":"successfully assigned copilot to issue - pull request pending","note":"The pull request may still be in progress. Once created, the PR number can be used to check job status, or check the issue timeline for updates.","owner":"owner","repo":"repo"}`, - structured: `{"is_suggestion":false,"issue_number":7,"message":"successfully assigned copilot to issue - pull request pending","note":"The pull request may still be in progress. Once created, the PR number can be used to check job status, or check the issue timeline for updates.","owner":"owner","repo":"repo"}`, + structured: `{"is_suggestion":false,"issue_number":7,"issue_url":"https://github.com/owner/repo/issues/7","message":"successfully assigned copilot to issue - pull request pending","note":"The pull request may still be in progress. Once created, the PR number can be used to check job status, or check the issue timeline for updates.","owner":"owner","repo":"repo"}`, }, { name: "request_copilot_review", @@ -302,6 +312,50 @@ func TestTypedUIGetNonemptyWireOutputs(t *testing.T) { } } +func TestTypedCopilotAssignmentCanonicalURLs(t *testing.T) { + for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25"} { + t.Run(protocol, func(t *testing.T) { + session, schemas := typedCopilotUISession(t, typedCopilotUIClient(t, "linked-enterprise"), protocol, PollConfig{MaxAttempts: 1}) + for _, tc := range []struct { + name string + suggestion bool + }{ + {name: "assign_copilot_to_issue"}, + {name: "assign_copilot_to_issue_with_intent"}, + {name: "assign_copilot_to_issue_with_intent", suggestion: true}, + } { + t.Run(tc.name+"/"+mustMarshalJSON(t, tc.suggestion), func(t *testing.T) { + args := map[string]any{"owner": "owner", "repo": "repo", "issue_number": 7} + if tc.name == "assign_copilot_to_issue_with_intent" { + args["is_suggestion"] = tc.suggestion + args["rationale"] = "Clear acceptance criteria" + args["confidence"] = "HIGH" + } + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.name, Arguments: args}) + require.NoError(t, err) + require.False(t, result.IsError, mustMarshalJSON(t, result)) + var output map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &output)) + assert.Equal(t, "https://github.enterprise.example/owner/repo/issues/7", output["issue_url"]) + if tc.suggestion { + assert.NotContains(t, output, "pull_request") + } else { + pr, ok := output["pull_request"].(map[string]any) + require.True(t, ok, "%v", output) + assert.Equal(t, "https://github.enterprise.example/owner/repo/pull/8", pr["url"]) + } + if schema := schemas[tc.name]; schema != nil { + require.NoError(t, schema.Validate(output)) + assert.JSONEq(t, mustMarshalJSON(t, output), mustMarshalJSON(t, result.StructuredContent)) + } else { + assert.Nil(t, result.StructuredContent) + } + }) + } + }) + } +} + func TestTypedUIGetNullIssueTypesWireOutput(t *testing.T) { for _, protocol := range []string{inventory.ProtocolVersionMultiRoundTrip, "2025-11-25"} { t.Run(protocol, func(t *testing.T) { @@ -477,12 +531,12 @@ func TestTypedCopilotOutputSchemas(t *testing.T) { { name: "assign_copilot_to_issue", schema: assignCopilotToIssueOutputSchema(), - raw: `{"issue_number":7,"message":"successfully assigned copilot to issue - pull request pending","note":"pending","owner":"owner","pull_request":{"number":8,"state":"OPEN","title":"Fix"},"repo":"repo"}`, + raw: `{"issue_number":7,"issue_url":"https://github.enterprise.example/owner/repo/issues/7","message":"successfully assigned copilot to issue - pull request pending","note":"pending","owner":"owner","pull_request":{"number":8,"state":"OPEN","title":"Fix","url":"https://github.enterprise.example/owner/repo/pull/8"},"repo":"repo"}`, }, { name: "assign_copilot_to_issue_with_intent", schema: assignCopilotToIssueWithIntentOutputSchema(), - raw: `{"is_suggestion":true,"issue_number":7,"message":"suggested","owner":"owner","repo":"repo"}`, + raw: `{"is_suggestion":true,"issue_number":7,"issue_url":"https://github.enterprise.example/owner/repo/issues/7","message":"suggested","owner":"owner","repo":"repo"}`, }, { name: "request_copilot_review", @@ -503,8 +557,8 @@ func TestTypedCopilotOutputSchemas(t *testing.T) { require.NoError(t, resolved.Validate(value)) }) } - assert.NotContains(t, mustMarshalJSON(t, assignCopilotToIssueOutputSchema()), `"url"`) - assert.NotContains(t, mustMarshalJSON(t, assignCopilotToIssueWithIntentOutputSchema()), `"url"`) + assert.Contains(t, mustMarshalJSON(t, assignCopilotToIssueOutputSchema()), `"url"`) + assert.Contains(t, mustMarshalJSON(t, assignCopilotToIssueWithIntentOutputSchema()), `"url"`) reviewSchema, err := copilotReviewOutputSchema().Resolve(nil) require.NoError(t, err) require.NoError(t, reviewSchema.Validate(map[string]any{"status": "requested"})) @@ -517,7 +571,7 @@ func TestTypedUIGetOutputSchemaDiscriminator(t *testing.T) { require.Equal(t, "object", schema.Type) require.Equal(t, []string{"method"}, schema.Required) assert.Nil(t, schema.AnyOf) - assert.Nil(t, schema.OneOf) + assert.Len(t, schema.OneOf, len(schema.Properties["method"].Enum)) require.Contains(t, schema.Properties, "method") assert.Equal(t, []any{"labels", "assignees", "milestones", "issue_types", "branches", "issue_fields", "reviewers"}, schema.Properties["method"].Enum) for _, property := range []string{"labels", "assignees", "milestones", "issue_types", "branches", "issue_fields", "reviewers"} { diff --git a/pkg/github/typed_git_gist_outputs_test.go b/pkg/github/typed_git_gist_outputs_test.go index 631c9309b7..9795b314b0 100644 --- a/pkg/github/typed_git_gist_outputs_test.go +++ b/pkg/github/typed_git_gist_outputs_test.go @@ -141,7 +141,7 @@ func gitGistDeps(t *testing.T, current **gitGistCase, apiError bool, private ... const ( gistBody = `{"id":"abc","description":"d","public":true,"comments":2,"html_url":"https://gist.github.com/abc","created_at":"2026-01-01T00:00:00Z","owner":{"login":"octocat","id":1,"url":"https://api.github.com/users/octocat","html_url":"https://github.com/octocat","node_id":"user-node"},"files":{"a.txt":{"filename":"a.txt","size":3,"content":"hey","raw_url":"https://gist.githubusercontent.com/raw"}},"node_id":"gist-node","git_pull_url":"https://gist.github.com/abc.git","git_push_url":"https://gist.github.com/abc.git"}` gistText = `{"id":"abc","description":"d","public":true,"owner":{"login":"octocat","id":1,"node_id":"user-node","html_url":"https://github.com/octocat","url":"https://api.github.com/users/octocat"},"files":{"a.txt":{"size":3,"filename":"a.txt","raw_url":"https://gist.githubusercontent.com/raw","content":"hey"}},"comments":2,"html_url":"https://gist.github.com/abc","git_pull_url":"https://gist.github.com/abc.git","git_push_url":"https://gist.github.com/abc.git","created_at":"2026-01-01T00:00:00Z","node_id":"gist-node"}` - gistStructured = `{"id":"abc","description":"d","public":true,"owner":{"login":"octocat","id":1,"profile_url":"https://github.com/octocat"},"files":{"a.txt":{"size":3,"filename":"a.txt","content":"hey"}},"comments":2,"html_url":"https://gist.github.com/abc","created_at":"2026-01-01T00:00:00Z"}` + gistStructured = `{"id":"abc","description":"d","public":true,"owner":{"login":"octocat","id":1,"profile_url":"https://github.com/octocat"},"files":{"a.txt":{"size":3,"filename":"a.txt","content":"hey","raw_url":"https://gist.githubusercontent.com/raw"}},"comments":2,"html_url":"https://gist.github.com/abc","git_pull_url":"https://gist.github.com/abc.git","created_at":"2026-01-01T00:00:00Z"}` treeBody = `{"sha":"t1","truncated":false,"tree":[{"path":"src/a.go","mode":"100644","type":"blob","sha":"s1","size":5,"url":"u1"},{"path":"src","mode":"040000","type":"tree","sha":"s2","url":"u2"},{"path":"README.md","mode":"100644","type":"blob","sha":"s3","size":1,"url":"u3"}]}` minimalOK = `{"id":"abc","url":"https://gist.github.com/abc"}` ) @@ -267,9 +267,9 @@ func TestGitGistOutputSchemasRejectMismatches(t *testing.T) { "gist": {gistOutputSchema(), `{"id":null}`}, "list": {gistListOutputSchema(), `[{"files":{"a":{"size":"3"}}}]`}, "create": {gistMutationOutputSchema(), `{"id":"a"}`}, - "api_url": {gistOutputSchema(), `{"git_pull_url":"https://gist.github.com/abc.git"}`}, + "api_url": {gistOutputSchema(), `{"git_push_url":"https://gist.github.com/abc.git"}`}, "node_id": {gistOutputSchema(), `{"owner":{"login":"octocat","node_id":"node"}}`}, - "raw_url": {gistOutputSchema(), `{"files":{"a":{"raw_url":"https://gist.githubusercontent.com/raw"}}}`}, + "raw_url": {gistOutputSchema(), `{"files":{"a":{"raw_url":null}}}`}, "timestamp": {gistOutputSchema(), `{"created_at":42}`}, "tree_type": {treeOutputSchema(), `{"sha":"s","truncated":false,"tree":[{"path":"p","type":"invalid","mode":"100644","sha":"s"}],"tree_sha":"t","owner":"o","repo":"r","recursive":false,"count":1}`}, } { diff --git a/pkg/github/typed_governance_outputs.go b/pkg/github/typed_governance_outputs.go index 94529c26f1..400fd1bdff 100644 --- a/pkg/github/typed_governance_outputs.go +++ b/pkg/github/typed_governance_outputs.go @@ -157,22 +157,23 @@ type RuleEvaluationOutput struct { type RuleSourceOutput struct { Type string `json:"type"` - ID int64 `json:"id"` + ID *int64 `json:"id"` Name string `json:"name,omitempty"` } type RuleSuiteOutput struct { - ID int64 `json:"id"` - ActorID *int64 `json:"actor_id,omitempty"` - ActorName string `json:"actor_name,omitempty"` - BeforeSHA string `json:"before_sha,omitempty"` - AfterSHA string `json:"after_sha,omitempty"` - Ref string `json:"ref,omitempty"` - PushedAt string `json:"pushed_at,omitempty" jsonschema:"RFC3339 push time."` - Result string `json:"result"` - RepositoryID *int64 `json:"repository_id,omitempty"` - RepositoryName string `json:"repository_name,omitempty"` - Evaluations []RuleEvaluationOutput `json:"rule_evaluations,omitempty"` + ID int64 `json:"id"` + ActorID *int64 `json:"actor_id,omitempty"` + ActorName string `json:"actor_name,omitempty"` + BeforeSHA string `json:"before_sha,omitempty"` + AfterSHA string `json:"after_sha,omitempty"` + Ref string `json:"ref,omitempty"` + PushedAt string `json:"pushed_at,omitempty" jsonschema:"RFC3339 push time."` + Result string `json:"result"` + EvaluationResult *string `json:"evaluation_result,omitempty" jsonschema:"Result of rules evaluated without enforcement, distinct from the enforced result."` + RepositoryID *int64 `json:"repository_id,omitempty"` + RepositoryName string `json:"repository_name,omitempty"` + Evaluations []RuleEvaluationOutput `json:"rule_evaluations,omitempty"` } type RulesetReadOutput struct { diff --git a/pkg/github/typed_governance_outputs_test.go b/pkg/github/typed_governance_outputs_test.go index 463638bceb..989f3916b1 100644 --- a/pkg/github/typed_governance_outputs_test.go +++ b/pkg/github/typed_governance_outputs_test.go @@ -346,6 +346,56 @@ func TestTypedGovernanceWireOutputs(t *testing.T) { } } +func TestRuleSuiteOutputFidelity(t *testing.T) { + for _, sourceID := range []string{"null", "0", "7"} { + for _, method := range []string{"get_rule_suite", "list_rule_suites"} { + for _, level := range []string{"repository", "organization"} { + t.Run(level+"/"+method+"/"+sourceID, func(t *testing.T) { + suite := `{"id":11,"result":"pass","evaluation_result":"fail","rule_evaluations":[{"rule_source":{"type":"protected_branch","id":` + sourceID + `},"result":"pass","rule_type":"pull_request"}]}` + body, field := suite, "rule_suite" + if method == "list_rule_suites" { + body, field = "["+suite+"]", "rule_suites" + } + tc := governanceWireCase{ + tool: RepositoryRulesetRead(translations.NullTranslationHelper), + args: map[string]any{"method": method, "level": level, "owner": "o", "repo": "r", "org": "o", "rule_suite_id": 11}, + route: func(t *testing.T, r *http.Request) string { + t.Helper() + assert.Equal(t, http.MethodGet, r.Method) + path := "/repos/o/r/rulesets/rule-suites" + if level == "organization" { + path = "/orgs/o/rulesets/rule-suites" + } + if method == "get_rule_suite" { + path += "/11" + } + assert.Equal(t, path, r.URL.Path) + return body + }, + } + for _, protocol := range []string{"2025-11-25", inventory.ProtocolVersionMultiRoundTrip} { + session, schema := governanceTypedSession(t, tc.tool, governanceWireDeps(t, tc), protocol) + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: tc.tool.Tool.Name, Arguments: tc.args}) + require.NoError(t, err) + require.False(t, result.IsError) + var legacy any + require.NoError(t, json.Unmarshal([]byte(body), &legacy)) + assertGovernanceWireText(t, schema != nil, mustMarshalJSON(t, legacy), result) + if schema == nil { + assert.Nil(t, result.StructuredContent) + continue + } + var output any + require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, result.StructuredContent)), &output)) + require.NoError(t, schema.Validate(output)) + assert.JSONEq(t, `{"method":"`+method+`","level":"`+level+`","`+field+`":`+body+`}`, mustMarshalJSON(t, output)) + } + }) + } + } + } +} + func TestTypedGovernanceLegacyInputErrors(t *testing.T) { cases := []struct { tool inventory.ServerTool diff --git a/pkg/github/typed_granular_pull_request_outputs_test.go b/pkg/github/typed_granular_pull_request_outputs_test.go index bf2a4d86f0..0b8f5e5e98 100644 --- a/pkg/github/typed_granular_pull_request_outputs_test.go +++ b/pkg/github/typed_granular_pull_request_outputs_test.go @@ -241,8 +241,8 @@ func granularPRSession(t *testing.T, deps ToolDependencies, protocol string, rea require.NoError(t, err, name) schemas[tool.Name] = resolved if tool.Name == "add_pull_request_review_comment_reaction" { - require.NoError(t, resolved.Validate(map[string]any{"id": float64(77), "content": "heart"})) - require.Error(t, resolved.Validate(map[string]any{"id": float64(77), "url": "https://api.github.com/reaction"})) + require.NoError(t, resolved.Validate(map[string]any{"id": "77", "url": "https://api.github.com/reaction"})) + require.Error(t, resolved.Validate(map[string]any{"id": float64(77), "content": "heart"})) } } return session, schemas @@ -272,15 +272,7 @@ func assertGranularPRResult(t *testing.T, result *mcp.CallToolResult, schema *js require.NoError(t, schema.Validate(output)) switch { case strings.Contains(text, "/reactions/"): - var reference MinimalResponse - require.NoError(t, json.Unmarshal([]byte(text), &reference)) - var reaction MinimalPullRequestCommentReaction - require.NoError(t, json.Unmarshal([]byte(mustMarshalJSON(t, output)), &reaction)) - assert.Equal(t, reference.ID, fmt.Sprint(reaction.ID)) - if reaction.ID != 0 { - assert.Equal(t, PullRequestCommentReactionType("heart"), reaction.Content) - } - assert.NotContains(t, mustMarshalJSON(t, output), "url") + assert.JSONEq(t, text, mustMarshalJSON(t, output)) assert.JSONEq(t, mustMarshalJSON(t, output), wireText) case strings.HasPrefix(text, "{"): assert.JSONEq(t, mustMarshalJSON(t, output), wireText) @@ -456,8 +448,8 @@ func TestTypedInputDescriptionsMatchMain(t *testing.T) { "state": "The new state for the pull request", }, "find_duplicate": { - "page": "Page number for pagination (min 1)", - "perPage": "Results per page for pagination (min 1, max 100)", + "page": "Page number for pagination (min 0). Zero is forwarded for the GitHub API default.", + "perPage": "Results per page for pagination (min 0, max 100). Zero is forwarded for the GitHub API default.", }, } tools := append(granularPullRequestTools(), FindDuplicate(translations.NullTranslationHelper)) @@ -473,21 +465,20 @@ func TestTypedInputDescriptionsMatchMain(t *testing.T) { findDuplicate, ok := FindDuplicate(translations.NullTranslationHelper).Tool.InputSchema.(*jsonschema.Schema) require.True(t, ok) - assert.Equal(t, 1.0, *findDuplicate.Properties["page"].Minimum) - assert.Equal(t, 1.0, *findDuplicate.Properties["perPage"].Minimum) + assert.Equal(t, 0.0, *findDuplicate.Properties["page"].Minimum) + assert.Equal(t, 0.0, *findDuplicate.Properties["perPage"].Minimum) assert.Equal(t, 100.0, *findDuplicate.Properties["perPage"].Maximum) } func TestGranularPullRequestReactionSchema(t *testing.T) { schema, err := minimalPullRequestCommentReactionSchema().Resolve(nil) require.NoError(t, err) - for _, content := range PullRequestCommentReactionType("").Values() { - require.NoError(t, schema.Validate(map[string]any{"id": float64(77), "content": content})) - } - require.NoError(t, schema.Validate(map[string]any{"id": float64(0)})) + require.NoError(t, schema.Validate(map[string]any{"id": "77", "url": "https://api.github.com/reaction"})) + require.NoError(t, schema.Validate(map[string]any{"id": "0", "url": ""})) for _, raw := range []string{ `null`, `[]`, `{}`, `{"id":"77"}`, `{"id":77,"content":"other"}`, `{"id":77,"content":null}`, `{"id":77,"url":"https://api.github.com/reaction"}`, + `{"id":"77","url":null}`, `{"id":"77","url":"https://api.github.com/reaction","content":"heart"}`, } { var output any require.NoError(t, json.Unmarshal([]byte(raw), &output)) diff --git a/pkg/github/typed_repository_outputs_test.go b/pkg/github/typed_repository_outputs_test.go index a9239a75fe..f3a83a0216 100644 --- a/pkg/github/typed_repository_outputs_test.go +++ b/pkg/github/typed_repository_outputs_test.go @@ -378,13 +378,15 @@ func TestTypedRepositoryOutputs(t *testing.T) { var entries []*RepositoryDirectoryEntryOutput require.NoError(t, json.Unmarshal([]byte(first), &entries)) assert.JSONEq(t, mustMarshalJSON(t, entries), mustMarshalJSON(t, result.StructuredContent)) - assert.NotContains(t, mustMarshalJSON(t, result.StructuredContent), "api-content-url") + if call.args["fields"] == nil { + assert.NotContains(t, mustMarshalJSON(t, result.StructuredContent), "api-content-url") + } assert.NotContains(t, mustMarshalJSON(t, result.StructuredContent), "api-git-url") if call.args["path"] == "submodule-directory" { assert.JSONEq(t, `[{"name":"lib","path":"lib","type":"file","sha":"sha","submodule_git_url":"https://github.com/owner/lib.git"}]`, mustMarshalJSON(t, result.StructuredContent)) } if call.args["path"] == "directory" && call.args["fields"] != nil && len(call.args["fields"].([]any)) == 3 { - assert.JSONEq(t, `[{"size":0,"html_url":"https://github.com/owner/repo/blob/main/file"}]`, mustMarshalJSON(t, result.StructuredContent)) + assert.JSONEq(t, `[{"size":0,"html_url":"https://github.com/owner/repo/blob/main/file","url":"api-content-url"}]`, mustMarshalJSON(t, result.StructuredContent)) } case call.name == "create_branch" || call.name == "push_files": assert.JSONEq(t, `{"ref":"refs/heads/main","object":{"type":"commit","sha":"base"}}`, mustMarshalJSON(t, result.StructuredContent)) diff --git a/pkg/github/typed_search_outputs_test.go b/pkg/github/typed_search_outputs_test.go index e78097bad3..24cfc77fa8 100644 --- a/pkg/github/typed_search_outputs_test.go +++ b/pkg/github/typed_search_outputs_test.go @@ -335,7 +335,7 @@ func TestTypedProjectedReadOutputsRespectFieldSelection(t *testing.T) { emptyFieldValues := structuredSearchIssuesOutput(SearchIssuesResponse{ Items: []SearchIssueResult{{Issue: &github.Issue{}, FieldValues: []MinimalFieldValue{}}}, }, []string{"field_values"}) - assert.Equal(t, `{"items":[{}]}`, mustMarshalJSON(t, emptyFieldValues)) + assert.Equal(t, `{"items":[{"field_values":[]}]}`, mustMarshalJSON(t, emptyFieldValues)) unavailableFieldValues := structuredSearchIssuesOutput(SearchIssuesResponse{ Items: []SearchIssueResult{{Issue: &github.Issue{}}}, diff --git a/pkg/github/typed_security_outputs_test.go b/pkg/github/typed_security_outputs_test.go index c1828a6c6b..e55b77235b 100644 --- a/pkg/github/typed_security_outputs_test.go +++ b/pkg/github/typed_security_outputs_test.go @@ -89,6 +89,8 @@ func TestTypedSecurityToolOutputs(t *testing.T) { Validity: new("active"), ResolutionComment: new("Rotate this credential"), PushProtectionBypassed: new(true), + IsBase64Encoded: new(true), + HasMoreLocations: new(true), FirstLocationDetected: &github.SecretScanningAlertLocationDetails{ Path: new("config.env"), CommitSHA: new("abc123"), StartColumn: new(5), CommitURL: new("https://api.github.com/repos/owner/repo/commits/abc123"), @@ -131,6 +133,7 @@ func TestTypedSecurityToolOutputs(t *testing.T) { VulnerableFunctions: []string{"unsafeQuery"}, }}, } + globalAdvisory.CWEs = []*github.AdvisoryCWEs{{CWEID: new("CWE-79"), Name: new("Cross-site scripting")}} codeQualityJSON := json.RawMessage(`{"unknown_legacy_field":"preserved in text","url":"https://api.github.com/repos/owner/repo/code-quality/findings/42","state":"open","rule":{"id":"test-rule","description":"Test rule"},"number":42}`) legacyOutputs := map[string]any{ "get_code_quality_finding": codeQualityFinding, @@ -269,16 +272,20 @@ func TestTypedSecurityToolOutputs(t *testing.T) { assert.Contains(t, string(structuredJSON), "Use parameterized queries") assert.Contains(t, string(structuredJSON), `"security_severity_level":"high"`) case "get_secret_scanning_alert", "list_secret_scanning_alerts": + assert.Contains(t, string(structuredJSON), `"is_base64_encoded":true`) + assert.Contains(t, string(structuredJSON), `"has_more_locations":true`) assert.Contains(t, text, `"secret":"test-secret"`) assert.Contains(t, string(structuredJSON), `"secret":"test-secret"`, "preserve the same intentionally scoped secret as text") assert.Contains(t, string(structuredJSON), `"validity":"active"`) assert.Contains(t, string(structuredJSON), "Rotate this credential") assert.Contains(t, string(structuredJSON), `"start_column":5`) case "get_dependabot_alert", "list_dependabot_alerts": + assert.Contains(t, string(structuredJSON), `"classification":"malware"`) assert.Contains(t, string(structuredJSON), `"first_patched_version":"2.0"`) assert.Contains(t, string(structuredJSON), `"dismissed_reason":"tolerable_risk"`) assert.NotContains(t, string(structuredJSON), `"vulnerabilities"`, "use the alert's affected package instead of repeating advisory-wide packages") case "get_global_security_advisory", "list_global_security_advisories": + assert.Contains(t, string(structuredJSON), `"cwe_ids":["CWE-79"]`) assert.Contains(t, string(structuredJSON), `"first_patched_version":"2.0"`) assert.Contains(t, string(structuredJSON), "unsafeQuery") case "list_repository_security_advisories", "list_org_repository_security_advisories": @@ -296,9 +303,6 @@ func TestTypedSecurityToolOutputs(t *testing.T) { if call.Name == "get_secret_scanning_alert" { assert.NotContains(t, mustMarshalJSON(t, result.StructuredContent), "legacy response detail") } - if call.Name == "list_dependabot_alerts" { - assert.NotContains(t, mustMarshalJSON(t, result.StructuredContent), "classification") - } if call.Name == "get_code_scanning_alert" { assert.NotNil(t, result.Meta["ifc"], "typed output must retain the security alert IFC label") } @@ -307,6 +311,53 @@ func TestTypedSecurityToolOutputs(t *testing.T) { } } +func TestGlobalSecurityAdvisoryCWEIDs(t *testing.T) { + for _, tc := range []struct { + name string + cweIDs []string + cwes []*github.AdvisoryCWEs + expected []string + }{ + {name: "absent"}, + {name: "global CWEs", cwes: []*github.AdvisoryCWEs{{CWEID: new("CWE-79")}, {CWEID: new("CWE-89")}}, expected: []string{"CWE-79", "CWE-89"}}, + {name: "missing CWE ID", cwes: []*github.AdvisoryCWEs{nil, {Name: new("No ID")}, {CWEID: new("CWE-79")}}, expected: []string{"CWE-79"}}, + {name: "explicit IDs", cweIDs: []string{"CWE-89"}, cwes: []*github.AdvisoryCWEs{{CWEID: new("CWE-79")}}, expected: []string{"CWE-89"}}, + {name: "empty IDs", cweIDs: []string{}, cwes: []*github.AdvisoryCWEs{{CWEID: new("CWE-79")}}, expected: []string{"CWE-79"}}, + } { + t.Run(tc.name, func(t *testing.T) { + advisory := github.GlobalSecurityAdvisory{ + SecurityAdvisory: github.SecurityAdvisory{CWEIDs: tc.cweIDs, CWEs: tc.cwes}, + } + assert.Equal(t, tc.expected, globalSecurityAdvisoryOutput(&advisory).CWEIDs) + assert.Equal(t, tc.cweIDs, advisory.CWEIDs, "projection must not mutate the API model") + }) + } +} + +func TestSecretScanningOutputQualifiers(t *testing.T) { + for _, tc := range []struct { + name string + value *bool + expected string + }{ + {"absent", nil, `{}`}, + {"false", new(false), `{"is_base64_encoded":false,"has_more_locations":false}`}, + {"true", new(true), `{"is_base64_encoded":true,"has_more_locations":true}`}, + } { + t.Run(tc.name, func(t *testing.T) { + output := secretScanningAlertOutput(&github.SecretScanningAlert{ + IsBase64Encoded: tc.value, HasMoreLocations: tc.value, + }) + assert.JSONEq(t, tc.expected, mustMarshalJSON(t, output)) + resolved, err := securityOutputSchema(t, "get_secret_scanning_alert").Resolve(nil) + require.NoError(t, err) + var value any + require.NoError(t, json.Unmarshal([]byte(tc.expected), &value)) + require.NoError(t, resolved.Validate(value)) + }) + } +} + func TestSecurityOutputDTOs(t *testing.T) { seen := make(map[reflect.Type]bool) var check func(reflect.Type) diff --git a/script/build-ui b/script/build-ui index a68f6764ec..a3a9179cdd 100755 --- a/script/build-ui +++ b/script/build-ui @@ -10,6 +10,9 @@ if [ ! -d "node_modules" ]; then npm install fi +echo "Testing UI..." +npm test + echo "Building UI..." npm run build From 6330b02c71ade6ecfebe3571d640ba3177cb9390 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 7 Oct 2026 01:40:51 +0200 Subject: [PATCH 59/61] ci(mcp-diff): prebuild HTTP server and fail when a side is unreachable (#3440) The streamable-http job started the server with `go run` and relied on mcp-server-diff's fixed http_startup_wait_ms sleep. On the baseline worktree (.mcp-diff-base) nothing is cached, and since the typed tool refactors compile+link exceeds 5s, so every baseline probe hit ECONNREFUSED ("fetch failed"). The action treats a one-sided startup failure as a non-fatal "config-missing" and diffs against an empty baseline, so fail_on_error never triggered and the job stayed green. Build the binary in install_command (which completes on both sides before the server starts) and start it directly, and add a step that fails the job if any configuration did not start on either side. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/workflows/mcp-diff.yml | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/.github/workflows/mcp-diff.yml b/.github/workflows/mcp-diff.yml index 3e635244f9..0e1ec42f10 100644 --- a/.github/workflows/mcp-diff.yml +++ b/.github/workflows/mcp-diff.yml @@ -119,7 +119,14 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Run MCP Server Diff (streamable-http) + id: diff # Pinned to mcp-server-diff v3.0.0 — see rationale on the stdio job above. + # The action sleeps http_startup_wait_ms and then probes, with no + # readiness check. Build the binary in install_command (which runs to + # completion on both the current and baseline checkouts before the + # server is started) so the wait only covers process startup, not + # compilation. Using `go run` here let compile time on the uncached + # baseline checkout exceed the wait, so every baseline probe failed. uses: SamMorrowDrums/mcp-server-diff@40d992e0a220e5b63378758f9a40d6a8982898d2 # v3.0.0 with: setup_go: "false" @@ -127,10 +134,26 @@ jobs: 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 + go build -o bin/github-mcp-server ./cmd/github-mcp-server + http_start_command: ./bin/github-mcp-server http --port 8082 http_startup_wait_ms: "5000" configurations: ${{ steps.configs.outputs.configurations }} + - name: Verify both sides were probed + # mcp-server-diff treats a configuration that fails on only one side as + # "missing on that ref" (non-fatal) and diffs against an empty + # baseline, so fail_on_error does not catch an unreachable server. + # With a single shared HTTP server, a one-sided failure always means + # that side's server never came up, so fail the job explicitly. + env: + REPORT: ${{ steps.diff.outputs.json_report_path }} + run: | + jq -e 'all(.results[]; .configMissing == null)' "$REPORT" > /dev/null || { + jq -r '.results[] | select(.configMissing) | "::error::\(.configName): server unreachable on \(.configMissing.side) side: \(.configMissing.error)"' "$REPORT" + exit 1 + } + jq -r '.results[] | "\(.configName): base=\(.baseProtocolVersion) head=\(.branchProtocolVersion) tools base=\(.baseCounts.tools) head=\(.branchCounts.tools)"' "$REPORT" + - name: Add interpretation note if: always() run: | From 55edd58d5e1127fe7ea3c036bc14dca66f7e41c4 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Wed, 7 Oct 2026 10:09:18 +0200 Subject: [PATCH 60/61] fix(go)!: use the v2 module path for Go consumers (#3445) Migrate internal imports, build linker symbols, installation docs, and generated license attribution to the /v2 module path. BREAKING CHANGE: Go consumers must import github.com/github/github-mcp-server/v2. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .goreleaser.yaml | 2 +- Dockerfile | 2 +- README.md | 14 +++++++++- cmd/github-mcp-server/feature_flag_docs.go | 6 ++-- cmd/github-mcp-server/generate_docs.go | 6 ++-- cmd/github-mcp-server/list_scopes.go | 6 ++-- cmd/github-mcp-server/main.go | 16 +++++------ cmd/github-mcp-server/main_test.go | 4 +-- cmd/mcpcurl/README.md | 2 +- docs/oauth-login.md | 4 +-- e2e/README.md | 2 +- e2e/e2e_test.go | 8 +++--- go.mod | 2 +- internal/buildinfo/buildinfo.go | 2 +- internal/ghmcp/oauth.go | 4 +-- internal/ghmcp/oauth_test.go | 6 ++-- internal/ghmcp/oauth_typed_test.go | 4 +-- internal/ghmcp/server.go | 28 +++++++++---------- internal/ghmcp/server_test.go | 8 +++--- pkg/context/token.go | 2 +- pkg/errors/error.go | 4 +-- pkg/github/actions.go | 16 +++++------ pkg/github/actions_test.go | 4 +-- pkg/github/actions_types.go | 2 +- pkg/github/code_quality.go | 10 +++---- pkg/github/code_quality_test.go | 2 +- pkg/github/code_scanning.go | 12 ++++---- pkg/github/code_scanning_test.go | 2 +- pkg/github/comment_minimize.go | 10 +++---- pkg/github/comment_minimize_test.go | 8 +++--- pkg/github/consolidated_pull_request_types.go | 2 +- pkg/github/context_tools.go | 12 ++++---- pkg/github/context_tools_test.go | 8 +++--- pkg/github/copilot.go | 14 +++++----- pkg/github/copilot_test.go | 6 ++-- pkg/github/csv_output.go | 4 +-- pkg/github/csv_output_test.go | 2 +- pkg/github/custom_properties.go | 8 +++--- pkg/github/custom_properties_test.go | 4 +-- pkg/github/dependabot.go | 12 ++++---- pkg/github/dependabot_test.go | 2 +- pkg/github/dependencies.go | 18 ++++++------ pkg/github/dependencies_test.go | 14 +++++----- .../discussion_notification_contracts_test.go | 6 ++-- pkg/github/discussion_notification_inputs.go | 2 +- pkg/github/discussions.go | 12 ++++---- pkg/github/discussions_test.go | 6 ++-- pkg/github/encoded_schemas_test.go | 8 +++--- pkg/github/feature_flags.go | 2 +- pkg/github/feature_flags_benchmark_test.go | 4 +-- pkg/github/feature_flags_test.go | 8 +++--- pkg/github/fields_filtering_test.go | 6 ++-- pkg/github/fields_telemetry_test.go | 4 +-- pkg/github/find_duplicate.go | 12 ++++---- pkg/github/find_duplicate_test.go | 6 ++-- pkg/github/gists.go | 12 ++++---- pkg/github/gists_test.go | 4 +-- pkg/github/git.go | 12 ++++---- pkg/github/git_gist_types.go | 2 +- pkg/github/git_test.go | 4 +-- pkg/github/granular_issue_types.go | 4 +-- pkg/github/granular_pull_request_types.go | 2 +- pkg/github/granular_tools_test.go | 12 ++++---- pkg/github/header_params_test.go | 2 +- pkg/github/ifc_labels.go | 2 +- pkg/github/inventory.go | 4 +-- pkg/github/issue_dependencies.go | 10 +++---- pkg/github/issue_dependencies_test.go | 6 ++-- pkg/github/issue_fields.go | 12 ++++---- pkg/github/issue_fields_test.go | 6 ++-- pkg/github/issue_tool_types.go | 2 +- pkg/github/issues.go | 18 ++++++------ pkg/github/issues_delete_test.go | 2 +- pkg/github/issues_granular.go | 12 ++++---- pkg/github/issues_test.go | 14 +++++----- pkg/github/labels.go | 12 ++++---- pkg/github/labels_test.go | 6 ++-- pkg/github/lockdown.go | 4 +-- pkg/github/minimal_types.go | 2 +- .../notification_output_fidelity_test.go | 4 +-- pkg/github/notifications.go | 12 ++++---- pkg/github/notifications_test.go | 4 +-- pkg/github/projects.go | 16 +++++------ pkg/github/projects_batch.go | 4 +-- pkg/github/projects_batch_test.go | 8 +++--- pkg/github/projects_resolver.go | 4 +-- pkg/github/projects_resolver_test.go | 8 +++--- pkg/github/projects_test.go | 14 +++++----- pkg/github/projects_types.go | 2 +- pkg/github/projects_v2_test.go | 4 +-- pkg/github/prompts.go | 4 +-- pkg/github/public_repo_scopes_test.go | 6 ++-- pkg/github/pullrequests.go | 14 +++++----- pkg/github/pullrequests_granular.go | 10 +++---- pkg/github/pullrequests_sha_pinning_test.go | 2 +- pkg/github/pullrequests_test.go | 6 ++-- pkg/github/repositories.go | 16 +++++------ pkg/github/repositories_helper.go | 6 ++-- pkg/github/repositories_test.go | 14 +++++----- pkg/github/repository_path.go | 2 +- pkg/github/repository_path_test.go | 4 +-- pkg/github/repository_resource.go | 8 +++--- pkg/github/repository_resource_test.go | 2 +- pkg/github/repository_tool_types.go | 2 +- pkg/github/resources.go | 4 +-- pkg/github/rulesets.go | 10 +++---- pkg/github/rulesets_test.go | 6 ++-- pkg/github/scope_filter.go | 2 +- pkg/github/scope_filter_test.go | 6 ++-- pkg/github/search.go | 12 ++++---- pkg/github/search_semantic_test.go | 4 +-- pkg/github/search_test.go | 4 +-- pkg/github/search_utils.go | 4 +-- pkg/github/secret_scanning.go | 12 ++++---- pkg/github/secret_scanning_test.go | 2 +- pkg/github/security_advisories.go | 12 ++++---- pkg/github/security_advisories_test.go | 2 +- pkg/github/security_outputs.go | 2 +- pkg/github/server.go | 10 +++---- pkg/github/server_test.go | 12 ++++---- pkg/github/tool_scopes.go | 4 +-- pkg/github/tool_scopes_test.go | 4 +-- pkg/github/tools.go | 6 ++-- pkg/github/tools_static_validation_test.go | 2 +- pkg/github/tools_validation_test.go | 2 +- pkg/github/toolset_icons_test.go | 2 +- pkg/github/toolset_instructions.go | 2 +- .../typed_account_repository_search_test.go | 6 ++-- pkg/github/typed_actions_outputs_test.go | 4 +-- pkg/github/typed_compatibility_test.go | 8 +++--- ...yped_consolidated_issue_enrichment_test.go | 4 +-- .../typed_consolidated_issue_outputs_test.go | 8 +++--- ..._consolidated_pull_request_outputs_test.go | 8 +++--- pkg/github/typed_copilot_ui_outputs.go | 2 +- pkg/github/typed_copilot_ui_outputs_test.go | 14 +++++----- ...ed_discussion_notification_outputs_test.go | 8 +++--- pkg/github/typed_git_gist_outputs_test.go | 8 +++--- pkg/github/typed_governance_outputs.go | 2 +- pkg/github/typed_governance_outputs_test.go | 6 ++-- .../typed_granular_issue_outputs_test.go | 8 +++--- ...yped_granular_pull_request_outputs_test.go | 8 +++--- pkg/github/typed_issue_outputs_test.go | 4 +-- pkg/github/typed_projects_outputs_test.go | 6 ++-- pkg/github/typed_read_normalizers.go | 2 +- .../typed_repository_commit_outputs_test.go | 6 ++-- pkg/github/typed_repository_outputs_test.go | 8 +++--- pkg/github/typed_search_outputs_test.go | 10 +++---- .../typed_security_compatibility_test.go | 4 +-- pkg/github/typed_security_outputs_test.go | 6 ++-- pkg/github/ui_capability.go | 2 +- pkg/github/ui_capability_test.go | 4 +-- pkg/github/ui_resources_test.go | 2 +- pkg/github/ui_tools.go | 8 +++--- pkg/github/ui_tools_test.go | 8 +++--- pkg/github/workflow_prompts.go | 4 +-- pkg/http/handler.go | 18 ++++++------ pkg/http/handler_test.go | 16 +++++------ pkg/http/middleware/cors.go | 2 +- pkg/http/middleware/cors_test.go | 2 +- pkg/http/middleware/mcp_parse.go | 4 +-- pkg/http/middleware/mcp_parse_test.go | 2 +- pkg/http/middleware/pat_scope.go | 6 ++-- pkg/http/middleware/pat_scope_test.go | 4 +-- pkg/http/middleware/request_config.go | 4 +-- pkg/http/middleware/request_config_test.go | 4 +-- pkg/http/middleware/scope_challenge.go | 8 +++--- pkg/http/middleware/scope_challenge_test.go | 10 +++---- pkg/http/middleware/token.go | 6 ++-- pkg/http/middleware/token_test.go | 8 +++--- pkg/http/oauth/oauth.go | 6 ++-- pkg/http/oauth/oauth_test.go | 4 +-- pkg/http/server.go | 24 ++++++++-------- pkg/http/server_test.go | 12 ++++---- pkg/http/servercard/handler.go | 2 +- pkg/http/servercard/handler_test.go | 2 +- pkg/http/transport/bearer.go | 4 +-- pkg/http/transport/bearer_test.go | 4 +-- pkg/http/transport/etag.go | 2 +- pkg/http/transport/etag_test.go | 2 +- pkg/http/transport/graphql_features.go | 6 ++-- pkg/http/transport/graphql_features_test.go | 4 +-- pkg/http/transport/user_agent.go | 2 +- pkg/inventory/registry.go | 2 +- pkg/inventory/registry_test.go | 2 +- pkg/inventory/server_tool.go | 2 +- pkg/inventory/tool_availability.go | 2 +- pkg/inventory/tool_availability_test.go | 2 +- pkg/inventory/typed_output.go | 2 +- pkg/inventory/typed_output_test.go | 2 +- pkg/lockdown/lockdown_test.go | 2 +- pkg/observability/observability.go | 2 +- pkg/observability/observability_test.go | 2 +- pkg/scopes/fetcher.go | 4 +-- pkg/scopes/map.go | 2 +- pkg/scopes/map_test.go | 2 +- pkg/scopes/scopes.go | 2 +- pkg/toolvalidation/readonlyhint_test.go | 2 +- pkg/utils/token.go | 4 +-- script/print-mcp-diff-configs/main.go | 4 +-- third-party-licenses.darwin.md | 2 +- third-party-licenses.linux.md | 2 +- third-party-licenses.windows.md | 2 +- .../github/github-mcp-server/{ => v2}/LICENSE | 0 203 files changed, 621 insertions(+), 609 deletions(-) rename third-party/github.com/github/github-mcp-server/{ => v2}/LICENSE (100%) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 36dfc47bce..fa70312feb 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -9,7 +9,7 @@ builds: - env: - CGO_ENABLED=0 ldflags: - - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID={{ .Env.OAUTH_CLIENT_ID }} -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientSecret={{ .Env.OAUTH_CLIENT_SECRET }} + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} -X github.com/github/github-mcp-server/v2/internal/buildinfo.OAuthClientID={{ .Env.OAUTH_CLIENT_ID }} -X github.com/github/github-mcp-server/v2/internal/buildinfo.OAuthClientSecret={{ .Env.OAUTH_CLIENT_SECRET }} goos: - linux - windows diff --git a/Dockerfile b/Dockerfile index 04e39a09f2..e555c50045 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ --mount=type=secret,id=oauth_client_secret \ export OAUTH_CLIENT_ID="$(cat /run/secrets/oauth_client_id 2>/dev/null || echo '')" && \ export OAUTH_CLIENT_SECRET="$(cat /run/secrets/oauth_client_secret 2>/dev/null || echo '')" && \ - CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=$(git rev-parse HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ) -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID=${OAUTH_CLIENT_ID} -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientSecret=${OAUTH_CLIENT_SECRET}" \ + CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION} -X main.commit=$(git rev-parse HEAD) -X main.date=$(date -u +%Y-%m-%dT%H:%M:%SZ) -X github.com/github/github-mcp-server/v2/internal/buildinfo.OAuthClientID=${OAUTH_CLIENT_ID} -X github.com/github/github-mcp-server/v2/internal/buildinfo.OAuthClientSecret=${OAUTH_CLIENT_SECRET}" \ -o /bin/github-mcp-server ./cmd/github-mcp-server # Make a stage to run the app diff --git a/README.md b/README.md index 1409f73735..6413b58760 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Go Report Card](https://goreportcard.com/badge/github.com/github/github-mcp-server)](https://goreportcard.com/report/github.com/github/github-mcp-server) +[![Go Report Card](https://goreportcard.com/badge/github.com/github/github-mcp-server/v2)](https://goreportcard.com/report/github.com/github/github-mcp-server/v2) # GitHub MCP Server @@ -14,6 +14,18 @@ The GitHub MCP Server connects AI tools directly to GitHub's platform. This give Built for developers who want to connect their AI tools to GitHub context and capabilities, from simple natural language queries to complex multi-step agent workflows. +### Using the Go module + +The Go module path is `github.com/github/github-mcp-server/v2`. Library consumers +must use this prefix in imports, for example +`github.com/github/github-mcp-server/v2/pkg/github`, and in `go get` commands. +No `/v2` subdirectory is needed when building from a repository checkout. + +The existing `v2.0.0` tag predates this module-path correction and cannot be used +as a Go module. After this change is merged, a new release such as `v2.0.1` must +be tagged from a commit containing the corrected module path. Until then, +consumers can use a pseudo-version from a commit containing this change. + --- ## Remote GitHub MCP Server diff --git a/cmd/github-mcp-server/feature_flag_docs.go b/cmd/github-mcp-server/feature_flag_docs.go index e52237b138..33e84908ac 100644 --- a/cmd/github-mcp-server/feature_flag_docs.go +++ b/cmd/github-mcp-server/feature_flag_docs.go @@ -8,9 +8,9 @@ import ( "sort" "strings" - "github.com/github/github-mcp-server/pkg/github" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/github" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" ) // generateInsidersFeaturesDocs refreshes the auto-generated section of diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index 736513f6f1..3fb793aaa1 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -9,9 +9,9 @@ import ( "sort" "strings" - "github.com/github/github-mcp-server/pkg/github" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/github" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/jsonschema-go/jsonschema" "github.com/spf13/cobra" ) diff --git a/cmd/github-mcp-server/list_scopes.go b/cmd/github-mcp-server/list_scopes.go index 8d6d038d1e..25e6e87b5c 100644 --- a/cmd/github-mcp-server/list_scopes.go +++ b/cmd/github-mcp-server/list_scopes.go @@ -8,9 +8,9 @@ import ( "sort" "strings" - "github.com/github/github-mcp-server/pkg/github" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/github" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/spf13/cobra" "github.com/spf13/viper" ) diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 91abef6a08..04ba68e226 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -9,14 +9,14 @@ import ( "strings" "time" - "github.com/github/github-mcp-server/internal/buildinfo" - "github.com/github/github-mcp-server/internal/ghmcp" - "github.com/github/github-mcp-server/internal/githubapp" - "github.com/github/github-mcp-server/internal/oauth" - "github.com/github/github-mcp-server/pkg/github" - ghhttp "github.com/github/github-mcp-server/pkg/http" - ghoauth "github.com/github/github-mcp-server/pkg/http/oauth" - "github.com/github/github-mcp-server/pkg/utils" + "github.com/github/github-mcp-server/v2/internal/buildinfo" + "github.com/github/github-mcp-server/v2/internal/ghmcp" + "github.com/github/github-mcp-server/v2/internal/githubapp" + "github.com/github/github-mcp-server/v2/internal/oauth" + "github.com/github/github-mcp-server/v2/pkg/github" + ghhttp "github.com/github/github-mcp-server/v2/pkg/http" + ghoauth "github.com/github/github-mcp-server/v2/pkg/http/oauth" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/spf13/cobra" "github.com/spf13/pflag" "github.com/spf13/viper" diff --git a/cmd/github-mcp-server/main_test.go b/cmd/github-mcp-server/main_test.go index a5b2b84967..48d1367acf 100644 --- a/cmd/github-mcp-server/main_test.go +++ b/cmd/github-mcp-server/main_test.go @@ -6,8 +6,8 @@ import ( "strings" "testing" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/spf13/viper" diff --git a/cmd/mcpcurl/README.md b/cmd/mcpcurl/README.md index e1227d585d..cf24a74358 100644 --- a/cmd/mcpcurl/README.md +++ b/cmd/mcpcurl/README.md @@ -27,7 +27,7 @@ go build -o mcpcurl ### Using Go Install ```bash -go install github.com/github/github-mcp-server/cmd/mcpcurl@latest +go install github.com/github/github-mcp-server/v2/cmd/mcpcurl@latest ``` ### Verify Installation diff --git a/docs/oauth-login.md b/docs/oauth-login.md index 92fc79c9df..b2016580bb 100644 --- a/docs/oauth-login.md +++ b/docs/oauth-login.md @@ -260,8 +260,8 @@ credentials, set them with `-ldflags`: ```bash go build -ldflags "\ - -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID= \ - -X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientSecret=" \ + -X github.com/github/github-mcp-server/v2/internal/buildinfo.OAuthClientID= \ + -X github.com/github/github-mcp-server/v2/internal/buildinfo.OAuthClientSecret=" \ ./cmd/github-mcp-server ``` diff --git a/e2e/README.md b/e2e/README.md index 62730431ad..08f26850f2 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -73,7 +73,7 @@ Running the tests: --- PASS: TestE2E/Initialize (0.09s) --- FAIL: TestE2E/CallTool_get_me (0.46s) FAIL -FAIL github.com/github/github-mcp-server/e2e 1.433s +FAIL github.com/github/github-mcp-server/v2/e2e 1.433s FAIL ``` diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 3df717087a..eb32972650 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -16,10 +16,10 @@ import ( "testing" "time" - "github.com/github/github-mcp-server/internal/ghmcp" - "github.com/github/github-mcp-server/pkg/github" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + "github.com/github/github-mcp-server/v2/internal/ghmcp" + "github.com/github/github-mcp-server/v2/pkg/github" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" gogithub "github.com/google/go-github/v92/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" diff --git a/go.mod b/go.mod index e4fc3c7975..ea0cd5eb9d 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/github/github-mcp-server +module github.com/github/github-mcp-server/v2 go 1.26.8 diff --git a/internal/buildinfo/buildinfo.go b/internal/buildinfo/buildinfo.go index cd5084fa29..7e9f55d813 100644 --- a/internal/buildinfo/buildinfo.go +++ b/internal/buildinfo/buildinfo.go @@ -6,7 +6,7 @@ // // Example: // -// go build -ldflags="-X github.com/github/github-mcp-server/internal/buildinfo.OAuthClientID=xxx" +// go build -ldflags="-X github.com/github/github-mcp-server/v2/internal/buildinfo.OAuthClientID=xxx" package buildinfo // OAuthClientID is the default OAuth client ID, set at build time. Empty in diff --git a/internal/ghmcp/oauth.go b/internal/ghmcp/oauth.go index 3648189217..c363da2e50 100644 --- a/internal/ghmcp/oauth.go +++ b/internal/ghmcp/oauth.go @@ -8,8 +8,8 @@ import ( "log/slog" "strings" - "github.com/github/github-mcp-server/internal/oauth" - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/internal/oauth" + "github.com/github/github-mcp-server/v2/pkg/inventory" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/internal/ghmcp/oauth_test.go b/internal/ghmcp/oauth_test.go index e5a29d6b27..fc6b37b17f 100644 --- a/internal/ghmcp/oauth_test.go +++ b/internal/ghmcp/oauth_test.go @@ -10,9 +10,9 @@ import ( "net/url" "testing" - "github.com/github/github-mcp-server/internal/oauth" - "github.com/github/github-mcp-server/pkg/github" - "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/github/github-mcp-server/v2/internal/oauth" + "github.com/github/github-mcp-server/v2/pkg/github" + "github.com/github/github-mcp-server/v2/pkg/http/headers" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" diff --git a/internal/ghmcp/oauth_typed_test.go b/internal/ghmcp/oauth_typed_test.go index 19eb63e327..11f37d4b68 100644 --- a/internal/ghmcp/oauth_typed_test.go +++ b/internal/ghmcp/oauth_typed_test.go @@ -5,8 +5,8 @@ import ( "encoding/json" "testing" - "github.com/github/github-mcp-server/internal/oauth" - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/internal/oauth" + "github.com/github/github-mcp-server/v2/pkg/inventory" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 886f90c5f8..c826b6d0ea 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -13,20 +13,20 @@ import ( "syscall" "time" - "github.com/github/github-mcp-server/internal/oauth" - "github.com/github/github-mcp-server/internal/requeststate" - "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/github" - "github.com/github/github-mcp-server/pkg/http/transport" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/lockdown" - mcplog "github.com/github/github-mcp-server/pkg/log" - "github.com/github/github-mcp-server/pkg/observability" - "github.com/github/github-mcp-server/pkg/observability/metrics" - "github.com/github/github-mcp-server/pkg/raw" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + "github.com/github/github-mcp-server/v2/internal/oauth" + "github.com/github/github-mcp-server/v2/internal/requeststate" + "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/github" + "github.com/github/github-mcp-server/v2/pkg/http/transport" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/lockdown" + mcplog "github.com/github/github-mcp-server/v2/pkg/log" + "github.com/github/github-mcp-server/v2/pkg/observability" + "github.com/github/github-mcp-server/v2/pkg/observability/metrics" + "github.com/github/github-mcp-server/v2/pkg/raw" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" gogithub "github.com/google/go-github/v92/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/internal/ghmcp/server_test.go b/internal/ghmcp/server_test.go index b0fa1f7544..1d6f844c41 100644 --- a/internal/ghmcp/server_test.go +++ b/internal/ghmcp/server_test.go @@ -10,10 +10,10 @@ import ( "testing" "time" - "github.com/github/github-mcp-server/pkg/github" - "github.com/github/github-mcp-server/pkg/observability" - "github.com/github/github-mcp-server/pkg/observability/metrics" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/github" + "github.com/github/github-mcp-server/v2/pkg/observability" + "github.com/github/github-mcp-server/v2/pkg/observability/metrics" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/context/token.go b/pkg/context/token.go index 97091a922f..32f52b64a5 100644 --- a/pkg/context/token.go +++ b/pkg/context/token.go @@ -3,7 +3,7 @@ package context import ( "context" - "github.com/github/github-mcp-server/pkg/utils" + "github.com/github/github-mcp-server/v2/pkg/utils" ) type tokenCtxKey struct{} diff --git a/pkg/errors/error.go b/pkg/errors/error.go index c0e45ad1ad..7c9fb049e1 100644 --- a/pkg/errors/error.go +++ b/pkg/errors/error.go @@ -9,8 +9,8 @@ import ( "strings" "time" - "github.com/github/github-mcp-server/pkg/sanitize" - "github.com/github/github-mcp-server/pkg/utils" + "github.com/github/github-mcp-server/v2/pkg/sanitize" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/go-github/v92/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/actions.go b/pkg/github/actions.go index 0f22c3cc5f..fc31477f63 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -10,14 +10,14 @@ import ( "strconv" "strings" - "github.com/github/github-mcp-server/internal/profiler" - buffer "github.com/github/github-mcp-server/pkg/buffer" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/ifc" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + "github.com/github/github-mcp-server/v2/internal/profiler" + buffer "github.com/github/github-mcp-server/v2/pkg/buffer" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go index 5caf7d269b..cee001c804 100644 --- a/pkg/github/actions_test.go +++ b/pkg/github/actions_test.go @@ -7,8 +7,8 @@ import ( "net/http/httptest" "testing" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" diff --git a/pkg/github/actions_types.go b/pkg/github/actions_types.go index ca16cfbc52..ffdd689b00 100644 --- a/pkg/github/actions_types.go +++ b/pkg/github/actions_types.go @@ -8,7 +8,7 @@ import ( "strings" "sync" - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/inventory" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/code_quality.go b/pkg/github/code_quality.go index a3d3e95556..e5ef82c394 100644 --- a/pkg/github/code_quality.go +++ b/pkg/github/code_quality.go @@ -10,11 +10,11 @@ import ( "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" - 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" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" ) func GetCodeQualityFinding(t translations.TranslationHelperFunc) inventory.ServerTool { diff --git a/pkg/github/code_quality_test.go b/pkg/github/code_quality_test.go index 24a5452a8a..0542cd2f53 100644 --- a/pkg/github/code_quality_test.go +++ b/pkg/github/code_quality_test.go @@ -11,7 +11,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/translations" ) func Test_GetCodeQualityFinding(t *testing.T) { diff --git a/pkg/github/code_scanning.go b/pkg/github/code_scanning.go index 189ab9eafa..6d0ef073b5 100644 --- a/pkg/github/code_scanning.go +++ b/pkg/github/code_scanning.go @@ -6,12 +6,12 @@ import ( "io" "net/http" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/ifc" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/go-github/v92/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 9d65535308..4095a46f1a 100644 --- a/pkg/github/code_scanning_test.go +++ b/pkg/github/code_scanning_test.go @@ -6,7 +6,7 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" diff --git a/pkg/github/comment_minimize.go b/pkg/github/comment_minimize.go index db87e98869..a80f7a79a9 100644 --- a/pkg/github/comment_minimize.go +++ b/pkg/github/comment_minimize.go @@ -8,11 +8,11 @@ import ( "slices" "strings" - 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" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/comment_minimize_test.go b/pkg/github/comment_minimize_test.go index 6a4c552462..9411500bd8 100644 --- a/pkg/github/comment_minimize_test.go +++ b/pkg/github/comment_minimize_test.go @@ -8,10 +8,10 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/internal/githubv4mock" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/jsonrpc" diff --git a/pkg/github/consolidated_pull_request_types.go b/pkg/github/consolidated_pull_request_types.go index e5b48e7964..6d6abaed63 100644 --- a/pkg/github/consolidated_pull_request_types.go +++ b/pkg/github/consolidated_pull_request_types.go @@ -7,7 +7,7 @@ import ( "slices" "strings" - "github.com/github/github-mcp-server/pkg/utils" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/go-viper/mapstructure/v2" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 72b2c3e0a0..93f9eed04c 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -8,12 +8,12 @@ import ( "strings" "time" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/ifc" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index 52b2c58a04..041f2d168c 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -7,10 +7,10 @@ import ( "testing" "time" - "github.com/github/github-mcp-server/internal/githubv4mock" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/copilot.go b/pkg/github/copilot.go index cc49042c9f..151c7e3f59 100644 --- a/pkg/github/copilot.go +++ b/pkg/github/copilot.go @@ -10,13 +10,13 @@ import ( "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/inventory" - "github.com/github/github-mcp-server/pkg/octicons" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/octicons" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/go-viper/mapstructure/v2" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" diff --git a/pkg/github/copilot_test.go b/pkg/github/copilot_test.go index 8bb0722141..7d7ecf9ac7 100644 --- a/pkg/github/copilot_test.go +++ b/pkg/github/copilot_test.go @@ -10,9 +10,9 @@ import ( "testing" "time" - "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/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" diff --git a/pkg/github/csv_output.go b/pkg/github/csv_output.go index 8bb368385c..ee4bba19db 100644 --- a/pkg/github/csv_output.go +++ b/pkg/github/csv_output.go @@ -9,8 +9,8 @@ import ( "sort" "strings" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/utils" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/csv_output_test.go b/pkg/github/csv_output_test.go index ec7222199a..43e59ec0e9 100644 --- a/pkg/github/csv_output_test.go +++ b/pkg/github/csv_output_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/inventory" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" diff --git a/pkg/github/custom_properties.go b/pkg/github/custom_properties.go index 89e8ee43d6..055efc4461 100644 --- a/pkg/github/custom_properties.go +++ b/pkg/github/custom_properties.go @@ -7,10 +7,10 @@ import ( "net/http" "net/url" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/custom_properties_test.go b/pkg/github/custom_properties_test.go index 88af8114c3..a2ff2d69c1 100644 --- a/pkg/github/custom_properties_test.go +++ b/pkg/github/custom_properties_test.go @@ -12,8 +12,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/translations" ) func Test_CustomPropertiesRead(t *testing.T) { diff --git a/pkg/github/dependabot.go b/pkg/github/dependabot.go index 83f3e624ea..32669788c0 100644 --- a/pkg/github/dependabot.go +++ b/pkg/github/dependabot.go @@ -7,12 +7,12 @@ import ( "io" "net/http" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/ifc" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/go-github/v92/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 1656f4942c..089ed58faa 100644 --- a/pkg/github/dependabot_test.go +++ b/pkg/github/dependabot_test.go @@ -6,7 +6,7 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/dependencies.go b/pkg/github/dependencies.go index b8a4b80eef..0ede5ff9a7 100644 --- a/pkg/github/dependencies.go +++ b/pkg/github/dependencies.go @@ -7,15 +7,15 @@ import ( "log/slog" "net/http" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/http/transport" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/lockdown" - "github.com/github/github-mcp-server/pkg/observability" - "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" - "github.com/github/github-mcp-server/pkg/utils" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/http/transport" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/lockdown" + "github.com/github/github-mcp-server/v2/pkg/observability" + "github.com/github/github-mcp-server/v2/pkg/observability/metrics" + "github.com/github/github-mcp-server/v2/pkg/raw" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" gogithub "github.com/google/go-github/v92/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/dependencies_test.go b/pkg/github/dependencies_test.go index fc8d70ab04..b99ac2aa00 100644 --- a/pkg/github/dependencies_test.go +++ b/pkg/github/dependencies_test.go @@ -13,13 +13,13 @@ import ( "sync" "testing" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/github" - "github.com/github/github-mcp-server/pkg/http/headers" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/observability" - "github.com/github/github-mcp-server/pkg/observability/metrics" - "github.com/github/github-mcp-server/pkg/translations" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/github" + "github.com/github/github-mcp-server/v2/pkg/http/headers" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/observability" + "github.com/github/github-mcp-server/v2/pkg/observability/metrics" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" diff --git a/pkg/github/discussion_notification_contracts_test.go b/pkg/github/discussion_notification_contracts_test.go index 88586c4914..11d6ea26d9 100644 --- a/pkg/github/discussion_notification_contracts_test.go +++ b/pkg/github/discussion_notification_contracts_test.go @@ -7,9 +7,9 @@ import ( "strings" "testing" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/sanitize" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/sanitize" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/discussion_notification_inputs.go b/pkg/github/discussion_notification_inputs.go index 6204bfc93d..05c52db19c 100644 --- a/pkg/github/discussion_notification_inputs.go +++ b/pkg/github/discussion_notification_inputs.go @@ -4,7 +4,7 @@ import ( "encoding/json" "strings" - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/inventory" "github.com/go-viper/mapstructure/v2" ) diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index 9125d99329..2de3b3fd95 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -6,12 +6,12 @@ import ( "fmt" "strings" - "github.com/github/github-mcp-server/pkg/ifc" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/sanitize" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/sanitize" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/discussions_test.go b/pkg/github/discussions_test.go index c502f2aa47..be94ad777b 100644 --- a/pkg/github/discussions_test.go +++ b/pkg/github/discussions_test.go @@ -6,9 +6,9 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/internal/githubv4mock" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" diff --git a/pkg/github/encoded_schemas_test.go b/pkg/github/encoded_schemas_test.go index dadf5b3e60..820e019bb6 100644 --- a/pkg/github/encoded_schemas_test.go +++ b/pkg/github/encoded_schemas_test.go @@ -13,10 +13,10 @@ import ( "slices" "testing" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/feature_flags.go b/pkg/github/feature_flags.go index 321d8fa3bb..1bf679f5c9 100644 --- a/pkg/github/feature_flags.go +++ b/pkg/github/feature_flags.go @@ -3,7 +3,7 @@ package github import ( "slices" - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/inventory" ) // MCPAppsDisableFormDeferralFeatureFlag disables handing write-tool calls off diff --git a/pkg/github/feature_flags_benchmark_test.go b/pkg/github/feature_flags_benchmark_test.go index 97d4951ca6..8868f2fe78 100644 --- a/pkg/github/feature_flags_benchmark_test.go +++ b/pkg/github/feature_flags_benchmark_test.go @@ -6,8 +6,8 @@ import ( "sync/atomic" "testing" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/feature_flags_test.go b/pkg/github/feature_flags_test.go index 241d02c5dd..173a8cfd12 100644 --- a/pkg/github/feature_flags_test.go +++ b/pkg/github/feature_flags_test.go @@ -5,15 +5,15 @@ import ( "encoding/json" "testing" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/utils" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/utils" ) // RemoteMCPEnthusiasticGreeting is a dummy test feature flag . diff --git a/pkg/github/fields_filtering_test.go b/pkg/github/fields_filtering_test.go index cc3e684013..b54480252f 100644 --- a/pkg/github/fields_filtering_test.go +++ b/pkg/github/fields_filtering_test.go @@ -6,9 +6,9 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/internal/githubv4mock" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" diff --git a/pkg/github/fields_telemetry_test.go b/pkg/github/fields_telemetry_test.go index 14e898bf29..63e4da9326 100644 --- a/pkg/github/fields_telemetry_test.go +++ b/pkg/github/fields_telemetry_test.go @@ -7,8 +7,8 @@ import ( "testing" "time" - "github.com/github/github-mcp-server/pkg/observability" - "github.com/github/github-mcp-server/pkg/observability/metrics" + "github.com/github/github-mcp-server/v2/pkg/observability" + "github.com/github/github-mcp-server/v2/pkg/observability/metrics" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/github/find_duplicate.go b/pkg/github/find_duplicate.go index 2077bf7933..b276d608da 100644 --- a/pkg/github/find_duplicate.go +++ b/pkg/github/find_duplicate.go @@ -8,12 +8,12 @@ import ( "net/url" "strconv" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/ifc" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/find_duplicate_test.go b/pkg/github/find_duplicate_test.go index 640141a924..e6c2dc233e 100644 --- a/pkg/github/find_duplicate_test.go +++ b/pkg/github/find_duplicate_test.go @@ -8,9 +8,9 @@ import ( "net/url" "testing" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/gists.go b/pkg/github/gists.go index 3fcfffc883..9d9861470e 100644 --- a/pkg/github/gists.go +++ b/pkg/github/gists.go @@ -7,12 +7,12 @@ import ( "io" "net/http" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/ifc" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/go-github/v92/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 9b3f72ba5a..67a687e1af 100644 --- a/pkg/github/gists_test.go +++ b/pkg/github/gists_test.go @@ -7,8 +7,8 @@ import ( "testing" "time" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" diff --git a/pkg/github/git.go b/pkg/github/git.go index a67e67df59..872e342527 100644 --- a/pkg/github/git.go +++ b/pkg/github/git.go @@ -6,12 +6,12 @@ import ( "fmt" "strings" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/ifc" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/git_gist_types.go b/pkg/github/git_gist_types.go index 5fdc6bab89..6f412c3df8 100644 --- a/pkg/github/git_gist_types.go +++ b/pkg/github/git_gist_types.go @@ -6,7 +6,7 @@ import ( "sync" "time" - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/inventory" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" ) diff --git a/pkg/github/git_test.go b/pkg/github/git_test.go index e914617642..a84da7c5d0 100644 --- a/pkg/github/git_test.go +++ b/pkg/github/git_test.go @@ -7,8 +7,8 @@ import ( "strings" "testing" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" diff --git a/pkg/github/granular_issue_types.go b/pkg/github/granular_issue_types.go index 9b39158d62..a1a8f47816 100644 --- a/pkg/github/granular_issue_types.go +++ b/pkg/github/granular_issue_types.go @@ -6,8 +6,8 @@ import ( "fmt" "strings" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/utils" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/go-github/v92/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/granular_pull_request_types.go b/pkg/github/granular_pull_request_types.go index c07c2319d2..6a173e13a4 100644 --- a/pkg/github/granular_pull_request_types.go +++ b/pkg/github/granular_pull_request_types.go @@ -6,7 +6,7 @@ import ( "fmt" "maps" - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/inventory" "github.com/google/jsonschema-go/jsonschema" ) diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go index 8cbdc51760..460a192766 100644 --- a/pkg/github/granular_tools_test.go +++ b/pkg/github/granular_tools_test.go @@ -8,12 +8,12 @@ import ( "strings" "testing" - "github.com/github/github-mcp-server/internal/githubv4mock" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/http/headers" - transportpkg "github.com/github/github-mcp-server/pkg/http/transport" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/http/headers" + transportpkg "github.com/github/github-mcp-server/v2/pkg/http/transport" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" gogithub "github.com/google/go-github/v92/github" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" diff --git a/pkg/github/header_params_test.go b/pkg/github/header_params_test.go index cbd0771ca4..0ee0c523af 100644 --- a/pkg/github/header_params_test.go +++ b/pkg/github/header_params_test.go @@ -4,7 +4,7 @@ import ( "context" "testing" - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/inventory" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/require" ) diff --git a/pkg/github/ifc_labels.go b/pkg/github/ifc_labels.go index 423362a0dc..a1b7ca60ad 100644 --- a/pkg/github/ifc_labels.go +++ b/pkg/github/ifc_labels.go @@ -3,7 +3,7 @@ package github import ( "context" - "github.com/github/github-mcp-server/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/ifc" "github.com/google/go-github/v92/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/inventory.go b/pkg/github/inventory.go index 6799c1e4b2..fe226524bc 100644 --- a/pkg/github/inventory.go +++ b/pkg/github/inventory.go @@ -1,8 +1,8 @@ package github import ( - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" ) // NewInventory creates an Inventory with all available tools, resources, and prompts. diff --git a/pkg/github/issue_dependencies.go b/pkg/github/issue_dependencies.go index 8766264605..19a3b7db82 100644 --- a/pkg/github/issue_dependencies.go +++ b/pkg/github/issue_dependencies.go @@ -7,11 +7,11 @@ import ( "net/http" "strings" - 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" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/issue_dependencies_test.go b/pkg/github/issue_dependencies_test.go index 35baa815ee..9d411b0153 100644 --- a/pkg/github/issue_dependencies_test.go +++ b/pkg/github/issue_dependencies_test.go @@ -7,9 +7,9 @@ import ( "strconv" "testing" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/issue_fields.go b/pkg/github/issue_fields.go index 763c63548c..9937f74bfd 100644 --- a/pkg/github/issue_fields.go +++ b/pkg/github/issue_fields.go @@ -6,12 +6,12 @@ import ( "fmt" "strconv" - 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" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/issue_fields_test.go b/pkg/github/issue_fields_test.go index 54ae2f97eb..f30cdab256 100644 --- a/pkg/github/issue_fields_test.go +++ b/pkg/github/issue_fields_test.go @@ -5,9 +5,9 @@ import ( "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/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" diff --git a/pkg/github/issue_tool_types.go b/pkg/github/issue_tool_types.go index 6e211f4783..97d000cd2f 100644 --- a/pkg/github/issue_tool_types.go +++ b/pkg/github/issue_tool_types.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/inventory" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" ) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index c19c668e32..e8df57a54d 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -12,15 +12,15 @@ import ( "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" - "github.com/github/github-mcp-server/pkg/lockdown" - "github.com/github/github-mcp-server/pkg/sanitize" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/lockdown" + "github.com/github/github-mcp-server/v2/pkg/sanitize" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/issues_delete_test.go b/pkg/github/issues_delete_test.go index d514d31084..a8e403af32 100644 --- a/pkg/github/issues_delete_test.go +++ b/pkg/github/issues_delete_test.go @@ -9,7 +9,7 @@ import ( "sync" "testing" - "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/internal/githubv4mock" gogithub "github.com/google/go-github/v92/github" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go index af26c82f69..eab4a204cd 100644 --- a/pkg/github/issues_granular.go +++ b/pkg/github/issues_granular.go @@ -6,12 +6,12 @@ import ( "maps" "strings" - 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" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index f279b41243..79eeb22100 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -13,13 +13,13 @@ import ( "testing" "time" - "github.com/github/github-mcp-server/internal/githubv4mock" - "github.com/github/github-mcp-server/internal/toolsnaps" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "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/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/http/headers" + transportpkg "github.com/github/github-mcp-server/v2/pkg/http/transport" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/labels.go b/pkg/github/labels.go index 689ca2af16..70b5c5d8db 100644 --- a/pkg/github/labels.go +++ b/pkg/github/labels.go @@ -6,12 +6,12 @@ import ( "fmt" "strings" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/ifc" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/labels_test.go b/pkg/github/labels_test.go index b030c9cab7..a527664a81 100644 --- a/pkg/github/labels_test.go +++ b/pkg/github/labels_test.go @@ -5,9 +5,9 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/internal/githubv4mock" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/lockdown.go b/pkg/github/lockdown.go index 1d3a687028..b1a1b006ea 100644 --- a/pkg/github/lockdown.go +++ b/pkg/github/lockdown.go @@ -6,8 +6,8 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/github/github-mcp-server/pkg/lockdown" - "github.com/github/github-mcp-server/pkg/utils" + "github.com/github/github-mcp-server/v2/pkg/lockdown" + "github.com/github/github-mcp-server/v2/pkg/utils" ) // Restriction messages returned when lockdown mode withholds content from a read tool. diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 2bb9efba35..b005223cd3 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -12,7 +12,7 @@ import ( "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" - "github.com/github/github-mcp-server/pkg/sanitize" + "github.com/github/github-mcp-server/v2/pkg/sanitize" ) // codeSearchItemFieldEnum lists the selectable fields for search_code result diff --git a/pkg/github/notification_output_fidelity_test.go b/pkg/github/notification_output_fidelity_test.go index 35f29738c2..7fe969e876 100644 --- a/pkg/github/notification_output_fidelity_test.go +++ b/pkg/github/notification_output_fidelity_test.go @@ -6,8 +6,8 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" diff --git a/pkg/github/notifications.go b/pkg/github/notifications.go index 108e42e290..7558b07edf 100644 --- a/pkg/github/notifications.go +++ b/pkg/github/notifications.go @@ -8,12 +8,12 @@ import ( "net/http" "time" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/ifc" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/notifications_test.go b/pkg/github/notifications_test.go index 09ee191914..0fb37eebc8 100644 --- a/pkg/github/notifications_test.go +++ b/pkg/github/notifications_test.go @@ -6,8 +6,8 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 4915373d12..436364c7ba 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -11,14 +11,14 @@ import ( "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" - "github.com/github/github-mcp-server/pkg/sanitize" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/sanitize" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/projects_batch.go b/pkg/github/projects_batch.go index 3c4e16c979..cf464f2870 100644 --- a/pkg/github/projects_batch.go +++ b/pkg/github/projects_batch.go @@ -9,8 +9,8 @@ import ( "sync" "time" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/utils" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/go-github/v92/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/projects_batch_test.go b/pkg/github/projects_batch_test.go index 7e17849e4e..32b7522a31 100644 --- a/pkg/github/projects_batch_test.go +++ b/pkg/github/projects_batch_test.go @@ -14,10 +14,10 @@ import ( "testing" "time" - "github.com/github/github-mcp-server/internal/githubv4mock" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/githubv4mock" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/projects_resolver.go b/pkg/github/projects_resolver.go index b4fba6c33e..9716d36a93 100644 --- a/pkg/github/projects_resolver.go +++ b/pkg/github/projects_resolver.go @@ -6,8 +6,8 @@ import ( "strconv" "strings" - ghcontext "github.com/github/github-mcp-server/pkg/context" - ghErrors "github.com/github/github-mcp-server/pkg/errors" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" "github.com/shurcooL/githubv4" ) diff --git a/pkg/github/projects_resolver_test.go b/pkg/github/projects_resolver_test.go index 459c6f1192..769572b411 100644 --- a/pkg/github/projects_resolver_test.go +++ b/pkg/github/projects_resolver_test.go @@ -6,10 +6,10 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/internal/githubv4mock" - "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/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/pkg/http/headers" + transportpkg "github.com/github/github-mcp-server/v2/pkg/http/transport" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 4c1aa3af46..9cbbdf26bf 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -6,13 +6,13 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/internal/githubv4mock" - "github.com/github/github-mcp-server/internal/toolsnaps" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "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/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/http/headers" + transportpkg "github.com/github/github-mcp-server/v2/pkg/http/transport" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" gogithub "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" diff --git a/pkg/github/projects_types.go b/pkg/github/projects_types.go index ecd92c4b48..746406ffca 100644 --- a/pkg/github/projects_types.go +++ b/pkg/github/projects_types.go @@ -7,7 +7,7 @@ import ( "fmt" "reflect" - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/inventory" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/projects_v2_test.go b/pkg/github/projects_v2_test.go index 2f397e7fce..f5255e99e5 100644 --- a/pkg/github/projects_v2_test.go +++ b/pkg/github/projects_v2_test.go @@ -9,8 +9,8 @@ import ( "testing" "time" - "github.com/github/github-mcp-server/internal/githubv4mock" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/prompts.go b/pkg/github/prompts.go index 0c1ac2e9ea..23c4bdfc99 100644 --- a/pkg/github/prompts.go +++ b/pkg/github/prompts.go @@ -1,8 +1,8 @@ package github import ( - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" ) // AllPrompts returns all prompts with their embedded toolset metadata. diff --git a/pkg/github/public_repo_scopes_test.go b/pkg/github/public_repo_scopes_test.go index 305410a433..4d959bcb12 100644 --- a/pkg/github/public_repo_scopes_test.go +++ b/pkg/github/public_repo_scopes_test.go @@ -3,9 +3,9 @@ package github import ( "testing" - "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/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index e376eed5db..5545d43964 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -14,13 +14,13 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/ifc" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/octicons" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/octicons" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" ) // PullRequestRead creates a tool to get details of a specific pull request. diff --git a/pkg/github/pullrequests_granular.go b/pkg/github/pullrequests_granular.go index 0ebd93b797..423a53114d 100644 --- a/pkg/github/pullrequests_granular.go +++ b/pkg/github/pullrequests_granular.go @@ -7,11 +7,11 @@ import ( "maps" "strings" - 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" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" gogithub "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/pullrequests_sha_pinning_test.go b/pkg/github/pullrequests_sha_pinning_test.go index 02f8b758a3..0995855f87 100644 --- a/pkg/github/pullrequests_sha_pinning_test.go +++ b/pkg/github/pullrequests_sha_pinning_test.go @@ -5,7 +5,7 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index a6bb796839..d0f8887e6a 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -9,9 +9,9 @@ import ( "testing" "time" - "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/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index d0fcdd8299..5db5880917 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -12,14 +12,14 @@ import ( "strings" "time" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/ifc" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/octicons" - "github.com/github/github-mcp-server/pkg/sanitize" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/octicons" + "github.com/github/github-mcp-server/v2/pkg/sanitize" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/go-github/v92/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 519385fc8c..62a4fcf755 100644 --- a/pkg/github/repositories_helper.go +++ b/pkg/github/repositories_helper.go @@ -12,9 +12,9 @@ import ( "strings" "unicode/utf8" - 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" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/raw" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/go-github/v92/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 5e5ef09123..a054de6db7 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -10,13 +10,13 @@ import ( "testing" "time" - "github.com/github/github-mcp-server/internal/githubv4mock" - "github.com/github/github-mcp-server/internal/requeststate" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/inventory" - "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/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/internal/requeststate" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/raw" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/repository_path.go b/pkg/github/repository_path.go index 78c8de5f96..ebc0aa62db 100644 --- a/pkg/github/repository_path.go +++ b/pkg/github/repository_path.go @@ -6,7 +6,7 @@ import ( "slices" "strings" - "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/scopes" ) const workflowPathPrefix = ".github/workflows/" diff --git a/pkg/github/repository_path_test.go b/pkg/github/repository_path_test.go index 4ec51304ee..b23fe86ce5 100644 --- a/pkg/github/repository_path_test.go +++ b/pkg/github/repository_path_test.go @@ -4,8 +4,8 @@ import ( "context" "testing" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/github/repository_resource.go b/pkg/github/repository_resource.go index d6f82294f8..f7e8410146 100644 --- a/pkg/github/repository_resource.go +++ b/pkg/github/repository_resource.go @@ -11,10 +11,10 @@ import ( "strconv" "strings" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/octicons" - "github.com/github/github-mcp-server/pkg/raw" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/octicons" + "github.com/github/github-mcp-server/v2/pkg/raw" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/yosida95/uritemplate/v3" diff --git a/pkg/github/repository_resource_test.go b/pkg/github/repository_resource_test.go index c1402da60d..900842b9bb 100644 --- a/pkg/github/repository_resource_test.go +++ b/pkg/github/repository_resource_test.go @@ -10,7 +10,7 @@ import ( "strings" "testing" - "github.com/github/github-mcp-server/pkg/raw" + "github.com/github/github-mcp-server/v2/pkg/raw" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" ) diff --git a/pkg/github/repository_tool_types.go b/pkg/github/repository_tool_types.go index 2a6239a0c4..4f3b584983 100644 --- a/pkg/github/repository_tool_types.go +++ b/pkg/github/repository_tool_types.go @@ -6,7 +6,7 @@ import ( "fmt" "maps" - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/inventory" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/resources.go b/pkg/github/resources.go index 2db7cac551..6eb88fa9a7 100644 --- a/pkg/github/resources.go +++ b/pkg/github/resources.go @@ -1,8 +1,8 @@ package github import ( - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" ) // AllResources returns all resource templates with their embedded toolset metadata. diff --git a/pkg/github/rulesets.go b/pkg/github/rulesets.go index d6beef64a5..af0ce1b3c2 100644 --- a/pkg/github/rulesets.go +++ b/pkg/github/rulesets.go @@ -9,11 +9,11 @@ import ( "strconv" "strings" - 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" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/rulesets_test.go b/pkg/github/rulesets_test.go index f849687673..ca49482967 100644 --- a/pkg/github/rulesets_test.go +++ b/pkg/github/rulesets_test.go @@ -13,9 +13,9 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" ) func Test_RepositoryRulesetRead(t *testing.T) { diff --git a/pkg/github/scope_filter.go b/pkg/github/scope_filter.go index f5c1a46e38..dd944ac0a1 100644 --- a/pkg/github/scope_filter.go +++ b/pkg/github/scope_filter.go @@ -3,7 +3,7 @@ package github import ( "context" - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/inventory" ) // CreateToolScopeFilter creates an inventory.ToolFilter that filters tools diff --git a/pkg/github/scope_filter_test.go b/pkg/github/scope_filter_test.go index 43ef6335b4..5ef06b2bec 100644 --- a/pkg/github/scope_filter_test.go +++ b/pkg/github/scope_filter_test.go @@ -4,9 +4,9 @@ import ( "context" "testing" - "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/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/search.go b/pkg/github/search.go index 7d86f0a5e3..368357c4ed 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -8,12 +8,12 @@ import ( "net/http" "slices" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/ifc" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/search_semantic_test.go b/pkg/github/search_semantic_test.go index 0a3bfba5fe..102664d08c 100644 --- a/pkg/github/search_semantic_test.go +++ b/pkg/github/search_semantic_test.go @@ -3,8 +3,8 @@ package github import ( "testing" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/search_test.go b/pkg/github/search_test.go index c66bb36df0..a94380876a 100644 --- a/pkg/github/search_test.go +++ b/pkg/github/search_test.go @@ -7,8 +7,8 @@ import ( "testing" "time" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" diff --git a/pkg/github/search_utils.go b/pkg/github/search_utils.go index 5c82f4b60f..b05bf1c21b 100644 --- a/pkg/github/search_utils.go +++ b/pkg/github/search_utils.go @@ -9,8 +9,8 @@ import ( "regexp" "strings" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/utils" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/go-github/v92/github" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/secret_scanning.go b/pkg/github/secret_scanning.go index 7293c3be2b..43dd1fd005 100644 --- a/pkg/github/secret_scanning.go +++ b/pkg/github/secret_scanning.go @@ -7,12 +7,12 @@ import ( "io" "net/http" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/ifc" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/go-github/v92/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 26dcfa999c..4891b142ec 100644 --- a/pkg/github/secret_scanning_test.go +++ b/pkg/github/secret_scanning_test.go @@ -6,7 +6,7 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/stretchr/testify/assert" diff --git a/pkg/github/security_advisories.go b/pkg/github/security_advisories.go index f0cc044ef0..42eb505f77 100644 --- a/pkg/github/security_advisories.go +++ b/pkg/github/security_advisories.go @@ -7,12 +7,12 @@ import ( "io" "net/http" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/ifc" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/go-github/v92/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 e6ec8aa496..a398729acc 100644 --- a/pkg/github/security_advisories_test.go +++ b/pkg/github/security_advisories_test.go @@ -6,7 +6,7 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/security_outputs.go b/pkg/github/security_outputs.go index def9716271..88fb34f12b 100644 --- a/pkg/github/security_outputs.go +++ b/pkg/github/security_outputs.go @@ -8,7 +8,7 @@ import ( "strconv" "time" - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/inventory" "github.com/google/go-github/v92/github" ) diff --git a/pkg/github/server.go b/pkg/github/server.go index 37d571cd01..de82c8be3f 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -8,11 +8,11 @@ import ( "strings" "time" - gherrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/octicons" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + gherrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/octicons" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/server_test.go b/pkg/github/server_test.go index 366ecc8a0d..8fe76965b0 100644 --- a/pkg/github/server_test.go +++ b/pkg/github/server_test.go @@ -11,12 +11,12 @@ import ( "testing" "time" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/lockdown" - "github.com/github/github-mcp-server/pkg/observability" - "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" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/lockdown" + "github.com/github/github-mcp-server/v2/pkg/observability" + "github.com/github/github-mcp-server/v2/pkg/observability/metrics" + "github.com/github/github-mcp-server/v2/pkg/raw" + "github.com/github/github-mcp-server/v2/pkg/translations" gogithub "github.com/google/go-github/v92/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/tool_scopes.go b/pkg/github/tool_scopes.go index 4482a6e243..02b01c7881 100644 --- a/pkg/github/tool_scopes.go +++ b/pkg/github/tool_scopes.go @@ -1,8 +1,8 @@ package github import ( - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" ) func publicRepositoryWriteScopeAccess() inventory.ScopeAccess { diff --git a/pkg/github/tool_scopes_test.go b/pkg/github/tool_scopes_test.go index 6854237f56..64b490854d 100644 --- a/pkg/github/tool_scopes_test.go +++ b/pkg/github/tool_scopes_test.go @@ -3,8 +3,8 @@ package github import ( "testing" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/github/tools.go b/pkg/github/tools.go index e3177f668f..53503f68fe 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -8,9 +8,9 @@ import ( "github.com/google/go-github/v92/github" "github.com/shurcooL/githubv4" - "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/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" ) type GetClientFn func(context.Context) (*github.Client, error) diff --git a/pkg/github/tools_static_validation_test.go b/pkg/github/tools_static_validation_test.go index 34cd309d6a..6f8914c212 100644 --- a/pkg/github/tools_static_validation_test.go +++ b/pkg/github/tools_static_validation_test.go @@ -4,7 +4,7 @@ import ( "os" "testing" - "github.com/github/github-mcp-server/pkg/toolvalidation" + "github.com/github/github-mcp-server/v2/pkg/toolvalidation" "github.com/stretchr/testify/require" ) diff --git a/pkg/github/tools_validation_test.go b/pkg/github/tools_validation_test.go index dc3e758dd3..fd34eec246 100644 --- a/pkg/github/tools_validation_test.go +++ b/pkg/github/tools_validation_test.go @@ -9,7 +9,7 @@ import ( "strings" "testing" - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/inventory" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/github/toolset_icons_test.go b/pkg/github/toolset_icons_test.go index 7cfe4bef77..53a6aa3380 100644 --- a/pkg/github/toolset_icons_test.go +++ b/pkg/github/toolset_icons_test.go @@ -3,7 +3,7 @@ package github import ( "testing" - "github.com/github/github-mcp-server/pkg/octicons" + "github.com/github/github-mcp-server/v2/pkg/octicons" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/github/toolset_instructions.go b/pkg/github/toolset_instructions.go index 3b3a54eadd..b282d42806 100644 --- a/pkg/github/toolset_instructions.go +++ b/pkg/github/toolset_instructions.go @@ -1,6 +1,6 @@ package github -import "github.com/github/github-mcp-server/pkg/inventory" +import "github.com/github/github-mcp-server/v2/pkg/inventory" // Toolset instruction functions - these generate context-aware instructions for each toolset. // They are called during inventory build to generate server instructions. diff --git a/pkg/github/typed_account_repository_search_test.go b/pkg/github/typed_account_repository_search_test.go index 3e33263201..64d32b2973 100644 --- a/pkg/github/typed_account_repository_search_test.go +++ b/pkg/github/typed_account_repository_search_test.go @@ -10,9 +10,9 @@ import ( "strings" "testing" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/typed_actions_outputs_test.go b/pkg/github/typed_actions_outputs_test.go index 599eca1feb..7e7a8dbab4 100644 --- a/pkg/github/typed_actions_outputs_test.go +++ b/pkg/github/typed_actions_outputs_test.go @@ -13,8 +13,8 @@ import ( "testing" "time" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/typed_compatibility_test.go b/pkg/github/typed_compatibility_test.go index d89836b9ce..d1e68a6b1f 100644 --- a/pkg/github/typed_compatibility_test.go +++ b/pkg/github/typed_compatibility_test.go @@ -8,10 +8,10 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/internal/githubv4mock" - "github.com/github/github-mcp-server/pkg/ifc" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/typed_consolidated_issue_enrichment_test.go b/pkg/github/typed_consolidated_issue_enrichment_test.go index 61d29f5712..2e02dcf09c 100644 --- a/pkg/github/typed_consolidated_issue_enrichment_test.go +++ b/pkg/github/typed_consolidated_issue_enrichment_test.go @@ -7,8 +7,8 @@ import ( "testing" "time" - "github.com/github/github-mcp-server/internal/githubv4mock" - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/pkg/inventory" "github.com/google/go-github/v92/github" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/typed_consolidated_issue_outputs_test.go b/pkg/github/typed_consolidated_issue_outputs_test.go index 44501c1527..60374e10cb 100644 --- a/pkg/github/typed_consolidated_issue_outputs_test.go +++ b/pkg/github/typed_consolidated_issue_outputs_test.go @@ -10,10 +10,10 @@ import ( "testing" "time" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/sanitize" - "github.com/github/github-mcp-server/pkg/translations" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/sanitize" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/typed_consolidated_pull_request_outputs_test.go b/pkg/github/typed_consolidated_pull_request_outputs_test.go index 082691bc20..60afd2f1f1 100644 --- a/pkg/github/typed_consolidated_pull_request_outputs_test.go +++ b/pkg/github/typed_consolidated_pull_request_outputs_test.go @@ -11,10 +11,10 @@ import ( "testing" "time" - "github.com/github/github-mcp-server/internal/toolsnaps" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/typed_copilot_ui_outputs.go b/pkg/github/typed_copilot_ui_outputs.go index 88c4aec5e2..cefedb6893 100644 --- a/pkg/github/typed_copilot_ui_outputs.go +++ b/pkg/github/typed_copilot_ui_outputs.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/inventory" "github.com/go-viper/mapstructure/v2" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/typed_copilot_ui_outputs_test.go b/pkg/github/typed_copilot_ui_outputs_test.go index a3c2fed021..6245f783bc 100644 --- a/pkg/github/typed_copilot_ui_outputs_test.go +++ b/pkg/github/typed_copilot_ui_outputs_test.go @@ -11,13 +11,13 @@ import ( "testing" "time" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/http/middleware" - "github.com/github/github-mcp-server/pkg/http/oauth" - "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" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/http/middleware" + "github.com/github/github-mcp-server/v2/pkg/http/oauth" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/typed_discussion_notification_outputs_test.go b/pkg/github/typed_discussion_notification_outputs_test.go index 7c49a9b70a..de7f27817a 100644 --- a/pkg/github/typed_discussion_notification_outputs_test.go +++ b/pkg/github/typed_discussion_notification_outputs_test.go @@ -7,10 +7,10 @@ import ( "strings" "testing" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/ifc" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/typed_git_gist_outputs_test.go b/pkg/github/typed_git_gist_outputs_test.go index 9795b314b0..ef411295df 100644 --- a/pkg/github/typed_git_gist_outputs_test.go +++ b/pkg/github/typed_git_gist_outputs_test.go @@ -9,10 +9,10 @@ import ( "testing" "time" - "github.com/github/github-mcp-server/pkg/ifc" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" diff --git a/pkg/github/typed_governance_outputs.go b/pkg/github/typed_governance_outputs.go index 400fd1bdff..74925ad881 100644 --- a/pkg/github/typed_governance_outputs.go +++ b/pkg/github/typed_governance_outputs.go @@ -8,7 +8,7 @@ import ( "reflect" "strings" - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/inventory" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/typed_governance_outputs_test.go b/pkg/github/typed_governance_outputs_test.go index 989f3916b1..b274d0e68d 100644 --- a/pkg/github/typed_governance_outputs_test.go +++ b/pkg/github/typed_governance_outputs_test.go @@ -7,9 +7,9 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/typed_granular_issue_outputs_test.go b/pkg/github/typed_granular_issue_outputs_test.go index c5bdff08c2..d2b833d650 100644 --- a/pkg/github/typed_granular_issue_outputs_test.go +++ b/pkg/github/typed_granular_issue_outputs_test.go @@ -9,10 +9,10 @@ import ( "strings" "testing" - "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/inventory" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/http/headers" + transportpkg "github.com/github/github-mcp-server/v2/pkg/http/transport" + "github.com/github/github-mcp-server/v2/pkg/inventory" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/typed_granular_pull_request_outputs_test.go b/pkg/github/typed_granular_pull_request_outputs_test.go index 0b8f5e5e98..9585e96632 100644 --- a/pkg/github/typed_granular_pull_request_outputs_test.go +++ b/pkg/github/typed_granular_pull_request_outputs_test.go @@ -10,10 +10,10 @@ import ( "testing" "time" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" gogithub "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/typed_issue_outputs_test.go b/pkg/github/typed_issue_outputs_test.go index 4c7a5ec9e3..698abdf01a 100644 --- a/pkg/github/typed_issue_outputs_test.go +++ b/pkg/github/typed_issue_outputs_test.go @@ -7,8 +7,8 @@ import ( "strings" "testing" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/typed_projects_outputs_test.go b/pkg/github/typed_projects_outputs_test.go index 6a97e17380..34cbc9acf2 100644 --- a/pkg/github/typed_projects_outputs_test.go +++ b/pkg/github/typed_projects_outputs_test.go @@ -7,9 +7,9 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" diff --git a/pkg/github/typed_read_normalizers.go b/pkg/github/typed_read_normalizers.go index 20ba37ab94..1bec77b7ce 100644 --- a/pkg/github/typed_read_normalizers.go +++ b/pkg/github/typed_read_normalizers.go @@ -7,7 +7,7 @@ import ( "strconv" "strings" - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/inventory" ) func normalizeTypedReadArguments(uppercaseFields []string, preserveZeroPage bool) inventory.InputNormalizer { diff --git a/pkg/github/typed_repository_commit_outputs_test.go b/pkg/github/typed_repository_commit_outputs_test.go index 9c1218eab5..59b5f207ff 100644 --- a/pkg/github/typed_repository_commit_outputs_test.go +++ b/pkg/github/typed_repository_commit_outputs_test.go @@ -6,9 +6,9 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/typed_repository_outputs_test.go b/pkg/github/typed_repository_outputs_test.go index f3a83a0216..672837c711 100644 --- a/pkg/github/typed_repository_outputs_test.go +++ b/pkg/github/typed_repository_outputs_test.go @@ -11,10 +11,10 @@ import ( "testing" "time" - "github.com/github/github-mcp-server/internal/requeststate" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/requeststate" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/typed_search_outputs_test.go b/pkg/github/typed_search_outputs_test.go index 24cfc77fa8..93aab4ff99 100644 --- a/pkg/github/typed_search_outputs_test.go +++ b/pkg/github/typed_search_outputs_test.go @@ -6,11 +6,11 @@ import ( "net/http" "testing" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/ifc" - "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/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/ifc" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/typed_security_compatibility_test.go b/pkg/github/typed_security_compatibility_test.go index 40f7ff5f0f..994aacb69e 100644 --- a/pkg/github/typed_security_compatibility_test.go +++ b/pkg/github/typed_security_compatibility_test.go @@ -7,8 +7,8 @@ import ( "net/url" "testing" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" diff --git a/pkg/github/typed_security_outputs_test.go b/pkg/github/typed_security_outputs_test.go index e55b77235b..414e8cb7a8 100644 --- a/pkg/github/typed_security_outputs_test.go +++ b/pkg/github/typed_security_outputs_test.go @@ -7,9 +7,9 @@ import ( "reflect" "testing" - "github.com/github/github-mcp-server/internal/toolsnaps" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/ui_capability.go b/pkg/github/ui_capability.go index b4f5ddf281..a46a8bb842 100644 --- a/pkg/github/ui_capability.go +++ b/pkg/github/ui_capability.go @@ -3,7 +3,7 @@ package github import ( "context" - ghcontext "github.com/github/github-mcp-server/pkg/context" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/github/ui_capability_test.go b/pkg/github/ui_capability_test.go index 7d3ee34c4a..02648361c0 100644 --- a/pkg/github/ui_capability_test.go +++ b/pkg/github/ui_capability_test.go @@ -4,8 +4,8 @@ import ( "context" "testing" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/inventory" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/inventory" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/ui_resources_test.go b/pkg/github/ui_resources_test.go index 49cce09bbd..9cc78ada5d 100644 --- a/pkg/github/ui_resources_test.go +++ b/pkg/github/ui_resources_test.go @@ -5,7 +5,7 @@ import ( "slices" "testing" - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/inventory" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/github/ui_tools.go b/pkg/github/ui_tools.go index ab6ba6ccb4..84c6aae81f 100644 --- a/pkg/github/ui_tools.go +++ b/pkg/github/ui_tools.go @@ -9,10 +9,10 @@ import ( "sort" "strings" - ghErrors "github.com/github/github-mcp-server/pkg/errors" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" - "github.com/github/github-mcp-server/pkg/utils" + ghErrors "github.com/github/github-mcp-server/v2/pkg/errors" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/github/ui_tools_test.go b/pkg/github/ui_tools_test.go index 4e6aba185f..1a9eaf6a46 100644 --- a/pkg/github/ui_tools_test.go +++ b/pkg/github/ui_tools_test.go @@ -10,10 +10,10 @@ import ( "testing" "time" - "github.com/github/github-mcp-server/internal/githubv4mock" - "github.com/github/github-mcp-server/internal/toolsnaps" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/internal/toolsnaps" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/google/go-github/v92/github" "github.com/google/jsonschema-go/jsonschema" "github.com/shurcooL/githubv4" diff --git a/pkg/github/workflow_prompts.go b/pkg/github/workflow_prompts.go index e85c933486..415d5a74cf 100644 --- a/pkg/github/workflow_prompts.go +++ b/pkg/github/workflow_prompts.go @@ -4,8 +4,8 @@ import ( "context" "fmt" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/translations" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/http/handler.go b/pkg/http/handler.go index 1aad3ed01d..25c02f254c 100644 --- a/pkg/http/handler.go +++ b/pkg/http/handler.go @@ -7,15 +7,15 @@ import ( "net/http" "slices" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/github" - "github.com/github/github-mcp-server/pkg/http/headers" - "github.com/github/github-mcp-server/pkg/http/middleware" - "github.com/github/github-mcp-server/pkg/http/oauth" - "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" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/github" + "github.com/github/github-mcp-server/v2/pkg/http/headers" + "github.com/github/github-mcp-server/v2/pkg/http/middleware" + "github.com/github/github-mcp-server/v2/pkg/http/oauth" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/go-chi/chi/v5" "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/http/handler_test.go b/pkg/http/handler_test.go index 050e53dee7..953a564da4 100644 --- a/pkg/http/handler_test.go +++ b/pkg/http/handler_test.go @@ -12,14 +12,14 @@ import ( "strings" "testing" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/github" - "github.com/github/github-mcp-server/pkg/http/headers" - "github.com/github/github-mcp-server/pkg/http/middleware" - "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" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/github" + "github.com/github/github-mcp-server/v2/pkg/http/headers" + "github.com/github/github-mcp-server/v2/pkg/http/middleware" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/go-chi/chi/v5" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/jsonrpc" diff --git a/pkg/http/middleware/cors.go b/pkg/http/middleware/cors.go index 00fd6d0a8e..1e23596962 100644 --- a/pkg/http/middleware/cors.go +++ b/pkg/http/middleware/cors.go @@ -5,7 +5,7 @@ import ( "sort" "strings" - "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/github/github-mcp-server/v2/pkg/http/headers" "golang.org/x/net/http/httpguts" ) diff --git a/pkg/http/middleware/cors_test.go b/pkg/http/middleware/cors_test.go index 4cc2dc2b4f..4572edf284 100644 --- a/pkg/http/middleware/cors_test.go +++ b/pkg/http/middleware/cors_test.go @@ -7,7 +7,7 @@ import ( "strings" "testing" - "github.com/github/github-mcp-server/pkg/http/middleware" + "github.com/github/github-mcp-server/v2/pkg/http/middleware" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/http/middleware/mcp_parse.go b/pkg/http/middleware/mcp_parse.go index 0a91de6bf1..cf5bc7bea4 100644 --- a/pkg/http/middleware/mcp_parse.go +++ b/pkg/http/middleware/mcp_parse.go @@ -6,8 +6,8 @@ import ( "io" "net/http" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/http/headers" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/http/headers" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/http/middleware/mcp_parse_test.go b/pkg/http/middleware/mcp_parse_test.go index c8de0d1173..6c6863b154 100644 --- a/pkg/http/middleware/mcp_parse_test.go +++ b/pkg/http/middleware/mcp_parse_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - ghcontext "github.com/github/github-mcp-server/pkg/context" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/http/middleware/pat_scope.go b/pkg/http/middleware/pat_scope.go index bb1efdc011..ace4b19c09 100644 --- a/pkg/http/middleware/pat_scope.go +++ b/pkg/http/middleware/pat_scope.go @@ -4,9 +4,9 @@ import ( "log/slog" "net/http" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/utils" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/utils" ) // WithPATScopes is a middleware that fetches and stores scopes for classic Personal Access Tokens (PATs) in the request context. diff --git a/pkg/http/middleware/pat_scope_test.go b/pkg/http/middleware/pat_scope_test.go index 5182a636fe..bb5ff669fa 100644 --- a/pkg/http/middleware/pat_scope_test.go +++ b/pkg/http/middleware/pat_scope_test.go @@ -8,8 +8,8 @@ import ( "net/http/httptest" "testing" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/utils" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/http/middleware/request_config.go b/pkg/http/middleware/request_config.go index dee8a2c6f4..6102825192 100644 --- a/pkg/http/middleware/request_config.go +++ b/pkg/http/middleware/request_config.go @@ -5,8 +5,8 @@ import ( "slices" "strings" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/http/headers" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/http/headers" ) const queryParamFeatures = "features" diff --git a/pkg/http/middleware/request_config_test.go b/pkg/http/middleware/request_config_test.go index 8aef0fa17a..1937d0986b 100644 --- a/pkg/http/middleware/request_config_test.go +++ b/pkg/http/middleware/request_config_test.go @@ -5,8 +5,8 @@ import ( "net/http/httptest" "testing" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/http/headers" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/http/headers" "github.com/stretchr/testify/assert" ) diff --git a/pkg/http/middleware/scope_challenge.go b/pkg/http/middleware/scope_challenge.go index d99dfba35d..77db625be5 100644 --- a/pkg/http/middleware/scope_challenge.go +++ b/pkg/http/middleware/scope_challenge.go @@ -8,10 +8,10 @@ import ( "net/http" "strings" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/http/oauth" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/utils" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/http/oauth" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/utils" ) // WithScopeChallenge creates a new middleware that determines if an OAuth request contains sufficient scopes to diff --git a/pkg/http/middleware/scope_challenge_test.go b/pkg/http/middleware/scope_challenge_test.go index 6fe6dfb685..232624b350 100644 --- a/pkg/http/middleware/scope_challenge_test.go +++ b/pkg/http/middleware/scope_challenge_test.go @@ -11,11 +11,11 @@ import ( "strings" "testing" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/http/oauth" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/utils" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/http/oauth" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/http/middleware/token.go b/pkg/http/middleware/token.go index 012bbabef2..951a86e34a 100644 --- a/pkg/http/middleware/token.go +++ b/pkg/http/middleware/token.go @@ -5,9 +5,9 @@ import ( "fmt" "net/http" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/http/oauth" - "github.com/github/github-mcp-server/pkg/utils" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/http/oauth" + "github.com/github/github-mcp-server/v2/pkg/utils" ) func ExtractUserToken(oauthCfg *oauth.Config) func(next http.Handler) http.Handler { diff --git a/pkg/http/middleware/token_test.go b/pkg/http/middleware/token_test.go index a8373a1565..664d957528 100644 --- a/pkg/http/middleware/token_test.go +++ b/pkg/http/middleware/token_test.go @@ -5,10 +5,10 @@ import ( "net/http/httptest" "testing" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/http/headers" - "github.com/github/github-mcp-server/pkg/http/oauth" - "github.com/github/github-mcp-server/pkg/utils" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/http/headers" + "github.com/github/github-mcp-server/v2/pkg/http/oauth" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/http/oauth/oauth.go b/pkg/http/oauth/oauth.go index 43d57d57bf..b4ceba274d 100644 --- a/pkg/http/oauth/oauth.go +++ b/pkg/http/oauth/oauth.go @@ -7,9 +7,9 @@ import ( "net/http" "strings" - "github.com/github/github-mcp-server/pkg/http/headers" - "github.com/github/github-mcp-server/pkg/scopes" - "github.com/github/github-mcp-server/pkg/utils" + "github.com/github/github-mcp-server/v2/pkg/http/headers" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/go-chi/chi/v5" "github.com/modelcontextprotocol/go-sdk/auth" "github.com/modelcontextprotocol/go-sdk/oauthex" diff --git a/pkg/http/oauth/oauth_test.go b/pkg/http/oauth/oauth_test.go index 515a0a471f..671a62a3fe 100644 --- a/pkg/http/oauth/oauth_test.go +++ b/pkg/http/oauth/oauth_test.go @@ -10,8 +10,8 @@ import ( "strings" "testing" - "github.com/github/github-mcp-server/pkg/http/headers" - "github.com/github/github-mcp-server/pkg/utils" + "github.com/github/github-mcp-server/v2/pkg/http/headers" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/http/server.go b/pkg/http/server.go index 8a3a305e49..08e76b48e7 100644 --- a/pkg/http/server.go +++ b/pkg/http/server.go @@ -13,18 +13,18 @@ import ( "syscall" "time" - "github.com/github/github-mcp-server/internal/requeststate" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/github" - "github.com/github/github-mcp-server/pkg/http/middleware" - "github.com/github/github-mcp-server/pkg/http/oauth" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/lockdown" - "github.com/github/github-mcp-server/pkg/observability" - "github.com/github/github-mcp-server/pkg/observability/metrics" - "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/github/github-mcp-server/v2/internal/requeststate" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/github" + "github.com/github/github-mcp-server/v2/pkg/http/middleware" + "github.com/github/github-mcp-server/v2/pkg/http/oauth" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/lockdown" + "github.com/github/github-mcp-server/v2/pkg/observability" + "github.com/github/github-mcp-server/v2/pkg/observability/metrics" + "github.com/github/github-mcp-server/v2/pkg/scopes" + "github.com/github/github-mcp-server/v2/pkg/translations" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/go-chi/chi/v5" ) diff --git a/pkg/http/server_test.go b/pkg/http/server_test.go index 903194082e..60b0df0da3 100644 --- a/pkg/http/server_test.go +++ b/pkg/http/server_test.go @@ -11,12 +11,12 @@ import ( "strings" "testing" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/github" - "github.com/github/github-mcp-server/pkg/http/middleware" - "github.com/github/github-mcp-server/pkg/http/oauth" - "github.com/github/github-mcp-server/pkg/inventory" - "github.com/github/github-mcp-server/pkg/utils" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/github" + "github.com/github/github-mcp-server/v2/pkg/http/middleware" + "github.com/github/github-mcp-server/v2/pkg/http/oauth" + "github.com/github/github-mcp-server/v2/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/utils" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/http/servercard/handler.go b/pkg/http/servercard/handler.go index 6a1ce970aa..f081357e43 100644 --- a/pkg/http/servercard/handler.go +++ b/pkg/http/servercard/handler.go @@ -8,7 +8,7 @@ import ( "strconv" "strings" - "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/github/github-mcp-server/v2/pkg/http/headers" "github.com/go-chi/chi/v5" ) diff --git a/pkg/http/servercard/handler_test.go b/pkg/http/servercard/handler_test.go index a63a2d7cb5..57d16e9bb7 100644 --- a/pkg/http/servercard/handler_test.go +++ b/pkg/http/servercard/handler_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/github/github-mcp-server/v2/pkg/http/headers" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/http/transport/bearer.go b/pkg/http/transport/bearer.go index 522f4c6753..ff0049a238 100644 --- a/pkg/http/transport/bearer.go +++ b/pkg/http/transport/bearer.go @@ -4,8 +4,8 @@ import ( "net/http" "strings" - ghcontext "github.com/github/github-mcp-server/pkg/context" - headers "github.com/github/github-mcp-server/pkg/http/headers" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + headers "github.com/github/github-mcp-server/v2/pkg/http/headers" ) type BearerAuthTransport struct { diff --git a/pkg/http/transport/bearer_test.go b/pkg/http/transport/bearer_test.go index 49f50710d0..46f2a51375 100644 --- a/pkg/http/transport/bearer_test.go +++ b/pkg/http/transport/bearer_test.go @@ -7,8 +7,8 @@ import ( "net/url" "testing" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/http/headers" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/http/headers" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/http/transport/etag.go b/pkg/http/transport/etag.go index 152bc33429..7af7611d2f 100644 --- a/pkg/http/transport/etag.go +++ b/pkg/http/transport/etag.go @@ -11,7 +11,7 @@ import ( "strings" "sync" - "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/github/github-mcp-server/v2/pkg/http/headers" ) const ( diff --git a/pkg/http/transport/etag_test.go b/pkg/http/transport/etag_test.go index dcac5566eb..36b6613f09 100644 --- a/pkg/http/transport/etag_test.go +++ b/pkg/http/transport/etag_test.go @@ -9,7 +9,7 @@ import ( "sync/atomic" "testing" - "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/github/github-mcp-server/v2/pkg/http/headers" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/http/transport/graphql_features.go b/pkg/http/transport/graphql_features.go index 7fe9182fcb..b22f9eb909 100644 --- a/pkg/http/transport/graphql_features.go +++ b/pkg/http/transport/graphql_features.go @@ -4,8 +4,8 @@ import ( "net/http" "strings" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/http/headers" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/http/headers" ) // GraphQLFeaturesTransport is an http.RoundTripper that adds GraphQL-Features @@ -18,7 +18,7 @@ import ( // // Usage: // -// import "github.com/github/github-mcp-server/pkg/http/transport" +// import "github.com/github/github-mcp-server/v2/pkg/http/transport" // // httpClient := &http.Client{ // Transport: &transport.GraphQLFeaturesTransport{ diff --git a/pkg/http/transport/graphql_features_test.go b/pkg/http/transport/graphql_features_test.go index 8c814fd6f2..d4c3b74ce5 100644 --- a/pkg/http/transport/graphql_features_test.go +++ b/pkg/http/transport/graphql_features_test.go @@ -6,8 +6,8 @@ import ( "net/http/httptest" "testing" - ghcontext "github.com/github/github-mcp-server/pkg/context" - "github.com/github/github-mcp-server/pkg/http/headers" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" + "github.com/github/github-mcp-server/v2/pkg/http/headers" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/http/transport/user_agent.go b/pkg/http/transport/user_agent.go index a40b4cc45b..0bb82eb31e 100644 --- a/pkg/http/transport/user_agent.go +++ b/pkg/http/transport/user_agent.go @@ -4,7 +4,7 @@ import ( "context" "net/http" - "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/github/github-mcp-server/v2/pkg/http/headers" ) type userAgentKey struct{} diff --git a/pkg/inventory/registry.go b/pkg/inventory/registry.go index 2bc87e9bb8..e85d8b607c 100644 --- a/pkg/inventory/registry.go +++ b/pkg/inventory/registry.go @@ -7,7 +7,7 @@ import ( "slices" "sort" - ghcontext "github.com/github/github-mcp-server/pkg/context" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go index 4e827936c6..ffae37b169 100644 --- a/pkg/inventory/registry_test.go +++ b/pkg/inventory/registry_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - ghcontext "github.com/github/github-mcp-server/pkg/context" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" ) diff --git a/pkg/inventory/server_tool.go b/pkg/inventory/server_tool.go index d633c97938..947b91b501 100644 --- a/pkg/inventory/server_tool.go +++ b/pkg/inventory/server_tool.go @@ -10,7 +10,7 @@ import ( "reflect" "slices" - "github.com/github/github-mcp-server/pkg/octicons" + "github.com/github/github-mcp-server/v2/pkg/octicons" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/inventory/tool_availability.go b/pkg/inventory/tool_availability.go index 5f7edcee88..3951dcaa6f 100644 --- a/pkg/inventory/tool_availability.go +++ b/pkg/inventory/tool_availability.go @@ -4,7 +4,7 @@ import ( "context" "fmt" - ghcontext "github.com/github/github-mcp-server/pkg/context" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" "github.com/modelcontextprotocol/go-sdk/mcp" ) diff --git a/pkg/inventory/tool_availability_test.go b/pkg/inventory/tool_availability_test.go index d52e4ff607..a59905b2e4 100644 --- a/pkg/inventory/tool_availability_test.go +++ b/pkg/inventory/tool_availability_test.go @@ -7,7 +7,7 @@ import ( "testing" "time" - ghcontext "github.com/github/github-mcp-server/pkg/context" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" diff --git a/pkg/inventory/typed_output.go b/pkg/inventory/typed_output.go index 854ee7a77e..f6ec5d1ea2 100644 --- a/pkg/inventory/typed_output.go +++ b/pkg/inventory/typed_output.go @@ -7,7 +7,7 @@ import ( "errors" "fmt" - ghcontext "github.com/github/github-mcp-server/pkg/context" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/inventory/typed_output_test.go b/pkg/inventory/typed_output_test.go index 707e97199e..99b6b30ed3 100644 --- a/pkg/inventory/typed_output_test.go +++ b/pkg/inventory/typed_output_test.go @@ -13,7 +13,7 @@ import ( "sync" "testing" - ghcontext "github.com/github/github-mcp-server/pkg/context" + ghcontext "github.com/github/github-mcp-server/v2/pkg/context" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/jsonrpc" "github.com/modelcontextprotocol/go-sdk/mcp" diff --git a/pkg/lockdown/lockdown_test.go b/pkg/lockdown/lockdown_test.go index d724a46c4b..4b3a984ff8 100644 --- a/pkg/lockdown/lockdown_test.go +++ b/pkg/lockdown/lockdown_test.go @@ -10,7 +10,7 @@ import ( "testing" "time" - "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/v2/internal/githubv4mock" gogithub "github.com/google/go-github/v92/github" "github.com/muesli/cache2go" "github.com/shurcooL/githubv4" diff --git a/pkg/observability/observability.go b/pkg/observability/observability.go index 3741b05c75..72385ba47a 100644 --- a/pkg/observability/observability.go +++ b/pkg/observability/observability.go @@ -5,7 +5,7 @@ import ( "errors" "log/slog" - "github.com/github/github-mcp-server/pkg/observability/metrics" + "github.com/github/github-mcp-server/v2/pkg/observability/metrics" ) // Exporters bundles observability primitives (logger + metrics) for dependency injection. diff --git a/pkg/observability/observability_test.go b/pkg/observability/observability_test.go index c8949fdbd4..7516c89f37 100644 --- a/pkg/observability/observability_test.go +++ b/pkg/observability/observability_test.go @@ -5,7 +5,7 @@ import ( "log/slog" "testing" - "github.com/github/github-mcp-server/pkg/observability/metrics" + "github.com/github/github-mcp-server/v2/pkg/observability/metrics" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/scopes/fetcher.go b/pkg/scopes/fetcher.go index b372455031..5701c4afa2 100644 --- a/pkg/scopes/fetcher.go +++ b/pkg/scopes/fetcher.go @@ -8,8 +8,8 @@ import ( "strings" "time" - "github.com/github/github-mcp-server/pkg/http/headers" - "github.com/github/github-mcp-server/pkg/utils" + "github.com/github/github-mcp-server/v2/pkg/http/headers" + "github.com/github/github-mcp-server/v2/pkg/utils" ) // OAuthScopesHeader is the HTTP response header containing the token's OAuth scopes. diff --git a/pkg/scopes/map.go b/pkg/scopes/map.go index 2bc1bd0bb2..77b96ae598 100644 --- a/pkg/scopes/map.go +++ b/pkg/scopes/map.go @@ -3,7 +3,7 @@ package scopes import ( "encoding/json" - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/inventory" ) // ToolScopeMap maps tool names to their complete scope access policies. diff --git a/pkg/scopes/map_test.go b/pkg/scopes/map_test.go index 4db83b1295..f3c366af5a 100644 --- a/pkg/scopes/map_test.go +++ b/pkg/scopes/map_test.go @@ -3,7 +3,7 @@ package scopes import ( "testing" - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/inventory" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/pkg/scopes/scopes.go b/pkg/scopes/scopes.go index 2294ac01ef..709fd050f0 100644 --- a/pkg/scopes/scopes.go +++ b/pkg/scopes/scopes.go @@ -1,7 +1,7 @@ package scopes import ( - "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/v2/pkg/inventory" ) // Scope represents a GitHub OAuth scope. diff --git a/pkg/toolvalidation/readonlyhint_test.go b/pkg/toolvalidation/readonlyhint_test.go index 7ef3c4829b..56e48863ed 100644 --- a/pkg/toolvalidation/readonlyhint_test.go +++ b/pkg/toolvalidation/readonlyhint_test.go @@ -6,7 +6,7 @@ import ( "strings" "testing" - "github.com/github/github-mcp-server/pkg/toolvalidation" + "github.com/github/github-mcp-server/v2/pkg/toolvalidation" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) diff --git a/pkg/utils/token.go b/pkg/utils/token.go index 8933fb0bda..6bca4d8da1 100644 --- a/pkg/utils/token.go +++ b/pkg/utils/token.go @@ -6,8 +6,8 @@ import ( "regexp" "strings" - httpheaders "github.com/github/github-mcp-server/pkg/http/headers" - "github.com/github/github-mcp-server/pkg/http/mark" + httpheaders "github.com/github/github-mcp-server/v2/pkg/http/headers" + "github.com/github/github-mcp-server/v2/pkg/http/mark" ) type TokenType int diff --git a/script/print-mcp-diff-configs/main.go b/script/print-mcp-diff-configs/main.go index ada18c4063..7648e5f831 100644 --- a/script/print-mcp-diff-configs/main.go +++ b/script/print-mcp-diff-configs/main.go @@ -36,8 +36,8 @@ import ( "sort" "strings" - "github.com/github/github-mcp-server/pkg/github" - mcphdr "github.com/github/github-mcp-server/pkg/http/headers" + "github.com/github/github-mcp-server/v2/pkg/github" + mcphdr "github.com/github/github-mcp-server/v2/pkg/http/headers" ) type config struct { diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index d67b374b56..5868e15ae9 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -14,7 +14,7 @@ The following packages are included for the amd64, arm64 architectures. - [github.com/aymerick/douceur](https://pkg.go.dev/github.com/aymerick/douceur) ([MIT](https://github.com/aymerick/douceur/blob/v0.2.0/LICENSE)) - [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.9.0/LICENSE)) - - [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/github/github-mcp-server/v2](https://pkg.go.dev/github.com/github/github-mcp-server/v2) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.3.2/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/v92/github](https://pkg.go.dev/github.com/google/go-github/v92/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v92.0.0/LICENSE)) diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index a1abb6821b..b305cf81e5 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -14,7 +14,7 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/aymerick/douceur](https://pkg.go.dev/github.com/aymerick/douceur) ([MIT](https://github.com/aymerick/douceur/blob/v0.2.0/LICENSE)) - [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.9.0/LICENSE)) - - [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/github/github-mcp-server/v2](https://pkg.go.dev/github.com/github/github-mcp-server/v2) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.3.2/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/v92/github](https://pkg.go.dev/github.com/google/go-github/v92/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v92.0.0/LICENSE)) diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index 7e6f57aeb7..cd62776f35 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -14,7 +14,7 @@ The following packages are included for the 386, amd64, arm64 architectures. - [github.com/aymerick/douceur](https://pkg.go.dev/github.com/aymerick/douceur) ([MIT](https://github.com/aymerick/douceur/blob/v0.2.0/LICENSE)) - [github.com/fsnotify/fsnotify](https://pkg.go.dev/github.com/fsnotify/fsnotify) ([BSD-3-Clause](https://github.com/fsnotify/fsnotify/blob/v1.9.0/LICENSE)) - - [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/github/github-mcp-server/v2](https://pkg.go.dev/github.com/github/github-mcp-server/v2) ([MIT](https://github.com/github/github-mcp-server/blob/HEAD/LICENSE)) - [github.com/go-chi/chi/v5](https://pkg.go.dev/github.com/go-chi/chi/v5) ([MIT](https://github.com/go-chi/chi/blob/v5.3.2/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/v92/github](https://pkg.go.dev/github.com/google/go-github/v92/github) ([BSD-3-Clause](https://github.com/google/go-github/blob/v92.0.0/LICENSE)) diff --git a/third-party/github.com/github/github-mcp-server/LICENSE b/third-party/github.com/github/github-mcp-server/v2/LICENSE similarity index 100% rename from third-party/github.com/github/github-mcp-server/LICENSE rename to third-party/github.com/github/github-mcp-server/v2/LICENSE From eb47a99ddb866ca2b8a162920e6bda9521f33ebb Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Thu, 8 Oct 2026 13:25:50 +0200 Subject: [PATCH 61/61] fix(inventory): retain typed tool header validation (#3454) Preserve advertised parameter header bindings in the SDK registration schema so Streamable HTTP rejects header/body mismatches before receiving middleware. Keep full body validation and defaults behind tool guards, with unchanged advertised schemas and OAuth finalization. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/streamable-http.md | 16 ++ internal/ghmcp/oauth_typed_test.go | 141 +++++++++++++++ pkg/inventory/header_params_test.go | 266 ++++++++++++++++++++++++++++ pkg/inventory/server_tool.go | 5 +- pkg/inventory/typed_input_test.go | 14 ++ pkg/inventory/typed_schema.go | 51 +++++- 6 files changed, 490 insertions(+), 3 deletions(-) create mode 100644 pkg/inventory/header_params_test.go diff --git a/docs/streamable-http.md b/docs/streamable-http.md index ae86dba8ef..d5fc8a5d0d 100644 --- a/docs/streamable-http.md +++ b/docs/streamable-http.md @@ -12,6 +12,22 @@ The Streamable HTTP mode enables the GitHub MCP Server to run as an HTTP service ## Running the Server +### MCP parameter headers + +For protocol version `2026-07-28` and later, the SDK validates standard +`Mcp-Method`, `Mcp-Name`, and schema-annotated `Mcp-Param-*` headers at the +Streamable HTTP boundary. Tool schemas annotate `owner` and `repo` with +`x-mcp-header`; when an annotated argument is present and non-null, its header +must be present and match the body value. A header for an absent or null +argument is rejected. Header names are case-insensitive; string values and +JSON property names are case-sensitive. + +Mismatches return HTTP `400` with JSON-RPC error code `-32020` before MCP +receiving middleware or tool handlers run. This applies to typed and untyped +tools without changing their advertised schemas. Full typed body validation +and defaults still run after tool authorization guards. Older protocol versions +do not require or validate these standard parameter headers. + ### Basic HTTP Server Start the server on the default port (8082): diff --git a/internal/ghmcp/oauth_typed_test.go b/internal/ghmcp/oauth_typed_test.go index 11f37d4b68..e91e2f45aa 100644 --- a/internal/ghmcp/oauth_typed_test.go +++ b/internal/ghmcp/oauth_typed_test.go @@ -1,8 +1,12 @@ package ghmcp import ( + "bytes" "context" "encoding/json" + "fmt" + "net/http" + "net/http/httptest" "testing" "github.com/github/github-mcp-server/v2/internal/oauth" @@ -237,6 +241,143 @@ func TestOAuthTypedRegistrationLegacyElicitation(t *testing.T) { } } +func TestOAuthTypedHTTPHeaderBoundary(t *testing.T) { + for _, tc := range []struct { + name string + arguments string + owner string + repo string + mismatch bool + valid bool + }{ + {"owner-mismatch", `{"owner":"octo","repo":"hello","mode":"valid"}`, "other", "hello", true, true}, + {"repo-mismatch", `{"owner":"octo","repo":"hello","mode":"valid"}`, "octo", "other", true, true}, + {"missing-header", `{"owner":"octo","repo":"hello","mode":"valid"}`, "", "hello", true, true}, + {"matching", `{"owner":"octo","repo":"hello","mode":"valid"}`, "octo", "hello", false, true}, + {"missing-required", `{}`, "", "", false, false}, + {"invalid-enum", `{"owner":"octo","repo":"hello","mode":"invalid"}`, "octo", "hello", false, false}, + {"invalid-owner-type", `{"owner":42,"repo":"hello","mode":"valid"}`, "42", "hello", false, false}, + {"null-owner", `{"owner":null,"repo":"hello","mode":"valid"}`, "", "hello", false, false}, + {"non-object", `[]`, "", "", false, false}, + } { + t.Run(tc.name, func(t *testing.T) { + fake := oauthPendingAuthenticator() + handlerCalls, receivingCalls, preflightCalls, normalizerCalls := 0, 0, 0, 0 + type input struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Mode string `json:"mode"` + } + tool := inventory.NewServerToolWithContextHandlerAndSchemaOptions( + mcp.Tool{ + Name: probeToolName, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string"}, + "repo": {Type: "string"}, + "mode": {Type: "string", Enum: []any{"valid"}}, + }, + Required: []string{"owner", "repo", "mode"}, + }, + }, + inventory.ToolsetMetadata{ID: "test"}, + func(context.Context, *mcp.CallToolRequest, input) (*mcp.CallToolResult, oauthProbeOutput, error) { + handlerCalls++ + return nil, oauthProbeOutput{Status: "tool-ran"}, nil + }, + inventory.TypedSchemaOptions{ + Preflight: func(ctx context.Context, _ *mcp.CallToolRequest) (context.Context, *mcp.CallToolResult, error) { + preflightCalls++ + return ctx, nil, nil + }, + }, + func(raw json.RawMessage) (json.RawMessage, error) { + normalizerCalls++ + return raw, nil + }, + ) + server := mcp.NewServer(&mcp.Implementation{Name: "oauth-http", Version: "test"}, nil) + tool.RegisterFunc(server, nil, createOAuthToolMiddleware(fake, discardLogger())) + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + if method == inventory.MCPMethodToolsCall { + receivingCalls++ + } + return next(ctx, method, req) + } + }) + handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { + return server + }, &mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true}) + call := func(accept bool) *httptest.ResponseRecorder { + t.Helper() + params := map[string]any{ + "name": probeToolName, "arguments": json.RawMessage(tc.arguments), + "_meta": mcp.Meta{ + mcp.MetaKeyProtocolVersion: inventory.ProtocolVersionMultiRoundTrip, + mcp.MetaKeyClientInfo: &mcp.Implementation{Name: "test", Version: "test"}, + mcp.MetaKeyClientCapabilities: &mcp.ClientCapabilities{Elicitation: &mcp.ElicitationCapabilities{URL: &mcp.URLElicitationCapabilities{}}}, + }, + } + if accept { + params["inputResponses"] = mcp.InputResponseMap{ + oauthElicitIDPrefix + "flow-1": &mcp.ElicitResult{Action: "accept"}, + } + } + body, err := json.Marshal(params) + require.NoError(t, err) + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(fmt.Sprintf( + `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":%s}`, body))) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Mcp-Protocol-Version", inventory.ProtocolVersionMultiRoundTrip) + req.Header.Set("Mcp-Method", "tools/call") + req.Header.Set("Mcp-Name", probeToolName) + req.Header.Set("Mcp-Param-owner", tc.owner) + req.Header.Set("Mcp-Param-repo", tc.repo) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec + } + rec := call(false) + if tc.mismatch { + require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), "header mismatch") + assert.Zero(t, receivingCalls) + assert.Zero(t, fake.authCalls) + } else { + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"resultType":"input_required"`) + assert.NotContains(t, rec.Body.String(), "structuredContent") + assert.Equal(t, 1, receivingCalls) + assert.Equal(t, 1, fake.authCalls) + } + assert.Zero(t, handlerCalls) + assert.Zero(t, preflightCalls) + assert.Zero(t, normalizerCalls) + if tc.mismatch { + return + } + rec = call(true) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Contains(t, rec.Body.String(), `"resultType":"complete"`) + assert.NotContains(t, rec.Body.String(), `"resultType":"input_required"`) + assert.Equal(t, 1, fake.awaitCalls) + assert.Equal(t, 1, preflightCalls) + assert.Equal(t, 1, normalizerCalls) + if tc.valid { + assert.Equal(t, 1, handlerCalls) + assert.Contains(t, rec.Body.String(), `"structuredContent":{"status":"tool-ran"}`) + } else { + assert.Zero(t, handlerCalls) + assert.Contains(t, rec.Body.String(), `"isError":true`) + assert.NotContains(t, rec.Body.String(), "structuredContent") + } + }) + } +} + func TestOAuthTypedRegistrationGuardsInvalidArguments(t *testing.T) { type input struct { Mode string `json:"mode"` diff --git a/pkg/inventory/header_params_test.go b/pkg/inventory/header_params_test.go new file mode 100644 index 0000000000..1b3e3e5b41 --- /dev/null +++ b/pkg/inventory/header_params_test.go @@ -0,0 +1,266 @@ +package inventory + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/jsonrpc" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCachedHeaderInputSchema(t *testing.T) { + original := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", MinLength: new(1), Enum: []any{"octo"}, + Default: json.RawMessage(`"octo"`), + Extra: map[string]any{"x-mcp-header": "owner", "unrelated": "metadata"}, + }, + "query": {Type: "string", Default: json.RawMessage(`"default"`)}, + "nested": {Type: "object", Required: []string{"count"}, Properties: map[string]*jsonschema.Schema{ + "count": {Type: "integer", Minimum: new(float64(1)), Extra: map[string]any{"x-mcp-header": "count"}}, + }}, + }, + Required: []string{"owner", "query", "nested"}, + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + } + before := mustMarshalJSON(t, original) + raw := json.RawMessage(before) + var mapped map[string]any + require.NoError(t, json.Unmarshal(raw, &mapped)) + var first *jsonschema.Schema + for _, value := range []any{original, CloneSchema(original), raw, mapped} { + projected, err := cachedHeaderInputSchema(value) + require.NoError(t, err) + if first == nil { + first = projected + } else { + assert.Same(t, first, projected) + } + assert.JSONEq(t, `{ + "type":"object", + "properties":{ + "owner":{"type":"string","x-mcp-header":"owner"}, + "nested":{"properties":{"count":{"type":"integer","x-mcp-header":"count"}}} + } + }`, mustMarshalJSON(t, projected)) + resolved, err := projected.Resolve(nil) + require.NoError(t, err) + arguments := map[string]any{"query": 42} + require.NoError(t, resolved.ApplyDefaults(&arguments)) + require.NoError(t, resolved.Validate(arguments)) + assert.Equal(t, map[string]any{"query": 42}, arguments, "no required fields, defaults, or unannotated body constraints") + } + assert.JSONEq(t, before, mustMarshalJSON(t, original), "projection must not mutate the advertised schema") + empty, err := cachedHeaderInputSchema(&jsonschema.Schema{Type: "object", Properties: map[string]*jsonschema.Schema{"query": {Type: "string"}}}) + require.NoError(t, err) + object, err := cachedObjectInputSchema() + require.NoError(t, err) + assert.Same(t, object, empty) +} + +func TestTypedHeaderParamsHTTP(t *testing.T) { + type input struct { + Owner string `json:"owner"` + Repo string `json:"repo"` + Query string `json:"query,omitempty"` + Active bool `json:"active,omitempty"` + Count int `json:"count,omitempty"` + Nested struct { + Route string `json:"route"` + } `json:"nested,omitzero"` + } + schema := &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string", MinLength: new(1)}, + "repo": {Type: "string"}, + "query": {Type: "string", Default: json.RawMessage(`"default-query"`), Enum: []any{"default-query"}}, + "active": {Type: "boolean", Extra: map[string]any{ + "x-mcp-header": "Active", + }}, + "count": {Type: "integer", Extra: map[string]any{ + "x-mcp-header": "count", + }}, + "nested": {Type: "object", Properties: map[string]*jsonschema.Schema{ + "route": {Type: "string", Extra: map[string]any{"x-mcp-header": "route"}}, + }}, + }, + Required: []string{"owner", "repo"}, + } + for _, typed := range []bool{false, true} { + for _, registration := range []string{"single", "inventory", "modern", "legacy", "validation-override", "inferred"} { + t.Run(fmt.Sprintf("typed=%t/%s", typed, registration), func(t *testing.T) { + for _, protocol := range []string{ProtocolVersionMultiRoundTrip, "2025-11-25"} { + t.Run(protocol, func(t *testing.T) { + toolSchema := schema + if registration == "inferred" { + var err error + toolSchema, err = CachedInputSchemaFor[input](nil) + require.NoError(t, err) + } + options := TypedSchemaOptions{} + if registration == "validation-override" { + options.ValidationInputSchema = CloneSchema(toolSchema) + // Runtime validation need not carry the advertised bindings. + for _, property := range options.ValidationInputSchema.Properties { + property.Extra = nil + } + options.ValidationInputSchema.Properties["nested"].Properties["route"].Extra = nil + } + handlerCalls, receivingCalls, guardCalls := 0, 0, 0 + gotQuery := "" + definition := mcp.Tool{Name: "header_probe", InputSchema: toolSchema} + tool := NewServerToolWithContextHandlerAndSchemaOptions( + definition, testToolsetMetadata("test"), + func(_ context.Context, _ *mcp.CallToolRequest, args input) (*mcp.CallToolResult, typedTestOutput, error) { + handlerCalls++ + gotQuery = args.Query + return nil, typedTestOutput{Query: args.Query}, nil + }, options, + ) + if !typed { + tool = NewServerToolWithContextHandler( + definition, testToolsetMetadata("test"), + func(context.Context, *mcp.CallToolRequest, input) (*mcp.CallToolResult, any, error) { + handlerCalls++ + return &mcp.CallToolResult{Content: []mcp.Content{&mcp.TextContent{Text: "ok"}}}, nil, nil + }, + ) + } else if registration == "inferred" { + tool.Tool.InputSchema = nil + } + server := mcp.NewServer(&mcp.Implementation{Name: "header-test", Version: "test"}, nil) + guard := func(next mcp.ToolHandler) mcp.ToolHandler { + return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + guardCalls++ + return next(ctx, req) + } + } + switch registration { + case "inventory": + inv, err := NewBuilder().SetTools([]ServerTool{tool}).WithToolsets([]string{"test"}).Build() + require.NoError(t, err) + inv.RegisterTools(context.Background(), server, nil, guard) + case "modern": + tool.RegisterFuncForProtocolEra(server, nil, ProtocolEraModern, guard) + case "legacy": + tool.RegisterFuncForProtocolEra(server, nil, ProtocolEraLegacy, guard) + default: + tool.RegisterFunc(server, nil, guard) + } + // Model a scanner installed as receiving middleware. Header failures + // must be rejected by the HTTP transport before reaching it. + server.AddReceivingMiddleware(func(next mcp.MethodHandler) mcp.MethodHandler { + return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { + if method == MCPMethodToolsCall { + receivingCalls++ + } + return next(ctx, method, req) + } + }) + handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { + return server + }, &mcp.StreamableHTTPOptions{Stateless: true, JSONResponse: true}) + request := func(method, arguments string, headers map[string]string) *httptest.ResponseRecorder { + t.Helper() + params := fmt.Sprintf(`{"name":"header_probe","arguments":%s,"_meta":{"io.modelcontextprotocol/protocolVersion":%q,"io.modelcontextprotocol/clientInfo":{"name":"test","version":"1"},"io.modelcontextprotocol/clientCapabilities":{}}}`, arguments, protocol) + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(fmt.Sprintf( + `{"jsonrpc":"2.0","id":1,"method":%q,"params":%s}`, method, params))) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Mcp-Protocol-Version", protocol) + req.Header.Set("Mcp-Method", method) + req.Header.Set("Mcp-Name", "header_probe") + for key, value := range headers { + req.Header.Set(key, value) + } + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + return rec + } + list := request(MCPMethodToolsList, `{}`, nil) + require.Equal(t, http.StatusOK, list.Code, list.Body.String()) + var listed struct { + Result mcp.ListToolsResult `json:"result"` + } + require.NoError(t, json.Unmarshal(list.Body.Bytes(), &listed)) + require.Len(t, listed.Result.Tools, 1) + want := definition + want.InputSchema = toolSchema + AnnotateHeaderParams(&want) + assert.JSONEq(t, mustMarshalJSON(t, want.InputSchema), mustMarshalJSON(t, listed.Result.Tools[0].InputSchema)) + + type headerCase struct { + name string + arguments string + headers map[string]string + reject bool + } + cases := []headerCase{ + {"matching", `{"owner":"octo","repo":"hello"}`, map[string]string{"Mcp-Param-owner": "octo", "Mcp-Param-repo": "hello"}, false}, + {"owner-mismatch", `{"owner":"octo","repo":"hello"}`, map[string]string{"Mcp-Param-owner": "other", "Mcp-Param-repo": "hello"}, true}, + {"repo-mismatch", `{"owner":"octo","repo":"hello"}`, map[string]string{"Mcp-Param-owner": "octo", "Mcp-Param-repo": "other"}, true}, + {"missing-headers", `{"owner":"octo","repo":"hello"}`, nil, true}, + {"absent-body", `{}`, map[string]string{"Mcp-Param-owner": "octo"}, true}, + {"null-body", `{"owner":null}`, map[string]string{"Mcp-Param-owner": "octo"}, true}, + {"case-insensitive-header", `{"owner":"octo","repo":"hello"}`, map[string]string{"mcp-param-OWNER": "octo", "MCP-PARAM-REPO": "hello"}, false}, + {"case-sensitive-value", `{"owner":"Octo","repo":"hello"}`, map[string]string{"Mcp-Param-owner": "octo", "Mcp-Param-repo": "hello"}, true}, + {"case-sensitive-property", `{"Owner":"octo"}`, map[string]string{"Mcp-Param-owner": "octo"}, true}, + {"base64", `{"owner":"octo","repo":"hello"}`, map[string]string{"Mcp-Param-owner": "=?base64?b2N0bw==?=", "Mcp-Param-repo": "hello"}, false}, + {"invalid-base64", `{"owner":"octo","repo":"hello"}`, map[string]string{"Mcp-Param-owner": "=?base64?!!!?=", "Mcp-Param-repo": "hello"}, true}, + } + if registration != "inferred" { + cases = append(cases, + headerCase{"optional-and-nested", `{"owner":"octo","repo":"hello","active":false,"count":42,"nested":{"route":"here"}}`, + map[string]string{"Mcp-Param-owner": "octo", "Mcp-Param-repo": "hello", "Mcp-Param-active": "false", "Mcp-Param-count": "42", "Mcp-Param-route": "here"}, false}, + headerCase{"nested-mismatch", `{"owner":"octo","repo":"hello","nested":{"route":"here"}}`, + map[string]string{"Mcp-Param-owner": "octo", "Mcp-Param-repo": "hello", "Mcp-Param-route": "elsewhere"}, true}, + headerCase{"optional-absent", `{"owner":"octo","repo":"hello"}`, + map[string]string{"Mcp-Param-owner": "octo", "Mcp-Param-repo": "hello", "Mcp-Param-active": "false"}, true}, + ) + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + handlerCalls, receivingCalls, guardCalls = 0, 0, 0 + rec := request(MCPMethodToolsCall, tc.arguments, tc.headers) + if tc.reject && protocol == ProtocolVersionMultiRoundTrip { + require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String()) + var wire struct { + Error *jsonrpc.Error `json:"error"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &wire)) + require.NotNil(t, wire.Error) + assert.EqualValues(t, mcp.CodeHeaderMismatch, wire.Error.Code) + assert.Contains(t, wire.Error.Message, "header mismatch") + assert.Zero(t, handlerCalls) + assert.Zero(t, receivingCalls) + assert.Zero(t, guardCalls) + } else { + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + assert.Equal(t, 1, receivingCalls) + assert.Equal(t, 1, guardCalls) + if !tc.reject { + assert.Equal(t, 1, handlerCalls, rec.Body.String()) + if typed && registration != "inferred" { + assert.Equal(t, "default-query", gotQuery) + } + } + } + }) + } + }) + } + }) + } + } +} diff --git a/pkg/inventory/server_tool.go b/pkg/inventory/server_tool.go index 947b91b501..cb59fb8aec 100644 --- a/pkg/inventory/server_tool.go +++ b/pkg/inventory/server_tool.go @@ -472,8 +472,9 @@ func NewServerToolWithContextHandlerAndSchemaOptions[In any, Out any]( panic(fmt.Sprintf("failed to resolve input schema for tool %q: %v", tool.Name, err)) } // Input validation is deferred until guards have allowed the call. - // The advertised and validation schemas remain the original schemas. - tool.InputSchema, err = cachedObjectInputSchema() + // Retain the advertised header bindings for checks at the original + // HTTP boundary, even when the body validation schema is overridden. + tool.InputSchema, err = cachedHeaderInputSchema(registration.modernTool.InputSchema) if err != nil { panic(fmt.Sprintf("failed to prepare input schema for tool %q: %v", tool.Name, err)) } diff --git a/pkg/inventory/typed_input_test.go b/pkg/inventory/typed_input_test.go index e0924f9b77..29ae6bedb6 100644 --- a/pkg/inventory/typed_input_test.go +++ b/pkg/inventory/typed_input_test.go @@ -41,6 +41,8 @@ func compareTypedInputWithSDK[In any](t *testing.T, advertised, validation *json for _, raw := range []string{ `{"mode":"valid"}`, `{"mode":"valid","count":9}`, `{"mode":"valid","Count":9}`, `{"mode":"invalid"}`, `{"mode":42}`, `{}`, `{"mode":"valid","count":"bad"}`, + `{"mode":"valid","owner":"octo"}`, `{"mode":"valid","owner":"invalid"}`, + `{"mode":"valid","owner":42}`, `{"mode":"valid","owner":null}`, `[]`, `"not an object"`, } { t.Run(raw, func(t *testing.T) { @@ -83,6 +85,18 @@ func TestTypedInputValidationMatchesSDK(t *testing.T) { runtime.Properties["count"].Default = json.RawMessage(`3`) compareTypedInputWithSDK[typedValidationInput](t, schema, runtime) }) + t.Run("header-annotations", func(t *testing.T) { + type input struct { + Owner string `json:"owner"` + typedValidationInput + } + advertised := CloneSchema(schema) + advertised.Properties["owner"] = &jsonschema.Schema{ + Type: "string", Enum: []any{"octo"}, Default: json.RawMessage(`"octo"`), + Extra: map[string]any{"x-mcp-header": "owner"}, + } + compareTypedInputWithSDK[input](t, advertised, advertised) + }) } func TestResolvedTypedInputSchemaCache(t *testing.T) { diff --git a/pkg/inventory/typed_schema.go b/pkg/inventory/typed_schema.go index f473df7638..5c1f78d558 100644 --- a/pkg/inventory/typed_schema.go +++ b/pkg/inventory/typed_schema.go @@ -111,6 +111,7 @@ var inferredSchemaCache sync.Map var explicitSchemaCache sync.Map var ownedSchemaPointers sync.Map var annotatedSchemaCache sync.Map +var headerInputSchemaCache sync.Map var defaultObjectInputSchemaEntry inferredSchemaEntry type resolvedInputSchemaEntry struct { @@ -121,7 +122,7 @@ type resolvedInputSchemaEntry struct { var resolvedInputSchemaCache sync.Map -func cachedResolvedInputSchema(value any) (*jsonschema.Resolved, error) { +func cachedInputSchema(value any) (*jsonschema.Schema, error) { schema, ok := value.(*jsonschema.Schema) if !ok { encoded, err := json.Marshal(value) @@ -140,6 +141,14 @@ func cachedResolvedInputSchema(value any) (*jsonschema.Resolved, error) { if schema.Type != "object" { return nil, fmt.Errorf("input schema must have type \"object\"") } + return schema, nil +} + +func cachedResolvedInputSchema(value any) (*jsonschema.Resolved, error) { + schema, err := cachedInputSchema(value) + if err != nil { + return nil, err + } entryValue, _ := resolvedInputSchemaCache.LoadOrStore(schema, &resolvedInputSchemaEntry{}) entry := entryValue.(*resolvedInputSchemaEntry) entry.once.Do(func() { @@ -155,6 +164,46 @@ func cachedObjectInputSchema() (*jsonschema.Schema, error) { return defaultObjectInputSchemaEntry.schema, defaultObjectInputSchemaEntry.err } +// Keep header bindings visible to the SDK's HTTP transport without restoring +// required fields, defaults, or other body constraints ahead of tool guards. +func cachedHeaderInputSchema(value any) (*jsonschema.Schema, error) { + schema, err := cachedInputSchema(value) + if err != nil { + return nil, err + } + entryValue, _ := headerInputSchemaCache.LoadOrStore(schema, &inferredSchemaEntry{}) + entry := entryValue.(*inferredSchemaEntry) + entry.once.Do(func() { + headerSchema := headerPropertySchema(schema) + headerSchema.Type = "object" + entry.schema, entry.err = CachedSchema(headerSchema) + }) + return entry.schema, entry.err +} + +func headerPropertySchema(schema *jsonschema.Schema) *jsonschema.Schema { + result := &jsonschema.Schema{} + if annotation, ok := schema.Extra["x-mcp-header"]; ok { + // The SDK requires a primitive type when validating header annotations. + result.Type = schema.Type + result.Extra = map[string]any{"x-mcp-header": annotation} + } + for name, property := range schema.Properties { + if property == nil { + continue + } + child := headerPropertySchema(property) + if len(child.Extra) == 0 && len(child.Properties) == 0 { + continue + } + if result.Properties == nil { + result.Properties = make(map[string]*jsonschema.Schema) + } + result.Properties[name] = child + } + return result +} + // CloneSchemaWithoutDefaults returns a deep schema copy with default keywords // removed. The MCP SDK applies input-schema defaults before decoding arguments, // so use this for runtime validation schemas when omitted arguments must stay