diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go
index b8002d456..05c2c6e0b 100644
--- a/cmd/github-mcp-server/main.go
+++ b/cmd/github-mcp-server/main.go
@@ -61,6 +61,14 @@ var (
}
}
+ // Parse excluded tools (similar to tools)
+ var excludeTools []string
+ if viper.IsSet("exclude_tools") {
+ if err := viper.UnmarshalKey("exclude_tools", &excludeTools); err != nil {
+ return fmt.Errorf("failed to unmarshal exclude-tools: %w", err)
+ }
+ }
+
// Parse enabled features (similar to toolsets)
var enabledFeatures []string
if viper.IsSet("features") {
@@ -85,6 +93,7 @@ var (
ContentWindowSize: viper.GetInt("content-window-size"),
LockdownMode: viper.GetBool("lockdown-mode"),
InsidersMode: viper.GetBool("insiders"),
+ ExcludeTools: excludeTools,
RepoAccessCacheTTL: &ttl,
}
return ghmcp.RunStdioServer(stdioServerConfig)
@@ -126,6 +135,7 @@ func init() {
// Add global flags that will be shared by all commands
rootCmd.PersistentFlags().StringSlice("toolsets", nil, github.GenerateToolsetsHelp())
rootCmd.PersistentFlags().StringSlice("tools", nil, "Comma-separated list of specific tools to enable")
+ rootCmd.PersistentFlags().StringSlice("exclude-tools", nil, "Comma-separated list of tool names to disable regardless of other settings")
rootCmd.PersistentFlags().StringSlice("features", nil, "Comma-separated list of feature flags to enable")
rootCmd.PersistentFlags().Bool("dynamic-toolsets", false, "Enable dynamic toolsets")
rootCmd.PersistentFlags().Bool("read-only", false, "Restrict the server to read-only operations")
@@ -147,6 +157,7 @@ func init() {
// Bind flag to viper
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
_ = viper.BindPFlag("tools", rootCmd.PersistentFlags().Lookup("tools"))
+ _ = viper.BindPFlag("exclude_tools", rootCmd.PersistentFlags().Lookup("exclude-tools"))
_ = viper.BindPFlag("features", rootCmd.PersistentFlags().Lookup("features"))
_ = viper.BindPFlag("dynamic_toolsets", rootCmd.PersistentFlags().Lookup("dynamic-toolsets"))
_ = viper.BindPFlag("read-only", rootCmd.PersistentFlags().Lookup("read-only"))
diff --git a/docs/server-configuration.md b/docs/server-configuration.md
index 46ec3bc64..506ac0354 100644
--- a/docs/server-configuration.md
+++ b/docs/server-configuration.md
@@ -9,6 +9,7 @@ We currently support the following ways in which the GitHub MCP Server can be co
|---------------|---------------|--------------|
| Toolsets | `X-MCP-Toolsets` header or `/x/{toolset}` URL | `--toolsets` flag or `GITHUB_TOOLSETS` env var |
| Individual Tools | `X-MCP-Tools` header | `--tools` flag or `GITHUB_TOOLS` env var |
+| Exclude Tools | `X-MCP-Exclude-Tools` header | `--exclude-tools` flag or `GITHUB_EXCLUDE_TOOLS` env var |
| Read-Only Mode | `X-MCP-Readonly` header or `/readonly` URL | `--read-only` flag or `GITHUB_READ_ONLY` env var |
| Dynamic Mode | Not available | `--dynamic-toolsets` flag or `GITHUB_DYNAMIC_TOOLSETS` env var |
| Lockdown Mode | `X-MCP-Lockdown` header | `--lockdown-mode` flag or `GITHUB_LOCKDOWN_MODE` env var |
@@ -20,10 +21,12 @@ We currently support the following ways in which the GitHub MCP Server can be co
## How Configuration Works
-All configuration options are **composable**: you can combine toolsets, individual tools, dynamic discovery, read-only mode and lockdown mode in any way that suits your workflow.
+All configuration options are **composable**: you can combine toolsets, individual tools, excluded tools, dynamic discovery, read-only mode and lockdown mode in any way that suits your workflow.
Note: **read-only** mode acts as a strict security filter that takes precedence over any other configuration, by disabling write tools even when explicitly requested.
+Note: **excluded tools** takes precedence over toolsets and individual tools — listed tools are always excluded, even if their toolset is enabled or they are explicitly added via `--tools` / `X-MCP-Tools`.
+
---
## Configuration Examples
@@ -170,6 +173,56 @@ Enable entire toolsets, then add individual tools from toolsets you don't want f
---
+### Excluding Specific Tools
+
+**Best for:** Users who want to enable a broad toolset but need to exclude specific tools for security, compliance, or to prevent undesired behavior.
+
+Listed tools are removed regardless of any other configuration — even if their toolset is enabled or they are individually added.
+
+
+| Remote Server | Local Server |
+
+|
+
+```json
+{
+ "type": "http",
+ "url": "https://api.githubcopilot.com/mcp/",
+ "headers": {
+ "X-MCP-Toolsets": "pull_requests",
+ "X-MCP-Exclude-Tools": "create_pull_request,merge_pull_request"
+ }
+}
+```
+
+ |
+
+
+```json
+{
+ "type": "stdio",
+ "command": "go",
+ "args": [
+ "run",
+ "./cmd/github-mcp-server",
+ "stdio",
+ "--toolsets=pull_requests",
+ "--exclude-tools=create_pull_request,merge_pull_request"
+ ],
+ "env": {
+ "GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}"
+ }
+}
+```
+
+ |
+
+
+
+**Result:** All pull request tools except `create_pull_request` and `merge_pull_request` — the user gets read and review tools only.
+
+---
+
### Read-Only Mode
**Best for:** Security conscious users who want to ensure the server won't allow operations that modify issues, pull requests, repositories etc.
diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go
index 6f5ba4e45..5c4e7f6f1 100644
--- a/internal/ghmcp/server.go
+++ b/internal/ghmcp/server.go
@@ -135,6 +135,7 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se
WithReadOnly(cfg.ReadOnly).
WithToolsets(github.ResolvedEnabledToolsets(cfg.DynamicToolsets, cfg.EnabledToolsets, cfg.EnabledTools)).
WithTools(github.CleanTools(cfg.EnabledTools)).
+ WithExcludeTools(cfg.ExcludeTools).
WithServerInstructions().
WithFeatureChecker(featureChecker).
WithInsidersMode(cfg.InsidersMode)
@@ -214,6 +215,11 @@ type StdioServerConfig struct {
// InsidersMode indicates if we should enable experimental features
InsidersMode bool
+ // ExcludeTools is a list of tool names to disable regardless of other settings.
+ // These tools will be excluded even if their toolset is enabled or they are
+ // explicitly listed in EnabledTools.
+ ExcludeTools []string
+
// RepoAccessCacheTTL overrides the default TTL for repository access cache entries.
RepoAccessCacheTTL *time.Duration
}
@@ -271,6 +277,7 @@ func RunStdioServer(cfg StdioServerConfig) error {
ContentWindowSize: cfg.ContentWindowSize,
LockdownMode: cfg.LockdownMode,
InsidersMode: cfg.InsidersMode,
+ ExcludeTools: cfg.ExcludeTools,
Logger: logger,
RepoAccessTTL: cfg.RepoAccessCacheTTL,
TokenScopes: tokenScopes,
diff --git a/pkg/context/request.go b/pkg/context/request.go
index 70867f32e..9af925fc1 100644
--- a/pkg/context/request.go
+++ b/pkg/context/request.go
@@ -82,6 +82,22 @@ func IsInsidersMode(ctx context.Context) bool {
return false
}
+// excludeToolsCtxKey is a context key for excluded tools
+type excludeToolsCtxKey struct{}
+
+// WithExcludeTools adds the excluded tools to the context
+func WithExcludeTools(ctx context.Context, tools []string) context.Context {
+ return context.WithValue(ctx, excludeToolsCtxKey{}, tools)
+}
+
+// GetExcludeTools retrieves the excluded tools from the context
+func GetExcludeTools(ctx context.Context) []string {
+ if tools, ok := ctx.Value(excludeToolsCtxKey{}).([]string); ok {
+ return tools
+ }
+ return nil
+}
+
// headerFeaturesCtxKey is a context key for raw header feature flags
type headerFeaturesCtxKey struct{}
diff --git a/pkg/github/server.go b/pkg/github/server.go
index 14741939d..06c12575d 100644
--- a/pkg/github/server.go
+++ b/pkg/github/server.go
@@ -62,6 +62,11 @@ type MCPServerConfig struct {
// RepoAccessTTL overrides the default TTL for repository access cache entries.
RepoAccessTTL *time.Duration
+ // ExcludeTools is a list of tool names that should be disabled regardless of
+ // other configuration. These tools will be excluded even if their toolset is enabled
+ // or they are explicitly listed in EnabledTools.
+ ExcludeTools []string
+
// TokenScopes contains the OAuth scopes available to the token.
// When non-nil, tools requiring scopes not in this list will be hidden.
// This is used for PAT scope filtering where we can't issue scope challenges.
diff --git a/pkg/http/handler.go b/pkg/http/handler.go
index c4fcdec72..2e828211d 100644
--- a/pkg/http/handler.go
+++ b/pkg/http/handler.go
@@ -275,6 +275,10 @@ func InventoryFiltersForRequest(r *http.Request, builder *inventory.Builder) *in
builder = builder.WithTools(github.CleanTools(tools))
}
+ if excluded := ghcontext.GetExcludeTools(ctx); len(excluded) > 0 {
+ builder = builder.WithExcludeTools(excluded)
+ }
+
return builder
}
diff --git a/pkg/http/handler_test.go b/pkg/http/handler_test.go
index 32125f987..2a19e0a23 100644
--- a/pkg/http/handler_test.go
+++ b/pkg/http/handler_test.go
@@ -104,6 +104,31 @@ func TestInventoryFiltersForRequest(t *testing.T) {
},
expectedTools: []string{"get_file_contents", "create_repository", "list_issues"},
},
+ {
+ name: "excluded tools removes specific tools",
+ contextSetup: func(ctx context.Context) context.Context {
+ return ghcontext.WithExcludeTools(ctx, []string{"create_repository", "issue_write"})
+ },
+ expectedTools: []string{"get_file_contents", "list_issues"},
+ },
+ {
+ name: "excluded tools overrides explicit tools",
+ contextSetup: func(ctx context.Context) context.Context {
+ ctx = ghcontext.WithTools(ctx, []string{"list_issues", "create_repository"})
+ ctx = ghcontext.WithExcludeTools(ctx, []string{"create_repository"})
+ return ctx
+ },
+ expectedTools: []string{"list_issues"},
+ },
+ {
+ name: "excluded tools combines with readonly",
+ contextSetup: func(ctx context.Context) context.Context {
+ ctx = ghcontext.WithReadonly(ctx, true)
+ ctx = ghcontext.WithExcludeTools(ctx, []string{"list_issues"})
+ return ctx
+ },
+ expectedTools: []string{"get_file_contents"},
+ },
}
for _, tt := range tests {
@@ -267,6 +292,40 @@ func TestHTTPHandlerRoutes(t *testing.T) {
},
expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "create_issue", "list_pull_requests", "create_pull_request", "hidden_by_holdback"},
},
+ {
+ name: "X-MCP-Exclude-Tools header removes specific tools",
+ path: "/",
+ headers: map[string]string{
+ headers.MCPExcludeToolsHeader: "create_issue,create_pull_request",
+ },
+ expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "list_pull_requests", "hidden_by_holdback"},
+ },
+ {
+ name: "X-MCP-Exclude-Tools with toolset header",
+ path: "/",
+ headers: map[string]string{
+ headers.MCPToolsetsHeader: "issues",
+ headers.MCPExcludeToolsHeader: "create_issue",
+ },
+ expectedTools: []string{"list_issues"},
+ },
+ {
+ name: "X-MCP-Exclude-Tools overrides X-MCP-Tools",
+ path: "/",
+ headers: map[string]string{
+ headers.MCPToolsHeader: "list_issues,create_issue",
+ headers.MCPExcludeToolsHeader: "create_issue",
+ },
+ expectedTools: []string{"list_issues"},
+ },
+ {
+ name: "X-MCP-Exclude-Tools with readonly path",
+ path: "/readonly",
+ headers: map[string]string{
+ headers.MCPExcludeToolsHeader: "list_issues",
+ },
+ expectedTools: []string{"get_file_contents", "list_pull_requests", "hidden_by_holdback"},
+ },
}
for _, tt := range tests {
diff --git a/pkg/http/headers/headers.go b/pkg/http/headers/headers.go
index bbc46b43f..e032a0ce9 100644
--- a/pkg/http/headers/headers.go
+++ b/pkg/http/headers/headers.go
@@ -41,6 +41,9 @@ const (
MCPLockdownHeader = "X-MCP-Lockdown"
// MCPInsidersHeader indicates whether insiders mode is enabled for early access features.
MCPInsidersHeader = "X-MCP-Insiders"
+ // MCPExcludeToolsHeader is a comma-separated list of MCP tools that should be
+ // disabled regardless of other settings or header values.
+ MCPExcludeToolsHeader = "X-MCP-Exclude-Tools"
// MCPFeaturesHeader is a comma-separated list of feature flags to enable.
MCPFeaturesHeader = "X-MCP-Features"
diff --git a/pkg/http/middleware/request_config.go b/pkg/http/middleware/request_config.go
index 5cabe16eb..a7311334d 100644
--- a/pkg/http/middleware/request_config.go
+++ b/pkg/http/middleware/request_config.go
@@ -35,6 +35,11 @@ func WithRequestConfig(next http.Handler) http.Handler {
ctx = ghcontext.WithLockdownMode(ctx, true)
}
+ // Excluded tools
+ if excludeTools := headers.ParseCommaSeparated(r.Header.Get(headers.MCPExcludeToolsHeader)); len(excludeTools) > 0 {
+ ctx = ghcontext.WithExcludeTools(ctx, excludeTools)
+ }
+
// Insiders mode
if relaxedParseBool(r.Header.Get(headers.MCPInsidersHeader)) {
ctx = ghcontext.WithInsidersMode(ctx, true)
diff --git a/pkg/inventory/builder.go b/pkg/inventory/builder.go
index 6d2f080aa..d492e69b5 100644
--- a/pkg/inventory/builder.go
+++ b/pkg/inventory/builder.go
@@ -141,6 +141,19 @@ func (b *Builder) WithFilter(filter ToolFilter) *Builder {
return b
}
+// WithExcludeTools specifies tools that should be disabled regardless of other settings.
+// These tools will be excluded even if their toolset is enabled or they are in the
+// additional tools list. This takes precedence over all other tool enablement settings.
+// Input is cleaned (trimmed, deduplicated) before applying.
+// Returns self for chaining.
+func (b *Builder) WithExcludeTools(toolNames []string) *Builder {
+ cleaned := cleanTools(toolNames)
+ if len(cleaned) > 0 {
+ b.filters = append(b.filters, CreateExcludeToolsFilter(cleaned))
+ }
+ return b
+}
+
// WithInsidersMode enables or disables insiders mode features.
// When insiders mode is disabled (default), UI metadata is removed from tools
// so clients won't attempt to load UI resources.
@@ -150,6 +163,20 @@ func (b *Builder) WithInsidersMode(enabled bool) *Builder {
return b
}
+// CreateExcludeToolsFilter creates a ToolFilter that excludes tools by name.
+// Any tool whose name appears in the excluded list will be filtered out.
+// The input slice should already be cleaned (trimmed, deduplicated).
+func CreateExcludeToolsFilter(excluded []string) ToolFilter {
+ set := make(map[string]struct{}, len(excluded))
+ for _, name := range excluded {
+ set[name] = struct{}{}
+ }
+ return func(_ context.Context, tool *ServerTool) (bool, error) {
+ _, blocked := set[tool.Tool.Name]
+ return !blocked, nil
+ }
+}
+
// cleanTools trims whitespace and removes duplicates from tool names.
// Empty strings after trimming are excluded.
func cleanTools(tools []string) []string {
diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go
index fc380ab32..207e65dba 100644
--- a/pkg/inventory/registry_test.go
+++ b/pkg/inventory/registry_test.go
@@ -2129,3 +2129,151 @@ func TestWithInsidersMode_DoesNotMutateOriginalTools(t *testing.T) {
require.Equal(t, "data", tools[0].Tool.Meta["ui"], "original tool should not be mutated")
require.Equal(t, "kept", tools[0].Tool.Meta["description"], "original tool should not be mutated")
}
+
+func TestWithExcludeTools(t *testing.T) {
+ tools := []ServerTool{
+ mockTool("tool1", "toolset1", true),
+ mockTool("tool2", "toolset1", true),
+ mockTool("tool3", "toolset2", true),
+ }
+
+ tests := []struct {
+ name string
+ excluded []string
+ toolsets []string
+ expectedNames []string
+ unexpectedNames []string
+ }{
+ {
+ name: "single tool excluded",
+ excluded: []string{"tool2"},
+ toolsets: []string{"all"},
+ expectedNames: []string{"tool1", "tool3"},
+ unexpectedNames: []string{"tool2"},
+ },
+ {
+ name: "multiple tools excluded",
+ excluded: []string{"tool1", "tool3"},
+ toolsets: []string{"all"},
+ expectedNames: []string{"tool2"},
+ unexpectedNames: []string{"tool1", "tool3"},
+ },
+ {
+ name: "empty excluded list is a no-op",
+ excluded: []string{},
+ toolsets: []string{"all"},
+ expectedNames: []string{"tool1", "tool2", "tool3"},
+ unexpectedNames: nil,
+ },
+ {
+ name: "nil excluded list is a no-op",
+ excluded: nil,
+ toolsets: []string{"all"},
+ expectedNames: []string{"tool1", "tool2", "tool3"},
+ unexpectedNames: nil,
+ },
+ {
+ name: "excluding non-existent tool is a no-op",
+ excluded: []string{"nonexistent"},
+ toolsets: []string{"all"},
+ expectedNames: []string{"tool1", "tool2", "tool3"},
+ unexpectedNames: nil,
+ },
+ {
+ name: "exclude all tools",
+ excluded: []string{"tool1", "tool2", "tool3"},
+ toolsets: []string{"all"},
+ expectedNames: nil,
+ unexpectedNames: []string{"tool1", "tool2", "tool3"},
+ },
+ {
+ name: "whitespace is trimmed",
+ excluded: []string{" tool2 ", " tool3 "},
+ toolsets: []string{"all"},
+ expectedNames: []string{"tool1"},
+ unexpectedNames: []string{"tool2", "tool3"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ reg := mustBuild(t, NewBuilder().
+ SetTools(tools).
+ WithToolsets(tt.toolsets).
+ WithExcludeTools(tt.excluded))
+
+ available := reg.AvailableTools(context.Background())
+ names := make(map[string]bool)
+ for _, tool := range available {
+ names[tool.Tool.Name] = true
+ }
+
+ for _, expected := range tt.expectedNames {
+ require.True(t, names[expected], "tool %q should be available", expected)
+ }
+ for _, unexpected := range tt.unexpectedNames {
+ require.False(t, names[unexpected], "tool %q should be excluded", unexpected)
+ }
+ })
+ }
+}
+
+func TestWithExcludeTools_OverridesAdditionalTools(t *testing.T) {
+ tools := []ServerTool{
+ mockTool("tool1", "toolset1", true),
+ mockTool("tool2", "toolset1", true),
+ mockTool("tool3", "toolset2", true),
+ }
+
+ // tool3 is explicitly enabled via WithTools, but also excluded
+ // excluded should win because builder filters run before additional tools check
+ reg := mustBuild(t, NewBuilder().
+ SetTools(tools).
+ WithToolsets([]string{"toolset1"}).
+ WithTools([]string{"tool3"}).
+ WithExcludeTools([]string{"tool3"}))
+
+ available := reg.AvailableTools(context.Background())
+ names := make(map[string]bool)
+ for _, tool := range available {
+ names[tool.Tool.Name] = true
+ }
+
+ require.True(t, names["tool1"], "tool1 should be available")
+ require.True(t, names["tool2"], "tool2 should be available")
+ require.False(t, names["tool3"], "tool3 should be excluded even though explicitly added via WithTools")
+}
+
+func TestWithExcludeTools_CombinesWithReadOnly(t *testing.T) {
+ tools := []ServerTool{
+ mockTool("read_tool", "toolset1", true),
+ mockTool("write_tool", "toolset1", false),
+ mockTool("another_read", "toolset1", true),
+ }
+
+ // read-only excludes write_tool, exclude-tools excludes read_tool
+ reg := mustBuild(t, NewBuilder().
+ SetTools(tools).
+ WithToolsets([]string{"all"}).
+ WithReadOnly(true).
+ WithExcludeTools([]string{"read_tool"}))
+
+ available := reg.AvailableTools(context.Background())
+ require.Len(t, available, 1)
+ require.Equal(t, "another_read", available[0].Tool.Name)
+}
+
+func TestCreateExcludeToolsFilter(t *testing.T) {
+ filter := CreateExcludeToolsFilter([]string{"blocked_tool"})
+
+ blockedTool := mockTool("blocked_tool", "toolset1", true)
+ allowedTool := mockTool("allowed_tool", "toolset1", true)
+
+ allowed, err := filter(context.Background(), &blockedTool)
+ require.NoError(t, err)
+ require.False(t, allowed, "blocked_tool should be excluded")
+
+ allowed, err = filter(context.Background(), &allowedTool)
+ require.NoError(t, err)
+ require.True(t, allowed, "allowed_tool should be included")
+}