Skip to content

Commit e1a0728

Browse files
fix(http): harden URL feature flag handling
Preserve exact OAuth resource queries across route variants, retain presence-based header precedence, and mark feature-dependent responses with Vary. Expand request, allowlist, metadata, route, and cache behavior coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 0c3dc68 commit e1a0728

12 files changed

Lines changed: 156 additions & 84 deletions

File tree

docs/feature-flags.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ When both the query parameter and the header are present, the header wins —
2323
even when its value is empty, whitespace-only, or contains only unknown flags.
2424
The two channels are never combined.
2525

26+
The complete query string is preserved during OAuth protected-resource metadata
27+
discovery because it is part of the canonical resource identifier. Query
28+
parameters also participate in HTTP cache keys, while MCP responses vary on
29+
`X-MCP-Features` so a header override cannot reuse a response selected for a
30+
different feature set. Feature names are configuration identifiers, not
31+
secrets; as with any URL query value, they may appear in client history, proxy
32+
logs, and server access logs.
33+
2634
Only flags listed in
2735
[`AllowedFeatureFlags`](../pkg/github/feature_flags.go) can be enabled by
2836
end users. Insiders-only flags are not user-toggleable.
@@ -364,7 +372,7 @@ runtime behavior (such as output formatting) won't appear here.
364372
### `thread_resolution_reason`
365373

366374
- **pull_request_review_write** - Write operations (create, submit, delete) on pull request reviews
367-
- **Required OAuth Scopes**: `repo`
375+
- **OAuth Challenge Scopes**: `repo`
368376
- `body`: Review comment text (string, optional)
369377
- `commitID`: SHA of commit to review (string, optional)
370378
- `event`: Review action to perform. (string, optional)

docs/insiders-features.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,9 @@ Insiders is a **meta feature flag** — the same shape as `default` or `all` for
200200

201201
1. **User input.** Users may opt into specific features:
202202
- Local server: `--features=<flag>,<flag>` CLI flag (or `GITHUB_FEATURES` env var).
203-
- Self-hosted HTTP server: `X-MCP-Features: <flag>,<flag>` request header.
203+
- HTTP server: `X-MCP-Features: <flag>,<flag>` request header or a
204+
`?features=<flag>,<flag>` server URL. Header presence takes precedence,
205+
and the two request channels are never combined.
204206
2. **Allowlist filter.** User-supplied flags are filtered against [`AllowedFeatureFlags`](../pkg/github/feature_flags.go). Anything not on the allowlist is silently dropped — flags missing from the allowlist can only be turned on by remote-server feature management, not by end users.
205207
3. **Insiders expansion.** If insiders mode is on (`--insiders`, `/insiders` route, or `X-MCP-Insiders: true`), every flag in [`InsidersFeatureFlags`](../pkg/github/feature_flags.go) is unioned in. The insiders expansion is **not** re-validated against the allowlist — insiders is a server-controlled switch that can reach internal-only flags.
206208
4. **Server-side fallback (remote server only).** Any flag not yet decided falls back to the remote server's feature manager, which can roll a feature out independently of user input or insiders membership.
@@ -214,7 +216,8 @@ Insiders is a **meta feature flag** — the same shape as `default` or `all` for
214216
### Adding a new feature flag
215217

216218
1. Add a constant in `pkg/github/feature_flags.go`.
217-
2. Add it to `AllowedFeatureFlags` if end users should be able to opt in via `--features` / `X-MCP-Features`.
219+
2. Add it to `AllowedFeatureFlags` if end users should be able to opt in via
220+
`--features`, `X-MCP-Features`, or the `features` URL query parameter.
218221
3. Add it to `InsidersFeatureFlags` if insiders mode should turn it on automatically.
219222
4. Gate the behavior on the concrete flag (`deps.IsFeatureEnabled(ctx, FeatureFlagX)`), never on `cfg.InsidersMode`. There is a `TestGitHubPackageDoesNotReadInsidersMode` guard test that fails if `pkg/github` reads `InsidersMode` directly.
220223
5. The MCP-diff CI workflow picks up new entries in `AllowedFeatureFlags` automatically — see `.github/workflows/mcp-diff.yml`.

