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
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1650,6 +1650,27 @@ docker run -i --rm \
ghcr.io/github/github-mcp-server
```

### Per-toolset read-only mode

Use `--read-only-toolsets` or `GITHUB_READ_ONLY_TOOLSETS` to keep selected toolsets read-only while allowing writes in other enabled toolsets. This works with both `stdio` and self-hosted `http` servers:

```bash
./github-mcp-server stdio --toolsets=repos,issues,pull_requests --read-only-toolsets=issues,pull_requests
```

Environment equivalent: `GITHUB_READ_ONLY_TOOLSETS=issues,pull_requests`.

Read tools in `issues` and `pull_requests` remain available; their write tools are omitted. Repository write tools remain available. The policy follows each tool's declared toolset and read-only annotation.

- Global `--read-only` still blocks all write tools.
- Explicit `--tools` selections and HTTP request configuration cannot bypass the restriction.
- Listing a toolset here does not enable it. Normal toolset selection still applies.
- In HTTP mode, this policy alone preserves default tool selection and allows request headers to select other toolsets. Use `--toolsets` to limit which toolsets requests may select.
- Names are trimmed and deduplicated. `all` restricts every toolset; `default` restricts the default toolsets. Unknown names cause startup to fail.
- `list-scopes` honors this setting when reporting available tools and required scopes.

This is a static policy; it does not prompt for approval or grant temporary write access.

## Lockdown Mode

Lockdown mode limits the content that the server will surface from public repositories. When enabled, the server checks whether the author of each item has push access to the repository. Private repositories are unaffected, and collaborators keep full access to their own content.
Expand Down
8 changes: 7 additions & 1 deletion cmd/github-mcp-server/list_scopes.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,11 @@ func runListScopes() error {
}
}

var readOnlyToolsets []string
if err := viper.UnmarshalKey("read-only-toolsets", &readOnlyToolsets); err != nil {
return fmt.Errorf("failed to unmarshal read-only-toolsets: %w", err)
}

readOnly := viper.GetBool("read-only")
outputFormat := viper.GetString("list-scopes-output")

Expand All @@ -107,7 +112,8 @@ func runListScopes() error {

// Build inventory using the same logic as the stdio server
inventoryBuilder := github.NewInventory(t).
WithReadOnly(readOnly)
WithReadOnly(readOnly).
WithReadOnlyToolsets(readOnlyToolsets)

// Configure toolsets (same as stdio)
if enabledToolsets != nil {
Expand Down
14 changes: 14 additions & 0 deletions cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ var (
}
}

var readOnlyToolsets []string
if err := viper.UnmarshalKey("read-only-toolsets", &readOnlyToolsets); err != nil {
return fmt.Errorf("failed to unmarshal read-only-toolsets: %w", err)
}

ttl := viper.GetDuration("repo-access-cache-ttl")
stdioServerConfig := ghmcp.StdioServerConfig{
Version: version,
Expand All @@ -117,6 +122,7 @@ var (
EnabledTools: enabledTools,
EnabledFeatures: enabledFeatures,
ReadOnly: viper.GetBool("read-only"),
ReadOnlyToolsets: readOnlyToolsets,
ExportTranslations: viper.GetBool("export-translations"),
EnableCommandLogging: viper.GetBool("enable-command-logging"),
LogFilePath: viper.GetString("log-file"),
Expand Down Expand Up @@ -195,6 +201,11 @@ var (
}
}

var readOnlyToolsets []string
if err := viper.UnmarshalKey("read-only-toolsets", &readOnlyToolsets); err != nil {
return fmt.Errorf("failed to unmarshal read-only-toolsets: %w", err)
}

ttl := viper.GetDuration("repo-access-cache-ttl")
httpConfig := ghhttp.ServerConfig{
Version: version,
Expand All @@ -212,6 +223,7 @@ var (
RepoAccessCacheTTL: &ttl,
ScopeChallenge: viper.GetBool("scope-challenge"),
ReadOnly: viper.GetBool("read-only"),
ReadOnlyToolsets: readOnlyToolsets,
EnabledToolsets: enabledToolsets,
EnabledTools: enabledTools,
ExcludeTools: excludeTools,
Expand All @@ -237,6 +249,7 @@ func init() {
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().StringSlice("read-only-toolsets", nil, "Comma-separated list of toolsets to restrict to read-only operations (supports all and default)")
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 Down Expand Up @@ -274,6 +287,7 @@ func init() {
_ = 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("read-only-toolsets", rootCmd.PersistentFlags().Lookup("read-only-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
22 changes: 22 additions & 0 deletions cmd/github-mcp-server/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/github/github-mcp-server/pkg/scopes"
"github.com/google/jsonschema-go/jsonschema"
"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/spf13/pflag"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -93,3 +94,24 @@ func TestSchemaTypeString(t *testing.T) {
})
}
}

func TestReadOnlyToolsetsConfiguration(t *testing.T) {
initConfig()
flag := rootCmd.PersistentFlags().Lookup("read-only-toolsets")
require.NotNil(t, flag)
originalValue, err := rootCmd.PersistentFlags().GetStringSlice("read-only-toolsets")
require.NoError(t, err)
originalChanged := flag.Changed
t.Cleanup(func() {
require.NoError(t, flag.Value.(pflag.SliceValue).Replace(originalValue))
flag.Changed = originalChanged
})
t.Setenv("GITHUB_READ_ONLY_TOOLSETS", "issues,pull_requests")
var toolsets []string
require.NoError(t, viper.UnmarshalKey("read-only-toolsets", &toolsets))
require.Equal(t, []string{"issues", "pull_requests"}, toolsets)

require.NoError(t, rootCmd.PersistentFlags().Set("read-only-toolsets", "repos"))
require.NoError(t, viper.UnmarshalKey("read-only-toolsets", &toolsets))
require.Equal(t, []string{"repos"}, toolsets, "CLI flag takes precedence over environment")
}
3 changes: 3 additions & 0 deletions docs/server-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,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 |
| Per-toolset read-only | Not available on the hosted server | `--read-only-toolsets` or `GITHUB_READ_ONLY_TOOLSETS` (stdio and self-hosted HTTP) |
| Read-Only Mode | `X-MCP-Readonly` header or `/readonly` URL | `--read-only` flag or `GITHUB_READ_ONLY` env var |
| Lockdown Mode | `X-MCP-Lockdown` header | `--lockdown-mode` flag or `GITHUB_LOCKDOWN_MODE` env var |
| Insiders Mode | `X-MCP-Insiders` header or `/insiders` URL | `--insiders` flag or `GITHUB_INSIDERS` env var |
Expand Down Expand Up @@ -233,6 +234,8 @@ Listed tools are removed regardless of any other configuration — even if their

When active, this mode will disable all tools that are not read-only even if they were requested.

For a mixed policy on a local or self-hosted server, use `--read-only-toolsets=issues,pull_requests` or `GITHUB_READ_ONLY_TOOLSETS=issues,pull_requests`. Reads in those toolsets remain available, while their write tools are removed. Other enabled toolsets remain writable. Global read-only takes precedence, and explicit tools or HTTP request headers cannot restore blocked tools. This setting does not enable toolsets. Names are trimmed and deduplicated; `all` and `default` are supported, and unknown names fail startup. See [per-toolset read-only mode](../README.md#per-toolset-read-only-mode).

**Example:**
<table>
<tr><th>Remote Server</th><th>Local Server</th></tr>
Expand Down
5 changes: 5 additions & 0 deletions internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ func NewStdioMCPServer(ctx context.Context, cfg github.MCPServerConfig) (*mcp.Se
inventoryBuilder := github.NewInventory(cfg.Translator, github.WithHost(hostType)).
WithDeprecatedAliases(github.DeprecatedToolAliases).
WithReadOnly(cfg.ReadOnly).
WithReadOnlyToolsets(cfg.ReadOnlyToolsets).
WithToolsets(github.ResolvedEnabledToolsets(cfg.EnabledToolsets, cfg.EnabledTools)).
WithTools(github.CleanTools(cfg.EnabledTools)).
WithExcludeTools(cfg.ExcludeTools).
Expand Down Expand Up @@ -262,6 +263,9 @@ type StdioServerConfig struct {
// ReadOnly indicates if we should only register read-only tools
ReadOnly bool

// ReadOnlyToolsets restricts write tools in the listed toolsets.
ReadOnlyToolsets []string

// ExportTranslations indicates if we should export translations
// See: https://github.com/github/github-mcp-server?tab=readme-ov-file#i18n--overriding-descriptions
ExportTranslations bool
Expand Down Expand Up @@ -375,6 +379,7 @@ func RunStdioServer(cfg StdioServerConfig) error {
EnabledTools: cfg.EnabledTools,
EnabledFeatures: cfg.EnabledFeatures,
ReadOnly: cfg.ReadOnly,
ReadOnlyToolsets: cfg.ReadOnlyToolsets,
Translator: t,
ContentWindowSize: cfg.ContentWindowSize,
LockdownMode: cfg.LockdownMode,
Expand Down
3 changes: 3 additions & 0 deletions pkg/github/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ type MCPServerConfig struct {
// ReadOnly indicates if we should only offer read-only tools
ReadOnly bool

// ReadOnlyToolsets restricts write tools in the listed toolsets.
ReadOnlyToolsets []string

// Translator provides translated text for the server tooling
Translator translations.TranslationHelperFunc

Expand Down
13 changes: 10 additions & 3 deletions pkg/http/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,7 @@ func NewDefaultInventoryFactory(cfg *ServerConfig, t translations.TranslationHel

// Filter request tool names to only those in the static universe,
// so requests for statically-excluded tools degrade gracefully.
if hasStaticFilters {
if hasStaticFilters || len(cfg.ReadOnlyToolsets) > 0 {
r = filterRequestTools(r, validToolNames)
}

Expand Down Expand Up @@ -372,7 +372,8 @@ func filterRequestTools(r *http.Request, validNames map[string]bool) *http.Reque
return r.WithContext(ctx)
}

// hasStaticConfig returns true if any static filtering flags are set on the ServerConfig.
// hasStaticConfig reports whether static configuration sets the toolset bounds.
// Per-toolset read-only policy alone preserves per-request toolset selection.
func hasStaticConfig(cfg *ServerConfig) bool {
return cfg.ReadOnly ||
cfg.EnabledToolsets != nil ||
Expand Down Expand Up @@ -407,7 +408,7 @@ func buildStaticInventory(cfg *ServerConfig, t translations.TranslationHelperFun
})
}

if !hasStaticConfig(cfg) {
if !hasStaticConfig(cfg) && len(cfg.ReadOnlyToolsets) == 0 {
return filterUnavailable(tools), github.AllResources(t), github.AllPrompts(t), nil
}

Expand All @@ -416,7 +417,13 @@ func buildStaticInventory(cfg *ServerConfig, t translations.TranslationHelperFun
SetResources(github.AllResources(t)).
SetPrompts(github.AllPrompts(t)).
WithReadOnly(cfg.ReadOnly).
WithReadOnlyToolsets(cfg.ReadOnlyToolsets).
WithToolsets(github.ResolvedEnabledToolsets(cfg.EnabledToolsets, cfg.EnabledTools))
if !hasStaticConfig(cfg) {
// Apply the policy to the full catalogue; request defaults and headers
// still determine which toolsets are selected.
b = b.WithToolsets([]string{"all"})
}

if len(cfg.EnabledTools) > 0 {
b = b.WithTools(github.CleanTools(cfg.EnabledTools))
Expand Down
105 changes: 103 additions & 2 deletions pkg/http/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,12 @@ func TestStaticConfigEnforcement(t *testing.T) {
path: "/",
expectedTools: []string{"get_file_contents", "list_issues", "list_pull_requests", "hidden_by_holdback"},
},
{
name: "per-toolset policy filters only its writes",
config: &ServerConfig{Version: "test", ReadOnlyToolsets: []string{"issues"}},
path: "/",
expectedTools: []string{"get_file_contents", "create_repository", "list_issues", "list_pull_requests", "create_pull_request", "hidden_by_holdback"},
},
{
name: "static read-only cannot be overridden by header",
config: &ServerConfig{Version: "test", ReadOnly: true},
Expand Down Expand Up @@ -619,7 +625,7 @@ func TestStaticConfigEnforcement(t *testing.T) {
builder = builder.WithReadOnly(true)
}

if hasStatic {
if hasStatic || len(tt.config.ReadOnlyToolsets) > 0 {
r = filterRequestTools(r, validToolNames)
}

Expand Down Expand Up @@ -897,14 +903,18 @@ func TestContentTypeHandling(t *testing.T) {
// buildStaticInventoryFromTools is a test helper that mirrors buildStaticInventory
// but uses the provided mock tools instead of calling github.AllTools.
func buildStaticInventoryFromTools(cfg *ServerConfig, tools []inventory.ServerTool) ([]inventory.ServerTool, []inventory.ServerResourceTemplate, []inventory.ServerPrompt, error) {
if !hasStaticConfig(cfg) {
if !hasStaticConfig(cfg) && len(cfg.ReadOnlyToolsets) == 0 {
return tools, nil, nil, nil
}

b := inventory.NewBuilder().
SetTools(tools).
WithReadOnly(cfg.ReadOnly).
WithReadOnlyToolsets(cfg.ReadOnlyToolsets).
WithToolsets(github.ResolvedEnabledToolsets(cfg.EnabledToolsets, cfg.EnabledTools))
if !hasStaticConfig(cfg) {
b = b.WithToolsets([]string{"all"})
}

if len(cfg.EnabledTools) > 0 {
b = b.WithTools(github.CleanTools(cfg.EnabledTools))
Expand Down Expand Up @@ -1525,3 +1535,94 @@ func TestMaxRequestBodySizeEnforcement(t *testing.T) {
assert.True(t, mcpServerFactoryCalled, "the MCP server should be constructed for an allowed request")
})
}

func TestDefaultInventoryFactoryReadOnlyToolsets(t *testing.T) {
cfg := &ServerConfig{
Version: "test", EnabledToolsets: []string{"issues", "repos", "pull_requests"},
ReadOnlyToolsets: []string{"issues", "pull_requests"},
EnabledTools: []string{"add_issue_comment"},
}
factory, err := NewDefaultInventoryFactory(cfg, translations.NullTranslationHelper, nil, allScopesFetcher{})
require.NoError(t, err)
for _, explicit := range []bool{false, true} {
t.Run(fmt.Sprintf("explicit_request_%v", explicit), func(t *testing.T) {
ctx := ghcontext.WithToolsets(context.Background(), []string{"all"})
if explicit {
ctx = ghcontext.WithTools(ctx, []string{"add_issue_comment", "merge_pull_request", "push_files", "get_file_contents"})
}
req := httptest.NewRequest(http.MethodPost, "/", nil).WithContext(ctx)
req.Header.Set(headers.MCPReadOnlyHeader, "false")
inv, err := factory(req)
require.NoError(t, err)
names := extractToolNames(ctx, inv)
require.Contains(t, names, "push_files")
require.Contains(t, names, "get_file_contents")
require.NotContains(t, names, "add_issue_comment")
require.NotContains(t, names, "merge_pull_request")
require.Empty(t, inv.ForMCPRequest(inventory.MCPMethodToolsCall, "add_issue_comment").AvailableTools(ctx))
})
}
// A policy alone must trigger static filtering, even without tool selection.
factory, err = NewDefaultInventoryFactory(&ServerConfig{
ReadOnlyToolsets: []string{"issues"},
}, translations.NullTranslationHelper, nil, allScopesFetcher{})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/", nil)
inv, err := factory(req)
require.NoError(t, err)
names := extractToolNames(req.Context(), inv)
require.NotContains(t, names, "add_issue_comment")
require.Contains(t, names, "issue_read")
require.Contains(t, names, "push_files")

cfg.ReadOnlyToolsets = []string{"pull-request"}
factory, err = NewDefaultInventoryFactory(cfg, translations.NullTranslationHelper, nil, allScopesFetcher{})
require.ErrorIs(t, err, inventory.ErrUnknownReadOnlyToolsets)
require.Nil(t, factory)
}

func TestReadOnlyToolsetsRequestSelection(t *testing.T) {
tests := []struct {
name string
policy []string
toolsets string
tools string
readonly string
want []string
absent []string
}{
{name: "defaults remain defaults", policy: []string{"issues"}, want: []string{"issue_read", "push_files"}, absent: []string{"add_issue_comment", "actions_list"}},
{name: "non-default toolset", policy: []string{"issues"}, toolsets: "actions", want: []string{"actions_list", "actions_run_trigger"}, absent: []string{"issue_read"}},
{name: "restricted non-default toolset", policy: []string{"actions"}, toolsets: "actions", want: []string{"actions_list"}, absent: []string{"actions_run_trigger"}},
{name: "explicit tools respect policy", policy: []string{"issues"}, tools: "add_issue_comment,actions_run_trigger", readonly: "false", want: []string{"actions_run_trigger"}, absent: []string{"add_issue_comment", "issue_read"}},
{name: "request can restrict further", policy: []string{"issues"}, toolsets: "actions", readonly: "true", want: []string{"actions_list"}, absent: []string{"actions_run_trigger"}},
{name: "empty policy", policy: []string{}, toolsets: "actions,issues", want: []string{"actions_run_trigger", "add_issue_comment"}},
{name: "blank policy", policy: []string{" ", ""}, toolsets: "actions,issues", want: []string{"actions_run_trigger", "add_issue_comment"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
factory, err := NewDefaultInventoryFactory(&ServerConfig{ReadOnlyToolsets: tt.policy}, translations.NullTranslationHelper, createHTTPFeatureChecker(nil, false), allScopesFetcher{})
require.NoError(t, err)
called := false
handler := middleware.WithRequestConfig(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
called = true
inv, err := factory(r)
require.NoError(t, err)
names := extractToolNames(r.Context(), inv)
for _, name := range tt.want {
assert.Contains(t, names, name)
}
for _, name := range tt.absent {
assert.NotContains(t, names, name)
assert.Empty(t, inv.ForMCPRequest(inventory.MCPMethodToolsCall, name).AvailableTools(r.Context()))
}
}))
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.Header.Set(headers.MCPToolsetsHeader, tt.toolsets)
req.Header.Set(headers.MCPToolsHeader, tt.tools)
req.Header.Set(headers.MCPReadOnlyHeader, tt.readonly)
handler.ServeHTTP(httptest.NewRecorder(), req)
require.True(t, called)
})
}
}
3 changes: 3 additions & 0 deletions pkg/http/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ type ServerConfig struct {
// cannot re-enable write tools.
ReadOnly bool

// ReadOnlyToolsets restricts write tools in the listed toolsets.
ReadOnlyToolsets []string

// EnabledToolsets is a list of toolsets to enable.
// When set via CLI flag, per-request headers can only narrow within these toolsets.
EnabledToolsets []string
Expand Down
Loading