Skip to content

Commit e54bc78

Browse files
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 2bd162a ("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>
1 parent 2a5d38a commit e54bc78

12 files changed

Lines changed: 583 additions & 34 deletions

docs/feature-flags.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ runtime behavior (such as output formatting) won't appear here.
4444
- `maintainer_can_modify`: Allow maintainer edits (boolean, optional)
4545
- `owner`: Repository owner (string, required)
4646
- `repo`: Repository name (string, required)
47+
- `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)
4748
- `title`: PR title (string, required)
4849

4950
- **get_me** - Get my user profile
@@ -66,6 +67,7 @@ runtime behavior (such as output formatting) won't appear here.
6667
- `milestone`: Milestone number (number, optional)
6768
- `owner`: Repository owner (string, required)
6869
- `repo`: Repository name (string, required)
70+
- `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)
6971
- `state`: New state (string, optional)
7072
- `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional)
7173
- `title`: Issue title (string, optional)
@@ -240,7 +242,7 @@ runtime behavior (such as output formatting) won't appear here.
240242
- `owner`: Repository owner (username or organization) (string, required)
241243
- `pullNumber`: The pull request number (number, required)
242244
- `repo`: Repository name (string, required)
243-
- `reviewers`: GitHub usernames to request reviews from (string[], required)
245+
- `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], required)
244246

245247
- **resolve_review_thread** - Resolve Review Thread
246248
- **Required OAuth Scopes**: `repo`

docs/insiders-features.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ The list below is generated from the Go source. It covers tool **inventory and s
3838
- `maintainer_can_modify`: Allow maintainer edits (boolean, optional)
3939
- `owner`: Repository owner (string, required)
4040
- `repo`: Repository name (string, required)
41+
- `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)
4142
- `title`: PR title (string, required)
4243

4344
- **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
6061
- `milestone`: Milestone number (number, optional)
6162
- `owner`: Repository owner (string, required)
6263
- `repo`: Repository name (string, required)
64+
- `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)
6365
- `state`: New state (string, optional)
6466
- `state_reason`: Reason for the state change. Ignored unless state is changed. (string, optional)
6567
- `title`: Issue title (string, optional)

pkg/github/__toolsnaps__/create_pull_request.snap

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@
4242
"description": "Repository name",
4343
"type": "string"
4444
},
45+
"show_ui": {
46+
"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.",
47+
"type": "boolean"
48+
},
4549
"title": {
4650
"description": "PR title",
4751
"type": "string"

pkg/github/__toolsnaps__/issue_write.snap

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@
6060
"description": "Repository name",
6161
"type": "string"
6262
},
63+
"show_ui": {
64+
"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.",
65+
"type": "boolean"
66+
},
6367
"state": {
6468
"description": "New state",
6569
"enum": [

pkg/github/__toolsnaps__/issue_write_ff_remote_mcp_issue_fields.snap

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@
9696
"description": "Repository name",
9797
"type": "string"
9898
},
99+
"show_ui": {
100+
"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.",
101+
"type": "boolean"
102+
},
99103
"state": {
100104
"description": "New state",
101105
"enum": [

pkg/github/issues.go

Lines changed: 41 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1774,6 +1774,7 @@ var issueWriteFormParams = map[string]struct{}{
17741774
"title": {},
17751775
"body": {},
17761776
"issue_number": {},
1777+
"show_ui": {},
17771778
"_ui_submitted": {},
17781779
}
17791780

@@ -1918,6 +1919,15 @@ Options are:
19181919
Required: []string{"field_name"},
19191920
},
19201921
},
1922+
// show_ui is hidden from clients that do not advertise MCP App
1923+
// UI support. The strip happens per-request in
1924+
// inventory.ToolsForRegistration; it is present in the static
1925+
// schema (and therefore in toolsnaps / README) so the UI-capable
1926+
// surface is fully documented.
1927+
"show_ui": {
1928+
Type: "boolean",
1929+
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.",
1930+
},
19211931
},
19221932
Required: []string{"method", "owner", "repo"},
19231933
},
@@ -1939,13 +1949,19 @@ Options are:
19391949
}
19401950

