Skip to content

Commit 0b5601b

Browse files
committed
add disallowed-tools flag to enable shuting off tools as part of server configuration
1 parent 851030c commit 0b5601b

File tree

6 files changed

+252
-1
lines changed

6 files changed

+252
-1
lines changed

cmd/github-mcp-server/main.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ var (
6161
}
6262
}
6363

64+
// Parse disallowed tools (similar to tools)
65+
var disallowedTools []string
66+
if viper.IsSet("disallowed_tools") {
67+
if err := viper.UnmarshalKey("disallowed_tools", &disallowedTools); err != nil {
68+
return fmt.Errorf("failed to unmarshal disallowed-tools: %w", err)
69+
}
70+
}
71+
6472
// Parse enabled features (similar to toolsets)
6573
var enabledFeatures []string
6674
if viper.IsSet("features") {
@@ -85,6 +93,7 @@ var (
8593
ContentWindowSize: viper.GetInt("content-window-size"),
8694
LockdownMode: viper.GetBool("lockdown-mode"),
8795
InsidersMode: viper.GetBool("insiders"),
96+
DisallowedTools: disallowedTools,
8897
RepoAccessCacheTTL: &ttl,
8998
}
9099
return ghmcp.RunStdioServer(stdioServerConfig)
@@ -126,6 +135,7 @@ func init() {
126135
// Add global flags that will be shared by all commands
127136
rootCmd.PersistentFlags().StringSlice("toolsets", nil, github.GenerateToolsetsHelp())
128137
rootCmd.PersistentFlags().StringSlice("tools", nil, "Comma-separated list of specific tools to enable")
138+
rootCmd.PersistentFlags().StringSlice("disallowed-tools", nil, "Comma-separated list of tool names to disable regardless of other settings")
129139
rootCmd.PersistentFlags().StringSlice("features", nil, "Comma-separated list of feature flags to enable")
130140
rootCmd.PersistentFlags().Bool("dynamic-toolsets", false, "Enable dynamic toolsets")
131141
rootCmd.PersistentFlags().Bool("read-only", false, "Restrict the server to read-only operations")
@@ -147,6 +157,7 @@ func init() {
147157
// Bind flag to viper
148158
_ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets"))
149159
_ = viper.BindPFlag("tools", rootCmd.PersistentFlags().Lookup("tools"))
160+
_ = viper.BindPFlag("disallowed_tools", rootCmd.PersistentFlags().Lookup("disallowed-tools"))
150161
_ = viper.BindPFlag("features", rootCmd.PersistentFlags().Lookup("features"))
151162
_ = viper.BindPFlag("dynamic_toolsets", rootCmd.PersistentFlags().Lookup("dynamic-toolsets"))
152163
_ = viper.BindPFlag("read-only", rootCmd.PersistentFlags().Lookup("read-only"))

docs/server-configuration.md

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ We currently support the following ways in which the GitHub MCP Server can be co
99
|---------------|---------------|--------------|
1010
| Toolsets | `X-MCP-Toolsets` header or `/x/{toolset}` URL | `--toolsets` flag or `GITHUB_TOOLSETS` env var |
1111
| Individual Tools | `X-MCP-Tools` header | `--tools` flag or `GITHUB_TOOLS` env var |
12+
| Disallowed Tools | `X-MCP-Disallowed-Tools` header | `--disallowed-tools` flag or `GITHUB_DISALLOWED_TOOLS` env var |
1213
| Read-Only Mode | `X-MCP-Readonly` header or `/readonly` URL | `--read-only` flag or `GITHUB_READ_ONLY` env var |
1314
| Dynamic Mode | Not available | `--dynamic-toolsets` flag or `GITHUB_DYNAMIC_TOOLSETS` env var |
1415
| 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
2021

2122
## How Configuration Works
2223

23-
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.
24+
All configuration options are **composable**: you can combine toolsets, individual tools, disallowed tools, dynamic discovery, read-only mode and lockdown mode in any way that suits your workflow.
2425

2526
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.
2627

28+
Note: **disallowed 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`.
29+
2730
---
2831

2932
## Configuration Examples
@@ -170,6 +173,56 @@ Enable entire toolsets, then add individual tools from toolsets you don't want f
170173

171174
---
172175

176+
### Disallowing Specific Tools
177+
178+
**Best for:** Users who want to enable a broad toolset but need to exclude specific tools for security, compliance, or to prevent undesired behavior.
179+
180+
Listed tools are removed regardless of any other configuration — even if their toolset is enabled or they are individually added.
181+
182+
<table>
183+
<tr><th>Remote Server</th><th>Local Server</th></tr>
184+
<tr valign="top">
185+
<td>
186+
187+
```json
188+
{
189+
"type": "http",
190+
"url": "https://api.githubcopilot.com/mcp/",
191+
"headers": {
192+
"X-MCP-Toolsets": "pull_requests",
193+
"X-MCP-Disallowed-Tools": "create_pull_request,merge_pull_request"
194+
}
195+
}
196+
```
197+
198+
</td>
199+
<td>
200+
201+
```json
202+
{
203+
"type": "stdio",
204+
"command": "go",
205+
"args": [
206+
"run",
207+
"./cmd/github-mcp-server",
208+
"stdio",
209+
"--toolsets=pull_requests",
210+
"--disallowed-tools=create_pull_request,merge_pull_request"
211+
],
212+
"env": {
213+
"GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}"
214+
}
215+
}
216+
```
217+
218+
</td>
219+
</tr>
220+
</table>
221+
222+
**Result:** All pull request tools except `create_pull_request` and `merge_pull_request` — the user gets read and review tools only.
223+
224+
---
225+
173226
### Read-Only Mode
174227

175228
**Best for:** Security conscious users who want to ensure the server won't allow operations that modify issues, pull requests, repositories etc.

internal/ghmcp/server.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se
135135
WithReadOnly(cfg.ReadOnly).
136136
WithToolsets(github.ResolvedEnabledToolsets(cfg.DynamicToolsets, cfg.EnabledToolsets, cfg.EnabledTools)).
137137
WithTools(github.CleanTools(cfg.EnabledTools)).
138+
WithDisallowedTools(cfg.DisallowedTools).
138139
WithServerInstructions().
139140
WithFeatureChecker(featureChecker).
140141
WithInsidersMode(cfg.InsidersMode)
@@ -214,6 +215,11 @@ type StdioServerConfig struct {
214215
// InsidersMode indicates if we should enable experimental features
215216
InsidersMode bool
216217

218+
// DisallowedTools is a list of tool names to disable regardless of other settings.
219+
// These tools will be excluded even if their toolset is enabled or they are
220+
// explicitly listed in EnabledTools.
221+
DisallowedTools []string
222+
217223
// RepoAccessCacheTTL overrides the default TTL for repository access cache entries.
218224
RepoAccessCacheTTL *time.Duration
219225
}
@@ -271,6 +277,7 @@ func RunStdioServer(cfg StdioServerConfig) error {
271277
ContentWindowSize: cfg.ContentWindowSize,
272278
LockdownMode: cfg.LockdownMode,
273279
InsidersMode: cfg.InsidersMode,
280+
DisallowedTools: cfg.DisallowedTools,
274281
Logger: logger,
275282
RepoAccessTTL: cfg.RepoAccessCacheTTL,
276283
TokenScopes: tokenScopes,

pkg/github/server.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ type MCPServerConfig struct {
6262
// RepoAccessTTL overrides the default TTL for repository access cache entries.
6363
RepoAccessTTL *time.Duration
6464

65+
// DisallowedTools is a list of tool names that should be disabled regardless of
66+
// other configuration. These tools will be excluded even if their toolset is enabled
67+
// or they are explicitly listed in EnabledTools.
68+
DisallowedTools []string
69+
6570
// TokenScopes contains the OAuth scopes available to the token.
6671
// When non-nil, tools requiring scopes not in this list will be hidden.
6772
// This is used for PAT scope filtering where we can't issue scope challenges.

pkg/inventory/builder.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,19 @@ func (b *Builder) WithFilter(filter ToolFilter) *Builder {
141141
return b
142142
}
143143

144+
// WithDisallowedTools specifies tools that should be disabled regardless of other settings.
145+
// These tools will be excluded even if their toolset is enabled or they are in the
146+
// additional tools list. This takes precedence over all other tool enablement settings.
147+
// Input is cleaned (trimmed, deduplicated) before applying.
148+
// Returns self for chaining.
149+
func (b *Builder) WithDisallowedTools(toolNames []string) *Builder {
150+
cleaned := cleanTools(toolNames)
151+
if len(cleaned) > 0 {
152+
b.filters = append(b.filters, CreateDisallowedToolsFilter(cleaned))
153+
}
154+
return b
155+
}
156+
144157
// WithInsidersMode enables or disables insiders mode features.
145158
// When insiders mode is disabled (default), UI metadata is removed from tools
146159
// so clients won't attempt to load UI resources.
@@ -150,6 +163,20 @@ func (b *Builder) WithInsidersMode(enabled bool) *Builder {
150163
return b
151164
}
152165

166+
// CreateDisallowedToolsFilter creates a ToolFilter that excludes tools by name.
167+
// Any tool whose name appears in the disallowed list will be filtered out.
168+
// The input slice should already be cleaned (trimmed, deduplicated).
169+
func CreateDisallowedToolsFilter(disallowed []string) ToolFilter {
170+
set := make(map[string]struct{}, len(disallowed))
171+
for _, name := range disallowed {
172+
set[name] = struct{}{}
173+
}
174+
return func(_ context.Context, tool *ServerTool) (bool, error) {
175+
_, blocked := set[tool.Tool.Name]
176+
return !blocked, nil
177+
}
178+
}
179+
153180
// cleanTools trims whitespace and removes duplicates from tool names.
154181
// Empty strings after trimming are excluded.
155182
func cleanTools(tools []string) []string {

pkg/inventory/registry_test.go

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2129,3 +2129,151 @@ func TestWithInsidersMode_DoesNotMutateOriginalTools(t *testing.T) {
21292129
require.Equal(t, "data", tools[0].Tool.Meta["ui"], "original tool should not be mutated")
21302130
require.Equal(t, "kept", tools[0].Tool.Meta["description"], "original tool should not be mutated")
21312131
}
2132+
2133+
func TestWithDisallowedTools(t *testing.T) {
2134+
tools := []ServerTool{
2135+
mockTool("tool1", "toolset1", true),
2136+
mockTool("tool2", "toolset1", true),
2137+
mockTool("tool3", "toolset2", true),
2138+
}
2139+
2140+
tests := []struct {
2141+
name string
2142+
disallowed []string
2143+
toolsets []string
2144+
expectedNames []string
2145+
unexpectedNames []string
2146+
}{
2147+
{
2148+
name: "single tool disallowed",
2149+
disallowed: []string{"tool2"},
2150+
toolsets: []string{"all"},
2151+
expectedNames: []string{"tool1", "tool3"},
2152+
unexpectedNames: []string{"tool2"},
2153+
},
2154+
{
2155+
name: "multiple tools disallowed",
2156+
disallowed: []string{"tool1", "tool3"},
2157+
toolsets: []string{"all"},
2158+
expectedNames: []string{"tool2"},
2159+
unexpectedNames: []string{"tool1", "tool3"},
2160+
},
2161+
{
2162+
name: "empty disallowed list is a no-op",
2163+
disallowed: []string{},
2164+
toolsets: []string{"all"},
2165+
expectedNames: []string{"tool1", "tool2", "tool3"},
2166+
unexpectedNames: nil,
2167+
},
2168+
{
2169+
name: "nil disallowed list is a no-op",
2170+
disallowed: nil,
2171+
toolsets: []string{"all"},
2172+
expectedNames: []string{"tool1", "tool2", "tool3"},
2173+
unexpectedNames: nil,
2174+
},
2175+
{
2176+
name: "disallowing non-existent tool is a no-op",
2177+
disallowed: []string{"nonexistent"},
2178+
toolsets: []string{"all"},
2179+
expectedNames: []string{"tool1", "tool2", "tool3"},
2180+
unexpectedNames: nil,
2181+
},
2182+
{
2183+
name: "disallow all tools",
2184+
disallowed: []string{"tool1", "tool2", "tool3"},
2185+
toolsets: []string{"all"},
2186+
expectedNames: nil,
2187+
unexpectedNames: []string{"tool1", "tool2", "tool3"},
2188+
},
2189+
{
2190+
name: "whitespace is trimmed",
2191+
disallowed: []string{" tool2 ", " tool3 "},
2192+
toolsets: []string{"all"},
2193+
expectedNames: []string{"tool1"},
2194+
unexpectedNames: []string{"tool2", "tool3"},
2195+
},
2196+
}
2197+
2198+
for _, tt := range tests {
2199+
t.Run(tt.name, func(t *testing.T) {
2200+
reg := mustBuild(t, NewBuilder().
2201+
SetTools(tools).
2202+
WithToolsets(tt.toolsets).
2203+
WithDisallowedTools(tt.disallowed))
2204+
2205+
available := reg.AvailableTools(context.Background())
2206+
names := make(map[string]bool)
2207+
for _, tool := range available {
2208+
names[tool.Tool.Name] = true
2209+
}
2210+
2211+
for _, expected := range tt.expectedNames {
2212+
require.True(t, names[expected], "tool %q should be available", expected)
2213+
}
2214+
for _, unexpected := range tt.unexpectedNames {
2215+
require.False(t, names[unexpected], "tool %q should be disallowed", unexpected)
2216+
}
2217+
})
2218+
}
2219+
}
2220+
2221+
func TestWithDisallowedTools_OverridesAdditionalTools(t *testing.T) {
2222+
tools := []ServerTool{
2223+
mockTool("tool1", "toolset1", true),
2224+
mockTool("tool2", "toolset1", true),
2225+
mockTool("tool3", "toolset2", true),
2226+
}
2227+
2228+
// tool3 is explicitly enabled via WithTools, but also disallowed
2229+
// disallowed should win because builder filters run before additional tools check
2230+
reg := mustBuild(t, NewBuilder().
2231+
SetTools(tools).
2232+
WithToolsets([]string{"toolset1"}).
2233+
WithTools([]string{"tool3"}).
2234+
WithDisallowedTools([]string{"tool3"}))
2235+
2236+
available := reg.AvailableTools(context.Background())
2237+
names := make(map[string]bool)
2238+
for _, tool := range available {
2239+
names[tool.Tool.Name] = true
2240+
}
2241+
2242+
require.True(t, names["tool1"], "tool1 should be available")
2243+
require.True(t, names["tool2"], "tool2 should be available")
2244+
require.False(t, names["tool3"], "tool3 should be disallowed even though explicitly added via WithTools")
2245+
}
2246+
2247+
func TestWithDisallowedTools_CombinesWithReadOnly(t *testing.T) {
2248+
tools := []ServerTool{
2249+
mockTool("read_tool", "toolset1", true),
2250+
mockTool("write_tool", "toolset1", false),
2251+
mockTool("another_read", "toolset1", true),
2252+
}
2253+
2254+
// read-only excludes write_tool, disallowed excludes read_tool
2255+
reg := mustBuild(t, NewBuilder().
2256+
SetTools(tools).
2257+
WithToolsets([]string{"all"}).
2258+
WithReadOnly(true).
2259+
WithDisallowedTools([]string{"read_tool"}))
2260+
2261+
available := reg.AvailableTools(context.Background())
2262+
require.Len(t, available, 1)
2263+
require.Equal(t, "another_read", available[0].Tool.Name)
2264+
}
2265+
2266+
func TestCreateDisallowedToolsFilter(t *testing.T) {
2267+
filter := CreateDisallowedToolsFilter([]string{"blocked_tool"})
2268+
2269+
blockedTool := mockTool("blocked_tool", "toolset1", true)
2270+
allowedTool := mockTool("allowed_tool", "toolset1", true)
2271+
2272+
allowed, err := filter(context.Background(), &blockedTool)
2273+
require.NoError(t, err)
2274+
require.False(t, allowed, "blocked_tool should be excluded")
2275+
2276+
allowed, err = filter(context.Background(), &allowedTool)
2277+
require.NoError(t, err)
2278+
require.True(t, allowed, "allowed_tool should be included")
2279+
}

0 commit comments

Comments
 (0)