Skip to content

Commit 2420c6a

Browse files
Merge branch 'main' into add-set-issue-fields-tool
2 parents afa16f1 + b482ac6 commit 2420c6a

File tree

12 files changed

+316
-190
lines changed

12 files changed

+316
-190
lines changed

docs/server-configuration.md

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ We currently support the following ways in which the GitHub MCP Server can be co
1414
| Dynamic Mode | Not available | `--dynamic-toolsets` flag or `GITHUB_DYNAMIC_TOOLSETS` env var |
1515
| Lockdown Mode | `X-MCP-Lockdown` header | `--lockdown-mode` flag or `GITHUB_LOCKDOWN_MODE` env var |
1616
| Insiders Mode | `X-MCP-Insiders` header or `/insiders` URL | `--insiders` flag or `GITHUB_INSIDERS` env var |
17+
| Feature Flags | `X-MCP-Features` header | `--features` flag |
1718
| Scope Filtering | Always enabled | Always enabled |
1819
| Server Name/Title | Not available | `GITHUB_MCP_SERVER_NAME` / `GITHUB_MCP_SERVER_TITLE` env vars or `github-mcp-server-config.json` |
1920

@@ -390,7 +391,7 @@ Lockdown mode ensures the server only surfaces content in public repositories fr
390391

391392
**Best for:** Users who want early access to experimental features and new tools before they reach general availability.
392393

393-
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.
394+
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.
394395

395396
<table>
396397
<tr><th>Remote Server</th><th>Local Server</th></tr>
@@ -443,6 +444,62 @@ See [Insiders Features](./insiders-features.md) for a full list of what's availa
443444

444445
---
445446

447+
### MCP Apps
448+
449+
[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.
450+
451+
MCP Apps is enabled by [Insiders Mode](#insiders-mode), or independently via the `remote_mcp_ui_apps` feature flag.
452+
453+
**Supported tools:**
454+
455+
| Tool | Description |
456+
|------|-------------|
457+
| `get_me` | Displays your GitHub user profile with avatar, bio, and stats in a rich card |
458+
| `issue_write` | Opens an interactive form to create or update issues |
459+
| `create_pull_request` | Provides a full PR creation form to create a pull request (or a draft pull request) |
460+
461+
**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).
462+
463+
<table>
464+
<tr><th>Remote Server</th><th>Local Server</th></tr>
465+
<tr valign="top">
466+
<td>
467+
468+
```json
469+
{
470+
"type": "http",
471+
"url": "https://api.githubcopilot.com/mcp/",
472+
"headers": {
473+
"X-MCP-Features": "remote_mcp_ui_apps"
474+
}
475+
}
476+
```
477+
478+
</td>
479+
<td>
480+
481+
```json
482+
{
483+
"type": "stdio",
484+
"command": "go",
485+
"args": [
486+
"run",
487+
"./cmd/github-mcp-server",
488+
"stdio",
489+
"--features=remote_mcp_ui_apps"
490+
],
491+
"env": {
492+
"GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}"
493+
}
494+
}
495+
```
496+
497+
</td>
498+
</tr>
499+
</table>
500+
501+
---
502+
446503
### Scope Filtering
447504

448505
**Automatic feature:** The server handles OAuth scopes differently depending on authentication type:

internal/ghmcp/server.go

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,8 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se
114114
return nil, fmt.Errorf("failed to create GitHub clients: %w", err)
115115
}
116116

117-
// Create feature checker
118-
featureChecker := createFeatureChecker(cfg.EnabledFeatures)
117+
// Create feature checker — resolves explicit features + insiders expansion
118+
featureChecker := createFeatureChecker(cfg.EnabledFeatures, cfg.InsidersMode)
119119

120120
// Create dependencies for tool handlers
121121
obs, err := observability.NewExporters(cfg.Logger, metrics.NewNoopMetrics())
@@ -144,8 +144,7 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se
144144
WithTools(github.CleanTools(cfg.EnabledTools)).
145145
WithExcludeTools(cfg.ExcludeTools).
146146
WithServerInstructions().
147-
WithFeatureChecker(featureChecker).
148-
WithInsidersMode(cfg.InsidersMode)
147+
WithFeatureChecker(featureChecker)
149148

