From b879ca26dfaa3e834646576332c66af6f47c27fd Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 15 Jun 2026 16:01:05 +0200 Subject: [PATCH 01/12] fix(repos): default create_repository to private when visibility omitted (#2694) Previously, omitting the `private` parameter on create_repository defaulted the new repository to public, an insecure default that could unintentionally expose code, configuration, and history. Omission now defaults to a private repository; public repositories are only created when `private` is explicitly set to false. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- .../__toolsnaps__/create_repository.snap | 3 ++- pkg/github/repositories.go | 5 ++-- pkg/github/repositories_test.go | 26 +++++++++++++++++-- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index dc063f22ce..5d6caae6d3 100644 --- a/README.md +++ b/README.md @@ -1204,7 +1204,7 @@ The following sets of tools are available: - `description`: Repository description (string, optional) - `name`: Repository name (string, required) - `organization`: Organization to create the repository in (omit to create in your personal account) (string, optional) - - `private`: Whether repo should be private (boolean, optional) + - `private`: Whether the repository should be private. Defaults to true (private) when omitted. (boolean, optional) - **delete_file** - Delete file - **Required OAuth Scopes**: `repo` diff --git a/pkg/github/__toolsnaps__/create_repository.snap b/pkg/github/__toolsnaps__/create_repository.snap index 2cc4227b23..0aa2123673 100644 --- a/pkg/github/__toolsnaps__/create_repository.snap +++ b/pkg/github/__toolsnaps__/create_repository.snap @@ -22,7 +22,8 @@ "type": "string" }, "private": { - "description": "Whether repo should be private", + "default": true, + "description": "Whether the repository should be private. Defaults to true (private) when omitted.", "type": "boolean" } }, diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 60bb45c44f..21cbf7e643 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -600,7 +600,8 @@ func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool }, "private": { Type: "boolean", - Description: "Whether repo should be private", + Description: "Whether the repository should be private. Defaults to true (private) when omitted.", + Default: json.RawMessage("true"), }, "autoInit": { Type: "boolean", @@ -624,7 +625,7 @@ func CreateRepository(t translations.TranslationHelperFunc) inventory.ServerTool if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - private, err := OptionalParam[bool](args, "private") + private, err := OptionalBoolParamWithDefault(args, "private", true) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 8b0b196a63..e5531cc55b 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -2020,7 +2020,7 @@ func Test_CreateRepository(t *testing.T) { expectedRepo: mockRepo, }, { - name: "successful repository creation with minimal parameters", + name: "successful repository creation with minimal parameters defaults to private", mockedClient: NewMockedHTTPClient( WithRequestMatchHandler( EndpointPattern("POST /user/repos"), @@ -2028,7 +2028,7 @@ func Test_CreateRepository(t *testing.T) { "name": "test-repo", "auto_init": false, "description": "", - "private": false, + "private": true, }).andThen( mockResponse(t, http.StatusCreated, mockRepo), ), @@ -2040,6 +2040,28 @@ func Test_CreateRepository(t *testing.T) { expectError: false, expectedRepo: mockRepo, }, + { + name: "successful public repository creation when private is explicitly false", + mockedClient: NewMockedHTTPClient( + WithRequestMatchHandler( + EndpointPattern("POST /user/repos"), + expectRequestBody(t, map[string]any{ + "name": "test-repo", + "auto_init": false, + "description": "", + "private": false, + }).andThen( + mockResponse(t, http.StatusCreated, mockRepo), + ), + ), + ), + requestArgs: map[string]any{ + "name": "test-repo", + "private": false, + }, + expectError: false, + expectedRepo: mockRepo, + }, { name: "repository creation fails", mockedClient: NewMockedHTTPClient( From d27540ff67b9b1ec61a859afc7b347dadabf3878 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Mon, 15 Jun 2026 16:31:28 +0100 Subject: [PATCH 02/12] Add explicit show_ui parameter to UI-enabled write tools (#2601) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add explicit show_ui parameter to UI-enabled write tools Today the server decides whether to route issue_write and create_pull_request through the MCP App form using two implicit signals: _ui_submitted (set by the form on submit) and a heuristic that bypasses the form when the call carries any parameter the form cannot represent (labels, assignees, issue_fields, state, reviewers, etc.). The model had no first-class, documented way to say "execute directly, do not show a form". Add a show_ui boolean parameter to the input schema of IssueWrite, LegacyIssueWrite, and CreatePullRequest. It defaults to true and is visible only to clients that advertise MCP App UI support: the strip happens per-request in inventory.ToolsForRegistration via a new stripUIOnlySchemaProperties helper, gated by the same predicate that already strips _meta.ui (shouldStripMCPAppsMetadata). The two strips share one decision so the schema and metadata stay in lock-step. Form-routing predicate becomes: MCPApps FF on && client supports UI && !_ui_submitted && show_ui && !hasNonFormParams show_ui=false is a new explicit way for the model to opt out. The existing non-form-param auto-bypass stays as a safety net, and the React forms keep sending _ui_submitted=true on submit unchanged. get_me is out of scope because its UI is pure client-side card rendering with no server-side gating to replace. The current strip gate ("strip when FF is off OR capability explicitly absent") mirrors today's _meta.ui behavior exactly, including the "capability unknown" case. For stdio that means UI-capable schemas are exposed to any FF-enabled client. The handler-side clientSupportsUI check still gates form execution at call time, so it is functionally a no-op for non-UI stdio clients. A separate follow-up will tighten the gate to "strip on unknown too" and wire an InitializedHandler in stdio to re-register the un-stripped surface only after a UI-capable client has advertised; the two changes must ship together to avoid breaking stdio. docs/feature-flags.md and docs/insiders-features.md include an unrelated "reviewers" description update picked up by script/generate-docs from commit 2bd162ac ("fix: support team pull request reviewers"), which updated the source schema but did not regenerate docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Clarify where show_ui appears in generated docs The code comments next to the show_ui schema entries (and the uiOnlySchemaProperties allowlist) said the property is documented in "toolsnaps / README". README is generated from the stripped (non-UI) schema, so show_ui is not actually in it — it only appears in toolsnaps and the feature-flag / insiders docs. Reword the comments to match reality. Comment-only change; no behavior or test impact. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Guard issue_write/create_pull_request schemas against UI-gating desync The form-routing logic depends on a hand-maintained classification of each schema property into form-resendable vs known-non-form. A new property added without updating the classification would silently shift UI gating behavior (e.g. a form-incompatible param wouldn't trigger the safety-net bypass). Add Test_issueWriteSchemaClassification and Test_createPullRequestSchemaClassification that enumerate each tool's InputSchema.Properties and require every property to be classified as exactly one of: - form-resendable (member of issueWriteFormParams / pullRequestWriteFormParams) - known-non-form (test-local allowlist) A future schema addition without classification fails the test with a message pointing at the exact set the contributor needs to update. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Mark conditional schema parameters in generated docs Previously `show_ui` was listed in docs/feature-flags.md and docs/insiders-features.md alongside ordinary parameters with no indication that it is hidden from clients without MCP App UI support. A reader scanning the parameter list would assume it is always available. Add a programmatic conditional-property mechanism: - `inventory.ConditionalSchemaPropertyDescriptions()` exposes a map[propertyName]conditionDescription derived from the same uiOnlySchemaProperties allowlist that drives the per-request strip in ToolsForRegistration. Single source of truth. - The doc generator (writeToolDoc) consults this map and appends "conditional — " to the parameter's parenthesised type/required suffix. Example rendered output: - `show_ui`: Whether to render the MCP App form... (boolean, optional, conditional — only visible to clients that advertise MCP App UI support) A small test (TestConditionalSchemaPropertyDescriptions) ensures every entry in uiOnlySchemaProperties has a description, so a future stripped property addition can't silently lose its doc marker. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Sam Morrow --- cmd/github-mcp-server/generate_docs.go | 8 +- docs/feature-flags.md | 2 + docs/insiders-features.md | 2 + .../__toolsnaps__/create_pull_request.snap | 4 + pkg/github/__toolsnaps__/issue_write.snap | 4 + ...ssue_write_ff_remote_mcp_issue_fields.snap | 4 + pkg/github/issues.go | 55 +++- pkg/github/issues_test.go | 133 ++++++++ pkg/github/pullrequests.go | 27 +- pkg/github/pullrequests_test.go | 111 +++++++ pkg/inventory/builder.go | 93 ++++++ pkg/inventory/registry.go | 13 +- pkg/inventory/registry_test.go | 284 +++++++++++++++++- 13 files changed, 706 insertions(+), 34 deletions(-) diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index 78ed8361a8..74977c1a37 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -257,6 +257,8 @@ func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) { } sort.Strings(paramNames) + conditional := inventory.ConditionalSchemaPropertyDescriptions() + for i, propName := range paramNames { prop := schema.Properties[propName] required := slices.Contains(schema.Required, propName) @@ -282,7 +284,11 @@ func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) { // Indent any continuation lines in the description to maintain markdown formatting description := indentMultilineDescription(prop.Description, " ") - fmt.Fprintf(buf, " - `%s`: %s (%s, %s)", propName, description, typeStr, requiredStr) + if cond, isConditional := conditional[propName]; isConditional { + fmt.Fprintf(buf, " - `%s`: %s (%s, %s, conditional — %s)", propName, description, typeStr, requiredStr, cond) + } else { + fmt.Fprintf(buf, " - `%s`: %s (%s, %s)", propName, description, typeStr, requiredStr) + } if i < len(paramNames)-1 { buf.WriteString("\n") } diff --git a/docs/feature-flags.md b/docs/feature-flags.md index cb02463a10..a3074bdd23 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -44,6 +44,7 @@ runtime behavior (such as output formatting) won't appear here. - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) + - `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like reviewers) and the user has already confirmed the action. (boolean, optional, conditional — only visible to clients that advertise MCP App UI support) - `title`: PR title (string, required) - **get_me** - Get my user profile @@ -66,6 +67,7 @@ runtime behavior (such as output formatting) won't appear here. - `milestone`: Milestone number (number, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) + - `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, or state changes) and the user has already confirmed the action. (boolean, optional, conditional — only visible to clients that advertise MCP App UI support) - `state`: New state (string, optional) - `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional) - `title`: Issue title (string, optional) diff --git a/docs/insiders-features.md b/docs/insiders-features.md index 2277f0c8e2..8ad297e30a 100644 --- a/docs/insiders-features.md +++ b/docs/insiders-features.md @@ -38,6 +38,7 @@ The list below is generated from the Go source. It covers tool **inventory and s - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) + - `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like reviewers) and the user has already confirmed the action. (boolean, optional, conditional — only visible to clients that advertise MCP App UI support) - `title`: PR title (string, required) - **get_me** - Get my user profile @@ -60,6 +61,7 @@ The list below is generated from the Go source. It covers tool **inventory and s - `milestone`: Milestone number (number, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) + - `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, or state changes) and the user has already confirmed the action. (boolean, optional, conditional — only visible to clients that advertise MCP App UI support) - `state`: New state (string, optional) - `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional) - `title`: Issue title (string, optional) diff --git a/pkg/github/__toolsnaps__/create_pull_request.snap b/pkg/github/__toolsnaps__/create_pull_request.snap index a8a94ce690..5442aeda30 100644 --- a/pkg/github/__toolsnaps__/create_pull_request.snap +++ b/pkg/github/__toolsnaps__/create_pull_request.snap @@ -42,6 +42,10 @@ "description": "Repository name", "type": "string" }, + "show_ui": { + "description": "Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like reviewers) and the user has already confirmed the action.", + "type": "boolean" + }, "title": { "description": "PR title", "type": "string" diff --git a/pkg/github/__toolsnaps__/issue_write.snap b/pkg/github/__toolsnaps__/issue_write.snap index 88b01f08f1..43e0317d63 100644 --- a/pkg/github/__toolsnaps__/issue_write.snap +++ b/pkg/github/__toolsnaps__/issue_write.snap @@ -60,6 +60,10 @@ "description": "Repository name", "type": "string" }, + "show_ui": { + "description": "Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, or state changes) and the user has already confirmed the action.", + "type": "boolean" + }, "state": { "description": "New state", "enum": [ diff --git a/pkg/github/__toolsnaps__/issue_write_ff_remote_mcp_issue_fields.snap b/pkg/github/__toolsnaps__/issue_write_ff_remote_mcp_issue_fields.snap index 332a4de3e1..dc373d47ca 100644 --- a/pkg/github/__toolsnaps__/issue_write_ff_remote_mcp_issue_fields.snap +++ b/pkg/github/__toolsnaps__/issue_write_ff_remote_mcp_issue_fields.snap @@ -96,6 +96,10 @@ "description": "Repository name", "type": "string" }, + "show_ui": { + "description": "Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, issue_fields, or state changes) and the user has already confirmed the action.", + "type": "boolean" + }, "state": { "description": "New state", "enum": [ diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 27fc0a4abe..79b8b23ad2 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1763,6 +1763,7 @@ var issueWriteFormParams = map[string]struct{}{ "title": {}, "body": {}, "issue_number": {}, + "show_ui": {}, "_ui_submitted": {}, } @@ -1907,6 +1908,17 @@ Options are: Required: []string{"field_name"}, }, }, + // show_ui is hidden from clients that do not advertise MCP App + // UI support. The strip happens per-request in + // inventory.ToolsForRegistration; it is present in the static + // schema (and therefore in toolsnaps and the feature-flag / + // insiders docs) so the UI-capable surface is fully + // documented. It is intentionally not in the main README, + // which renders the stripped (non-UI) schema. + "show_ui": { + Type: "boolean", + Description: "Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, issue_fields, or state changes) and the user has already confirmed the action.", + }, }, Required: []string{"method", "owner", "repo"}, }, @@ -1928,13 +1940,19 @@ Options are: } // When MCP Apps are enabled and the client supports UI, route the - // call to the interactive form unless it is itself a form submission - // (the UI sends _ui_submitted=true) or it carries parameters the form - // cannot represent (e.g. labels, assignees or issue_fields). Those - // must be applied directly so their values aren't silently dropped. + // call to the interactive form unless: + // - it is itself a form submission (the UI sends _ui_submitted=true), + // - the caller explicitly asked to skip the UI (show_ui=false), or + // - it carries parameters the form cannot represent (e.g. labels, + // assignees or issue_fields). Those must be applied directly so + // their values aren't silently dropped. uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") + showUI, err := OptionalBoolParamWithDefault(args, "show_ui", true) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && !issueWriteHasNonFormParams(args) { + if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && showUI && !issueWriteHasNonFormParams(args) { if method == "update" { issueNumber, numErr := RequiredInt(args, "issue_number") if numErr != nil { @@ -2146,6 +2164,17 @@ Options are: Type: "number", Description: "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.", }, + // show_ui is hidden from clients that do not advertise MCP App + // UI support. The strip happens per-request in + // inventory.ToolsForRegistration; it is present in the static + // schema (and therefore in toolsnaps and the feature-flag / + // insiders docs) so the UI-capable surface is fully + // documented. It is intentionally not in the main README, + // which renders the stripped (non-UI) schema. + "show_ui": { + Type: "boolean", + Description: "Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, or state changes) and the user has already confirmed the action.", + }, }, Required: []string{"method", "owner", "repo"}, }, @@ -2167,13 +2196,19 @@ Options are: } // When MCP Apps are enabled and the client supports UI, route the - // call to the interactive form unless it is itself a form submission - // (the UI sends _ui_submitted=true) or it carries parameters the form - // cannot represent (e.g. labels, assignees or issue_fields). Those - // must be applied directly so their values aren't silently dropped. + // call to the interactive form unless: + // - it is itself a form submission (the UI sends _ui_submitted=true), + // - the caller explicitly asked to skip the UI (show_ui=false), or + // - it carries parameters the form cannot represent (e.g. labels, + // assignees or issue_fields). Those must be applied directly so + // their values aren't silently dropped. uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") + showUI, err := OptionalBoolParamWithDefault(args, "show_ui", true) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && !issueWriteHasNonFormParams(args) { + if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && showUI && !issueWriteHasNonFormParams(args) { if method == "update" { issueNumber, numErr := RequiredInt(args, "issue_number") if numErr != nil { diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 7e47cdb527..5378ff62b7 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -15,6 +15,7 @@ import ( "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/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" @@ -1794,6 +1795,86 @@ func Test_IssueWrite_MCPAppsFeature_UIGate(t *testing.T) { assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1", "labels call should execute directly and return issue URL") }) + + t.Run("UI client with show_ui=false skips form and executes directly", func(t *testing.T) { + // show_ui=false is the explicit, model-facing way to opt out of the + // form. It must bypass the form even when every other condition would + // route the call there (UI capability, MCP Apps flag on, no + // _ui_submitted, only form params present). + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Test", + "show_ui": false, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.NotContains(t, textContent.Text, "Ready to create an issue", + "show_ui=false should skip UI form") + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1", + "show_ui=false call should execute directly and return issue URL") + }) + + t.Run("UI client with show_ui=true returns form message", func(t *testing.T) { + // show_ui=true is the explicit, redundant-with-the-default way to ask + // for the form. It must still route through the form and must not be + // treated as a non-form parameter that would trigger the safety-net + // bypass. + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Test", + "show_ui": true, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "Ready to create an issue", + "show_ui=true should still route through the form") + }) + + t.Run("UI client with show_ui=false and _ui_submitted=true executes directly", func(t *testing.T) { + // _ui_submitted and show_ui=false are two ways to say "execute + // directly". When both are set there must be no conflict — the call + // still executes directly. + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Test", + "show_ui": false, + "_ui_submitted": true, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1", + "show_ui=false + _ui_submitted should execute directly") + }) + + t.Run("non-UI client with show_ui=false executes directly (no regression)", func(t *testing.T) { + // show_ui is irrelevant when the client does not support UI; the call + // must execute directly exactly as it does today. + request := createMCPRequest(map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Test", + "show_ui": false, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1", + "non-UI client should execute directly regardless of show_ui") + }) } func Test_issueWriteHasNonFormParams(t *testing.T) { @@ -1806,6 +1887,8 @@ func Test_issueWriteHasNonFormParams(t *testing.T) { }{ {name: "no params", args: map[string]any{}, want: false}, {name: "only form params", args: map[string]any{"method": "create", "owner": "o", "repo": "r", "title": "t", "body": "b", "issue_number": float64(1), "_ui_submitted": true}, want: false}, + {name: "show_ui true is a form param", args: map[string]any{"title": "t", "show_ui": true}, want: false}, + {name: "show_ui false is a form param", args: map[string]any{"title": "t", "show_ui": false}, want: false}, {name: "labels present", args: map[string]any{"title": "t", "labels": []any{"bug"}}, want: true}, {name: "assignees present", args: map[string]any{"title": "t", "assignees": []any{"octocat"}}, want: true}, {name: "milestone present", args: map[string]any{"title": "t", "milestone": float64(2)}, want: true}, @@ -1825,6 +1908,56 @@ func Test_issueWriteHasNonFormParams(t *testing.T) { } } +// Test_issueWriteSchemaClassification fails when a schema property is added +// without classifying it as either form-resendable (issueWriteFormParams) or +// known-non-form (knownNonForm below). Without this guard, an unclassified +// property would silently flip UI gating: form-incompatible fields would +// stop tripping the safety-net bypass and the form would drop their values. +func Test_issueWriteSchemaClassification(t *testing.T) { + t.Parallel() + + // Schema properties the MCP App form cannot represent — their presence + // must trigger the safety-net bypass via issueWriteHasNonFormParams. + knownNonForm := map[string]struct{}{ + "assignees": {}, + "labels": {}, + "milestone": {}, + "type": {}, + "state": {}, + "state_reason": {}, + "duplicate_of": {}, + "issue_fields": {}, // only on the FF-enabled IssueWrite variant + } + + cases := []struct { + name string + tool inventory.ServerTool + }{ + {name: "IssueWrite", tool: IssueWrite(translations.NullTranslationHelper)}, + {name: "LegacyIssueWrite", tool: LegacyIssueWrite(translations.NullTranslationHelper)}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + schema, ok := tc.tool.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + + for prop := range schema.Properties { + _, isForm := issueWriteFormParams[prop] + _, isNonForm := knownNonForm[prop] + + assert.Falsef(t, isForm && isNonForm, + "property %q is classified as both form-resendable and non-form — pick one", prop) + assert.Truef(t, isForm || isNonForm, + "property %q in %s schema is unclassified — add it to issueWriteFormParams (pkg/github/issues.go) "+ + "if the MCP App form can carry it on submit, otherwise add it to the knownNonForm allowlist in this test", + prop, tc.name) + } + }) + } +} + func Test_ListIssues(t *testing.T) { // Verify tool definition serverTool := ListIssues(translations.NullTranslationHelper) diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index ae7d04331d..985d8cc932 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -599,6 +599,7 @@ var pullRequestWriteFormParams = map[string]struct{}{ "base": {}, "draft": {}, "maintainer_can_modify": {}, + "show_ui": {}, "_ui_submitted": {}, } @@ -670,6 +671,17 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo Type: "boolean", Description: "Allow maintainer edits", }, + // show_ui is hidden from clients that do not advertise MCP App + // UI support. The strip happens per-request in + // inventory.ToolsForRegistration; it is present in the static + // schema (and therefore in toolsnaps and the feature-flag / + // insiders docs) so the UI-capable surface is fully + // documented. It is intentionally not in the main README, + // which renders the stripped (non-UI) schema. + "show_ui": { + Type: "boolean", + Description: "Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like reviewers) and the user has already confirmed the action.", + }, }, Required: []string{"owner", "repo", "title", "head", "base"}, }, @@ -686,13 +698,18 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo } // When MCP Apps are enabled and the client supports UI, route the - // call to the interactive form unless it is itself a form submission - // (the UI sends _ui_submitted=true) or it carries parameters the form - // cannot represent. Those must be applied directly so their values - // aren't silently dropped. + // call to the interactive form unless: + // - it is itself a form submission (the UI sends _ui_submitted=true), + // - the caller explicitly asked to skip the UI (show_ui=false), or + // - it carries parameters the form cannot represent. Those must be + // applied directly so their values aren't silently dropped. uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") + showUI, err := OptionalBoolParamWithDefault(args, "show_ui", true) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && !pullRequestWriteHasNonFormParams(args) { + if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && showUI && !pullRequestWriteHasNonFormParams(args) { return utils.NewToolResultText(fmt.Sprintf("Ready to create a pull request in %s/%s. IMPORTANT: The PR has NOT been created yet. Do NOT tell the user the PR was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil } diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 2b911636a9..207f027b31 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -2685,6 +2685,89 @@ func Test_CreatePullRequest_MCPAppsFeature_UIGate(t *testing.T) { assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42", "non-form param call should execute directly and return PR URL") }) + + t.Run("UI client with show_ui=false skips form and executes directly", func(t *testing.T) { + // show_ui=false is the explicit, model-facing way to opt out of the + // form. It must bypass the form even when every other condition would + // route the call there (UI capability, MCP Apps flag on, no + // _ui_submitted, only form params present). + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "owner": "owner", + "repo": "repo", + "title": "Test PR", + "head": "feature", + "base": "main", + "show_ui": false, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.NotContains(t, textContent.Text, "Ready to create a pull request", + "show_ui=false should skip UI form") + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42", + "show_ui=false call should execute directly and return PR URL") + }) + + t.Run("UI client with show_ui=true returns form message", func(t *testing.T) { + // show_ui=true must still route through the form and must not be + // treated as a non-form parameter that would trigger the safety-net + // bypass. + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "owner": "owner", + "repo": "repo", + "title": "Test PR", + "head": "feature", + "base": "main", + "show_ui": true, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "Ready to create a pull request", + "show_ui=true should still route through the form") + }) + + t.Run("UI client with show_ui=false and _ui_submitted=true executes directly", func(t *testing.T) { + // _ui_submitted and show_ui=false are two ways to say "execute + // directly". When both are set there must be no conflict — the call + // still executes directly. + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "owner": "owner", + "repo": "repo", + "title": "Test PR", + "head": "feature", + "base": "main", + "show_ui": false, + "_ui_submitted": true, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42", + "show_ui=false + _ui_submitted should execute directly") + }) + + t.Run("non-UI client with show_ui=false executes directly (no regression)", func(t *testing.T) { + // show_ui is irrelevant when the client does not support UI; the call + // must execute directly exactly as it does today. + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "title": "Test PR", + "head": "feature", + "base": "main", + "show_ui": false, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42", + "non-UI client should execute directly regardless of show_ui") + }) } func Test_pullRequestWriteHasNonFormParams(t *testing.T) { @@ -2697,6 +2780,8 @@ func Test_pullRequestWriteHasNonFormParams(t *testing.T) { }{ {name: "no params", args: map[string]any{}, want: false}, {name: "only form params", args: map[string]any{"owner": "o", "repo": "r", "title": "t", "body": "b", "head": "h", "base": "b", "draft": true, "maintainer_can_modify": false, "_ui_submitted": true}, want: false}, + {name: "show_ui true is a form param", args: map[string]any{"title": "t", "show_ui": true}, want: false}, + {name: "show_ui false is a form param", args: map[string]any{"title": "t", "show_ui": false}, want: false}, {name: "unknown param present", args: map[string]any{"title": "t", "reviewers": []any{"octocat"}}, want: true}, {name: "nil value is ignored", args: map[string]any{"reviewers": nil}, want: false}, } @@ -2709,6 +2794,32 @@ func Test_pullRequestWriteHasNonFormParams(t *testing.T) { } } +// Test_createPullRequestSchemaClassification fails when a schema property is +// added without classifying it as either form-resendable +// (pullRequestWriteFormParams) or known-non-form (knownNonForm below). +// Today every property is form-resendable, so knownNonForm is empty. +func Test_createPullRequestSchemaClassification(t *testing.T) { + t.Parallel() + + knownNonForm := map[string]struct{}{} + + tool := CreatePullRequest(translations.NullTranslationHelper) + schema, ok := tool.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + + for prop := range schema.Properties { + _, isForm := pullRequestWriteFormParams[prop] + _, isNonForm := knownNonForm[prop] + + assert.Falsef(t, isForm && isNonForm, + "property %q is classified as both form-resendable and non-form — pick one", prop) + assert.Truef(t, isForm || isNonForm, + "property %q in create_pull_request schema is unclassified — add it to pullRequestWriteFormParams "+ + "(pkg/github/pullrequests.go) if the MCP App form can carry it on submit, otherwise add it to "+ + "the knownNonForm allowlist in this test", prop) + } +} + func TestCreateAndSubmitPullRequestReview(t *testing.T) { t.Parallel() diff --git a/pkg/inventory/builder.go b/pkg/inventory/builder.go index 9ecaca1f57..c8a9c21bc9 100644 --- a/pkg/inventory/builder.go +++ b/pkg/inventory/builder.go @@ -7,6 +7,8 @@ import ( "maps" "slices" "strings" + + "github.com/google/jsonschema-go/jsonschema" ) var ( @@ -406,6 +408,97 @@ func stripMCPAppsMetadata(tools []ServerTool) []ServerTool { return result } +// uiOnlySchemaProperties lists input-schema property names that should only +// be visible to clients that advertise MCP Apps UI support. They live on the +// static schema (so toolsnaps and the feature-flag / insiders docs document +// the full UI-capable surface; the main README renders the stripped +// non-UI schema) and are stripped per-request when the same gate that hides +// _meta.ui is true. +var uiOnlySchemaProperties = []string{ + "show_ui", // explicit "render the MCP App form" toggle on form-backed write tools +} + +// ConditionalSchemaPropertyDescriptions returns a map of schema property name +// to a human-readable description of the condition under which the property +// is visible to clients. The doc generator uses this to annotate conditional +// parameters so readers can see at a glance which fields are not always +// available. This is the single source of truth for the conditional-property +// surface — entries here must correspond to a strip rule in +// ToolsForRegistration. +func ConditionalSchemaPropertyDescriptions() map[string]string { + const uiOnlyCondition = "visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui" + out := make(map[string]string, len(uiOnlySchemaProperties)) + for _, name := range uiOnlySchemaProperties { + out[name] = uiOnlyCondition + } + return out +} + +// stripUIOnlySchemaProperties removes UI-capability-gated input-schema +// properties (currently just "show_ui") from each tool's static schema. +// Tools whose InputSchema is not a *jsonschema.Schema (e.g. json.RawMessage) +// are passed through untouched — no such tool currently declares a gated +// property, and inferring intent from an opaque schema is not safe. +// Tools without any gated property are returned as-is so we only allocate +// when a change is actually made (mirrors the stripMetaKeys pattern). +func stripUIOnlySchemaProperties(tools []ServerTool) []ServerTool { + result := make([]ServerTool, 0, len(tools)) + for _, tool := range tools { + if stripped := stripSchemaProperties(tool, uiOnlySchemaProperties); stripped != nil { + result = append(result, *stripped) + } else { + result = append(result, tool) + } + } + return result +} + +// stripSchemaProperties removes the named keys from tool.Tool.InputSchema's +// Properties map (and Required list, if present) and returns a modified copy. +// Returns nil when the schema is not a *jsonschema.Schema or no listed key +// is present, signalling no change. +func stripSchemaProperties(tool ServerTool, keys []string) *ServerTool { + if tool.Tool.InputSchema == nil || len(keys) == 0 { + return nil + } + schema, ok := tool.Tool.InputSchema.(*jsonschema.Schema) + if !ok || schema == nil || len(schema.Properties) == 0 { + return nil + } + + hasKey := false + for _, key := range keys { + if _, exists := schema.Properties[key]; exists { + hasKey = true + break + } + } + if !hasKey { + return nil + } + + toolCopy := tool + schemaCopy := *schema + newProps := make(map[string]*jsonschema.Schema, len(schema.Properties)) + for k, v := range schema.Properties { + if !slices.Contains(keys, k) { + newProps[k] = v + } + } + schemaCopy.Properties = newProps + if len(schemaCopy.Required) > 0 { + newRequired := make([]string, 0, len(schemaCopy.Required)) + for _, r := range schemaCopy.Required { + if !slices.Contains(keys, r) { + newRequired = append(newRequired, r) + } + } + schemaCopy.Required = newRequired + } + toolCopy.Tool.InputSchema = &schemaCopy + return &toolCopy +} + // stripMetaKeys removes the specified Meta keys from a single tool. // Returns a modified copy if changes were made, nil otherwise. func stripMetaKeys(tool ServerTool, keys []string) *ServerTool { diff --git a/pkg/inventory/registry.go b/pkg/inventory/registry.go index b8a70a3420..101f8ee944 100644 --- a/pkg/inventory/registry.go +++ b/pkg/inventory/registry.go @@ -169,8 +169,9 @@ func (r *Inventory) ToolsetDescriptions() map[ToolsetID]string { } // ToolsForRegistration returns AvailableTools(ctx) post-processed exactly as -// RegisterTools would expose them: with MCP Apps UI metadata stripped when -// the client cannot consume it. Useful for documentation generators and +// RegisterTools would expose them: with MCP Apps UI metadata stripped and +// UI-capability-gated input-schema properties (e.g. show_ui) removed when +// the client cannot consume them. Useful for documentation generators and // diagnostics that need the same view of the tool surface the server would // register. // @@ -186,6 +187,7 @@ func (r *Inventory) ToolsForRegistration(ctx context.Context) []ServerTool { tools := r.AvailableTools(ctx) if shouldStripMCPAppsMetadata(ctx, r.checkFeatureFlag(ctx, mcpAppsFeatureFlag)) { tools = stripMCPAppsMetadata(tools) + tools = stripUIOnlySchemaProperties(tools) } return tools } @@ -206,9 +208,10 @@ func shouldStripMCPAppsMetadata(ctx context.Context, featureFlagEnabled bool) bo // RegisterTools registers all available tools with the server using the provided dependencies. // The context is used for feature flag evaluation and client capability checks. // -// MCP Apps UI metadata (`_meta.ui`) is stripped from the registered tools -// when either the MCP Apps feature flag is not enabled for this request, or -// the client did not advertise the io.modelcontextprotocol/ui extension. The +// MCP Apps UI metadata (`_meta.ui`) and UI-capability-gated input-schema +// properties (e.g. `show_ui`) are stripped from the registered tools when +// either the MCP Apps feature flag is not enabled for this request, or the +// client did not advertise the io.modelcontextprotocol/ui extension. The // strip happens here (rather than at Build() time) so the per-request // context is in scope — HTTP feature checkers that read insiders mode or // user identity from ctx would otherwise see context.Background() and diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go index 20b1fb718c..bcdd70f000 100644 --- a/pkg/inventory/registry_test.go +++ b/pkg/inventory/registry_test.go @@ -4,9 +4,12 @@ import ( "context" "encoding/json" "fmt" + "maps" + "slices" "testing" 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/require" ) @@ -2019,6 +2022,267 @@ func TestStripMCPAppsMetadata(t *testing.T) { require.Nil(t, result[2].Tool.Meta) } +// mockToolWithSchema creates a ServerTool with the given *jsonschema.Schema as +// InputSchema. Used to exercise schema-based strip helpers. +func mockToolWithSchema(name string, toolsetID string, schema *jsonschema.Schema) ServerTool { + return NewServerTool( + mcp.Tool{ + Name: name, + Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: true, + }, + InputSchema: schema, + }, + testToolsetMetadata(toolsetID), + func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return nil, nil + }, + ) +} + +func TestStripSchemaProperties(t *testing.T) { + tests := []struct { + name string + schema any + keys []string + expectChange bool + wantProperties []string // property names expected to remain (order-independent) + wantRequired []string // required fields expected to remain (order-independent) + }{ + { + name: "nil schema - no change", + schema: nil, + keys: []string{"show_ui"}, + expectChange: false, + }, + { + name: "RawMessage schema - skipped (not a *jsonschema.Schema)", + schema: json.RawMessage(`{"type":"object","properties":{"show_ui":{"type":"boolean"}}}`), + keys: []string{"show_ui"}, + expectChange: false, + }, + { + name: "schema without the key - no change", + schema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string"}, + }, + }, + keys: []string{"show_ui"}, + expectChange: false, + }, + { + name: "empty keys list - no change", + schema: &jsonschema.Schema{Type: "object", Properties: map[string]*jsonschema.Schema{"show_ui": {Type: "boolean"}}}, + keys: []string{}, + expectChange: false, + }, + { + name: "schema with the key - stripped, others preserved", + schema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string"}, + "repo": {Type: "string"}, + "show_ui": {Type: "boolean"}, + }, + Required: []string{"owner", "repo"}, + }, + keys: []string{"show_ui"}, + expectChange: true, + wantProperties: []string{"owner", "repo"}, + wantRequired: []string{"owner", "repo"}, + }, + { + name: "key in required list is also stripped", + schema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string"}, + "show_ui": {Type: "boolean"}, + }, + Required: []string{"owner", "show_ui"}, + }, + keys: []string{"show_ui"}, + expectChange: true, + wantProperties: []string{"owner"}, + wantRequired: []string{"owner"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tool := NewServerTool( + mcp.Tool{ + Name: "test", + Annotations: &mcp.ToolAnnotations{ReadOnlyHint: true}, + InputSchema: tt.schema, + }, + testToolsetMetadata("toolset1"), + func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return nil, nil + }, + ) + + result := stripSchemaProperties(tool, tt.keys) + + if !tt.expectChange { + require.Nil(t, result, "expected no change but got result") + return + } + + require.NotNil(t, result, "expected change but got nil") + schema, ok := result.Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "result schema should remain *jsonschema.Schema") + require.ElementsMatch(t, tt.wantProperties, slices.Collect(maps.Keys(schema.Properties))) + require.ElementsMatch(t, tt.wantRequired, schema.Required) + + // Original schema must not be mutated. + origSchema := tt.schema.(*jsonschema.Schema) + _, stillThere := origSchema.Properties["show_ui"] + require.True(t, stillThere || !slices.Contains(tt.keys, "show_ui"), "original schema should not be mutated") + }) + } +} + +func TestStripUIOnlySchemaProperties(t *testing.T) { + tools := []ServerTool{ + mockToolWithSchema("with_show_ui", "toolset1", &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string"}, + "show_ui": {Type: "boolean"}, + }, + }), + mockToolWithSchema("without_show_ui", "toolset1", &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string"}, + }, + }), + mockTool("raw_schema_tool", "toolset1", true), // InputSchema is json.RawMessage + } + + result := stripUIOnlySchemaProperties(tools) + require.Len(t, result, 3) + + stripped := result[0].Tool.InputSchema.(*jsonschema.Schema) + require.NotContains(t, stripped.Properties, "show_ui", + "show_ui should be stripped from a tool that declares it") + require.Contains(t, stripped.Properties, "owner", + "other properties on the same schema must be preserved") + + // Tool without show_ui: same value returned (no allocation), schema untouched. + require.Same(t, tools[1].Tool.InputSchema, result[1].Tool.InputSchema, + "tools without the gated property must be returned unchanged") + + // Tool with an opaque (json.RawMessage) schema: passed through untouched. + require.Equal(t, tools[2].Tool.InputSchema, result[2].Tool.InputSchema, + "tools with a non-*jsonschema.Schema input schema must be passed through") +} + +// TestConditionalSchemaPropertyDescriptions ensures every property that +// inventory strips per-request also has a human-readable condition the doc +// generator can render. A future addition to uiOnlySchemaProperties that +// forgets to wire a description through will fail here. +func TestConditionalSchemaPropertyDescriptions(t *testing.T) { + t.Parallel() + + descs := ConditionalSchemaPropertyDescriptions() + require.NotEmpty(t, descs, "expected at least show_ui to be advertised as conditional") + + for _, name := range uiOnlySchemaProperties { + desc, ok := descs[name] + require.Truef(t, ok, "ui-only property %q must have a conditional description", name) + require.NotEmptyf(t, desc, "conditional description for %q must be non-empty", name) + } +} + +func TestToolsForRegistration_StripsShowUIUnderSameGate(t *testing.T) { + // A tool whose schema declares both `_meta.ui` and `show_ui`. The strip + // for both must fire — or not — together, governed by the same gate + // already covered by TestShouldStripMCPAppsMetadata. + makeTool := func() ServerTool { + st := mockToolWithSchema("ui_tool", "toolset1", &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": {Type: "string"}, + "show_ui": {Type: "boolean"}, + }, + }) + st.Tool.Meta = map[string]any{ + "ui": map[string]any{"resourceUri": "ui://example"}, + "description": "kept", + } + return st + } + + mcpAppsChecker := func(_ context.Context, flag string) (bool, error) { + return flag == mcpAppsFeatureFlag, nil + } + + tests := []struct { + name string + ctx context.Context + ffOn bool + wantShowUI bool // expect show_ui to remain in registered schema + wantUIMeta bool // expect _meta.ui to remain on registered tool + }{ + { + name: "FF off, capability unknown -> both stripped", + ctx: context.Background(), + ffOn: false, + wantShowUI: false, + wantUIMeta: false, + }, + { + name: "FF on, capability unknown -> both kept", + ctx: context.Background(), + ffOn: true, + wantShowUI: true, + wantUIMeta: true, + }, + { + name: "FF on, capability present -> both kept", + ctx: ghcontext.WithUISupport(context.Background(), true), + ffOn: true, + wantShowUI: true, + wantUIMeta: true, + }, + { + name: "FF on, capability explicitly absent -> both stripped", + ctx: ghcontext.WithUISupport(context.Background(), false), + ffOn: true, + wantShowUI: false, + wantUIMeta: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + builder := NewBuilder().SetTools([]ServerTool{makeTool()}).WithToolsets([]string{"all"}) + if tc.ffOn { + builder = builder.WithFeatureChecker(mcpAppsChecker) + } + reg := mustBuild(t, builder) + + registered := reg.ToolsForRegistration(tc.ctx) + require.Len(t, registered, 1) + schema, ok := registered[0].Tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok) + + _, hasShowUI := schema.Properties["show_ui"] + require.Equal(t, tc.wantShowUI, hasShowUI, + "show_ui presence in registered schema should match strip gate") + + _, hasUIMeta := registered[0].Tool.Meta["ui"] + require.Equal(t, tc.wantUIMeta, hasUIMeta, + "_meta.ui presence on registered tool should match strip gate") + }) + } +} + func TestStripMetaKeys_MultipleKeys(t *testing.T) { // This test verifies the mechanism works for multiple keys keys := []string{"ui", "experimental_feature", "beta"} @@ -2203,23 +2467,17 @@ func TestCreateExcludeToolsFilter(t *testing.T) { // captureRegisteredTools mirrors RegisterTools' per-request strip behavior so // tests can verify what the wire sees, without requiring tools to have real -// handlers (RegisterTools panics on tools without HandlerFunc). +// handlers (RegisterTools panics on tools without HandlerFunc). It delegates +// to ToolsForRegistration so any future strip added there is picked up +// automatically. func captureRegisteredTools(ctx context.Context, t *testing.T, reg *Inventory) []*mcp.Tool { t.Helper() - tools := reg.AvailableTools(ctx) - out := make([]*mcp.Tool, 0, len(tools)) - for i := range tools { - toolCopy := tools[i].Tool + forReg := reg.ToolsForRegistration(ctx) + out := make([]*mcp.Tool, 0, len(forReg)) + for i := range forReg { + toolCopy := forReg[i].Tool out = append(out, &toolCopy) } - if shouldStripMCPAppsMetadata(ctx, reg.checkFeatureFlag(ctx, mcpAppsFeatureFlag)) { - for _, tt := range out { - delete(tt.Meta, "ui") - if len(tt.Meta) == 0 { - tt.Meta = nil - } - } - } return out } From 909235ed3a04b9c5200f0de30e500fa34a54587b Mon Sep 17 00:00:00 2001 From: Kazuhiko Yamashita Date: Tue, 16 Jun 2026 03:13:57 +0800 Subject: [PATCH 03/12] feat(http): support custom listen address (#2655) --- cmd/github-mcp-server/main.go | 3 +++ pkg/http/server.go | 20 +++++++++++++++-- pkg/http/server_test.go | 41 +++++++++++++++++++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 558fdb9980..604556692c 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -138,6 +138,7 @@ var ( Version: version, Host: viper.GetString("host"), Port: viper.GetInt("port"), + ListenHost: viper.GetString("listen-host"), BaseURL: viper.GetString("base-url"), ResourcePath: viper.GetString("base-path"), ExportTranslations: viper.GetBool("export-translations"), @@ -184,6 +185,7 @@ func init() { // HTTP-specific flags httpCmd.Flags().Int("port", 8082, "HTTP server port") + httpCmd.Flags().String("listen-host", "", "Host the HTTP server binds to (e.g. 127.0.0.1). Empty binds to all interfaces.") httpCmd.Flags().String("base-url", "", "Base URL where this server is publicly accessible (for OAuth resource metadata)") httpCmd.Flags().String("base-path", "", "Externally visible base path for the HTTP server (for OAuth resource metadata)") httpCmd.Flags().Bool("scope-challenge", false, "Enable OAuth scope challenge responses") @@ -204,6 +206,7 @@ func init() { _ = viper.BindPFlag("insiders", rootCmd.PersistentFlags().Lookup("insiders")) _ = viper.BindPFlag("repo-access-cache-ttl", rootCmd.PersistentFlags().Lookup("repo-access-cache-ttl")) _ = viper.BindPFlag("port", httpCmd.Flags().Lookup("port")) + _ = viper.BindPFlag("listen-host", httpCmd.Flags().Lookup("listen-host")) _ = viper.BindPFlag("base-url", httpCmd.Flags().Lookup("base-url")) _ = viper.BindPFlag("base-path", httpCmd.Flags().Lookup("base-path")) _ = viper.BindPFlag("scope-challenge", httpCmd.Flags().Lookup("scope-challenge")) diff --git a/pkg/http/server.go b/pkg/http/server.go index 3c9d7679e4..36d3e111bc 100644 --- a/pkg/http/server.go +++ b/pkg/http/server.go @@ -5,9 +5,11 @@ import ( "fmt" "io" "log/slog" + "net" "net/http" "os" "os/signal" + "strconv" "syscall" "time" @@ -32,9 +34,13 @@ type ServerConfig struct { // GitHub Host to target for API requests (e.g. github.com or github.enterprise.com) Host string - // Port to listen on (default: 8082) + // Port to listen on (default: 8082). Port int + // ListenHost is the host the HTTP server binds to (e.g. "127.0.0.1"). + // When empty, the server binds to all interfaces. Combined with Port. + ListenHost string + // BaseURL is the publicly accessible URL of this server for OAuth resource metadata. // If not set, the server will derive the URL from incoming request headers. BaseURL string @@ -192,7 +198,7 @@ func RunHTTPServer(cfg ServerConfig) error { }) logger.Info("OAuth protected resource endpoints registered", "baseURL", cfg.BaseURL) - addr := fmt.Sprintf(":%d", cfg.Port) + addr := resolveListenAddress(cfg.ListenHost, cfg.Port) httpSvr := http.Server{ Addr: addr, Handler: r, @@ -223,6 +229,16 @@ func RunHTTPServer(cfg ServerConfig) error { return nil } +// resolveListenAddress returns the address string passed to http.Server. +// When host is empty the server binds to all interfaces on the given port; +// otherwise host and port are joined into a single address. +func resolveListenAddress(host string, port int) string { + if host == "" { + return fmt.Sprintf(":%d", port) + } + return net.JoinHostPort(host, strconv.Itoa(port)) +} + func initGlobalToolScopeMap(t translations.TranslationHelperFunc) error { // Build inventory with all tools to extract scope information inv, err := inventory.NewBuilder(). diff --git a/pkg/http/server_test.go b/pkg/http/server_test.go index 1804134651..b509876d9e 100644 --- a/pkg/http/server_test.go +++ b/pkg/http/server_test.go @@ -125,6 +125,47 @@ func TestCreateHTTPFeatureChecker(t *testing.T) { } } +func TestResolveListenAddress(t *testing.T) { + tests := []struct { + name string + host string + port int + want string + }{ + { + name: "empty host falls back to :port", + host: "", + port: 8082, + want: ":8082", + }, + { + name: "ipv4 host is joined with port", + host: "127.0.0.1", + port: 9090, + want: "127.0.0.1:9090", + }, + { + name: "ipv6 host is bracketed and joined with port", + host: "::1", + port: 9090, + want: "[::1]:9090", + }, + { + name: "hostname is joined with port", + host: "localhost", + port: 8082, + want: "localhost:8082", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := resolveListenAddress(tt.host, tt.port) + assert.Equal(t, tt.want, got) + }) + } +} + func TestHeaderAllowedFeatureFlagsMatchesAllowed(t *testing.T) { // Ensure HeaderAllowedFeatureFlags delegates to AllowedFeatureFlags allowed := github.HeaderAllowedFeatureFlags() From 308ae5b9f04b72ea172f94c609145d1b59f0084d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 09:08:25 +0200 Subject: [PATCH 04/12] build(deps): bump the npm_and_yarn group across 1 directory with 2 updates (#2703) --- ui/package-lock.json | 388 ++++++++++++++++++++++--------------------- ui/package.json | 2 +- 2 files changed, 198 insertions(+), 192 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index 4046bc28f9..f8ebc8aedb 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -23,7 +23,7 @@ "@types/react-dom": "^18.0.0", "@vitejs/plugin-react": "^6.0.2", "typescript": "^5.7.0", - "vite": "^8.0.13", + "vite": "^8.0.16", "vite-plugin-singlefile": "^2.3.3" }, "engines": { @@ -31,13 +31,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "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.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -46,9 +46,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "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": { @@ -56,21 +56,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "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.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@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", @@ -87,14 +87,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.0.tgz", - "integrity": "sha512-vSH118/wwM/pLR38g/Sgk05sNtro6TlTJKuiMXDaZqPUfjTFcudpCOt00IhOfj+1BFAX+UFAlzCU+6WXr3GLFQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", "license": "MIT", "peer": true, "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -117,14 +117,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "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.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@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" @@ -134,9 +134,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "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": { @@ -144,29 +144,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "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.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "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.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -186,9 +186,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "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": { @@ -196,9 +196,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "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": { @@ -206,9 +206,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "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": { @@ -216,27 +216,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "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.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", "license": "MIT", "peer": true, "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.7" }, "bin": { "parser": "bin/babel-parser.js" @@ -271,33 +271,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "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.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@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.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", "license": "MIT", "peer": true, "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", "debug": "^4.3.1" }, "engines": { @@ -305,14 +305,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -559,14 +559,14 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.2" }, "funding": { "type": "github", @@ -584,9 +584,9 @@ "license": "BSD-3-Clause" }, "node_modules/@oxc-project/types": { - "version": "0.130.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz", - "integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==", + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "dev": true, "license": "MIT", "funding": { @@ -1431,9 +1431,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz", - "integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" ], @@ -1448,9 +1448,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz", - "integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], @@ -1465,9 +1465,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz", - "integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" ], @@ -1482,9 +1482,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz", - "integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" ], @@ -1499,9 +1499,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz", - "integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" ], @@ -1516,9 +1516,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz", - "integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], @@ -1533,9 +1533,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz", - "integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ "arm64" ], @@ -1550,9 +1550,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz", - "integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ "ppc64" ], @@ -1567,9 +1567,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz", - "integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ "s390x" ], @@ -1584,9 +1584,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz", - "integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], @@ -1601,9 +1601,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz", - "integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], @@ -1618,9 +1618,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz", - "integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "cpu": [ "arm64" ], @@ -1635,9 +1635,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz", - "integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", "cpu": [ "wasm32" ], @@ -1654,9 +1654,9 @@ } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz", - "integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ "arm64" ], @@ -1671,9 +1671,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz", - "integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "cpu": [ "x64" ], @@ -2076,13 +2076,16 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "version": "2.10.37", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.37.tgz", + "integrity": "sha512-girxaJ7WZssDOFhzCGZTDKoTa1gk6A1TbflaYTpykLJ4UU9Fz9kx1aREM8JCuoVHbL8X8T/mJg7w2oYSq72Oig==", "license": "Apache-2.0", "peer": true, "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/body-parser": { @@ -2124,9 +2127,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "funding": [ { "type": "opencollective", @@ -2144,11 +2147,11 @@ "license": "MIT", "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "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" }, "bin": { "browserslist": "cli.js" @@ -2209,9 +2212,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001767", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001767.tgz", - "integrity": "sha512-34+zUAMhSH+r+9eKmYG+k2Rpt8XttfE4yXAjoZvkAPs15xcYQhyBYdalJ65BzivAvGRMViEjy6oKr/S91loekQ==", + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", "funding": [ { "type": "opencollective", @@ -2529,9 +2532,9 @@ "peer": true }, "node_modules/electron-to-chromium": { - "version": "1.5.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "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==", "license": "ISC", "peer": true }, @@ -4575,9 +4578,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -4604,11 +4607,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "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==", "license": "MIT", - "peer": true + "peer": true, + "engines": { + "node": ">=18" + } }, "node_modules/object-assign": { "version": "4.1.1", @@ -4740,9 +4746,9 @@ } }, "node_modules/postcss": { - "version": "8.5.14", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", - "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -4760,7 +4766,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5009,13 +5015,13 @@ } }, "node_modules/rolldown": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz", - "integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.130.0", + "@oxc-project/types": "=0.133.0", "@rolldown/pluginutils": "^1.0.0" }, "bin": { @@ -5025,21 +5031,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.1", - "@rolldown/binding-darwin-arm64": "1.0.1", - "@rolldown/binding-darwin-x64": "1.0.1", - "@rolldown/binding-freebsd-x64": "1.0.1", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.1", - "@rolldown/binding-linux-arm64-gnu": "1.0.1", - "@rolldown/binding-linux-arm64-musl": "1.0.1", - "@rolldown/binding-linux-ppc64-gnu": "1.0.1", - "@rolldown/binding-linux-s390x-gnu": "1.0.1", - "@rolldown/binding-linux-x64-gnu": "1.0.1", - "@rolldown/binding-linux-x64-musl": "1.0.1", - "@rolldown/binding-openharmony-arm64": "1.0.1", - "@rolldown/binding-wasm32-wasi": "1.0.1", - "@rolldown/binding-win32-arm64-msvc": "1.0.1", - "@rolldown/binding-win32-x64-msvc": "1.0.1" + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, "node_modules/router": { @@ -5385,9 +5391,9 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.16", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", - "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { @@ -5732,17 +5738,17 @@ } }, "node_modules/vite": { - "version": "8.0.13", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz", - "integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==", + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", - "postcss": "^8.5.14", - "rolldown": "1.0.1", - "tinyglobby": "^0.2.16" + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" diff --git a/ui/package.json b/ui/package.json index b5bf095851..9644b72d9e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -29,7 +29,7 @@ "@types/react-dom": "^18.0.0", "@vitejs/plugin-react": "^6.0.2", "typescript": "^5.7.0", - "vite": "^8.0.13", + "vite": "^8.0.16", "vite-plugin-singlefile": "^2.3.3" } } From de9aee0afa3791c3b7750d7ff1a289e0514723e7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:02:14 +0200 Subject: [PATCH 05/12] build(deps): bump golang from `cd2fb35` to `8d95af5` (#2699) Bumps golang from `cd2fb35` to `8d95af5`. --- updated-dependencies: - dependency-name: golang dependency-version: 1.25.11-alpine dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index a4ea1d03b8..1e997b9a18 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ COPY ui/ ./ui/ RUN mkdir -p ./pkg/github/ui_dist && \ cd ui && npm run build -FROM golang:1.25.11-alpine@sha256:cd2fb3559df6e13bc93b7f0734a4eabe1d21e7b64eec211ed90784f00a17a56a AS build +FROM golang:1.25.11-alpine@sha256:8d95af53d0d58e1759ddb4028285d9b1239067e4fbf4f544618cad0f60fbc354 AS build ARG VERSION="dev" # Set the working directory From 117bace5ebee04eae60cd5eaccaa240f77907127 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:02:37 +0200 Subject: [PATCH 06/12] build(deps): bump distroless/base-debian12 from `58695f4` to `e7e678c` (#2698) Bumps distroless/base-debian12 from `58695f4` to `e7e678c`. --- updated-dependencies: - dependency-name: distroless/base-debian12 dependency-version: e7e678c88c59e70e105a46549bb3fbfb3d732ee3b4afd3a19fdab2e15afaa6b3 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 1e997b9a18..ecc7de632a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,7 +30,7 @@ RUN --mount=type=cache,target=/go/pkg/mod \ -o /bin/github-mcp-server ./cmd/github-mcp-server # Make a stage to run the app -FROM gcr.io/distroless/base-debian12@sha256:58695f439f772a00009c8f6be4c183f824c1f556d74b313c30900f167e4772f8 +FROM gcr.io/distroless/base-debian12@sha256:e7e678c88c59e70e105a46549bb3fbfb3d732ee3b4afd3a19fdab2e15afaa6b3 # Add required MCP server annotation LABEL io.modelcontextprotocol.server.name="io.github.github/github-mcp-server" From 6586b84b1a540a0d33e4292844b6990b7eec513a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 11:02:49 +0200 Subject: [PATCH 07/12] build(deps): bump node from `144769e` to `3ad34ca` (#2697) Bumps node from `144769e` to `3ad34ca`. --- updated-dependencies: - dependency-name: node dependency-version: 26-alpine dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index ecc7de632a..132752fde4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:26-alpine@sha256:144769ec3f32e8ee36b3cfde91e82bee25d9367b20f31a151f3f7eea3a2a8541 AS ui-build +FROM node:26-alpine@sha256:3ad34ca6292aec4a91d8ddeb9229e29d9c2f689efd0dd242860889ac71842eba AS ui-build WORKDIR /app COPY ui/package*.json ./ui/ RUN cd ui && npm ci From 4e8eb81daccfba8d3b8bd614360368442e3dc473 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Tue, 16 Jun 2026 16:45:16 +0100 Subject: [PATCH 08/12] MCP Apps with extra functionality (#1974) * PoC full flow (hello world example) * add avatar resource domain * add postmessage logic and richer UI * add create issue ui * update ui for issue creatioon * fix * ignore banner * update docs after rebase * update toolsnap for get_me * new UI changes * update docs * update workflows that need ui build * add UI diff * fix build ui step for windows runners to use git bash * fix UI diff * refactor issue creation UI * add AvatarWithFallback component and update UserCard to use it; enhance CreateIssueApp to manage existing issue data * fix formatting of button labels * add create pull request functionality with UI support and insiders * update docs * add test for insiders mode handling in ServerTool schema * remove `show_ui` param for now * make insiders mode metadata stripping generic * remove ui diff * fix CI * remove redundant mention of old app name * add node types to fix ide issues for ts code * remove unused TriangleDownIcon import * update @primer/behaviors and electron-to-chromium versions in package-lock.json * add check to ensure base and head are not the same when creating a new PR * remove old show_ui * fix gitignore for dist so builds dont break * add tests for insiders mode handling and metadata stripping in ServerTool * remove unused state and components from CreatePRApp * fix ui build * update docker build to fix npm issue * remove reference to show_ui * allow insiders to work for non-ui features * formalise insiders inventory support * update docs * fix overflow issues and replace pull request dropdown with matching UI from dotcom * fix createpullrequest test * consolidate fetching tools under `ui_get` tool to remove toolset deps * fix issue data prefill in issue_write form * fix link component when updating issue * fix avatar URL * fix broken issue update logic * remove dbg * fix for new GetFlags * revert to original required fields for create_pull_request * fix for UI form submission * Simplify MCP App UIs for basic branch Remove advanced features to be kept in mcp-ui-apps-advanced: - Strip labels, assignees, milestones, issue types, repo picker from issue-write - Strip repo picker, branch selectors from pr-write - Delete ui_get tool (ui_tools.go, ui_tools_test.go, ui_get.snap) - Remove UIGet registration from tools.go Basic forms retain: title, body, submit with _ui_submitted, draft/regular split button (PR), MarkdownEditor, and SuccessView. * Fix header spacing in issue-write and pr-write UIs Add proper spacing between icon, title text, and repo name in the header bar for both issue-write and create-pull-request forms. * fix UI spacing * Revert "Simplify MCP App UIs for basic branch" This reverts commit 24174b91e222e45b47913ff6b760db809f48660b. * Undo dependency downgrades in ui/package-lock.json * Update ui/src/apps/pr-write/App.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update ui/src/apps/issue-write/App.tsx Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Implement pagination for uiGetBranches (#2012) * Initial plan * Implement pagination for uiGetBranches function Co-authored-by: mattdholloway <918573+mattdholloway@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mattdholloway <918573+mattdholloway@users.noreply.github.com> * update to new insiders feature flag func * ensure transient state is reset on successive tool calls * Mark ui_get as app-only visibility ui_get backs only the MCP App views and has no business in the agent's tool list. Per the MCP Apps 2026-01-26 spec, omitting _meta.ui.visibility defaults to ["model","app"], which exposes the tool to the model. Declare visibility ["app"] so the host hides it from tools/list while the views can still invoke it via tools/call. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update ui_get toolsnap for app-only visibility Regenerated via UPDATE_TOOLSNAPS to capture the new _meta.ui.visibility. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Assert ui_get declares app-only visibility Locks in the _meta.ui.visibility ["app"] contract so a future edit can't silently re-expose the UI data tool to the model. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add ui_get to insiders feature docs Regenerated docs/feature-flags.md and docs/insiders-features.md to include the ui_get tool entry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address ui_get review feedback - Paginate the labels GraphQL query (cursor-based) so repos with more than 100 labels return a complete list instead of silently truncating. - Emit an empty due_on for milestones without a due date instead of formatting the zero time as "0001-01-01". - Use NewGitHubAPIErrorResponse in uiGetIssueTypes to preserve GitHub response context, matching the other REST-backed methods. - Extend tests to cover the labels (GraphQL), milestones (including the no-due-date case) and issue_types methods, plus the issue_types error path. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix repo reset and stale base-branch in MCP App views - Re-initialize selectedRepo from toolInput inside the reset-on-invocation effect instead of a separate effect. The two effects both depended on toolInput and ran in declaration order, so the reset wiped the just- initialized repo and the picker never reflected the invocation's owner/repo. - Set the default base branch with a functional update in pr-write so a base prefilled from toolInput.base (or chosen by the user) isn't overwritten by a stale baseBranch value captured before the branches request resolved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Fix issue-write repo owner mapping and clear stale UI state on reset - issue-write: derive owner/name from full_name since search_repositories minimal output omits the owner object (mirrors pr-write) - pr-write/issue-write: clear available branch/label/assignee/milestone/type lists and filters in the toolInput reset effect so prefill effects can't match against the previous repo's stale data Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Merge remote-tracking branch 'origin/main' into mcp-ui-apps-advanced * feat: add pull request editing functionality with reviewers support * feat: implement interactive form handling for issue and pull request creation and updates * Close response body per page in ui_get pagination loops Avoids leaking HTTP connections when paging through assignees, milestones, branches, collaborators, and teams. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Cache pr-edit.html in build-ui action The build-ui cache only saved get-me/issue-write/pr-write HTML, so once a cache entry was stored it restored an incomplete ui_dist on later runs and skipped the rebuild, leaving pr-edit.html absent and panicking the tests. Add pr-edit.html to the cached paths and bump the cache key to v2 to evict the incomplete entries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: tommaso-moro Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/actions/build-ui/action.yml | 3 +- README.md | 1 + docs/feature-flags.md | 26 +- docs/insiders-features.md | 26 +- .../__toolsnaps__/create_pull_request.snap | 7 + pkg/github/__toolsnaps__/ui_get.snap | 45 + .../__toolsnaps__/update_pull_request.snap | 9 + pkg/github/issues.go | 53 +- pkg/github/issues_test.go | 185 +-- pkg/github/pullrequests.go | 101 +- pkg/github/pullrequests_test.go | 121 +- pkg/github/tools.go | 3 + pkg/github/ui_resources.go | 27 + pkg/github/ui_resources_test.go | 1 + pkg/github/ui_tools.go | 516 ++++++ pkg/github/ui_tools_test.go | 414 +++++ pkg/utils/result.go | 24 + ui/scripts/build.mjs | 6 +- ui/src/apps/issue-write/App.tsx | 1417 ++++++++++++++++- ui/src/apps/pr-edit/App.tsx | 773 +++++++++ ui/src/apps/pr-edit/index.html | 12 + ui/src/apps/pr-write/App.tsx | 499 +++++- 22 files changed, 4041 insertions(+), 228 deletions(-) create mode 100644 pkg/github/__toolsnaps__/ui_get.snap create mode 100644 pkg/github/ui_tools.go create mode 100644 pkg/github/ui_tools_test.go create mode 100644 ui/src/apps/pr-edit/App.tsx create mode 100644 ui/src/apps/pr-edit/index.html diff --git a/.github/actions/build-ui/action.yml b/.github/actions/build-ui/action.yml index 229057d5cb..46308ba0f8 100644 --- a/.github/actions/build-ui/action.yml +++ b/.github/actions/build-ui/action.yml @@ -12,7 +12,8 @@ runs: pkg/github/ui_dist/get-me.html pkg/github/ui_dist/issue-write.html pkg/github/ui_dist/pr-write.html - key: ui-dist-v1-${{ hashFiles('ui/package-lock.json', 'ui/package.json', 'ui/index.html', 'ui/tsconfig*.json', 'ui/vite.config.ts', 'ui/src/**', 'ui/scripts/**') }} + 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/**') }} enableCrossOsArchive: true - name: Set up Node.js diff --git a/README.md b/README.md index 5d6caae6d3..da0fb37038 100644 --- a/README.md +++ b/README.md @@ -1090,6 +1090,7 @@ The following sets of tools are available: - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) + - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional) - `title`: PR title (string, required) - **list_pull_requests** - List pull requests diff --git a/docs/feature-flags.md b/docs/feature-flags.md index a3074bdd23..37e6e712ef 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -44,7 +44,8 @@ runtime behavior (such as output formatting) won't appear here. - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - - `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like reviewers) and the user has already confirmed the action. (boolean, optional, conditional — only visible to clients that advertise MCP App UI support) + - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional) + - `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like reviewers) and the user has already confirmed the action. (boolean, optional, conditional — visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui) - `title`: PR title (string, required) - **get_me** - Get my user profile @@ -67,12 +68,33 @@ runtime behavior (such as output formatting) won't appear here. - `milestone`: Milestone number (number, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - - `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, or state changes) and the user has already confirmed the action. (boolean, optional, conditional — only visible to clients that advertise MCP App UI support) + - `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, or state changes) and the user has already confirmed the action. (boolean, optional, conditional — visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui) - `state`: New state (string, optional) - `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional) - `title`: Issue title (string, optional) - `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional) +- **ui_get** - Get UI data + - **Required OAuth Scopes**: `repo`, `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org` + - `method`: The type of data to fetch (string, required) + - `owner`: Repository owner (required for all methods) (string, required) + - `repo`: Repository name (required for labels, assignees, milestones, branches, issue fields, reviewers) (string, optional) + +- **update_pull_request** - Edit pull request + - **Required OAuth Scopes**: `repo` + - **MCP App UI**: `ui://github-mcp-server/pr-edit` + - `base`: New base branch name (string, optional) + - `body`: New description (string, optional) + - `draft`: Mark pull request as draft (true) or ready for review (false) (boolean, optional) + - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) + - `owner`: Repository owner (string, required) + - `pullNumber`: Pull request number to update (number, required) + - `repo`: Repository name (string, required) + - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional) + - `state`: New state (string, optional) + - `title`: New title (string, optional) + ### `remote_mcp_issue_fields` - **issue_write** - Create or update issue/pull request diff --git a/docs/insiders-features.md b/docs/insiders-features.md index 8ad297e30a..d67947039e 100644 --- a/docs/insiders-features.md +++ b/docs/insiders-features.md @@ -38,7 +38,8 @@ The list below is generated from the Go source. It covers tool **inventory and s - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - - `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like reviewers) and the user has already confirmed the action. (boolean, optional, conditional — only visible to clients that advertise MCP App UI support) + - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional) + - `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like reviewers) and the user has already confirmed the action. (boolean, optional, conditional — visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui) - `title`: PR title (string, required) - **get_me** - Get my user profile @@ -61,12 +62,33 @@ The list below is generated from the Go source. It covers tool **inventory and s - `milestone`: Milestone number (number, optional) - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) - - `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, or state changes) and the user has already confirmed the action. (boolean, optional, conditional — only visible to clients that advertise MCP App UI support) + - `show_ui`: Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like labels, assignees, milestone, type, or state changes) and the user has already confirmed the action. (boolean, optional, conditional — visible when remote_mcp_ui_apps is enabled unless the client explicitly indicates it does not support io.modelcontextprotocol/ui) - `state`: New state (string, optional) - `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional) - `title`: Issue title (string, optional) - `type`: Type of this issue. Only use if the repository has issue types configured. Use list_issue_types tool to get valid type values for the organization. If the repository doesn't support issue types, omit this parameter. (string, optional) +- **ui_get** - Get UI data + - **Required OAuth Scopes**: `repo`, `read:org` + - **Accepted OAuth Scopes**: `admin:org`, `read:org`, `repo`, `write:org` + - `method`: The type of data to fetch (string, required) + - `owner`: Repository owner (required for all methods) (string, required) + - `repo`: Repository name (required for labels, assignees, milestones, branches, issue fields, reviewers) (string, optional) + +- **update_pull_request** - Edit pull request + - **Required OAuth Scopes**: `repo` + - **MCP App UI**: `ui://github-mcp-server/pr-edit` + - `base`: New base branch name (string, optional) + - `body`: New description (string, optional) + - `draft`: Mark pull request as draft (true) or ready for review (false) (boolean, optional) + - `maintainer_can_modify`: Allow maintainer edits (boolean, optional) + - `owner`: Repository owner (string, required) + - `pullNumber`: Pull request number to update (number, required) + - `repo`: Repository name (string, required) + - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional) + - `state`: New state (string, optional) + - `title`: New title (string, optional) + ### `remote_mcp_issue_fields` - **issue_write** - Create or update issue/pull request diff --git a/pkg/github/__toolsnaps__/create_pull_request.snap b/pkg/github/__toolsnaps__/create_pull_request.snap index 5442aeda30..b2f14e3908 100644 --- a/pkg/github/__toolsnaps__/create_pull_request.snap +++ b/pkg/github/__toolsnaps__/create_pull_request.snap @@ -42,6 +42,13 @@ "description": "Repository name", "type": "string" }, + "reviewers": { + "description": "GitHub usernames or ORG/team-slug team reviewers to request reviews from", + "items": { + "type": "string" + }, + "type": "array" + }, "show_ui": { "description": "Whether to render the MCP App form instead of executing the request immediately. Defaults to true. Set to false to skip the form and execute directly — useful when you have all required values (especially ones the form does not collect, like reviewers) and the user has already confirmed the action.", "type": "boolean" diff --git a/pkg/github/__toolsnaps__/ui_get.snap b/pkg/github/__toolsnaps__/ui_get.snap new file mode 100644 index 0000000000..7f13d97c1c --- /dev/null +++ b/pkg/github/__toolsnaps__/ui_get.snap @@ -0,0 +1,45 @@ +{ + "_meta": { + "ui": { + "visibility": [ + "app" + ] + } + }, + "annotations": { + "readOnlyHint": true, + "title": "Get UI data" + }, + "description": "Fetch UI data for MCP Apps (labels, assignees, milestones, issue types, branches, issue fields, reviewers).", + "inputSchema": { + "properties": { + "method": { + "description": "The type of data to fetch", + "enum": [ + "labels", + "assignees", + "milestones", + "issue_types", + "branches", + "issue_fields", + "reviewers" + ], + "type": "string" + }, + "owner": { + "description": "Repository owner (required for all methods)", + "type": "string" + }, + "repo": { + "description": "Repository name (required for labels, assignees, milestones, branches, issue fields, reviewers)", + "type": "string" + } + }, + "required": [ + "method", + "owner" + ], + "type": "object" + }, + "name": "ui_get" +} \ 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 3d87fe75fe..cadc391ef4 100644 --- a/pkg/github/__toolsnaps__/update_pull_request.snap +++ b/pkg/github/__toolsnaps__/update_pull_request.snap @@ -1,4 +1,13 @@ { + "_meta": { + "ui": { + "resourceUri": "ui://github-mcp-server/pr-edit", + "visibility": [ + "model", + "app" + ] + } + }, "annotations": { "title": "Edit pull request" }, diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 79b8b23ad2..8ed3b90571 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1753,8 +1753,7 @@ func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[st const IssueWriteUIResourceURI = "ui://github-mcp-server/issue-write" // issueWriteFormParams are the parameters the issue_write MCP App form collects -// and re-sends on submit. The form only supports title/body editing (plus the -// routing/identity fields), so any other parameter present on a call cannot be +// and re-sends on submit. Any other parameter present on a call cannot be // represented by the form. var issueWriteFormParams = map[string]struct{}{ "method": {}, @@ -1763,13 +1762,17 @@ var issueWriteFormParams = map[string]struct{}{ "title": {}, "body": {}, "issue_number": {}, + "issue_fields": {}, + "state": {}, + "state_reason": {}, + "duplicate_of": {}, "show_ui": {}, "_ui_submitted": {}, } // issueWriteHasNonFormParams reports whether the call carries any parameter the // issue_write MCP App form cannot represent (anything outside issueWriteFormParams, -// e.g. labels, assignees, issue_fields or a state change). Such calls must bypass +// e.g. labels, assignees, milestones or issue types). Such calls must bypass // the UI form and execute directly so the supplied values aren't silently dropped. func issueWriteHasNonFormParams(args map[string]any) bool { for key, value := range args { @@ -1783,6 +1786,36 @@ func issueWriteHasNonFormParams(args map[string]any) bool { return false } +// issueWriteAwaitingFormResult builds the "awaiting form submission" stub +// returned when issue_write hands off to the MCP App form. The body is shared +// by IssueWrite and LegacyIssueWrite. The result is marked IsError=true so +// agents that bail on error don't claim success or chain dependent tool calls +// while the user is still interacting with the form; the host renders the UI +// regardless because rendering is keyed off the tool's _meta.ui resourceUri. +func issueWriteAwaitingFormResult(method, owner, repo string, issueNumber int) *mcp.CallToolResult { + var msg string + if method == "update" { + msg = fmt.Sprintf( + "An interactive form has been shown to the user for editing issue #%d in %s/%s. "+ + "STOP — do not call any other tools, do not respond as if the issue was updated, "+ + "and do not claim the operation succeeded. The issue 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.", + issueNumber, owner, repo, + ) + } else { + msg = fmt.Sprintf( + "An interactive form has been shown to the user for creating a new issue in %s/%s. "+ + "STOP — do not call any other tools, do not respond as if the issue was created, "+ + "and do not claim the operation succeeded. The issue 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, + ) + } + return utils.NewToolResultAwaitingFormSubmission(msg) +} + // IssueWrite is the FeatureFlagIssueFields-enabled variant of issue_write // (with the issue_fields parameter). LegacyIssueWrite is served when the flag // is off. Both register under the tool name "issue_write"; exactly one is @@ -1953,14 +1986,15 @@ Options are: } if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && showUI && !issueWriteHasNonFormParams(args) { + issueNumber := 0 if method == "update" { - issueNumber, numErr := RequiredInt(args, "issue_number") + n, numErr := RequiredInt(args, "issue_number") if numErr != nil { return utils.NewToolResultError("issue_number is required for update method"), nil, nil } - return utils.NewToolResultText(fmt.Sprintf("Ready to update issue #%d in %s/%s. IMPORTANT: The issue has NOT been updated yet. Do NOT tell the user the issue was updated. The user MUST click Submit in the form to update it.", issueNumber, owner, repo)), nil, nil + issueNumber = n } - return utils.NewToolResultText(fmt.Sprintf("Ready to create an issue in %s/%s. IMPORTANT: The issue has NOT been created yet. Do NOT tell the user the issue was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil + return issueWriteAwaitingFormResult(method, owner, repo, issueNumber), nil, nil } title, err := OptionalParam[string](args, "title") @@ -2209,14 +2243,15 @@ Options are: } if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && showUI && !issueWriteHasNonFormParams(args) { + issueNumber := 0 if method == "update" { - issueNumber, numErr := RequiredInt(args, "issue_number") + n, numErr := RequiredInt(args, "issue_number") if numErr != nil { return utils.NewToolResultError("issue_number is required for update method"), nil, nil } - return utils.NewToolResultText(fmt.Sprintf("Ready to update issue #%d in %s/%s. IMPORTANT: The issue has NOT been updated yet. Do NOT tell the user the issue was updated. The user MUST click Submit in the form to update it.", issueNumber, owner, repo)), nil, nil + issueNumber = n } - return utils.NewToolResultText(fmt.Sprintf("Ready to create an issue in %s/%s. IMPORTANT: The issue has NOT been created yet. Do NOT tell the user the issue was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil + return issueWriteAwaitingFormResult(method, owner, repo, issueNumber), nil, nil } title, err := OptionalParam[string](args, "title") diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index 5378ff62b7..bd718c6793 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -1562,7 +1562,8 @@ func Test_IssueWrite_MCPAppsFeature_UIGate(t *testing.T) { require.NoError(t, err) textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, "Ready to create an issue") + assert.Contains(t, textContent.Text, "interactive form has been shown to the user for creating a new issue") + assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success") }) t.Run("UI client with _ui_submitted executes directly", func(t *testing.T) { @@ -1596,78 +1597,10 @@ func Test_IssueWrite_MCPAppsFeature_UIGate(t *testing.T) { "non-UI client should execute directly") }) - t.Run("UI client with state change skips form and executes directly", func(t *testing.T) { - mockBaseIssue := &github.Issue{ - Number: github.Ptr(1), - Title: github.Ptr("Test"), - State: github.Ptr("open"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/1"), - } - issueIDQueryResponse := githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "issue": map[string]any{ - "id": "I_kwDOA0xdyM50BPaO", - }, - }, - }) - closeSuccessResponse := githubv4mock.DataResponse(map[string]any{ - "closeIssue": map[string]any{ - "issue": map[string]any{ - "id": "I_kwDOA0xdyM50BPaO", - "number": 1, - "url": "https://github.com/owner/repo/issues/1", - "state": "CLOSED", - }, - }, - }) - completedReason := IssueClosedStateReasonCompleted - - closeClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PatchReposIssuesByOwnerByRepoByIssueNumber: mockResponse(t, http.StatusOK, mockBaseIssue), - })) - closeGQLClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - struct { - Repository struct { - Issue struct { - ID githubv4.ID - } `graphql:"issue(number: $issueNumber)"` - } `graphql:"repository(owner: $owner, name: $repo)"` - }{}, - map[string]any{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - "issueNumber": githubv4.Int(1), - }, - issueIDQueryResponse, - ), - githubv4mock.NewMutationMatcher( - struct { - CloseIssue struct { - Issue struct { - ID githubv4.ID - Number githubv4.Int - URL githubv4.String - State githubv4.String - } - } `graphql:"closeIssue(input: $input)"` - }{}, - CloseIssueInput{ - IssueID: "I_kwDOA0xdyM50BPaO", - StateReason: &completedReason, - }, - nil, - closeSuccessResponse, - ), - )) - - closeDeps := BaseDeps{ - Client: closeClient, - GQLClient: closeGQLClient, - featureChecker: featureCheckerFor(MCPAppsFeatureFlag), - } - closeHandler := serverTool.Handler(closeDeps) - + t.Run("UI client with state change routes through UI form", func(t *testing.T) { + // state/state_reason/duplicate_of are form params (the issue-write view + // renders close/reopen controls), so a call carrying them must go to + // the form rather than execute directly. request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ "method": "update", "owner": "owner", @@ -1676,14 +1609,13 @@ func Test_IssueWrite_MCPAppsFeature_UIGate(t *testing.T) { "state": "closed", "state_reason": "completed", }) - result, err := closeHandler(ContextWithDeps(context.Background(), closeDeps), &request) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) textContent := getTextResult(t, result) - assert.NotContains(t, textContent.Text, "Ready to update issue", - "state change should skip UI form") - assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1", - "state change should execute directly and return issue URL") + assert.Contains(t, textContent.Text, "interactive form has been shown to the user for editing issue #1", + "state change should route through UI form") + assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success") }) t.Run("UI client update without state change returns form message", func(t *testing.T) { @@ -1698,65 +1630,15 @@ func Test_IssueWrite_MCPAppsFeature_UIGate(t *testing.T) { require.NoError(t, err) textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, "Ready to update issue #1", + assert.Contains(t, textContent.Text, "interactive form has been shown to the user for editing issue #1", "update without state should show UI form") + assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success") }) - t.Run("UI client with issue_fields skips form and executes directly", func(t *testing.T) { - // The MCP App form does not collect or re-send issue_fields, so a call - // carrying them must bypass the form and apply the values directly. - fieldsClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PostReposIssuesByOwnerByRepo: expectRequestBody(t, map[string]any{ - "title": "Issue with fields", - "body": "", - "labels": []any{}, - "assignees": []any{}, - "issue_field_values": []any{ - map[string]any{"field_id": float64(101), "value": "P1"}, - }, - }).andThen( - mockResponse(t, http.StatusCreated, &github.Issue{ - Number: github.Ptr(125), - Title: github.Ptr("Issue with fields"), - HTMLURL: github.Ptr("https://github.com/owner/repo/issues/125"), - State: github.Ptr("open"), - }), - ), - })) - fieldsGQLClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( - githubv4mock.NewQueryMatcher( - issueFieldWriteMetadataQuery{}, - map[string]any{ - "owner": githubv4.String("owner"), - "repo": githubv4.String("repo"), - }, - githubv4mock.DataResponse(map[string]any{ - "repository": map[string]any{ - "issueFields": map[string]any{ - "nodes": []any{ - map[string]any{ - "__typename": "IssueFieldSingleSelect", - "fullDatabaseId": "101", - "name": "Priority", - "dataType": "single_select", - "options": []any{ - map[string]any{"fullDatabaseId": "9001", "name": "P1"}, - }, - }, - }, - }, - }, - }), - ), - )) - - fieldsDeps := BaseDeps{ - Client: fieldsClient, - GQLClient: fieldsGQLClient, - featureChecker: featureCheckerFor(MCPAppsFeatureFlag), - } - fieldsHandler := serverTool.Handler(fieldsDeps) - + t.Run("UI client with issue_fields routes through UI form", func(t *testing.T) { + // issue_fields is now a form param (the issue-write view renders a + // per-field editor), so a call carrying it must go to the form rather + // than execute directly. request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ "method": "create", "owner": "owner", @@ -1766,14 +1648,13 @@ func Test_IssueWrite_MCPAppsFeature_UIGate(t *testing.T) { map[string]any{"field_name": "Priority", "field_option_name": "P1"}, }, }) - result, err := fieldsHandler(ContextWithDeps(context.Background(), fieldsDeps), &request) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) textContent := getTextResult(t, result) - assert.NotContains(t, textContent.Text, "Ready to create an issue", - "issue_fields should skip UI form") - assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/125", - "issue_fields call should execute directly and return issue URL") + assert.Contains(t, textContent.Text, "interactive form has been shown to the user for creating a new issue", + "issue_fields should route through UI form") + assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success") }) t.Run("UI client with labels skips form and executes directly", func(t *testing.T) { @@ -1790,7 +1671,7 @@ func Test_IssueWrite_MCPAppsFeature_UIGate(t *testing.T) { require.NoError(t, err) textContent := getTextResult(t, result) - assert.NotContains(t, textContent.Text, "Ready to create an issue", + assert.NotContains(t, textContent.Text, "interactive form has been shown", "labels should skip UI form") assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1", "labels call should execute directly and return issue URL") @@ -1812,7 +1693,7 @@ func Test_IssueWrite_MCPAppsFeature_UIGate(t *testing.T) { require.NoError(t, err) textContent := getTextResult(t, result) - assert.NotContains(t, textContent.Text, "Ready to create an issue", + assert.NotContains(t, textContent.Text, "interactive form has been shown", "show_ui=false should skip UI form") assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1", "show_ui=false call should execute directly and return issue URL") @@ -1834,7 +1715,7 @@ func Test_IssueWrite_MCPAppsFeature_UIGate(t *testing.T) { require.NoError(t, err) textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, "Ready to create an issue", + assert.Contains(t, textContent.Text, "interactive form has been shown", "show_ui=true should still route through the form") }) @@ -1893,10 +1774,10 @@ func Test_issueWriteHasNonFormParams(t *testing.T) { {name: "assignees present", args: map[string]any{"title": "t", "assignees": []any{"octocat"}}, want: true}, {name: "milestone present", args: map[string]any{"title": "t", "milestone": float64(2)}, want: true}, {name: "type present", args: map[string]any{"title": "t", "type": "Bug"}, want: true}, - {name: "issue_fields present", args: map[string]any{"issue_fields": []any{map[string]any{"field_name": "Priority"}}}, want: true}, - {name: "state present", args: map[string]any{"state": "closed"}, want: true}, - {name: "state_reason present", args: map[string]any{"state_reason": "completed"}, want: true}, - {name: "duplicate_of present", args: map[string]any{"duplicate_of": float64(7)}, want: true}, + {name: "issue_fields present", args: map[string]any{"issue_fields": []any{map[string]any{"field_name": "Priority"}}}, want: false}, + {name: "state present", args: map[string]any{"state": "closed"}, want: false}, + {name: "state_reason present", args: map[string]any{"state_reason": "completed"}, want: false}, + {name: "duplicate_of present", args: map[string]any{"duplicate_of": float64(7)}, want: false}, {name: "nil value is ignored", args: map[string]any{"issue_fields": nil}, want: false}, } @@ -1919,14 +1800,10 @@ func Test_issueWriteSchemaClassification(t *testing.T) { // Schema properties the MCP App form cannot represent — their presence // must trigger the safety-net bypass via issueWriteHasNonFormParams. knownNonForm := map[string]struct{}{ - "assignees": {}, - "labels": {}, - "milestone": {}, - "type": {}, - "state": {}, - "state_reason": {}, - "duplicate_of": {}, - "issue_fields": {}, // only on the FF-enabled IssueWrite variant + "assignees": {}, + "labels": {}, + "milestone": {}, + "type": {}, } cases := []struct { diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 985d8cc932..07ff6a87f0 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -587,6 +587,9 @@ func GetPullRequestReviews(ctx context.Context, client *github.Client, deps Tool // PullRequestWriteUIResourceURI is the URI for the create_pull_request tool's MCP App UI resource. const PullRequestWriteUIResourceURI = "ui://github-mcp-server/pr-write" +// PullRequestEditUIResourceURI is the URI for the update_pull_request tool's MCP App UI resource. +const PullRequestEditUIResourceURI = "ui://github-mcp-server/pr-edit" + // pullRequestWriteFormParams are the parameters the create_pull_request MCP App // form collects and re-sends on submit. Any other parameter present on a call // cannot be represented by the form. @@ -599,10 +602,25 @@ var pullRequestWriteFormParams = map[string]struct{}{ "base": {}, "draft": {}, "maintainer_can_modify": {}, + "reviewers": {}, "show_ui": {}, "_ui_submitted": {}, } +var pullRequestUpdateFormParams = map[string]struct{}{ + "owner": {}, + "repo": {}, + "pullNumber": {}, + "title": {}, + "body": {}, + "state": {}, + "draft": {}, + "base": {}, + "maintainer_can_modify": {}, + "reviewers": {}, + "_ui_submitted": {}, +} + // pullRequestWriteHasNonFormParams reports whether the call carries any parameter // the create_pull_request MCP App form cannot represent (anything outside // pullRequestWriteFormParams). Such calls must bypass the UI form and execute @@ -619,6 +637,18 @@ func pullRequestWriteHasNonFormParams(args map[string]any) bool { return false } +func pullRequestUpdateHasNonFormParams(args map[string]any) bool { + for key, value := range args { + if value == nil { + continue + } + if _, ok := pullRequestUpdateFormParams[key]; !ok { + return true + } + } + return false +} + // CreatePullRequest creates a tool to create a new pull request. func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( @@ -671,6 +701,13 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo Type: "boolean", Description: "Allow maintainer edits", }, + "reviewers": { + Type: "array", + Description: "GitHub usernames or ORG/team-slug team reviewers to request reviews from", + Items: &jsonschema.Schema{ + Type: "string", + }, + }, // show_ui is hidden from clients that do not advertise MCP App // UI support. The strip happens per-request in // inventory.ToolsForRegistration; it is present in the static @@ -710,7 +747,14 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo } if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && showUI && !pullRequestWriteHasNonFormParams(args) { - return utils.NewToolResultText(fmt.Sprintf("Ready to create a pull request in %s/%s. IMPORTANT: The PR has NOT been created yet. Do NOT tell the user the PR was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil + return utils.NewToolResultAwaitingFormSubmission(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 @@ -751,6 +795,11 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultError(err.Error()), nil, nil } + reviewers, err := OptionalStringArrayParam(args, "reviewers") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + newPR := &github.NewPullRequest{ Title: github.Ptr(title), Head: github.Ptr(head), @@ -786,6 +835,36 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to create pull request", resp, bodyBytes), nil, nil } + if len(reviewers) > 0 { + userReviewers, teamReviewers := splitPullRequestReviewers(reviewers) + reviewersRequest := github.ReviewersRequest{ + Reviewers: userReviewers, + TeamReviewers: teamReviewers, + } + + _, reviewerResp, err := client.PullRequests.RequestReviewers(ctx, owner, repo, pr.GetNumber(), reviewersRequest) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to request reviewers", + reviewerResp, + err, + ), nil, nil + } + defer func() { + if reviewerResp != nil && reviewerResp.Body != nil { + _ = reviewerResp.Body.Close() + } + }() + + if reviewerResp.StatusCode != http.StatusCreated && reviewerResp.StatusCode != http.StatusOK { + bodyBytes, err := io.ReadAll(reviewerResp.Body) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to request reviewers", reviewerResp, bodyBytes), nil, nil + } + } + // Return minimal response with just essential information minimalResponse := MinimalResponse{ ID: fmt.Sprintf("%d", pr.GetID()), @@ -863,10 +942,16 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo Title: t("TOOL_UPDATE_PULL_REQUEST_USER_TITLE", "Edit pull request"), ReadOnlyHint: false, }, + Meta: mcp.Meta{ + "ui": map[string]any{ + "resourceUri": PullRequestEditUIResourceURI, + "visibility": []string{"model", "app"}, + }, + }, InputSchema: schema, }, []scopes.Scope{scopes.Repo}, - func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + 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 @@ -880,6 +965,18 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultError(err.Error()), nil, nil } + uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") + if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && !pullRequestUpdateHasNonFormParams(args) { + return utils.NewToolResultAwaitingFormSubmission(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"] var draftValue bool if draftProvided { diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 207f027b31..0f372519e5 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -2629,7 +2629,8 @@ func Test_CreatePullRequest_MCPAppsFeature_UIGate(t *testing.T) { require.NoError(t, err) textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, "Ready to create a pull request") + assert.Contains(t, textContent.Text, "interactive form has been shown to the user for creating a new pull request") + assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success") }) t.Run("UI client with _ui_submitted executes directly", func(t *testing.T) { @@ -2669,18 +2670,18 @@ func Test_CreatePullRequest_MCPAppsFeature_UIGate(t *testing.T) { // A parameter the form does not collect must bypass the form rather than // be silently dropped. request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ - "owner": "owner", - "repo": "repo", - "title": "Test PR", - "head": "feature", - "base": "main", - "reviewers": []any{"octocat"}, + "owner": "owner", + "repo": "repo", + "title": "Test PR", + "head": "feature", + "base": "main", + "unknown_param": "value", }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) textContent := getTextResult(t, result) - assert.NotContains(t, textContent.Text, "Ready to create a pull request", + assert.NotContains(t, textContent.Text, "interactive form has been shown", "non-form param should skip UI form") assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42", "non-form param call should execute directly and return PR URL") @@ -2703,7 +2704,7 @@ func Test_CreatePullRequest_MCPAppsFeature_UIGate(t *testing.T) { require.NoError(t, err) textContent := getTextResult(t, result) - assert.NotContains(t, textContent.Text, "Ready to create a pull request", + assert.NotContains(t, textContent.Text, "interactive form has been shown", "show_ui=false should skip UI form") assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42", "show_ui=false call should execute directly and return PR URL") @@ -2725,7 +2726,7 @@ func Test_CreatePullRequest_MCPAppsFeature_UIGate(t *testing.T) { require.NoError(t, err) textContent := getTextResult(t, result) - assert.Contains(t, textContent.Text, "Ready to create a pull request", + assert.Contains(t, textContent.Text, "interactive form has been shown", "show_ui=true should still route through the form") }) @@ -2770,6 +2771,102 @@ func Test_CreatePullRequest_MCPAppsFeature_UIGate(t *testing.T) { }) } +// Test_UpdatePullRequest_MCPAppsFeature_UIGate verifies the form-routing +// behavior for update_pull_request: UI clients without _ui_submitted get a +// pending-form stub (marked IsError so agents don't claim success), UI clients +// with _ui_submitted execute directly, non-UI clients execute directly, and +// UI clients carrying non-form params bypass the form. +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")}, + } + + serverTool := UpdatePullRequest(translations.NullTranslationHelper) + + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPR), + })) + + deps := BaseDeps{ + Client: client, + GQLClient: githubv4.NewClient(nil), + featureChecker: featureCheckerFor(MCPAppsFeatureFlag), + } + handler := serverTool.Handler(deps) + + t.Run("UI client without _ui_submitted returns form message", func(t *testing.T) { + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "title": "Updated", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "interactive form has been shown to the user for editing pull request #42") + assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success") + }) + + t.Run("UI client with _ui_submitted executes directly", func(t *testing.T) { + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "title": "Updated", + "_ui_submitted": true, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.False(t, result.IsError, "submitted form should execute successfully: %s", textContent.Text) + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42", + "submitted form should return the updated PR URL") + }) + + t.Run("non-UI client executes directly without _ui_submitted", func(t *testing.T) { + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "title": "Updated", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.False(t, result.IsError, "non-UI client should execute directly: %s", textContent.Text) + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42", + "non-UI client should return the updated PR URL") + }) + + t.Run("UI client with non-form param skips form and executes directly", func(t *testing.T) { + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "title": "Updated", + "unknown_param": "value", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.NotContains(t, textContent.Text, "interactive form has been shown", + "non-form param should skip UI form") + }) +} + func Test_pullRequestWriteHasNonFormParams(t *testing.T) { t.Parallel() @@ -2779,10 +2876,10 @@ func Test_pullRequestWriteHasNonFormParams(t *testing.T) { want bool }{ {name: "no params", args: map[string]any{}, want: false}, - {name: "only form params", args: map[string]any{"owner": "o", "repo": "r", "title": "t", "body": "b", "head": "h", "base": "b", "draft": true, "maintainer_can_modify": false, "_ui_submitted": true}, want: false}, + {name: "only form params", args: map[string]any{"owner": "o", "repo": "r", "title": "t", "body": "b", "head": "h", "base": "b", "draft": true, "maintainer_can_modify": false, "reviewers": []any{"octocat"}, "show_ui": true, "_ui_submitted": true}, want: false}, {name: "show_ui true is a form param", args: map[string]any{"title": "t", "show_ui": true}, want: false}, {name: "show_ui false is a form param", args: map[string]any{"title": "t", "show_ui": false}, want: false}, - {name: "unknown param present", args: map[string]any{"title": "t", "reviewers": []any{"octocat"}}, want: true}, + {name: "unknown param present", args: map[string]any{"title": "t", "unknown_param": "value"}, want: true}, {name: "nil value is ignored", args: map[string]any{"reviewers": nil}, want: false}, } diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 906fa777d7..cd6932877f 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -291,6 +291,9 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { ListLabels(t), LabelWrite(t), + // UI tools (insiders only) + UIGet(t), + // Granular issue tools (feature-flagged, replace consolidated issue_write/sub_issue_write) GranularCreateIssue(t), GranularUpdateIssueTitle(t), diff --git a/pkg/github/ui_resources.go b/pkg/github/ui_resources.go index 28051c0c4a..045e129360 100644 --- a/pkg/github/ui_resources.go +++ b/pkg/github/ui_resources.go @@ -107,4 +107,31 @@ func RegisterUIResources(s *mcp.Server, readOnly bool) { }, nil }, ) + + s.AddResource( + &mcp.Resource{ + URI: PullRequestEditUIResourceURI, + Name: "pr_edit_ui", + Description: "MCP App UI for editing GitHub pull requests", + MIMEType: MCPAppMIMEType, + }, + func(_ context.Context, _ *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + html := MustGetUIAsset("pr-edit.html") + return &mcp.ReadResourceResult{ + Contents: []*mcp.ResourceContents{ + { + URI: PullRequestEditUIResourceURI, + MIMEType: MCPAppMIMEType, + Text: html, + Meta: mcp.Meta{ + "ui": map[string]any{ + "csp": map[string]any{}, + "prefersBorder": true, + }, + }, + }, + }, + }, nil + }, + ) } diff --git a/pkg/github/ui_resources_test.go b/pkg/github/ui_resources_test.go index 7e67d5faed..49cce09bbd 100644 --- a/pkg/github/ui_resources_test.go +++ b/pkg/github/ui_resources_test.go @@ -55,6 +55,7 @@ func TestRegisterUIResources_ReadableViaClient(t *testing.T) { GetMeUIResourceURI, IssueWriteUIResourceURI, PullRequestWriteUIResourceURI, + PullRequestEditUIResourceURI, } for _, uri := range uris { t.Run(uri, func(t *testing.T) { diff --git a/pkg/github/ui_tools.go b/pkg/github/ui_tools.go new file mode 100644 index 0000000000..640250dea3 --- /dev/null +++ b/pkg/github/ui_tools.go @@ -0,0 +1,516 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "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/scopes" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/google/go-github/v87/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" +) + +// UIGet creates a tool to fetch UI data for MCP Apps. +func UIGet(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + 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)."), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_UI_GET_USER_TITLE", "Get UI data"), + ReadOnlyHint: true, + }, + // ui_get only backs MCP App views; declaring app-only visibility keeps + // it out of the agent's tool list while remaining callable by the views + // via tools/call (per the MCP Apps 2026-01-26 spec). + Meta: mcp.Meta{ + "ui": map[string]any{ + "visibility": []string{"app"}, + }, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "method": { + Type: "string", + Enum: []any{"labels", "assignees", "milestones", "issue_types", "branches", "issue_fields", "reviewers"}, + Description: "The type of data to fetch", + }, + "owner": { + Type: "string", + Description: "Repository owner (required for all methods)", + }, + "repo": { + Type: "string", + Description: "Repository name (required for labels, assignees, milestones, branches, issue fields, reviewers)", + }, + }, + Required: []string{"method", "owner"}, + }, + }, + []scopes.Scope{scopes.Repo, scopes.ReadOrg}, + 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 + } + + switch method { + case "labels": + return uiGetLabels(ctx, deps, args, owner) + case "assignees": + return uiGetAssignees(ctx, deps, args, owner) + case "milestones": + return uiGetMilestones(ctx, deps, args, owner) + case "issue_types": + return uiGetIssueTypes(ctx, deps, owner) + case "branches": + return uiGetBranches(ctx, deps, args, owner) + case "issue_fields": + return uiGetIssueFields(ctx, deps, args, owner) + case "reviewers": + return uiGetReviewers(ctx, deps, args, owner) + default: + return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil + } + }) + st.FeatureFlagEnable = MCPAppsFeatureFlag + return st +} + +func uiGetLabels(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) { + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetGQLClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + var query struct { + Repository struct { + Labels struct { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + Color githubv4.String + Description githubv4.String + } + TotalCount githubv4.Int + PageInfo struct { + HasNextPage githubv4.Boolean + EndCursor githubv4.String + } + } `graphql:"labels(first: 100, after: $cursor)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "cursor": (*githubv4.String)(nil), + } + + labels := make([]map[string]any, 0) + var totalCount int + for { + if err := client.Query(ctx, &query, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "Failed to list labels", err), nil, nil + } + for _, labelNode := range query.Repository.Labels.Nodes { + labels = append(labels, map[string]any{ + "id": fmt.Sprintf("%v", labelNode.ID), + "name": string(labelNode.Name), + "color": string(labelNode.Color), + "description": string(labelNode.Description), + }) + } + totalCount = int(query.Repository.Labels.TotalCount) + if !query.Repository.Labels.PageInfo.HasNextPage { + break + } + vars["cursor"] = githubv4.NewString(query.Repository.Labels.PageInfo.EndCursor) + } + + response := map[string]any{ + "labels": labels, + "totalCount": totalCount, + } + + out, err := json.Marshal(response) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal labels: %w", err) + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func uiGetAssignees(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) { + repo, err := RequiredParam[string](args, "repo") + 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 + } + + opts := &github.ListOptions{PerPage: 100} + var allAssignees []*github.User + + for { + assignees, resp, err := client.Issues.ListAssignees(ctx, owner, repo, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list assignees", resp, err), nil, nil + } + allAssignees = append(allAssignees, assignees...) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + + result := make([]map[string]string, len(allAssignees)) + for i, u := range allAssignees { + result[i] = map[string]string{ + "login": u.GetLogin(), + "avatar_url": u.GetAvatarURL(), + } + } + + out, err := json.Marshal(map[string]any{ + "assignees": result, + "totalCount": len(result), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal assignees", err), nil, nil + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func uiGetMilestones(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) { + repo, err := RequiredParam[string](args, "repo") + 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 + } + + opts := &github.MilestoneListOptions{ + State: "open", + ListOptions: github.ListOptions{PerPage: 100}, + } + + var allMilestones []*github.Milestone + for { + milestones, resp, err := client.Issues.ListMilestones(ctx, owner, repo, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list milestones", resp, err), nil, nil + } + allMilestones = append(allMilestones, milestones...) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + + result := make([]map[string]any, len(allMilestones)) + for i, m := range allMilestones { + dueOn := "" + if m.DueOn != nil { + dueOn = m.GetDueOn().Format("2006-01-02") + } + result[i] = map[string]any{ + "number": m.GetNumber(), + "title": m.GetTitle(), + "description": m.GetDescription(), + "state": m.GetState(), + "open_issues": m.GetOpenIssues(), + "due_on": dueOn, + } + } + + out, err := json.Marshal(map[string]any{ + "milestones": result, + "totalCount": len(result), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal milestones", err), nil, nil + } + + return utils.NewToolResultText(string(out)), nil, nil +} + +func uiGetIssueTypes(ctx context.Context, deps ToolDependencies, owner string) (*mcp.CallToolResult, any, error) { + client, err := deps.GetClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil + } + + issueTypes, resp, err := client.Organizations.ListIssueTypes(ctx, owner) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list issue types", resp, err), nil, nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to read response body", err), nil, nil + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list issue types", resp, body), nil, nil + } + + r, err := json.Marshal(issueTypes) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal issue types", err), nil, nil + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func uiGetBranches(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) { + repo, err := RequiredParam[string](args, "repo") + 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 + } + + opts := &github.BranchListOptions{ + ListOptions: github.ListOptions{PerPage: 100}, + } + + var allBranches []*github.Branch + for { + branches, resp, err := client.Repositories.ListBranches(ctx, owner, repo, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list branches", resp, err), nil, nil + } + allBranches = append(allBranches, branches...) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + if resp.NextPage == 0 { + break + } + opts.Page = resp.NextPage + } + + minimalBranches := make([]MinimalBranch, 0, len(allBranches)) + for _, branch := range allBranches { + minimalBranches = append(minimalBranches, convertToMinimalBranch(branch)) + } + + r, err := json.Marshal(map[string]any{ + "branches": minimalBranches, + "totalCount": len(minimalBranches), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func uiGetIssueFields(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) { + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + if !deps.IsFeatureEnabled(ctx, FeatureFlagIssueFields) { + return marshalUIGetIssueFields(nil) + } + + gqlClient, err := deps.GetGQLClient(ctx) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to get GitHub GraphQL client", err), nil, nil + } + + fields, err := fetchIssueFields(ctx, gqlClient, owner, repo) + if err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to list issue fields", err), nil, nil + } + + return marshalUIGetIssueFields(fields) +} + +func marshalUIGetIssueFields(fields []IssueField) (*mcp.CallToolResult, any, error) { + resultFields := make([]map[string]any, 0, len(fields)) + for _, field := range fields { + if !uiSupportedIssueFieldDataType(field.DataType) { + continue + } + + fieldResult := map[string]any{ + "id": field.ID, + "name": field.Name, + "data_type": field.DataType, + "description": field.Description, + } + + if field.DataType == "single_select" { + fieldOptions := append([]IssueSingleSelectFieldOption(nil), field.Options...) + sort.SliceStable(fieldOptions, func(i, j int) bool { + left, leftOK := issueFieldOptionPriority(fieldOptions[i]) + right, rightOK := issueFieldOptionPriority(fieldOptions[j]) + if leftOK != rightOK { + return leftOK + } + return left < right + }) + + options := make([]map[string]string, 0, len(fieldOptions)) + for _, option := range fieldOptions { + options = append(options, map[string]string{ + "name": option.Name, + "description": option.Description, + "color": option.Color, + }) + } + fieldResult["options"] = options + } + + resultFields = append(resultFields, fieldResult) + } + + r, err := json.Marshal(map[string]any{ + "fields": resultFields, + "totalCount": len(resultFields), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal issue fields", err), nil, nil + } + + return utils.NewToolResultText(string(r)), nil, nil +} + +func uiSupportedIssueFieldDataType(dataType string) bool { + switch dataType { + case "text", "number", "date", "single_select": + return true + default: + return false + } +} + +func issueFieldOptionPriority(option IssueSingleSelectFieldOption) (int, bool) { + if option.Priority == nil { + return 0, false + } + return *option.Priority, true +} + +func uiGetReviewers(ctx context.Context, deps ToolDependencies, args map[string]any, owner string) (*mcp.CallToolResult, any, error) { + repo, err := RequiredParam[string](args, "repo") + 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 + } + + collaboratorOpts := &github.ListCollaboratorsOptions{ + Affiliation: "all", + ListOptions: github.ListOptions{PerPage: 100}, + } + var allCollaborators []*github.User + for { + collaborators, resp, err := client.Repositories.ListCollaborators(ctx, owner, repo, collaboratorOpts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list reviewers", resp, err), nil, nil + } + allCollaborators = append(allCollaborators, collaborators...) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + if resp.NextPage == 0 { + break + } + collaboratorOpts.Page = resp.NextPage + } + + teamOpts := &github.ListOptions{PerPage: 100} + var allTeams []*github.Team + for { + teams, resp, err := client.Repositories.ListTeams(ctx, owner, repo, teamOpts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to list reviewer teams", resp, err), nil, nil + } + allTeams = append(allTeams, teams...) + if resp != nil && resp.Body != nil { + _ = resp.Body.Close() + } + if resp.NextPage == 0 { + break + } + teamOpts.Page = resp.NextPage + } + + users := make([]map[string]string, 0, len(allCollaborators)) + for _, user := range allCollaborators { + login := user.GetLogin() + if user.GetType() == "Bot" || strings.HasSuffix(login, "[bot]") { + continue + } + users = append(users, map[string]string{ + "login": login, + "avatar_url": user.GetAvatarURL(), + }) + } + + teams := make([]map[string]string, len(allTeams)) + for i, team := range allTeams { + teams[i] = map[string]string{ + "slug": team.GetSlug(), + "name": team.GetName(), + "org": owner, + } + } + + r, err := json.Marshal(map[string]any{ + "users": users, + "teams": teams, + "totalCount": len(users) + len(teams), + }) + if err != nil { + return utils.NewToolResultErrorFromErr("failed to marshal reviewers", err), nil, nil + } + + return utils.NewToolResultText(string(r)), nil, nil +} diff --git a/pkg/github/ui_tools_test.go b/pkg/github/ui_tools_test.go new file mode 100644 index 0000000000..2fded6b20e --- /dev/null +++ b/pkg/github/ui_tools_test.go @@ -0,0 +1,414 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "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/google/go-github/v87/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_UIGet(t *testing.T) { + // Verify tool definition + serverTool := UIGet(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "ui_get", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "method") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "owner") + assert.Contains(t, tool.InputSchema.(*jsonschema.Schema).Properties, "repo") + assert.ElementsMatch(t, tool.InputSchema.(*jsonschema.Schema).Required, []string{"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") + + // 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). + ui, ok := tool.Meta["ui"].(map[string]any) + require.True(t, ok, "ui_get should declare _meta.ui") + assert.Equal(t, []string{"app"}, ui["visibility"], "ui_get should be app-only") + + // 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")}, + } + + mockBranches := []*github.Branch{ + {Name: github.Ptr("main"), Protected: github.Ptr(true)}, + {Name: github.Ptr("feature"), Protected: github.Ptr(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")}, + } + + mockIssueTypes := []*github.IssueType{ + {Name: github.Ptr("Bug")}, + {Name: github.Ptr("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")}, + } + + mockReviewerTeams := []*github.Team{ + {Slug: github.Ptr("docs"), Name: github.Ptr("Docs")}, + } + + tests := []struct { + name string + mockedClient *http.Client + mockedGQLClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + validateResult func(t *testing.T, responseText string) + }{ + { + name: "successful assignees fetch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/owner/repo/assignees": mockResponse(t, http.StatusOK, mockAssignees), + }), + requestArgs: map[string]any{ + "method": "assignees", + "owner": "owner", + "repo": "repo", + }, + expectError: false, + validateResult: func(t *testing.T, responseText string) { + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(responseText), &response)) + assert.Contains(t, response, "assignees") + assert.Contains(t, response, "totalCount") + }, + }, + { + name: "successful branches fetch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/owner/repo/branches": mockResponse(t, http.StatusOK, mockBranches), + }), + requestArgs: map[string]any{ + "method": "branches", + "owner": "owner", + "repo": "repo", + }, + expectError: false, + validateResult: func(t *testing.T, responseText string) { + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(responseText), &response)) + assert.Contains(t, response, "branches") + assert.Contains(t, response, "totalCount") + }, + }, + { + name: "successful milestones fetch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/owner/repo/milestones": mockResponse(t, http.StatusOK, mockMilestones), + }), + requestArgs: map[string]any{ + "method": "milestones", + "owner": "owner", + "repo": "repo", + }, + expectError: false, + validateResult: func(t *testing.T, responseText string) { + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(responseText), &response)) + milestones, ok := response["milestones"].([]any) + require.True(t, ok, "milestones should be a list") + require.Len(t, milestones, 2) + first := milestones[0].(map[string]any) + assert.Equal(t, "2026-01-31", first["due_on"], "milestone with a due date should be formatted") + second := milestones[1].(map[string]any) + assert.Equal(t, "", second["due_on"], "milestone without a due date should be empty, not zero time") + }, + }, + { + name: "successful issue_types fetch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/owner/issue-types": mockResponse(t, http.StatusOK, mockIssueTypes), + }), + requestArgs: map[string]any{ + "method": "issue_types", + "owner": "owner", + }, + expectError: false, + validateResult: func(t *testing.T, responseText string) { + var issueTypes []map[string]any + require.NoError(t, json.Unmarshal([]byte(responseText), &issueTypes)) + require.Len(t, issueTypes, 2) + assert.Equal(t, "Bug", issueTypes[0]["name"]) + }, + }, + { + name: "issue_types API error returns response context", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /orgs/owner/issue-types": mockResponse(t, http.StatusForbidden, map[string]string{"message": "Forbidden"}), + }), + requestArgs: map[string]any{ + "method": "issue_types", + "owner": "owner", + }, + expectError: true, + expectedErrMsg: "failed to list issue types", + }, + { + name: "successful labels fetch", + mockedGQLClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Labels struct { + Nodes []struct { + ID githubv4.ID + Name githubv4.String + Color githubv4.String + Description githubv4.String + } + TotalCount githubv4.Int + PageInfo struct { + HasNextPage githubv4.Boolean + EndCursor githubv4.String + } + } `graphql:"labels(first: 100, after: $cursor)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "cursor": (*githubv4.String)(nil), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "labels": map[string]any{ + "nodes": []any{ + map[string]any{ + "id": githubv4.ID("label-1"), + "name": githubv4.String("bug"), + "color": githubv4.String("d73a4a"), + "description": githubv4.String("Something isn't working"), + }, + }, + "totalCount": githubv4.Int(1), + "pageInfo": map[string]any{ + "hasNextPage": githubv4.Boolean(false), + "endCursor": githubv4.String(""), + }, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "method": "labels", + "owner": "owner", + "repo": "repo", + }, + expectError: false, + validateResult: func(t *testing.T, responseText string) { + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(responseText), &response)) + labels, ok := response["labels"].([]any) + require.True(t, ok, "labels should be a list") + require.Len(t, labels, 1) + assert.Equal(t, "bug", labels[0].(map[string]any)["name"]) + assert.Equal(t, float64(1), response["totalCount"]) + }, + }, + { + name: "issue_fields feature disabled returns empty list", + requestArgs: map[string]any{ + "method": "issue_fields", + "owner": "owner", + "repo": "repo", + }, + expectError: false, + validateResult: func(t *testing.T, responseText string) { + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(responseText), &response)) + fields, ok := response["fields"].([]any) + require.True(t, ok, "fields should be a list") + assert.Empty(t, fields) + assert.Equal(t, float64(0), response["totalCount"]) + }, + }, + { + name: "successful reviewers fetch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + "GET /repos/owner/repo/collaborators": mockResponse(t, http.StatusOK, mockReviewers), + "GET /repos/owner/repo/teams": mockResponse(t, http.StatusOK, mockReviewerTeams), + }), + requestArgs: map[string]any{ + "method": "reviewers", + "owner": "owner", + "repo": "repo", + }, + expectError: false, + validateResult: func(t *testing.T, responseText string) { + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(responseText), &response)) + users, ok := response["users"].([]any) + require.True(t, ok, "users should be a list") + require.Len(t, users, 1) + assert.Equal(t, "octocat", users[0].(map[string]any)["login"]) + teams, ok := response["teams"].([]any) + require.True(t, ok, "teams should be a list") + require.Len(t, teams, 1) + assert.Equal(t, "docs", teams[0].(map[string]any)["slug"]) + assert.Equal(t, "owner", teams[0].(map[string]any)["org"]) + assert.Equal(t, float64(2), response["totalCount"]) + }, + }, + { + name: "missing method parameter", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: method", + }, + { + name: "missing owner parameter", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "method": "assignees", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "missing repo parameter for assignees", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "method": "assignees", + "owner": "owner", + }, + expectError: true, + expectedErrMsg: "missing required parameter: repo", + }, + { + name: "unknown method", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]any{ + "method": "unknown", + "owner": "owner", + "repo": "repo", + }, + expectError: true, + expectedErrMsg: "unknown method: unknown", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Setup deps with REST and/or GraphQL mocks + deps := BaseDeps{} + if tc.mockedClient != nil { + client, err := github.NewClient(github.WithHTTPClient(tc.mockedClient)) + require.NoError(t, err) + deps.Client = client + } + if tc.mockedGQLClient != nil { + deps.GQLClient = githubv4.NewClient(tc.mockedGQLClient) + } + handler := serverTool.Handler(deps) + + // Create call request + request := createMCPRequest(tc.requestArgs) + + // Call handler + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + // Verify results + if tc.expectError { + if err != nil { + assert.Contains(t, err.Error(), tc.expectedErrMsg) + return + } + require.NotNil(t, result) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.NotNil(t, result) + require.False(t, result.IsError) + textContent := getTextResult(t, result) + + if tc.validateResult != nil { + tc.validateResult(t, textContent.Text) + } + }) + } +} + +func Test_marshalUIGetIssueFields_TrimsForUI(t *testing.T) { + priorityLow := 1 + priorityHigh := 2 + result, _, err := marshalUIGetIssueFields([]IssueField{ + { + ID: "field-1", + DatabaseID: 123, + Name: "Priority", + Description: "How urgent this is", + DataType: "single_select", + Visibility: "public", + Options: []IssueSingleSelectFieldOption{ + {ID: "option-2", Name: "High", Description: "High priority", Color: "red", Priority: &priorityHigh}, + {ID: "option-1", Name: "Low", Description: "Low priority", Color: "blue", Priority: &priorityLow}, + {ID: "option-3", Name: "No priority", Description: "No priority set", Color: "gray"}, + }, + }, + { + ID: "field-2", + Name: "Unsupported", + DataType: "iteration", + }, + { + ID: "field-3", + Name: "Notes", + DataType: "text", + }, + }) + require.NoError(t, err) + + var response map[string]any + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &response)) + fields := response["fields"].([]any) + require.Len(t, fields, 2) + assert.Equal(t, float64(2), response["totalCount"]) + + singleSelectField := fields[0].(map[string]any) + assert.NotContains(t, singleSelectField, "full_database_id") + assert.NotContains(t, singleSelectField, "visibility") + options := singleSelectField["options"].([]any) + require.Len(t, options, 3) + assert.Equal(t, "Low", options[0].(map[string]any)["name"]) + assert.Equal(t, "High", options[1].(map[string]any)["name"]) + assert.Equal(t, "No priority", options[2].(map[string]any)["name"]) + assert.NotContains(t, options[0].(map[string]any), "id") + assert.NotContains(t, options[0].(map[string]any), "priority") + + textField := fields[1].(map[string]any) + assert.NotContains(t, textField, "options") +} diff --git a/pkg/utils/result.go b/pkg/utils/result.go index 1bfd800e28..99c37602bc 100644 --- a/pkg/utils/result.go +++ b/pkg/utils/result.go @@ -59,3 +59,27 @@ func NewToolResultResourceLink(message string, link *mcp.ResourceLink) *mcp.Call IsError: false, } } + +// NewToolResultAwaitingFormSubmission signals to the agent that a tool call +// has been intercepted to show an MCP App form to the user and has NOT +// performed the requested operation. The agent must stop, not chain dependent +// tool calls, and not claim the operation succeeded. The result is marked +// IsError=true so agents that bail on error don't proceed; the host still +// renders the UI because rendering is keyed off the tool's _meta.ui, not the +// result. The MCP App form will submit the operation directly when the user +// clicks submit, after which a ui/update-model-context call delivers the real +// outcome to the agent. +func NewToolResultAwaitingFormSubmission(message string) *mcp.CallToolResult { + return &mcp.CallToolResult{ + Content: []mcp.Content{ + &mcp.TextContent{ + Text: message, + }, + }, + StructuredContent: map[string]any{ + "status": "awaiting_user_submission", + "reason": "An interactive form is being shown to the user. The operation has not been performed.", + }, + IsError: true, + } +} diff --git a/ui/scripts/build.mjs b/ui/scripts/build.mjs index c99d846039..9efa58524c 100644 --- a/ui/scripts/build.mjs +++ b/ui/scripts/build.mjs @@ -1,12 +1,12 @@ // Build all UI apps in a single Node process. // -// Replaces three serial `cross-env APP= vite build` invocations: doing it -// in one process avoids paying Vite/plugin startup cost three times and is +// Replaces serial `cross-env APP= vite build` invocations: doing it +// in one process avoids paying Vite/plugin startup cost for each app and is // portable without `cross-env`. import { build } from "vite"; -const apps = ["get-me", "issue-write", "pr-write"]; +const apps = ["get-me", "issue-write", "pr-write", "pr-edit"]; for (const app of apps) { process.env.APP = app; diff --git a/ui/src/apps/issue-write/App.tsx b/ui/src/apps/issue-write/App.tsx index 6c46b8c081..6372e2d503 100644 --- a/ui/src/apps/issue-write/App.tsx +++ b/ui/src/apps/issue-write/App.tsx @@ -1,4 +1,4 @@ -import { StrictMode, useState, useCallback, useEffect } from "react"; +import { StrictMode, useState, useCallback, useEffect, useMemo, useRef } from "react"; import { createRoot } from "react-dom/client"; import { Box, @@ -8,10 +8,19 @@ import { Flash, Spinner, FormControl, + CounterLabel, + ActionMenu, + ActionList, + Label, } from "@primer/react"; import { IssueOpenedIcon, CheckCircleIcon, + TagIcon, + PersonIcon, + RepoIcon, + MilestoneIcon, + LockIcon, } from "@primer/octicons-react"; import { AppProvider } from "../../components/AppProvider"; import { useMcpApp } from "../../hooks/useMcpApp"; @@ -27,11 +36,251 @@ interface IssueResult { URL?: string; } +interface LabelItem { + id: string; + text: string; + color: string; +} + +interface AssigneeItem { + id: string; + text: string; +} + +interface MilestoneItem { + id: string; + number: number; + text: string; + description: string; +} + +interface IssueTypeItem { + id: string; + text: string; +} + +type IssueState = "open" | "closed"; +type StateReason = "completed" | "not_planned" | "duplicate"; +type IssueFieldPrimitive = string | number | boolean; + +interface IssueFieldOption { + id: string; + name: string; + description: string; + color: string; +} + +interface IssueFieldItem { + id: string; + name: string; + data_type: string; + description: string; + options: IssueFieldOption[]; +} + +interface IssueFieldValue { + value?: IssueFieldPrimitive; + optionName?: string; + cleared?: boolean; +} + +interface IssueFieldSubmission { + field_name: string; + value?: IssueFieldPrimitive; + field_option_name?: string; + delete?: boolean; +} + +interface RepositoryItem { + id: string; + owner: string; + name: string; + fullName: string; + isPrivate: boolean; +} + +// Calculate text color based on background luminance +function getContrastColor(hexColor: string): string { + const r = parseInt(hexColor.substring(0, 2), 16); + const g = parseInt(hexColor.substring(2, 4), 16); + const b = parseInt(hexColor.substring(4, 6), 16); + const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255; + return luminance > 0.5 ? "#000000" : "#ffffff"; +} + +const stateReasonOptions: Array<{ value: StateReason; label: string; description: string }> = [ + { value: "completed", label: "Completed", description: "The work is done" }, + { value: "not_planned", label: "Not planned", description: "The issue won't be worked on" }, + { value: "duplicate", label: "Duplicate", description: "Another issue tracks this" }, +]; + +function normalizeSwatchColor(color: string): string { + const trimmed = color.trim(); + if (!trimmed) return "var(--borderColor-default, var(--color-border-default))"; + if (/^#?[0-9a-fA-F]{6}$/.test(trimmed)) { + return trimmed.startsWith("#") ? trimmed : `#${trimmed}`; + } + return trimmed.toLowerCase(); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringValue(value: unknown): string | undefined { + if (typeof value === "string" && value.trim()) return value; + if (typeof value === "number" && Number.isFinite(value)) return String(value); + return undefined; +} + +function parseIssueState(value: unknown): IssueState | null { + return value === "open" || value === "closed" ? value : null; +} + +function parseStateReason(value: unknown): StateReason | null { + return value === "completed" || value === "not_planned" || value === "duplicate" ? value : null; +} + +function normalizeRawIssueFieldValue( + field: IssueFieldItem | undefined, + rawValue: unknown +): IssueFieldValue | null { + if (rawValue === null || rawValue === undefined) return null; + + if (isRecord(rawValue)) { + const optionName = + stringValue(rawValue.optionName) || + stringValue(rawValue.field_option_name) || + stringValue(rawValue.name); + if (field?.data_type === "single_select" && optionName) { + return { optionName }; + } + return normalizeRawIssueFieldValue( + field, + rawValue.value ?? rawValue.text ?? rawValue.number ?? rawValue.date ?? rawValue.name + ); + } + + if (field?.data_type === "single_select") { + const optionName = stringValue(rawValue); + return optionName ? { optionName } : null; + } + + if ( + typeof rawValue === "string" || + typeof rawValue === "number" || + typeof rawValue === "boolean" + ) { + return { value: rawValue }; + } + + return null; +} + +function parseStringIssueFieldValue( + entry: string, + fieldsByName: Map +): [string, IssueFieldValue] | null { + const match = entry.match(/^([^:=]+)\s*[:=]\s*(.*)$/); + if (!match) return null; + + const fieldName = match[1].trim(); + const field = fieldsByName.get(fieldName); + if (!field) return null; + + const normalized = normalizeRawIssueFieldValue(field, match[2].trim()); + return normalized ? [fieldName, normalized] : null; +} + +function normalizeIssueFieldEntry( + entry: unknown, + fieldsByName: Map +): [string, IssueFieldValue] | null { + if (typeof entry === "string") return parseStringIssueFieldValue(entry, fieldsByName); + if (!isRecord(entry)) return null; + + const fieldRecord = isRecord(entry.field) ? entry.field : undefined; + const entryName = stringValue(entry.name); + const fieldName = + stringValue(entry.field_name) || + stringValue(entry.fieldName) || + (fieldRecord ? stringValue(fieldRecord.name) : undefined) || + entryName; + if (!fieldName) return null; + + const field = fieldsByName.get(fieldName); + if (!field) return null; + + if (entry.delete === true || entry.cleared === true) { + return [fieldName, { cleared: true }]; + } + + const directOptionName = + stringValue(entry.field_option_name) || + stringValue(entry.fieldOptionName) || + stringValue(entry.optionName) || + (field.data_type === "single_select" && entryName && entryName !== fieldName ? entryName : undefined); + if (directOptionName) return [fieldName, { optionName: directOptionName }]; + + const optionRecord = isRecord(entry.option) ? entry.option : undefined; + const optionName = optionRecord ? stringValue(optionRecord.name) : undefined; + if (optionName) return [fieldName, { optionName }]; + + const normalized = normalizeRawIssueFieldValue( + field, + entry.value ?? entry.text ?? entry.number ?? entry.date + ); + return normalized ? [fieldName, normalized] : null; +} + +function normalizeIssueFieldValues( + input: unknown, + fields: IssueFieldItem[] +): Record { + const fieldsByName = new Map(fields.map((field) => [field.name, field])); + const values: Record = {}; + + if (Array.isArray(input)) { + for (const item of input) { + const normalized = normalizeIssueFieldEntry(item, fieldsByName); + if (normalized) values[normalized[0]] = normalized[1]; + } + return values; + } + + if (!isRecord(input)) return values; + + const normalizedEntry = normalizeIssueFieldEntry(input, fieldsByName); + if (normalizedEntry) { + values[normalizedEntry[0]] = normalizedEntry[1]; + return values; + } + + for (const [fieldName, rawValue] of Object.entries(input)) { + const field = fieldsByName.get(fieldName); + if (!field) continue; + + if (isRecord(rawValue)) { + const nested = normalizeIssueFieldEntry({ ...rawValue, field_name: fieldName }, fieldsByName); + if (nested) { + values[fieldName] = nested[1]; + continue; + } + } + + const normalized = normalizeRawIssueFieldValue(field, rawValue); + if (normalized) values[fieldName] = normalized; + } + + return values; +} + function SuccessView({ issue, owner, repo, submittedTitle, + submittedLabels, isUpdate, openLink, }: { @@ -39,6 +288,7 @@ function SuccessView({ owner: string; repo: string; submittedTitle: string; + submittedLabels: LabelItem[]; isUpdate: boolean; openLink: (url: string) => Promise; }) { @@ -118,6 +368,22 @@ function SuccessView({ {owner}/{repo} + {submittedLabels.length > 0 && ( + + {submittedLabels.map((label) => ( + + ))} + + )} @@ -131,23 +397,576 @@ function CreateIssueApp() { const [error, setError] = useState(null); const [successIssue, setSuccessIssue] = useState(null); + // Labels state + const [availableLabels, setAvailableLabels] = useState([]); + const [selectedLabels, setSelectedLabels] = useState([]); + const [labelsLoading, setLabelsLoading] = useState(false); + const [labelsFilter, setLabelsFilter] = useState(""); + + // Assignees state + const [availableAssignees, setAvailableAssignees] = useState([]); + const [selectedAssignees, setSelectedAssignees] = useState([]); + const [assigneesLoading, setAssigneesLoading] = useState(false); + const [assigneesFilter, setAssigneesFilter] = useState(""); + + // Milestones state + const [availableMilestones, setAvailableMilestones] = useState([]); + const [selectedMilestone, setSelectedMilestone] = useState(null); + const [milestonesLoading, setMilestonesLoading] = useState(false); + + // Issue types state + const [availableIssueTypes, setAvailableIssueTypes] = useState([]); + const [selectedIssueType, setSelectedIssueType] = useState(null); + const [issueTypesLoading, setIssueTypesLoading] = useState(false); + + // State transition state + const [currentState, setCurrentState] = useState("open"); + const [stateReason, setStateReason] = useState("completed"); + const [duplicateOf, setDuplicateOf] = useState(""); + const [prefilledStateChange, setPrefilledStateChange] = useState(null); + + // Issue fields state + const [availableIssueFields, setAvailableIssueFields] = useState([]); + const [fieldValues, setFieldValues] = useState>({}); + + // Repository state + const [selectedRepo, setSelectedRepo] = useState(null); + const [repoSearchResults, setRepoSearchResults] = useState([]); + const [repoSearchLoading, setRepoSearchLoading] = useState(false); + const [repoFilter, setRepoFilter] = useState(""); + const { app, error: appError, toolInput, callTool, hostContext, setModelContext, openLink } = useMcpApp({ appName: "github-mcp-server-issue-write", }); + // Get method and issue_number from toolInput const method = (toolInput?.method as string) || "create"; const issueNumber = toolInput?.issue_number as number | undefined; const isUpdateMode = method === "update" && issueNumber !== undefined; - const owner = (toolInput?.owner as string) || ""; - const repo = (toolInput?.repo as string) || ""; - // Pre-fill from toolInput + // Initialize from toolInput or selected repo + const owner = selectedRepo?.owner || (toolInput?.owner as string) || ""; + const repo = selectedRepo?.name || (toolInput?.repo as string) || ""; + + // Search repositories when filter changes + useEffect(() => { + if (!app || !repoFilter.trim()) { + setRepoSearchResults([]); + return; + } + + const searchRepos = async () => { + setRepoSearchLoading(true); + try { + const result = await callTool("search_repositories", { + query: repoFilter, + perPage: 10, + }); + if (result && !result.isError && result.content) { + const textContent = result.content.find( + (c) => c.type === "text" + ); + if (textContent && textContent.type === "text" && textContent.text) { + const data = JSON.parse(textContent.text); + const repos = (data.repositories || data.items || []).map( + (r: { id?: number; owner?: { login?: string } | string; name?: string; full_name?: string; private?: boolean }) => ({ + id: String(r.id || r.full_name), + owner: + typeof r.owner === "string" + ? r.owner + : r.owner?.login || r.full_name?.split("/")[0] || "", + name: r.name || r.full_name?.split("/")[1] || "", + fullName: r.full_name || "", + isPrivate: r.private || false, + }) + ); + setRepoSearchResults(repos); + } + } + } catch (e) { + console.error("Failed to search repositories:", e); + } finally { + setRepoSearchLoading(false); + } + }; + + const debounce = setTimeout(searchRepos, 300); + return () => clearTimeout(debounce); + }, [app, callTool, repoFilter]); + + // Load labels, assignees, milestones, issue types, and issue fields when owner/repo available useEffect(() => { - if (toolInput?.title) setTitle(toolInput.title as string); - if (toolInput?.body) setBody(toolInput.body as string); + if (!owner || !repo || !app) return; + + const loadLabels = async () => { + setLabelsLoading(true); + try { + const result = await callTool("ui_get", { method: "labels", owner, repo }); + 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 labels = (data.labels || []).map( + (l: { name: string; color: string; id: string }) => ({ + id: l.id || l.name, + text: l.name, + color: l.color, + }) + ); + setAvailableLabels(labels); + } + } + } catch (e) { + console.error("Failed to load labels:", e); + } finally { + setLabelsLoading(false); + } + }; + + const loadAssignees = async () => { + setAssigneesLoading(true); + try { + const result = await callTool("ui_get", { method: "assignees", owner, repo }); + 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 assignees = (data.assignees || []).map( + (a: { login: string }) => ({ + id: a.login, + text: a.login, + }) + ); + setAvailableAssignees(assignees); + } + } + } catch (e) { + console.error("Failed to load assignees:", e); + } finally { + setAssigneesLoading(false); + } + }; + + const loadMilestones = async () => { + setMilestonesLoading(true); + try { + const result = await callTool("ui_get", { method: "milestones", owner, repo }); + 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 milestones = (data.milestones || []).map( + (m: { number: number; title: string; description: string }) => ({ + id: String(m.number), + number: m.number, + text: m.title, + description: m.description || "", + }) + ); + setAvailableMilestones(milestones); + } + } + } catch (e) { + console.error("Failed to load milestones:", e); + } finally { + setMilestonesLoading(false); + } + }; + + const loadIssueTypes = async () => { + setIssueTypesLoading(true); + try { + const result = await callTool("ui_get", { method: "issue_types", owner }); + 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); + // 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( + (t: { id: number; name: string; description?: string } | string) => { + if (typeof t === "string") { + return { id: t, text: t }; + } + return { id: String(t.id || t.name), text: t.name }; + } + ); + setAvailableIssueTypes(types); + } + } + } catch (e) { + // Issue types may not be available for all repos/orgs + console.debug("Issue types not available:", e); + } finally { + setIssueTypesLoading(false); + } + }; + + const loadIssueFields = async () => { + try { + const result = await callTool("ui_get", { method: "issue_fields", owner, repo }); + 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 fields = (data.fields || []) + .map( + (field: { + id?: string; + name?: string; + data_type?: string; + description?: string; + options?: Array<{ id?: string; name?: string; description?: string; color?: string }>; + }) => ({ + id: String(field.id || field.name || ""), + name: field.name || "", + data_type: field.data_type || "text", + description: field.description || "", + options: (field.options || []) + .map((option) => ({ + id: String(option.id || option.name || ""), + name: option.name || "", + description: option.description || "", + color: option.color || "", + })) + .filter((option) => option.name), + }) + ) + .filter((field: IssueFieldItem) => field.name); + setAvailableIssueFields(fields); + } + } + } catch (e) { + console.debug("Issue fields not available:", e); + setAvailableIssueFields([]); + } + }; + + loadLabels(); + loadAssignees(); + loadMilestones(); + loadIssueTypes(); + loadIssueFields(); + }, [owner, repo, app, callTool]); + + // Track which prefill fields have been applied to avoid re-applying after user edits + const prefillApplied = useRef<{ + title: boolean; + body: boolean; + labels: boolean; + assignees: boolean; + milestone: boolean; + type: boolean; + issueFields: boolean; + }>({ + title: false, + body: false, + labels: false, + assignees: false, + milestone: false, + type: false, + issueFields: false, + }); + + // Store existing issue data for matching when available lists load + interface ExistingIssueData { + labels: string[]; + assignees: string[]; + milestoneNumber: number | null; + issueType: string | null; + fieldValues: unknown; + } + const [existingIssueData, setExistingIssueData] = useState(null); + + // Reset all transient form/result state when toolInput changes (new invocation). + // Without this, the SuccessView from a previous submit stays visible and stale + // form values (e.g. body) bleed through because prefill effects use truthy guards + // that won't overwrite with empty values. The repo is re-initialized from the new + // invocation here (rather than in a separate effect) so it isn't wiped by this reset. + useEffect(() => { + prefillApplied.current = { + title: false, + body: false, + labels: false, + assignees: false, + milestone: false, + type: false, + issueFields: false, + }; + setExistingIssueData(null); + setTitle(""); + setBody(""); + setSelectedLabels([]); + setSelectedAssignees([]); + setSelectedMilestone(null); + setSelectedIssueType(null); + setCurrentState("open"); + setStateReason("completed"); + setDuplicateOf(""); + setPrefilledStateChange(null); + setFieldValues({}); + setSuccessIssue(null); + setError(null); + // Clear available metadata (and filters) so prefill effects, which are gated + // on these lists being non-empty, can't match against the previous repo's data + // before the new repo's ui_get calls resolve. + setAvailableLabels([]); + setAvailableAssignees([]); + setAvailableMilestones([]); + setAvailableIssueTypes([]); + setAvailableIssueFields([]); + setLabelsFilter(""); + setAssigneesFilter(""); + if (toolInput?.owner && toolInput?.repo) { + setSelectedRepo({ + id: `${toolInput.owner}/${toolInput.repo}`, + owner: toolInput.owner as string, + name: toolInput.repo as string, + fullName: `${toolInput.owner}/${toolInput.repo}`, + isPrivate: false, + }); + } else { + setSelectedRepo(null); + } }, [toolInput]); - const handleSubmit = useCallback(async () => { + // Load existing issue data when in update mode + useEffect(() => { + if (!isUpdateMode || !owner || !repo || !issueNumber || !app || existingIssueData !== null) { + return; + } + + const loadExistingIssue = async () => { + try { + const result = await callTool("issue_read", { + method: "get", + owner, + repo, + issue_number: issueNumber, + }); + + if (result && !result.isError && result.content) { + const textContent = result.content.find( + (c) => c.type === "text" + ); + if (textContent && textContent.type === "text" && textContent.text) { + const issueData = JSON.parse(textContent.text); + + const issueState = parseIssueState(issueData.state); + if (issueState) { + setCurrentState(issueState); + } + + // Pre-fill title and body immediately + if (issueData.title && !prefillApplied.current.title) { + setTitle(issueData.title); + prefillApplied.current.title = true; + } + if (issueData.body && !prefillApplied.current.body) { + setBody(issueData.body); + prefillApplied.current.body = true; + } + + // Pre-fill assignees immediately from issue data + const assigneeLogins = (issueData.assignees || []) + .map((a: { login?: string } | string) => typeof a === 'string' ? a : a.login) + .filter(Boolean) as string[]; + if (assigneeLogins.length > 0 && !prefillApplied.current.assignees) { + setSelectedAssignees(assigneeLogins.map(login => ({ id: login, text: login }))); + prefillApplied.current.assignees = true; + } + + // Pre-fill issue type immediately from issue data + const issueTypeName = issueData.type?.name || (typeof issueData.type === 'string' ? issueData.type : null); + if (issueTypeName && !prefillApplied.current.type) { + setSelectedIssueType({ id: issueTypeName, text: issueTypeName }); + prefillApplied.current.type = true; + } + + // Extract data for deferred matching when available lists load (for labels and milestones) + const labelNames = (issueData.labels || []) + .map((l: { name?: string } | string) => typeof l === 'string' ? l : l.name) + .filter(Boolean) as string[]; + + const milestoneNumber = issueData.milestone + ? (typeof issueData.milestone === 'object' ? issueData.milestone.number : issueData.milestone) + : null; + + setExistingIssueData({ + labels: labelNames, + assignees: assigneeLogins, + milestoneNumber, + issueType: issueTypeName, + fieldValues: issueData.field_values || issueData.fieldValues || [], + }); + } + } + } catch (e) { + console.error("Error loading existing issue:", e); + } + }; + + loadExistingIssue(); + }, [isUpdateMode, owner, repo, issueNumber, app, callTool, existingIssueData]); + + // Apply existing labels when available labels load + useEffect(() => { + if (!existingIssueData?.labels.length || !availableLabels.length || prefillApplied.current.labels) return; + const matched = availableLabels.filter((l) => existingIssueData.labels.includes(l.text)); + if (matched.length > 0) { + setSelectedLabels(matched); + prefillApplied.current.labels = true; + } + }, [existingIssueData, availableLabels]); + + // Apply existing milestone when available milestones load + useEffect(() => { + if (!existingIssueData?.milestoneNumber || !availableMilestones.length || prefillApplied.current.milestone) return; + const matched = availableMilestones.find((m) => m.number === existingIssueData.milestoneNumber); + if (matched) { + setSelectedMilestone(matched); + } + prefillApplied.current.milestone = true; + }, [existingIssueData, availableMilestones]); + + // Pre-fill title and body immediately (don't wait for data loading) + useEffect(() => { + if (toolInput?.title && !prefillApplied.current.title) { + setTitle(toolInput.title as string); + prefillApplied.current.title = true; + } + if (toolInput?.body && !prefillApplied.current.body) { + setBody(toolInput.body as string); + prefillApplied.current.body = true; + } + }, [toolInput]); + + // Pre-fill requested state transition controls from tool input + useEffect(() => { + const state = parseIssueState(toolInput?.state); + if (state) { + setPrefilledStateChange(state); + } + + const reason = parseStateReason(toolInput?.state_reason); + if (reason) { + setStateReason(reason); + } + + if (toolInput?.duplicate_of !== undefined && toolInput?.duplicate_of !== null) { + setDuplicateOf(String(toolInput.duplicate_of)); + } + }, [toolInput]); + + // Pre-fill labels once available data is loaded + useEffect(() => { + if ( + toolInput?.labels && + Array.isArray(toolInput.labels) && + availableLabels.length > 0 && + !prefillApplied.current.labels + ) { + const prefillLabels = availableLabels.filter((l) => + (toolInput.labels as string[]).includes(l.text) + ); + if (prefillLabels.length > 0) { + setSelectedLabels(prefillLabels); + prefillApplied.current.labels = true; + } + } + }, [toolInput, availableLabels]); + + // Pre-fill assignees once available data is loaded + useEffect(() => { + if ( + toolInput?.assignees && + Array.isArray(toolInput.assignees) && + availableAssignees.length > 0 && + !prefillApplied.current.assignees + ) { + const prefillAssignees = availableAssignees.filter((a) => + (toolInput.assignees as string[]).includes(a.text) + ); + if (prefillAssignees.length > 0) { + setSelectedAssignees(prefillAssignees); + prefillApplied.current.assignees = true; + } + } + }, [toolInput, availableAssignees]); + + // Pre-fill milestone once available data is loaded + useEffect(() => { + if ( + toolInput?.milestone && + availableMilestones.length > 0 && + !prefillApplied.current.milestone + ) { + const milestone = availableMilestones.find( + (m) => m.number === Number(toolInput.milestone) + ); + if (milestone) { + setSelectedMilestone(milestone); + prefillApplied.current.milestone = true; + } + } + }, [toolInput, availableMilestones]); + + // Pre-fill issue type once available data is loaded + useEffect(() => { + if ( + toolInput?.type && + availableIssueTypes.length > 0 && + !prefillApplied.current.type + ) { + const issueType = availableIssueTypes.find( + (t) => t.text === toolInput.type + ); + if (issueType) { + setSelectedIssueType(issueType); + prefillApplied.current.type = true; + } + } + }, [toolInput, availableIssueTypes]); + + // Pre-fill custom fields once field definitions are loaded + useEffect(() => { + if (!availableIssueFields.length || prefillApplied.current.issueFields) return; + + const toolInputValues = normalizeIssueFieldValues(toolInput?.issue_fields, availableIssueFields); + if (Object.keys(toolInputValues).length > 0) { + setFieldValues(toolInputValues); + prefillApplied.current.issueFields = true; + return; + } + + const existingValues = normalizeIssueFieldValues(existingIssueData?.fieldValues, availableIssueFields); + if (Object.keys(existingValues).length > 0) { + setFieldValues(existingValues); + prefillApplied.current.issueFields = true; + } + }, [toolInput, existingIssueData, availableIssueFields]); + + const issueFieldsByName = useMemo( + () => new Map(availableIssueFields.map((field) => [field.name, field])), + [availableIssueFields] + ); + + const updateIssueFieldValue = useCallback((fieldName: string, value: IssueFieldValue) => { + prefillApplied.current.issueFields = true; + setFieldValues((prev) => ({ ...prev, [fieldName]: value })); + }, []); + + const handleSubmit = useCallback(async (stateChange?: IssueState) => { if (!title.trim()) { setError("Title is required"); return; @@ -157,6 +976,16 @@ function CreateIssueApp() { return; } + const requestedState = isUpdateMode ? stateChange || prefilledStateChange : null; + let duplicateIssueNumber: number | undefined; + if (requestedState === "closed" && stateReason === "duplicate") { + duplicateIssueNumber = Number(duplicateOf); + if (!Number.isInteger(duplicateIssueNumber) || duplicateIssueNumber <= 0) { + setError("Duplicate issue number is required"); + return; + } + } + setIsSubmitting(true); setError(null); @@ -171,10 +1000,60 @@ function CreateIssueApp() { _ui_submitted: true }; + delete params.state; + delete params.state_reason; + delete params.duplicate_of; + delete params.issue_fields; + if (isUpdateMode && issueNumber) { params.issue_number = issueNumber; } + if (selectedLabels.length > 0) { + params.labels = selectedLabels.map((l) => l.text); + } + if (selectedAssignees.length > 0) { + params.assignees = selectedAssignees.map((a) => a.text); + } + if (selectedMilestone) { + params.milestone = selectedMilestone.number; + } + if (selectedIssueType) { + params.type = selectedIssueType.text; + } + + if (requestedState) { + params.state = requestedState; + if (requestedState === "closed") { + params.state_reason = stateReason; + if (stateReason === "duplicate" && duplicateIssueNumber !== undefined) { + params.duplicate_of = duplicateIssueNumber; + } + } + } + + const issueFields = Object.entries(fieldValues) + .map(([fieldName, value]): IssueFieldSubmission | null => { + if (value.cleared) return { field_name: fieldName, delete: true }; + if (value.optionName !== undefined) { + return { field_name: fieldName, field_option_name: value.optionName }; + } + if (value.value !== undefined && value.value !== "") { + const field = issueFieldsByName.get(fieldName); + const fieldValue = + field?.data_type === "number" && typeof value.value === "string" + ? Number(value.value) + : value.value; + if (typeof fieldValue === "number" && Number.isNaN(fieldValue)) return null; + return { field_name: fieldName, value: fieldValue }; + } + return null; + }) + .filter((field): field is IssueFieldSubmission => field !== null); + if (issueFields.length > 0) { + params.issue_fields = issueFields; + } + const result = await callTool("issue_write", params); if (result.isError) { @@ -215,7 +1094,104 @@ function CreateIssueApp() { } finally { setIsSubmitting(false); } - }, [title, body, owner, repo, isUpdateMode, issueNumber, toolInput, callTool, setModelContext]); + }, [ + title, + body, + owner, + repo, + selectedLabels, + selectedAssignees, + selectedMilestone, + selectedIssueType, + isUpdateMode, + issueNumber, + stateReason, + duplicateOf, + prefilledStateChange, + fieldValues, + issueFieldsByName, + toolInput, + callTool, + setModelContext, + ]); + + // Filtered items for dropdowns + const filteredLabels = useMemo(() => { + if (!labelsFilter) return availableLabels; + const lowerFilter = labelsFilter.toLowerCase(); + return availableLabels.filter((l) => + l.text.toLowerCase().includes(lowerFilter) + ); + }, [availableLabels, labelsFilter]); + + const filteredAssignees = useMemo(() => { + if (!assigneesFilter) return availableAssignees; + const lowerFilter = assigneesFilter.toLowerCase(); + return availableAssignees.filter((a) => + a.text.toLowerCase().includes(lowerFilter) + ); + }, [availableAssignees, assigneesFilter]); + + const selectedStateReason = stateReasonOptions.find((option) => option.value === stateReason) || stateReasonOptions[0]; + + const renderIssueFieldInput = (field: IssueFieldItem) => { + const fieldValue = fieldValues[field.name] || {}; + + if (field.data_type === "single_select") { + const selectedOptionName = fieldValue.cleared ? undefined : fieldValue.optionName; + const selectedOption = field.options.find((option) => option.name === selectedOptionName); + return ( + + + + {selectedOption ? selectedOption.name : "Select option"} + + + + {field.options.length === 0 ? ( + No options available + ) : ( + field.options.map((option) => ( + updateIssueFieldValue(field.name, { optionName: option.name })} + > + + + + {option.name} + + )) + )} + + + + + ); + } + + return ( + updateIssueFieldValue(field.name, { value: e.target.value })} + block + contrast + sx={{ flex: 1 }} + /> + ); + }; const body_node = (() => { if (appError) { @@ -241,6 +1217,7 @@ function CreateIssueApp() { owner={owner} repo={repo} submittedTitle={title} + submittedLabels={selectedLabels} isUpdate={isUpdateMode} openLink={openLink} /> @@ -256,7 +1233,7 @@ function CreateIssueApp() { bg="canvas.subtle" p={3} > - {/* Header */} + {/* Repository picker */} - - + + + span:last-child": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }} + > + {selectedRepo ? selectedRepo.fullName : "Select repository"} + + + + + setRepoFilter(e.target.value)} + sx={{ width: "100%" }} + size="small" + autoFocus + /> + + + {repoSearchLoading ? ( + + + + ) : repoSearchResults.length > 0 ? ( + repoSearchResults.map((r) => ( + { + setSelectedRepo(r); + setRepoFilter(""); + // Clear metadata when switching repos + setAvailableLabels([]); + setSelectedLabels([]); + setAvailableAssignees([]); + setSelectedAssignees([]); + setAvailableMilestones([]); + setSelectedMilestone(null); + setAvailableIssueTypes([]); + setSelectedIssueType(null); + setAvailableIssueFields([]); + setFieldValues({}); + }} + > + + {r.isPrivate ? : } + + {r.fullName} + + )) + ) : selectedRepo ? ( + setRepoFilter("")} + > + + {selectedRepo.isPrivate ? : } + + {selectedRepo.fullName} + + ) : ( + + + Type to search repositories... + + + )} + + + - - {isUpdateMode ? `Update issue #${issueNumber}` : "New issue"} - - - {owner}/{repo} - {/* Error banner */} @@ -314,11 +1358,344 @@ function CreateIssueApp() { /> - {/* Submit button */} - + {/* Metadata section */} + + {/* Labels dropdown */} + + + Labels + {selectedLabels.length > 0 && ( + {selectedLabels.length} + )} + + + + setLabelsFilter(e.target.value)} + size="small" + block + /> + + + {labelsLoading ? ( + + Loading... + + ) : filteredLabels.length === 0 ? ( + No labels available + ) : ( + filteredLabels.map((label) => ( + l.id === label.id)} + onSelect={() => { + setSelectedLabels((prev) => + prev.some((l) => l.id === label.id) + ? prev.filter((l) => l.id !== label.id) + : [...prev, label] + ); + }} + > + + + + {label.text} + + )) + )} + + + + + {/* Assignees dropdown */} + + + Assignees + {selectedAssignees.length > 0 && ( + {selectedAssignees.length} + )} + + + + setAssigneesFilter(e.target.value)} + size="small" + block + /> + + + {assigneesLoading ? ( + + Loading... + + ) : filteredAssignees.length === 0 ? ( + No assignees available + ) : ( + filteredAssignees.map((assignee) => ( + a.id === assignee.id)} + onSelect={() => { + setSelectedAssignees((prev) => + prev.some((a) => a.id === assignee.id) + ? prev.filter((a) => a.id !== assignee.id) + : [...prev, assignee] + ); + }} + > + {assignee.text} + + )) + )} + + + + + {/* Milestones dropdown */} + + + {selectedMilestone ? selectedMilestone.text : "Milestone"} + + + + {milestonesLoading ? ( + + Loading... + + ) : availableMilestones.length === 0 ? ( + No milestones + ) : ( + <> + {selectedMilestone && ( + setSelectedMilestone(null)} + > + Clear selection + + )} + {availableMilestones.map((milestone) => ( + setSelectedMilestone(milestone)} + > + {milestone.text} + {milestone.description && ( + + {milestone.description} + + )} + + ))} + + )} + + + + + {/* Issue Types dropdown */} + + + {selectedIssueType ? selectedIssueType.text : "Type"} + + + + {issueTypesLoading ? ( + + Loading... + + ) : availableIssueTypes.length === 0 ? ( + No issue types + ) : ( + <> + {selectedIssueType && ( + setSelectedIssueType(null)} + > + Clear selection + + )} + {availableIssueTypes.map((type) => ( + setSelectedIssueType(type)} + > + {type.text} + + ))} + + )} + + + + + + {/* Fields section */} + {availableIssueFields.length > 0 && ( + + + Fields + + + {availableIssueFields.map((field) => { + const fieldValue = fieldValues[field.name]; + const hasFieldValue = + fieldValue && + !fieldValue.cleared && + (fieldValue.optionName !== undefined || + (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(", ")} + + )} + {selectedMilestone && ( + Milestone: {selectedMilestone.text} + )} + + )} + + {/* State and submit actions */} + + {isUpdateMode && ( + + {currentState === "open" ? ( + <> + + + + + {selectedStateReason.label} + + + + {stateReasonOptions.map((option) => ( + setStateReason(option.value)} + > + {option.label} + {option.description} + + ))} + + + + + {stateReason === "duplicate" && ( + + Duplicate of + setDuplicateOf(e.target.value)} + size="small" + sx={{ width: 140 }} + /> + + )} + + ) : ( + + )} + + )} + + + + + + + setIsDraft(e.target.checked)} /> + Mark as draft + + + + + reviewers + + + {selectedReviewers.length === 0 ? ( + "No reviewers" + ) : ( + <> + Reviewers + {selectedReviewers.length} + + )} + + + + setReviewersFilter(e.target.value)} + size="small" + block + /> + + + {reviewersLoading ? ( + Loading... + ) : filteredReviewers.length === 0 ? ( + No reviewers available + ) : ( + filteredReviewers.map((reviewer) => ( + r.id === reviewer.id)} + onSelect={() => { + setSelectedReviewers((prev) => + prev.some((r) => r.id === reviewer.id) + ? prev.filter((r) => r.id !== reviewer.id) + : [...prev, reviewer] + ); + }} + > + + {reviewer.kind === "user" ? ( + reviewer.avatar ? ( + + ) : ( + + ) + ) : ( + + )} + + {reviewer.text} + + )) + )} + + + + {selectedReviewers.length > 0 && ( + + {selectedReviewers.map((reviewer) => ( + + ))} + + )} + + + + + setMaintainerCanModify(e.target.checked)} /> + Allow maintainer edits + + + + + + )} + + + ); +} + +createRoot(document.getElementById("root")!).render( + + + +); diff --git a/ui/src/apps/pr-edit/index.html b/ui/src/apps/pr-edit/index.html new file mode 100644 index 0000000000..9fa60aa992 --- /dev/null +++ b/ui/src/apps/pr-edit/index.html @@ -0,0 +1,12 @@ + + + + + + Edit pull request + + +
+ + + diff --git a/ui/src/apps/pr-write/App.tsx b/ui/src/apps/pr-write/App.tsx index 245753a1bc..769523d41b 100644 --- a/ui/src/apps/pr-write/App.tsx +++ b/ui/src/apps/pr-write/App.tsx @@ -1,4 +1,4 @@ -import { StrictMode, useState, useCallback, useEffect } from "react"; +import { StrictMode, useState, useCallback, useEffect, useMemo } from "react"; import { createRoot } from "react-dom/client"; import { Box, @@ -12,11 +12,18 @@ import { ActionList, Checkbox, ButtonGroup, + CounterLabel, + Label, } from "@primer/react"; import { GitPullRequestIcon, CheckCircleIcon, + RepoIcon, + LockIcon, + GitBranchIcon, TriangleDownIcon, + PersonIcon, + PeopleIcon, } from "@primer/octicons-react"; import { AppProvider } from "../../components/AppProvider"; import { useMcpApp } from "../../hooks/useMcpApp"; @@ -31,6 +38,33 @@ interface PRResult { URL?: string; } +interface RepositoryItem { + id: string; + owner: string; + name: string; + fullName: string; + isPrivate: boolean; +} + +interface BranchItem { + name: string; + protected: boolean; +} + +type ReviewerItem = { kind: "user" | "team"; id: string; text: string; avatar?: string; org?: string }; + +function reviewerFromValue(value: string): ReviewerItem { + if (value.includes("/")) { + const [org, slug] = value.split("/", 2); + return { kind: "team", id: `${org}/${slug}`, text: `${org}/${slug}`, org }; + } + return { kind: "user", id: value, text: value }; +} + +function reviewerValue(reviewer: ReviewerItem): string { + return reviewer.kind === "team" ? reviewer.id : reviewer.text; +} + function SuccessView({ pr, owner, @@ -133,32 +167,231 @@ function CreatePRApp() { const [error, setError] = useState(null); const [successPR, setSuccessPR] = useState(null); + // Branch state + const [availableBranches, setAvailableBranches] = useState([]); + const [baseBranch, setBaseBranch] = useState(""); + const [headBranch, setHeadBranch] = useState(""); + const [branchesLoading, setBranchesLoading] = useState(false); + const [baseFilter, setBaseFilter] = useState(""); + const [headFilter, setHeadFilter] = useState(""); + + // Options const [isDraft, setIsDraft] = useState(false); const [maintainerCanModify, setMaintainerCanModify] = useState(true); + const [availableReviewers, setAvailableReviewers] = useState([]); + const [selectedReviewers, setSelectedReviewers] = useState([]); + const [reviewersLoading, setReviewersLoading] = useState(false); + const [reviewersFilter, setReviewersFilter] = useState(""); + + // Repository state + const [selectedRepo, setSelectedRepo] = useState(null); + const [repoSearchResults, setRepoSearchResults] = useState([]); + const [repoSearchLoading, setRepoSearchLoading] = useState(false); + const [repoFilter, setRepoFilter] = useState(""); const { app, error: appError, toolInput, callTool, hostContext, setModelContext, openLink } = useMcpApp({ appName: "github-mcp-server-create-pull-request", }); - const owner = (toolInput?.owner as string) || ""; - const repo = (toolInput?.repo as string) || ""; - const head = (toolInput?.head as string) || ""; - const base = (toolInput?.base as string) || ""; + const owner = selectedRepo?.owner || (toolInput?.owner as string) || ""; + const repo = selectedRepo?.name || (toolInput?.repo as string) || ""; const [submittedTitle, setSubmittedTitle] = useState(""); + // Reset all transient form/result state when toolInput changes (new invocation). + // Without this, the SuccessView from a previous submit stays visible and stale + // form values bleed through because the prefill effect below only sets when + // toolInput has truthy values and never clears. The repo is re-initialized from + // the new invocation here (rather than in a separate effect) so it isn't wiped + // by this reset. + useEffect(() => { + setTitle(""); + setBody(""); + setHeadBranch(""); + setBaseBranch(""); + setIsDraft(false); + setMaintainerCanModify(true); + setSuccessPR(null); + setError(null); + setSubmittedTitle(""); + // Clear branch list and filters so a new invocation doesn't briefly show stale + // branches from the previous repo (or allow selecting invalid options) before the + // new repo's ui_get branches call resolves. + setAvailableBranches([]); + setBaseFilter(""); + setHeadFilter(""); + setAvailableReviewers([]); + setSelectedReviewers([]); + setReviewersFilter(""); + if (toolInput?.owner && toolInput?.repo) { + setSelectedRepo({ + id: `${toolInput.owner}/${toolInput.repo}`, + owner: toolInput.owner as string, + name: toolInput.repo as string, + fullName: `${toolInput.owner}/${toolInput.repo}`, + isPrivate: false, + }); + } else { + setSelectedRepo(null); + } + }, [toolInput]); + // Pre-fill from toolInput useEffect(() => { if (toolInput?.title) setTitle(toolInput.title as string); if (toolInput?.body) setBody(toolInput.body as string); + if (toolInput?.head) setHeadBranch(toolInput.head as string); + if (toolInput?.base) setBaseBranch(toolInput.base as string); if (toolInput?.draft) setIsDraft(toolInput.draft as boolean); if (toolInput?.maintainer_can_modify !== undefined) { setMaintainerCanModify(toolInput.maintainer_can_modify as boolean); } + if (Array.isArray(toolInput?.reviewers)) { + setSelectedReviewers((toolInput.reviewers as string[]).map(reviewerFromValue)); + } }, [toolInput]); + // Search repositories + useEffect(() => { + if (!app || !repoFilter.trim()) { + setRepoSearchResults([]); + return; + } + + const searchRepos = async () => { + setRepoSearchLoading(true); + try { + const result = await callTool("search_repositories", { query: repoFilter, perPage: 10 }); + if (result && !result.isError && result.content) { + const textContent = result.content.find((c) => c.type === "text"); + if (textContent && textContent.type === "text" && textContent.text) { + const data = JSON.parse(textContent.text); + const repos = (data.repositories || data.items || []).map( + (r: { id?: number; owner?: { login?: string } | string; name?: string; full_name?: string; private?: boolean }) => ({ + id: String(r.id || r.full_name), + owner: typeof r.owner === 'string' ? r.owner : r.owner?.login || r.full_name?.split('/')[0] || '', + name: r.name || '', + fullName: r.full_name || '', + isPrivate: r.private || false, + }) + ); + setRepoSearchResults(repos); + } + } + } catch (e) { + console.error("Failed to search repositories:", e); + } finally { + setRepoSearchLoading(false); + } + }; + + const debounce = setTimeout(searchRepos, 300); + return () => clearTimeout(debounce); + }, [app, callTool, repoFilter]); + + // Load branches and reviewers when repo is selected + useEffect(() => { + if (!owner || !repo || !app) return; + + const loadBranches = async () => { + setBranchesLoading(true); + try { + const result = await callTool("ui_get", { method: "branches", owner, repo }); + 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( + (b: { name: string; protected?: boolean }) => ({ name: b.name, protected: b.protected || false }) + ); + setAvailableBranches(branches); + if (branches.length > 0) { + const defaultBranch = branches.find((b: BranchItem) => b.name === 'main' || b.name === 'master'); + // Functional update so a base branch already prefilled from + // toolInput.base (or chosen by the user) isn't overwritten by a + // stale closure value captured before the request resolved. + if (defaultBranch) setBaseBranch((prev) => prev || defaultBranch.name); + } + } + } + } catch (e) { + console.error("Failed to load branches:", e); + } finally { + setBranchesLoading(false); + } + }; + + const loadReviewers = async () => { + setReviewersLoading(true); + try { + const result = await callTool("ui_get", { method: "reviewers", owner, repo }); + 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 users = (data.users || []).map( + (u: { login: string; avatar_url?: string }) => ({ + kind: "user" as const, + id: u.login, + text: u.login, + avatar: u.avatar_url, + }) + ); + const teams = (data.teams || []).map( + (t: { slug: string; name?: string; org: string }) => ({ + kind: "team" as const, + id: `${t.org}/${t.slug}`, + text: `${t.org}/${t.slug}`, + org: t.org, + }) + ); + setAvailableReviewers([...users, ...teams]); + } + } + } catch (e) { + console.error("Failed to load reviewers:", e); + } finally { + setReviewersLoading(false); + } + }; + + loadBranches(); + loadReviewers(); + }, [owner, repo, app, callTool]); + + useEffect(() => { + if (availableReviewers.length === 0) return; + setSelectedReviewers((prev) => + prev.map((reviewer) => + availableReviewers.find((available) => available.id === reviewer.id || available.text === reviewer.text) || reviewer + ) + ); + }, [availableReviewers]); + + // Filters + const filteredBaseBranches = useMemo(() => { + if (!baseFilter.trim()) return availableBranches; + return availableBranches.filter((b) => b.name.toLowerCase().includes(baseFilter.toLowerCase())); + }, [availableBranches, baseFilter]); + + const filteredHeadBranches = useMemo(() => { + if (!headFilter.trim()) return availableBranches; + return availableBranches.filter((b) => b.name.toLowerCase().includes(headFilter.toLowerCase())); + }, [availableBranches, headFilter]); + + const filteredReviewers = useMemo(() => { + if (!reviewersFilter.trim()) return availableReviewers; + const lowerFilter = reviewersFilter.toLowerCase(); + return availableReviewers.filter((reviewer) => + reviewer.text.toLowerCase().includes(lowerFilter) || reviewer.id.toLowerCase().includes(lowerFilter) + ); + }, [availableReviewers, reviewersFilter]); + const handleSubmit = useCallback(async () => { if (!title.trim()) { setError("Title is required"); return; } if (!owner || !repo) { setError("Repository information not available"); return; } + if (!baseBranch) { setError("Base branch is required"); return; } + if (!headBranch) { setError("Head branch is required"); return; } + if (baseBranch === headBranch) { setError("Base and head branches cannot be the same"); return; } setIsSubmitting(true); setError(null); @@ -170,10 +403,11 @@ function CreatePRApp() { owner, repo, title: title.trim(), body: body.trim(), - head, - base, + head: headBranch, + base: baseBranch, draft: isDraft, maintainer_can_modify: maintainerCanModify, + reviewers: selectedReviewers.map(reviewerValue), _ui_submitted: true }); @@ -204,7 +438,7 @@ function CreatePRApp() { } finally { setIsSubmitting(false); } - }, [title, body, owner, repo, head, base, isDraft, maintainerCanModify, toolInput, callTool, setModelContext]); + }, [title, body, owner, repo, baseBranch, headBranch, isDraft, maintainerCanModify, selectedReviewers, toolInput, callTool, setModelContext]); if (successPR) { return ( @@ -242,7 +476,7 @@ function CreatePRApp() { bg="canvas.subtle" p={3} > - {/* Header */} + {/* Repository picker */} - - + + + span:last-child": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }} + > + {selectedRepo ? selectedRepo.fullName : "Select repository"} + + + + + setRepoFilter(e.target.value)} + sx={{ width: "100%" }} + size="small" + autoFocus + /> + + + {repoSearchLoading ? ( + + + + ) : repoSearchResults.length > 0 ? ( + repoSearchResults.map((r) => ( + { + setSelectedRepo(r); + setRepoFilter(""); + setAvailableBranches([]); + setBaseBranch(""); + setHeadBranch(""); + setAvailableReviewers([]); + setSelectedReviewers([]); + setReviewersFilter(""); + }} + > + + {r.isPrivate ? : } + + {r.fullName} + + )) + ) : selectedRepo ? ( + setRepoFilter("")}> + + {selectedRepo.isPrivate ? : } + + {selectedRepo.fullName} + + ) : ( + + Type to search repositories... + + )} + + + + + + + {/* Branch selectors */} + + + base + + span": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}> + {baseBranch || "Select base"} + + + + + setBaseFilter(e.target.value)} + size="small" + block + /> + + + {branchesLoading ? ( + Loading... + ) : filteredBaseBranches.length === 0 ? ( + No branches found + ) : ( + filteredBaseBranches.map((branch) => ( + { setBaseBranch(branch.name); setBaseFilter(""); }} + > + {branch.name} + {branch.protected && } + + )) + )} + + + + + + + + + compare + + span": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}> + {headBranch || "Select head"} + + + + + setHeadFilter(e.target.value)} + size="small" + block + /> + + + {branchesLoading ? ( + Loading... + ) : filteredHeadBranches.length === 0 ? ( + No branches found + ) : ( + filteredHeadBranches.map((branch) => ( + { setHeadBranch(branch.name); setHeadFilter(""); }} + > + {branch.name} + + )) + )} + + + - New pull request - - {owner}/{repo} - - {head && base && ( - - {base} ← {head} - - )} {/* Error banner */} @@ -290,9 +659,93 @@ function CreatePRApp() { + {/* Reviewers */} + + reviewers + + + {selectedReviewers.length === 0 ? ( + "No reviewers" + ) : ( + <> + Reviewers + {selectedReviewers.length} + + )} + + + + setReviewersFilter(e.target.value)} + size="small" + block + /> + + + {reviewersLoading ? ( + Loading... + ) : filteredReviewers.length === 0 ? ( + No reviewers available + ) : ( + filteredReviewers.map((reviewer) => ( + r.id === reviewer.id)} + onSelect={() => { + setSelectedReviewers((prev) => + prev.some((r) => r.id === reviewer.id) + ? prev.filter((r) => r.id !== reviewer.id) + : [...prev, reviewer] + ); + }} + > + + {reviewer.kind === "user" ? ( + reviewer.avatar ? ( + + ) : ( + + ) + ) : ( + + )} + + {reviewer.text} + + )) + )} + + + + {selectedReviewers.length > 0 && ( + + {selectedReviewers.map((reviewer) => ( + + ))} + + )} + + {/* Options and Submit */} - + setMaintainerCanModify(e.target.checked)} /> Allow maintainer edits @@ -301,7 +754,7 @@ function CreatePRApp() {