Skip to content

Commit 67950de

Browse files
Merge branch 'main' into feature/http-listen-address
2 parents 0f8d1fa + d27540f commit 67950de

17 files changed

Lines changed: 736 additions & 40 deletions

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1204,7 +1204,7 @@ The following sets of tools are available:
12041204
- `description`: Repository description (string, optional)
12051205
- `name`: Repository name (string, required)
12061206
- `organization`: Organization to create the repository in (omit to create in your personal account) (string, optional)
1207-
- `private`: Whether repo should be private (boolean, optional)
1207+
- `private`: Whether the repository should be private. Defaults to true (private) when omitted. (boolean, optional)
12081208

12091209
- **delete_file** - Delete file
12101210
- **Required OAuth Scopes**: `repo`

cmd/github-mcp-server/generate_docs.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,8 @@ func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) {
257257
}
258258
sort.Strings(paramNames)
259259

260+
conditional := inventory.ConditionalSchemaPropertyDescriptions()
261+
260262
for i, propName := range paramNames {
261263
prop := schema.Properties[propName]
262264
required := slices.Contains(schema.Required, propName)
@@ -282,7 +284,11 @@ func writeToolDoc(buf *strings.Builder, tool inventory.ServerTool) {
282284
// Indent any continuation lines in the description to maintain markdown formatting
283285
description := indentMultilineDescription(prop.Description, " ")
284286

285-
fmt.Fprintf(buf, " - `%s`: %s (%s, %s)", propName, description, typeStr, requiredStr)
287+
if cond, isConditional := conditional[propName]; isConditional {
288+
fmt.Fprintf(buf, " - `%s`: %s (%s, %s, conditional — %s)", propName, description, typeStr, requiredStr, cond)
289+
} else {
290+
fmt.Fprintf(buf, " - `%s`: %s (%s, %s)", propName, description, typeStr, requiredStr)
291+
}
286292
if i < len(paramNames)-1 {
287293
buf.WriteString("\n")
288294
}

docs/feature-flags.md

Lines changed: 2 additions & 0 deletions
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, conditional — only visible to clients that advertise MCP App UI support)
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, conditional — only visible to clients that advertise MCP App UI support)
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)

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, conditional — only visible to clients that advertise MCP App UI support)
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, conditional — only visible to clients that advertise MCP App UI support)
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__/create_repository.snap

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@
2222
"type": "string"
2323
},
2424
"private": {
25-
"description": "Whether repo should be private",
25+
"default": true,
26+
"description": "Whether the repository should be private. Defaults to true (private) when omitted.",
2627
"type": "boolean"
2728
}
2829
},

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: 45 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1763,6 +1763,7 @@ var issueWriteFormParams = map[string]struct{}{
17631763
"title": {},
17641764
"body": {},
17651765
"issue_number": {},
1766+
"show_ui": {},
17661767
"_ui_submitted": {},
17671768
}
17681769

@@ -1907,6 +1908,17 @@ Options are:
19071908
Required: []string{"field_name"},
19081909
},
19091910
},
1911+
// show_ui is hidden from clients that do not advertise MCP App
1912+
// UI support. The strip happens per-request in
1913+
// inventory.ToolsForRegistration; it is present in the static
1914+
// schema (and therefore in toolsnaps and the feature-flag /
1915+
// insiders docs) so the UI-capable surface is fully
1916+
// documented. It is intentionally not in the main README,
1917+
// which renders the stripped (non-UI) schema.
1918+
"show_ui": {
1919+
Type: "boolean",
1920+
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.",
1921+
},
19101922
},
19111923
Required: []string{"method", "owner", "repo"},
19121924
},
@@ -1928,13 +1940,19 @@ Options are:
19281940
}
19291941

19301942
// When MCP Apps are enabled and the client supports UI, route the
1931-
// call to the interactive form unless it is itself a form submission
1932-
// (the UI sends _ui_submitted=true) or it carries parameters the form
1933-
// cannot represent (e.g. labels, assignees or issue_fields). Those
1934-
// must be applied directly so their values aren't silently dropped.
1943+
// call to the interactive form unless:
1944+
// - it is itself a form submission (the UI sends _ui_submitted=true),
1945+
// - the caller explicitly asked to skip the UI (show_ui=false), or
1946+
// - it carries parameters the form cannot represent (e.g. labels,
1947+
// assignees or issue_fields). Those must be applied directly so
1948+
// their values aren't silently dropped.
19351949
uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted")
1950+
showUI, err := OptionalBoolParamWithDefault(args, "show_ui", true)
1951+
if err != nil {
1952+
return utils.NewToolResultError(err.Error()), nil, nil
1953+
}
19361954

