diff --git a/README.md b/README.md index 7c4884074..44472458b 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,38 @@ To specify toolsets you want available to the LLM, you can pass an allow-list in The environment variable `GITHUB_TOOLSETS` takes precedence over the command line argument if both are provided. +#### Specifying Individual Tools + +You can also configure specific tools using the `--tools` flag. Tools can be used independently or combined with toolsets and dynamic toolsets discovery for fine-grained control. + +1. **Using Command Line Argument**: + + ```bash + github-mcp-server --tools get_file_contents,issue_read,create_pull_request + ``` + +2. **Using Environment Variable**: + ```bash + GITHUB_TOOLS="get_file_contents,issue_read,create_pull_request" ./github-mcp-server + ``` + +3. **Combining with Toolsets** (additive): + ```bash + github-mcp-server --toolsets repos,issues --tools get_gist + ``` + This registers all tools from `repos` and `issues` toolsets, plus `get_gist`. + +4. **Combining with Dynamic Toolsets** (additive): + ```bash + github-mcp-server --tools get_file_contents --dynamic-toolsets + ``` + This registers `get_file_contents` plus the dynamic toolset tools (`enable_toolset`, `list_available_toolsets`, `get_toolset_tools`). + +**Important Notes:** +- Tools, toolsets, and dynamic toolsets can all be used together +- Read-only mode takes priority: write tools are skipped if `--read-only` is set, even if explicitly requested via `--tools` +- Tool names must match exactly (e.g., `get_file_contents`, not `getFileContents`). Invalid tool names will cause the server to fail at startup with an error message + ### Using Toolsets With Docker When using Docker, you can pass the toolsets as environment variables: @@ -354,6 +386,25 @@ docker run -i --rm \ ghcr.io/github/github-mcp-server ``` +### Using Tools With Docker + +When using Docker, you can pass specific tools as environment variables. You can also combine tools with toolsets: + +```bash +# Tools only +docker run -i --rm \ + -e GITHUB_PERSONAL_ACCESS_TOKEN= \ + -e GITHUB_TOOLS="get_file_contents,issue_read,create_pull_request" \ + ghcr.io/github/github-mcp-server + +# Tools combined with toolsets (additive) +docker run -i --rm \ + -e GITHUB_PERSONAL_ACCESS_TOKEN= \ + -e GITHUB_TOOLSETS="repos,issues" \ + -e GITHUB_TOOLS="get_gist" \ + ghcr.io/github/github-mcp-server +``` + ### Special toolsets #### "all" toolset diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 125cd5a8d..a559e878a 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -45,8 +45,14 @@ var ( return fmt.Errorf("failed to unmarshal toolsets: %w", err) } - // No passed toolsets configuration means we enable the default toolset - if len(enabledToolsets) == 0 { + // Parse tools (similar to toolsets) + var enabledTools []string + if err := viper.UnmarshalKey("tools", &enabledTools); err != nil { + return fmt.Errorf("failed to unmarshal tools: %w", err) + } + + // If neither toolset config nor tools config is passed we enable the default toolset + if len(enabledToolsets) == 0 && len(enabledTools) == 0 { enabledToolsets = []string{github.ToolsetMetadataDefault.ID} } @@ -55,6 +61,7 @@ var ( Host: viper.GetString("host"), Token: token, EnabledToolsets: enabledToolsets, + EnabledTools: enabledTools, DynamicToolsets: viper.GetBool("dynamic_toolsets"), ReadOnly: viper.GetBool("read-only"), ExportTranslations: viper.GetBool("export-translations"), @@ -76,6 +83,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().Bool("dynamic-toolsets", false, "Enable dynamic toolsets") rootCmd.PersistentFlags().Bool("read-only", false, "Restrict the server to read-only operations") rootCmd.PersistentFlags().String("log-file", "", "Path to log file") @@ -87,6 +95,7 @@ func init() { // Bind flag to viper _ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets")) + _ = viper.BindPFlag("tools", rootCmd.PersistentFlags().Lookup("tools")) _ = viper.BindPFlag("dynamic_toolsets", rootCmd.PersistentFlags().Lookup("dynamic-toolsets")) _ = viper.BindPFlag("read-only", rootCmd.PersistentFlags().Lookup("read-only")) _ = viper.BindPFlag("log-file", rootCmd.PersistentFlags().Lookup("log-file")) diff --git a/go.mod b/go.mod index 02b9ad252..b88e1dc1c 100644 --- a/go.mod +++ b/go.mod @@ -37,7 +37,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 - github.com/google/go-querystring v1.1.0 + github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 1067a222f..cb617a78f 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -39,6 +39,10 @@ type MCPServerConfig struct { // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration EnabledToolsets []string + // 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 + // Whether to enable dynamic toolsets // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#dynamic-tool-discovery DynamicToolsets bool @@ -166,15 +170,32 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) { cfg.ContentWindowSize, github.FeatureFlags{LockdownMode: cfg.LockdownMode}, ) - err = tsg.EnableToolsets(enabledToolsets, nil) - if err != nil { - return nil, fmt.Errorf("failed to enable toolsets: %w", err) + // Enable and register toolsets if configured + // This always happens if toolsets are specified, regardless of whether tools are also specified + if len(enabledToolsets) > 0 { + err = tsg.EnableToolsets(enabledToolsets, nil) + if err != nil { + return nil, fmt.Errorf("failed to enable toolsets: %w", err) + } + + // Register all mcp functionality with the server + tsg.RegisterAll(ghServer) } - // Register all mcp functionality with the server - tsg.RegisterAll(ghServer) + // Register specific tools if configured + if len(cfg.EnabledTools) > 0 { + // Clean and validate tool names + enabledTools := github.CleanTools(cfg.EnabledTools) + // Register the specified tools (additive to any toolsets already enabled) + err = tsg.RegisterSpecificTools(ghServer, enabledTools, cfg.ReadOnly) + if err != nil { + return nil, fmt.Errorf("failed to register tools: %w", err) + } + } + + // Register dynamic toolsets if configured (additive to toolsets and tools) if cfg.DynamicToolsets { dynamic := github.InitDynamicToolset(ghServer, tsg, cfg.Translator) dynamic.RegisterTools(ghServer) @@ -197,6 +218,10 @@ type StdioServerConfig struct { // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#tool-configuration EnabledToolsets []string + // 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 + // Whether to enable dynamic toolsets // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#dynamic-tool-discovery DynamicToolsets bool @@ -234,6 +259,7 @@ func RunStdioServer(cfg StdioServerConfig) error { Host: cfg.Host, Token: cfg.Token, EnabledToolsets: cfg.EnabledToolsets, + EnabledTools: cfg.EnabledTools, DynamicToolsets: cfg.DynamicToolsets, ReadOnly: cfg.ReadOnly, Translator: t, diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 0594f2f94..1756f4bc8 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -523,3 +523,24 @@ func ContainsToolset(tools []string, toCheck string) bool { } return false } + +// CleanTools cleans tool names by removing duplicates and trimming whitespace. +// Validation of tool existence is done during registration. +func CleanTools(toolNames []string) []string { + seen := make(map[string]bool) + result := make([]string, 0, len(toolNames)) + + // Remove duplicates and trim whitespace + for _, tool := range toolNames { + trimmed := strings.TrimSpace(tool) + if trimmed == "" { + continue + } + if !seen[trimmed] { + seen[trimmed] = true + result = append(result, trimmed) + } + } + + return result +} diff --git a/pkg/toolsets/toolsets.go b/pkg/toolsets/toolsets.go index 96f1fc3ca..ba68649e3 100644 --- a/pkg/toolsets/toolsets.go +++ b/pkg/toolsets/toolsets.go @@ -2,6 +2,8 @@ package toolsets import ( "fmt" + "os" + "strings" "github.com/mark3labs/mcp-go/mcp" "github.com/mark3labs/mcp-go/server" @@ -263,3 +265,68 @@ func (tg *ToolsetGroup) GetToolset(name string) (*Toolset, error) { } return toolset, nil } + +type ToolDoesNotExistError struct { + Name string +} + +func (e *ToolDoesNotExistError) Error() string { + return fmt.Sprintf("tool %s does not exist", e.Name) +} + +func NewToolDoesNotExistError(name string) *ToolDoesNotExistError { + return &ToolDoesNotExistError{Name: name} +} + +// FindToolByName searches all toolsets (enabled or disabled) for a tool by name. +// Returns the tool, its parent toolset name, and an error if not found. +func (tg *ToolsetGroup) FindToolByName(toolName string) (*server.ServerTool, string, error) { + for toolsetName, toolset := range tg.Toolsets { + // Check read tools + for _, tool := range toolset.readTools { + if tool.Tool.Name == toolName { + return &tool, toolsetName, nil + } + } + // Check write tools + for _, tool := range toolset.writeTools { + if tool.Tool.Name == toolName { + return &tool, toolsetName, nil + } + } + } + return nil, "", NewToolDoesNotExistError(toolName) +} + +// RegisterSpecificTools registers only the specified tools. +// Respects read-only mode (skips write tools if readOnly=true). +// Returns error if any tool is not found. +func (tg *ToolsetGroup) RegisterSpecificTools(s *server.MCPServer, toolNames []string, readOnly bool) error { + var skippedTools []string + for _, toolName := range toolNames { + tool, _, err := tg.FindToolByName(toolName) + if err != nil { + return fmt.Errorf("tool %s not found: %w", toolName, err) + } + + // Check if it's a write tool and we're in read-only mode + if tool.Tool.Annotations.ReadOnlyHint != nil { + isWriteTool := !*tool.Tool.Annotations.ReadOnlyHint + if isWriteTool && readOnly { + // Skip write tools in read-only mode + skippedTools = append(skippedTools, toolName) + continue + } + } + + // Register the tool + s.AddTool(tool.Tool, tool.Handler) + } + + // Log skipped write tools if any + if len(skippedTools) > 0 { + fmt.Fprintf(os.Stderr, "Write tools skipped due to read-only mode: %s\n", strings.Join(skippedTools, ", ")) + } + + return nil +}