Skip to content

Commit 0e0feeb

Browse files
feat(http): enforce static CLI flags as upper bound for per-request filtering
The HTTP server now respects the same static CLI flags as the stdio server: --toolsets, --tools, --exclude-tools, --read-only, --dynamic-toolsets, and --insiders. A static inventory is built once at startup from these flags, producing a pre-filtered tool/resource/prompt universe. Per-request headers (X-MCP-Toolsets, X-MCP-Tools, etc.) can only narrow within these bounds, never expand beyond them. When no static flags are set, the existing behavior is preserved — headers have full access to all toolsets. Fixes #2156 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 1da41fa commit 0e0feeb

File tree

4 files changed

+415
-4
lines changed

4 files changed

+415
-4
lines changed

cmd/github-mcp-server/main.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,28 @@ var (
105105
Short: "Start HTTP server",
106106
Long: `Start an HTTP server that listens for MCP requests over HTTP.`,
107107
RunE: func(_ *cobra.Command, _ []string) error {
108+
// Parse toolsets (same approach as stdio — see comment there)
109+
var enabledToolsets []string
110+
if viper.IsSet("toolsets") {
111+
if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil {
112+
return fmt.Errorf("failed to unmarshal toolsets: %w", err)
113+
}
114+
}
115+
116+
var enabledTools []string
117+
if viper.IsSet("tools") {
118+
if err := viper.UnmarshalKey("tools", &enabledTools); err != nil {
119+
return fmt.Errorf("failed to unmarshal tools: %w", err)
120+
}
121+
}
122+
123+
var excludeTools []string
124+
if viper.IsSet("exclude_tools") {
125+
if err := viper.UnmarshalKey("exclude_tools", &excludeTools); err != nil {
126+
return fmt.Errorf("failed to unmarshal exclude-tools: %w", err)
127+
}
128+
}
129+
108130
ttl := viper.GetDuration("repo-access-cache-ttl")
109131
httpConfig := ghhttp.ServerConfig{
110132
Version: version,
@@ -119,6 +141,12 @@ var (
119141
LockdownMode: viper.GetBool("lockdown-mode"),
120142
RepoAccessCacheTTL: &ttl,
121143
ScopeChallenge: viper.GetBool("scope-challenge"),
144+
ReadOnly: viper.GetBool("read-only"),
145+
EnabledToolsets: enabledToolsets,
146+
EnabledTools: enabledTools,
147+
DynamicToolsets: viper.GetBool("dynamic_toolsets"),
148+
ExcludeTools: excludeTools,
149+
InsidersMode: viper.GetBool("insiders"),
122150
}
123151

124152
return ghhttp.RunHTTPServer(httpConfig)

pkg/http/handler.go

Lines changed: 109 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,13 +236,56 @@ func DefaultGitHubMCPServerFactory(r *http.Request, deps github.ToolDependencies
236236
return github.NewMCPServer(r.Context(), cfg, deps, inventory)
237237
}
238238

239-
// DefaultInventoryFactory creates the default inventory factory for HTTP mode
240-
func DefaultInventoryFactory(_ *ServerConfig, t translations.TranslationHelperFunc, featureChecker inventory.FeatureFlagChecker, scopeFetcher scopes.FetcherInterface) InventoryFactoryFunc {
239+
// DefaultInventoryFactory creates the default inventory factory for HTTP mode.
240+
// When the ServerConfig includes static flags (--toolsets, --read-only, etc.),
241+
// a static inventory is built once at factory creation to pre-filter the tool
242+
// universe. Per-request headers can only narrow within these bounds.
243+
func DefaultInventoryFactory(cfg *ServerConfig, t translations.TranslationHelperFunc, featureChecker inventory.FeatureFlagChecker, scopeFetcher scopes.FetcherInterface) InventoryFactoryFunc {
244+
// Build the static tool/resource/prompt universe from CLI flags.
245+
// This is done once at startup and captured in the closure.
246+
staticTools, staticResources, staticPrompts := buildStaticInventory(cfg, t, featureChecker)
247+
hasStaticFilters := hasStaticConfig(cfg)
248+
249+
// Pre-compute valid tool names for filtering per-request tool headers.
250+
// When a request asks for a tool by name that's been excluded from the
251+
// static universe, we silently drop it rather than returning an error.
252+
validToolNames := make(map[string]bool, len(staticTools))
253+
for i := range staticTools {
254+
validToolNames[staticTools[i].Tool.Name] = true
255+
}
256+
241257
return func(r *http.Request) (*inventory.Inventory, error) {
242-
b := github.NewInventory(t).
258+
b := inventory.NewBuilder().
259+
SetTools(staticTools).
260+
SetResources(staticResources).
261+
SetPrompts(staticPrompts).
243262
WithDeprecatedAliases(github.DeprecatedToolAliases).
244263
WithFeatureChecker(featureChecker)
245264

265+
// When static flags constrain the universe, default to showing
266+
// everything within those bounds (per-request filters narrow further).
267+
// When no static flags are set, preserve existing behavior where
268+
// the default toolsets apply.
269+
if hasStaticFilters {
270+
b = b.WithToolsets([]string{"all"})
271+
}
272+
273+
// Static read-only is an upper bound — enforce before request filters
274+
if cfg.ReadOnly {
275+
b = b.WithReadOnly(true)
276+
}
277+
278+
// Static insiders mode — enforce before request filters
279+
if cfg.InsidersMode {
280+
b = b.WithInsidersMode(true)
281+
}
282+
283+
// Filter request tool names to only those in the static universe,
284+
// so requests for statically-excluded tools degrade gracefully.
285+
if hasStaticFilters {
286+
r = filterRequestTools(r, validToolNames)
287+
}
288+
246289
b = InventoryFiltersForRequest(r, b)
247290
b = PATScopeFilter(b, r, scopeFetcher)
248291

@@ -252,6 +295,69 @@ func DefaultInventoryFactory(_ *ServerConfig, t translations.TranslationHelperFu
252295
}
253296
}
254297

298+
// filterRequestTools returns a shallow copy of the request with any per-request
299+
// tool names (from X-MCP-Tools header) filtered to only include tools that exist
300+
// in validNames. This ensures requests for statically-excluded tools are silently
301+
// ignored rather than causing build errors.
302+
func filterRequestTools(r *http.Request, validNames map[string]bool) *http.Request {
303+
reqTools := ghcontext.GetTools(r.Context())
304+
if len(reqTools) == 0 {
305+
return r
306+
}
307+
308+
filtered := make([]string, 0, len(reqTools))
309+
for _, name := range reqTools {
310+
if validNames[name] {
311+
filtered = append(filtered, name)
312+
}
313+
}
314+
ctx := ghcontext.WithTools(r.Context(), filtered)
315+
return r.WithContext(ctx)
316+
}
317+
318+
// hasStaticConfig returns true if any static filtering flags are set on the ServerConfig.
319+
func hasStaticConfig(cfg *ServerConfig) bool {
320+
return cfg.ReadOnly ||
321+
cfg.EnabledToolsets != nil ||
322+
cfg.EnabledTools != nil ||
323+
cfg.DynamicToolsets ||
324+
len(cfg.ExcludeTools) > 0 ||
325+
cfg.InsidersMode
326+
}
327+
328+
// buildStaticInventory pre-filters the full tool/resource/prompt universe using
329+
// the static CLI flags (--toolsets, --read-only, --exclude-tools, etc.).
330+
// The returned slices serve as the upper bound for per-request inventory builders.
331+
func buildStaticInventory(cfg *ServerConfig, t translations.TranslationHelperFunc, featureChecker inventory.FeatureFlagChecker) ([]inventory.ServerTool, []inventory.ServerResourceTemplate, []inventory.ServerPrompt) {
332+
if !hasStaticConfig(cfg) {
333+
return github.AllTools(t), github.AllResources(t), github.AllPrompts(t)
334+
}
335+
336+
b := github.NewInventory(t).
337+
WithFeatureChecker(featureChecker).
338+
WithReadOnly(cfg.ReadOnly).
339+
WithToolsets(github.ResolvedEnabledToolsets(cfg.DynamicToolsets, cfg.EnabledToolsets, cfg.EnabledTools)).
340+
WithInsidersMode(cfg.InsidersMode)
341+
342+
if len(cfg.EnabledTools) > 0 {
343+
b = b.WithTools(github.CleanTools(cfg.EnabledTools))
344+
}
345+
346+
if len(cfg.ExcludeTools) > 0 {
347+
b = b.WithExcludeTools(cfg.ExcludeTools)
348+
}
349+
350+
inv, err := b.Build()
351+
if err != nil {
352+
// Fall back to all tools if there's an error (e.g. unknown tool names).
353+
// The error will surface again at per-request time if relevant.
354+
return github.AllTools(t), github.AllResources(t), github.AllPrompts(t)
355+
}
356+
357+
ctx := context.Background()
358+
return inv.AvailableTools(ctx), inv.AvailableResourceTemplates(ctx), inv.AvailablePrompts(ctx)
359+
}
360+
255361
// InventoryFiltersForRequest applies filters to the inventory builder
256362
// based on the request context and headers
257363
func InventoryFiltersForRequest(r *http.Request, builder *inventory.Builder) *inventory.Builder {

0 commit comments

Comments
 (0)