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
4 changes: 4 additions & 0 deletions cmd/github-mcp-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ var (
EnabledToolsets: enabledToolsets,
DynamicToolsets: viper.GetBool("dynamic_toolsets"),
ReadOnly: viper.GetBool("read-only"),
WritePrivateOnly: viper.GetBool("write-private-only"),
ExportTranslations: viper.GetBool("export-translations"),
EnableCommandLogging: viper.GetBool("enable-command-logging"),
LogFilePath: viper.GetString("log-file"),
Expand Down Expand Up @@ -105,6 +106,7 @@ func init() {
rootCmd.PersistentFlags().StringSlice("toolsets", github.DefaultTools, "An optional comma separated list of groups of tools to allow with optional modes (e.g., 'repos:rw,issues:ro,users'), defaults to enabling all")
rootCmd.PersistentFlags().Bool("dynamic-toolsets", false, "Enable dynamic toolsets")
rootCmd.PersistentFlags().Bool("read-only", false, "Restrict the server to read-only operations")
rootCmd.PersistentFlags().Bool("write-private-only", false, "Restrict all write operations to private repositories only")
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")
rootCmd.PersistentFlags().Bool("export-translations", false, "Save translations to a JSON file")
Expand All @@ -121,6 +123,8 @@ func init() {
_ = viper.BindEnv("toolsets", "GITHUB_TOOLSETS")
_ = viper.BindPFlag("dynamic_toolsets", rootCmd.PersistentFlags().Lookup("dynamic-toolsets"))
_ = viper.BindPFlag("read-only", rootCmd.PersistentFlags().Lookup("read-only"))
_ = viper.BindPFlag("write-private-only", rootCmd.PersistentFlags().Lookup("write-private-only"))
_ = viper.BindEnv("write-private-only", "GITHUB_WRITE_PRIVATE_ONLY")
_ = viper.BindPFlag("log-file", rootCmd.PersistentFlags().Lookup("log-file"))
_ = viper.BindPFlag("enable-command-logging", rootCmd.PersistentFlags().Lookup("enable-command-logging"))
_ = viper.BindPFlag("export-translations", rootCmd.PersistentFlags().Lookup("export-translations"))
Expand Down
34 changes: 26 additions & 8 deletions internal/ghmcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,11 @@ type MCPServerConfig struct {
// ReadOnly indicates if we should only offer read-only tools
ReadOnly bool

// WritePrivateOnly restricts all write operations to private repositories only.
// When true, write tool handlers are wrapped with a visibility guard that blocks
// writes to public repositories. Has no effect when ReadOnly is also true.
WritePrivateOnly bool

// Installations maps organization names to GitHub App installation IDs
Installations map[string]int64

Expand Down Expand Up @@ -288,6 +293,7 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {
toolsets, err := github.InitToolsets(
enabledToolsets,
cfg.ReadOnly,
cfg.WritePrivateOnly,
getClient,
getGQLClient,
cfg.Translator,
Expand All @@ -296,6 +302,14 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) {
return nil, fmt.Errorf("failed to initialize toolsets: %w", err)
}

if cfg.WritePrivateOnly {
if cfg.ReadOnly {
logrus.Warn("GITHUB_WRITE_PRIVATE_ONLY is set but has no effect because --read-only is also active. Write tools are not registered in read-only mode.")
} else {
logrus.Info("Write operations restricted to private repositories (GITHUB_WRITE_PRIVATE_ONLY=true)")
}
}

github.RegisterResources(ghServer, getClient, cfg.Translator)

// Register the tools with the server
Expand Down Expand Up @@ -330,6 +344,9 @@ type StdioServerConfig struct {
// ReadOnly indicates if we should only register read-only tools
ReadOnly bool

// WritePrivateOnly restricts all write operations to private repositories only.
WritePrivateOnly bool

// 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 All @@ -353,14 +370,15 @@ func RunStdioServer(cfg StdioServerConfig) error {
t, dumpTranslations := translations.TranslationHelper()

ghServer, err := NewMCPServer(MCPServerConfig{
Version: cfg.Version,
Host: cfg.Host,
Token: cfg.Token,
EnabledToolsets: cfg.EnabledToolsets,
DynamicToolsets: cfg.DynamicToolsets,
ReadOnly: cfg.ReadOnly,
Installations: cfg.Installations,
Translator: t,
Version: cfg.Version,
Host: cfg.Host,
Token: cfg.Token,
EnabledToolsets: cfg.EnabledToolsets,
DynamicToolsets: cfg.DynamicToolsets,
ReadOnly: cfg.ReadOnly,
WritePrivateOnly: cfg.WritePrivateOnly,
Installations: cfg.Installations,
Translator: t,
})
if err != nil {
return fmt.Errorf("failed to create MCP server: %w", err)
Expand Down
66 changes: 44 additions & 22 deletions pkg/github/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"github.com/github/github-mcp-server/pkg/toolsets"
"github.com/github/github-mcp-server/pkg/translations"
"github.com/google/go-github/v69/github"
"github.com/mark3labs/mcp-go/mcp"
"github.com/mark3labs/mcp-go/server"
"github.com/shurcooL/githubv4"
)
Expand All @@ -16,20 +17,41 @@ type GetGQLClientFn func(ctx context.Context, owner string) (*githubv4.Client, e

var DefaultTools = []string{"all"}

func InitToolsets(passedToolsets []string, readOnly bool, getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (*toolsets.ToolsetGroup, error) {
func InitToolsets(passedToolsets []string, readOnly bool, writePrivateOnly bool, getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (*toolsets.ToolsetGroup, error) {
// Parse toolset configurations from the passed toolsets
configs, err := toolsets.ParseToolsetConfigFromSlice(passedToolsets)
if err != nil {
return nil, fmt.Errorf("failed to parse toolset configuration: %w", err)
}

return InitToolsetsWithConfig(configs, readOnly, getClient, getGQLClient, t)
return InitToolsetsWithConfig(configs, readOnly, writePrivateOnly, getClient, getGQLClient, t)
}

func InitToolsetsWithConfig(configs []toolsets.ToolsetConfig, readOnly bool, getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (*toolsets.ToolsetGroup, error) {
func InitToolsetsWithConfig(configs []toolsets.ToolsetConfig, readOnly bool, writePrivateOnly bool, getClient GetClientFn, getGQLClient GetGQLClientFn, t translations.TranslationHelperFunc) (*toolsets.ToolsetGroup, error) {
// Create a new toolset group
tsg := toolsets.NewToolsetGroup(readOnly)

// Helper functions to conditionally wrap write tool handlers with guards.
// When writePrivateOnly=false, these are no-ops that return the tool and handler unchanged.
guardWrite := func(tool mcp.Tool, handler server.ToolHandlerFunc) (mcp.Tool, server.ToolHandlerFunc) {
if writePrivateOnly {
return WritePrivateOnlyGuard(getClient, tool, handler)
}
return tool, handler
}
guardCreate := func(tool mcp.Tool, handler server.ToolHandlerFunc) (mcp.Tool, server.ToolHandlerFunc) {
if writePrivateOnly {
return CreateRepositoryPrivateOnlyGuard(tool, handler)
}
return tool, handler
}
guardFork := func(tool mcp.Tool, handler server.ToolHandlerFunc) (mcp.Tool, server.ToolHandlerFunc) {
if writePrivateOnly {
return ForkRepositoryPrivateOnlyGuard(tool, handler)
}
return tool, handler
}

// Define all available features with their default state (disabled)
// Create toolsets
repos := toolsets.NewToolset("repos", "GitHub Repository related tools").
Expand All @@ -44,12 +66,12 @@ func InitToolsetsWithConfig(configs []toolsets.ToolsetConfig, readOnly bool, get
toolsets.NewServerTool(GetTag(getClient, t)),
).
AddWriteTools(
toolsets.NewServerTool(CreateOrUpdateFile(getClient, t)),
toolsets.NewServerTool(CreateRepository(getClient, t)),
toolsets.NewServerTool(ForkRepository(getClient, t)),
toolsets.NewServerTool(CreateBranch(getClient, t)),
toolsets.NewServerTool(PushFiles(getClient, t)),
toolsets.NewServerTool(DeleteFile(getClient, t)),
toolsets.NewServerTool(guardWrite(CreateOrUpdateFile(getClient, t))),
toolsets.NewServerTool(guardCreate(CreateRepository(getClient, t))),
toolsets.NewServerTool(guardFork(ForkRepository(getClient, t))),
toolsets.NewServerTool(guardWrite(CreateBranch(getClient, t))),
toolsets.NewServerTool(guardWrite(PushFiles(getClient, t))),
toolsets.NewServerTool(guardWrite(DeleteFile(getClient, t))),
)
issues := toolsets.NewToolset("issues", "GitHub Issues related tools").
AddReadTools(
Expand All @@ -59,9 +81,9 @@ func InitToolsetsWithConfig(configs []toolsets.ToolsetConfig, readOnly bool, get
toolsets.NewServerTool(GetIssueComments(getClient, t)),
).
AddWriteTools(
toolsets.NewServerTool(CreateIssue(getClient, t)),
toolsets.NewServerTool(AddIssueComment(getClient, t)),
toolsets.NewServerTool(UpdateIssue(getClient, t)),
toolsets.NewServerTool(guardWrite(CreateIssue(getClient, t))),
toolsets.NewServerTool(guardWrite(AddIssueComment(getClient, t))),
toolsets.NewServerTool(guardWrite(UpdateIssue(getClient, t))),
)
users := toolsets.NewToolset("users", "GitHub User related tools").
AddReadTools(
Expand All @@ -78,18 +100,18 @@ func InitToolsetsWithConfig(configs []toolsets.ToolsetConfig, readOnly bool, get
toolsets.NewServerTool(GetPullRequestDiff(getClient, t)),
).
AddWriteTools(
toolsets.NewServerTool(MergePullRequest(getClient, t)),
toolsets.NewServerTool(UpdatePullRequestBranch(getClient, t)),
toolsets.NewServerTool(CreatePullRequest(getClient, t)),
toolsets.NewServerTool(UpdatePullRequest(getClient, t)),
toolsets.NewServerTool(RequestCopilotReview(getClient, t)),
toolsets.NewServerTool(guardWrite(MergePullRequest(getClient, t))),
toolsets.NewServerTool(guardWrite(UpdatePullRequestBranch(getClient, t))),
toolsets.NewServerTool(guardWrite(CreatePullRequest(getClient, t))),
toolsets.NewServerTool(guardWrite(UpdatePullRequest(getClient, t))),
toolsets.NewServerTool(guardWrite(RequestCopilotReview(getClient, t))),

// Reviews
toolsets.NewServerTool(CreateAndSubmitPullRequestReview(getGQLClient, t)),
toolsets.NewServerTool(CreatePendingPullRequestReview(getGQLClient, t)),
toolsets.NewServerTool(AddPullRequestReviewCommentToPendingReview(getGQLClient, t)),
toolsets.NewServerTool(SubmitPendingPullRequestReview(getGQLClient, t)),
toolsets.NewServerTool(DeletePendingPullRequestReview(getGQLClient, t)),
toolsets.NewServerTool(guardWrite(CreateAndSubmitPullRequestReview(getGQLClient, t))),
toolsets.NewServerTool(guardWrite(CreatePendingPullRequestReview(getGQLClient, t))),
toolsets.NewServerTool(guardWrite(AddPullRequestReviewCommentToPendingReview(getGQLClient, t))),
toolsets.NewServerTool(guardWrite(SubmitPendingPullRequestReview(getGQLClient, t))),
toolsets.NewServerTool(guardWrite(DeletePendingPullRequestReview(getGQLClient, t))),
)
codeSecurity := toolsets.NewToolset("code_security", "Code security related tools, such as GitHub Code Scanning").
AddReadTools(
Expand Down
82 changes: 78 additions & 4 deletions pkg/github/tools_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ func TestInitToolsetsWithConfig(t *testing.T) {
return nil, nil
}

tsg, err := InitToolsetsWithConfig(tt.configs, tt.readOnly, getClient, getGQLClient, mockTranslator)
tsg, err := InitToolsetsWithConfig(tt.configs, tt.readOnly, false, getClient, getGQLClient, mockTranslator)

if tt.wantErr {
if err == nil {
Expand Down Expand Up @@ -165,7 +165,7 @@ func TestInitToolsets_BackwardCompatibility(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tsg, err := InitToolsets(tt.passedToolsets, tt.readOnly, getClient, getGQLClient, mockTranslator)
tsg, err := InitToolsets(tt.passedToolsets, tt.readOnly, false, getClient, getGQLClient, mockTranslator)

if tt.wantErr {
if err == nil {
Expand Down Expand Up @@ -255,7 +255,7 @@ func TestToolsetModeFiltering(t *testing.T) {

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tsg, err := InitToolsetsWithConfig(tt.configs, false, getClient, getGQLClient, mockTranslator)
tsg, err := InitToolsetsWithConfig(tt.configs, false, false, getClient, getGQLClient, mockTranslator)
if err != nil {
t.Fatalf("InitToolsetsWithConfig() error: %v", err)
}
Expand Down Expand Up @@ -339,7 +339,7 @@ func TestContextToolsetIntegration(t *testing.T) {
{Name: "repos", Mode: toolsets.ReadOnly},
}

tsg, err := InitToolsetsWithConfig(configs, false, getClient, getGQLClient, mockTranslator)
tsg, err := InitToolsetsWithConfig(configs, false, false, getClient, getGQLClient, mockTranslator)
if err != nil {
t.Fatalf("InitToolsetsWithConfig() error: %v", err)
}
Expand Down Expand Up @@ -379,3 +379,77 @@ func TestContextToolsetIntegration(t *testing.T) {

t.Logf("Context toolset has %d active tools", len(activeTools))
}

func TestWritePrivateOnlyGuardWiring(t *testing.T) {
// Verify that when writePrivateOnly=true, write tools are wrapped with guards
// and that when writePrivateOnly=false, they are not.
mockTranslator := func(key, fallback string) string {
return fallback
}
getClient := func(ctx context.Context, _ string) (*github.Client, error) {
return github.NewClient(nil), nil
}
getGQLClient := func(ctx context.Context, _ string) (*githubv4.Client, error) {
return nil, nil
}

configs := []toolsets.ToolsetConfig{
{Name: "all", Mode: toolsets.ReadWrite},
}

// Initialize with writePrivateOnly=false
tsgOff, err := InitToolsetsWithConfig(configs, false, false, getClient, getGQLClient, mockTranslator)
if err != nil {
t.Fatalf("InitToolsetsWithConfig(writePrivateOnly=false) error: %v", err)
}

// Initialize with writePrivateOnly=true
tsgOn, err := InitToolsetsWithConfig(configs, false, true, getClient, getGQLClient, mockTranslator)
if err != nil {
t.Fatalf("InitToolsetsWithConfig(writePrivateOnly=true) error: %v", err)
}

// Both should have the same number of toolsets
if len(tsgOff.Toolsets) != len(tsgOn.Toolsets) {
t.Errorf("Expected same number of toolsets, got %d vs %d", len(tsgOff.Toolsets), len(tsgOn.Toolsets))
}

// Both should have the same tools (write tools are still registered, just wrapped)
for name, tsOff := range tsgOff.Toolsets {
tsOn, exists := tsgOn.Toolsets[name]
if !exists {
t.Errorf("Toolset %s missing from writePrivateOnly=true", name)
continue
}
offTools := tsOff.GetActiveTools()
onTools := tsOn.GetActiveTools()
if len(offTools) != len(onTools) {
t.Errorf("Toolset %s: expected %d tools, got %d with writePrivateOnly=true",
name, len(offTools), len(onTools))
}
}

// Verify that fork_repository is blocked when writePrivateOnly=true
// by calling the handler directly
repoToolset := tsgOn.Toolsets["repos"]
if repoToolset == nil {
t.Fatal("repos toolset not found")
}
for _, tool := range repoToolset.GetActiveTools() {
if tool.Tool.Name == "fork_repository" {
result, err := tool.Handler(context.Background(), createMCPRequest(map[string]interface{}{
"owner": "testowner",
"repo": "testrepo",
}))
if err != nil {
t.Fatalf("fork_repository handler returned error: %v", err)
}
// Should be blocked
if result == nil || !result.IsError {
t.Error("Expected fork_repository to be blocked when writePrivateOnly=true")
}
return
}
}
t.Error("fork_repository tool not found in repos toolset")
}
Loading