From 32fb7438a977f9ed68b376d6155c364417749c7a Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Thu, 2 Apr 2026 13:26:34 +0100 Subject: [PATCH 1/7] migrate mcp apps support from insiders mode to feature flag with insiders opt-in --- docs/insiders-features.md | 23 +--------- docs/server-configuration.md | 62 ++++++++++++++++++++++++++- internal/ghmcp/server.go | 22 +++++++--- pkg/github/feature_flags.go | 6 +++ pkg/github/issues.go | 4 +- pkg/github/issues_test.go | 31 +++++++++----- pkg/github/pullrequests.go | 4 +- pkg/github/pullrequests_test.go | 18 +++++--- pkg/http/handler.go | 14 ++++++ pkg/http/server.go | 11 ++++- pkg/inventory/builder.go | 66 +++++++++++++++++++++------- pkg/inventory/registry_test.go | 76 ++++++++++++++++++++++----------- 12 files changed, 242 insertions(+), 95 deletions(-) diff --git a/docs/insiders-features.md b/docs/insiders-features.md index 911257ae4..1ed5b90f3 100644 --- a/docs/insiders-features.md +++ b/docs/insiders-features.md @@ -20,25 +20,4 @@ For configuration examples, see the [Server Configuration Guide](./server-config --- -## MCP Apps - -[MCP Apps](https://modelcontextprotocol.io/docs/extensions/apps) is an extension to the Model Context Protocol that enables servers to deliver interactive user interfaces to end users. Instead of returning plain text that the LLM must interpret and relay, tools can render forms, profiles, and dashboards right in the chat using MCP Apps. - -This means you can interact with GitHub visually: fill out forms to create issues, see user profiles with avatars, open pull requests — all without leaving your agent chat. - -### Supported tools - -The following tools have MCP Apps UIs: - -| Tool | Description | -|------|-------------| -| `get_me` | Displays your GitHub user profile with avatar, bio, and stats in a rich card | -| `issue_write` | Opens an interactive form to create or update issues | -| `create_pull_request` | Provides a full PR creation form to create a pull request (or a draft pull request) | - -### Client requirements - -MCP Apps requires a host that supports the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps). Currently tested and working with: - -- **VS Code Insiders** — enable via the `chat.mcp.apps.enabled` setting -- **Visual Studio Code** — enable via the `chat.mcp.apps.enabled` setting +_There are currently no insiders-only features. [MCP Apps](./server-configuration.md#mcp-apps) has graduated to a feature flag (`remote_mcp_ui_apps`) and can be enabled independently._ diff --git a/docs/server-configuration.md b/docs/server-configuration.md index 87d48e01e..6fa3f2491 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -14,6 +14,7 @@ We currently support the following ways in which the GitHub MCP Server can be co | Dynamic Mode | Not available | `--dynamic-toolsets` flag or `GITHUB_DYNAMIC_TOOLSETS` env var | | Lockdown Mode | `X-MCP-Lockdown` header | `--lockdown-mode` flag or `GITHUB_LOCKDOWN_MODE` env var | | Insiders Mode | `X-MCP-Insiders` header or `/insiders` URL | `--insiders` flag or `GITHUB_INSIDERS` env var | +| Feature Flags | `X-MCP-Features` header | `--features` flag | | Scope Filtering | Always enabled | Always enabled | | Server Name/Title | Not available | `GITHUB_MCP_SERVER_NAME` / `GITHUB_MCP_SERVER_TITLE` env vars or `github-mcp-server-config.json` | @@ -390,7 +391,10 @@ Lockdown mode ensures the server only surfaces content in public repositories fr **Best for:** Users who want early access to experimental features and new tools before they reach general availability. -Insiders Mode unlocks experimental features, such as [MCP Apps](./insiders-features.md#mcp-apps) support. We created this mode to have a way to roll out experimental features and collect feedback. So if you are using Insiders, please don't hesitate to share your feedback with us! Features in Insiders Mode may change, evolve, or be removed based on user feedback. +Insiders Mode unlocks experimental features. We created this mode to have a way to roll out experimental features and collect feedback. So if you are using Insiders, please don't hesitate to share your feedback with us! Features in Insiders Mode may change, evolve, or be removed based on user feedback. + +> [!NOTE] +> Insiders mode also enables the `remote_mcp_ui_apps` feature flag for backward compatibility. See [MCP Apps](#mcp-apps) below. @@ -443,6 +447,62 @@ See [Insiders Features](./insiders-features.md) for a full list of what's availa --- +### MCP Apps + +[MCP Apps](https://modelcontextprotocol.io/docs/extensions/apps) is an extension to the Model Context Protocol that enables servers to deliver interactive user interfaces to end users. Instead of returning plain text that the LLM must interpret and relay, tools can render forms, profiles, and dashboards right in the chat. + +MCP Apps is controlled by the `remote_mcp_ui_apps` feature flag and can be enabled independently of insiders mode. + +**Supported tools:** + +| Tool | Description | +|------|-------------| +| `get_me` | Displays your GitHub user profile with avatar, bio, and stats in a rich card | +| `issue_write` | Opens an interactive form to create or update issues | +| `create_pull_request` | Provides a full PR creation form to create a pull request (or a draft pull request) | + +**Client requirements:** MCP Apps requires a host that supports the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps). Currently tested with VS Code (`chat.mcp.apps.enabled` setting). + +
Remote ServerLocal Server
+ + + + + +
Remote ServerLocal Server
+ +```json +{ + "type": "http", + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "X-MCP-Features": "remote_mcp_ui_apps" + } +} +``` + + + +```json +{ + "type": "stdio", + "command": "go", + "args": [ + "run", + "./cmd/github-mcp-server", + "stdio", + "--features=remote_mcp_ui_apps" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}" + } +} +``` + +
+ +--- + ### Scope Filtering **Automatic feature:** The server handles OAuth scopes differently depending on authentication type: diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 5dfaf596c..4e45b751e 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "os/signal" + "slices" "strings" "syscall" "time" @@ -114,8 +115,13 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se return nil, fmt.Errorf("failed to create GitHub clients: %w", err) } - // Create feature checker - featureChecker := createFeatureChecker(cfg.EnabledFeatures) + // Create feature checker — insiders mode transitionally enables remote_mcp_ui_apps + enabledFeatures := cfg.EnabledFeatures + if cfg.InsidersMode && !slices.Contains(enabledFeatures, github.MCPAppsFeatureFlag) { + enabledFeatures = append(slices.Clone(enabledFeatures), github.MCPAppsFeatureFlag) + } + featureChecker := createFeatureChecker(enabledFeatures) + mcpAppsEnabled := slices.Contains(enabledFeatures, github.MCPAppsFeatureFlag) // Create dependencies for tool handlers obs, err := observability.NewExporters(cfg.Logger, metrics.NewNoopMetrics()) @@ -145,7 +151,8 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se WithExcludeTools(cfg.ExcludeTools). WithServerInstructions(). WithFeatureChecker(featureChecker). - WithInsidersMode(cfg.InsidersMode) + WithInsidersMode(cfg.InsidersMode). + WithMCPApps(mcpAppsEnabled) // Apply token scope filtering if scopes are known (for PAT filtering) if cfg.TokenScopes != nil { @@ -162,10 +169,11 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se return nil, fmt.Errorf("failed to create GitHub MCP server: %w", err) } - // Register MCP App UI resources if available (requires running script/build-ui). - // We check availability to allow Insiders mode to work for non-UI features - // even when UI assets haven't been built. - if cfg.InsidersMode && github.UIAssetsAvailable() { + // Register MCP App UI resources if the remote_mcp_ui_apps feature flag is enabled + // and UI assets are available (requires running script/build-ui). + // We check availability to allow the feature flag to be enabled without + // requiring a UI build (graceful degradation). + if mcpAppsEnabled && github.UIAssetsAvailable() { github.RegisterUIResources(ghServer) } diff --git a/pkg/github/feature_flags.go b/pkg/github/feature_flags.go index fd06a659b..b5dd7e7f2 100644 --- a/pkg/github/feature_flags.go +++ b/pkg/github/feature_flags.go @@ -1,5 +1,11 @@ package github +// MCPAppsFeatureFlag is the feature flag name that enables MCP Apps +// (interactive UI forms) for supported tools. When enabled, tools like +// get_me, issue_write, and create_pull_request can render rich UI via +// the MCP Apps extension instead of plain text responses. +const MCPAppsFeatureFlag = "remote_mcp_ui_apps" + // FeatureFlags defines runtime feature toggles that adjust tool behavior. type FeatureFlags struct { LockdownMode bool diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 05af64cab..0e20393f3 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1073,12 +1073,12 @@ Options are: return utils.NewToolResultError(err.Error()), nil, nil } - // When insiders mode is enabled and the client supports MCP Apps UI, + // When the MCP Apps feature flag is enabled and the client supports MCP Apps UI, // check if this is a UI form submission. The UI sends _ui_submitted=true // to distinguish form submissions from LLM calls. uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") - if deps.GetFlags(ctx).InsidersMode && clientSupportsUI(ctx, req) && !uiSubmitted { + if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted { if method == "update" { // Skip the UI form when a state change is requested because // the form only handles title/body editing and would lose the diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index d06721be7..24d42f1cc 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -932,9 +932,9 @@ func Test_CreateIssue(t *testing.T) { } } -// Test_IssueWrite_InsidersMode_UIGate verifies the insiders mode UI gate +// Test_IssueWrite_MCPApps_UIGate verifies the MCP Apps feature flag UI gate // behavior: UI clients get a form message, non-UI clients execute directly. -func Test_IssueWrite_InsidersMode_UIGate(t *testing.T) { +func Test_IssueWrite_MCPApps_UIGate(t *testing.T) { t.Parallel() mockIssue := &github.Issue{ @@ -949,11 +949,17 @@ func Test_IssueWrite_InsidersMode_UIGate(t *testing.T) { PostReposIssuesByOwnerByRepo: mockResponse(t, http.StatusCreated, mockIssue), })) - deps := BaseDeps{ - Client: client, - GQLClient: githubv4.NewClient(nil), - Flags: FeatureFlags{InsidersMode: true}, + mcpAppsChecker := func(_ context.Context, flag string) (bool, error) { + return flag == MCPAppsFeatureFlag, nil } + deps := NewBaseDeps( + client, githubv4.NewClient(nil), nil, nil, + translations.NullTranslationHelper, + FeatureFlags{}, + 0, + mcpAppsChecker, + stubExporters(), + ) handler := serverTool.Handler(deps) t.Run("UI client without _ui_submitted returns form message", func(t *testing.T) { @@ -1066,11 +1072,14 @@ func Test_IssueWrite_InsidersMode_UIGate(t *testing.T) { ), )) - closeDeps := BaseDeps{ - Client: closeClient, - GQLClient: closeGQLClient, - Flags: FeatureFlags{InsidersMode: true}, - } + closeDeps := NewBaseDeps( + closeClient, closeGQLClient, nil, nil, + translations.NullTranslationHelper, + FeatureFlags{}, + 0, + mcpAppsChecker, + stubExporters(), + ) closeHandler := serverTool.Handler(closeDeps) request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 731db4931..69c02f159 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -601,12 +601,12 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultError(err.Error()), nil, nil } - // When insiders mode is enabled and the client supports MCP Apps UI, + // When the MCP Apps feature flag is enabled and the client supports MCP Apps UI, // check if this is a UI form submission. The UI sends _ui_submitted=true // to distinguish form submissions from LLM calls. uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") - if deps.GetFlags(ctx).InsidersMode && clientSupportsUI(ctx, req) && !uiSubmitted { + if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted { 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 801122dca..0febb2fc4 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -2312,9 +2312,9 @@ func Test_CreatePullRequest(t *testing.T) { } } -// Test_CreatePullRequest_InsidersMode_UIGate verifies the insiders mode UI gate +// Test_CreatePullRequest_MCPApps_UIGate verifies the MCP Apps feature flag UI gate // behavior: UI clients get a form message, non-UI clients execute directly. -func Test_CreatePullRequest_InsidersMode_UIGate(t *testing.T) { +func Test_CreatePullRequest_MCPApps_UIGate(t *testing.T) { t.Parallel() mockPR := &github.PullRequest{ @@ -2332,11 +2332,17 @@ func Test_CreatePullRequest_InsidersMode_UIGate(t *testing.T) { PostReposPullsByOwnerByRepo: mockResponse(t, http.StatusCreated, mockPR), })) - deps := BaseDeps{ - Client: client, - GQLClient: githubv4.NewClient(nil), - Flags: FeatureFlags{InsidersMode: true}, + mcpAppsChecker := func(_ context.Context, flag string) (bool, error) { + return flag == MCPAppsFeatureFlag, nil } + deps := NewBaseDeps( + client, githubv4.NewClient(nil), nil, nil, + translations.NullTranslationHelper, + FeatureFlags{}, + 0, + mcpAppsChecker, + stubExporters(), + ) handler := serverTool.Handler(deps) t.Run("UI client without _ui_submitted returns form message", func(t *testing.T) { diff --git a/pkg/http/handler.go b/pkg/http/handler.go index 2e828211d..d279f6ea7 100644 --- a/pkg/http/handler.go +++ b/pkg/http/handler.go @@ -5,6 +5,7 @@ import ( "errors" "log/slog" "net/http" + "slices" ghcontext "github.com/github/github-mcp-server/pkg/context" "github.com/github/github-mcp-server/pkg/github" @@ -261,6 +262,19 @@ func InventoryFiltersForRequest(r *http.Request, builder *inventory.Builder) *in builder = builder.WithReadOnly(true) } + insiders := ghcontext.IsInsidersMode(ctx) + if insiders { + builder = builder.WithInsidersMode(true) + } + + // Enable MCP Apps if the feature flag is present in the request headers + // or if insiders mode is active (transitional: insiders implies remote_mcp_ui_apps). + headerFeatures := ghcontext.GetHeaderFeatures(ctx) + mcpApps := slices.Contains(headerFeatures, github.MCPAppsFeatureFlag) || insiders + if mcpApps { + builder = builder.WithMCPApps(true) + } + toolsets := ghcontext.GetToolsets(ctx) tools := ghcontext.GetTools(ctx) diff --git a/pkg/http/server.go b/pkg/http/server.go index 55aed1c61..73b4a36ef 100644 --- a/pkg/http/server.go +++ b/pkg/http/server.go @@ -27,7 +27,9 @@ import ( // knownFeatureFlags are the feature flags that can be enabled via X-MCP-Features header. // Only these flags are accepted from headers. -var knownFeatureFlags = []string{} +var knownFeatureFlags = []string{ + github.MCPAppsFeatureFlag, +} type ServerConfig struct { // Version of the server @@ -212,7 +214,8 @@ func initGlobalToolScopeMap(t translations.TranslationHelperFunc) error { } // createHTTPFeatureChecker creates a feature checker that reads header features from context -// and validates them against the knownFeatureFlags whitelist +// and validates them against the knownFeatureFlags whitelist. +// It also handles transitional behavior where insiders mode implies remote_mcp_ui_apps. func createHTTPFeatureChecker() inventory.FeatureFlagChecker { // Pre-compute whitelist as set for O(1) lookup knownSet := make(map[string]bool, len(knownFeatureFlags)) @@ -224,6 +227,10 @@ func createHTTPFeatureChecker() inventory.FeatureFlagChecker { if knownSet[flag] && slices.Contains(ghcontext.GetHeaderFeatures(ctx), flag) { return true, nil } + // Transitional: insiders mode implies remote_mcp_ui_apps feature flag + if flag == github.MCPAppsFeatureFlag && ghcontext.IsInsidersMode(ctx) { + return true, nil + } return false, nil } } diff --git a/pkg/inventory/builder.go b/pkg/inventory/builder.go index d492e69b5..98df9166d 100644 --- a/pkg/inventory/builder.go +++ b/pkg/inventory/builder.go @@ -49,6 +49,7 @@ type Builder struct { filters []ToolFilter // filters to apply to all tools generateInstructions bool insidersMode bool + mcpApps bool } // NewBuilder creates a new Builder. @@ -155,14 +156,23 @@ func (b *Builder) WithExcludeTools(toolNames []string) *Builder { } // WithInsidersMode enables or disables insiders mode features. -// When insiders mode is disabled (default), UI metadata is removed from tools -// so clients won't attempt to load UI resources. +// When insiders mode is disabled (default), tools marked InsidersOnly are removed +// and insiders-only Meta keys are stripped. // Returns self for chaining. func (b *Builder) WithInsidersMode(enabled bool) *Builder { b.insidersMode = enabled return b } +// WithMCPApps enables or disables MCP Apps UI features. +// When disabled (default), the "ui" Meta key is stripped from tools +// so clients won't attempt to load UI resources. +// Returns self for chaining. +func (b *Builder) WithMCPApps(enabled bool) *Builder { + b.mcpApps = enabled + return b +} + // CreateExcludeToolsFilter creates a ToolFilter that excludes tools by name. // Any tool whose name appears in the excluded list will be filtered out. // The input slice should already be cleaned (trimmed, deduplicated). @@ -210,6 +220,11 @@ func (b *Builder) Build() (*Inventory, error) { tools = stripInsidersFeatures(b.tools) } + // When MCP Apps is disabled, strip UI metadata from tools + if !b.mcpApps { + tools = stripMCPAppsMetadata(tools) + } + r := &Inventory{ tools: tools, resourceTemplates: b.resourceTemplates, @@ -378,9 +393,9 @@ func (b *Builder) processToolsets() (map[ToolsetID]bool, []string, []ToolsetID, // insidersOnlyMetaKeys lists the Meta keys that are only available in insiders mode. // Add new experimental feature keys here to have them automatically stripped // when insiders mode is disabled. -var insidersOnlyMetaKeys = []string{ - "ui", // MCP Apps UI metadata -} +// Note: "ui" (MCP Apps) is now controlled by the remote_mcp_ui_apps feature flag via +// WithMCPApps and mcpAppsMetaKeys, not by insiders mode. +var insidersOnlyMetaKeys = []string{} // stripInsidersFeatures removes insiders-only features from tools. // This includes removing tools marked with InsidersOnly and stripping @@ -392,7 +407,26 @@ func stripInsidersFeatures(tools []ServerTool) []ServerTool { if tool.InsidersOnly { continue } - if stripped := stripInsidersMetaFromTool(tool); stripped != nil { + if stripped := stripMetaKeys(tool, insidersOnlyMetaKeys); stripped != nil { + result = append(result, *stripped) + } else { + result = append(result, tool) + } + } + return result +} + +// mcpAppsMetaKeys lists the Meta keys controlled by the remote_mcp_ui_apps feature flag. +var mcpAppsMetaKeys = []string{ + "ui", // MCP Apps UI metadata +} + +// stripMCPAppsMetadata removes MCP Apps UI metadata from tools when the +// remote_mcp_ui_apps feature flag is not enabled. +func stripMCPAppsMetadata(tools []ServerTool) []ServerTool { + result := make([]ServerTool, 0, len(tools)) + for _, tool := range tools { + if stripped := stripMetaKeys(tool, mcpAppsMetaKeys); stripped != nil { result = append(result, *stripped) } else { result = append(result, tool) @@ -401,30 +435,30 @@ func stripInsidersFeatures(tools []ServerTool) []ServerTool { return result } -// stripInsidersMetaFromTool removes insiders-only Meta keys from a single tool. +// stripMetaKeys removes the specified Meta keys from a single tool. // Returns a modified copy if changes were made, nil otherwise. -func stripInsidersMetaFromTool(tool ServerTool) *ServerTool { - if tool.Tool.Meta == nil { +func stripMetaKeys(tool ServerTool, keys []string) *ServerTool { + if tool.Tool.Meta == nil || len(keys) == 0 { return nil } - // Check if any insiders-only keys exist - hasInsidersKeys := false - for _, key := range insidersOnlyMetaKeys { + // Check if any of the specified keys exist + hasKeys := false + for _, key := range keys { if tool.Tool.Meta[key] != nil { - hasInsidersKeys = true + hasKeys = true break } } - if !hasInsidersKeys { + if !hasKeys { return nil } - // Make a shallow copy and remove insiders-only keys + // Make a shallow copy and remove specified keys toolCopy := tool newMeta := make(map[string]any, len(tool.Tool.Meta)) for k, v := range tool.Tool.Meta { - if !slices.Contains(insidersOnlyMetaKeys, k) { + if !slices.Contains(keys, k) { newMeta[k] = v } } diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go index 207e65dba..45e861339 100644 --- a/pkg/inventory/registry_test.go +++ b/pkg/inventory/registry_test.go @@ -1874,24 +1874,24 @@ func TestWithInsidersMode_DisabledStripsUIMetadata(t *testing.T) { } } -func TestWithInsidersMode_EnabledPreservesUIMetadata(t *testing.T) { +func TestWithMCPApps_EnabledPreservesUIMetadata(t *testing.T) { uiData := map[string]any{"html": "
hello
"} toolWithUI := mockToolWithMeta("tool_with_ui", "toolset1", map[string]any{ "ui": uiData, "description": "kept", }) - // Insiders mode enabled - UI meta should be preserved + // MCP Apps enabled - UI meta should be preserved reg := mustBuild(t, NewBuilder(). SetTools([]ServerTool{toolWithUI}). WithToolsets([]string{"all"}). - WithInsidersMode(true)) + WithMCPApps(true)) available := reg.AvailableTools(context.Background()) require.Len(t, available, 1) // UI metadata should be preserved if available[0].Tool.Meta["ui"] == nil { - t.Errorf("Expected 'ui' meta to be preserved in insiders mode") + t.Errorf("Expected 'ui' meta to be preserved with MCP Apps enabled") } // Other metadata should also be preserved if available[0].Tool.Meta["description"] != "kept" { @@ -1933,14 +1933,14 @@ func TestWithInsidersMode_DisabledRemovesInsidersOnlyTools(t *testing.T) { require.Equal(t, "normal", available[0].Tool.Name) } -func TestWithInsidersMode_ToolsWithoutUIMetaUnaffected(t *testing.T) { +func TestWithMCPApps_ToolsWithoutUIMetaUnaffected(t *testing.T) { toolNoUI := mockToolWithMeta("tool_no_ui", "toolset1", map[string]any{ "description": "kept", "version": "1.0", }) toolNilMeta := mockTool("tool_nil_meta", "toolset1", true) - // Test with insiders disabled + // Test with MCP Apps disabled (default) - non-UI meta should be unaffected reg := mustBuild(t, NewBuilder(). SetTools([]ServerTool{toolNoUI, toolNilMeta}). WithToolsets([]string{"all"})) @@ -1973,8 +1973,8 @@ func TestWithInsidersMode_ToolsWithoutUIMetaUnaffected(t *testing.T) { } } -func TestWithInsidersMode_UIOnlyMetaBecomesNil(t *testing.T) { - // Tool with ONLY ui metadata - should become nil after stripping +func TestWithMCPApps_UIOnlyMetaBecomesNil(t *testing.T) { + // Tool with ONLY ui metadata - should become nil after stripping when MCP Apps is disabled toolUIOnly := mockToolWithMeta("tool_ui_only", "toolset1", map[string]any{ "ui": map[string]any{"html": "
hello
"}, }) @@ -1985,44 +1985,75 @@ func TestWithInsidersMode_UIOnlyMetaBecomesNil(t *testing.T) { available := reg.AvailableTools(context.Background()) require.Len(t, available, 1) - // Meta should be nil since ui was the only key + // Meta should be nil since ui was the only key and MCP Apps is off by default if available[0].Tool.Meta != nil { t.Errorf("Expected Meta to be nil after stripping only key, got %v", available[0].Tool.Meta) } } -func TestStripInsidersMetaFromTool(t *testing.T) { +func TestWithMCPApps_EnabledPreservesUIMeta(t *testing.T) { + // Tool with ui metadata - should be preserved when MCP Apps is enabled + toolWithUI := mockToolWithMeta("tool_with_ui", "toolset1", map[string]any{ + "ui": map[string]any{"html": "
hello
"}, + "description": "kept", + }) + + reg := mustBuild(t, NewBuilder(). + SetTools([]ServerTool{toolWithUI}). + WithToolsets([]string{"all"}). + WithMCPApps(true)) + available := reg.AvailableTools(context.Background()) + + require.Len(t, available, 1) + require.NotNil(t, available[0].Tool.Meta, "Meta should be preserved with MCP Apps enabled") + require.NotNil(t, available[0].Tool.Meta["ui"], "ui key should be preserved with MCP Apps enabled") + require.Equal(t, "kept", available[0].Tool.Meta["description"]) +} + +func TestStripMetaKeys(t *testing.T) { tests := []struct { name string meta map[string]any + keys []string expectChange bool expectedMeta map[string]any // nil means Meta should be nil }{ { name: "nil meta - no change", meta: nil, + keys: mcpAppsMetaKeys, expectChange: false, }, { - name: "no insiders keys - no change", + name: "no matching keys - no change", meta: map[string]any{"description": "test", "version": "1.0"}, + keys: mcpAppsMetaKeys, expectChange: false, }, { name: "ui key only - becomes nil", meta: map[string]any{"ui": "data"}, + keys: mcpAppsMetaKeys, expectChange: true, expectedMeta: nil, }, { name: "ui key with other keys - ui stripped", meta: map[string]any{"ui": "data", "description": "kept"}, + keys: mcpAppsMetaKeys, expectChange: true, expectedMeta: map[string]any{"description": "kept"}, }, { name: "ui is nil value - no change (nil value means key not present)", meta: map[string]any{"ui": nil, "description": "kept"}, + keys: mcpAppsMetaKeys, + expectChange: false, + }, + { + name: "empty keys list - no change", + meta: map[string]any{"ui": "data"}, + keys: []string{}, expectChange: false, }, } @@ -2030,7 +2061,7 @@ func TestStripInsidersMetaFromTool(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { tool := mockToolWithMeta("test", "toolset1", tt.meta) - result := stripInsidersMetaFromTool(tool) + result := stripMetaKeys(tool, tt.keys) if tt.expectChange { require.NotNil(t, result, "expected change but got nil") @@ -2050,14 +2081,14 @@ func TestStripInsidersMetaFromTool(t *testing.T) { } } -func TestStripInsidersFeatures(t *testing.T) { +func TestStripMCPAppsMetadata(t *testing.T) { tools := []ServerTool{ mockToolWithMeta("tool1", "toolset1", map[string]any{"ui": "data"}), mockToolWithMeta("tool2", "toolset1", map[string]any{"description": "kept"}), mockTool("tool3", "toolset1", true), // nil meta } - result := stripInsidersFeatures(tools) + result := stripMCPAppsMetadata(tools) require.Len(t, result, 3) @@ -2089,16 +2120,9 @@ func TestStripInsidersFeatures_RemovesInsidersOnlyTools(t *testing.T) { require.Equal(t, "normal2", result[1].Tool.Name) } -func TestInsidersOnlyMetaKeys_FutureAdditions(t *testing.T) { +func TestStripMetaKeys_MultipleKeys(t *testing.T) { // This test verifies the mechanism works for multiple keys - // If we add new experimental keys to insidersOnlyMetaKeys, they should be stripped - - // Save original and restore after test - originalKeys := insidersOnlyMetaKeys - defer func() { insidersOnlyMetaKeys = originalKeys }() - - // Add a hypothetical future experimental key - insidersOnlyMetaKeys = []string{"ui", "experimental_feature", "beta"} + keys := []string{"ui", "experimental_feature", "beta"} tool := mockToolWithMeta("test", "toolset1", map[string]any{ "ui": "ui data", @@ -2107,7 +2131,7 @@ func TestInsidersOnlyMetaKeys_FutureAdditions(t *testing.T) { "description": "kept", }) - result := stripInsidersMetaFromTool(tool) + result := stripMetaKeys(tool, keys) require.NotNil(t, result) require.NotNil(t, result.Tool.Meta) @@ -2117,12 +2141,12 @@ func TestInsidersOnlyMetaKeys_FutureAdditions(t *testing.T) { require.Equal(t, "kept", result.Tool.Meta["description"], "description should be preserved") } -func TestWithInsidersMode_DoesNotMutateOriginalTools(t *testing.T) { +func TestWithMCPApps_DoesNotMutateOriginalTools(t *testing.T) { originalMeta := map[string]any{"ui": "data", "description": "kept"} tool := mockToolWithMeta("test", "toolset1", originalMeta) tools := []ServerTool{tool} - // Build with insiders disabled - should strip ui + // Build with MCP Apps disabled (default) - should strip ui _ = mustBuild(t, NewBuilder().SetTools(tools).WithToolsets([]string{"all"})) // Original tool should be unchanged From d3ab0ea86081569036ca4b09f5a3b069af53c628 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Thu, 2 Apr 2026 15:18:54 +0100 Subject: [PATCH 2/7] Update pkg/inventory/registry_test.go Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- pkg/inventory/registry_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go index 45e861339..0a7bd51d9 100644 --- a/pkg/inventory/registry_test.go +++ b/pkg/inventory/registry_test.go @@ -2045,10 +2045,11 @@ func TestStripMetaKeys(t *testing.T) { expectedMeta: map[string]any{"description": "kept"}, }, { - name: "ui is nil value - no change (nil value means key not present)", + name: "ui is nil value - ui stripped", meta: map[string]any{"ui": nil, "description": "kept"}, keys: mcpAppsMetaKeys, - expectChange: false, + expectChange: true, + expectedMeta: map[string]any{"description": "kept"}, }, { name: "empty keys list - no change", From 7830fe62c3af43dadbb347cd82809eb1bc3559f5 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Tue, 7 Apr 2026 09:32:50 +0100 Subject: [PATCH 3/7] refactor: improve key existence check in stripMetaKeys function --- pkg/inventory/builder.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/inventory/builder.go b/pkg/inventory/builder.go index 98df9166d..b22cc0388 100644 --- a/pkg/inventory/builder.go +++ b/pkg/inventory/builder.go @@ -445,7 +445,7 @@ func stripMetaKeys(tool ServerTool, keys []string) *ServerTool { // Check if any of the specified keys exist hasKeys := false for _, key := range keys { - if tool.Tool.Meta[key] != nil { + if _, ok := tool.Tool.Meta[key]; ok { hasKeys = true break } From 2a252856b36612d7bf032b7cf0886d85c89dae3d Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Tue, 7 Apr 2026 16:34:06 +0100 Subject: [PATCH 4/7] merge in changes --- docs/server-configuration.md | 2 +- internal/ghmcp/server.go | 12 ++++-- pkg/github/feature_flags.go | 13 ++++-- pkg/http/handler.go | 9 +---- pkg/http/server.go | 12 ++++-- pkg/inventory/builder.go | 40 ------------------ pkg/inventory/registry_test.go | 74 +--------------------------------- pkg/inventory/server_tool.go | 4 -- 8 files changed, 31 insertions(+), 135 deletions(-) diff --git a/docs/server-configuration.md b/docs/server-configuration.md index 6fa3f2491..e852ca4db 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -394,7 +394,7 @@ Lockdown mode ensures the server only surfaces content in public repositories fr Insiders Mode unlocks experimental features. We created this mode to have a way to roll out experimental features and collect feedback. So if you are using Insiders, please don't hesitate to share your feedback with us! Features in Insiders Mode may change, evolve, or be removed based on user feedback. > [!NOTE] -> Insiders mode also enables the `remote_mcp_ui_apps` feature flag for backward compatibility. See [MCP Apps](#mcp-apps) below. +> Insiders mode enables a curated set of feature flags, including `remote_mcp_ui_apps`. See [MCP Apps](#mcp-apps) below. diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 4e45b751e..f6260950f 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -115,10 +115,15 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se return nil, fmt.Errorf("failed to create GitHub clients: %w", err) } - // Create feature checker — insiders mode transitionally enables remote_mcp_ui_apps + // Create feature checker — insiders mode expands InsidersFeatureFlags enabledFeatures := cfg.EnabledFeatures - if cfg.InsidersMode && !slices.Contains(enabledFeatures, github.MCPAppsFeatureFlag) { - enabledFeatures = append(slices.Clone(enabledFeatures), github.MCPAppsFeatureFlag) + if cfg.InsidersMode { + enabledFeatures = slices.Clone(enabledFeatures) + for _, flag := range github.InsidersFeatureFlags { + if !slices.Contains(enabledFeatures, flag) { + enabledFeatures = append(enabledFeatures, flag) + } + } } featureChecker := createFeatureChecker(enabledFeatures) mcpAppsEnabled := slices.Contains(enabledFeatures, github.MCPAppsFeatureFlag) @@ -151,7 +156,6 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se WithExcludeTools(cfg.ExcludeTools). WithServerInstructions(). WithFeatureChecker(featureChecker). - WithInsidersMode(cfg.InsidersMode). WithMCPApps(mcpAppsEnabled) // Apply token scope filtering if scopes are known (for PAT filtering) diff --git a/pkg/github/feature_flags.go b/pkg/github/feature_flags.go index b5dd7e7f2..dd3aeddcc 100644 --- a/pkg/github/feature_flags.go +++ b/pkg/github/feature_flags.go @@ -1,11 +1,16 @@ package github -// MCPAppsFeatureFlag is the feature flag name that enables MCP Apps -// (interactive UI forms) for supported tools. When enabled, tools like -// get_me, issue_write, and create_pull_request can render rich UI via -// the MCP Apps extension instead of plain text responses. +// MCPAppsFeatureFlag is the feature flag name for MCP Apps (interactive UI forms). const MCPAppsFeatureFlag = "remote_mcp_ui_apps" +// InsidersFeatureFlags is the list of feature flags that insiders mode enables. +// When insiders mode is active, all flags in this list are treated as enabled. +// This is the single source of truth for what "insiders" means in terms of +// feature flag expansion. +var InsidersFeatureFlags = []string{ + MCPAppsFeatureFlag, +} + // FeatureFlags defines runtime feature toggles that adjust tool behavior. type FeatureFlags struct { LockdownMode bool diff --git a/pkg/http/handler.go b/pkg/http/handler.go index d279f6ea7..15ec07955 100644 --- a/pkg/http/handler.go +++ b/pkg/http/handler.go @@ -262,15 +262,10 @@ func InventoryFiltersForRequest(r *http.Request, builder *inventory.Builder) *in builder = builder.WithReadOnly(true) } - insiders := ghcontext.IsInsidersMode(ctx) - if insiders { - builder = builder.WithInsidersMode(true) - } - // Enable MCP Apps if the feature flag is present in the request headers - // or if insiders mode is active (transitional: insiders implies remote_mcp_ui_apps). + // or if insiders mode is active (insiders expands InsidersFeatureFlags). headerFeatures := ghcontext.GetHeaderFeatures(ctx) - mcpApps := slices.Contains(headerFeatures, github.MCPAppsFeatureFlag) || insiders + mcpApps := slices.Contains(headerFeatures, github.MCPAppsFeatureFlag) || ghcontext.IsInsidersMode(ctx) if mcpApps { builder = builder.WithMCPApps(true) } diff --git a/pkg/http/server.go b/pkg/http/server.go index 73b4a36ef..86cad7b63 100644 --- a/pkg/http/server.go +++ b/pkg/http/server.go @@ -215,7 +215,7 @@ func initGlobalToolScopeMap(t translations.TranslationHelperFunc) error { // createHTTPFeatureChecker creates a feature checker that reads header features from context // and validates them against the knownFeatureFlags whitelist. -// It also handles transitional behavior where insiders mode implies remote_mcp_ui_apps. +// When insiders mode is active, InsidersFeatureFlags are also treated as enabled. func createHTTPFeatureChecker() inventory.FeatureFlagChecker { // Pre-compute whitelist as set for O(1) lookup knownSet := make(map[string]bool, len(knownFeatureFlags)) @@ -223,12 +223,18 @@ func createHTTPFeatureChecker() inventory.FeatureFlagChecker { knownSet[f] = true } + // Pre-compute insiders flags as set for O(1) lookup + insidersSet := make(map[string]bool, len(github.InsidersFeatureFlags)) + for _, f := range github.InsidersFeatureFlags { + insidersSet[f] = true + } + return func(ctx context.Context, flag string) (bool, error) { if knownSet[flag] && slices.Contains(ghcontext.GetHeaderFeatures(ctx), flag) { return true, nil } - // Transitional: insiders mode implies remote_mcp_ui_apps feature flag - if flag == github.MCPAppsFeatureFlag && ghcontext.IsInsidersMode(ctx) { + // Insiders mode enables all InsidersFeatureFlags + if insidersSet[flag] && ghcontext.IsInsidersMode(ctx) { return true, nil } return false, nil diff --git a/pkg/inventory/builder.go b/pkg/inventory/builder.go index b22cc0388..3c36ded1a 100644 --- a/pkg/inventory/builder.go +++ b/pkg/inventory/builder.go @@ -48,7 +48,6 @@ type Builder struct { featureChecker FeatureFlagChecker filters []ToolFilter // filters to apply to all tools generateInstructions bool - insidersMode bool mcpApps bool } @@ -155,15 +154,6 @@ func (b *Builder) WithExcludeTools(toolNames []string) *Builder { return b } -// WithInsidersMode enables or disables insiders mode features. -// When insiders mode is disabled (default), tools marked InsidersOnly are removed -// and insiders-only Meta keys are stripped. -// Returns self for chaining. -func (b *Builder) WithInsidersMode(enabled bool) *Builder { - b.insidersMode = enabled - return b -} - // WithMCPApps enables or disables MCP Apps UI features. // When disabled (default), the "ui" Meta key is stripped from tools // so clients won't attempt to load UI resources. @@ -214,11 +204,7 @@ func cleanTools(tools []string) []string { // (i.e., they don't exist in the tool set and are not deprecated aliases). // This ensures invalid tool configurations fail fast at build time. func (b *Builder) Build() (*Inventory, error) { - // When insiders mode is disabled, strip insiders-only features from tools tools := b.tools - if !b.insidersMode { - tools = stripInsidersFeatures(b.tools) - } // When MCP Apps is disabled, strip UI metadata from tools if !b.mcpApps { @@ -390,32 +376,6 @@ func (b *Builder) processToolsets() (map[ToolsetID]bool, []string, []ToolsetID, return enabledToolsets, unrecognized, allToolsetIDs, validIDs, defaultToolsetIDList, descriptions } -// insidersOnlyMetaKeys lists the Meta keys that are only available in insiders mode. -// Add new experimental feature keys here to have them automatically stripped -// when insiders mode is disabled. -// Note: "ui" (MCP Apps) is now controlled by the remote_mcp_ui_apps feature flag via -// WithMCPApps and mcpAppsMetaKeys, not by insiders mode. -var insidersOnlyMetaKeys = []string{} - -// stripInsidersFeatures removes insiders-only features from tools. -// This includes removing tools marked with InsidersOnly and stripping -// Meta keys listed in insidersOnlyMetaKeys from remaining tools. -func stripInsidersFeatures(tools []ServerTool) []ServerTool { - result := make([]ServerTool, 0, len(tools)) - for _, tool := range tools { - // Skip tools marked as insiders-only - if tool.InsidersOnly { - continue - } - if stripped := stripMetaKeys(tool, insidersOnlyMetaKeys); stripped != nil { - result = append(result, *stripped) - } else { - result = append(result, tool) - } - } - return result -} - // mcpAppsMetaKeys lists the Meta keys controlled by the remote_mcp_ui_apps feature flag. var mcpAppsMetaKeys = []string{ "ui", // MCP Apps UI metadata diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go index 0a7bd51d9..d3c68fbac 100644 --- a/pkg/inventory/registry_test.go +++ b/pkg/inventory/registry_test.go @@ -1853,13 +1853,13 @@ func mockToolWithMeta(name string, toolsetID string, meta map[string]any) Server ) } -func TestWithInsidersMode_DisabledStripsUIMetadata(t *testing.T) { +func TestWithMCPApps_DisabledStripsUIMetadata(t *testing.T) { toolWithUI := mockToolWithMeta("tool_with_ui", "toolset1", map[string]any{ "ui": map[string]any{"html": "
hello
"}, "description": "kept", }) - // Default: insiders mode is disabled - UI meta should be stripped + // Default: MCP Apps is disabled - UI meta should be stripped reg := mustBuild(t, NewBuilder().SetTools([]ServerTool{toolWithUI}).WithToolsets([]string{"all"})) available := reg.AvailableTools(context.Background()) @@ -1899,40 +1899,6 @@ func TestWithMCPApps_EnabledPreservesUIMetadata(t *testing.T) { } } -func TestWithInsidersMode_EnabledPreservesInsidersOnlyTools(t *testing.T) { - normalTool := mockTool("normal", "toolset1", true) - insidersTool := mockTool("insiders_only", "toolset1", true) - insidersTool.InsidersOnly = true - - // With insiders mode enabled, both tools should be available - reg := mustBuild(t, NewBuilder(). - SetTools([]ServerTool{normalTool, insidersTool}). - WithToolsets([]string{"all"}). - WithInsidersMode(true)) - available := reg.AvailableTools(context.Background()) - - require.Len(t, available, 2) - names := []string{available[0].Tool.Name, available[1].Tool.Name} - require.Contains(t, names, "normal") - require.Contains(t, names, "insiders_only") -} - -func TestWithInsidersMode_DisabledRemovesInsidersOnlyTools(t *testing.T) { - normalTool := mockTool("normal", "toolset1", true) - insidersTool := mockTool("insiders_only", "toolset1", true) - insidersTool.InsidersOnly = true - - // With insiders mode disabled, insiders-only tool should be removed - reg := mustBuild(t, NewBuilder(). - SetTools([]ServerTool{normalTool, insidersTool}). - WithToolsets([]string{"all"}). - WithInsidersMode(false)) - available := reg.AvailableTools(context.Background()) - - require.Len(t, available, 1) - require.Equal(t, "normal", available[0].Tool.Name) -} - func TestWithMCPApps_ToolsWithoutUIMetaUnaffected(t *testing.T) { toolNoUI := mockToolWithMeta("tool_no_ui", "toolset1", map[string]any{ "description": "kept", @@ -1991,25 +1957,6 @@ func TestWithMCPApps_UIOnlyMetaBecomesNil(t *testing.T) { } } -func TestWithMCPApps_EnabledPreservesUIMeta(t *testing.T) { - // Tool with ui metadata - should be preserved when MCP Apps is enabled - toolWithUI := mockToolWithMeta("tool_with_ui", "toolset1", map[string]any{ - "ui": map[string]any{"html": "
hello
"}, - "description": "kept", - }) - - reg := mustBuild(t, NewBuilder(). - SetTools([]ServerTool{toolWithUI}). - WithToolsets([]string{"all"}). - WithMCPApps(true)) - available := reg.AvailableTools(context.Background()) - - require.Len(t, available, 1) - require.NotNil(t, available[0].Tool.Meta, "Meta should be preserved with MCP Apps enabled") - require.NotNil(t, available[0].Tool.Meta["ui"], "ui key should be preserved with MCP Apps enabled") - require.Equal(t, "kept", available[0].Tool.Meta["description"]) -} - func TestStripMetaKeys(t *testing.T) { tests := []struct { name string @@ -2104,23 +2051,6 @@ func TestStripMCPAppsMetadata(t *testing.T) { require.Nil(t, result[2].Tool.Meta) } -func TestStripInsidersFeatures_RemovesInsidersOnlyTools(t *testing.T) { - // Create tools: one normal, one insiders-only, one normal - normalTool1 := mockTool("normal1", "toolset1", true) - insidersTool := mockTool("insiders_only", "toolset1", true) - insidersTool.InsidersOnly = true - normalTool2 := mockTool("normal2", "toolset1", true) - - tools := []ServerTool{normalTool1, insidersTool, normalTool2} - - result := stripInsidersFeatures(tools) - - // Should only have 2 tools (insiders-only tool filtered out) - require.Len(t, result, 2) - require.Equal(t, "normal1", result[0].Tool.Name) - require.Equal(t, "normal2", result[1].Tool.Name) -} - func TestStripMetaKeys_MultipleKeys(t *testing.T) { // This test verifies the mechanism works for multiple keys keys := []string{"ui", "experimental_feature", "beta"} diff --git a/pkg/inventory/server_tool.go b/pkg/inventory/server_tool.go index b08ae1f01..752a4c2bd 100644 --- a/pkg/inventory/server_tool.go +++ b/pkg/inventory/server_tool.go @@ -82,10 +82,6 @@ type ServerTool struct { // This includes the required scopes plus any higher-level scopes that provide // the necessary permissions due to scope hierarchy. AcceptedScopes []string - - // InsidersOnly marks this tool as only available when insiders mode is enabled. - // When insiders mode is disabled, tools with this flag set are completely omitted. - InsidersOnly bool } // IsReadOnly returns true if this tool is marked as read-only via annotations. From fd3a3c4d0ffc89448d0296cf8df285d5bcf181c0 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Tue, 7 Apr 2026 16:54:48 +0100 Subject: [PATCH 5/7] undo docs changes --- docs/server-configuration.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/docs/server-configuration.md b/docs/server-configuration.md index e852ca4db..693c096a1 100644 --- a/docs/server-configuration.md +++ b/docs/server-configuration.md @@ -391,10 +391,7 @@ Lockdown mode ensures the server only surfaces content in public repositories fr **Best for:** Users who want early access to experimental features and new tools before they reach general availability. -Insiders Mode unlocks experimental features. We created this mode to have a way to roll out experimental features and collect feedback. So if you are using Insiders, please don't hesitate to share your feedback with us! Features in Insiders Mode may change, evolve, or be removed based on user feedback. - -> [!NOTE] -> Insiders mode enables a curated set of feature flags, including `remote_mcp_ui_apps`. See [MCP Apps](#mcp-apps) below. +Insiders Mode unlocks experimental features, such as [MCP Apps](#mcp-apps) support. We created this mode to have a way to roll out experimental features and collect feedback. So if you are using Insiders, please don't hesitate to share your feedback with us! Features in Insiders Mode may change, evolve, or be removed based on user feedback.
Remote ServerLocal Server
@@ -451,7 +448,7 @@ See [Insiders Features](./insiders-features.md) for a full list of what's availa [MCP Apps](https://modelcontextprotocol.io/docs/extensions/apps) is an extension to the Model Context Protocol that enables servers to deliver interactive user interfaces to end users. Instead of returning plain text that the LLM must interpret and relay, tools can render forms, profiles, and dashboards right in the chat. -MCP Apps is controlled by the `remote_mcp_ui_apps` feature flag and can be enabled independently of insiders mode. +MCP Apps is enabled by [Insiders Mode](#insiders-mode), or independently via the `remote_mcp_ui_apps` feature flag. **Supported tools:** From 3bdaf75528f45f3beebd5ed62a585a8b98e31304 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Tue, 7 Apr 2026 16:55:41 +0100 Subject: [PATCH 6/7] restore insiders-features.md --- docs/insiders-features.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/docs/insiders-features.md b/docs/insiders-features.md index 1ed5b90f3..911257ae4 100644 --- a/docs/insiders-features.md +++ b/docs/insiders-features.md @@ -20,4 +20,25 @@ For configuration examples, see the [Server Configuration Guide](./server-config --- -_There are currently no insiders-only features. [MCP Apps](./server-configuration.md#mcp-apps) has graduated to a feature flag (`remote_mcp_ui_apps`) and can be enabled independently._ +## MCP Apps + +[MCP Apps](https://modelcontextprotocol.io/docs/extensions/apps) is an extension to the Model Context Protocol that enables servers to deliver interactive user interfaces to end users. Instead of returning plain text that the LLM must interpret and relay, tools can render forms, profiles, and dashboards right in the chat using MCP Apps. + +This means you can interact with GitHub visually: fill out forms to create issues, see user profiles with avatars, open pull requests — all without leaving your agent chat. + +### Supported tools + +The following tools have MCP Apps UIs: + +| Tool | Description | +|------|-------------| +| `get_me` | Displays your GitHub user profile with avatar, bio, and stats in a rich card | +| `issue_write` | Opens an interactive form to create or update issues | +| `create_pull_request` | Provides a full PR creation form to create a pull request (or a draft pull request) | + +### Client requirements + +MCP Apps requires a host that supports the [MCP Apps extension](https://modelcontextprotocol.io/docs/extensions/apps). Currently tested and working with: + +- **VS Code Insiders** — enable via the `chat.mcp.apps.enabled` setting +- **Visual Studio Code** — enable via the `chat.mcp.apps.enabled` setting From 90a7201bdf4fb5045ea39fe656156091dd474d9d Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Fri, 10 Apr 2026 09:48:40 +0100 Subject: [PATCH 7/7] refactor: centralize feature flag resolution and improve feature checker logic --- internal/ghmcp/server.go | 33 +++++----------- pkg/github/feature_flags.go | 36 ++++++++++++++++++ pkg/github/feature_flags_test.go | 64 ++++++++++++++++++++++++++++++++ pkg/http/handler.go | 13 ++----- pkg/http/server.go | 37 ++++-------------- pkg/inventory/builder.go | 34 +++++++++++------ pkg/inventory/registry_test.go | 11 +++++- 7 files changed, 150 insertions(+), 78 deletions(-) diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index f6260950f..3f81ac3f7 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -8,7 +8,6 @@ import ( "net/http" "os" "os/signal" - "slices" "strings" "syscall" "time" @@ -115,18 +114,8 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se return nil, fmt.Errorf("failed to create GitHub clients: %w", err) } - // Create feature checker — insiders mode expands InsidersFeatureFlags - enabledFeatures := cfg.EnabledFeatures - if cfg.InsidersMode { - enabledFeatures = slices.Clone(enabledFeatures) - for _, flag := range github.InsidersFeatureFlags { - if !slices.Contains(enabledFeatures, flag) { - enabledFeatures = append(enabledFeatures, flag) - } - } - } - featureChecker := createFeatureChecker(enabledFeatures) - mcpAppsEnabled := slices.Contains(enabledFeatures, github.MCPAppsFeatureFlag) + // Create feature checker — resolves explicit features + insiders expansion + featureChecker := createFeatureChecker(cfg.EnabledFeatures, cfg.InsidersMode) // Create dependencies for tool handlers obs, err := observability.NewExporters(cfg.Logger, metrics.NewNoopMetrics()) @@ -155,8 +144,7 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se WithTools(github.CleanTools(cfg.EnabledTools)). WithExcludeTools(cfg.ExcludeTools). WithServerInstructions(). - WithFeatureChecker(featureChecker). - WithMCPApps(mcpAppsEnabled) + WithFeatureChecker(featureChecker) // Apply token scope filtering if scopes are known (for PAT filtering) if cfg.TokenScopes != nil { @@ -177,6 +165,7 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se // and UI assets are available (requires running script/build-ui). // We check availability to allow the feature flag to be enabled without // requiring a UI build (graceful degradation). + mcpAppsEnabled, _ := featureChecker(context.Background(), github.MCPAppsFeatureFlag) if mcpAppsEnabled && github.UIAssetsAvailable() { github.RegisterUIResources(ghServer) } @@ -346,15 +335,11 @@ func RunStdioServer(cfg StdioServerConfig) error { return nil } -// createFeatureChecker returns a FeatureFlagChecker that checks if a flag name -// is present in the provided list of enabled features. For the local server, -// this is populated from the --features CLI flag. -func createFeatureChecker(enabledFeatures []string) inventory.FeatureFlagChecker { - // Build a set for O(1) lookup - featureSet := make(map[string]bool, len(enabledFeatures)) - for _, f := range enabledFeatures { - featureSet[f] = true - } +// createFeatureChecker returns a FeatureFlagChecker that resolves features +// using the centralized ResolveFeatureFlags function. For the local server, +// features are resolved once at startup from --features CLI flag + insiders mode. +func createFeatureChecker(enabledFeatures []string, insidersMode bool) inventory.FeatureFlagChecker { + featureSet := github.ResolveFeatureFlags(enabledFeatures, insidersMode) return func(_ context.Context, flagName string) (bool, error) { return featureSet[flagName], nil } diff --git a/pkg/github/feature_flags.go b/pkg/github/feature_flags.go index dd3aeddcc..cfb6c1e65 100644 --- a/pkg/github/feature_flags.go +++ b/pkg/github/feature_flags.go @@ -3,6 +3,14 @@ package github // MCPAppsFeatureFlag is the feature flag name for MCP Apps (interactive UI forms). const MCPAppsFeatureFlag = "remote_mcp_ui_apps" +// AllowedFeatureFlags is the allowlist of feature flags that can be enabled +// by users via --features CLI flag or X-MCP-Features HTTP header. +// Only flags in this list are accepted; unknown flags are silently ignored. +// This is the single source of truth for which flags are user-controllable. +var AllowedFeatureFlags = []string{ + MCPAppsFeatureFlag, +} + // InsidersFeatureFlags is the list of feature flags that insiders mode enables. // When insiders mode is active, all flags in this list are treated as enabled. // This is the single source of truth for what "insiders" means in terms of @@ -16,3 +24,31 @@ type FeatureFlags struct { LockdownMode bool InsidersMode bool } + +// ResolveFeatureFlags computes the effective set of enabled feature flags by: +// 1. Taking explicitly enabled features (from CLI flags or HTTP headers) +// 2. Adding insiders-expanded features when insiders mode is active +// 3. Validating all features against the AllowedFeatureFlags allowlist +// +// Returns a set (map) for O(1) lookup by the feature checker. +func ResolveFeatureFlags(enabledFeatures []string, insidersMode bool) map[string]bool { + allowed := make(map[string]bool, len(AllowedFeatureFlags)) + for _, f := range AllowedFeatureFlags { + allowed[f] = true + } + + effective := make(map[string]bool) + for _, f := range enabledFeatures { + if allowed[f] { + effective[f] = true + } + } + if insidersMode { + for _, f := range InsidersFeatureFlags { + if allowed[f] { + effective[f] = true + } + } + } + return effective +} diff --git a/pkg/github/feature_flags_test.go b/pkg/github/feature_flags_test.go index 0f08c4f12..b0c1a4305 100644 --- a/pkg/github/feature_flags_test.go +++ b/pkg/github/feature_flags_test.go @@ -136,6 +136,70 @@ func TestHelloWorld_ConditionalBehavior_Featureflag(t *testing.T) { } } +func TestResolveFeatureFlags(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + enabledFeatures []string + insidersMode bool + expectedFlags []string + unexpectedFlags []string + }{ + { + name: "no features, no insiders", + enabledFeatures: nil, + insidersMode: false, + expectedFlags: nil, + unexpectedFlags: []string{MCPAppsFeatureFlag}, + }, + { + name: "explicit feature enabled", + enabledFeatures: []string{MCPAppsFeatureFlag}, + insidersMode: false, + expectedFlags: []string{MCPAppsFeatureFlag}, + }, + { + name: "insiders mode enables insiders flags", + enabledFeatures: nil, + insidersMode: true, + expectedFlags: InsidersFeatureFlags, + }, + { + name: "unknown flags are filtered out", + enabledFeatures: []string{"unknown_flag", "another_unknown"}, + insidersMode: false, + unexpectedFlags: []string{"unknown_flag", "another_unknown"}, + }, + { + name: "mix of known and unknown flags", + enabledFeatures: []string{MCPAppsFeatureFlag, "unknown_flag"}, + insidersMode: false, + expectedFlags: []string{MCPAppsFeatureFlag}, + unexpectedFlags: []string{"unknown_flag"}, + }, + { + name: "explicit plus insiders deduplicates", + enabledFeatures: []string{MCPAppsFeatureFlag}, + insidersMode: true, + expectedFlags: []string{MCPAppsFeatureFlag}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := ResolveFeatureFlags(tt.enabledFeatures, tt.insidersMode) + for _, flag := range tt.expectedFlags { + assert.True(t, result[flag], "expected flag %q to be enabled", flag) + } + for _, flag := range tt.unexpectedFlags { + assert.False(t, result[flag], "expected flag %q to not be enabled", flag) + } + }) + } +} + func TestHelloWorld_ConditionalBehavior_Config(t *testing.T) { t.Parallel() diff --git a/pkg/http/handler.go b/pkg/http/handler.go index 15ec07955..3d0fc4c68 100644 --- a/pkg/http/handler.go +++ b/pkg/http/handler.go @@ -5,7 +5,6 @@ import ( "errors" "log/slog" "net/http" - "slices" ghcontext "github.com/github/github-mcp-server/pkg/context" "github.com/github/github-mcp-server/pkg/github" @@ -254,7 +253,9 @@ func DefaultInventoryFactory(_ *ServerConfig, t translations.TranslationHelperFu } // InventoryFiltersForRequest applies filters to the inventory builder -// based on the request context and headers +// based on the request context and headers. +// MCP Apps UI metadata is handled by the builder via the feature checker — +// no need to check headers here. func InventoryFiltersForRequest(r *http.Request, builder *inventory.Builder) *inventory.Builder { ctx := r.Context() @@ -262,14 +263,6 @@ func InventoryFiltersForRequest(r *http.Request, builder *inventory.Builder) *in builder = builder.WithReadOnly(true) } - // Enable MCP Apps if the feature flag is present in the request headers - // or if insiders mode is active (insiders expands InsidersFeatureFlags). - headerFeatures := ghcontext.GetHeaderFeatures(ctx) - mcpApps := slices.Contains(headerFeatures, github.MCPAppsFeatureFlag) || ghcontext.IsInsidersMode(ctx) - if mcpApps { - builder = builder.WithMCPApps(true) - } - toolsets := ghcontext.GetToolsets(ctx) tools := ghcontext.GetTools(ctx) diff --git a/pkg/http/server.go b/pkg/http/server.go index 86cad7b63..e373e7d8a 100644 --- a/pkg/http/server.go +++ b/pkg/http/server.go @@ -8,7 +8,6 @@ import ( "net/http" "os" "os/signal" - "slices" "syscall" "time" @@ -25,12 +24,6 @@ import ( "github.com/go-chi/chi/v5" ) -// knownFeatureFlags are the feature flags that can be enabled via X-MCP-Features header. -// Only these flags are accepted from headers. -var knownFeatureFlags = []string{ - github.MCPAppsFeatureFlag, -} - type ServerConfig struct { // Version of the server Version string @@ -213,30 +206,14 @@ func initGlobalToolScopeMap(t translations.TranslationHelperFunc) error { return nil } -// createHTTPFeatureChecker creates a feature checker that reads header features from context -// and validates them against the knownFeatureFlags whitelist. -// When insiders mode is active, InsidersFeatureFlags are also treated as enabled. +// createHTTPFeatureChecker creates a feature checker that resolves features +// per-request by reading header features and insiders mode from context, +// then validating against the centralized AllowedFeatureFlags allowlist. func createHTTPFeatureChecker() inventory.FeatureFlagChecker { - // Pre-compute whitelist as set for O(1) lookup - knownSet := make(map[string]bool, len(knownFeatureFlags)) - for _, f := range knownFeatureFlags { - knownSet[f] = true - } - - // Pre-compute insiders flags as set for O(1) lookup - insidersSet := make(map[string]bool, len(github.InsidersFeatureFlags)) - for _, f := range github.InsidersFeatureFlags { - insidersSet[f] = true - } - return func(ctx context.Context, flag string) (bool, error) { - if knownSet[flag] && slices.Contains(ghcontext.GetHeaderFeatures(ctx), flag) { - return true, nil - } - // Insiders mode enables all InsidersFeatureFlags - if insidersSet[flag] && ghcontext.IsInsidersMode(ctx) { - return true, nil - } - return false, nil + headerFeatures := ghcontext.GetHeaderFeatures(ctx) + insidersMode := ghcontext.IsInsidersMode(ctx) + effective := github.ResolveFeatureFlags(headerFeatures, insidersMode) + return effective[flag], nil } } diff --git a/pkg/inventory/builder.go b/pkg/inventory/builder.go index 3c36ded1a..b9a0d8548 100644 --- a/pkg/inventory/builder.go +++ b/pkg/inventory/builder.go @@ -14,6 +14,11 @@ var ( ErrUnknownTools = errors.New("unknown tools specified in WithTools") ) +// mcpAppsFeatureFlag is the feature flag name that controls MCP Apps UI metadata. +// This is defined here to avoid importing pkg/github (which imports pkg/inventory). +// The value must match github.MCPAppsFeatureFlag. +const mcpAppsFeatureFlag = "remote_mcp_ui_apps" + // ToolFilter is a function that determines if a tool should be included. // Returns true if the tool should be included, false to exclude it. type ToolFilter func(ctx context.Context, tool *ServerTool) (bool, error) @@ -48,7 +53,6 @@ type Builder struct { featureChecker FeatureFlagChecker filters []ToolFilter // filters to apply to all tools generateInstructions bool - mcpApps bool } // NewBuilder creates a new Builder. @@ -154,15 +158,6 @@ func (b *Builder) WithExcludeTools(toolNames []string) *Builder { return b } -// WithMCPApps enables or disables MCP Apps UI features. -// When disabled (default), the "ui" Meta key is stripped from tools -// so clients won't attempt to load UI resources. -// Returns self for chaining. -func (b *Builder) WithMCPApps(enabled bool) *Builder { - b.mcpApps = enabled - return b -} - // CreateExcludeToolsFilter creates a ToolFilter that excludes tools by name. // Any tool whose name appears in the excluded list will be filtered out. // The input slice should already be cleaned (trimmed, deduplicated). @@ -195,6 +190,19 @@ func cleanTools(tools []string) []string { return cleaned } +// checkFeatureFlag checks a feature flag at build time using the builder's feature checker. +// Returns false if no checker is configured or the flag is not enabled. +func (b *Builder) checkFeatureFlag(flag string) bool { + if b.featureChecker == nil { + return false + } + enabled, err := b.featureChecker(context.Background(), flag) + if err != nil { + return false + } + return enabled +} + // Build creates the final Inventory with all configuration applied. // This processes toolset filtering, tool name resolution, and sets up // the inventory for use. The returned Inventory is ready for use with @@ -206,8 +214,10 @@ func cleanTools(tools []string) []string { func (b *Builder) Build() (*Inventory, error) { tools := b.tools - // When MCP Apps is disabled, strip UI metadata from tools - if !b.mcpApps { + // When MCP Apps feature flag is not enabled, strip UI metadata from tools + // so clients won't attempt to load UI resources. + // The feature checker is the single source of truth for flag evaluation. + if !b.checkFeatureFlag(mcpAppsFeatureFlag) { tools = stripMCPAppsMetadata(tools) } diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go index d3c68fbac..e6c9e450c 100644 --- a/pkg/inventory/registry_test.go +++ b/pkg/inventory/registry_test.go @@ -1723,6 +1723,10 @@ func TestFilteringOrder(t *testing.T) { WithFeatureChecker(checker). WithFilter(filter)) + // Reset call order — Build() may call the checker for MCP Apps metadata. + // We're testing the AvailableTools filter order here. + callOrder = callOrder[:0] + _ = reg.AvailableTools(context.Background()) // Expected order: Enabled, FeatureFlag, ReadOnly (stops here because it's write tool) @@ -1881,11 +1885,14 @@ func TestWithMCPApps_EnabledPreservesUIMetadata(t *testing.T) { "description": "kept", }) - // MCP Apps enabled - UI meta should be preserved + // Feature checker enables MCP Apps - UI meta should be preserved + mcpAppsChecker := func(_ context.Context, flag string) (bool, error) { + return flag == mcpAppsFeatureFlag, nil + } reg := mustBuild(t, NewBuilder(). SetTools([]ServerTool{toolWithUI}). WithToolsets([]string{"all"}). - WithMCPApps(true)) + WithFeatureChecker(mcpAppsChecker)) available := reg.AvailableTools(context.Background()) require.Len(t, available, 1)
Remote ServerLocal Server