150149
// Apply token scope filtering if scopes are known (for PAT filtering)
151150
if cfg.TokenScopes != nil {
@@ -162,10 +161,12 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se
162161
return nil, fmt.Errorf("failed to create GitHub MCP server: %w", err)
163162
}
164163

165-
// Register MCP App UI resources if available (requires running script/build-ui).
166-
// We check availability to allow Insiders mode to work for non-UI features
167-
// even when UI assets haven't been built.
168-
if cfg.InsidersMode && github.UIAssetsAvailable() {
164+
// Register MCP App UI resources if the remote_mcp_ui_apps feature flag is enabled
165+
// and UI assets are available (requires running script/build-ui).
166+
// We check availability to allow the feature flag to be enabled without
167+
// requiring a UI build (graceful degradation).
168+
mcpAppsEnabled, _ := featureChecker(context.Background(), github.MCPAppsFeatureFlag)
169+
if mcpAppsEnabled && github.UIAssetsAvailable() {
169170
github.RegisterUIResources(ghServer)
170171
}
171172

@@ -334,15 +335,11 @@ func RunStdioServer(cfg StdioServerConfig) error {
334335
return nil
335336
}
336337

337-
// createFeatureChecker returns a FeatureFlagChecker that checks if a flag name
338-
// is present in the provided list of enabled features. For the local server,
339-
// this is populated from the --features CLI flag.
340-
func createFeatureChecker(enabledFeatures []string) inventory.FeatureFlagChecker {
341-
// Build a set for O(1) lookup
342-
featureSet := make(map[string]bool, len(enabledFeatures))
343-
for _, f := range enabledFeatures {
344-
featureSet[f] = true
345-
}
338+
// createFeatureChecker returns a FeatureFlagChecker that resolves features
339+
// using the centralized ResolveFeatureFlags function. For the local server,
340+
// features are resolved once at startup from --features CLI flag + insiders mode.
341+
func createFeatureChecker(enabledFeatures []string, insidersMode bool) inventory.FeatureFlagChecker {
342+
featureSet := github.ResolveFeatureFlags(enabledFeatures, insidersMode)
346343
return func(_ context.Context, flagName string) (bool, error) {
347344
return featureSet[flagName], nil
348345
}

pkg/github/feature_flags.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,56 @@
11
package github
22

3+
// MCPAppsFeatureFlag is the feature flag name for MCP Apps (interactive UI forms).
4+
const MCPAppsFeatureFlag = "remote_mcp_ui_apps"
5+
6+
// AllowedFeatureFlags is the allowlist of feature flags that can be enabled
7+
// by users via --features CLI flag or X-MCP-Features HTTP header.
8+
// Only flags in this list are accepted; unknown flags are silently ignored.
9+
// This is the single source of truth for which flags are user-controllable.
10+
var AllowedFeatureFlags = []string{
11+
MCPAppsFeatureFlag,
12+
FeatureFlagIssuesGranular,
13+
FeatureFlagPullRequestsGranular,
14+
}
15+
16+
// InsidersFeatureFlags is the list of feature flags that insiders mode enables.
17+
// When insiders mode is active, all flags in this list are treated as enabled.
18+
// This is the single source of truth for what "insiders" means in terms of
19+
// feature flag expansion.
20+
var InsidersFeatureFlags = []string{
21+
MCPAppsFeatureFlag,
22+
}
23+
324
// FeatureFlags defines runtime feature toggles that adjust tool behavior.
425
type FeatureFlags struct {
526
LockdownMode bool
627
InsidersMode bool
728
}
29+
30+
// ResolveFeatureFlags computes the effective set of enabled feature flags by:
31+
// 1. Taking explicitly enabled features (from CLI flags or HTTP headers)
32+
// 2. Adding insiders-expanded features when insiders mode is active
33+
// 3. Validating all features against the AllowedFeatureFlags allowlist
34+
//
35+
// Returns a set (map) for O(1) lookup by the feature checker.
36+
func ResolveFeatureFlags(enabledFeatures []string, insidersMode bool) map[string]bool {
37+
allowed := make(map[string]bool, len(AllowedFeatureFlags))
38+
for _, f := range AllowedFeatureFlags {
39+
allowed[f] = true
40+
}
41+
42+
effective := make(map[string]bool)
43+
for _, f := range enabledFeatures {
44+
if allowed[f] {
45+
effective[f] = true
46+
}
47+
}
48+
if insidersMode {
49+
for _, f := range InsidersFeatureFlags {
50+
if allowed[f] {
51+
effective[f] = true
52+
}
53+
}
54+
}
55+
return effective
56+
}

pkg/github/feature_flags_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,70 @@ func TestHelloWorld_ConditionalBehavior_Featureflag(t *testing.T) {
136136
}
137137
}
138138

139+
func TestResolveFeatureFlags(t *testing.T) {
140+
t.Parallel()
141+
142+
tests := []struct {
143+
name string
144+
enabledFeatures []string
145+
insidersMode bool
146+
expectedFlags []string
147+
unexpectedFlags []string
148+
}{
149+
{
150+
name: "no features, no insiders",
151+
enabledFeatures: nil,
152+
insidersMode: false,
153+
expectedFlags: nil,
154+
unexpectedFlags: []string{MCPAppsFeatureFlag},
155+
},
156+
{
157+
name: "explicit feature enabled",
158+
enabledFeatures: []string{MCPAppsFeatureFlag},
159+
insidersMode: false,
160+
expectedFlags: []string{MCPAppsFeatureFlag},
161+
},
162+
{
163+
name: "insiders mode enables insiders flags",
164+
enabledFeatures: nil,
165+
insidersMode: true,
166+
expectedFlags: InsidersFeatureFlags,
167+
},
168+
{
169+
name: "unknown flags are filtered out",
170+
enabledFeatures: []string{"unknown_flag", "another_unknown"},
171+
insidersMode: false,
172+
unexpectedFlags: []string{"unknown_flag", "another_unknown"},
173+
},
174+
{
175+
name: "mix of known and unknown flags",
176+
enabledFeatures: []string{MCPAppsFeatureFlag, "unknown_flag"},
177+
insidersMode: false,
178+
expectedFlags: []string{MCPAppsFeatureFlag},
179+
unexpectedFlags: []string{"unknown_flag"},
180+
},
181+
{
182+
name: "explicit plus insiders deduplicates",
183+
enabledFeatures: []string{MCPAppsFeatureFlag},
184+
insidersMode: true,
185+
expectedFlags: []string{MCPAppsFeatureFlag},
186+
},
187+
}
188+
189+
for _, tt := range tests {
190+
t.Run(tt.name, func(t *testing.T) {
191+
t.Parallel()
192+
result := ResolveFeatureFlags(tt.enabledFeatures, tt.insidersMode)
193+
for _, flag := range tt.expectedFlags {
194+
assert.True(t, result[flag], "expected flag %q to be enabled", flag)
195+
}
196+
for _, flag := range tt.unexpectedFlags {
197+
assert.False(t, result[flag], "expected flag %q to not be enabled", flag)
198+
}
199+
})
200+
}
201+
}
202+
139203
func TestHelloWorld_ConditionalBehavior_Config(t *testing.T) {
140204
t.Parallel()
141205

pkg/github/tools.go

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -147,18 +147,11 @@ var (
147147
FeatureFlagPullRequestsGranular = "pull_requests_granular"
148148
)
149149

150-
// headerAllowedFeatureFlags are the feature flags that clients may enable via the
151-
// X-MCP-Features header. Only these flags are accepted from headers; unknown flags
152-
// are silently ignored.
153-
var headerAllowedFeatureFlags = []string{
154-
FeatureFlagIssuesGranular,
155-
FeatureFlagPullRequestsGranular,
156-
}
157-
158150
// HeaderAllowedFeatureFlags returns the feature flags that clients may enable via
159-
// the X-MCP-Features header.
151+
// the X-MCP-Features header. It delegates to AllowedFeatureFlags as the single
152+
// source of truth.
160153
func HeaderAllowedFeatureFlags() []string {
161-
return slices.Clone(headerAllowedFeatureFlags)
154+
return slices.Clone(AllowedFeatureFlags)
162155
}
163156

164157
var (

pkg/http/handler.go

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -275,11 +275,6 @@ func DefaultInventoryFactory(cfg *ServerConfig, t translations.TranslationHelper
275275
b = b.WithReadOnly(true)
276276
}
277277

278-
// Static insiders mode — enforce before request filters
279-
if cfg.InsidersMode {
280-
b = b.WithInsidersMode(true)
281-
}
282-
283278
// Filter request tool names to only those in the static universe,
284279
// so requests for statically-excluded tools degrade gracefully.
285280
if hasStaticFilters {
@@ -336,8 +331,7 @@ func buildStaticInventory(cfg *ServerConfig, t translations.TranslationHelperFun
336331
b := github.NewInventory(t).
337332
WithFeatureChecker(featureChecker).
338333
WithReadOnly(cfg.ReadOnly).
339-
WithToolsets(github.ResolvedEnabledToolsets(cfg.DynamicToolsets, cfg.EnabledToolsets, cfg.EnabledTools)).
340-
WithInsidersMode(cfg.InsidersMode)
334+
WithToolsets(github.ResolvedEnabledToolsets(cfg.DynamicToolsets, cfg.EnabledToolsets, cfg.EnabledTools))
341335

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

361355
// InventoryFiltersForRequest applies filters to the inventory builder
362-
// based on the request context and headers
356+
// based on the request context and headers.
357+
// MCP Apps UI metadata is handled by the builder via the feature checker —
358+
// no need to check headers here.
363359
func InventoryFiltersForRequest(r *http.Request, builder *inventory.Builder) *inventory.Builder {
364360
ctx := r.Context()
365361

pkg/http/handler_test.go

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -576,9 +576,6 @@ func TestStaticConfigEnforcement(t *testing.T) {
576576
if tt.config.ReadOnly {
577577
builder = builder.WithReadOnly(true)
578578
}
579-
if tt.config.InsidersMode {
580-
builder = builder.WithInsidersMode(true)
581-
}
582579

583580
if hasStatic {
584581
r = filterRequestTools(r, validToolNames)
@@ -645,8 +642,7 @@ func buildStaticInventoryFromTools(cfg *ServerConfig, tools []inventory.ServerTo
645642
SetTools(tools).
646643
WithFeatureChecker(featureChecker).
647644
WithReadOnly(cfg.ReadOnly).
648-
WithToolsets(github.ResolvedEnabledToolsets(cfg.DynamicToolsets, cfg.EnabledToolsets, cfg.EnabledTools)).
649-
WithInsidersMode(cfg.InsidersMode)
645+
WithToolsets(github.ResolvedEnabledToolsets(cfg.DynamicToolsets, cfg.EnabledToolsets, cfg.EnabledTools))
650646

651647
if len(cfg.EnabledTools) > 0 {
652648
b = b.WithTools(github.CleanTools(cfg.EnabledTools))

pkg/http/server.go

Lines changed: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import (
88
"net/http"
99
"os"
1010
"os/signal"
11-
"slices"
1211
"syscall"
1312
"time"
1413

@@ -25,10 +24,6 @@ import (
2524
"github.com/go-chi/chi/v5"
2625
)
2726

28-
// knownFeatureFlags are the feature flags that can be enabled via X-MCP-Features header.
29-
// Only these flags are accepted from headers.
30-
var knownFeatureFlags = github.HeaderAllowedFeatureFlags()
31-
3227
type ServerConfig struct {
3328
// Version of the server
3429
Version string
@@ -233,19 +228,14 @@ func initGlobalToolScopeMap(t translations.TranslationHelperFunc) error {
233228
return nil
234229
}
235230

236-
// createHTTPFeatureChecker creates a feature checker that reads header features from context
237-
// and validates them against the knownFeatureFlags whitelist
231+
// createHTTPFeatureChecker creates a feature checker that resolves features
232+
// per-request by reading header features and insiders mode from context,
233+
// then validating against the centralized AllowedFeatureFlags allowlist.
238234
func createHTTPFeatureChecker() inventory.FeatureFlagChecker {
239-
// Pre-compute whitelist as set for O(1) lookup
240-
knownSet := make(map[string]bool, len(knownFeatureFlags))
241-
for _, f := range knownFeatureFlags {
242-
knownSet[f] = true
243-
}
244-
245235
return func(ctx context.Context, flag string) (bool, error) {
246-
if knownSet[flag] && slices.Contains(ghcontext.GetHeaderFeatures(ctx), flag) {
247-
return true, nil
248-
}
249-
return false, nil
236+
headerFeatures := ghcontext.GetHeaderFeatures(ctx)
237+
insidersMode := ghcontext.IsInsidersMode(ctx)
238+
effective := github.ResolveFeatureFlags(headerFeatures, insidersMode)
239+
return effective[flag], nil
250240
}
251241
}

0 commit comments

Comments
 (0)