diff --git a/README.md b/README.md index e0119d523..bdba0d146 100644 --- a/README.md +++ b/README.md @@ -87,17 +87,12 @@ Alternatively, to manually configure VS Code, choose the appropriate JSON block ### Configuration -#### Default toolset configuration - -The default configuration is: -- context -- repos -- issues -- pull_requests -- users +#### Toolset configuration See [Remote Server Documentation](docs/remote-server.md) for full details on remote server configuration, toolsets, headers, and advanced usage. This file provides comprehensive instructions and examples for connecting, customizing, and installing the remote GitHub MCP Server in VS Code and other MCP hosts. +When no toolsets are specified, [default toolsets](#default-toolset) are used. + #### Enterprise Cloud with data residency (ghe.com) GitHub Enterprise Cloud can also make use of the remote server. @@ -329,7 +324,7 @@ The GitHub MCP Server supports enabling or disabling specific groups of function _Toolsets are not limited to Tools. Relevant MCP Resources and Prompts are also included where applicable._ -The Local GitHub MCP Server follows the same [default toolset configuration](#default-toolset-configuration) as the remote version. +When no toolsets are specified, [default toolsets](#default-toolset) are used. #### Specifying Toolsets @@ -359,7 +354,9 @@ docker run -i --rm \ ghcr.io/github/github-mcp-server ``` -### The "all" Toolset +### Special toolsets + +#### "all" toolset The special toolset `all` can be provided to enable all available toolsets regardless of any other configuration: @@ -373,6 +370,22 @@ Or using the environment variable: GITHUB_TOOLSETS="all" ./github-mcp-server ``` +#### "default" toolset +The default toolset `default` is the configuration that gets passed to the server if no toolsets are specified. + +The default configuration is: +- context +- repos +- issues +- pull_requests +- users + +To keep the default configuration and add additional toolsets: + +```bash +GITHUB_TOOLSETS="default,stargazers" ./github-mcp-server +``` + ### Available Toolsets The following sets of tools are available: diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index a0e225293..e34044a89 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -45,8 +45,9 @@ var ( return fmt.Errorf("failed to unmarshal toolsets: %w", err) } + // No passed toolsets configuration means we enable the default toolset if len(enabledToolsets) == 0 { - enabledToolsets = github.GetDefaultToolsetIDs() + enabledToolsets = []string{github.ToolsetMetadataDefault.ID} } stdioServerConfig := ghmcp.StdioServerConfig{ diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index cb44dffa0..5b4c5c158 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -106,15 +106,10 @@ func NewMCPServer(cfg MCPServerConfig) (*server.MCPServer, error) { }, } - enabledToolsets := cfg.EnabledToolsets - if cfg.DynamicToolsets { - // filter "all" from the enabled toolsets - enabledToolsets = make([]string, 0, len(cfg.EnabledToolsets)) - for _, toolset := range cfg.EnabledToolsets { - if toolset != "all" { - enabledToolsets = append(enabledToolsets, toolset) - } - } + enabledToolsets, invalidToolsets := cleanToolsets(cfg.EnabledToolsets, cfg.DynamicToolsets) + + if len(invalidToolsets) > 0 { + fmt.Fprintf(os.Stderr, "Invalid toolsets ignored: %s\n", strings.Join(invalidToolsets, ", ")) } // Generate instructions based on enabled toolsets @@ -470,3 +465,57 @@ func (t *bearerAuthTransport) RoundTrip(req *http.Request) (*http.Response, erro req.Header.Set("Authorization", "Bearer "+t.token) return t.transport.RoundTrip(req) } + +// cleanToolsets cleans and handles special toolset keywords: +// - Duplicates are removed from the result +// - Removes whitespaces +// - Validates toolset names and returns invalid ones separately +// - "all": Returns ["all"] immediately, ignoring all other toolsets +// - when dynamicToolsets is true, filters out "all" from the enabled toolsets +// - "default": Replaces with the actual default toolset IDs from GetDefaultToolsetIDs() +// Returns: (validToolsets, invalidToolsets) +func cleanToolsets(enabledToolsets []string, dynamicToolsets bool) ([]string, []string) { + seen := make(map[string]bool) + result := make([]string, 0, len(enabledToolsets)) + invalid := make([]string, 0) + validIDs := github.GetValidToolsetIDs() + + // Add non-default toolsets, removing duplicates and trimming whitespace + for _, toolset := range enabledToolsets { + trimmed := strings.TrimSpace(toolset) + if trimmed == "" { + continue + } + if !seen[trimmed] { + seen[trimmed] = true + if trimmed != github.ToolsetMetadataDefault.ID && trimmed != github.ToolsetMetadataAll.ID { + // Validate the toolset name + if validIDs[trimmed] { + result = append(result, trimmed) + } else { + invalid = append(invalid, trimmed) + } + } + } + } + + hasDefault := seen[github.ToolsetMetadataDefault.ID] + hasAll := seen[github.ToolsetMetadataAll.ID] + + // Handle "all" keyword - return early if not in dynamic mode + if hasAll && !dynamicToolsets { + return []string{github.ToolsetMetadataAll.ID}, invalid + } + + // Expand "default" keyword to actual default toolsets + if hasDefault { + for _, defaultToolset := range github.GetDefaultToolsetIDs() { + if !seen[defaultToolset] { + result = append(result, defaultToolset) + seen[defaultToolset] = true + } + } + } + + return result, invalid +} diff --git a/internal/ghmcp/server_test.go b/internal/ghmcp/server_test.go new file mode 100644 index 000000000..c675306f6 --- /dev/null +++ b/internal/ghmcp/server_test.go @@ -0,0 +1,278 @@ +package ghmcp + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCleanToolsets(t *testing.T) { + tests := []struct { + name string + input []string + dynamicToolsets bool + expected []string + expectedInvalid []string + }{ + { + name: "empty slice", + input: []string{}, + dynamicToolsets: false, + expected: []string{}, + }, + { + name: "nil input slice", + input: nil, + dynamicToolsets: false, + expected: []string{}, + }, + // all test cases + { + name: "all only", + input: []string{"all"}, + dynamicToolsets: false, + expected: []string{"all"}, + }, + { + name: "all appears multiple times", + input: []string{"all", "actions", "all"}, + dynamicToolsets: false, + expected: []string{"all"}, + }, + { + name: "all with other toolsets", + input: []string{"all", "actions", "gists"}, + dynamicToolsets: false, + expected: []string{"all"}, + }, + { + name: "all with default", + input: []string{"default", "all", "actions"}, + dynamicToolsets: false, + expected: []string{"all"}, + }, + // default test cases + { + name: "default only", + input: []string{"default"}, + dynamicToolsets: false, + expected: []string{ + "context", + "repos", + "issues", + "pull_requests", + "users", + }, + }, + { + name: "default with additional toolsets", + input: []string{"default", "actions", "gists"}, + dynamicToolsets: false, + expected: []string{ + "actions", + "gists", + "context", + "repos", + "issues", + "pull_requests", + "users", + }, + }, + { + name: "no default present", + input: []string{"actions", "gists", "notifications"}, + dynamicToolsets: false, + expected: []string{"actions", "gists", "notifications"}, + }, + { + name: "duplicate toolsets without default", + input: []string{"actions", "gists", "actions"}, + dynamicToolsets: false, + expected: []string{"actions", "gists"}, + }, + { + name: "duplicate toolsets with default", + input: []string{"context", "repos", "issues", "pull_requests", "users", "default"}, + dynamicToolsets: false, + expected: []string{ + "context", + "repos", + "issues", + "pull_requests", + "users", + }, + }, + { + name: "default appears multiple times with different toolsets in between", + input: []string{"default", "actions", "default", "gists", "default"}, + dynamicToolsets: false, + expected: []string{ + "actions", + "gists", + "context", + "repos", + "issues", + "pull_requests", + "users", + }, + }, + // Dynamic toolsets test cases + { + name: "dynamic toolsets - all only should be filtered", + input: []string{"all"}, + dynamicToolsets: true, + expected: []string{}, + }, + { + name: "dynamic toolsets - all with other toolsets", + input: []string{"all", "actions", "gists"}, + dynamicToolsets: true, + expected: []string{"actions", "gists"}, + }, + { + name: "dynamic toolsets - all with default", + input: []string{"all", "default", "actions"}, + dynamicToolsets: true, + expected: []string{ + "actions", + "context", + "repos", + "issues", + "pull_requests", + "users", + }, + }, + { + name: "dynamic toolsets - no all present", + input: []string{"actions", "gists"}, + dynamicToolsets: true, + expected: []string{"actions", "gists"}, + }, + { + name: "dynamic toolsets - default only", + input: []string{"default"}, + dynamicToolsets: true, + expected: []string{ + "context", + "repos", + "issues", + "pull_requests", + "users", + }, + }, + { + name: "only special keywords with dynamic mode", + input: []string{"all", "default"}, + dynamicToolsets: true, + expected: []string{ + "context", + "repos", + "issues", + "pull_requests", + "users", + }, + }, + { + name: "all with default and overlapping default toolsets in dynamic mode", + input: []string{"all", "default", "issues", "repos"}, + dynamicToolsets: true, + expected: []string{ + "issues", + "repos", + "context", + "pull_requests", + "users", + }, + }, + // Whitespace test cases + { + name: "whitespace check - leading and trailing whitespace on regular toolsets", + input: []string{" actions ", " gists ", "notifications"}, + dynamicToolsets: false, + expected: []string{"actions", "gists", "notifications"}, + }, + { + name: "whitespace check - default toolset", + input: []string{" actions ", " default ", "notifications"}, + dynamicToolsets: false, + expected: []string{ + "actions", + "notifications", + "context", + "repos", + "issues", + "pull_requests", + "users", + }, + }, + { + name: "whitespace check - all toolset", + input: []string{" actions ", " gists ", "notifications", " all "}, + dynamicToolsets: false, + expected: []string{"all"}, + }, + // Invalid toolset test cases + { + name: "mix of valid and invalid toolsets", + input: []string{"actions", "invalid_toolset", "gists", "typo_repo"}, + dynamicToolsets: false, + expected: []string{"actions", "gists"}, + expectedInvalid: []string{"invalid_toolset", "typo_repo"}, + }, + { + name: "invalid with whitespace", + input: []string{" invalid_tool ", " actions ", " typo_gist "}, + dynamicToolsets: false, + expected: []string{"actions"}, + expectedInvalid: []string{"invalid_tool", "typo_gist"}, + }, + { + name: "empty string in toolsets", + input: []string{"", "actions", " ", "gists"}, + dynamicToolsets: false, + expected: []string{"actions", "gists"}, + expectedInvalid: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, invalid := cleanToolsets(tt.input, tt.dynamicToolsets) + + require.Len(t, result, len(tt.expected), "result length should match expected length") + + if tt.expectedInvalid == nil { + tt.expectedInvalid = []string{} + } + require.Len(t, invalid, len(tt.expectedInvalid), "invalid length should match expected invalid length") + + resultMap := make(map[string]bool) + for _, toolset := range result { + resultMap[toolset] = true + } + + expectedMap := make(map[string]bool) + for _, toolset := range tt.expected { + expectedMap[toolset] = true + } + + invalidMap := make(map[string]bool) + for _, toolset := range invalid { + invalidMap[toolset] = true + } + + expectedInvalidMap := make(map[string]bool) + for _, toolset := range tt.expectedInvalid { + expectedInvalidMap[toolset] = true + } + + assert.Equal(t, expectedMap, resultMap, "result should contain all expected toolsets without duplicates") + assert.Equal(t, expectedInvalidMap, invalidMap, "invalid should contain all expected invalid toolsets") + + assert.Len(t, resultMap, len(result), "result should not contain duplicates") + + assert.False(t, resultMap["default"], "result should not contain 'default'") + }) + } +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index a982060de..a0b1690c9 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -23,6 +23,14 @@ type ToolsetMetadata struct { } var ( + ToolsetMetadataAll = ToolsetMetadata{ + ID: "all", + Description: "Special toolset that enables all available toolsets", + } + ToolsetMetadataDefault = ToolsetMetadata{ + ID: "default", + Description: "Special toolset that enables the default toolset configuration. When no toolsets are specified, this is the set that is enabled", + } ToolsetMetadataContext = ToolsetMetadata{ ID: "context", Description: "Tools that provide context about the current user and GitHub context you are operating in", @@ -125,6 +133,18 @@ func AvailableTools() []ToolsetMetadata { } } +// GetValidToolsetIDs returns a map of all valid toolset IDs for quick lookup +func GetValidToolsetIDs() map[string]bool { + validIDs := make(map[string]bool) + for _, tool := range AvailableTools() { + validIDs[tool.ID] = true + } + // Add special keywords + validIDs[ToolsetMetadataAll.ID] = true + validIDs[ToolsetMetadataDefault.ID] = true + return validIDs +} + func GetDefaultToolsetIDs() []string { return []string{ ToolsetMetadataContext.ID, @@ -414,8 +434,14 @@ func GenerateToolsetsHelp() string { availableTools := strings.Join(availableToolsLines, ",\n\t ") toolsetsHelp := fmt.Sprintf("Comma-separated list of tool groups to enable (no spaces).\n"+ - "Default: %s\n"+ - "Available: %s\n", defaultTools, availableTools) + - "To enable all tools, use \"all\"." + "Available: %s\n", availableTools) + + "Special toolset keywords:\n" + + " - all: Enables all available toolsets\n" + + fmt.Sprintf(" - default: Enables the default toolset configuration of:\n\t %s\n", defaultTools) + + "Examples:\n" + + " - --toolsets=actions,gists,notifications\n" + + " - Default + additional: --toolsets=default,actions,gists\n" + + " - All tools: --toolsets=all" + return toolsetsHelp }