19411951
// When MCP Apps are enabled and the client supports UI, route the
1942-
// call to the interactive form unless it is itself a form submission
1943-
// (the UI sends _ui_submitted=true) or it carries parameters the form
1944-
// cannot represent (e.g. labels, assignees or issue_fields). Those
1945-
// must be applied directly so their values aren't silently dropped.
1952+
// call to the interactive form unless:
1953+
// - it is itself a form submission (the UI sends _ui_submitted=true),
1954+
// - the caller explicitly asked to skip the UI (show_ui=false), or
1955+
// - it carries parameters the form cannot represent (e.g. labels,
1956+
// assignees or issue_fields). Those must be applied directly so
1957+
// their values aren't silently dropped.
19461958
uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted")
1959+
showUI, err := OptionalBoolParamWithDefault(args, "show_ui", true)
1960+
if err != nil {
1961+
return utils.NewToolResultError(err.Error()), nil, nil
1962+
}
19471963

1948-
if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && !issueWriteHasNonFormParams(args) {
1964+
if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && showUI && !issueWriteHasNonFormParams(args) {
19491965
if method == "update" {
19501966
issueNumber, numErr := RequiredInt(args, "issue_number")
19511967
if numErr != nil {
@@ -2150,6 +2166,15 @@ Options are:
21502166
Type: "number",
21512167
Description: "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.",
21522168
},
2169+
// show_ui is hidden from clients that do not advertise MCP App
2170+
// UI support. The strip happens per-request in
2171+
// inventory.ToolsForRegistration; it is present in the static
2172+
// schema (and therefore in toolsnaps / README) so the UI-capable
2173+
// surface is fully documented.
2174+
"show_ui": {
2175+
Type: "boolean",
2176+
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.",
2177+
},
21532178
},
21542179
Required: []string{"method", "owner", "repo"},
21552180
},
@@ -2171,13 +2196,19 @@ Options are:
21712196
}
21722197

21732198
// When MCP Apps are enabled and the client supports UI, route the
2174-
// call to the interactive form unless it is itself a form submission
2175-
// (the UI sends _ui_submitted=true) or it carries parameters the form
2176-
// cannot represent (e.g. labels, assignees or issue_fields). Those
2177-
// must be applied directly so their values aren't silently dropped.
2199+
// call to the interactive form unless:
2200+
// - it is itself a form submission (the UI sends _ui_submitted=true),
2201+
// - the caller explicitly asked to skip the UI (show_ui=false), or
2202+
// - it carries parameters the form cannot represent (e.g. labels,
2203+
// assignees or issue_fields). Those must be applied directly so
2204+
// their values aren't silently dropped.
21782205
uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted")
2206+
showUI, err := OptionalBoolParamWithDefault(args, "show_ui", true)
2207+
if err != nil {
2208+
return utils.NewToolResultError(err.Error()), nil, nil
2209+
}
21792210

2180-
if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && !issueWriteHasNonFormParams(args) {
2211+
if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && showUI && !issueWriteHasNonFormParams(args) {
21812212
if method == "update" {
21822213
issueNumber, numErr := RequiredInt(args, "issue_number")
21832214
if numErr != nil {

pkg/github/issues_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1794,6 +1794,86 @@ func Test_IssueWrite_MCPAppsFeature_UIGate(t *testing.T) {
17941794
assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1",
17951795
"labels call should execute directly and return issue URL")
17961796
})
1797+
1798+
t.Run("UI client with show_ui=false skips form and executes directly", func(t *testing.T) {
1799+
// show_ui=false is the explicit, model-facing way to opt out of the
1800+
// form. It must bypass the form even when every other condition would
1801+
// route the call there (UI capability, MCP Apps flag on, no
1802+
// _ui_submitted, only form params present).
1803+
request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{
1804+
"method": "create",
1805+
"owner": "owner",
1806+
"repo": "repo",
1807+
"title": "Test",
1808+
"show_ui": false,
1809+
})
1810+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
1811+
require.NoError(t, err)
1812+
1813+
textContent := getTextResult(t, result)
1814+
assert.NotContains(t, textContent.Text, "Ready to create an issue",
1815+
"show_ui=false should skip UI form")
1816+
assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1",
1817+
"show_ui=false call should execute directly and return issue URL")
1818+
})
1819+
1820+
t.Run("UI client with show_ui=true returns form message", func(t *testing.T) {
1821+
// show_ui=true is the explicit, redundant-with-the-default way to ask
1822+
// for the form. It must still route through the form and must not be
1823+
// treated as a non-form parameter that would trigger the safety-net
1824+
// bypass.
1825+
request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{
1826+
"method": "create",
1827+
"owner": "owner",
1828+
"repo": "repo",
1829+
"title": "Test",
1830+
"show_ui": true,
1831+
})
1832+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
1833+
require.NoError(t, err)
1834+
1835+
textContent := getTextResult(t, result)
1836+
assert.Contains(t, textContent.Text, "Ready to create an issue",
1837+
"show_ui=true should still route through the form")
1838+
})
1839+
1840+
t.Run("UI client with show_ui=false and _ui_submitted=true executes directly", func(t *testing.T) {
1841+
// _ui_submitted and show_ui=false are two ways to say "execute
1842+
// directly". When both are set there must be no conflict — the call
1843+
// still executes directly.
1844+
request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{
1845+
"method": "create",
1846+
"owner": "owner",
1847+
"repo": "repo",
1848+
"title": "Test",
1849+
"show_ui": false,
1850+
"_ui_submitted": true,
1851+
})
1852+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
1853+
require.NoError(t, err)
1854+
1855+
textContent := getTextResult(t, result)
1856+
assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1",
1857+
"show_ui=false + _ui_submitted should execute directly")
1858+
})
1859+
1860+
t.Run("non-UI client with show_ui=false executes directly (no regression)", func(t *testing.T) {
1861+
// show_ui is irrelevant when the client does not support UI; the call
1862+
// must execute directly exactly as it does today.
1863+
request := createMCPRequest(map[string]any{
1864+
"method": "create",
1865+
"owner": "owner",
1866+
"repo": "repo",
1867+
"title": "Test",
1868+
"show_ui": false,
1869+
})
1870+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
1871+
require.NoError(t, err)
1872+
1873+
textContent := getTextResult(t, result)
1874+
assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1",
1875+
"non-UI client should execute directly regardless of show_ui")
1876+
})
17971877
}
17981878

