Skip to content
Open
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
35 changes: 19 additions & 16 deletions cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,22 +79,23 @@ var (

ttl := viper.GetDuration("repo-access-cache-ttl")
stdioServerConfig := ghmcp.StdioServerConfig{
Version: version,
Host: viper.GetString("host"),
Token: token,
EnabledToolsets: enabledToolsets,
EnabledTools: enabledTools,
EnabledFeatures: enabledFeatures,
DynamicToolsets: viper.GetBool("dynamic_toolsets"),
ReadOnly: viper.GetBool("read-only"),
ExportTranslations: viper.GetBool("export-translations"),
EnableCommandLogging: viper.GetBool("enable-command-logging"),
LogFilePath: viper.GetString("log-file"),
ContentWindowSize: viper.GetInt("content-window-size"),
LockdownMode: viper.GetBool("lockdown-mode"),
InsidersMode: viper.GetBool("insiders"),
ExcludeTools: excludeTools,
RepoAccessCacheTTL: &ttl,
Version: version,
Host: viper.GetString("host"),
Token: token,
EnabledToolsets: enabledToolsets,
StrictToolsetValidation: viper.GetBool("strict_toolsets"),
EnabledTools: enabledTools,
EnabledFeatures: enabledFeatures,
DynamicToolsets: viper.GetBool("dynamic_toolsets"),
ReadOnly: viper.GetBool("read-only"),
ExportTranslations: viper.GetBool("export-translations"),
EnableCommandLogging: viper.GetBool("enable-command-logging"),
LogFilePath: viper.GetString("log-file"),
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 @@ -138,6 +139,7 @@ func init() {
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("strict-toolsets", false, "Fail startup if configured toolsets include unknown names")
rootCmd.PersistentFlags().Bool("read-only", false, "Restrict the server to read-only operations")
rootCmd.PersistentFlags().String("log-file", "", "Path to log file")
rootCmd.PersistentFlags().Bool("enable-command-logging", false, "When enabled, the server will log all command requests and responses to the log file")
Expand All @@ -160,6 +162,7 @@ func init() {
_ = 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("strict_toolsets", rootCmd.PersistentFlags().Lookup("strict-toolsets"))
_ = viper.BindPFlag("read-only", rootCmd.PersistentFlags().Lookup("read-only"))
_ = viper.BindPFlag("log-file", rootCmd.PersistentFlags().Lookup("log-file"))
_ = viper.BindPFlag("enable-command-logging", rootCmd.PersistentFlags().Lookup("enable-command-logging"))
Expand Down
34 changes: 34 additions & 0 deletions docs/server-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,40 @@ Starts with only discovery tools (`enable_toolset`, `list_available_toolsets`, `

When both dynamic mode and specific tools are enabled in the server configuration, the server will start with the 3 dynamic tools + the specified tools.

### Strict Toolset Validation

**Best for:** Locked-down environments where toolset allow-lists must fail closed.

By default, unknown toolset names are ignored and logged as warnings so existing configurations remain backward compatible. If you want startup to fail when a configured toolset name is unknown, enable strict validation.
Comment on lines +341 to +343
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR description says strict-toolsets is documented in the README, but there don’t appear to be any README updates in this change set (and the only doc update is in this file). Either update README.md (tool configuration section) to mention --strict-toolsets / GITHUB_STRICT_TOOLSETS, or adjust the PR description so it matches what’s actually being changed.

Copilot uses AI. Check for mistakes.
Comment on lines +339 to +343
Copy link

Copilot AI Mar 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new Strict Toolset Validation section doesn’t mention the corresponding env var (viper key strict_toolsetsGITHUB_STRICT_TOOLSETS) and the doc’s “Quick Reference” table near the top doesn’t list this new configuration option. To keep docs consistent with other settings, consider documenting both the flag and env var here and adding an entry to the Quick Reference table (Local Server column).

Copilot uses AI. Check for mistakes.

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

```json
{
"type": "stdio",
"command": "go",
"args": [
"run",
"./cmd/github-mcp-server",
"stdio",
"--toolsets=repos,issues,typo",
"--strict-toolsets"
],
"env": {
"GITHUB_PERSONAL_ACCESS_TOKEN": "${input:github_token}"
}
}
```

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

Use this when a typo in a toolset name should be treated as a startup error instead of silently falling back to a narrower or unintended capability set.

---

### Lockdown Mode
Expand Down
37 changes: 21 additions & 16 deletions internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se
WithDeprecatedAliases(github.DeprecatedToolAliases).
WithReadOnly(cfg.ReadOnly).
WithToolsets(github.ResolvedEnabledToolsets(cfg.DynamicToolsets, cfg.EnabledToolsets, cfg.EnabledTools)).
WithStrictToolsetValidation(cfg.StrictToolsetValidation).
WithTools(github.CleanTools(cfg.EnabledTools)).
WithExcludeTools(cfg.ExcludeTools).
WithServerInstructions().
Expand Down Expand Up @@ -181,6 +182,9 @@ type StdioServerConfig struct {
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration
EnabledToolsets []string

// StrictToolsetValidation fails startup when enabled toolsets include unknown names.
StrictToolsetValidation bool

// EnabledTools is a list of specific tools to enable (additive to toolsets)
// When specified, these tools are registered in addition to any specified toolset tools
EnabledTools []string
Expand Down Expand Up @@ -265,22 +269,23 @@ func RunStdioServer(cfg StdioServerConfig) error {
}

ghServer, err := NewStdioMCPServer(ctx, github.MCPServerConfig{
Version: cfg.Version,
Host: cfg.Host,
Token: cfg.Token,
EnabledToolsets: cfg.EnabledToolsets,
EnabledTools: cfg.EnabledTools,
EnabledFeatures: cfg.EnabledFeatures,
DynamicToolsets: cfg.DynamicToolsets,
ReadOnly: cfg.ReadOnly,
Translator: t,
ContentWindowSize: cfg.ContentWindowSize,
LockdownMode: cfg.LockdownMode,
InsidersMode: cfg.InsidersMode,
ExcludeTools: cfg.ExcludeTools,
Logger: logger,
RepoAccessTTL: cfg.RepoAccessCacheTTL,
TokenScopes: tokenScopes,
Version: cfg.Version,
Host: cfg.Host,
Token: cfg.Token,
EnabledToolsets: cfg.EnabledToolsets,
StrictToolsetValidation: cfg.StrictToolsetValidation,
EnabledTools: cfg.EnabledTools,
EnabledFeatures: cfg.EnabledFeatures,
DynamicToolsets: cfg.DynamicToolsets,
ReadOnly: cfg.ReadOnly,
Translator: t,
ContentWindowSize: cfg.ContentWindowSize,
LockdownMode: cfg.LockdownMode,
InsidersMode: cfg.InsidersMode,
ExcludeTools: cfg.ExcludeTools,
Logger: logger,
RepoAccessTTL: cfg.RepoAccessCacheTTL,
TokenScopes: tokenScopes,
})
if err != nil {
return fmt.Errorf("failed to create MCP server: %w", err)
Expand Down
41 changes: 41 additions & 0 deletions internal/ghmcp/server_test.go
Original file line number Diff line number Diff line change
@@ -1 +1,42 @@
package ghmcp

import (
"context"
"io"
"log/slog"
"testing"

"github.com/github/github-mcp-server/pkg/github"
"github.com/github/github-mcp-server/pkg/inventory"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/stretchr/testify/require"
)

func TestNewStdioMCPServer_StrictToolsetValidation(t *testing.T) {
t.Parallel()

_, err := NewStdioMCPServer(context.Background(), testMCPServerConfig([]string{"repos", "typo"}, true))
require.Error(t, err)
require.ErrorIs(t, err, inventory.ErrUnknownToolsets)
require.Contains(t, err.Error(), "typo")
}

func TestNewStdioMCPServer_AllowsUnknownToolsetsWhenNotStrict(t *testing.T) {
t.Parallel()

server, err := NewStdioMCPServer(context.Background(), testMCPServerConfig([]string{"repos", "typo"}, false))
require.NoError(t, err)
require.NotNil(t, server)
}

func testMCPServerConfig(toolsets []string, strict bool) github.MCPServerConfig {
return github.MCPServerConfig{
Version: "test",
Token: "test-token",
EnabledToolsets: toolsets,
StrictToolsetValidation: strict,
Translator: translations.NullTranslationHelper,
ContentWindowSize: 5000,
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
}
}
3 changes: 3 additions & 0 deletions pkg/github/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ type MCPServerConfig struct {
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration
EnabledToolsets []string

// StrictToolsetValidation fails startup when EnabledToolsets contains unknown names.
StrictToolsetValidation bool

// EnabledTools is a list of specific tools to enable (additive to toolsets)
// When specified, these tools are registered in addition to any specified toolset tools
EnabledTools []string
Expand Down
13 changes: 13 additions & 0 deletions pkg/inventory/builder.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import (
var (
// ErrUnknownTools is returned when tools specified via WithTools() are not recognized.
ErrUnknownTools = errors.New("unknown tools specified in WithTools")
// ErrUnknownToolsets is returned when toolsets specified via WithToolsets() are not recognized.
ErrUnknownToolsets = errors.New("unknown toolsets specified in WithToolsets")
)

// ToolFilter is a function that determines if a tool should be included.
Expand Down Expand Up @@ -49,6 +51,7 @@ type Builder struct {
filters []ToolFilter // filters to apply to all tools
generateInstructions bool
insidersMode bool
strictToolsets bool
}

// NewBuilder creates a new Builder.
Expand Down Expand Up @@ -111,6 +114,13 @@ func (b *Builder) WithToolsets(toolsetIDs []string) *Builder {
return b
}

// WithStrictToolsetValidation controls whether unknown toolset IDs should fail Build().
// When disabled, unknown toolsets are recorded on the inventory for warning-only behavior.
func (b *Builder) WithStrictToolsetValidation(strict bool) *Builder {
b.strictToolsets = strict
return b
}

// WithTools specifies additional tools that bypass toolset filtering.
// These tools are additive - they will be included even if their toolset is not enabled.
// Read-only filtering still applies to these tools.
Expand Down Expand Up @@ -222,6 +232,9 @@ func (b *Builder) Build() (*Inventory, error) {

// Process toolsets and pre-compute metadata in a single pass
r.enabledToolsets, r.unrecognizedToolsets, r.toolsetIDs, r.toolsetIDSet, r.defaultToolsetIDs, r.toolsetDescriptions = b.processToolsets()
if b.strictToolsets && len(r.unrecognizedToolsets) > 0 {
return nil, fmt.Errorf("%w: %s", ErrUnknownToolsets, strings.Join(r.unrecognizedToolsets, ", "))
}

// Build set of valid tool names for validation
validToolNames := make(map[string]bool, len(tools))
Expand Down
28 changes: 28 additions & 0 deletions pkg/inventory/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,34 @@ func TestUnrecognizedToolsets(t *testing.T) {
}
}

func TestBuildErrorsOnUnrecognizedToolsetsWhenStrict(t *testing.T) {
tools := []ServerTool{
mockTool("tool1", "toolset1", true),
}

_, err := NewBuilder().
SetTools(tools).
WithToolsets([]string{"toolset1", "typo"}).
WithStrictToolsetValidation(true).
Build()

require.Error(t, err, "expected error for unrecognized toolset in strict mode")
require.ErrorIs(t, err, ErrUnknownToolsets)
require.Contains(t, err.Error(), "typo")
}

func TestBuildAllowsUnrecognizedToolsetsWhenNotStrict(t *testing.T) {
tools := []ServerTool{
mockTool("tool1", "toolset1", true),
}

reg := mustBuild(t, NewBuilder().
SetTools(tools).
WithToolsets([]string{"toolset1", "typo"}))

require.Equal(t, []string{"typo"}, reg.UnrecognizedToolsets())
}

func TestBuildErrorsOnUnrecognizedTools(t *testing.T) {
tools := []ServerTool{
mockTool("tool1", "toolset1", true),
Expand Down
Loading