1937-
if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && !issueWriteHasNonFormParams(args) {
1955+
if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && showUI && !issueWriteHasNonFormParams(args) {
19381956
if method == "update" {
19391957
issueNumber, numErr := RequiredInt(args, "issue_number")
19401958
if numErr != nil {
@@ -2146,6 +2164,17 @@ Options are:
21462164
Type: "number",
21472165
Description: "Issue number that this issue is a duplicate of. Only used when state_reason is 'duplicate'.",
21482166
},
2167+
// show_ui is hidden from clients that do not advertise MCP App
2168+
// UI support. The strip happens per-request in
2169+
// inventory.ToolsForRegistration; it is present in the static
2170+
// schema (and therefore in toolsnaps and the feature-flag /
2171+
// insiders docs) so the UI-capable surface is fully
2172+
// documented. It is intentionally not in the main README,
2173+
// which renders the stripped (non-UI) schema.
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+
},
21492178
},
21502179
Required: []string{"method", "owner", "repo"},
21512180
},
@@ -2167,13 +2196,19 @@ Options are:
21672196
}
21682197

21692198
// When MCP Apps are enabled and the client supports UI, route the
2170-
// call to the interactive form unless it is itself a form submission
2171-
// (the UI sends _ui_submitted=true) or it carries parameters the form
2172-
// cannot represent (e.g. labels, assignees or issue_fields). Those
2173-
// 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.
21742205
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+
}
21752210

2176-
if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && !issueWriteHasNonFormParams(args) {
2211+
if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && showUI && !issueWriteHasNonFormParams(args) {
21772212
if method == "update" {
21782213
issueNumber, numErr := RequiredInt(args, "issue_number")
21792214
if numErr != nil {

pkg/github/issues_test.go

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

17991880
func Test_issueWriteHasNonFormParams(t *testing.T) {
@@ -1806,6 +1887,8 @@ func Test_issueWriteHasNonFormParams(t *testing.T) {
18061887
}{
18071888
{name: "no params", args: map[string]any{}, want: false},
18081889
{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},
1890+
{name: "show_ui true is a form param", args: map[string]any{"title": "t", "show_ui": true}, want: false},
1891+
{name: "show_ui false is a form param", args: map[string]any{"title": "t", "show_ui": false}, want: false},
18091892
{name: "labels present", args: map[string]any{"title": "t", "labels": []any{"bug"}}, want: true},
18101893
{name: "assignees present", args: map[string]any{"title": "t", "assignees": []any{"octocat"}}, want: true},
18111894
{name: "milestone present", args: map[string]any{"title": "t", "milestone": float64(2)}, want: true},
@@ -1825,6 +1908,56 @@ func Test_issueWriteHasNonFormParams(t *testing.T) {
18251908
}
18261909
}
18271910

1911+
// Test_issueWriteSchemaClassification fails when a schema property is added
1912+
// without classifying it as either form-resendable (issueWriteFormParams) or
1913+
// known-non-form (knownNonForm below). Without this guard, an unclassified
1914+
// property would silently flip UI gating: form-incompatible fields would
1915+
// stop tripping the safety-net bypass and the form would drop their values.
1916+
func Test_issueWriteSchemaClassification(t *testing.T) {
1917+
t.Parallel()
1918+
1919+
// Schema properties the MCP App form cannot represent — their presence
1920+
// must trigger the safety-net bypass via issueWriteHasNonFormParams.
1921+
knownNonForm := map[string]struct{}{
1922+
"assignees": {},
1923+
"labels": {},
1924+
"milestone": {},
1925+
"type": {},
1926+
"state": {},
1927+
"state_reason": {},
1928+
"duplicate_of": {},
1929+
"issue_fields": {}, // only on the FF-enabled IssueWrite variant
1930+
}
1931+
1932+
cases := []struct {
1933+
name string
1934+
tool inventory.ServerTool
1935+
}{
1936+
{name: "IssueWrite", tool: IssueWrite(translations.NullTranslationHelper)},
1937+
{name: "LegacyIssueWrite", tool: LegacyIssueWrite(translations.NullTranslationHelper)},
1938+
}
1939+
1940+
for _, tc := range cases {
1941+
t.Run(tc.name, func(t *testing.T) {
1942+
t.Parallel()
1943+
schema, ok := tc.tool.Tool.InputSchema.(*jsonschema.Schema)
1944+
require.True(t, ok, "InputSchema should be *jsonschema.Schema")
1945+
1946+
for prop := range schema.Properties {
1947+
_, isForm := issueWriteFormParams[prop]
1948+
_, isNonForm := knownNonForm[prop]
1949+
1950+
assert.Falsef(t, isForm && isNonForm,
1951+
"property %q is classified as both form-resendable and non-form — pick one", prop)
1952+
assert.Truef(t, isForm || isNonForm,
1953+
"property %q in %s schema is unclassified — add it to issueWriteFormParams (pkg/github/issues.go) "+
1954+
"if the MCP App form can carry it on submit, otherwise add it to the knownNonForm allowlist in this test",
1955+
prop, tc.name)
1956+
}
1957+
})
1958+
}
1959+
}
1960+
18281961
func Test_ListIssues(t *testing.T) {
18291962
// Verify tool definition
18301963
serverTool := ListIssues(translations.NullTranslationHelper)

0 commit comments

Comments
 (0)