Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion docs/server-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

Expand Down Expand Up @@ -390,7 +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, 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, 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.

<table>
<tr><th>Remote Server</th><th>Local Server</th></tr>
Expand Down Expand Up @@ -443,6 +444,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 enabled by [Insiders Mode](#insiders-mode), or independently via the `remote_mcp_ui_apps` feature flag.

**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).

<table>
<tr><th>Remote Server</th><th>Local Server</th></tr>
<tr valign="top">
<td>

```json
{
"type": "http",
"url": "https://api.githubcopilot.com/mcp/",
"headers": {
"X-MCP-Features": "remote_mcp_ui_apps"
}
}
```

</td>
<td>

```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}"
}
}
```

</td>
</tr>
</table>

---

### Scope Filtering

**Automatic feature:** The server handles OAuth scopes differently depending on authentication type:
Expand Down
31 changes: 14 additions & 17 deletions internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,8 +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
featureChecker := createFeatureChecker(cfg.EnabledFeatures)
// 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())
Expand Down Expand Up @@ -144,8 +144,7 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se
WithTools(github.CleanTools(cfg.EnabledTools)).
WithExcludeTools(cfg.ExcludeTools).
WithServerInstructions().
WithFeatureChecker(featureChecker).
WithInsidersMode(cfg.InsidersMode)
WithFeatureChecker(featureChecker)

// Apply token scope filtering if scopes are known (for PAT filtering)
if cfg.TokenScopes != nil {
Expand All @@ -162,10 +161,12 @@ 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).
mcpAppsEnabled, _ := featureChecker(context.Background(), github.MCPAppsFeatureFlag)
if mcpAppsEnabled && github.UIAssetsAvailable() {
github.RegisterUIResources(ghServer)
}

Expand Down Expand Up @@ -334,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
}
Expand Down
49 changes: 49 additions & 0 deletions pkg/github/feature_flags.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,56 @@
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,
FeatureFlagIssuesGranular,
FeatureFlagPullRequestsGranular,
}
Comment on lines +6 to +14
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AllowedFeatureFlags is exported as a mutable slice variable. External callers can accidentally append/modify it, which can lead to hard-to-debug behavior changes and potential data races in concurrent programs. Consider making the underlying allowlist unexported and exposing an exported function that returns a cloned slice (similar to HeaderAllowedFeatureFlags), or otherwise ensuring callers cannot mutate the shared backing array.

Copilot uses AI. Check for mistakes.

// 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
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
}
64 changes: 64 additions & 0 deletions pkg/github/feature_flags_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
13 changes: 3 additions & 10 deletions pkg/github/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,18 +147,11 @@ var (
FeatureFlagPullRequestsGranular = "pull_requests_granular"
)

// headerAllowedFeatureFlags are the feature flags that clients may enable via the
// X-MCP-Features header. Only these flags are accepted from headers; unknown flags
// are silently ignored.
var headerAllowedFeatureFlags = []string{
FeatureFlagIssuesGranular,
FeatureFlagPullRequestsGranular,
}

// HeaderAllowedFeatureFlags returns the feature flags that clients may enable via
// the X-MCP-Features header.
// the X-MCP-Features header. It delegates to AllowedFeatureFlags as the single
// source of truth.
func HeaderAllowedFeatureFlags() []string {
return slices.Clone(headerAllowedFeatureFlags)
return slices.Clone(AllowedFeatureFlags)
}

var (
Expand Down
12 changes: 4 additions & 8 deletions pkg/http/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,11 +275,6 @@ func DefaultInventoryFactory(cfg *ServerConfig, t translations.TranslationHelper
b = b.WithReadOnly(true)
}

// Static insiders mode — enforce before request filters
if cfg.InsidersMode {
b = b.WithInsidersMode(true)
}

// Filter request tool names to only those in the static universe,
// so requests for statically-excluded tools degrade gracefully.
if hasStaticFilters {
Expand Down Expand Up @@ -336,8 +331,7 @@ func buildStaticInventory(cfg *ServerConfig, t translations.TranslationHelperFun
b := github.NewInventory(t).
WithFeatureChecker(featureChecker).
WithReadOnly(cfg.ReadOnly).
WithToolsets(github.ResolvedEnabledToolsets(cfg.DynamicToolsets, cfg.EnabledToolsets, cfg.EnabledTools)).
WithInsidersMode(cfg.InsidersMode)
WithToolsets(github.ResolvedEnabledToolsets(cfg.DynamicToolsets, cfg.EnabledToolsets, cfg.EnabledTools))

if len(cfg.EnabledTools) > 0 {
b = b.WithTools(github.CleanTools(cfg.EnabledTools))
Expand All @@ -359,7 +353,9 @@ func buildStaticInventory(cfg *ServerConfig, t translations.TranslationHelperFun
}

// 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()

Expand Down
6 changes: 1 addition & 5 deletions pkg/http/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -576,9 +576,6 @@ func TestStaticConfigEnforcement(t *testing.T) {
if tt.config.ReadOnly {
builder = builder.WithReadOnly(true)
}
if tt.config.InsidersMode {
builder = builder.WithInsidersMode(true)
}

if hasStatic {
r = filterRequestTools(r, validToolNames)
Expand Down Expand Up @@ -645,8 +642,7 @@ func buildStaticInventoryFromTools(cfg *ServerConfig, tools []inventory.ServerTo
SetTools(tools).
WithFeatureChecker(featureChecker).
WithReadOnly(cfg.ReadOnly).
WithToolsets(github.ResolvedEnabledToolsets(cfg.DynamicToolsets, cfg.EnabledToolsets, cfg.EnabledTools)).
WithInsidersMode(cfg.InsidersMode)
WithToolsets(github.ResolvedEnabledToolsets(cfg.DynamicToolsets, cfg.EnabledToolsets, cfg.EnabledTools))

if len(cfg.EnabledTools) > 0 {
b = b.WithTools(github.CleanTools(cfg.EnabledTools))
Expand Down
24 changes: 7 additions & 17 deletions pkg/http/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import (
"net/http"
"os"
"os/signal"
"slices"
"syscall"
"time"

Expand All @@ -25,10 +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 = github.HeaderAllowedFeatureFlags()

type ServerConfig struct {
// Version of the server
Version string
Expand Down Expand Up @@ -233,19 +228,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
// 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
}

return func(ctx context.Context, flag string) (bool, error) {
if knownSet[flag] && slices.Contains(ghcontext.GetHeaderFeatures(ctx), flag) {
return true, nil
}
return false, nil
headerFeatures := ghcontext.GetHeaderFeatures(ctx)
insidersMode := ghcontext.IsInsidersMode(ctx)
effective := github.ResolveFeatureFlags(headerFeatures, insidersMode)
return effective[flag], nil
}
Comment on lines +231 to 240
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

createHTTPFeatureChecker calls ResolveFeatureFlags on every feature check, rebuilding maps each time. Inventory filtering can invoke the checker many times per request (once per feature-flagged tool), so this adds avoidable per-request overhead. Consider computing the effective feature set once per request (e.g., in middleware when parsing headers, or by caching within the checker using a request-scoped value) and doing O(1) lookups from that cached set.

Copilot uses AI. Check for mistakes.
}
Loading
Loading