Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand All @@ -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)
Expand Down Expand Up @@ -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")
Expand All @@ -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"))
Expand Down
55 changes: 54 additions & 1 deletion docs/server-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand Down Expand Up @@ -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.

<table>
<tr><th>Remote Server</th><th>Local Server</th></tr>
<tr valign="top">
<td>

```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"
}
}
```

</td>
<td>

```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}"
}
}
```

</td>
</tr>
</table>

**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.
Expand Down
7 changes: 7 additions & 0 deletions internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions pkg/context/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}

Expand Down
5 changes: 5 additions & 0 deletions pkg/github/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions pkg/http/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
59 changes: 59 additions & 0 deletions pkg/http/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions pkg/http/headers/headers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down
5 changes: 5 additions & 0 deletions pkg/http/middleware/request_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
27 changes: 27 additions & 0 deletions pkg/inventory/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand Down
Loading