17991879
func Test_issueWriteHasNonFormParams(t *testing.T) {
@@ -1806,6 +1886,8 @@ func Test_issueWriteHasNonFormParams(t *testing.T) {
18061886
}{
18071887
{name: "no params", args: map[string]any{}, want: false},
18081888
{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},
1889+
{name: "show_ui true is a form param", args: map[string]any{"title": "t", "show_ui": true}, want: false},
1890+
{name: "show_ui false is a form param", args: map[string]any{"title": "t", "show_ui": false}, want: false},
18091891
{name: "labels present", args: map[string]any{"title": "t", "labels": []any{"bug"}}, want: true},
18101892
{name: "assignees present", args: map[string]any{"title": "t", "assignees": []any{"octocat"}}, want: true},
18111893
{name: "milestone present", args: map[string]any{"title": "t", "milestone": float64(2)}, want: true},

pkg/github/pullrequests.go

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,7 @@ var pullRequestWriteFormParams = map[string]struct{}{
556556
"base": {},
557557
"draft": {},
558558
"maintainer_can_modify": {},
559+
"show_ui": {},
559560
"_ui_submitted": {},
560561
}
561562

@@ -627,6 +628,15 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo
627628
Type: "boolean",
628629
Description: "Allow maintainer edits",
629630
},
631+
// show_ui is hidden from clients that do not advertise MCP App
632+
// UI support. The strip happens per-request in
633+
// inventory.ToolsForRegistration; it is present in the static
634+
// schema (and therefore in toolsnaps / README) so the UI-capable
635+
// surface is fully documented.
636+
"show_ui": {
637+
Type: "boolean",
638+
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.",
639+
},
630640
},
631641
Required: []string{"owner", "repo", "title", "head", "base"},
632642
},
@@ -643,13 +653,18 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo
643653
}
644654

645655
// When MCP Apps are enabled and the client supports UI, route the
646-
// call to the interactive form unless it is itself a form submission
647-
// (the UI sends _ui_submitted=true) or it carries parameters the form
648-
// cannot represent. Those must be applied directly so their values
649-
// aren't silently dropped.
656+
// call to the interactive form unless:
657+
// - it is itself a form submission (the UI sends _ui_submitted=true),
658+
// - the caller explicitly asked to skip the UI (show_ui=false), or
659+
// - it carries parameters the form cannot represent. Those must be
660+
// applied directly so their values aren't silently dropped.
650661
uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted")
662+
showUI, err := OptionalBoolParamWithDefault(args, "show_ui", true)
663+
if err != nil {
664+
return utils.NewToolResultError(err.Error()), nil, nil
665+
}
651666

652-
if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && !pullRequestWriteHasNonFormParams(args) {
667+
if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && showUI && !pullRequestWriteHasNonFormParams(args) {
653668
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
654669
}
655670

0 commit comments

Comments
 (0)