pkg/context/request.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,15 +98,15 @@ func GetExcludeTools(ctx context.Context) []string {
9898
return nil
9999
}
100100

101-
// headerFeaturesCtxKey is a context key for raw header feature flags
101+
// headerFeaturesCtxKey is a context key for raw HTTP request feature flags.
102102
type headerFeaturesCtxKey struct{}
103103

104-
// WithHeaderFeatures stores the raw feature flags from the X-MCP-Features header into context
104+
// WithHeaderFeatures stores raw HTTP request feature flags in context.
105105
func WithHeaderFeatures(ctx context.Context, features []string) context.Context {
106106
return context.WithValue(ctx, headerFeaturesCtxKey{}, features)
107107
}
108108

109-
// GetHeaderFeatures retrieves the raw feature flags from context
109+
// GetHeaderFeatures retrieves raw HTTP request feature flags from context.
110110
func GetHeaderFeatures(ctx context.Context) []string {
111111
if features, ok := ctx.Value(headerFeaturesCtxKey{}).([]string); ok {
112112
return features

pkg/github/feature_flags.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,8 @@ const FeatureFlagDuplicateDetection = "duplicate_detection"
3838
const FeatureFlagThreadResolutionReason = "thread_resolution_reason"
3939

4040
// AllowedFeatureFlags is the allowlist of feature flags that can be enabled
41-
// by users via --features CLI flag or X-MCP-Features HTTP header.
41+
// by users via --features CLI flag, X-MCP-Features HTTP header, or the
42+
// features URL query parameter.
4243
// Only flags in this list are accepted; unknown flags are silently ignored.
4344
// This is the single source of truth for which flags are user-controllable.
4445
var AllowedFeatureFlags = []string{
@@ -71,7 +72,8 @@ type FeatureFlags struct {
7172
}
7273

7374
// ResolveFeatureFlags computes the effective set of enabled feature flags by:
74-
// 1. Taking the user-supplied flags (from --features or X-MCP-Features) and
75+
// 1. Taking the user-supplied flags (from --features or HTTP request
76+
// configuration) and
7577
// keeping only those present in AllowedFeatureFlags. Unknown or unsafe
7678
// flags from request input are silently dropped here.
7779
// 2. If insiders mode is on, unioning in every flag from InsidersFeatureFlags.

pkg/github/tools.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,9 @@ var (
159159
FeatureFlagPullRequestsGranular = "pull_requests_granular"
160160
)
161161

162-
// HeaderAllowedFeatureFlags returns the feature flags that clients may enable via
163-
// the X-MCP-Features header. It delegates to AllowedFeatureFlags as the single
164-
// source of truth.
162+
// HeaderAllowedFeatureFlags returns the feature flags that clients may enable
163+
// through the X-MCP-Features header or features URL query parameter. It
164+
// delegates to AllowedFeatureFlags as the single source of truth.
165165
func HeaderAllowedFeatureFlags() []string {
166166
return slices.Clone(AllowedFeatureFlags)
167167
}

pkg/http/handler_test.go

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -177,9 +177,9 @@ func testTools() []inventory.ServerTool {
177177
mockTool("create_issue", "issues", false),
178178
mockTool("list_pull_requests", "pull_requests", true),
179179
mockTool("create_pull_request", "pull_requests", false),
180-
// Feature-flagged tools for testing X-MCP-Features header
181-
mockToolWithFeatureFlag("needs_holdback", "repos", true, "mcp_holdback_consolidated_projects", ""),
182-
mockToolWithFeatureFlag("hidden_by_holdback", "repos", true, "", "mcp_holdback_consolidated_projects"),
180+
// Feature-flagged tools for testing per-request feature selection.
181+
mockToolWithFeatureFlag("needs_holdback", "repos", true, github.FeatureFlagIssueDependencies, ""),
182+
mockToolWithFeatureFlag("hidden_by_holdback", "repos", true, "", github.FeatureFlagIssueDependencies),
183183
}
184184
}
185185

@@ -293,7 +293,7 @@ func TestHTTPHandlerRoutes(t *testing.T) {
293293
name: "X-MCP-Features header enables flagged tool",
294294
path: "/",
295295
headers: map[string]string{
296-
headers.MCPFeaturesHeader: "mcp_holdback_consolidated_projects",
296+
headers.MCPFeaturesHeader: github.FeatureFlagIssueDependencies,
297297
},
298298
expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "needs_holdback"},
299299
},
@@ -305,6 +305,29 @@ func TestHTTPHandlerRoutes(t *testing.T) {
305305
},
306306
expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "hidden_by_holdback"},
307307
},
308+
{
309+
name: "features query parameter enables allowlisted feature",
310+
path: "/?features=" + github.FeatureFlagIssueDependencies,
311+
expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "needs_holdback"},
312+
},
313+
{
314+
name: "features query parameter works with toolset and readonly routes",
315+
path: "/x/repos/readonly?features=" + github.FeatureFlagIssueDependencies,
316+
expectedTools: []string{"get_file_contents", "needs_holdback"},
317+
},
318+
{
319+
name: "unknown feature in query parameter is ignored",
320+
path: "/?features=unknown_flag",
321+
expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "hidden_by_holdback"},
322+
},
323+
{
324+
name: "unknown header suppresses allowlisted query feature",
325+
path: "/?features=" + github.FeatureFlagIssueDependencies,
326+
headers: map[string]string{
327+
headers.MCPFeaturesHeader: "unknown_flag",
328+
},
329+
expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "hidden_by_holdback"},
330+
},
308331
{
309332
name: "X-MCP-Exclude-Tools header removes specific tools",
310333
path: "/",
@@ -346,10 +369,13 @@ func TestHTTPHandlerRoutes(t *testing.T) {
346369
var capturedInventory *inventory.Inventory
347370
var capturedCtx context.Context
348371

349-
// Create feature checker that reads from context without whitelist validation
350-
// (the whitelist is tested separately; here we test the filtering logic)
372+
// Match the production allowlist and insiders expansion behavior.
351373
featureChecker := func(ctx context.Context, flag string) (bool, error) {
352-
return slices.Contains(ghcontext.GetHeaderFeatures(ctx), flag), nil
374+
effective := github.ResolveFeatureFlags(
375+
ghcontext.GetHeaderFeatures(ctx),
376+
ghcontext.IsInsidersMode(ctx),
377+
)
378+
return effective[flag], nil
353379
}
354380

355381
apiHost, err := utils.NewAPIHost("https://api.github.com")

pkg/http/middleware/request_config.go

Lines changed: 10 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,15 @@ import (
99
"github.com/github/github-mcp-server/pkg/http/headers"
1010
)
1111

12-
// queryParamFeatures is the URL query parameter that carries feature flags,
13-
// mirroring the X-MCP-Features header. It exists so clients that cannot set
14-
// custom headers on the MCP connection — hosted IDEs, agent platforms, or
15-
// harnesses that compose the server URL on the user's behalf (see #3145) —
16-
// can still opt into flagged tools.
1712
const queryParamFeatures = "features"
1813

1914
// WithRequestConfig is a middleware that extracts MCP-related headers and sets them in the request context.
2015
// This includes readonly mode, toolsets, tools, lockdown mode, insiders mode, and feature flags.
21-
// Feature flags may also arrive via the `features` URL query parameter. When
22-
// both are present, the X-MCP-Features header takes precedence and the two
23-
// channels are never combined.
2416
func WithRequestConfig(next http.Handler) http.Handler {
2517
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
18+
// Header-selected features can change the response for the same URL.
19+
w.Header().Add(headers.VaryHeader, headers.MCPFeaturesHeader)
20+
2621
ctx := r.Context()
2722

2823
// Readonly mode
@@ -55,28 +50,13 @@ func WithRequestConfig(next http.Handler) http.Handler {
5550
ctx = ghcontext.WithInsidersMode(ctx, true)
5651
}
5752

58-
// Feature flags: presence-based selection. The URL query parameter and
59-
// the X-MCP-Features header are separate channels — whichever is
60-
// present is used as-is, and they are never combined. Header presence
61-
// takes precedence even when its value is empty or contains only
62-
// unknown flags, so a query parameter cannot override an explicit
63-
// header. Unknown flags are dropped later by ResolveFeatureFlags
64-
// against AllowedFeatureFlags.
65-
queryFeatures, hasQuery := r.URL.Query()[queryParamFeatures]
66-
headerFeatures, hasHeader := r.Header[http.CanonicalHeaderKey(headers.MCPFeaturesHeader)]
67-
switch {
68-
case hasHeader:
69-
headerValue := ""
70-
if len(headerFeatures) > 0 {
71-
headerValue = headerFeatures[0]
72-
}
73-
ctx = ghcontext.WithHeaderFeatures(ctx, headers.ParseCommaSeparated(headerValue))
74-
case hasQuery:
75-
queryValue := ""
76-
if len(queryFeatures) > 0 {
77-
queryValue = queryFeatures[0]
78-
}
79-
ctx = ghcontext.WithHeaderFeatures(ctx, headers.ParseCommaSeparated(queryValue))
53+
query := r.URL.Query()
54+
_, hasHeaderFeatures := r.Header[http.CanonicalHeaderKey(headers.MCPFeaturesHeader)]
55+
_, hasQueryFeatures := query[queryParamFeatures]
56+
if hasHeaderFeatures {
57+
ctx = ghcontext.WithHeaderFeatures(ctx, headers.ParseCommaSeparated(r.Header.Get(headers.MCPFeaturesHeader)))
58+
} else if hasQueryFeatures {
59+
ctx = ghcontext.WithHeaderFeatures(ctx, headers.ParseCommaSeparated(query.Get(queryParamFeatures)))
8060
}
8161

8262
next.ServeHTTP(w, r.WithContext(ctx))

pkg/http/middleware/request_config_test.go

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
ghcontext "github.com/github/github-mcp-server/pkg/context"
99
"github.com/github/github-mcp-server/pkg/http/headers"
10+
"github.com/stretchr/testify/assert"
1011
)
1112

1213
func TestWithRequestConfigFeatureSelection(t *testing.T) {
@@ -16,52 +17,66 @@ func TestWithRequestConfigFeatureSelection(t *testing.T) {
1617
headerSet bool
1718
headerValue string
1819
wantFeatures []string
20+
wantPresent bool
1921
}{
2022
{
2123
name: "query parameter only",
2224
url: "/?features=mcp_holdback_consolidated_projects",
2325
wantFeatures: []string{"mcp_holdback_consolidated_projects"},
26+
wantPresent: true,
2427
},
2528
{
2629
name: "header only",
2730
url: "/",
2831
headerSet: true,
2932
headerValue: "mcp_holdback_consolidated_projects",
3033
wantFeatures: []string{"mcp_holdback_consolidated_projects"},
34+
wantPresent: true,
3135
},
3236
{
3337
name: "header wins over query parameter, never combined",
3438
url: "/?features=flag_from_query",
3539
headerSet: true,
3640
headerValue: "flag_from_header",
3741
wantFeatures: []string{"flag_from_header"},
42+
wantPresent: true,
3843
},
3944
{
4045
name: "empty header suppresses query parameter",
4146
url: "/?features=flag_from_query",
4247
headerSet: true,
4348
wantFeatures: []string{},
49+
wantPresent: true,
4450
},
4551
{
4652
name: "whitespace-only header suppresses query parameter",
4753
url: "/?features=flag_from_query",
4854
headerSet: true,
4955
headerValue: " , \t ",
5056
wantFeatures: []string{},
57+
wantPresent: true,
5158
},
5259
{
5360
name: "unknown header suppresses query parameter",
5461
url: "/?features=flag_from_query",
5562
headerSet: true,
5663
headerValue: "unknown_from_header",
5764
wantFeatures: []string{"unknown_from_header"},
65+
wantPresent: true,
5866
},
5967
{
6068
name: "empty query value with header",
6169
url: "/?features=",
6270
headerSet: true,
6371
headerValue: "flag_from_header",
6472
wantFeatures: []string{"flag_from_header"},
73+
wantPresent: true,
74+
},
75+
{
76+
name: "empty query value stores an explicit empty selection",
77+
url: "/?features=",
78+
wantFeatures: []string{},
79+
wantPresent: true,
6580
},
6681
{
6782
name: "no channel present stores nothing",
@@ -85,17 +100,13 @@ func TestWithRequestConfigFeatureSelection(t *testing.T) {
85100
rec := httptest.NewRecorder()
86101
handler.ServeHTTP(rec, req)
87102

88-
if tc.wantFeatures == nil && len(got) == 0 {
89-
return
90-
}
91-
if len(got) != len(tc.wantFeatures) {
92-
t.Fatalf("got features %v, want %v", got, tc.wantFeatures)
93-
}
94-
for i := range tc.wantFeatures {
95-
if got[i] != tc.wantFeatures[i] {
96-
t.Fatalf("got features %v, want %v", got, tc.wantFeatures)
97-
}
103+
assert.Equal(t, tc.wantFeatures, got)
104+
if tc.wantPresent {
105+
assert.NotNil(t, got)
106+
} else {
107+
assert.Nil(t, got)
98108
}
109+
assert.Contains(t, rec.Header().Values(headers.VaryHeader), headers.MCPFeaturesHeader)
99110
})
100111
}
101112
}

pkg/http/oauth/oauth.go

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -187,10 +187,6 @@ func ResolveResourcePath(r *http.Request, cfg *Config) string {
187187
}
188188

189189
// buildResourceURL constructs the full resource URL for OAuth metadata.
190-
// The request's query string is preserved: MCP clients that receive a server
191-
// URL such as /mcp/x/issues?features=... identify the protected resource by
192-
// exact string match against metadata.resource (RFC 9728), so dropping the
193-
// query would make standards-compliant clients reject the metadata.
194190
func (h *AuthHandler) buildResourceURL(r *http.Request, resourcePath string) string {
195191
host, scheme := GetEffectiveHostAndScheme(r, h.cfg)
196192
baseURL := fmt.Sprintf("%s://%s", scheme, host)
@@ -203,13 +199,12 @@ func (h *AuthHandler) buildResourceURL(r *http.Request, resourcePath string) str
203199
if !strings.HasPrefix(resourcePath, "/") {
204200
resourcePath = "/" + resourcePath
205201
}
206-
return AppendQuery(baseURL+resourcePath, r.URL.RawQuery)
202+
return appendRawQuery(baseURL+resourcePath, r.URL.RawQuery)
207203
}
208204

209-
// AppendQuery appends rawQuery to target when non-empty. It is shared by the
210-
// resource URL and the advertised metadata URL so both consistently carry the
211-
// same query string as the MCP server URL the client connects to.
212-
func AppendQuery(target, rawQuery string) string {
205+
// appendRawQuery avoids re-encoding the resource identifier that RFC 9728
206+
// clients compare as an exact string.
207+
func appendRawQuery(target, rawQuery string) string {
213208
if rawQuery == "" {
214209
return target
215210
}
@@ -268,9 +263,7 @@ func BuildResourceMetadataURL(r *http.Request, cfg *Config, resourcePath string)
268263
} else {
269264
metadataURL = fmt.Sprintf("%s://%s%s%s", scheme, host, OAuthProtectedResourcePrefix, suffix)
270265
}
271-
// Preserve the request query so the advertised metadata endpoint matches
272-
// the full resource identifier, including feature-flag query parameters.
273-
return AppendQuery(metadataURL, r.URL.RawQuery)
266+
return appendRawQuery(metadataURL, r.URL.RawQuery)
274267
}
275268

276269
func normalizeBasePath(path string) string {

0 commit comments

Comments
 (0)