Skip to content

Commit 76eddd5

Browse files
committed
feat(http): enable feature flags via URL query parameter
Feature flags could previously only be enabled per request through the X-MCP-Features header (or statically via --features / GITHUB_FEATURES). Clients that compose the server URL on the user's behalf - hosted IDEs, agent platforms, harnesses that provision the MCP connection - cannot set custom headers, which made every flagged tool unreachable on those connections (#3145). Accept a `features` URL query parameter as an additional channel. When both are present the query parameter wins over the header, matching how the toolset path segments take precedence over their headers. Validation is unchanged: ResolveFeatureFlags still filters every user-supplied flag against AllowedFeatureFlags, so a URL-supplied flag is no more privileged than a header-supplied one. Example: /mcp/x/issues?features=issue_dependencies
1 parent 64a49f3 commit 76eddd5

5 files changed

Lines changed: 136 additions & 3 deletions

File tree

docs/feature-flags.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,17 @@ section in the Insiders docs](./insiders-features.md#how-feature-flags-are-resol
1313
| Method | Remote Server | Local Server |
1414
|--------|---------------|--------------|
1515
| Header | `X-MCP-Features: <flag>,<flag>` | N/A |
16+
| URL query parameter | `https://api.githubcopilot.com/mcp?features=<flag>,<flag>` | N/A |
1617
| CLI flag | N/A | `--features=<flag>,<flag>` |
1718
| Environment variable | N/A | `GITHUB_FEATURES=<flag>,<flag>` |
1819

20+
The URL query parameter exists for clients that compose the server URL on the
21+
user's behalf (hosted IDEs, agent platforms) and cannot set custom headers on
22+
the MCP connection. When both are present, the query parameter wins over the
23+
header. It combines freely with the other URL-based selectors, for example
24+
`/mcp/x/issues?features=issue_dependencies`. Unknown flags are silently
25+
ignored, exactly as with the header.
26+
1927
Only flags listed in
2028
[`AllowedFeatureFlags`](../pkg/github/feature_flags.go) can be enabled by
2129
end users. Insiders-only flags are not user-toggleable.

docs/server-configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ We currently support the following ways in which the GitHub MCP Server can be co
1313
| Read-Only Mode | `X-MCP-Readonly` header or `/readonly` URL | `--read-only` flag or `GITHUB_READ_ONLY` env var |
1414
| Lockdown Mode | `X-MCP-Lockdown` header | `--lockdown-mode` flag or `GITHUB_LOCKDOWN_MODE` env var |
1515
| Insiders Mode | `X-MCP-Insiders` header or `/insiders` URL | `--insiders` flag or `GITHUB_INSIDERS` env var |
16-
| Feature Flags | `X-MCP-Features` header | `--features` flag |
16+
| Feature Flags | `X-MCP-Features` header or `?features=` URL query parameter | `--features` flag |
1717
| Scope Filtering | Always enabled | Always enabled |
1818
| Server Name/Title | Not available | `GITHUB_MCP_SERVER_NAME` / `GITHUB_MCP_SERVER_TITLE` env vars or `github-mcp-server-config.json` |
1919

pkg/http/handler_test.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,32 @@ 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 flagged tool",
310+
path: "/?features=mcp_holdback_consolidated_projects",
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 takes precedence over header",
315+
path: "/?features=mcp_holdback_consolidated_projects",
316+
headers: map[string]string{
317+
headers.MCPFeaturesHeader: "unknown_flag",
318+
},
319+
expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "needs_holdback"},
320+
},
321+
{
322+
name: "header takes effect when query parameter absent",
323+
path: "/",
324+
headers: map[string]string{
325+
headers.MCPFeaturesHeader: "mcp_holdback_consolidated_projects",
326+
},
327+
expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "needs_holdback"},
328+
},
329+
{
330+
name: "features query parameter combines with toolset path",
331+
path: "/x/repos?features=mcp_holdback_consolidated_projects",
332+
expectedTools: []string{"get_file_contents", "create_repository", "needs_holdback"},
333+
},
308334
{
309335
name: "X-MCP-Exclude-Tools header removes specific tools",
310336
path: "/",

pkg/http/middleware/request_config.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,18 @@ 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.
17+
const queryParamFeatures = "features"
18+
1219
// WithRequestConfig is a middleware that extracts MCP-related headers and sets them in the request context.
1320
// 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+
// present it takes precedence over the header, matching how the toolset path
23+
// segments take precedence over their headers.
1424
func WithRequestConfig(next http.Handler) http.Handler {
1525
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
1626
ctx := r.Context()
@@ -45,8 +55,16 @@ func WithRequestConfig(next http.Handler) http.Handler {
4555
ctx = ghcontext.WithInsidersMode(ctx, true)
4656
}
4757

48-
// Feature flags
49-
if features := headers.ParseCommaSeparated(r.Header.Get(headers.MCPFeaturesHeader)); len(features) > 0 {
58+
// Feature flags: the URL query parameter wins over the header, so a
59+
// client composing the server URL can always express its intent even
60+
// when it cannot control headers. Unknown flags are dropped later by
61+
// ResolveFeatureFlags against AllowedFeatureFlags, so this channel is
62+
// no more privileged than the header — it is the same allowlist.
63+
features := headers.ParseCommaSeparated(r.URL.Query().Get(queryParamFeatures))
64+
if len(features) == 0 {
65+
features = headers.ParseCommaSeparated(r.Header.Get(headers.MCPFeaturesHeader))
66+
}
67+
if len(features) > 0 {
5068
ctx = ghcontext.WithHeaderFeatures(ctx, features)
5169
}
5270

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package middleware
2+
3+
import (
4+
"net/http"
5+
"net/http/httptest"
6+
"testing"
7+
8+
ghcontext "github.com/github/github-mcp-server/pkg/context"
9+
"github.com/github/github-mcp-server/pkg/http/headers"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
func captureFeatureFeatures(t *testing.T, r *http.Request) []string {
15+
t.Helper()
16+
17+
var captured []string
18+
handler := WithRequestConfig(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
19+
captured = ghcontext.GetHeaderFeatures(req.Context())
20+
w.WriteHeader(http.StatusOK)
21+
}))
22+
23+
rec := httptest.NewRecorder()
24+
handler.ServeHTTP(rec, r)
25+
require.Equal(t, http.StatusOK, rec.Code)
26+
return captured
27+
}
28+
29+
func TestWithRequestConfigFeatureFlags(t *testing.T) {
30+
tests := []struct {
31+
name string
32+
path string
33+
header string
34+
wantFeatures []string
35+
}{
36+
{
37+
name: "no flags anywhere",
38+
path: "/",
39+
wantFeatures: nil,
40+
},
41+
{
42+
name: "header only",
43+
path: "/",
44+
header: "flag_a,flag_b",
45+
wantFeatures: []string{"flag_a", "flag_b"},
46+
},
47+
{
48+
name: "query parameter only",
49+
path: "/?features=flag_a",
50+
wantFeatures: []string{"flag_a"},
51+
},
52+
{
53+
name: "query parameter takes precedence over header",
54+
path: "/?features=from_url",
55+
header: "from_header",
56+
wantFeatures: []string{"from_url"},
57+
},
58+
{
59+
name: "empty query parameter falls back to header",
60+
path: "/?features=",
61+
header: "from_header",
62+
wantFeatures: []string{"from_header"},
63+
},
64+
{
65+
name: "query parameter coexists with other params",
66+
path: "/?foo=bar&features=flag_a&baz=qux",
67+
wantFeatures: []string{"flag_a"},
68+
},
69+
}
70+
71+
for _, tt := range tests {
72+
t.Run(tt.name, func(t *testing.T) {
73+
r := httptest.NewRequest(http.MethodPost, tt.path, http.NoBody)
74+
if tt.header != "" {
75+
r.Header.Set(headers.MCPFeaturesHeader, tt.header)
76+
}
77+
got := captureFeatureFeatures(t, r)
78+
assert.ElementsMatch(t, tt.wantFeatures, got)
79+
})
80+
}
81+
}

0 commit comments

Comments
 (0)