From 4311e7b2d54d1e2438d5d9b6771e032a83e87702 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 21 Jul 2026 11:00:36 -0700 Subject: [PATCH] Add MCP App form deferral opt-out Allow clients to keep MCP App views enabled while making form-backed write tools execute directly when explicitly configured. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/server-configuration.md | 11 ++++++++ pkg/github/feature_flags.go | 5 ++++ pkg/github/feature_flags_test.go | 11 ++++++++ pkg/github/ui_capability.go | 12 +++++---- pkg/github/ui_capability_test.go | 45 ++++++++++++++++++++++++++++++++ pkg/http/server_test.go | 12 +++++++++ 6 files changed, 91 insertions(+), 5 deletions(-) diff --git a/docs/server-configuration.md b/docs/server-configuration.md index 2342664c3..500c4bb86 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -396,6 +396,17 @@ See [Insiders Features](./insiders-features.md) for a full list of what's availa MCP Apps is enabled by [Insiders Mode](#insiders-mode), or independently via the `remote_mcp_ui_apps` feature flag. +To keep MCP App result views enabled while making write tools execute directly +instead of first opening an interactive form, also enable the +`mcp_apps_disable_form_deferral` feature flag. For the remote server, send both +flags in the request header: + +```http +X-MCP-Features: remote_mcp_ui_apps,mcp_apps_disable_form_deferral +``` + +For the local server, pass both flags to `--features`. + **Supported tools:** | Tool | Description | diff --git a/pkg/github/feature_flags.go b/pkg/github/feature_flags.go index 442f427cb..b0652c334 100644 --- a/pkg/github/feature_flags.go +++ b/pkg/github/feature_flags.go @@ -5,6 +5,10 @@ import "slices" // MCPAppsFeatureFlag is the feature flag name for MCP Apps (interactive UI forms). const MCPAppsFeatureFlag = "remote_mcp_ui_apps" +// MCPAppsDisableFormDeferralFeatureFlag disables handing write-tool calls off +// to MCP App forms while preserving MCP Apps UI metadata and result views. +const MCPAppsDisableFormDeferralFeatureFlag = "mcp_apps_disable_form_deferral" + // FeatureFlagCSVOutput is the feature flag name for CSV output on list tools. const FeatureFlagCSVOutput = "csv_output" @@ -37,6 +41,7 @@ const FeatureFlagFieldsParam = "fields_param" // This is the single source of truth for which flags are user-controllable. var AllowedFeatureFlags = []string{ MCPAppsFeatureFlag, + MCPAppsDisableFormDeferralFeatureFlag, FeatureFlagCSVOutput, FeatureFlagIFCLabels, FeatureFlagIssuesGranular, diff --git a/pkg/github/feature_flags_test.go b/pkg/github/feature_flags_test.go index aa4a6c9d2..30f2b5612 100644 --- a/pkg/github/feature_flags_test.go +++ b/pkg/github/feature_flags_test.go @@ -155,6 +155,11 @@ func TestResolveFeatureFlags(t *testing.T) { enabledFeatures: []string{MCPAppsFeatureFlag}, expectedFlags: []string{MCPAppsFeatureFlag}, }, + { + name: "MCP Apps form deferral can be disabled directly", + enabledFeatures: []string{MCPAppsDisableFormDeferralFeatureFlag}, + expectedFlags: []string{MCPAppsDisableFormDeferralFeatureFlag}, + }, { name: "fields param is not enabled by default", enabledFeatures: nil, @@ -183,6 +188,12 @@ func TestResolveFeatureFlags(t *testing.T) { insidersMode: true, unexpectedFlags: []string{FeatureFlagIFCLabels}, }, + { + name: "insiders mode does not disable MCP Apps form deferral", + enabledFeatures: nil, + insidersMode: true, + unexpectedFlags: []string{MCPAppsDisableFormDeferralFeatureFlag}, + }, { name: "ifc_labels can be directly enabled", enabledFeatures: []string{FeatureFlagIFCLabels}, diff --git a/pkg/github/ui_capability.go b/pkg/github/ui_capability.go index a850db0c9..3de6a39b7 100644 --- a/pkg/github/ui_capability.go +++ b/pkg/github/ui_capability.go @@ -63,13 +63,15 @@ func hasNonFormParams(args map[string]any, formParams map[string]struct{}) bool // shared by the form-backed write tools (create_pull_request, // update_pull_request, issue_write). It reports whether a call should be handed // off to its MCP App form instead of executing now: defer only when MCP Apps -// are enabled, the client can render UI, the call is not itself a form -// submission, and every supplied parameter can be represented by the form -// (formParams is the tool's form-parameter allowlist). When it returns false -// the handler executes directly; the host may still render the tool's view, -// which renders the result rather than an input form. +// are enabled, form deferral has not been disabled, the client can render UI, +// the call is not itself a form submission, and every supplied parameter can +// be represented by the form (formParams is the tool's form-parameter +// allowlist). When it returns false the handler executes directly; the host may +// still render the tool's view, which renders the result rather than an input +// form. func shouldDeferToForm(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args map[string]any, formParams map[string]struct{}) bool { return deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && + !deps.IsFeatureEnabled(ctx, MCPAppsDisableFormDeferralFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted(args) && !hasNonFormParams(args, formParams) diff --git a/pkg/github/ui_capability_test.go b/pkg/github/ui_capability_test.go index 72275d7c4..1c49ee15b 100644 --- a/pkg/github/ui_capability_test.go +++ b/pkg/github/ui_capability_test.go @@ -85,3 +85,48 @@ func Test_clientSupportsUI_fromContext(t *testing.T) { assert.False(t, clientSupportsUI(context.Background(), nil)) }) } + +func Test_shouldDeferToForm_featureFlags(t *testing.T) { + t.Parallel() + + ctx := ghcontext.WithUISupport(context.Background(), true) + args := map[string]any{"owner": "octocat"} + formParams := map[string]struct{}{"owner": {}} + + tests := []struct { + name string + enabledFlags []string + want bool + }{ + { + name: "MCP Apps enabled defers to form", + enabledFlags: []string{MCPAppsFeatureFlag}, + want: true, + }, + { + name: "form deferral disabled executes directly", + enabledFlags: []string{ + MCPAppsFeatureFlag, + MCPAppsDisableFormDeferralFeatureFlag, + }, + want: false, + }, + { + name: "form deferral opt-out does not enable MCP Apps", + enabledFlags: []string{MCPAppsDisableFormDeferralFeatureFlag}, + want: false, + }, + { + name: "MCP Apps disabled executes directly", + want: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + deps := BaseDeps{featureChecker: featureCheckerFor(tc.enabledFlags...)} + assert.Equal(t, tc.want, shouldDeferToForm(ctx, deps, nil, args, formParams)) + }) + } +} diff --git a/pkg/http/server_test.go b/pkg/http/server_test.go index b509876d9..d96f8a76e 100644 --- a/pkg/http/server_test.go +++ b/pkg/http/server_test.go @@ -38,6 +38,12 @@ func TestCreateHTTPFeatureChecker(t *testing.T) { headerFeatures: []string{github.MCPAppsFeatureFlag}, wantEnabled: true, }, + { + name: "MCP Apps form deferral opt-out accepted from header", + flagName: github.MCPAppsDisableFormDeferralFeatureFlag, + headerFeatures: []string{github.MCPAppsDisableFormDeferralFeatureFlag}, + wantEnabled: true, + }, { name: "unknown flag in header is ignored", flagName: "unknown_flag", @@ -74,6 +80,12 @@ func TestCreateHTTPFeatureChecker(t *testing.T) { insidersMode: true, wantEnabled: true, }, + { + name: "insiders mode does not disable MCP Apps form deferral", + flagName: github.MCPAppsDisableFormDeferralFeatureFlag, + insidersMode: true, + wantEnabled: false, + }, { name: "static feature is enabled without header", staticFeatures: []string{github.FeatureFlagCSVOutput},