Skip to content

Commit 0c3dc68

Browse files
committed
fix(http): give feature header precedence over query
1 parent c5e3f6f commit 0c3dc68

3 files changed

Lines changed: 52 additions & 19 deletions

File tree

docs/feature-flags.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@ section in the Insiders docs](./insiders-features.md#how-feature-flags-are-resol
1919

2020
The URL query parameter exists for clients that compose the server URL on the
2121
user's behalf (hosted IDEs, agent platforms) and cannot set custom headers.
22-
When both the query parameter and the header are present, the query parameter
23-
wins — the two channels are never combined.
22+
When both the query parameter and the header are present, the header wins —
23+
even when its value is empty, whitespace-only, or contains only unknown flags.
24+
The two channels are never combined.
2425

2526
Only flags listed in
2627
[`AllowedFeatureFlags`](../pkg/github/feature_flags.go) can be enabled by

pkg/http/middleware/request_config.go

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ const queryParamFeatures = "features"
1818

1919
// WithRequestConfig is a middleware that extracts MCP-related headers and sets them in the request context.
2020
// 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 query parameter wins, matching how the toolset path
23-
// segments take precedence over their headers.
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.
2424
func WithRequestConfig(next http.Handler) http.Handler {
2525
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2626
ctx := r.Context()
@@ -57,18 +57,26 @@ func WithRequestConfig(next http.Handler) http.Handler {
5757

5858
// Feature flags: presence-based selection. The URL query parameter and
5959
// the X-MCP-Features header are separate channels — whichever is
60-
// present is used as-is, and they are never combined. When both are
61-
// present the query parameter wins, so a client composing the server
62-
// URL can always express its intent even when it cannot control
63-
// headers. Unknown flags are dropped later by ResolveFeatureFlags
64-
// against AllowedFeatureFlags, so neither channel is privileged.
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.
6565
queryFeatures, hasQuery := r.URL.Query()[queryParamFeatures]
66-
headerFeatures := r.Header.Get(headers.MCPFeaturesHeader)
66+
headerFeatures, hasHeader := r.Header[http.CanonicalHeaderKey(headers.MCPFeaturesHeader)]
6767
switch {
68-
case hasQuery && strings.TrimSpace(queryFeatures[0]) != "":
69-
ctx = ghcontext.WithHeaderFeatures(ctx, headers.ParseCommaSeparated(queryFeatures[0]))
70-
case headerFeatures != "":
71-
ctx = ghcontext.WithHeaderFeatures(ctx, headers.ParseCommaSeparated(headerFeatures))
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))
7280
}
7381

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

pkg/http/middleware/request_config_test.go

Lines changed: 28 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ func TestWithRequestConfigFeatureSelection(t *testing.T) {
1313
tests := []struct {
1414
name string
1515
url string
16+
headerSet bool
1617
headerValue string
1718
wantFeatures []string
1819
}{
@@ -24,18 +25,41 @@ func TestWithRequestConfigFeatureSelection(t *testing.T) {
2425
{
2526
name: "header only",
2627
url: "/",
28+
headerSet: true,
2729
headerValue: "mcp_holdback_consolidated_projects",
2830
wantFeatures: []string{"mcp_holdback_consolidated_projects"},
2931
},
3032
{
31-
name: "query parameter wins over header, never combined",
33+
name: "header wins over query parameter, never combined",
3234
url: "/?features=flag_from_query",
35+
headerSet: true,
3336
headerValue: "flag_from_header",
34-
wantFeatures: []string{"flag_from_query"},
37+
wantFeatures: []string{"flag_from_header"},
38+
},
39+
{
40+
name: "empty header suppresses query parameter",
41+
url: "/?features=flag_from_query",
42+
headerSet: true,
43+
wantFeatures: []string{},
44+
},
45+
{
46+
name: "whitespace-only header suppresses query parameter",
47+
url: "/?features=flag_from_query",
48+
headerSet: true,
49+
headerValue: " , \t ",
50+
wantFeatures: []string{},
51+
},
52+
{
53+
name: "unknown header suppresses query parameter",
54+
url: "/?features=flag_from_query",
55+
headerSet: true,
56+
headerValue: "unknown_from_header",
57+
wantFeatures: []string{"unknown_from_header"},
3558
},
3659
{
37-
name: "empty query value falls back to header",
60+
name: "empty query value with header",
3861
url: "/?features=",
62+
headerSet: true,
3963
headerValue: "flag_from_header",
4064
wantFeatures: []string{"flag_from_header"},
4165
},
@@ -55,7 +79,7 @@ func TestWithRequestConfigFeatureSelection(t *testing.T) {
5579
}))
5680

5781
req := httptest.NewRequest(http.MethodPost, tc.url, nil)
58-
if tc.headerValue != "" {
82+
if tc.headerSet {
5983
req.Header.Set(headers.MCPFeaturesHeader, tc.headerValue)
6084
}
6185
rec := httptest.NewRecorder()

0 commit comments

Comments
 (0)