From ff237aa73152341fa207e1782d21eae4b80cbe97 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Sat, 13 Dec 2025 23:52:36 +0100 Subject: [PATCH 01/10] Add --features CLI flag for feature flag support Add CLI flag and config support for feature flags in the local server: - Add --features flag to main.go (StringSlice, comma-separated) - Add EnabledFeatures field to StdioServerConfig and MCPServerConfig - Create createFeatureChecker() that builds a set from enabled features - Wire WithFeatureChecker() into the toolset group filter chain This enables tools/resources/prompts that have FeatureFlagEnable set to a flag name that is passed via --features. The checker uses a simple set membership test for O(1) lookup. Usage: github-mcp-server stdio --features=my_feature,another_feature GITHUB_FEATURES=my_feature github-mcp-server stdio --- README.md | 7 +- cmd/github-mcp-server/generate_docs.go | 147 ++-- cmd/github-mcp-server/main.go | 10 +- docs/deprecated-tool-aliases.md | 31 + docs/remote-server.md | 1 - internal/ghmcp/server.go | 117 +-- pkg/github/actions.go | 14 + pkg/github/code_scanning.go | 2 + pkg/github/context_tools.go | 3 + pkg/github/dependabot.go | 2 + pkg/github/dependencies.go | 12 +- pkg/github/discussions.go | 4 + pkg/github/dynamic_tools.go | 294 ++++--- pkg/github/gists.go | 4 + pkg/github/git.go | 1 + pkg/github/issues.go | 20 +- pkg/github/labels.go | 3 + pkg/github/notifications.go | 6 + pkg/github/projects.go | 9 + pkg/github/prompts.go | 16 + pkg/github/pullrequests.go | 10 + pkg/github/repositories.go | 18 + pkg/github/repository_resource.go | 56 +- pkg/github/repository_resource_test.go | 30 +- pkg/github/resources.go | 20 + pkg/github/search.go | 4 + pkg/github/secret_scanning.go | 2 + pkg/github/security_advisories.go | 4 + pkg/github/server.go | 14 +- pkg/github/tools.go | 501 ++++------- pkg/github/toolset_group.go | 20 + pkg/github/workflow_prompts.go | 10 +- pkg/toolsets/server_tool.go | 59 +- pkg/toolsets/toolsets.go | 801 ++++++++++++----- pkg/toolsets/toolsets_test.go | 1109 +++++++++++++++++++----- 35 files changed, 2290 insertions(+), 1071 deletions(-) create mode 100644 docs/deprecated-tool-aliases.md create mode 100644 pkg/github/prompts.go create mode 100644 pkg/github/resources.go create mode 100644 pkg/github/toolset_group.go diff --git a/README.md b/README.md index bcd9f85c8..117bacacd 100644 --- a/README.md +++ b/README.md @@ -384,6 +384,7 @@ You can also configure specific tools using the `--tools` flag. Tools can be use - 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 +- When tools are renamed, old names are preserved as aliases for backward compatibility. See [Deprecated Tool Aliases](docs/deprecated-tool-aliases.md) for details. ### Using Toolsets With Docker @@ -459,7 +460,6 @@ The following sets of tools are available: | `code_security` | Code security related tools, such as GitHub Code Scanning | | `dependabot` | Dependabot tools | | `discussions` | GitHub Discussions related tools | -| `experiments` | Experimental features that are not considered stable yet | | `gists` | GitHub Gist related tools | | `git` | GitHub Git API related tools for low-level Git operations | | `issues` | GitHub Issues related tools | @@ -718,11 +718,6 @@ The following sets of tools are available: - `owner`: Repository owner (string, required) - `repo`: Repository name (string, required) -- **get_label** - Get a specific label from a repository. - - `name`: Label name. (string, required) - - `owner`: Repository owner (username or organization name) (string, required) - - `repo`: Repository name (string, required) - - **issue_read** - Get issue details - `issue_number`: The number of the issue (number, required) - `method`: The read operation to perform on a single issue. diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index 61459d7f0..785bd3ff0 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -10,14 +10,12 @@ import ( "strings" "github.com/github/github-mcp-server/pkg/github" - "github.com/github/github-mcp-server/pkg/lockdown" "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/toolsets" "github.com/github/github-mcp-server/pkg/translations" gogithub "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" - "github.com/shurcooL/githubv4" "github.com/spf13/cobra" ) @@ -39,11 +37,6 @@ func mockGetClient(_ context.Context) (*gogithub.Client, error) { return gogithub.NewClient(nil), nil } -// mockGetGQLClient returns a mock GraphQL client for documentation generation -func mockGetGQLClient(_ context.Context) (*githubv4.Client, error) { - return githubv4.NewClient(nil), nil -} - // mockGetRawClient returns a mock raw client for documentation generation func mockGetRawClient(_ context.Context) (*raw.Client, error) { return nil, nil @@ -58,6 +51,10 @@ func generateAllDocs() error { return fmt.Errorf("failed to generate remote-server docs: %w", err) } + if err := generateDeprecatedAliasesDocs("docs/deprecated-tool-aliases.md"); err != nil { + return fmt.Errorf("failed to generate deprecated aliases docs: %w", err) + } + return nil } @@ -65,9 +62,8 @@ func generateReadmeDocs(readmePath string) error { // Create translation helper t, _ := translations.TranslationHelper() - // Create toolset group with mock clients - repoAccessCache := lockdown.GetInstance(nil) - tsg := github.DefaultToolsetGroup(false, mockGetClient, mockGetGQLClient, mockGetRawClient, t, 5000, github.FeatureFlags{}, repoAccessCache) + // Create toolset group with mock clients (no deps needed for doc generation) + tsg := github.NewToolsetGroup(t, mockGetClient, mockGetRawClient) // Generate toolsets documentation toolsetsDoc := generateToolsetsDoc(tsg) @@ -133,20 +129,16 @@ func generateToolsetsDoc(tsg *toolsets.ToolsetGroup) string { // Add the context toolset row (handled separately in README) lines = append(lines, "| `context` | **Strongly recommended**: Tools that provide context about the current user and GitHub context you are operating in |") - // Get all toolsets except context (which is handled separately above) - var toolsetNames []string - for name := range tsg.Toolsets { - if name != "context" && name != "dynamic" { // Skip context and dynamic toolsets as they're handled separately - toolsetNames = append(toolsetNames, name) - } - } - - // Sort toolset names for consistent output - sort.Strings(toolsetNames) + // Get toolset IDs and descriptions + toolsetIDs := tsg.ToolsetIDs() + descriptions := tsg.ToolsetDescriptions() - for _, name := range toolsetNames { - toolset := tsg.Toolsets[name] - lines = append(lines, fmt.Sprintf("| `%s` | %s |", name, toolset.Description)) + // Filter out context and dynamic toolsets (handled separately) + for _, id := range toolsetIDs { + if id != "context" && id != "dynamic" { + description := descriptions[id] + lines = append(lines, fmt.Sprintf("| `%s` | %s |", id, description)) + } } return strings.Join(lines, "\n") @@ -155,30 +147,22 @@ func generateToolsetsDoc(tsg *toolsets.ToolsetGroup) string { func generateToolsDoc(tsg *toolsets.ToolsetGroup) string { var sections []string - // Get all toolset names and sort them alphabetically for deterministic order - var toolsetNames []string - for name := range tsg.Toolsets { - if name != "dynamic" { // Skip dynamic toolset as it's handled separately - toolsetNames = append(toolsetNames, name) - } - } - sort.Strings(toolsetNames) + // Get toolset IDs (already sorted deterministically) + toolsetIDs := tsg.ToolsetIDs() - for _, toolsetName := range toolsetNames { - toolset := tsg.Toolsets[toolsetName] + for _, toolsetID := range toolsetIDs { + if toolsetID == "dynamic" { // Skip dynamic toolset as it's handled separately + continue + } - tools := toolset.GetAvailableTools() + // Get tools for this toolset (already sorted deterministically) + tools := tsg.ToolsForToolset(toolsetID) if len(tools) == 0 { continue } - // Sort tools by name for deterministic order - sort.Slice(tools, func(i, j int) bool { - return tools[i].Tool.Name < tools[j].Tool.Name - }) - // Generate section header - capitalize first letter and replace underscores - sectionName := formatToolsetName(toolsetName) + sectionName := formatToolsetName(string(toolsetID)) var toolDocs []string for _, serverTool := range tools { @@ -322,33 +306,30 @@ func generateRemoteToolsetsDoc() string { t, _ := translations.TranslationHelper() // Create toolset group with mock clients - repoAccessCache := lockdown.GetInstance(nil) - tsg := github.DefaultToolsetGroup(false, mockGetClient, mockGetGQLClient, mockGetRawClient, t, 5000, github.FeatureFlags{}, repoAccessCache) + tsg := github.NewToolsetGroup(t, mockGetClient, mockGetRawClient) // Generate table header buf.WriteString("| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |\n") buf.WriteString("|----------------|--------------------------------------------------|-------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|\n") - // Get all toolsets - toolsetNames := make([]string, 0, len(tsg.Toolsets)) - for name := range tsg.Toolsets { - if name != "context" && name != "dynamic" { // Skip context and dynamic toolsets as they're handled separately - toolsetNames = append(toolsetNames, name) - } - } - sort.Strings(toolsetNames) + // Get toolset IDs and descriptions + toolsetIDs := tsg.ToolsetIDs() + descriptions := tsg.ToolsetDescriptions() // Add "all" toolset first (special case) buf.WriteString("| all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) |\n") // Add individual toolsets - for _, name := range toolsetNames { - toolset := tsg.Toolsets[name] + for _, id := range toolsetIDs { + idStr := string(id) + if idStr == "context" || idStr == "dynamic" { // Skip context and dynamic toolsets as they're handled separately + continue + } - formattedName := formatToolsetName(name) - description := toolset.Description - apiURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s", name) - readonlyURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s/readonly", name) + description := descriptions[id] + formattedName := formatToolsetName(idStr) + apiURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s", idStr) + readonlyURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s/readonly", idStr) // Create install config JSON (URL encoded) installConfig := url.QueryEscape(fmt.Sprintf(`{"type": "http","url": "%s"}`, apiURL)) @@ -358,8 +339,8 @@ func generateRemoteToolsetsDoc() string { installConfig = strings.ReplaceAll(installConfig, "+", "%20") readonlyConfig = strings.ReplaceAll(readonlyConfig, "+", "%20") - installLink := fmt.Sprintf("[Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", name, installConfig) - readonlyInstallLink := fmt.Sprintf("[Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", name, readonlyConfig) + installLink := fmt.Sprintf("[Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", idStr, installConfig) + readonlyInstallLink := fmt.Sprintf("[Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", idStr, readonlyConfig) buf.WriteString(fmt.Sprintf("| %-14s | %-48s | %-53s | %-218s | %-110s | %-288s |\n", formattedName, @@ -373,3 +354,53 @@ func generateRemoteToolsetsDoc() string { return buf.String() } + +func generateDeprecatedAliasesDocs(docsPath string) error { + // Read the current file + content, err := os.ReadFile(docsPath) //#nosec G304 + if err != nil { + return fmt.Errorf("failed to read docs file: %w", err) + } + + // Generate the table + aliasesDoc := generateDeprecatedAliasesTable() + + // Replace content between markers + updatedContent := replaceSection(string(content), "START AUTOMATED ALIASES", "END AUTOMATED ALIASES", aliasesDoc) + + // Write back to file + err = os.WriteFile(docsPath, []byte(updatedContent), 0600) + if err != nil { + return fmt.Errorf("failed to write deprecated aliases docs: %w", err) + } + + fmt.Println("Successfully updated docs/deprecated-tool-aliases.md with automated documentation") + return nil +} + +func generateDeprecatedAliasesTable() string { + var lines []string + + // Add table header + lines = append(lines, "| Old Name | New Name |") + lines = append(lines, "|----------|----------|") + + aliases := github.DeprecatedToolAliases + if len(aliases) == 0 { + lines = append(lines, "| *(none currently)* | |") + } else { + // Sort keys for deterministic output + var oldNames []string + for oldName := range aliases { + oldNames = append(oldNames, oldName) + } + sort.Strings(oldNames) + + for _, oldName := range oldNames { + newName := aliases[oldName] + lines = append(lines, fmt.Sprintf("| `%s` | `%s` |", oldName, newName)) + } + } + + return strings.Join(lines, "\n") +} diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 87eeedd2e..84c974dad 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -52,9 +52,10 @@ var ( 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} + // Parse enabled features (similar to toolsets) + var enabledFeatures []string + if err := viper.UnmarshalKey("features", &enabledFeatures); err != nil { + return fmt.Errorf("failed to unmarshal features: %w", err) } ttl := viper.GetDuration("repo-access-cache-ttl") @@ -64,6 +65,7 @@ var ( Token: token, EnabledToolsets: enabledToolsets, EnabledTools: enabledTools, + EnabledFeatures: enabledFeatures, DynamicToolsets: viper.GetBool("dynamic_toolsets"), ReadOnly: viper.GetBool("read-only"), ExportTranslations: viper.GetBool("export-translations"), @@ -87,6 +89,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().StringSlice("features", nil, "Comma-separated list of feature flags 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") @@ -100,6 +103,7 @@ func init() { // Bind flag to viper _ = viper.BindPFlag("toolsets", rootCmd.PersistentFlags().Lookup("toolsets")) _ = viper.BindPFlag("tools", rootCmd.PersistentFlags().Lookup("tools")) + _ = viper.BindPFlag("features", rootCmd.PersistentFlags().Lookup("features")) _ = 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/docs/deprecated-tool-aliases.md b/docs/deprecated-tool-aliases.md new file mode 100644 index 000000000..6ea2ba1de --- /dev/null +++ b/docs/deprecated-tool-aliases.md @@ -0,0 +1,31 @@ +# Deprecated Tool Aliases + +This document tracks tool renames in the GitHub MCP Server. When tools are renamed, the old names are preserved as aliases for backward compatibility. Using a deprecated alias will still work, but clients should migrate to the new canonical name. + +## Current Deprecations + + +| Old Name | New Name | +|----------|----------| +| *(none currently)* | | + + +## How It Works + +When a tool is renamed: + +1. The old name is added to `DeprecatedToolAliases` in [pkg/github/deprecated_tool_aliases.go](../pkg/github/deprecated_tool_aliases.go) +2. Clients using the old name will receive the new tool +3. A deprecation notice is logged when the alias is used + +## For Developers + +To deprecate a tool name when renaming: + +```go +var DeprecatedToolAliases = map[string]string{ + "old_tool_name": "new_tool_name", +} +``` + +The alias resolution happens at server startup, ensuring backward compatibility for existing client configurations. diff --git a/docs/remote-server.md b/docs/remote-server.md index e06d41a75..ffdf526a4 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -24,7 +24,6 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | Code Security | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | | Dependabot | Dependabot tools | https://api.githubcopilot.com/mcp/x/dependabot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/dependabot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-dependabot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdependabot%2Freadonly%22%7D) | | Discussions | GitHub Discussions related tools | https://api.githubcopilot.com/mcp/x/discussions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/discussions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-discussions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fdiscussions%2Freadonly%22%7D) | -| Experiments | Experimental features that are not considered stable yet | https://api.githubcopilot.com/mcp/x/experiments | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/experiments/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-experiments&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fexperiments%2Freadonly%22%7D) | | Gists | GitHub Gist related tools | https://api.githubcopilot.com/mcp/x/gists | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/gists/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-gists&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgists%2Freadonly%22%7D) | | Git | GitHub Git API related tools for low-level Git operations | https://api.githubcopilot.com/mcp/x/git | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/git/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-git&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fgit%2Freadonly%22%7D) | | Issues | GitHub Issues related tools | https://api.githubcopilot.com/mcp/x/issues | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/issues/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-issues&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fissues%2Freadonly%22%7D) | diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index c0f4e25e7..0edca88ed 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -18,6 +18,7 @@ import ( "github.com/github/github-mcp-server/pkg/lockdown" mcplog "github.com/github/github-mcp-server/pkg/log" "github.com/github/github-mcp-server/pkg/raw" + "github.com/github/github-mcp-server/pkg/toolsets" "github.com/github/github-mcp-server/pkg/translations" gogithub "github.com/google/go-github/v79/github" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -42,6 +43,10 @@ type MCPServerConfig struct { // When specified, these tools are registered in addition to any specified toolset tools EnabledTools []string + // EnabledFeatures is a list of feature flags that are enabled + // Items with FeatureFlagEnable matching an entry in this list will be available + EnabledFeatures []string + // Whether to enable dynamic toolsets // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#dynamic-tool-discovery DynamicToolsets bool @@ -100,24 +105,9 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { enabledToolsets := cfg.EnabledToolsets - // If dynamic toolsets are enabled, remove "all" and "default" from the enabled toolsets - if cfg.DynamicToolsets { - enabledToolsets = github.RemoveToolset(enabledToolsets, github.ToolsetMetadataAll.ID) - enabledToolsets = github.RemoveToolset(enabledToolsets, github.ToolsetMetadataDefault.ID) - } - - // Clean up the passed toolsets + // Clean up the passed toolsets (removes duplicates, whitespace) enabledToolsets, invalidToolsets := github.CleanToolsets(enabledToolsets) - // If "all" is present, override all other toolsets - if github.ContainsToolset(enabledToolsets, github.ToolsetMetadataAll.ID) { - enabledToolsets = []string{github.ToolsetMetadataAll.ID} - } - // If "default" is present, expand to real toolset IDs - if github.ContainsToolset(enabledToolsets, github.ToolsetMetadataDefault.ID) { - enabledToolsets = github.AddDefaultToolset(enabledToolsets) - } - if len(invalidToolsets) > 0 { fmt.Fprintf(os.Stderr, "Invalid toolsets ignored: %s\n", strings.Join(invalidToolsets, ", ")) } @@ -162,51 +152,73 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { ContentWindowSize: cfg.ContentWindowSize, } - // Create default toolsets - tsg := github.DefaultToolsetGroup( - cfg.ReadOnly, - getClient, - getGQLClient, - getRawClient, - cfg.Translator, - cfg.ContentWindowSize, - github.FeatureFlags{LockdownMode: cfg.LockdownMode}, - repoAccessCache, - ) - - // 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) - } + // Create toolset group with all tools, resources, and prompts + tsg := github.NewToolsetGroup(cfg.Translator, getClient, getRawClient) - // Register all mcp functionality with the server - tsg.RegisterAll(ghServer) - } + // Add deprecated tool aliases for backward compatibility + // See docs/deprecated-tool-aliases.md for the full list of renames + tsg.AddDeprecatedToolAliases(github.DeprecatedToolAliases) - // Register specific tools if configured - if len(cfg.EnabledTools) > 0 { - enabledTools := github.CleanTools(cfg.EnabledTools) - enabledTools, _ = tsg.ResolveToolAliases(enabledTools) + // Clean tool names (WithTools will resolve any deprecated aliases) + enabledTools := github.CleanTools(cfg.EnabledTools) - // Register the specified tools (additive to any toolsets already enabled) - err = tsg.RegisterSpecificTools(ghServer, enabledTools, cfg.ReadOnly, deps) - if err != nil { - return nil, fmt.Errorf("failed to register tools: %w", err) - } + // For dynamic toolsets mode: + // - If toolsets are explicitly provided (including "default"), honor them + // - If no toolsets are specified (nil), start with no toolsets enabled (empty slice) + // so users can enable them on demand via the dynamic tools + if cfg.DynamicToolsets && cfg.EnabledToolsets == nil { + enabledToolsets = []string{} } - // Register dynamic toolsets if configured (additive to toolsets and tools) + // Apply filters based on configuration + // - WithReadOnly: filters out write tools when true + // - WithToolsets: nil=defaults, empty=none, handles "all"/"default" keywords + // - WithTools: additional tools that bypass toolset filtering (additive, resolves aliases) + // - WithFeatureChecker: filters based on feature flags + filteredTsg := tsg. + WithReadOnly(cfg.ReadOnly). + WithToolsets(enabledToolsets). + WithTools(enabledTools). + WithFeatureChecker(createFeatureChecker(cfg.EnabledFeatures)) + + // Register all mcp functionality with the server + // Use background context for local server (no per-request actor context) + filteredTsg.RegisterAll(context.Background(), ghServer, deps) + + // Register dynamic toolset management if configured + // Dynamic tools get access to the filtered toolset group which tracks enabled state. + // ToolsForToolset() returns all tools for a toolset regardless of enabled status, + // so dynamic tools can enable any toolset at runtime. if cfg.DynamicToolsets { - dynamic := github.InitDynamicToolset(ghServer, tsg, cfg.Translator) - dynamic.RegisterTools(ghServer) + dynamicDeps := github.DynamicToolDependencies{ + Server: ghServer, + ToolsetGroup: filteredTsg, + ToolDeps: deps, + T: cfg.Translator, + } + dynamicTools := github.DynamicTools() + for _, tool := range dynamicTools { + tool.RegisterFunc(ghServer, dynamicDeps) + } } return ghServer, nil } +// createFeatureChecker returns a FeatureFlagChecker that checks if a flag name +// is present in the provided list of enabled features. For the local server, +// this is populated from the --features CLI flag. +func createFeatureChecker(enabledFeatures []string) toolsets.FeatureFlagChecker { + // Build a set for O(1) lookup + featureSet := make(map[string]bool, len(enabledFeatures)) + for _, f := range enabledFeatures { + featureSet[f] = true + } + return func(_ context.Context, flagName string) (bool, error) { + return featureSet[flagName], nil + } +} + type StdioServerConfig struct { // Version of the server Version string @@ -225,6 +237,10 @@ type StdioServerConfig struct { // When specified, these tools are registered in addition to any specified toolset tools EnabledTools []string + // EnabledFeatures is a list of feature flags that are enabled + // Items with FeatureFlagEnable matching an entry in this list will be available + EnabledFeatures []string + // Whether to enable dynamic toolsets // See: https://github.com/github/github-mcp-server?tab=readme-ov-file#dynamic-tool-discovery DynamicToolsets bool @@ -282,6 +298,7 @@ func RunStdioServer(cfg StdioServerConfig) error { Token: cfg.Token, EnabledToolsets: cfg.EnabledToolsets, EnabledTools: cfg.EnabledTools, + EnabledFeatures: cfg.EnabledFeatures, DynamicToolsets: cfg.DynamicToolsets, ReadOnly: cfg.ReadOnly, Translator: t, diff --git a/pkg/github/actions.go b/pkg/github/actions.go index e9c7c11a8..f29f75e99 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -27,6 +27,7 @@ const ( // ListWorkflows creates a tool to list workflows in a repository func ListWorkflows(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataActions, mcp.Tool{ Name: "list_workflows", Description: t("TOOL_LIST_WORKFLOWS_DESCRIPTION", "List workflows in a repository"), @@ -97,6 +98,7 @@ func ListWorkflows(t translations.TranslationHelperFunc) toolsets.ServerTool { // ListWorkflowRuns creates a tool to list workflow runs for a specific workflow func ListWorkflowRuns(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataActions, mcp.Tool{ Name: "list_workflow_runs", Description: t("TOOL_LIST_WORKFLOW_RUNS_DESCRIPTION", "List workflow runs for a specific workflow"), @@ -250,6 +252,7 @@ func ListWorkflowRuns(t translations.TranslationHelperFunc) toolsets.ServerTool // RunWorkflow creates a tool to run an Actions workflow func RunWorkflow(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataActions, mcp.Tool{ Name: "run_workflow", Description: t("TOOL_RUN_WORKFLOW_DESCRIPTION", "Run an Actions workflow by workflow ID or filename"), @@ -361,6 +364,7 @@ func RunWorkflow(t translations.TranslationHelperFunc) toolsets.ServerTool { // GetWorkflowRun creates a tool to get details of a specific workflow run func GetWorkflowRun(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataActions, mcp.Tool{ Name: "get_workflow_run", Description: t("TOOL_GET_WORKFLOW_RUN_DESCRIPTION", "Get details of a specific workflow run"), @@ -428,6 +432,7 @@ func GetWorkflowRun(t translations.TranslationHelperFunc) toolsets.ServerTool { // GetWorkflowRunLogs creates a tool to download logs for a specific workflow run func GetWorkflowRunLogs(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataActions, mcp.Tool{ Name: "get_workflow_run_logs", Description: t("TOOL_GET_WORKFLOW_RUN_LOGS_DESCRIPTION", "Download logs for a specific workflow run (EXPENSIVE: downloads ALL logs as ZIP. Consider using get_job_logs with failed_only=true for debugging failed jobs)"), @@ -505,6 +510,7 @@ func GetWorkflowRunLogs(t translations.TranslationHelperFunc) toolsets.ServerToo // ListWorkflowJobs creates a tool to list jobs for a specific workflow run func ListWorkflowJobs(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataActions, mcp.Tool{ Name: "list_workflow_jobs", Description: t("TOOL_LIST_WORKFLOW_JOBS_DESCRIPTION", "List jobs for a specific workflow run"), @@ -604,6 +610,7 @@ func ListWorkflowJobs(t translations.TranslationHelperFunc) toolsets.ServerTool // GetJobLogs creates a tool to download logs for a specific workflow job or efficiently get all failed job logs for a workflow run func GetJobLogs(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataActions, mcp.Tool{ Name: "get_job_logs", Description: t("TOOL_GET_JOB_LOGS_DESCRIPTION", "Download logs for a specific workflow job or efficiently get all failed job logs for a workflow run"), @@ -868,6 +875,7 @@ func downloadLogContent(ctx context.Context, logURL string, tailLines int, maxLi // RerunWorkflowRun creates a tool to re-run an entire workflow run func RerunWorkflowRun(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataActions, mcp.Tool{ Name: "rerun_workflow_run", Description: t("TOOL_RERUN_WORKFLOW_RUN_DESCRIPTION", "Re-run an entire workflow run"), @@ -942,6 +950,7 @@ func RerunWorkflowRun(t translations.TranslationHelperFunc) toolsets.ServerTool // RerunFailedJobs creates a tool to re-run only the failed jobs in a workflow run func RerunFailedJobs(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataActions, mcp.Tool{ Name: "rerun_failed_jobs", Description: t("TOOL_RERUN_FAILED_JOBS_DESCRIPTION", "Re-run only the failed jobs in a workflow run"), @@ -1016,6 +1025,7 @@ func RerunFailedJobs(t translations.TranslationHelperFunc) toolsets.ServerTool { // CancelWorkflowRun creates a tool to cancel a workflow run func CancelWorkflowRun(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataActions, mcp.Tool{ Name: "cancel_workflow_run", Description: t("TOOL_CANCEL_WORKFLOW_RUN_DESCRIPTION", "Cancel a workflow run"), @@ -1092,6 +1102,7 @@ func CancelWorkflowRun(t translations.TranslationHelperFunc) toolsets.ServerTool // ListWorkflowRunArtifacts creates a tool to list artifacts for a workflow run func ListWorkflowRunArtifacts(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataActions, mcp.Tool{ Name: "list_workflow_run_artifacts", Description: t("TOOL_LIST_WORKFLOW_RUN_ARTIFACTS_DESCRIPTION", "List artifacts for a workflow run"), @@ -1171,6 +1182,7 @@ func ListWorkflowRunArtifacts(t translations.TranslationHelperFunc) toolsets.Ser // DownloadWorkflowRunArtifact creates a tool to download a workflow run artifact func DownloadWorkflowRunArtifact(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataActions, mcp.Tool{ Name: "download_workflow_run_artifact", Description: t("TOOL_DOWNLOAD_WORKFLOW_RUN_ARTIFACT_DESCRIPTION", "Get download URL for a workflow run artifact"), @@ -1247,6 +1259,7 @@ func DownloadWorkflowRunArtifact(t translations.TranslationHelperFunc) toolsets. // DeleteWorkflowRunLogs creates a tool to delete logs for a workflow run func DeleteWorkflowRunLogs(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataActions, mcp.Tool{ Name: "delete_workflow_run_logs", Description: t("TOOL_DELETE_WORKFLOW_RUN_LOGS_DESCRIPTION", "Delete logs for a workflow run"), @@ -1322,6 +1335,7 @@ func DeleteWorkflowRunLogs(t translations.TranslationHelperFunc) toolsets.Server // GetWorkflowRunUsage creates a tool to get usage metrics for a workflow run func GetWorkflowRunUsage(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataActions, mcp.Tool{ Name: "get_workflow_run_usage", Description: t("TOOL_GET_WORKFLOW_RUN_USAGE_DESCRIPTION", "Get usage metrics for a workflow run"), diff --git a/pkg/github/code_scanning.go b/pkg/github/code_scanning.go index 518855a59..888ad4fd2 100644 --- a/pkg/github/code_scanning.go +++ b/pkg/github/code_scanning.go @@ -18,6 +18,7 @@ import ( func GetCodeScanningAlert(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataCodeSecurity, mcp.Tool{ Name: "get_code_scanning_alert", Description: t("TOOL_GET_CODE_SCANNING_ALERT_DESCRIPTION", "Get details of a specific code scanning alert in a GitHub repository."), @@ -95,6 +96,7 @@ func GetCodeScanningAlert(t translations.TranslationHelperFunc) toolsets.ServerT func ListCodeScanningAlerts(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataCodeSecurity, mcp.Tool{ Name: "list_code_scanning_alerts", Description: t("TOOL_LIST_CODE_SCANNING_ALERTS_DESCRIPTION", "List code scanning alerts in a GitHub repository."), diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index e8043731a..d5e0cfee9 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -39,6 +39,7 @@ type UserDetails struct { // GetMe creates a tool to get details of the authenticated user. func GetMe(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataContext, mcp.Tool{ Name: "get_me", Description: t("TOOL_GET_ME_DESCRIPTION", "Get details of the authenticated GitHub user. Use this when a request is about the user's own profile for GitHub. Or when information is missing to build other tool calls."), @@ -112,6 +113,7 @@ type OrganizationTeams struct { func GetTeams(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataContext, mcp.Tool{ Name: "get_teams", Description: t("TOOL_GET_TEAMS_DESCRIPTION", "Get details of the teams the user is a member of. Limited to organizations accessible with current credentials"), @@ -210,6 +212,7 @@ func GetTeams(t translations.TranslationHelperFunc) toolsets.ServerTool { func GetTeamMembers(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataContext, mcp.Tool{ Name: "get_team_members", Description: t("TOOL_GET_TEAM_MEMBERS_DESCRIPTION", "Get member usernames of a specific team in an organization. Limited to organizations accessible with current credentials"), diff --git a/pkg/github/dependabot.go b/pkg/github/dependabot.go index b80fd0aa0..1508d1382 100644 --- a/pkg/github/dependabot.go +++ b/pkg/github/dependabot.go @@ -18,6 +18,7 @@ import ( func GetDependabotAlert(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataDependabot, mcp.Tool{ Name: "get_dependabot_alert", Description: t("TOOL_GET_DEPENDABOT_ALERT_DESCRIPTION", "Get details of a specific dependabot alert in a GitHub repository."), @@ -95,6 +96,7 @@ func GetDependabotAlert(t translations.TranslationHelperFunc) toolsets.ServerToo func ListDependabotAlerts(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataDependabot, mcp.Tool{ Name: "list_dependabot_alerts", Description: t("TOOL_LIST_DEPENDABOT_ALERTS_DESCRIPTION", "List dependabot alerts in a GitHub repository."), diff --git a/pkg/github/dependencies.go b/pkg/github/dependencies.go index 3124f6bd0..7dcc33f75 100644 --- a/pkg/github/dependencies.go +++ b/pkg/github/dependencies.go @@ -35,19 +35,19 @@ type ToolDependencies struct { ContentWindowSize int } -// NewTool creates a ServerTool with fully-typed ToolDependencies. +// NewTool creates a ServerTool with fully-typed ToolDependencies and toolset metadata. // This helper isolates the type assertion from `any` to `ToolDependencies`, // so tool implementations remain fully typed without assertions scattered throughout. -func NewTool[In, Out any](tool mcp.Tool, handler func(deps ToolDependencies) mcp.ToolHandlerFor[In, Out]) toolsets.ServerTool { - return toolsets.NewServerTool(tool, func(d any) mcp.ToolHandlerFor[In, Out] { +func NewTool[In, Out any](toolset toolsets.ToolsetMetadata, tool mcp.Tool, handler func(deps ToolDependencies) mcp.ToolHandlerFor[In, Out]) toolsets.ServerTool { + return toolsets.NewServerTool(tool, toolset, func(d any) mcp.ToolHandlerFor[In, Out] { return handler(d.(ToolDependencies)) }) } -// NewToolFromHandler creates a ServerTool with fully-typed ToolDependencies +// NewToolFromHandler creates a ServerTool with fully-typed ToolDependencies and toolset metadata // for handlers that conform to mcp.ToolHandler directly. -func NewToolFromHandler(tool mcp.Tool, handler func(deps ToolDependencies) mcp.ToolHandler) toolsets.ServerTool { - return toolsets.NewServerToolFromHandler(tool, func(d any) mcp.ToolHandler { +func NewToolFromHandler(toolset toolsets.ToolsetMetadata, tool mcp.Tool, handler func(deps ToolDependencies) mcp.ToolHandler) toolsets.ServerTool { + return toolsets.NewServerToolFromHandler(tool, toolset, func(d any) mcp.ToolHandler { return handler(d.(ToolDependencies)) }) } diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index 94f7f6f1b..5bbdb2b5f 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -124,6 +124,7 @@ func getQueryType(useOrdering bool, categoryID *githubv4.ID) any { func ListDiscussions(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataDiscussions, mcp.Tool{ Name: "list_discussions", Description: t("TOOL_LIST_DISCUSSIONS_DESCRIPTION", "List discussions for a repository or organisation."), @@ -277,6 +278,7 @@ func ListDiscussions(t translations.TranslationHelperFunc) toolsets.ServerTool { func GetDiscussion(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataDiscussions, mcp.Tool{ Name: "get_discussion", Description: t("TOOL_GET_DISCUSSION_DESCRIPTION", "Get a specific discussion by ID"), @@ -381,6 +383,7 @@ func GetDiscussion(t translations.TranslationHelperFunc) toolsets.ServerTool { func GetDiscussionComments(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataDiscussions, mcp.Tool{ Name: "get_discussion_comments", Description: t("TOOL_GET_DISCUSSION_COMMENTS_DESCRIPTION", "Get comments from a discussion"), @@ -508,6 +511,7 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) toolsets.Server func ListDiscussionCategories(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataDiscussions, mcp.Tool{ Name: "list_discussion_categories", Description: t("TOOL_LIST_DISCUSSION_CATEGORIES_DESCRIPTION", "List discussion categories with their id and name, for a repository or organisation."), diff --git a/pkg/github/dynamic_tools.go b/pkg/github/dynamic_tools.go index 9118c1c45..cc44e85f5 100644 --- a/pkg/github/dynamic_tools.go +++ b/pkg/github/dynamic_tools.go @@ -12,147 +12,213 @@ import ( "github.com/modelcontextprotocol/go-sdk/mcp" ) +// DynamicToolDependencies contains dependencies for dynamic toolset management tools. +// It includes the managed ToolsetGroup, the server for registration, and the deps +// that will be passed to tools when they are dynamically enabled. +type DynamicToolDependencies struct { + // Server is the MCP server to register tools with + Server *mcp.Server + // ToolsetGroup contains all available tools that can be enabled dynamically + ToolsetGroup *toolsets.ToolsetGroup + // ToolDeps are the dependencies passed to tools when they are registered + ToolDeps any + // T is the translation helper function + T translations.TranslationHelperFunc +} + +// NewDynamicTool creates a ServerTool with fully-typed DynamicToolDependencies. +func NewDynamicTool(toolset toolsets.ToolsetMetadata, tool mcp.Tool, handler func(deps DynamicToolDependencies) mcp.ToolHandlerFor[map[string]any, any]) toolsets.ServerTool { + return toolsets.NewServerTool(tool, toolset, func(d any) mcp.ToolHandlerFor[map[string]any, any] { + return handler(d.(DynamicToolDependencies)) + }) +} + +// AllToolsetIDsEnum returns all available toolset IDs as an enum for JSON Schema. +func AllToolsetIDsEnum() []any { + toolsets := AvailableToolsets() + result := make([]any, len(toolsets)) + for i, ts := range toolsets { + result[i] = ts.ID + } + return result +} + +// ToolsetEnum returns the list of toolset IDs as an enum for JSON Schema. +// Deprecated: Use AllToolsetIDsEnum() instead. func ToolsetEnum(toolsetGroup *toolsets.ToolsetGroup) []any { - toolsetNames := make([]any, 0, len(toolsetGroup.Toolsets)) - for name := range toolsetGroup.Toolsets { - toolsetNames = append(toolsetNames, name) + toolsetIDs := toolsetGroup.ToolsetIDs() + result := make([]any, len(toolsetIDs)) + for i, id := range toolsetIDs { + result[i] = id } - return toolsetNames + return result } -func EnableToolset(s *mcp.Server, toolsetGroup *toolsets.ToolsetGroup, t translations.TranslationHelperFunc) toolsets.ServerTool { - return toolsets.NewServerToolLegacy(mcp.Tool{ - Name: "enable_toolset", - Description: t("TOOL_ENABLE_TOOLSET_DESCRIPTION", "Enable one of the sets of tools the GitHub MCP server provides, use get_toolset_tools and list_available_toolsets first to see what this will enable"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_ENABLE_TOOLSET_USER_TITLE", "Enable a toolset"), - // Not modifying GitHub data so no need to show a warning - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "toolset": { - Type: "string", - Description: "The name of the toolset to enable", - Enum: ToolsetEnum(toolsetGroup), +// DynamicTools returns the tools for dynamic toolset management. +// These tools allow runtime discovery and enablement of toolsets. +func DynamicTools() []toolsets.ServerTool { + return []toolsets.ServerTool{ + ListAvailableToolsets(), + GetToolsetsTools(), + EnableToolset(), + } +} + +// EnableToolset creates a tool that enables a toolset at runtime. +func EnableToolset() toolsets.ServerTool { + return NewDynamicTool( + ToolsetMetadataDynamic, + mcp.Tool{ + Name: "enable_toolset", + Description: "Enable one of the sets of tools the GitHub MCP server provides, use get_toolset_tools and list_available_toolsets first to see what this will enable", + Annotations: &mcp.ToolAnnotations{ + Title: "Enable a toolset", + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "toolset": { + Type: "string", + Description: "The name of the toolset to enable", + Enum: AllToolsetIDsEnum(), + }, }, + Required: []string{"toolset"}, }, - Required: []string{"toolset"}, }, - }, - mcp.ToolHandlerFor[map[string]any, any](func(_ context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - // We need to convert the toolsets back to a map for JSON serialization - toolsetName, err := RequiredParam[string](args, "toolset") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - toolset := toolsetGroup.Toolsets[toolsetName] - if toolset == nil { - return utils.NewToolResultError(fmt.Sprintf("Toolset %s not found", toolsetName)), nil, nil - } - if toolset.Enabled { - return utils.NewToolResultText(fmt.Sprintf("Toolset %s is already enabled", toolsetName)), nil, nil - } + func(deps DynamicToolDependencies) mcp.ToolHandlerFor[map[string]any, any] { + return func(_ context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + toolsetName, err := RequiredParam[string](args, "toolset") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } - toolset.Enabled = true + toolsetID := toolsets.ToolsetID(toolsetName) - // caution: this currently affects the global tools and notifies all clients: - // - // Send notification to all initialized sessions - // s.sendNotificationToAllClients("notifications/tools/list_changed", nil) - toolset.RegisterTools(s) + if !deps.ToolsetGroup.HasToolset(toolsetID) { + return utils.NewToolResultError(fmt.Sprintf("Toolset %s not found", toolsetName)), nil, nil + } - return utils.NewToolResultText(fmt.Sprintf("Toolset %s enabled", toolsetName)), nil, nil - })) -} + if deps.ToolsetGroup.IsToolsetEnabled(toolsetID) { + return utils.NewToolResultText(fmt.Sprintf("Toolset %s is already enabled", toolsetName)), nil, nil + } -func ListAvailableToolsets(toolsetGroup *toolsets.ToolsetGroup, t translations.TranslationHelperFunc) toolsets.ServerTool { - return toolsets.NewServerToolLegacy(mcp.Tool{ - Name: "list_available_toolsets", - Description: t("TOOL_LIST_AVAILABLE_TOOLSETS_DESCRIPTION", "List all available toolsets this GitHub MCP server can offer, providing the enabled status of each. Use this when a task could be achieved with a GitHub tool and the currently available tools aren't enough. Call get_toolset_tools with these toolset names to discover specific tools you can call"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_LIST_AVAILABLE_TOOLSETS_USER_TITLE", "List available toolsets"), - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{}, + // Mark the toolset as enabled so IsToolsetEnabled returns true + deps.ToolsetGroup.EnableToolset(toolsetID) + + // Get tools for this toolset and register them with the managed deps + toolsForToolset := deps.ToolsetGroup.ToolsForToolset(toolsetID) + for _, st := range toolsForToolset { + st.RegisterFunc(deps.Server, deps.ToolDeps) + } + + return utils.NewToolResultText(fmt.Sprintf("Toolset %s enabled with %d tools", toolsetName, len(toolsForToolset))), nil, nil + } }, - }, - mcp.ToolHandlerFor[map[string]any, any](func(_ context.Context, _ *mcp.CallToolRequest, _ map[string]any) (*mcp.CallToolResult, any, error) { - // We need to convert the toolsetGroup back to a map for JSON serialization + ) +} - payload := []map[string]string{} +// ListAvailableToolsets creates a tool that lists all available toolsets. +func ListAvailableToolsets() toolsets.ServerTool { + return NewDynamicTool( + ToolsetMetadataDynamic, + mcp.Tool{ + Name: "list_available_toolsets", + Description: "List all available toolsets this GitHub MCP server can offer, providing the enabled status of each. Use this when a task could be achieved with a GitHub tool and the currently available tools aren't enough. Call get_toolset_tools with these toolset names to discover specific tools you can call", + Annotations: &mcp.ToolAnnotations{ + Title: "List available toolsets", + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{}, + }, + }, + func(deps DynamicToolDependencies) mcp.ToolHandlerFor[map[string]any, any] { + return func(_ context.Context, _ *mcp.CallToolRequest, _ map[string]any) (*mcp.CallToolResult, any, error) { + toolsetIDs := deps.ToolsetGroup.ToolsetIDs() + descriptions := deps.ToolsetGroup.ToolsetDescriptions() - for name, ts := range toolsetGroup.Toolsets { - { + payload := make([]map[string]string, 0, len(toolsetIDs)) + for _, id := range toolsetIDs { t := map[string]string{ - "name": name, - "description": ts.Description, + "name": string(id), + "description": descriptions[id], "can_enable": "true", - "currently_enabled": fmt.Sprintf("%t", ts.Enabled), + "currently_enabled": fmt.Sprintf("%t", deps.ToolsetGroup.IsToolsetEnabled(id)), } payload = append(payload, t) } - } - r, err := json.Marshal(payload) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal features: %w", err) - } + r, err := json.Marshal(payload) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal features: %w", err) + } - return utils.NewToolResultText(string(r)), nil, nil - })) + return utils.NewToolResultText(string(r)), nil, nil + } + }, + ) } -func GetToolsetsTools(toolsetGroup *toolsets.ToolsetGroup, t translations.TranslationHelperFunc) toolsets.ServerTool { - return toolsets.NewServerToolLegacy(mcp.Tool{ - Name: "get_toolset_tools", - Description: t("TOOL_GET_TOOLSET_TOOLS_DESCRIPTION", "Lists all the capabilities that are enabled with the specified toolset, use this to get clarity on whether enabling a toolset would help you to complete a task"), - Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_GET_TOOLSET_TOOLS_USER_TITLE", "List all tools in a toolset"), - ReadOnlyHint: true, - }, - InputSchema: &jsonschema.Schema{ - Type: "object", - Properties: map[string]*jsonschema.Schema{ - "toolset": { - Type: "string", - Description: "The name of the toolset you want to get the tools for", - Enum: ToolsetEnum(toolsetGroup), +// GetToolsetsTools creates a tool that lists all tools in a specific toolset. +func GetToolsetsTools() toolsets.ServerTool { + return NewDynamicTool( + ToolsetMetadataDynamic, + mcp.Tool{ + Name: "get_toolset_tools", + Description: "Lists all the capabilities that are enabled with the specified toolset, use this to get clarity on whether enabling a toolset would help you to complete a task", + Annotations: &mcp.ToolAnnotations{ + Title: "List all tools in a toolset", + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "toolset": { + Type: "string", + Description: "The name of the toolset you want to get the tools for", + Enum: AllToolsetIDsEnum(), + }, }, + Required: []string{"toolset"}, }, - Required: []string{"toolset"}, }, - }, - mcp.ToolHandlerFor[map[string]any, any](func(_ context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - // We need to convert the toolsetGroup back to a map for JSON serialization - toolsetName, err := RequiredParam[string](args, "toolset") - if err != nil { - return utils.NewToolResultError(err.Error()), nil, nil - } - toolset := toolsetGroup.Toolsets[toolsetName] - if toolset == nil { - return utils.NewToolResultError(fmt.Sprintf("Toolset %s not found", toolsetName)), nil, nil - } - payload := []map[string]string{} - - for _, st := range toolset.GetAvailableTools() { - tool := map[string]string{ - "name": st.Tool.Name, - "description": st.Tool.Description, - "can_enable": "true", - "toolset": toolsetName, + func(deps DynamicToolDependencies) mcp.ToolHandlerFor[map[string]any, any] { + return func(_ context.Context, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + toolsetName, err := RequiredParam[string](args, "toolset") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil } - payload = append(payload, tool) - } - r, err := json.Marshal(payload) - if err != nil { - return nil, nil, fmt.Errorf("failed to marshal features: %w", err) - } + toolsetID := toolsets.ToolsetID(toolsetName) + + if !deps.ToolsetGroup.HasToolset(toolsetID) { + return utils.NewToolResultError(fmt.Sprintf("Toolset %s not found", toolsetName)), nil, nil + } + + // Get all tools for this toolset (ignoring current filters for discovery) + toolsInToolset := deps.ToolsetGroup.ToolsForToolset(toolsetID) + payload := make([]map[string]string, 0, len(toolsInToolset)) + + for _, st := range toolsInToolset { + tool := map[string]string{ + "name": st.Tool.Name, + "description": st.Tool.Description, + "can_enable": "true", + "toolset": toolsetName, + } + payload = append(payload, tool) + } + + r, err := json.Marshal(payload) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal features: %w", err) + } - return utils.NewToolResultText(string(r)), nil, nil - })) + return utils.NewToolResultText(string(r)), nil, nil + } + }, + ) } diff --git a/pkg/github/gists.go b/pkg/github/gists.go index baca42399..03e5e1bc8 100644 --- a/pkg/github/gists.go +++ b/pkg/github/gists.go @@ -18,6 +18,7 @@ import ( // ListGists creates a tool to list gists for a user func ListGists(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataGists, mcp.Tool{ Name: "list_gists", Description: t("TOOL_LIST_GISTS_DESCRIPTION", "List gists for a user"), @@ -105,6 +106,7 @@ func ListGists(t translations.TranslationHelperFunc) toolsets.ServerTool { // GetGist creates a tool to get the content of a gist func GetGist(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataGists, mcp.Tool{ Name: "get_gist", Description: t("TOOL_GET_GIST_DESCRIPTION", "Get gist content of a particular gist, by gist ID"), @@ -163,6 +165,7 @@ func GetGist(t translations.TranslationHelperFunc) toolsets.ServerTool { // CreateGist creates a tool to create a new gist func CreateGist(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataGists, mcp.Tool{ Name: "create_gist", Description: t("TOOL_CREATE_GIST_DESCRIPTION", "Create a new gist"), @@ -266,6 +269,7 @@ func CreateGist(t translations.TranslationHelperFunc) toolsets.ServerTool { // UpdateGist creates a tool to edit an existing gist func UpdateGist(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataGists, mcp.Tool{ Name: "update_gist", Description: t("TOOL_UPDATE_GIST_DESCRIPTION", "Update an existing gist"), diff --git a/pkg/github/git.go b/pkg/github/git.go index c5fdded7c..e619afc34 100644 --- a/pkg/github/git.go +++ b/pkg/github/git.go @@ -40,6 +40,7 @@ type TreeResponse struct { // GetRepositoryTree creates a tool to get the tree structure of a GitHub repository. func GetRepositoryTree(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataGit, mcp.Tool{ Name: "get_repository_tree", Description: t("TOOL_GET_REPOSITORY_TREE_DESCRIPTION", "Get the tree structure (files and directories) of a GitHub repository at a specific ref or SHA"), diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 142bdd421..1d0e3b2d5 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -263,6 +263,7 @@ Options are: WithPagination(schema) return NewTool( + ToolsetMetadataIssues, mcp.Tool{ Name: "issue_read", Description: t("TOOL_ISSUE_READ_DESCRIPTION", "Get information about a specific issue in a GitHub repository."), @@ -546,6 +547,7 @@ func GetIssueLabels(ctx context.Context, client *githubv4.Client, owner string, // ListIssueTypes creates a tool to list defined issue types for an organization. This can be used to understand supported issue type values for creating or updating issues. func ListIssueTypes(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataIssues, mcp.Tool{ Name: "list_issue_types", Description: t("TOOL_LIST_ISSUE_TYPES_FOR_ORG", "List supported issue types for repository owner (organization)."), @@ -602,6 +604,7 @@ func ListIssueTypes(t translations.TranslationHelperFunc) toolsets.ServerTool { // AddIssueComment creates a tool to add a comment to an issue. func AddIssueComment(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataIssues, mcp.Tool{ Name: "add_issue_comment", Description: t("TOOL_ADD_ISSUE_COMMENT_DESCRIPTION", "Add a comment to a specific issue in a GitHub repository. Use this tool to add comments to pull requests as well (in this case pass pull request number as issue_number), but only if user is not asking specifically to add review comments."), @@ -686,6 +689,7 @@ func AddIssueComment(t translations.TranslationHelperFunc) toolsets.ServerTool { // SubIssueWrite creates a tool to add a sub-issue to a parent issue. func SubIssueWrite(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataIssues, mcp.Tool{ Name: "sub_issue_write", Description: t("TOOL_SUB_ISSUE_WRITE_DESCRIPTION", "Add a sub-issue to a parent issue in a GitHub repository."), @@ -956,6 +960,7 @@ func SearchIssues(t translations.TranslationHelperFunc) toolsets.ServerTool { WithPagination(schema) return NewTool( + ToolsetMetadataIssues, mcp.Tool{ Name: "search_issues", Description: t("TOOL_SEARCH_ISSUES_DESCRIPTION", "Search for issues in GitHub repositories using issues search syntax already scoped to is:issue"), @@ -976,6 +981,7 @@ func SearchIssues(t translations.TranslationHelperFunc) toolsets.ServerTool { // IssueWrite creates a tool to create a new or update an existing issue in a GitHub repository. func IssueWrite(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataIssues, mcp.Tool{ Name: "issue_write", Description: t("TOOL_ISSUE_WRITE_DESCRIPTION", "Create a new or update an existing issue in a GitHub repository."), @@ -1376,6 +1382,7 @@ func ListIssues(t translations.TranslationHelperFunc) toolsets.ServerTool { WithCursorPagination(schema) return NewTool( + ToolsetMetadataIssues, mcp.Tool{ Name: "list_issues", Description: t("TOOL_LIST_ISSUES_DESCRIPTION", "List issues in a GitHub repository. For pagination, use the 'endCursor' from the previous response's 'pageInfo' in the 'after' parameter."), @@ -1611,6 +1618,7 @@ func AssignCopilotToIssue(t translations.TranslationHelperFunc) toolsets.ServerT } return NewTool( + ToolsetMetadataIssues, mcp.Tool{ Name: "assign_copilot_to_issue", Description: t("TOOL_ASSIGN_COPILOT_TO_ISSUE_DESCRIPTION", description.String()), @@ -1797,8 +1805,10 @@ func parseISOTimestamp(timestamp string) (time.Time, error) { return time.Time{}, fmt.Errorf("invalid ISO 8601 timestamp: %s (supported formats: YYYY-MM-DDThh:mm:ssZ or YYYY-MM-DD)", timestamp) } -func AssignCodingAgentPrompt(t translations.TranslationHelperFunc) (mcp.Prompt, mcp.PromptHandler) { - return mcp.Prompt{ +func AssignCodingAgentPrompt(t translations.TranslationHelperFunc) toolsets.ServerPrompt { + return toolsets.NewServerPrompt( + ToolsetMetadataIssues, + mcp.Prompt{ Name: "AssignCodingAgent", Description: t("PROMPT_ASSIGN_CODING_AGENT_DESCRIPTION", "Assign GitHub Coding Agent to multiple tasks in a GitHub repository."), Arguments: []*mcp.PromptArgument{ @@ -1808,7 +1818,8 @@ func AssignCodingAgentPrompt(t translations.TranslationHelperFunc) (mcp.Prompt, Required: true, }, }, - }, func(_ context.Context, request *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + }, + func(_ context.Context, request *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { repo := request.Params.Arguments["repo"] messages := []*mcp.PromptMessage{ @@ -1852,5 +1863,6 @@ func AssignCodingAgentPrompt(t translations.TranslationHelperFunc) (mcp.Prompt, return &mcp.GetPromptResult{ Messages: messages, }, nil - } + }, + ) } diff --git a/pkg/github/labels.go b/pkg/github/labels.go index 0c18c83e4..a98468fae 100644 --- a/pkg/github/labels.go +++ b/pkg/github/labels.go @@ -18,6 +18,7 @@ import ( // GetLabel retrieves a specific label by name from a GitHub repository func GetLabel(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetLabels, mcp.Tool{ Name: "get_label", Description: t("TOOL_GET_LABEL_DESCRIPTION", "Get a specific label from a repository."), @@ -112,6 +113,7 @@ func GetLabel(t translations.TranslationHelperFunc) toolsets.ServerTool { // ListLabels lists labels from a repository func ListLabels(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetLabels, mcp.Tool{ Name: "list_label", Description: t("TOOL_LIST_LABEL_DESCRIPTION", "List labels from a repository"), @@ -203,6 +205,7 @@ func ListLabels(t translations.TranslationHelperFunc) toolsets.ServerTool { // LabelWrite handles create, update, and delete operations for GitHub labels func LabelWrite(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetLabels, mcp.Tool{ Name: "label_write", Description: t("TOOL_LABEL_WRITE_DESCRIPTION", "Perform write operations on repository labels. To set labels on issues, use the 'update_issue' tool."), diff --git a/pkg/github/notifications.go b/pkg/github/notifications.go index 23d63c946..4eb2d7b5b 100644 --- a/pkg/github/notifications.go +++ b/pkg/github/notifications.go @@ -27,6 +27,7 @@ const ( // ListNotifications creates a tool to list notifications for the current user. func ListNotifications(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataNotifications, mcp.Tool{ Name: "list_notifications", Description: t("TOOL_LIST_NOTIFICATIONS_DESCRIPTION", "Lists all GitHub notifications for the authenticated user, including unread notifications, mentions, review requests, assignments, and updates on issues or pull requests. Use this tool whenever the user asks what to work on next, requests a summary of their GitHub activity, wants to see pending reviews, or needs to check for new updates or tasks. This tool is the primary way to discover actionable items, reminders, and outstanding work on GitHub. Always call this tool when asked what to work on next, what is pending, or what needs attention in GitHub."), @@ -164,6 +165,7 @@ func ListNotifications(t translations.TranslationHelperFunc) toolsets.ServerTool // DismissNotification creates a tool to mark a notification as read/done. func DismissNotification(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataNotifications, mcp.Tool{ Name: "dismiss_notification", Description: t("TOOL_DISMISS_NOTIFICATION_DESCRIPTION", "Dismiss a notification by marking it as read or done"), @@ -246,6 +248,7 @@ func DismissNotification(t translations.TranslationHelperFunc) toolsets.ServerTo // MarkAllNotificationsRead creates a tool to mark all notifications as read. func MarkAllNotificationsRead(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataNotifications, mcp.Tool{ Name: "mark_all_notifications_read", Description: t("TOOL_MARK_ALL_NOTIFICATIONS_READ_DESCRIPTION", "Mark all notifications as read"), @@ -338,6 +341,7 @@ func MarkAllNotificationsRead(t translations.TranslationHelperFunc) toolsets.Ser // GetNotificationDetails creates a tool to get details for a specific notification. func GetNotificationDetails(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataNotifications, mcp.Tool{ Name: "get_notification_details", Description: t("TOOL_GET_NOTIFICATION_DETAILS_DESCRIPTION", "Get detailed information for a specific GitHub notification, always call this tool when the user asks for details about a specific notification, if you don't know the ID list notifications first."), @@ -407,6 +411,7 @@ const ( // ManageNotificationSubscription creates a tool to manage a notification subscription (ignore, watch, delete) func ManageNotificationSubscription(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataNotifications, mcp.Tool{ Name: "manage_notification_subscription", Description: t("TOOL_MANAGE_NOTIFICATION_SUBSCRIPTION_DESCRIPTION", "Manage a notification subscription: ignore, watch, or delete a notification thread subscription."), @@ -503,6 +508,7 @@ const ( // ManageRepositoryNotificationSubscription creates a tool to manage a repository notification subscription (ignore, watch, delete) func ManageRepositoryNotificationSubscription(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataNotifications, mcp.Tool{ Name: "manage_repository_notification_subscription", Description: t("TOOL_MANAGE_REPOSITORY_NOTIFICATION_SUBSCRIPTION_DESCRIPTION", "Manage a repository notification subscription: ignore, watch, or delete repository notifications subscription for the provided repository."), diff --git a/pkg/github/projects.go b/pkg/github/projects.go index ca26e2550..a12aca7be 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -27,6 +27,7 @@ const ( func ListProjects(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataProjects, mcp.Tool{ Name: "list_projects", Description: t("TOOL_LIST_PROJECTS_DESCRIPTION", `List Projects for a user or organization`), @@ -145,6 +146,7 @@ func ListProjects(t translations.TranslationHelperFunc) toolsets.ServerTool { func GetProject(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataProjects, mcp.Tool{ Name: "get_project", Description: t("TOOL_GET_PROJECT_DESCRIPTION", "Get Project for a user or org"), @@ -234,6 +236,7 @@ func GetProject(t translations.TranslationHelperFunc) toolsets.ServerTool { func ListProjectFields(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataProjects, mcp.Tool{ Name: "list_project_fields", Description: t("TOOL_LIST_PROJECT_FIELDS_DESCRIPTION", "List Project fields for a user or org"), @@ -341,6 +344,7 @@ func ListProjectFields(t translations.TranslationHelperFunc) toolsets.ServerTool func GetProjectField(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataProjects, mcp.Tool{ Name: "get_project_field", Description: t("TOOL_GET_PROJECT_FIELD_DESCRIPTION", "Get Project field for a user or org"), @@ -434,6 +438,7 @@ func GetProjectField(t translations.TranslationHelperFunc) toolsets.ServerTool { func ListProjectItems(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataProjects, mcp.Tool{ Name: "list_project_items", Description: t("TOOL_LIST_PROJECT_ITEMS_DESCRIPTION", `Search project items with advanced filtering`), @@ -571,6 +576,7 @@ func ListProjectItems(t translations.TranslationHelperFunc) toolsets.ServerTool func GetProjectItem(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataProjects, mcp.Tool{ Name: "get_project_item", Description: t("TOOL_GET_PROJECT_ITEM_DESCRIPTION", "Get a specific Project item for a user or org"), @@ -678,6 +684,7 @@ func GetProjectItem(t translations.TranslationHelperFunc) toolsets.ServerTool { func AddProjectItem(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataProjects, mcp.Tool{ Name: "add_project_item", Description: t("TOOL_ADD_PROJECT_ITEM_DESCRIPTION", "Add a specific Project item for a user or org"), @@ -790,6 +797,7 @@ func AddProjectItem(t translations.TranslationHelperFunc) toolsets.ServerTool { func UpdateProjectItem(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataProjects, mcp.Tool{ Name: "update_project_item", Description: t("TOOL_UPDATE_PROJECT_ITEM_DESCRIPTION", "Update a specific Project item for a user or org"), @@ -903,6 +911,7 @@ func UpdateProjectItem(t translations.TranslationHelperFunc) toolsets.ServerTool func DeleteProjectItem(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataProjects, mcp.Tool{ Name: "delete_project_item", Description: t("TOOL_DELETE_PROJECT_ITEM_DESCRIPTION", "Delete a specific Project item for a user or org"), diff --git a/pkg/github/prompts.go b/pkg/github/prompts.go new file mode 100644 index 000000000..82d7bf514 --- /dev/null +++ b/pkg/github/prompts.go @@ -0,0 +1,16 @@ +package github + +import ( + "github.com/github/github-mcp-server/pkg/toolsets" + "github.com/github/github-mcp-server/pkg/translations" +) + +// AllPrompts returns all prompts with their embedded toolset metadata. +// Prompt functions return ServerPrompt directly with toolset info. +func AllPrompts(t translations.TranslationHelperFunc) []toolsets.ServerPrompt { + return []toolsets.ServerPrompt{ + // Issue prompts + AssignCodingAgentPrompt(t), + IssueToFixWorkflowPrompt(t), + } +} diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index bfe870775..229e20e57 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -58,6 +58,7 @@ Possible options: WithPagination(schema) return NewTool( + ToolsetMetadataPullRequests, mcp.Tool{ Name: "pull_request_read", Description: t("TOOL_PULL_REQUEST_READ_DESCRIPTION", "Get information on a specific pull request in GitHub repository."), @@ -430,6 +431,7 @@ func CreatePullRequest(t translations.TranslationHelperFunc) toolsets.ServerTool } return NewTool( + ToolsetMetadataPullRequests, mcp.Tool{ Name: "create_pull_request", Description: t("TOOL_CREATE_PULL_REQUEST_DESCRIPTION", "Create a new pull request in a GitHub repository."), @@ -582,6 +584,7 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) toolsets.ServerTool } return NewTool( + ToolsetMetadataPullRequests, mcp.Tool{ Name: "update_pull_request", Description: t("TOOL_UPDATE_PULL_REQUEST_DESCRIPTION", "Update an existing pull request in a GitHub repository."), @@ -864,6 +867,7 @@ func ListPullRequests(t translations.TranslationHelperFunc) toolsets.ServerTool WithPagination(schema) return NewTool( + ToolsetMetadataPullRequests, mcp.Tool{ Name: "list_pull_requests", Description: t("TOOL_LIST_PULL_REQUESTS_DESCRIPTION", "List pull requests in a GitHub repository. If the user specifies an author, then DO NOT use this tool and use the search_pull_requests tool instead."), @@ -1000,6 +1004,7 @@ func MergePullRequest(t translations.TranslationHelperFunc) toolsets.ServerTool } return NewTool( + ToolsetMetadataPullRequests, mcp.Tool{ Name: "merge_pull_request", Description: t("TOOL_MERGE_PULL_REQUEST_DESCRIPTION", "Merge a pull request in a GitHub repository."), @@ -1118,6 +1123,7 @@ func SearchPullRequests(t translations.TranslationHelperFunc) toolsets.ServerToo WithPagination(schema) return NewTool( + ToolsetMetadataPullRequests, mcp.Tool{ Name: "search_pull_requests", Description: t("TOOL_SEARCH_PULL_REQUESTS_DESCRIPTION", "Search for pull requests in GitHub repositories using issues search syntax already scoped to is:pr"), @@ -1161,6 +1167,7 @@ func UpdatePullRequestBranch(t translations.TranslationHelperFunc) toolsets.Serv } return NewTool( + ToolsetMetadataPullRequests, mcp.Tool{ Name: "update_pull_request_branch", Description: t("TOOL_UPDATE_PULL_REQUEST_BRANCH_DESCRIPTION", "Update the branch of a pull request with the latest changes from the base branch."), @@ -1282,6 +1289,7 @@ func PullRequestReviewWrite(t translations.TranslationHelperFunc) toolsets.Serve } return NewTool( + ToolsetMetadataPullRequests, mcp.Tool{ Name: "pull_request_review_write", Description: t("TOOL_PULL_REQUEST_REVIEW_WRITE_DESCRIPTION", `Create and/or submit, delete review of a pull request. @@ -1612,6 +1620,7 @@ func AddCommentToPendingReview(t translations.TranslationHelperFunc) toolsets.Se } return NewTool( + ToolsetMetadataPullRequests, mcp.Tool{ Name: "add_comment_to_pending_review", Description: t("TOOL_ADD_COMMENT_TO_PENDING_REVIEW_DESCRIPTION", "Add review comment to the requester's latest pending pull request review. A pending review needs to already exist to call this (check with the user if not sure)."), @@ -1764,6 +1773,7 @@ func RequestCopilotReview(t translations.TranslationHelperFunc) toolsets.ServerT } return NewTool( + ToolsetMetadataPullRequests, mcp.Tool{ Name: "request_copilot_review", Description: t("TOOL_REQUEST_COPILOT_REVIEW_DESCRIPTION", "Request a GitHub Copilot code review for a pull request. Use this for automated feedback on pull requests, usually before requesting a human reviewer."), diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index de5aaea5e..81e5c3a8c 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -21,6 +21,7 @@ import ( func GetCommit(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataRepos, mcp.Tool{ Name: "get_commit", Description: t("TOOL_GET_COMMITS_DESCRIPTION", "Get details for a commit from a GitHub repository"), @@ -119,6 +120,7 @@ func GetCommit(t translations.TranslationHelperFunc) toolsets.ServerTool { // ListCommits creates a tool to get commits of a branch in a repository. func ListCommits(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataRepos, mcp.Tool{ Name: "list_commits", Description: t("TOOL_LIST_COMMITS_DESCRIPTION", "Get list of commits of a branch in a GitHub repository. Returns at least 30 results per page by default, but can return more if specified using the perPage parameter (up to 100)."), @@ -227,6 +229,7 @@ func ListCommits(t translations.TranslationHelperFunc) toolsets.ServerTool { // ListBranches creates a tool to list branches in a GitHub repository. func ListBranches(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataRepos, mcp.Tool{ Name: "list_branches", Description: t("TOOL_LIST_BRANCHES_DESCRIPTION", "List branches in a GitHub repository"), @@ -314,6 +317,7 @@ func ListBranches(t translations.TranslationHelperFunc) toolsets.ServerTool { // CreateOrUpdateFile creates a tool to create or update a file in a GitHub repository. func CreateOrUpdateFile(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataRepos, mcp.Tool{ Name: "create_or_update_file", Description: t("TOOL_CREATE_OR_UPDATE_FILE_DESCRIPTION", "Create or update a single file in a GitHub repository. If updating, you must provide the SHA of the file you want to update. Use this tool to create or update a file in a GitHub repository remotely; do not use it for local file operations."), @@ -441,6 +445,7 @@ func CreateOrUpdateFile(t translations.TranslationHelperFunc) toolsets.ServerToo // CreateRepository creates a tool to create a new GitHub repository. func CreateRepository(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataRepos, mcp.Tool{ Name: "create_repository", Description: t("TOOL_CREATE_REPOSITORY_DESCRIPTION", "Create a new GitHub repository in your account or specified organization"), @@ -547,6 +552,7 @@ func CreateRepository(t translations.TranslationHelperFunc) toolsets.ServerTool // GetFileContents creates a tool to get the contents of a file or directory from a GitHub repository. func GetFileContents(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataRepos, mcp.Tool{ Name: "get_file_contents", Description: t("TOOL_GET_FILE_CONTENTS_DESCRIPTION", "Get the contents of a file or directory from a GitHub repository"), @@ -766,6 +772,7 @@ func GetFileContents(t translations.TranslationHelperFunc) toolsets.ServerTool { // ForkRepository creates a tool to fork a repository. func ForkRepository(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataRepos, mcp.Tool{ Name: "fork_repository", Description: t("TOOL_FORK_REPOSITORY_DESCRIPTION", "Fork a GitHub repository to your account or specified organization"), @@ -864,6 +871,7 @@ func ForkRepository(t translations.TranslationHelperFunc) toolsets.ServerTool { // both of which suit an LLM well. func DeleteFile(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataRepos, mcp.Tool{ Name: "delete_file", Description: t("TOOL_DELETE_FILE_DESCRIPTION", "Delete a file from a GitHub repository"), @@ -1049,6 +1057,7 @@ func DeleteFile(t translations.TranslationHelperFunc) toolsets.ServerTool { // CreateBranch creates a tool to create a new branch. func CreateBranch(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataRepos, mcp.Tool{ Name: "create_branch", Description: t("TOOL_CREATE_BRANCH_DESCRIPTION", "Create a new branch in a GitHub repository"), @@ -1162,6 +1171,7 @@ func CreateBranch(t translations.TranslationHelperFunc) toolsets.ServerTool { // PushFiles creates a tool to push multiple files in a single commit to a GitHub repository. func PushFiles(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataRepos, mcp.Tool{ Name: "push_files", Description: t("TOOL_PUSH_FILES_DESCRIPTION", "Push multiple files to a GitHub repository in a single commit"), @@ -1346,6 +1356,7 @@ func PushFiles(t translations.TranslationHelperFunc) toolsets.ServerTool { // ListTags creates a tool to list tags in a GitHub repository. func ListTags(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataRepos, mcp.Tool{ Name: "list_tags", Description: t("TOOL_LIST_TAGS_DESCRIPTION", "List git tags in a GitHub repository"), @@ -1425,6 +1436,7 @@ func ListTags(t translations.TranslationHelperFunc) toolsets.ServerTool { // GetTag creates a tool to get details about a specific tag in a GitHub repository. func GetTag(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataRepos, mcp.Tool{ Name: "get_tag", Description: t("TOOL_GET_TAG_DESCRIPTION", "Get details about a specific git tag in a GitHub repository"), @@ -1523,6 +1535,7 @@ func GetTag(t translations.TranslationHelperFunc) toolsets.ServerTool { // ListReleases creates a tool to list releases in a GitHub repository. func ListReleases(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataRepos, mcp.Tool{ Name: "list_releases", Description: t("TOOL_LIST_RELEASES_DESCRIPTION", "List releases in a GitHub repository"), @@ -1598,6 +1611,7 @@ func ListReleases(t translations.TranslationHelperFunc) toolsets.ServerTool { // GetLatestRelease creates a tool to get the latest release in a GitHub repository. func GetLatestRelease(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataRepos, mcp.Tool{ Name: "get_latest_release", Description: t("TOOL_GET_LATEST_RELEASE_DESCRIPTION", "Get the latest release in a GitHub repository"), @@ -1663,6 +1677,7 @@ func GetLatestRelease(t translations.TranslationHelperFunc) toolsets.ServerTool func GetReleaseByTag(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataRepos, mcp.Tool{ Name: "get_release_by_tag", Description: t("TOOL_GET_RELEASE_BY_TAG_DESCRIPTION", "Get a specific release by its tag name in a GitHub repository"), @@ -1876,6 +1891,7 @@ func resolveGitReference(ctx context.Context, githubClient *github.Client, owner // ListStarredRepositories creates a tool to list starred repositories for the authenticated user or a specified user. func ListStarredRepositories(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataStargazers, mcp.Tool{ Name: "list_starred_repositories", Description: t("TOOL_LIST_STARRED_REPOSITORIES_DESCRIPTION", "List starred repositories"), @@ -2008,6 +2024,7 @@ func ListStarredRepositories(t translations.TranslationHelperFunc) toolsets.Serv // StarRepository creates a tool to star a repository. func StarRepository(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataStargazers, mcp.Tool{ Name: "star_repository", Description: t("TOOL_STAR_REPOSITORY_DESCRIPTION", "Star a GitHub repository"), @@ -2073,6 +2090,7 @@ func StarRepository(t translations.TranslationHelperFunc) toolsets.ServerTool { // UnstarRepository creates a tool to unstar a repository. func UnstarRepository(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataStargazers, mcp.Tool{ Name: "unstar_repository", Description: t("TOOL_UNSTAR_REPOSITORY_DESCRIPTION", "Unstar a GitHub repository"), diff --git a/pkg/github/repository_resource.go b/pkg/github/repository_resource.go index 5dea9f4e9..d8fd13963 100644 --- a/pkg/github/repository_resource.go +++ b/pkg/github/repository_resource.go @@ -14,6 +14,7 @@ import ( "strings" "github.com/github/github-mcp-server/pkg/raw" + "github.com/github/github-mcp-server/pkg/toolsets" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v79/github" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -29,53 +30,68 @@ var ( ) // GetRepositoryResourceContent defines the resource template and handler for getting repository content. -func GetRepositoryResourceContent(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) (mcp.ResourceTemplate, mcp.ResourceHandler) { - return mcp.ResourceTemplate{ +func GetRepositoryResourceContent(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) toolsets.ServerResourceTemplate { + return toolsets.NewServerResourceTemplate( + ToolsetMetadataRepos, + mcp.ResourceTemplate{ Name: "repository_content", - URITemplate: repositoryResourceContentURITemplate.Raw(), // Resource template + URITemplate: repositoryResourceContentURITemplate.Raw(), Description: t("RESOURCE_REPOSITORY_CONTENT_DESCRIPTION", "Repository Content"), }, - RepositoryResourceContentsHandler(getClient, getRawClient, repositoryResourceContentURITemplate) + RepositoryResourceContentsHandler(getClient, getRawClient, repositoryResourceContentURITemplate), + ) } // GetRepositoryResourceBranchContent defines the resource template and handler for getting repository content for a branch. -func GetRepositoryResourceBranchContent(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) (mcp.ResourceTemplate, mcp.ResourceHandler) { - return mcp.ResourceTemplate{ +func GetRepositoryResourceBranchContent(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) toolsets.ServerResourceTemplate { + return toolsets.NewServerResourceTemplate( + ToolsetMetadataRepos, + mcp.ResourceTemplate{ Name: "repository_content_branch", - URITemplate: repositoryResourceBranchContentURITemplate.Raw(), // Resource template + URITemplate: repositoryResourceBranchContentURITemplate.Raw(), Description: t("RESOURCE_REPOSITORY_CONTENT_BRANCH_DESCRIPTION", "Repository Content for specific branch"), }, - RepositoryResourceContentsHandler(getClient, getRawClient, repositoryResourceBranchContentURITemplate) + RepositoryResourceContentsHandler(getClient, getRawClient, repositoryResourceBranchContentURITemplate), + ) } // GetRepositoryResourceCommitContent defines the resource template and handler for getting repository content for a commit. -func GetRepositoryResourceCommitContent(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) (mcp.ResourceTemplate, mcp.ResourceHandler) { - return mcp.ResourceTemplate{ +func GetRepositoryResourceCommitContent(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) toolsets.ServerResourceTemplate { + return toolsets.NewServerResourceTemplate( + ToolsetMetadataRepos, + mcp.ResourceTemplate{ Name: "repository_content_commit", - URITemplate: repositoryResourceCommitContentURITemplate.Raw(), // Resource template + URITemplate: repositoryResourceCommitContentURITemplate.Raw(), Description: t("RESOURCE_REPOSITORY_CONTENT_COMMIT_DESCRIPTION", "Repository Content for specific commit"), }, - RepositoryResourceContentsHandler(getClient, getRawClient, repositoryResourceCommitContentURITemplate) + RepositoryResourceContentsHandler(getClient, getRawClient, repositoryResourceCommitContentURITemplate), + ) } // GetRepositoryResourceTagContent defines the resource template and handler for getting repository content for a tag. -func GetRepositoryResourceTagContent(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) (mcp.ResourceTemplate, mcp.ResourceHandler) { - return mcp.ResourceTemplate{ +func GetRepositoryResourceTagContent(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) toolsets.ServerResourceTemplate { + return toolsets.NewServerResourceTemplate( + ToolsetMetadataRepos, + mcp.ResourceTemplate{ Name: "repository_content_tag", - URITemplate: repositoryResourceTagContentURITemplate.Raw(), // Resource template + URITemplate: repositoryResourceTagContentURITemplate.Raw(), Description: t("RESOURCE_REPOSITORY_CONTENT_TAG_DESCRIPTION", "Repository Content for specific tag"), }, - RepositoryResourceContentsHandler(getClient, getRawClient, repositoryResourceTagContentURITemplate) + RepositoryResourceContentsHandler(getClient, getRawClient, repositoryResourceTagContentURITemplate), + ) } // GetRepositoryResourcePrContent defines the resource template and handler for getting repository content for a pull request. -func GetRepositoryResourcePrContent(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) (mcp.ResourceTemplate, mcp.ResourceHandler) { - return mcp.ResourceTemplate{ +func GetRepositoryResourcePrContent(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) toolsets.ServerResourceTemplate { + return toolsets.NewServerResourceTemplate( + ToolsetMetadataRepos, + mcp.ResourceTemplate{ Name: "repository_content_pr", - URITemplate: repositoryResourcePrContentURITemplate.Raw(), // Resource template + URITemplate: repositoryResourcePrContentURITemplate.Raw(), Description: t("RESOURCE_REPOSITORY_CONTENT_PR_DESCRIPTION", "Repository Content for specific pull request"), }, - RepositoryResourceContentsHandler(getClient, getRawClient, repositoryResourcePrContentURITemplate) + RepositoryResourceContentsHandler(getClient, getRawClient, repositoryResourcePrContentURITemplate), + ) } // RepositoryResourceContentsHandler returns a handler function for repository content requests. diff --git a/pkg/github/repository_resource_test.go b/pkg/github/repository_resource_test.go index 113f46d89..1b4120ff0 100644 --- a/pkg/github/repository_resource_test.go +++ b/pkg/github/repository_resource_test.go @@ -47,8 +47,7 @@ func Test_repositoryResourceContents(t *testing.T) { ), uri: "repo:///repo/contents/README.md", handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) mcp.ResourceHandler { - _, handler := GetRepositoryResourceContent(getClient, getRawClient, t) - return handler + return GetRepositoryResourceContent(getClient, getRawClient, t).Handler }, expectedResponseType: resourceResponseTypeText, // Ignored as error is expected expectError: "owner is required", @@ -67,8 +66,7 @@ func Test_repositoryResourceContents(t *testing.T) { ), uri: "repo://owner//refs/heads/main/contents/README.md", handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) mcp.ResourceHandler { - _, handler := GetRepositoryResourceBranchContent(getClient, getRawClient, t) - return handler + return GetRepositoryResourceBranchContent(getClient, getRawClient, t).Handler }, expectedResponseType: resourceResponseTypeText, // Ignored as error is expected expectError: "repo is required", @@ -87,8 +85,7 @@ func Test_repositoryResourceContents(t *testing.T) { ), uri: "repo://owner/repo/contents/data.png", handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) mcp.ResourceHandler { - _, handler := GetRepositoryResourceContent(getClient, getRawClient, t) - return handler + return GetRepositoryResourceContent(getClient, getRawClient, t).Handler }, expectedResponseType: resourceResponseTypeBlob, expectedResult: &mcp.ReadResourceResult{ @@ -112,8 +109,7 @@ func Test_repositoryResourceContents(t *testing.T) { ), uri: "repo://owner/repo/contents/README.md", handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) mcp.ResourceHandler { - _, handler := GetRepositoryResourceContent(getClient, getRawClient, t) - return handler + return GetRepositoryResourceContent(getClient, getRawClient, t).Handler }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -139,8 +135,7 @@ func Test_repositoryResourceContents(t *testing.T) { ), uri: "repo://owner/repo/contents/pkg/github/actions.go", handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) mcp.ResourceHandler { - _, handler := GetRepositoryResourceContent(getClient, getRawClient, t) - return handler + return GetRepositoryResourceContent(getClient, getRawClient, t).Handler }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -164,8 +159,7 @@ func Test_repositoryResourceContents(t *testing.T) { ), uri: "repo://owner/repo/refs/heads/main/contents/README.md", handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) mcp.ResourceHandler { - _, handler := GetRepositoryResourceBranchContent(getClient, getRawClient, t) - return handler + return GetRepositoryResourceBranchContent(getClient, getRawClient, t).Handler }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -189,8 +183,7 @@ func Test_repositoryResourceContents(t *testing.T) { ), uri: "repo://owner/repo/refs/tags/v1.0.0/contents/README.md", handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) mcp.ResourceHandler { - _, handler := GetRepositoryResourceTagContent(getClient, getRawClient, t) - return handler + return GetRepositoryResourceTagContent(getClient, getRawClient, t).Handler }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -214,8 +207,7 @@ func Test_repositoryResourceContents(t *testing.T) { ), uri: "repo://owner/repo/sha/abc123/contents/README.md", handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) mcp.ResourceHandler { - _, handler := GetRepositoryResourceCommitContent(getClient, getRawClient, t) - return handler + return GetRepositoryResourceCommitContent(getClient, getRawClient, t).Handler }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -247,8 +239,7 @@ func Test_repositoryResourceContents(t *testing.T) { ), uri: "repo://owner/repo/refs/pull/42/head/contents/README.md", handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) mcp.ResourceHandler { - _, handler := GetRepositoryResourcePrContent(getClient, getRawClient, t) - return handler + return GetRepositoryResourcePrContent(getClient, getRawClient, t).Handler }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -271,8 +262,7 @@ func Test_repositoryResourceContents(t *testing.T) { ), uri: "repo://owner/repo/contents/nonexistent.md", handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) mcp.ResourceHandler { - _, handler := GetRepositoryResourceContent(getClient, getRawClient, t) - return handler + return GetRepositoryResourceContent(getClient, getRawClient, t).Handler }, expectedResponseType: resourceResponseTypeText, // Ignored as error is expected expectError: "404 Not Found", diff --git a/pkg/github/resources.go b/pkg/github/resources.go new file mode 100644 index 000000000..f0b07e831 --- /dev/null +++ b/pkg/github/resources.go @@ -0,0 +1,20 @@ +package github + +import ( + "github.com/github/github-mcp-server/pkg/raw" + "github.com/github/github-mcp-server/pkg/toolsets" + "github.com/github/github-mcp-server/pkg/translations" +) + +// AllResources returns all resource templates with their embedded toolset metadata. +// Resource template functions return ServerResourceTemplate directly with toolset info. +func AllResources(t translations.TranslationHelperFunc, getClient GetClientFn, getRawClient raw.GetRawClientFn) []toolsets.ServerResourceTemplate { + return []toolsets.ServerResourceTemplate{ + // Repository resources + GetRepositoryResourceContent(getClient, getRawClient, t), + GetRepositoryResourceBranchContent(getClient, getRawClient, t), + GetRepositoryResourceCommitContent(getClient, getRawClient, t), + GetRepositoryResourceTagContent(getClient, getRawClient, t), + GetRepositoryResourcePrContent(getClient, getRawClient, t), + } +} diff --git a/pkg/github/search.go b/pkg/github/search.go index eaaf49369..730435eba 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -46,6 +46,7 @@ func SearchRepositories(t translations.TranslationHelperFunc) toolsets.ServerToo WithPagination(schema) return NewTool( + ToolsetMetadataRepos, mcp.Tool{ Name: "search_repositories", Description: t("TOOL_SEARCH_REPOSITORIES_DESCRIPTION", "Find GitHub repositories by name, description, readme, topics, or other metadata. Perfect for discovering projects, finding examples, or locating specific repositories across GitHub."), @@ -189,6 +190,7 @@ func SearchCode(t translations.TranslationHelperFunc) toolsets.ServerTool { WithPagination(schema) return NewTool( + ToolsetMetadataRepos, mcp.Tool{ Name: "search_code", Description: t("TOOL_SEARCH_CODE_DESCRIPTION", "Fast and precise code search across ALL GitHub repositories using GitHub's native search engine. Best for finding exact symbols, functions, classes, or specific code patterns."), @@ -373,6 +375,7 @@ func SearchUsers(t translations.TranslationHelperFunc) toolsets.ServerTool { WithPagination(schema) return NewTool( + ToolsetMetadataUsers, mcp.Tool{ Name: "search_users", Description: t("TOOL_SEARCH_USERS_DESCRIPTION", "Find GitHub users by username, real name, or other profile information. Useful for locating developers, contributors, or team members."), @@ -413,6 +416,7 @@ func SearchOrgs(t translations.TranslationHelperFunc) toolsets.ServerTool { WithPagination(schema) return NewTool( + ToolsetMetadataOrgs, mcp.Tool{ Name: "search_orgs", Description: t("TOOL_SEARCH_ORGS_DESCRIPTION", "Find GitHub organizations by name, location, or other organization metadata. Ideal for discovering companies, open source foundations, or teams."), diff --git a/pkg/github/secret_scanning.go b/pkg/github/secret_scanning.go index fa5618791..7e842ded1 100644 --- a/pkg/github/secret_scanning.go +++ b/pkg/github/secret_scanning.go @@ -18,6 +18,7 @@ import ( func GetSecretScanningAlert(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataSecretProtection, mcp.Tool{ Name: "get_secret_scanning_alert", Description: t("TOOL_GET_SECRET_SCANNING_ALERT_DESCRIPTION", "Get details of a specific secret scanning alert in a GitHub repository."), @@ -95,6 +96,7 @@ func GetSecretScanningAlert(t translations.TranslationHelperFunc) toolsets.Serve func ListSecretScanningAlerts(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataSecretProtection, mcp.Tool{ Name: "list_secret_scanning_alerts", Description: t("TOOL_LIST_SECRET_SCANNING_ALERTS_DESCRIPTION", "List secret scanning alerts in a GitHub repository."), diff --git a/pkg/github/security_advisories.go b/pkg/github/security_advisories.go index 8c7df4265..cf507d17a 100644 --- a/pkg/github/security_advisories.go +++ b/pkg/github/security_advisories.go @@ -17,6 +17,7 @@ import ( func ListGlobalSecurityAdvisories(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataSecurityAdvisories, mcp.Tool{ Name: "list_global_security_advisories", Description: t("TOOL_LIST_GLOBAL_SECURITY_ADVISORIES_DESCRIPTION", "List global security advisories from GitHub."), @@ -208,6 +209,7 @@ func ListGlobalSecurityAdvisories(t translations.TranslationHelperFunc) toolsets func ListRepositorySecurityAdvisories(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataSecurityAdvisories, mcp.Tool{ Name: "list_repository_security_advisories", Description: t("TOOL_LIST_REPOSITORY_SECURITY_ADVISORIES_DESCRIPTION", "List repository security advisories for a GitHub repository."), @@ -312,6 +314,7 @@ func ListRepositorySecurityAdvisories(t translations.TranslationHelperFunc) tool func GetGlobalSecurityAdvisory(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataSecurityAdvisories, mcp.Tool{ Name: "get_global_security_advisory", Description: t("TOOL_GET_GLOBAL_SECURITY_ADVISORY_DESCRIPTION", "Get a global security advisory"), @@ -369,6 +372,7 @@ func GetGlobalSecurityAdvisory(t translations.TranslationHelperFunc) toolsets.Se func ListOrgRepositorySecurityAdvisories(t translations.TranslationHelperFunc) toolsets.ServerTool { return NewTool( + ToolsetMetadataSecurityAdvisories, mcp.Tool{ Name: "list_org_repository_security_advisories", Description: t("TOOL_LIST_ORG_REPOSITORY_SECURITY_ADVISORIES_DESCRIPTION", "List repository security advisories for a GitHub organization."), diff --git a/pkg/github/server.go b/pkg/github/server.go index e74596906..7432466d1 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -18,14 +18,16 @@ import ( func NewServer(version string, opts *mcp.ServerOptions) *mcp.Server { if opts == nil { - // Add default options - opts = &mcp.ServerOptions{ - HasTools: true, - HasResources: true, - HasPrompts: true, - } + opts = &mcp.ServerOptions{} } + // Always advertise capabilities so clients know we support list_changed notifications. + // This is important for dynamic toolsets mode where we start with few tools + // and add more at runtime. + opts.HasTools = true + opts.HasResources = true + opts.HasPrompts = true + // Create a new MCP server s := mcp.NewServer(&mcp.Implementation{ Name: "github-mcp-server", diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 02ec66e8a..1fe23dfd2 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -5,117 +5,110 @@ import ( "fmt" "strings" - "github.com/github/github-mcp-server/pkg/lockdown" - "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/toolsets" "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v79/github" - "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/shurcooL/githubv4" ) type GetClientFn func(context.Context) (*github.Client, error) type GetGQLClientFn func(context.Context) (*githubv4.Client, error) -// ToolsetMetadata holds metadata for a toolset including its ID and description -type ToolsetMetadata struct { - ID string - Description string -} - +// Toolset metadata constants - these define all available toolsets and their descriptions. +// Tools use these constants to declare which toolset they belong to. var ( - ToolsetMetadataAll = ToolsetMetadata{ + ToolsetMetadataAll = toolsets.ToolsetMetadata{ ID: "all", Description: "Special toolset that enables all available toolsets", } - ToolsetMetadataDefault = ToolsetMetadata{ + ToolsetMetadataDefault = toolsets.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{ + ToolsetMetadataContext = toolsets.ToolsetMetadata{ ID: "context", Description: "Tools that provide context about the current user and GitHub context you are operating in", } - ToolsetMetadataRepos = ToolsetMetadata{ + ToolsetMetadataRepos = toolsets.ToolsetMetadata{ ID: "repos", Description: "GitHub Repository related tools", } - ToolsetMetadataGit = ToolsetMetadata{ + ToolsetMetadataGit = toolsets.ToolsetMetadata{ ID: "git", Description: "GitHub Git API related tools for low-level Git operations", } - ToolsetMetadataIssues = ToolsetMetadata{ + ToolsetMetadataIssues = toolsets.ToolsetMetadata{ ID: "issues", Description: "GitHub Issues related tools", } - ToolsetMetadataPullRequests = ToolsetMetadata{ + ToolsetMetadataPullRequests = toolsets.ToolsetMetadata{ ID: "pull_requests", Description: "GitHub Pull Request related tools", } - ToolsetMetadataUsers = ToolsetMetadata{ + ToolsetMetadataUsers = toolsets.ToolsetMetadata{ ID: "users", Description: "GitHub User related tools", } - ToolsetMetadataOrgs = ToolsetMetadata{ + ToolsetMetadataOrgs = toolsets.ToolsetMetadata{ ID: "orgs", Description: "GitHub Organization related tools", } - ToolsetMetadataActions = ToolsetMetadata{ + ToolsetMetadataActions = toolsets.ToolsetMetadata{ ID: "actions", Description: "GitHub Actions workflows and CI/CD operations", } - ToolsetMetadataCodeSecurity = ToolsetMetadata{ + ToolsetMetadataCodeSecurity = toolsets.ToolsetMetadata{ ID: "code_security", Description: "Code security related tools, such as GitHub Code Scanning", } - ToolsetMetadataSecretProtection = ToolsetMetadata{ + ToolsetMetadataSecretProtection = toolsets.ToolsetMetadata{ ID: "secret_protection", Description: "Secret protection related tools, such as GitHub Secret Scanning", } - ToolsetMetadataDependabot = ToolsetMetadata{ + ToolsetMetadataDependabot = toolsets.ToolsetMetadata{ ID: "dependabot", Description: "Dependabot tools", } - ToolsetMetadataNotifications = ToolsetMetadata{ + ToolsetMetadataNotifications = toolsets.ToolsetMetadata{ ID: "notifications", Description: "GitHub Notifications related tools", } - ToolsetMetadataExperiments = ToolsetMetadata{ + ToolsetMetadataExperiments = toolsets.ToolsetMetadata{ ID: "experiments", Description: "Experimental features that are not considered stable yet", } - ToolsetMetadataDiscussions = ToolsetMetadata{ + ToolsetMetadataDiscussions = toolsets.ToolsetMetadata{ ID: "discussions", Description: "GitHub Discussions related tools", } - ToolsetMetadataGists = ToolsetMetadata{ + ToolsetMetadataGists = toolsets.ToolsetMetadata{ ID: "gists", Description: "GitHub Gist related tools", } - ToolsetMetadataSecurityAdvisories = ToolsetMetadata{ + ToolsetMetadataSecurityAdvisories = toolsets.ToolsetMetadata{ ID: "security_advisories", Description: "Security advisories related tools", } - ToolsetMetadataProjects = ToolsetMetadata{ + ToolsetMetadataProjects = toolsets.ToolsetMetadata{ ID: "projects", Description: "GitHub Projects related tools", } - ToolsetMetadataStargazers = ToolsetMetadata{ + ToolsetMetadataStargazers = toolsets.ToolsetMetadata{ ID: "stargazers", Description: "GitHub Stargazers related tools", } - ToolsetMetadataDynamic = ToolsetMetadata{ + ToolsetMetadataDynamic = toolsets.ToolsetMetadata{ ID: "dynamic", Description: "Discover GitHub MCP tools that can help achieve tasks by enabling additional sets of tools, you can control the enablement of any toolset to access its tools when this toolset is enabled.", } - ToolsetLabels = ToolsetMetadata{ + ToolsetLabels = toolsets.ToolsetMetadata{ ID: "labels", Description: "GitHub Labels related tools", } ) -func AvailableTools() []ToolsetMetadata { - return []ToolsetMetadata{ +func AvailableToolsets() []toolsets.ToolsetMetadata { + return []toolsets.ToolsetMetadata{ ToolsetMetadataContext, ToolsetMetadataRepos, ToolsetMetadataIssues, @@ -139,10 +132,10 @@ 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 +func GetValidToolsetIDs() map[toolsets.ToolsetID]bool { + validIDs := make(map[toolsets.ToolsetID]bool) + for _, toolset := range AvailableToolsets() { + validIDs[toolset.ID] = true } // Add special keywords validIDs[ToolsetMetadataAll.ID] = true @@ -150,8 +143,8 @@ func GetValidToolsetIDs() map[string]bool { return validIDs } -func GetDefaultToolsetIDs() []string { - return []string{ +func GetDefaultToolsetIDs() []toolsets.ToolsetID { + return []toolsets.ToolsetID{ ToolsetMetadataContext.ID, ToolsetMetadataRepos.ID, ToolsetMetadataIssues.ID, @@ -160,274 +153,138 @@ func GetDefaultToolsetIDs() []string { } } -func DefaultToolsetGroup(readOnly bool, getClient GetClientFn, getGQLClient GetGQLClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc, contentWindowSize int, flags FeatureFlags, cache *lockdown.RepoAccessCache) *toolsets.ToolsetGroup { - tsg := toolsets.NewToolsetGroup(readOnly) - - // Create the dependencies struct that will be passed to all tool handlers - deps := ToolDependencies{ - GetClient: getClient, - GetGQLClient: getGQLClient, - GetRawClient: getRawClient, - RepoAccessCache: cache, - T: t, - Flags: flags, - ContentWindowSize: contentWindowSize, - } - - // Define all available features with their default state (disabled) - // Create toolsets - repos := toolsets.NewToolset(ToolsetMetadataRepos.ID, ToolsetMetadataRepos.Description). - SetDependencies(deps). - AddReadTools( - SearchRepositories(t), - GetFileContents(t), - ListCommits(t), - SearchCode(t), - GetCommit(t), - ListBranches(t), - ListTags(t), - GetTag(t), - ListReleases(t), - GetLatestRelease(t), - GetReleaseByTag(t), - ). - AddWriteTools( - CreateOrUpdateFile(t), - CreateRepository(t), - ForkRepository(t), - CreateBranch(t), - PushFiles(t), - DeleteFile(t), - ). - AddResourceTemplates( - toolsets.NewServerResourceTemplate(GetRepositoryResourceContent(getClient, getRawClient, t)), - toolsets.NewServerResourceTemplate(GetRepositoryResourceBranchContent(getClient, getRawClient, t)), - toolsets.NewServerResourceTemplate(GetRepositoryResourceCommitContent(getClient, getRawClient, t)), - toolsets.NewServerResourceTemplate(GetRepositoryResourceTagContent(getClient, getRawClient, t)), - toolsets.NewServerResourceTemplate(GetRepositoryResourcePrContent(getClient, getRawClient, t)), - ) - git := toolsets.NewToolset(ToolsetMetadataGit.ID, ToolsetMetadataGit.Description). - SetDependencies(deps). - AddReadTools( - GetRepositoryTree(t), - ) - issues := toolsets.NewToolset(ToolsetMetadataIssues.ID, ToolsetMetadataIssues.Description). - SetDependencies(deps). - AddReadTools( - IssueRead(t), - SearchIssues(t), - ListIssues(t), - ListIssueTypes(t), - GetLabel(t), - ). - AddWriteTools( - IssueWrite(t), - AddIssueComment(t), - AssignCopilotToIssue(t), - SubIssueWrite(t), - ).AddPrompts( - toolsets.NewServerPrompt(AssignCodingAgentPrompt(t)), - toolsets.NewServerPrompt(IssueToFixWorkflowPrompt(t)), - ) - users := toolsets.NewToolset(ToolsetMetadataUsers.ID, ToolsetMetadataUsers.Description). - SetDependencies(deps). - AddReadTools( - SearchUsers(t), - ) - orgs := toolsets.NewToolset(ToolsetMetadataOrgs.ID, ToolsetMetadataOrgs.Description). - SetDependencies(deps). - AddReadTools( - SearchOrgs(t), - ) - pullRequests := toolsets.NewToolset(ToolsetMetadataPullRequests.ID, ToolsetMetadataPullRequests.Description). - SetDependencies(deps). - AddReadTools( - PullRequestRead(t), - ListPullRequests(t), - SearchPullRequests(t), - ). - AddWriteTools( - MergePullRequest(t), - UpdatePullRequestBranch(t), - CreatePullRequest(t), - UpdatePullRequest(t), - RequestCopilotReview(t), - // Reviews - PullRequestReviewWrite(t), - AddCommentToPendingReview(t), - ) - codeSecurity := toolsets.NewToolset(ToolsetMetadataCodeSecurity.ID, ToolsetMetadataCodeSecurity.Description). - SetDependencies(deps). - AddReadTools( - GetCodeScanningAlert(t), - ListCodeScanningAlerts(t), - ) - secretProtection := toolsets.NewToolset(ToolsetMetadataSecretProtection.ID, ToolsetMetadataSecretProtection.Description). - SetDependencies(deps). - AddReadTools( - GetSecretScanningAlert(t), - ListSecretScanningAlerts(t), - ) - dependabot := toolsets.NewToolset(ToolsetMetadataDependabot.ID, ToolsetMetadataDependabot.Description). - SetDependencies(deps). - AddReadTools( - GetDependabotAlert(t), - ListDependabotAlerts(t), - ) - - notifications := toolsets.NewToolset(ToolsetMetadataNotifications.ID, ToolsetMetadataNotifications.Description). - SetDependencies(deps). - AddReadTools( - ListNotifications(t), - GetNotificationDetails(t), - ). - AddWriteTools( - DismissNotification(t), - MarkAllNotificationsRead(t), - ManageNotificationSubscription(t), - ManageRepositoryNotificationSubscription(t), - ) - - discussions := toolsets.NewToolset(ToolsetMetadataDiscussions.ID, ToolsetMetadataDiscussions.Description). - SetDependencies(deps). - AddReadTools( - ListDiscussions(t), - GetDiscussion(t), - GetDiscussionComments(t), - ListDiscussionCategories(t), - ) - - actions := toolsets.NewToolset(ToolsetMetadataActions.ID, ToolsetMetadataActions.Description). - SetDependencies(deps). - AddReadTools( - ListWorkflows(t), - ListWorkflowRuns(t), - GetWorkflowRun(t), - GetWorkflowRunLogs(t), - ListWorkflowJobs(t), - GetJobLogs(t), - ListWorkflowRunArtifacts(t), - DownloadWorkflowRunArtifact(t), - GetWorkflowRunUsage(t), - ). - AddWriteTools( - RunWorkflow(t), - RerunWorkflowRun(t), - RerunFailedJobs(t), - CancelWorkflowRun(t), - DeleteWorkflowRunLogs(t), - ) - - securityAdvisories := toolsets.NewToolset(ToolsetMetadataSecurityAdvisories.ID, ToolsetMetadataSecurityAdvisories.Description). - SetDependencies(deps). - AddReadTools( - ListGlobalSecurityAdvisories(t), - GetGlobalSecurityAdvisory(t), - ListRepositorySecurityAdvisories(t), - ListOrgRepositorySecurityAdvisories(t), - ) - - // // Keep experiments alive so the system doesn't error out when it's always enabled - experiments := toolsets.NewToolset(ToolsetMetadataExperiments.ID, ToolsetMetadataExperiments.Description). - SetDependencies(deps) - - contextTools := toolsets.NewToolset(ToolsetMetadataContext.ID, ToolsetMetadataContext.Description). - SetDependencies(deps). - AddReadTools( - GetMe(t), - GetTeams(t), - GetTeamMembers(t), - ) - - gists := toolsets.NewToolset(ToolsetMetadataGists.ID, ToolsetMetadataGists.Description). - SetDependencies(deps). - AddReadTools( - ListGists(t), - GetGist(t), - ). - AddWriteTools( - CreateGist(t), - UpdateGist(t), - ) - - projects := toolsets.NewToolset(ToolsetMetadataProjects.ID, ToolsetMetadataProjects.Description). - SetDependencies(deps). - AddReadTools( - ListProjects(t), - GetProject(t), - ListProjectFields(t), - GetProjectField(t), - ListProjectItems(t), - GetProjectItem(t), - ). - AddWriteTools( - AddProjectItem(t), - DeleteProjectItem(t), - UpdateProjectItem(t), - ) - stargazers := toolsets.NewToolset(ToolsetMetadataStargazers.ID, ToolsetMetadataStargazers.Description). - SetDependencies(deps). - AddReadTools( - ListStarredRepositories(t), - ). - AddWriteTools( - StarRepository(t), - UnstarRepository(t), - ) - labels := toolsets.NewToolset(ToolsetLabels.ID, ToolsetLabels.Description). - SetDependencies(deps). - AddReadTools( - // get - GetLabel(t), - // list labels on repo or issue - ListLabels(t), - ). - AddWriteTools( - // create or update - LabelWrite(t), - ) - - // Add toolsets to the group - tsg.AddToolset(contextTools) - tsg.AddToolset(repos) - tsg.AddToolset(git) - tsg.AddToolset(issues) - tsg.AddToolset(orgs) - tsg.AddToolset(users) - tsg.AddToolset(pullRequests) - tsg.AddToolset(actions) - tsg.AddToolset(codeSecurity) - tsg.AddToolset(dependabot) - tsg.AddToolset(secretProtection) - tsg.AddToolset(notifications) - tsg.AddToolset(experiments) - tsg.AddToolset(discussions) - tsg.AddToolset(gists) - tsg.AddToolset(securityAdvisories) - tsg.AddToolset(projects) - tsg.AddToolset(stargazers) - tsg.AddToolset(labels) - - tsg.AddDeprecatedToolAliases(DeprecatedToolAliases) - - return tsg -} - -// InitDynamicToolset creates a dynamic toolset that can be used to enable other toolsets, and so requires the server and toolset group as arguments -// -//nolint:unused -func InitDynamicToolset(s *mcp.Server, tsg *toolsets.ToolsetGroup, t translations.TranslationHelperFunc) *toolsets.Toolset { - // Create a new dynamic toolset - // Need to add the dynamic toolset last so it can be used to enable other toolsets - dynamicToolSelection := toolsets.NewToolset(ToolsetMetadataDynamic.ID, ToolsetMetadataDynamic.Description). - AddReadTools( - ListAvailableToolsets(tsg, t), - GetToolsetsTools(tsg, t), - EnableToolset(s, tsg, t), - ) - - dynamicToolSelection.Enabled = true - return dynamicToolSelection +// AllTools returns all tools with their embedded toolset metadata. +// Tool functions return ServerTool directly with toolset info. +func AllTools(t translations.TranslationHelperFunc) []toolsets.ServerTool { + return []toolsets.ServerTool{ + // Context tools + GetMe(t), + GetTeams(t), + GetTeamMembers(t), + + // Repository tools + SearchRepositories(t), + GetFileContents(t), + ListCommits(t), + SearchCode(t), + GetCommit(t), + ListBranches(t), + ListTags(t), + GetTag(t), + ListReleases(t), + GetLatestRelease(t), + GetReleaseByTag(t), + CreateOrUpdateFile(t), + CreateRepository(t), + ForkRepository(t), + CreateBranch(t), + PushFiles(t), + DeleteFile(t), + ListStarredRepositories(t), + StarRepository(t), + UnstarRepository(t), + + // Git tools + GetRepositoryTree(t), + + // Issue tools + IssueRead(t), + SearchIssues(t), + ListIssues(t), + ListIssueTypes(t), + IssueWrite(t), + AddIssueComment(t), + AssignCopilotToIssue(t), + SubIssueWrite(t), + + // User tools + SearchUsers(t), + + // Organization tools + SearchOrgs(t), + + // Pull request tools + PullRequestRead(t), + ListPullRequests(t), + SearchPullRequests(t), + MergePullRequest(t), + UpdatePullRequestBranch(t), + CreatePullRequest(t), + UpdatePullRequest(t), + RequestCopilotReview(t), + PullRequestReviewWrite(t), + AddCommentToPendingReview(t), + + // Code security tools + GetCodeScanningAlert(t), + ListCodeScanningAlerts(t), + + // Secret protection tools + GetSecretScanningAlert(t), + ListSecretScanningAlerts(t), + + // Dependabot tools + GetDependabotAlert(t), + ListDependabotAlerts(t), + + // Notification tools + ListNotifications(t), + GetNotificationDetails(t), + DismissNotification(t), + MarkAllNotificationsRead(t), + ManageNotificationSubscription(t), + ManageRepositoryNotificationSubscription(t), + + // Discussion tools + ListDiscussions(t), + GetDiscussion(t), + GetDiscussionComments(t), + ListDiscussionCategories(t), + + // Actions tools + ListWorkflows(t), + ListWorkflowRuns(t), + GetWorkflowRun(t), + GetWorkflowRunLogs(t), + ListWorkflowJobs(t), + GetJobLogs(t), + ListWorkflowRunArtifacts(t), + DownloadWorkflowRunArtifact(t), + GetWorkflowRunUsage(t), + RunWorkflow(t), + RerunWorkflowRun(t), + RerunFailedJobs(t), + CancelWorkflowRun(t), + DeleteWorkflowRunLogs(t), + + // Security advisories tools + ListGlobalSecurityAdvisories(t), + GetGlobalSecurityAdvisory(t), + ListRepositorySecurityAdvisories(t), + ListOrgRepositorySecurityAdvisories(t), + + // Gist tools + ListGists(t), + GetGist(t), + CreateGist(t), + UpdateGist(t), + + // Project tools + ListProjects(t), + GetProject(t), + ListProjectFields(t), + GetProjectField(t), + ListProjectItems(t), + GetProjectItem(t), + AddProjectItem(t), + DeleteProjectItem(t), + UpdateProjectItem(t), + + // Label tools + GetLabel(t), + ListLabels(t), + LabelWrite(t), + } } // ToBoolPtr converts a bool to a *bool pointer. @@ -447,23 +304,29 @@ func ToStringPtr(s string) *string { // GenerateToolsetsHelp generates the help text for the toolsets flag func GenerateToolsetsHelp() string { // Format default tools - defaultTools := strings.Join(GetDefaultToolsetIDs(), ", ") + defaultIDs := GetDefaultToolsetIDs() + defaultStrings := make([]string, len(defaultIDs)) + for i, id := range defaultIDs { + defaultStrings[i] = string(id) + } + defaultTools := strings.Join(defaultStrings, ", ") // Format available tools with line breaks for better readability - allTools := AvailableTools() + allToolsets := AvailableToolsets() var availableToolsLines []string const maxLineLength = 70 currentLine := "" - for i, tool := range allTools { + for i, toolset := range allToolsets { + id := string(toolset.ID) switch { case i == 0: - currentLine = tool.ID - case len(currentLine)+len(tool.ID)+2 <= maxLineLength: - currentLine += ", " + tool.ID + currentLine = id + case len(currentLine)+len(id)+2 <= maxLineLength: + currentLine += ", " + id default: availableToolsLines = append(availableToolsLines, currentLine) - currentLine = tool.ID + currentLine = id } } if currentLine != "" { @@ -491,7 +354,7 @@ func AddDefaultToolset(result []string) []string { seen := make(map[string]bool) for _, toolset := range result { seen[toolset] = true - if toolset == ToolsetMetadataDefault.ID { + if toolset == string(ToolsetMetadataDefault.ID) { hasDefault = true } } @@ -501,11 +364,11 @@ func AddDefaultToolset(result []string) []string { return result } - result = RemoveToolset(result, ToolsetMetadataDefault.ID) + result = RemoveToolset(result, string(ToolsetMetadataDefault.ID)) for _, defaultToolset := range GetDefaultToolsetIDs() { - if !seen[defaultToolset] { - result = append(result, defaultToolset) + if !seen[string(defaultToolset)] { + result = append(result, string(defaultToolset)) } } return result @@ -531,7 +394,7 @@ func CleanToolsets(enabledToolsets []string) ([]string, []string) { if !seen[trimmed] { seen[trimmed] = true result = append(result, trimmed) - if !validIDs[trimmed] { + if !validIDs[toolsets.ToolsetID(trimmed)] { invalid = append(invalid, trimmed) } } diff --git a/pkg/github/toolset_group.go b/pkg/github/toolset_group.go new file mode 100644 index 000000000..bca1f7ca4 --- /dev/null +++ b/pkg/github/toolset_group.go @@ -0,0 +1,20 @@ +package github + +import ( + "github.com/github/github-mcp-server/pkg/raw" + "github.com/github/github-mcp-server/pkg/toolsets" + "github.com/github/github-mcp-server/pkg/translations" +) + +// NewToolsetGroup creates a ToolsetGroup with all available tools, resources, and prompts. +// Tools are self-describing with their toolset metadata embedded. +// The "default" keyword in WithToolsets will expand to GetDefaultToolsetIDs(). +func NewToolsetGroup(t translations.TranslationHelperFunc, getClient GetClientFn, getRawClient raw.GetRawClientFn) *toolsets.ToolsetGroup { + tsg := toolsets.NewToolsetGroup( + AllTools(t), + AllResources(t, getClient, getRawClient), + AllPrompts(t), + ) + tsg.SetDefaultToolsetIDs(GetDefaultToolsetIDs()) + return tsg +} diff --git a/pkg/github/workflow_prompts.go b/pkg/github/workflow_prompts.go index bc7c7581f..cf972020d 100644 --- a/pkg/github/workflow_prompts.go +++ b/pkg/github/workflow_prompts.go @@ -4,13 +4,16 @@ import ( "context" "fmt" + "github.com/github/github-mcp-server/pkg/toolsets" "github.com/github/github-mcp-server/pkg/translations" "github.com/modelcontextprotocol/go-sdk/mcp" ) // IssueToFixWorkflowPrompt provides a guided workflow for creating an issue and then generating a PR to fix it -func IssueToFixWorkflowPrompt(t translations.TranslationHelperFunc) (tool mcp.Prompt, handler mcp.PromptHandler) { - return mcp.Prompt{ +func IssueToFixWorkflowPrompt(t translations.TranslationHelperFunc) toolsets.ServerPrompt { + return toolsets.NewServerPrompt( + ToolsetMetadataIssues, + mcp.Prompt{ Name: "issue_to_fix_workflow", Description: t("PROMPT_ISSUE_TO_FIX_WORKFLOW_DESCRIPTION", "Create an issue for a problem and then generate a pull request to fix it"), Arguments: []*mcp.PromptArgument{ @@ -102,5 +105,6 @@ func IssueToFixWorkflowPrompt(t translations.TranslationHelperFunc) (tool mcp.Pr return &mcp.GetPromptResult{ Messages: messages, }, nil - } + }, + ) } diff --git a/pkg/toolsets/server_tool.go b/pkg/toolsets/server_tool.go index 3e3e5d9f8..334492c74 100644 --- a/pkg/toolsets/server_tool.go +++ b/pkg/toolsets/server_tool.go @@ -14,17 +14,47 @@ import ( // should define their own typed dependencies struct and type-assert as needed. type HandlerFunc func(deps any) mcp.ToolHandler -// ServerTool represents an MCP tool with a handler generator function. +// ToolsetID is a unique identifier for a toolset. +// Using a distinct type provides compile-time type safety. +type ToolsetID string + +// ToolsetMetadata contains metadata about the toolset a tool belongs to. +type ToolsetMetadata struct { + // ID is the unique identifier for the toolset (e.g., "repos", "issues") + ID ToolsetID + // Description provides a human-readable description of the toolset + Description string +} + +// ServerTool represents an MCP tool with metadata and a handler generator function. // The tool definition is static, while the handler is generated on-demand // when the tool is registered with a server. +// Tools are now self-describing with their toolset membership and read-only status +// derived from the Tool.Annotations.ReadOnlyHint field. type ServerTool struct { // Tool is the MCP tool definition containing name, description, schema, etc. Tool mcp.Tool + // Toolset contains metadata about which toolset this tool belongs to. + Toolset ToolsetMetadata + // HandlerFunc generates the handler when given dependencies. // This allows tools to be passed around without handlers being set up, // and handlers are only created when needed. HandlerFunc HandlerFunc + + // FeatureFlagEnable specifies a feature flag that must be enabled for this tool + // to be available. If set and the flag is not enabled, the tool is omitted. + FeatureFlagEnable string + + // FeatureFlagDisable specifies a feature flag that, when enabled, causes this tool + // to be omitted. Used to disable tools when a feature flag is on. + FeatureFlagDisable string +} + +// IsReadOnly returns true if this tool is marked as read-only via annotations. +func (st *ServerTool) IsReadOnly() bool { + return st.Tool.Annotations != nil && st.Tool.Annotations.ReadOnlyHint } // Handler returns a tool handler by calling HandlerFunc with the given dependencies. @@ -41,12 +71,13 @@ func (st *ServerTool) RegisterFunc(s *mcp.Server, deps any) { s.AddTool(&st.Tool, handler) } -// NewServerTool creates a ServerTool from a tool definition and a typed handler function. +// NewServerTool creates a ServerTool from a tool definition, toolset metadata, and a typed handler function. // The handler function takes dependencies (as any) and returns a typed handler. // Callers should type-assert deps to their typed dependencies struct. -func NewServerTool[In any, Out any](tool mcp.Tool, handlerFn func(deps any) mcp.ToolHandlerFor[In, Out]) ServerTool { +func NewServerTool[In any, Out any](tool mcp.Tool, toolset ToolsetMetadata, handlerFn func(deps any) mcp.ToolHandlerFor[In, Out]) ServerTool { return ServerTool{ - Tool: tool, + Tool: tool, + Toolset: toolset, HandlerFunc: func(deps any) mcp.ToolHandler { typedHandler := handlerFn(deps) return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { @@ -61,18 +92,19 @@ func NewServerTool[In any, Out any](tool mcp.Tool, handlerFn func(deps any) mcp. } } -// NewServerToolFromHandler creates a ServerTool from a tool definition and a raw handler function. +// NewServerToolFromHandler creates a ServerTool from a tool definition, toolset metadata, and a raw handler function. // Use this when you have a handler that already conforms to mcp.ToolHandler. -func NewServerToolFromHandler(tool mcp.Tool, handlerFn func(deps any) mcp.ToolHandler) ServerTool { - return ServerTool{Tool: tool, HandlerFunc: handlerFn} +func NewServerToolFromHandler(tool mcp.Tool, toolset ToolsetMetadata, handlerFn func(deps any) mcp.ToolHandler) ServerTool { + return ServerTool{Tool: tool, Toolset: toolset, HandlerFunc: handlerFn} } -// NewServerToolLegacy creates a ServerTool from a tool definition and an already-bound typed handler. +// NewServerToolLegacy creates a ServerTool from a tool definition, toolset metadata, and an already-bound typed handler. // This is for backward compatibility during the refactor - the handler doesn't use dependencies. // Deprecated: Use NewServerTool instead for new code. -func NewServerToolLegacy[In any, Out any](tool mcp.Tool, handler mcp.ToolHandlerFor[In, Out]) ServerTool { +func NewServerToolLegacy[In any, Out any](tool mcp.Tool, toolset ToolsetMetadata, handler mcp.ToolHandlerFor[In, Out]) ServerTool { return ServerTool{ - Tool: tool, + Tool: tool, + Toolset: toolset, HandlerFunc: func(_ any) mcp.ToolHandler { return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { var arguments In @@ -86,12 +118,13 @@ func NewServerToolLegacy[In any, Out any](tool mcp.Tool, handler mcp.ToolHandler } } -// NewServerToolFromHandlerLegacy creates a ServerTool from a tool definition and an already-bound raw handler. +// NewServerToolFromHandlerLegacy creates a ServerTool from a tool definition, toolset metadata, and an already-bound raw handler. // This is for backward compatibility during the refactor - the handler doesn't use dependencies. // Deprecated: Use NewServerToolFromHandler instead for new code. -func NewServerToolFromHandlerLegacy(tool mcp.Tool, handler mcp.ToolHandler) ServerTool { +func NewServerToolFromHandlerLegacy(tool mcp.Tool, toolset ToolsetMetadata, handler mcp.ToolHandler) ServerTool { return ServerTool{ - Tool: tool, + Tool: tool, + Toolset: toolset, HandlerFunc: func(_ any) mcp.ToolHandler { return handler }, diff --git a/pkg/toolsets/toolsets.go b/pkg/toolsets/toolsets.go index 8502328d5..e42a9bcae 100644 --- a/pkg/toolsets/toolsets.go +++ b/pkg/toolsets/toolsets.go @@ -1,9 +1,11 @@ package toolsets import ( + "context" "fmt" "os" - "strings" + "slices" + "sort" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -30,276 +32,630 @@ func NewToolsetDoesNotExistError(name string) *ToolsetDoesNotExistError { return &ToolsetDoesNotExistError{Name: name} } +// ToolDoesNotExistError is returned when a tool is not found. +type ToolDoesNotExistError struct { + Name string +} + +func (e *ToolDoesNotExistError) Error() string { + return fmt.Sprintf("tool %s does not exist", e.Name) +} + +// NewToolDoesNotExistError creates a new ToolDoesNotExistError. +func NewToolDoesNotExistError(name string) *ToolDoesNotExistError { + return &ToolDoesNotExistError{Name: name} +} + // ServerTool is defined in server_tool.go +// ServerResourceTemplate pairs a resource template with its toolset metadata. type ServerResourceTemplate struct { Template mcp.ResourceTemplate Handler mcp.ResourceHandler -} - -func NewServerResourceTemplate(resourceTemplate mcp.ResourceTemplate, handler mcp.ResourceHandler) ServerResourceTemplate { + // Toolset identifies which toolset this resource belongs to + Toolset ToolsetMetadata + // FeatureFlagEnable specifies a feature flag that must be enabled for this resource + // to be available. If set and the flag is not enabled, the resource is omitted. + FeatureFlagEnable string + // FeatureFlagDisable specifies a feature flag that, when enabled, causes this resource + // to be omitted. Used to disable resources when a feature flag is on. + FeatureFlagDisable string +} + +// NewServerResourceTemplate creates a new ServerResourceTemplate with toolset metadata. +func NewServerResourceTemplate(toolset ToolsetMetadata, resourceTemplate mcp.ResourceTemplate, handler mcp.ResourceHandler) ServerResourceTemplate { return ServerResourceTemplate{ Template: resourceTemplate, Handler: handler, + Toolset: toolset, } } +// ServerPrompt pairs a prompt with its toolset metadata. type ServerPrompt struct { Prompt mcp.Prompt Handler mcp.PromptHandler -} - -func NewServerPrompt(prompt mcp.Prompt, handler mcp.PromptHandler) ServerPrompt { + // Toolset identifies which toolset this prompt belongs to + Toolset ToolsetMetadata + // FeatureFlagEnable specifies a feature flag that must be enabled for this prompt + // to be available. If set and the flag is not enabled, the prompt is omitted. + FeatureFlagEnable string + // FeatureFlagDisable specifies a feature flag that, when enabled, causes this prompt + // to be omitted. Used to disable prompts when a feature flag is on. + FeatureFlagDisable string +} + +// NewServerPrompt creates a new ServerPrompt with toolset metadata. +func NewServerPrompt(toolset ToolsetMetadata, prompt mcp.Prompt, handler mcp.PromptHandler) ServerPrompt { return ServerPrompt{ Prompt: prompt, Handler: handler, + Toolset: toolset, } } -// Toolset represents a collection of MCP functionality that can be enabled or disabled as a group. -type Toolset struct { - Name string - Description string - Enabled bool - readOnly bool - writeTools []ServerTool - readTools []ServerTool - // deps holds the dependencies for tool handlers (typed as any to avoid circular deps) - deps any - // resources are not tools, but the community seems to be moving towards namespaces as a broader concept - // and in order to have multiple servers running concurrently, we want to avoid overlapping resources too. +// ToolsetGroup holds a collection of tools, resources, and prompts. +// It supports immutable filtering operations that return new ToolsetGroups +// without modifying the original. This design allows for: +// - Building a full set of tools/resources/prompts once +// - Applying filters (read-only, feature flags, enabled toolsets) without mutation +// - Deterministic ordering for documentation generation +// - Lazy dependency injection only when registering with a server +type ToolsetGroup struct { + // tools holds all tools in this group + tools []ServerTool + // resourceTemplates holds all resource templates in this group resourceTemplates []ServerResourceTemplate - // prompts are also not tools but are namespaced similarly + // prompts holds all prompts in this group prompts []ServerPrompt -} - -func (t *Toolset) GetActiveTools() []ServerTool { - if t.Enabled { - if t.readOnly { - return t.readTools + // deprecatedAliases maps old tool names to new canonical names + deprecatedAliases map[string]string + // defaultToolsetIDs are the toolset IDs that "default" expands to + defaultToolsetIDs []ToolsetID + + // Filters - these control what's returned by Available* methods + // readOnly when true filters out write tools + readOnly bool + // enabledToolsets when non-nil, only include tools/resources/prompts from these toolsets + // when nil, all toolsets are enabled + enabledToolsets map[ToolsetID]bool + // additionalTools are specific tools that bypass toolset filtering (but still respect read-only) + // These are additive - a tool is included if it matches toolset filters OR is in this set + additionalTools map[string]bool + // featureChecker when non-nil, checks if a feature flag is enabled. + // Takes context and flag name, returns (enabled, error). If error, log and treat as false. + // If checker is nil, all flag checks return false. + featureChecker FeatureFlagChecker +} + +// FeatureFlagChecker is a function that checks if a feature flag is enabled. +// The context can be used to extract actor/user information for flag evaluation. +// Returns (enabled, error). If error occurs, the caller should log and treat as false. +type FeatureFlagChecker func(ctx context.Context, flagName string) (bool, error) + +// NewToolsetGroup creates a new ToolsetGroup from the provided tools, resources, and prompts. +// The group is created with no filters applied. +func NewToolsetGroup(tools []ServerTool, resources []ServerResourceTemplate, prompts []ServerPrompt) *ToolsetGroup { + return &ToolsetGroup{ + tools: tools, + resourceTemplates: resources, + prompts: prompts, + deprecatedAliases: make(map[string]string), + readOnly: false, + enabledToolsets: nil, + additionalTools: nil, + featureChecker: nil, + } +} + +// copy creates a shallow copy of the ToolsetGroup for immutable operations. +func (tg *ToolsetGroup) copy() *ToolsetGroup { + newTG := &ToolsetGroup{ + tools: tg.tools, // slices are shared (immutable) + resourceTemplates: tg.resourceTemplates, + prompts: tg.prompts, + deprecatedAliases: tg.deprecatedAliases, + defaultToolsetIDs: tg.defaultToolsetIDs, + readOnly: tg.readOnly, + featureChecker: tg.featureChecker, + } + + // Copy maps if they exist + if tg.enabledToolsets != nil { + newTG.enabledToolsets = make(map[ToolsetID]bool, len(tg.enabledToolsets)) + for k, v := range tg.enabledToolsets { + newTG.enabledToolsets[k] = v } - return append(t.readTools, t.writeTools...) - } - return nil -} - -func (t *Toolset) GetAvailableTools() []ServerTool { - if t.readOnly { - return t.readTools - } - return append(t.readTools, t.writeTools...) -} - -func (t *Toolset) RegisterTools(s *mcp.Server) { - if !t.Enabled { - return - } - for i := range t.readTools { - t.readTools[i].RegisterFunc(s, t.deps) } - if !t.readOnly { - for i := range t.writeTools { - t.writeTools[i].RegisterFunc(s, t.deps) + if tg.additionalTools != nil { + newTG.additionalTools = make(map[string]bool, len(tg.additionalTools)) + for k, v := range tg.additionalTools { + newTG.additionalTools[k] = v } } -} -// SetDependencies sets the dependencies for this toolset's tool handlers. -// The deps parameter is typed as `any` to avoid circular dependencies between packages. -func (t *Toolset) SetDependencies(deps any) *Toolset { - t.deps = deps - return t + return newTG } -func (t *Toolset) AddResourceTemplates(templates ...ServerResourceTemplate) *Toolset { - t.resourceTemplates = append(t.resourceTemplates, templates...) - return t +// WithReadOnly returns a new ToolsetGroup with read-only mode set. +// When true, write tools are filtered out from Available* methods. +func (tg *ToolsetGroup) WithReadOnly(readOnly bool) *ToolsetGroup { + newTG := tg.copy() + newTG.readOnly = readOnly + return newTG } -func (t *Toolset) AddPrompts(prompts ...ServerPrompt) *Toolset { - t.prompts = append(t.prompts, prompts...) - return t +// SetDefaultToolsetIDs configures which toolset IDs the "default" keyword expands to. +// This should be called before WithToolsets if you want "default" to be recognized. +func (tg *ToolsetGroup) SetDefaultToolsetIDs(ids []ToolsetID) *ToolsetGroup { + tg.defaultToolsetIDs = ids + return tg } -func (t *Toolset) GetActiveResourceTemplates() []ServerResourceTemplate { - if !t.Enabled { - return nil +// WithToolsets returns a new ToolsetGroup that only includes items from the specified toolsets. +// Special keywords: +// - "all": enables all toolsets +// - "default": expands to the default toolset IDs (set via SetDefaultToolsetIDs) +// +// Pass nil to use default toolsets. Pass an empty slice to disable all toolsets +// (useful for dynamic toolsets mode where tools are enabled on demand). +func (tg *ToolsetGroup) WithToolsets(toolsetIDs []string) *ToolsetGroup { + newTG := tg.copy() + + // Check for "all" keyword - enables all toolsets + for _, id := range toolsetIDs { + if id == "all" { + newTG.enabledToolsets = nil + return newTG + } } - return t.resourceTemplates -} -func (t *Toolset) GetAvailableResourceTemplates() []ServerResourceTemplate { - return t.resourceTemplates -} + // nil means use defaults, empty slice means no toolsets + if toolsetIDs == nil { + toolsetIDs = []string{"default"} + } -func (t *Toolset) RegisterResourcesTemplates(s *mcp.Server) { - if !t.Enabled { - return + // Expand "default" keyword and collect other IDs + seen := make(map[ToolsetID]bool) + expanded := make([]ToolsetID, 0, len(toolsetIDs)) + for _, id := range toolsetIDs { + if id == "default" { + for _, defaultID := range tg.defaultToolsetIDs { + if !seen[defaultID] { + seen[defaultID] = true + expanded = append(expanded, defaultID) + } + } + } else { + tsID := ToolsetID(id) + if !seen[tsID] { + seen[tsID] = true + expanded = append(expanded, tsID) + } + } } - for _, resource := range t.resourceTemplates { - s.AddResourceTemplate(&resource.Template, resource.Handler) + + if len(expanded) == 0 { + newTG.enabledToolsets = make(map[ToolsetID]bool) + return newTG } + + newTG.enabledToolsets = make(map[ToolsetID]bool, len(expanded)) + for _, id := range expanded { + newTG.enabledToolsets[id] = true + } + return newTG } -func (t *Toolset) RegisterPrompts(s *mcp.Server) { - if !t.Enabled { - return +// WithTools returns a new ToolsetGroup with 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. +// Deprecated tool aliases are automatically resolved to their canonical names. +// Pass nil or empty slice to clear additional tools. +func (tg *ToolsetGroup) WithTools(toolNames []string) *ToolsetGroup { + newTG := tg.copy() + if len(toolNames) == 0 { + newTG.additionalTools = nil + return newTG } - for _, prompt := range t.prompts { - s.AddPrompt(&prompt.Prompt, prompt.Handler) + newTG.additionalTools = make(map[string]bool, len(toolNames)) + for _, name := range toolNames { + // Resolve deprecated aliases to canonical names + if canonical, isAlias := tg.deprecatedAliases[name]; isAlias { + newTG.additionalTools[canonical] = true + } else { + newTG.additionalTools[name] = true + } } -} + return newTG +} + +// WithFeatureChecker returns a new ToolsetGroup with a feature checker function. +// The checker receives a context (for actor extraction) and feature flag name, returns (enabled, error). +// If error occurs, it will be logged and treated as false. +// If checker is nil, all feature flag checks return false (items with FeatureFlagEnable are excluded, +// items with FeatureFlagDisable are included). +func (tg *ToolsetGroup) WithFeatureChecker(checker FeatureFlagChecker) *ToolsetGroup { + newTG := tg.copy() + newTG.featureChecker = checker + return newTG +} + +// MCP method constants for use with ForMCPRequest. +const ( + MCPMethodInitialize = "initialize" + MCPMethodToolsList = "tools/list" + MCPMethodToolsCall = "tools/call" + MCPMethodResourcesList = "resources/list" + MCPMethodResourcesRead = "resources/read" + MCPMethodResourcesTemplatesList = "resources/templates/list" + MCPMethodPromptsList = "prompts/list" + MCPMethodPromptsGet = "prompts/get" +) -func (t *Toolset) SetReadOnly() { - // Set the toolset to read-only - t.readOnly = true -} +// ForMCPRequest returns a ToolsetGroup optimized for a specific MCP request. +// This is designed for servers that create a new instance per request (like the remote server), +// allowing them to only register the items needed for that specific request rather than all ~90 tools. +// +// Parameters: +// - method: The MCP method being called (use MCP* constants) +// - itemName: Name of specific item for call/get methods (tool name, resource URI, or prompt name) +// +// Returns a new ToolsetGroup containing only the items relevant to the request: +// - MCPMethodInitialize: Empty (capabilities are set via ServerOptions, not registration) +// - MCPMethodToolsList: All available tools (no resources/prompts) +// - MCPMethodToolsCall: Only the named tool +// - MCPMethodResourcesList, MCPMethodResourcesTemplatesList: All available resources (no tools/prompts) +// - MCPMethodResourcesRead: Only the named resource template +// - MCPMethodPromptsList: All available prompts (no tools/resources) +// - MCPMethodPromptsGet: Only the named prompt +// - Unknown methods: Empty (no items registered) +// +// All existing filters (read-only, toolsets, etc.) still apply to the returned items. +func (tg *ToolsetGroup) ForMCPRequest(method string, itemName string) *ToolsetGroup { + result := tg.copy() + + switch method { + case MCPMethodInitialize: + // Capabilities only - no items need to be registered + // The server capabilities (tools, resources, prompts support) are set via ServerOptions + result.tools = []ServerTool{} + result.resourceTemplates = []ServerResourceTemplate{} + result.prompts = []ServerPrompt{} + + case MCPMethodToolsList: + // All available tools, but no resources or prompts + result.resourceTemplates = []ServerResourceTemplate{} + result.prompts = []ServerPrompt{} + + case MCPMethodToolsCall: + // Only the specific tool (if found), no resources or prompts + result.resourceTemplates = []ServerResourceTemplate{} + result.prompts = []ServerPrompt{} + if itemName != "" { + result.tools = tg.filterToolsByName(itemName) + } -func (t *Toolset) AddWriteTools(tools ...ServerTool) *Toolset { - // Silently ignore if the toolset is read-only to avoid any breach of that contract - for _, tool := range tools { - if tool.Tool.Annotations.ReadOnlyHint { - panic(fmt.Sprintf("tool (%s) is incorrectly annotated as read-only", tool.Tool.Name)) + case MCPMethodResourcesList, MCPMethodResourcesTemplatesList: + // All available resources, but no tools or prompts + result.tools = []ServerTool{} + result.prompts = []ServerPrompt{} + + case MCPMethodResourcesRead: + // Only the specific resource template, no tools or prompts + result.tools = []ServerTool{} + result.prompts = []ServerPrompt{} + if itemName != "" { + result.resourceTemplates = tg.filterResourcesByURI(itemName) } + + case MCPMethodPromptsList: + // All available prompts, but no tools or resources + result.tools = []ServerTool{} + result.resourceTemplates = []ServerResourceTemplate{} + + case MCPMethodPromptsGet: + // Only the specific prompt, no tools or resources + result.tools = []ServerTool{} + result.resourceTemplates = []ServerResourceTemplate{} + if itemName != "" { + result.prompts = tg.filterPromptsByName(itemName) + } + + default: + // Unknown method - register nothing + result.tools = []ServerTool{} + result.resourceTemplates = []ServerResourceTemplate{} + result.prompts = []ServerPrompt{} } - if !t.readOnly { - t.writeTools = append(t.writeTools, tools...) - } - return t + + return result } -func (t *Toolset) AddReadTools(tools ...ServerTool) *Toolset { - for _, tool := range tools { - if !tool.Tool.Annotations.ReadOnlyHint { - panic(fmt.Sprintf("tool (%s) must be annotated as read-only", tool.Tool.Name)) +// filterToolsByName returns tools matching the given name, checking deprecated aliases. +// Returns from the current tools slice (respects existing filter chain). +func (tg *ToolsetGroup) filterToolsByName(name string) []ServerTool { + // First check for exact match + for i := range tg.tools { + if tg.tools[i].Tool.Name == name { + return []ServerTool{tg.tools[i]} + } + } + // Check if name is a deprecated alias + if canonical, isAlias := tg.deprecatedAliases[name]; isAlias { + for i := range tg.tools { + if tg.tools[i].Tool.Name == canonical { + return []ServerTool{tg.tools[i]} + } } } - t.readTools = append(t.readTools, tools...) - return t + return []ServerTool{} } -type ToolsetGroup struct { - Toolsets map[string]*Toolset - deprecatedAliases map[string]string - everythingOn bool - readOnly bool +// filterResourcesByURI returns resource templates matching the given URI pattern. +func (tg *ToolsetGroup) filterResourcesByURI(uri string) []ServerResourceTemplate { + for i := range tg.resourceTemplates { + // Check if URI matches the template pattern (exact match on URITemplate string) + if tg.resourceTemplates[i].Template.URITemplate == uri { + return []ServerResourceTemplate{tg.resourceTemplates[i]} + } + } + return []ServerResourceTemplate{} } -func NewToolsetGroup(readOnly bool) *ToolsetGroup { - return &ToolsetGroup{ - Toolsets: make(map[string]*Toolset), - deprecatedAliases: make(map[string]string), - everythingOn: false, - readOnly: readOnly, +// filterPromptsByName returns prompts matching the given name. +func (tg *ToolsetGroup) filterPromptsByName(name string) []ServerPrompt { + for i := range tg.prompts { + if tg.prompts[i].Prompt.Name == name { + return []ServerPrompt{tg.prompts[i]} + } } + return []ServerPrompt{} } -func (tg *ToolsetGroup) AddDeprecatedToolAliases(aliases map[string]string) { +// AddDeprecatedToolAliases adds mappings from old tool names to new canonical names. +func (tg *ToolsetGroup) AddDeprecatedToolAliases(aliases map[string]string) *ToolsetGroup { for oldName, newName := range aliases { tg.deprecatedAliases[oldName] = newName } + return tg } -func (tg *ToolsetGroup) AddToolset(ts *Toolset) { - if tg.readOnly { - ts.SetReadOnly() +// isToolsetEnabled checks if a toolset is enabled based on current filters. +func (tg *ToolsetGroup) isToolsetEnabled(toolsetID ToolsetID) bool { + // Check enabled toolsets filter + if tg.enabledToolsets != nil { + return tg.enabledToolsets[toolsetID] } - tg.Toolsets[ts.Name] = ts + return true } -func NewToolset(name string, description string) *Toolset { - return &Toolset{ - Name: name, - Description: description, - Enabled: false, - readOnly: false, +// checkFeatureFlag checks a feature flag using the feature checker. +// Returns false if checker is nil or returns an error (errors are logged). +func (tg *ToolsetGroup) checkFeatureFlag(ctx context.Context, flagName string) bool { + if tg.featureChecker == nil || flagName == "" { + return false } + enabled, err := tg.featureChecker(ctx, flagName) + if err != nil { + fmt.Fprintf(os.Stderr, "Feature flag check error for %q: %v\n", flagName, err) + return false + } + return enabled } -func (tg *ToolsetGroup) IsEnabled(name string) bool { - // If everythingOn is true, all features are enabled - if tg.everythingOn { - return true +// isFeatureFlagAllowed checks if an item passes feature flag filtering. +// - If FeatureFlagEnable is set, the item is only allowed if the flag is enabled +// - If FeatureFlagDisable is set, the item is excluded if the flag is enabled +func (tg *ToolsetGroup) isFeatureFlagAllowed(ctx context.Context, enableFlag, disableFlag string) bool { + // Check enable flag - item requires this flag to be on + if enableFlag != "" && !tg.checkFeatureFlag(ctx, enableFlag) { + return false } + // Check disable flag - item is excluded if this flag is on + if disableFlag != "" && tg.checkFeatureFlag(ctx, disableFlag) { + return false + } + return true +} - feature, exists := tg.Toolsets[name] - if !exists { +// isToolEnabled checks if a specific tool is enabled based on current filters. +func (tg *ToolsetGroup) isToolEnabled(ctx context.Context, tool *ServerTool) bool { + // Check read-only filter first (applies to all tools) + if tg.readOnly && !tool.IsReadOnly() { return false } - return feature.Enabled + // Check feature flags + if !tg.isFeatureFlagAllowed(ctx, tool.FeatureFlagEnable, tool.FeatureFlagDisable) { + return false + } + // Check if tool is in additionalTools (bypasses toolset filter) + if tg.additionalTools != nil && tg.additionalTools[tool.Tool.Name] { + return true + } + // Check toolset filter + if !tg.isToolsetEnabled(tool.Toolset.ID) { + return false + } + return true } -type EnableToolsetsOptions struct { - ErrorOnUnknown bool +// AvailableTools returns the tools that pass all current filters, +// sorted deterministically by toolset ID, then tool name. +// The context is used for feature flag evaluation. +func (tg *ToolsetGroup) AvailableTools(ctx context.Context) []ServerTool { + var result []ServerTool + for i := range tg.tools { + tool := &tg.tools[i] + if tg.isToolEnabled(ctx, tool) { + result = append(result, *tool) + } + } + + // Sort deterministically: by toolset ID, then by tool name + sort.Slice(result, func(i, j int) bool { + if result[i].Toolset.ID != result[j].Toolset.ID { + return result[i].Toolset.ID < result[j].Toolset.ID + } + return result[i].Tool.Name < result[j].Tool.Name + }) + + return result } -func (tg *ToolsetGroup) EnableToolsets(names []string, options *EnableToolsetsOptions) error { - if options == nil { - options = &EnableToolsetsOptions{ - ErrorOnUnknown: false, +// AvailableResourceTemplates returns resource templates that pass all current filters, +// sorted deterministically by toolset ID, then template name. +// The context is used for feature flag evaluation. +func (tg *ToolsetGroup) AvailableResourceTemplates(ctx context.Context) []ServerResourceTemplate { + var result []ServerResourceTemplate + for i := range tg.resourceTemplates { + res := &tg.resourceTemplates[i] + // Check feature flags + if !tg.isFeatureFlagAllowed(ctx, res.FeatureFlagEnable, res.FeatureFlagDisable) { + continue + } + if tg.isToolsetEnabled(res.Toolset.ID) { + result = append(result, *res) } } - // Special case for "all" - for _, name := range names { - if name == "all" { - tg.everythingOn = true - break + // Sort deterministically: by toolset ID, then by template name + sort.Slice(result, func(i, j int) bool { + if result[i].Toolset.ID != result[j].Toolset.ID { + return result[i].Toolset.ID < result[j].Toolset.ID + } + return result[i].Template.Name < result[j].Template.Name + }) + + return result +} + +// AvailablePrompts returns prompts that pass all current filters, +// sorted deterministically by toolset ID, then prompt name. +// The context is used for feature flag evaluation. +func (tg *ToolsetGroup) AvailablePrompts(ctx context.Context) []ServerPrompt { + var result []ServerPrompt + for i := range tg.prompts { + prompt := &tg.prompts[i] + // Check feature flags + if !tg.isFeatureFlagAllowed(ctx, prompt.FeatureFlagEnable, prompt.FeatureFlagDisable) { + continue } - err := tg.EnableToolset(name) - if err != nil && options.ErrorOnUnknown { - return err + if tg.isToolsetEnabled(prompt.Toolset.ID) { + result = append(result, *prompt) } } - // Do this after to ensure all toolsets are enabled if "all" is present anywhere in list - if tg.everythingOn { - for name := range tg.Toolsets { - err := tg.EnableToolset(name) - if err != nil && options.ErrorOnUnknown { - return err - } + + // Sort deterministically: by toolset ID, then by prompt name + sort.Slice(result, func(i, j int) bool { + if result[i].Toolset.ID != result[j].Toolset.ID { + return result[i].Toolset.ID < result[j].Toolset.ID } - return nil + return result[i].Prompt.Name < result[j].Prompt.Name + }) + + return result +} + +// ToolsetIDs returns a sorted list of unique toolset IDs from all tools in this group. +func (tg *ToolsetGroup) ToolsetIDs() []ToolsetID { + seen := make(map[ToolsetID]bool) + for i := range tg.tools { + seen[tg.tools[i].Toolset.ID] = true + } + for i := range tg.resourceTemplates { + seen[tg.resourceTemplates[i].Toolset.ID] = true + } + for i := range tg.prompts { + seen[tg.prompts[i].Toolset.ID] = true } - return nil + + ids := make([]ToolsetID, 0, len(seen)) + for id := range seen { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + return ids } -func (tg *ToolsetGroup) EnableToolset(name string) error { - toolset, exists := tg.Toolsets[name] - if !exists { - return NewToolsetDoesNotExistError(name) +// ToolsetDescriptions returns a map of toolset ID to description for all toolsets. +func (tg *ToolsetGroup) ToolsetDescriptions() map[ToolsetID]string { + descriptions := make(map[ToolsetID]string) + for i := range tg.tools { + t := &tg.tools[i] + if t.Toolset.Description != "" { + descriptions[t.Toolset.ID] = t.Toolset.Description + } + } + for i := range tg.resourceTemplates { + r := &tg.resourceTemplates[i] + if r.Toolset.Description != "" { + descriptions[r.Toolset.ID] = r.Toolset.Description + } } - toolset.Enabled = true - tg.Toolsets[name] = toolset - return nil + for i := range tg.prompts { + p := &tg.prompts[i] + if p.Toolset.Description != "" { + descriptions[p.Toolset.ID] = p.Toolset.Description + } + } + return descriptions } -func (tg *ToolsetGroup) RegisterAll(s *mcp.Server) { - for _, toolset := range tg.Toolsets { - toolset.RegisterTools(s) - toolset.RegisterResourcesTemplates(s) - toolset.RegisterPrompts(s) +// ToolsForToolset returns all tools belonging to a specific toolset. +// This method bypasses the toolset enabled filter (for dynamic toolset registration), +// but still respects the read-only filter. +func (tg *ToolsetGroup) ToolsForToolset(toolsetID ToolsetID) []ServerTool { + var result []ServerTool + for i := range tg.tools { + tool := &tg.tools[i] + // Only check read-only filter, not toolset enabled filter + if tool.Toolset.ID == toolsetID { + if tg.readOnly && !tool.IsReadOnly() { + continue + } + result = append(result, *tool) + } } + + // Sort by tool name for deterministic order + sort.Slice(result, func(i, j int) bool { + return result[i].Tool.Name < result[j].Tool.Name + }) + + return result } -func (tg *ToolsetGroup) GetToolset(name string) (*Toolset, error) { - toolset, exists := tg.Toolsets[name] - if !exists { - return nil, NewToolsetDoesNotExistError(name) +// RegisterTools registers all available tools with the server using the provided dependencies. +// The context is used for feature flag evaluation. +func (tg *ToolsetGroup) RegisterTools(ctx context.Context, s *mcp.Server, deps any) { + for _, tool := range tg.AvailableTools(ctx) { + tool.RegisterFunc(s, deps) } - return toolset, nil } -type ToolDoesNotExistError struct { - Name string +// RegisterResourceTemplates registers all available resource templates with the server. +// The context is used for feature flag evaluation. +func (tg *ToolsetGroup) RegisterResourceTemplates(ctx context.Context, s *mcp.Server) { + for _, res := range tg.AvailableResourceTemplates(ctx) { + s.AddResourceTemplate(&res.Template, res.Handler) + } } -func (e *ToolDoesNotExistError) Error() string { - return fmt.Sprintf("tool %s does not exist", e.Name) +// RegisterPrompts registers all available prompts with the server. +// The context is used for feature flag evaluation. +func (tg *ToolsetGroup) RegisterPrompts(ctx context.Context, s *mcp.Server) { + for _, prompt := range tg.AvailablePrompts(ctx) { + s.AddPrompt(&prompt.Prompt, prompt.Handler) + } } -func NewToolDoesNotExistError(name string) *ToolDoesNotExistError { - return &ToolDoesNotExistError{Name: name} +// RegisterAll registers all available tools, resources, and prompts with the server. +// The context is used for feature flag evaluation. +func (tg *ToolsetGroup) RegisterAll(ctx context.Context, s *mcp.Server, deps any) { + tg.RegisterTools(ctx, s, deps) + tg.RegisterResourceTemplates(ctx, s) + tg.RegisterPrompts(ctx, s) } // ResolveToolAliases resolves deprecated tool aliases to their canonical names. @@ -322,51 +678,82 @@ func (tg *ToolsetGroup) ResolveToolAliases(toolNames []string) (resolved []strin return resolved, aliasesUsed } -// 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) (*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 - } +// FindToolByName searches all tools for one matching the given name. +// Returns the tool, its toolset ID, and an error if not found. +// This searches ALL tools regardless of filters. +func (tg *ToolsetGroup) FindToolByName(toolName string) (*ServerTool, ToolsetID, error) { + for i := range tg.tools { + tool := &tg.tools[i] + if tool.Tool.Name == toolName { + return tool, tool.Toolset.ID, 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 *mcp.Server, toolNames []string, readOnly bool, deps any) 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) +// HasToolset checks if any tool/resource/prompt belongs to the given toolset. +func (tg *ToolsetGroup) HasToolset(toolsetID ToolsetID) bool { + for i := range tg.tools { + if tg.tools[i].Toolset.ID == toolsetID { + return true } - - if !tool.Tool.Annotations.ReadOnlyHint && readOnly { - // Skip write tools in read-only mode - skippedTools = append(skippedTools, toolName) - continue + } + for i := range tg.resourceTemplates { + if tg.resourceTemplates[i].Toolset.ID == toolsetID { + return true + } + } + for i := range tg.prompts { + if tg.prompts[i].Toolset.ID == toolsetID { + return true } + } + return false +} - // Register the tool - tool.RegisterFunc(s, deps) +// EnabledToolsetIDs returns the list of enabled toolset IDs based on current filters. +// Returns all toolset IDs if no filter is set. +func (tg *ToolsetGroup) EnabledToolsetIDs() []ToolsetID { + if tg.enabledToolsets == nil { + return tg.ToolsetIDs() + } + + ids := make([]ToolsetID, 0, len(tg.enabledToolsets)) + for id := range tg.enabledToolsets { + if tg.HasToolset(id) { + ids = append(ids, id) + } } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + return ids +} - // 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, ", ")) +// IsToolsetEnabled checks if a toolset is currently enabled based on filters. +func (tg *ToolsetGroup) IsToolsetEnabled(toolsetID ToolsetID) bool { + return tg.isToolsetEnabled(toolsetID) +} + +// EnableToolset marks a toolset as enabled in this group. +// This is used by dynamic toolset management to track which toolsets have been enabled. +func (tg *ToolsetGroup) EnableToolset(toolsetID ToolsetID) { + if tg.enabledToolsets == nil { + // nil means all enabled, so nothing to do + return } + tg.enabledToolsets[toolsetID] = true +} + +// AllTools returns all tools without any filtering, sorted deterministically. +func (tg *ToolsetGroup) AllTools() []ServerTool { + result := slices.Clone(tg.tools) + + // Sort deterministically: by toolset ID, then by tool name + sort.Slice(result, func(i, j int) bool { + if result[i].Toolset.ID != result[j].Toolset.ID { + return result[i].Toolset.ID < result[j].Toolset.ID + } + return result[i].Tool.Name < result[j].Tool.Name + }) - return nil + return result } diff --git a/pkg/toolsets/toolsets_test.go b/pkg/toolsets/toolsets_test.go index 66be5cba2..7bec55848 100644 --- a/pkg/toolsets/toolsets_test.go +++ b/pkg/toolsets/toolsets_test.go @@ -3,14 +3,22 @@ package toolsets import ( "context" "encoding/json" - "errors" + "fmt" "testing" "github.com/modelcontextprotocol/go-sdk/mcp" ) +// testToolsetMetadata returns a ToolsetMetadata for testing +func testToolsetMetadata(id string) ToolsetMetadata { + return ToolsetMetadata{ + ID: ToolsetID(id), + Description: "Test toolset: " + id, + } +} + // mockTool creates a minimal ServerTool for testing -func mockTool(name string, readOnly bool) ServerTool { +func mockTool(name string, toolsetID string, readOnly bool) ServerTool { return NewServerToolFromHandler( mcp.Tool{ Name: name, @@ -19,6 +27,7 @@ func mockTool(name string, readOnly bool) ServerTool { }, InputSchema: json.RawMessage(`{"type":"object","properties":{}}`), }, + testToolsetMetadata(toolsetID), func(_ any) mcp.ToolHandler { return func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { return nil, nil @@ -27,382 +36,1000 @@ func mockTool(name string, readOnly bool) ServerTool { ) } -func TestNewToolsetGroupIsEmptyWithoutEverythingOn(t *testing.T) { - tsg := NewToolsetGroup(false) - if len(tsg.Toolsets) != 0 { - t.Fatalf("Expected Toolsets map to be empty, got %d items", len(tsg.Toolsets)) +func TestNewToolsetGroupEmpty(t *testing.T) { + tsg := NewToolsetGroup(nil, nil, nil) + if len(tsg.tools) != 0 { + t.Fatalf("Expected tools to be empty, got %d items", len(tsg.tools)) } - if tsg.everythingOn { - t.Fatal("Expected everythingOn to be initialized as false") + if len(tsg.resourceTemplates) != 0 { + t.Fatalf("Expected resourceTemplates to be empty, got %d items", len(tsg.resourceTemplates)) + } + if len(tsg.prompts) != 0 { + t.Fatalf("Expected prompts to be empty, got %d items", len(tsg.prompts)) } } -func TestAddToolset(t *testing.T) { - tsg := NewToolsetGroup(false) +func TestNewToolsetGroupWithTools(t *testing.T) { + tools := []ServerTool{ + mockTool("tool1", "toolset1", true), + mockTool("tool2", "toolset1", false), + mockTool("tool3", "toolset2", true), + } - // Test adding a toolset - toolset := NewToolset("test-toolset", "A test toolset") - toolset.Enabled = true - tsg.AddToolset(toolset) + tsg := NewToolsetGroup(tools, nil, nil) - // Verify toolset was added correctly - if len(tsg.Toolsets) != 1 { - t.Errorf("Expected 1 toolset, got %d", len(tsg.Toolsets)) + if len(tsg.tools) != 3 { + t.Errorf("Expected 3 tools, got %d", len(tsg.tools)) } +} - toolset, exists := tsg.Toolsets["test-toolset"] - if !exists { - t.Fatal("Feature was not added to the map") +func TestAvailableTools_NoFilters(t *testing.T) { + tools := []ServerTool{ + mockTool("tool_b", "toolset1", true), + mockTool("tool_a", "toolset1", false), + mockTool("tool_c", "toolset2", true), } - if toolset.Name != "test-toolset" { - t.Errorf("Expected toolset name to be 'test-toolset', got '%s'", toolset.Name) + tsg := NewToolsetGroup(tools, nil, nil) + available := tsg.AvailableTools(context.Background()) + + if len(available) != 3 { + t.Fatalf("Expected 3 available tools, got %d", len(available)) } - if toolset.Description != "A test toolset" { - t.Errorf("Expected toolset description to be 'A test toolset', got '%s'", toolset.Description) + // Verify deterministic sorting: by toolset ID, then tool name + expectedOrder := []string{"tool_a", "tool_b", "tool_c"} + for i, tool := range available { + if tool.Tool.Name != expectedOrder[i] { + t.Errorf("Tool at index %d: expected %s, got %s", i, expectedOrder[i], tool.Tool.Name) + } } +} - if !toolset.Enabled { - t.Error("Expected toolset to be enabled") +func TestWithReadOnly(t *testing.T) { + tools := []ServerTool{ + mockTool("read_tool", "toolset1", true), + mockTool("write_tool", "toolset1", false), } - // Test adding another toolset - anotherToolset := NewToolset("another-toolset", "Another test toolset") - tsg.AddToolset(anotherToolset) + tsg := NewToolsetGroup(tools, nil, nil) - if len(tsg.Toolsets) != 2 { - t.Errorf("Expected 2 toolsets, got %d", len(tsg.Toolsets)) + // Original should have both tools + allTools := tsg.AvailableTools(context.Background()) + if len(allTools) != 2 { + t.Fatalf("Expected 2 tools in original, got %d", len(allTools)) } - // Test overriding existing toolset - updatedToolset := NewToolset("test-toolset", "Updated description") - tsg.AddToolset(updatedToolset) - - toolset = tsg.Toolsets["test-toolset"] - if toolset.Description != "Updated description" { - t.Errorf("Expected toolset description to be updated to 'Updated description', got '%s'", toolset.Description) + // Read-only should filter out write tools + readOnlyTsg := tsg.WithReadOnly(true) + readOnlyTools := readOnlyTsg.AvailableTools(context.Background()) + if len(readOnlyTools) != 1 { + t.Fatalf("Expected 1 tool in read-only, got %d", len(readOnlyTools)) + } + if readOnlyTools[0].Tool.Name != "read_tool" { + t.Errorf("Expected read_tool, got %s", readOnlyTools[0].Tool.Name) } - if toolset.Enabled { - t.Error("Expected toolset to be disabled after update") + // Original should still have both (immutability test) + allTools = tsg.AvailableTools(context.Background()) + if len(allTools) != 2 { + t.Fatalf("Original was mutated! Expected 2 tools, got %d", len(allTools)) } } -func TestIsEnabled(t *testing.T) { - tsg := NewToolsetGroup(false) +func TestWithToolsets(t *testing.T) { + tools := []ServerTool{ + mockTool("tool1", "toolset1", true), + mockTool("tool2", "toolset2", true), + mockTool("tool3", "toolset3", true), + } + + tsg := NewToolsetGroup(tools, nil, nil) + + // Filter to specific toolsets + filteredTsg := tsg.WithToolsets([]string{"toolset1", "toolset3"}) + filteredTools := filteredTsg.AvailableTools(context.Background()) - // Test with non-existent toolset - if tsg.IsEnabled("non-existent") { - t.Error("Expected IsEnabled to return false for non-existent toolset") + if len(filteredTools) != 2 { + t.Fatalf("Expected 2 filtered tools, got %d", len(filteredTools)) } - // Test with disabled toolset - disabledToolset := NewToolset("disabled-toolset", "A disabled toolset") - tsg.AddToolset(disabledToolset) - if tsg.IsEnabled("disabled-toolset") { - t.Error("Expected IsEnabled to return false for disabled toolset") + // Verify correct tools are included + toolNames := make(map[string]bool) + for _, tool := range filteredTools { + toolNames[tool.Tool.Name] = true + } + if !toolNames["tool1"] || !toolNames["tool3"] { + t.Errorf("Expected tool1 and tool3, got %v", toolNames) } - // Test with enabled toolset - enabledToolset := NewToolset("enabled-toolset", "An enabled toolset") - enabledToolset.Enabled = true - tsg.AddToolset(enabledToolset) - if !tsg.IsEnabled("enabled-toolset") { - t.Error("Expected IsEnabled to return true for enabled toolset") + // Original should still have all 3 (immutability test) + allTools := tsg.AvailableTools(context.Background()) + if len(allTools) != 3 { + t.Fatalf("Original was mutated! Expected 3 tools, got %d", len(allTools)) } } -func TestEnableFeature(t *testing.T) { - tsg := NewToolsetGroup(false) +func TestWithTools(t *testing.T) { + tools := []ServerTool{ + mockTool("tool1", "toolset1", true), + mockTool("tool2", "toolset1", true), + mockTool("tool3", "toolset2", true), + } - // Test enabling non-existent toolset - err := tsg.EnableToolset("non-existent") - if err == nil { - t.Error("Expected error when enabling non-existent toolset") + tsg := NewToolsetGroup(tools, nil, nil) + + // WithTools adds additional tools that bypass toolset filtering + // When combined with WithToolsets([]), only the additional tools should be available + filteredTsg := tsg.WithToolsets([]string{}).WithTools([]string{"tool1", "tool3"}) + filteredTools := filteredTsg.AvailableTools(context.Background()) + + if len(filteredTools) != 2 { + t.Fatalf("Expected 2 filtered tools, got %d", len(filteredTools)) + } + + toolNames := make(map[string]bool) + for _, tool := range filteredTools { + toolNames[tool.Tool.Name] = true + } + if !toolNames["tool1"] || !toolNames["tool3"] { + t.Errorf("Expected tool1 and tool3, got %v", toolNames) + } +} + +func TestChainedFilters(t *testing.T) { + tools := []ServerTool{ + mockTool("read1", "toolset1", true), + mockTool("write1", "toolset1", false), + mockTool("read2", "toolset2", true), + mockTool("write2", "toolset2", false), } - // Test enabling toolset - testToolset := NewToolset("test-toolset", "A test toolset") - tsg.AddToolset(testToolset) + tsg := NewToolsetGroup(tools, nil, nil) - if tsg.IsEnabled("test-toolset") { - t.Error("Expected toolset to be disabled initially") + // Chain read-only and toolset filter + filtered := tsg.WithReadOnly(true).WithToolsets([]string{"toolset1"}) + result := filtered.AvailableTools(context.Background()) + + if len(result) != 1 { + t.Fatalf("Expected 1 tool after chained filters, got %d", len(result)) + } + if result[0].Tool.Name != "read1" { + t.Errorf("Expected read1, got %s", result[0].Tool.Name) } +} - err = tsg.EnableToolset("test-toolset") - if err != nil { - t.Errorf("Expected no error when enabling toolset, got: %v", err) +func TestToolsetIDs(t *testing.T) { + tools := []ServerTool{ + mockTool("tool1", "toolset_b", true), + mockTool("tool2", "toolset_a", true), + mockTool("tool3", "toolset_b", true), // duplicate toolset } - if !tsg.IsEnabled("test-toolset") { - t.Error("Expected toolset to be enabled after EnableFeature call") + tsg := NewToolsetGroup(tools, nil, nil) + ids := tsg.ToolsetIDs() + + if len(ids) != 2 { + t.Fatalf("Expected 2 unique toolset IDs, got %d", len(ids)) } - // Test enabling already enabled toolset - err = tsg.EnableToolset("test-toolset") - if err != nil { - t.Errorf("Expected no error when enabling already enabled toolset, got: %v", err) + // Should be sorted + if ids[0] != "toolset_a" || ids[1] != "toolset_b" { + t.Errorf("Expected sorted IDs [toolset_a, toolset_b], got %v", ids) } } -func TestEnableToolsets(t *testing.T) { - tsg := NewToolsetGroup(false) +func TestToolsetDescriptions(t *testing.T) { + tools := []ServerTool{ + mockTool("tool1", "toolset1", true), + mockTool("tool2", "toolset2", true), + } - // Prepare toolsets - toolset1 := NewToolset("toolset1", "Feature 1") - toolset2 := NewToolset("toolset2", "Feature 2") - tsg.AddToolset(toolset1) - tsg.AddToolset(toolset2) + tsg := NewToolsetGroup(tools, nil, nil) + descriptions := tsg.ToolsetDescriptions() - // Test enabling multiple toolsets - err := tsg.EnableToolsets([]string{"toolset1", "toolset2"}, &EnableToolsetsOptions{}) - if err != nil { - t.Errorf("Expected no error when enabling toolsets, got: %v", err) + if len(descriptions) != 2 { + t.Fatalf("Expected 2 descriptions, got %d", len(descriptions)) } - if !tsg.IsEnabled("toolset1") { - t.Error("Expected toolset1 to be enabled") + if descriptions["toolset1"] != "Test toolset: toolset1" { + t.Errorf("Wrong description for toolset1: %s", descriptions["toolset1"]) } +} - if !tsg.IsEnabled("toolset2") { - t.Error("Expected toolset2 to be enabled") +func TestToolsForToolset(t *testing.T) { + tools := []ServerTool{ + mockTool("tool1", "toolset1", true), + mockTool("tool2", "toolset1", true), + mockTool("tool3", "toolset2", true), } - // Test with non-existent toolset in the list - err = tsg.EnableToolsets([]string{"toolset1", "non-existent"}, nil) - if err != nil { - t.Errorf("Expected no error when ignoring unknown toolsets, got: %v", err) + tsg := NewToolsetGroup(tools, nil, nil) + toolset1Tools := tsg.ToolsForToolset("toolset1") + + if len(toolset1Tools) != 2 { + t.Fatalf("Expected 2 tools for toolset1, got %d", len(toolset1Tools)) } +} - err = tsg.EnableToolsets([]string{"toolset1", "non-existent"}, &EnableToolsetsOptions{ - ErrorOnUnknown: false, +func TestAddDeprecatedToolAliases(t *testing.T) { + tools := []ServerTool{ + mockTool("new_name", "toolset1", true), + } + + tsg := NewToolsetGroup(tools, nil, nil) + tsg.AddDeprecatedToolAliases(map[string]string{ + "old_name": "new_name", + "get_issue": "issue_read", }) + + if len(tsg.deprecatedAliases) != 2 { + t.Errorf("expected 2 aliases, got %d", len(tsg.deprecatedAliases)) + } + if tsg.deprecatedAliases["old_name"] != "new_name" { + t.Errorf("expected alias 'old_name' -> 'new_name', got '%s'", tsg.deprecatedAliases["old_name"]) + } +} + +func TestResolveToolAliases(t *testing.T) { + tools := []ServerTool{ + mockTool("issue_read", "toolset1", true), + mockTool("some_tool", "toolset1", true), + } + + tsg := NewToolsetGroup(tools, nil, nil) + tsg.AddDeprecatedToolAliases(map[string]string{ + "get_issue": "issue_read", + }) + + // Test resolving a mix of aliases and canonical names + input := []string{"get_issue", "some_tool"} + resolved, aliasesUsed := tsg.ResolveToolAliases(input) + + if len(resolved) != 2 { + t.Fatalf("expected 2 resolved names, got %d", len(resolved)) + } + if resolved[0] != "issue_read" { + t.Errorf("expected 'issue_read', got '%s'", resolved[0]) + } + if resolved[1] != "some_tool" { + t.Errorf("expected 'some_tool' (unchanged), got '%s'", resolved[1]) + } + + if len(aliasesUsed) != 1 { + t.Fatalf("expected 1 alias used, got %d", len(aliasesUsed)) + } + if aliasesUsed["get_issue"] != "issue_read" { + t.Errorf("expected aliasesUsed['get_issue'] = 'issue_read', got '%s'", aliasesUsed["get_issue"]) + } +} + +func TestFindToolByName(t *testing.T) { + tools := []ServerTool{ + mockTool("issue_read", "toolset1", true), + } + + tsg := NewToolsetGroup(tools, nil, nil) + + // Find by name + tool, toolsetID, err := tsg.FindToolByName("issue_read") if err != nil { - t.Errorf("Expected no error when ignoring unknown toolsets, got: %v", err) + t.Fatalf("expected no error, got %v", err) + } + if tool.Tool.Name != "issue_read" { + t.Errorf("expected tool name 'issue_read', got '%s'", tool.Tool.Name) + } + if toolsetID != "toolset1" { + t.Errorf("expected toolset ID 'toolset1', got '%s'", toolsetID) } - err = tsg.EnableToolsets([]string{"toolset1", "non-existent"}, &EnableToolsetsOptions{ErrorOnUnknown: true}) + // Non-existent tool + _, _, err = tsg.FindToolByName("nonexistent") if err == nil { - t.Error("Expected error when enabling list with non-existent toolset") + t.Error("expected error for non-existent tool") } - if !errors.Is(err, NewToolsetDoesNotExistError("non-existent")) { - t.Errorf("Expected ToolsetDoesNotExistError when enabling non-existent toolset, got: %v", err) +} + +func TestWithToolsAdditive(t *testing.T) { + tools := []ServerTool{ + mockTool("issue_read", "toolset1", true), + mockTool("issue_write", "toolset1", false), + mockTool("repo_read", "toolset2", true), } - // Test with empty list - err = tsg.EnableToolsets([]string{}, &EnableToolsetsOptions{}) - if err != nil { - t.Errorf("Expected no error with empty toolset list, got: %v", err) + tsg := NewToolsetGroup(tools, nil, nil) + + // Test WithTools bypasses toolset filtering + // Enable only toolset2, but add issue_read as additional tool + filtered := tsg.WithToolsets([]string{"toolset2"}).WithTools([]string{"issue_read"}) + + available := filtered.AvailableTools(context.Background()) + if len(available) != 2 { + t.Errorf("expected 2 tools (repo_read from toolset + issue_read additional), got %d", len(available)) } - // Test enabling everything through EnableToolsets - tsg = NewToolsetGroup(false) - err = tsg.EnableToolsets([]string{"all"}, &EnableToolsetsOptions{}) - if err != nil { - t.Errorf("Expected no error when enabling 'all', got: %v", err) + // Verify both tools are present + toolNames := make(map[string]bool) + for _, tool := range available { + toolNames[tool.Tool.Name] = true + } + if !toolNames["issue_read"] { + t.Error("expected issue_read to be included as additional tool") + } + if !toolNames["repo_read"] { + t.Error("expected repo_read to be included from toolset2") + } + + // Test WithTools respects read-only mode + readOnlyFiltered := tsg.WithReadOnly(true).WithTools([]string{"issue_write"}) + available = readOnlyFiltered.AvailableTools(context.Background()) + + // issue_write should be excluded because read-only applies to additional tools too + for _, tool := range available { + if tool.Tool.Name == "issue_write" { + t.Error("expected issue_write to be excluded in read-only mode") + } } - if !tsg.everythingOn { - t.Error("Expected everythingOn to be true after enabling 'all' via EnableToolsets") + // Test WithTools with non-existent tool (should not error, just won't match anything) + nonexistent := tsg.WithToolsets([]string{}).WithTools([]string{"nonexistent"}) + available = nonexistent.AvailableTools(context.Background()) + if len(available) != 0 { + t.Errorf("expected 0 tools for non-existent additional tool, got %d", len(available)) } } -func TestEnableEverything(t *testing.T) { - tsg := NewToolsetGroup(false) +func TestWithToolsResolvesAliases(t *testing.T) { + tools := []ServerTool{ + mockTool("issue_read", "toolset1", true), + } + + tsg := NewToolsetGroup(tools, nil, nil) + tsg.AddDeprecatedToolAliases(map[string]string{ + "get_issue": "issue_read", + }) - // Add a disabled toolset - testToolset := NewToolset("test-toolset", "A test toolset") - tsg.AddToolset(testToolset) + // Using deprecated alias should resolve to canonical name + filtered := tsg.WithToolsets([]string{}).WithTools([]string{"get_issue"}) + available := filtered.AvailableTools(context.Background()) - // Verify it's disabled - if tsg.IsEnabled("test-toolset") { - t.Error("Expected toolset to be disabled initially") + if len(available) != 1 { + t.Errorf("expected 1 tool, got %d", len(available)) } + if available[0].Tool.Name != "issue_read" { + t.Errorf("expected issue_read, got %s", available[0].Tool.Name) + } +} - // Enable "all" - err := tsg.EnableToolsets([]string{"all"}, &EnableToolsetsOptions{}) - if err != nil { - t.Errorf("Expected no error when enabling 'all', got: %v", err) +func TestHasToolset(t *testing.T) { + tools := []ServerTool{ + mockTool("tool1", "toolset1", true), } - // Verify everythingOn was set - if !tsg.everythingOn { - t.Error("Expected everythingOn to be true after enabling 'all'") + tsg := NewToolsetGroup(tools, nil, nil) + + if !tsg.HasToolset("toolset1") { + t.Error("expected HasToolset to return true for existing toolset") + } + if tsg.HasToolset("nonexistent") { + t.Error("expected HasToolset to return false for non-existent toolset") } +} - // Verify the previously disabled toolset is now enabled - if !tsg.IsEnabled("test-toolset") { - t.Error("Expected toolset to be enabled when everythingOn is true") +func TestEnabledToolsetIDs(t *testing.T) { + tools := []ServerTool{ + mockTool("tool1", "toolset1", true), + mockTool("tool2", "toolset2", true), } - // Verify a non-existent toolset is also enabled - if !tsg.IsEnabled("non-existent") { - t.Error("Expected non-existent toolset to be enabled when everythingOn is true") + tsg := NewToolsetGroup(tools, nil, nil) + + // Without filter, all toolsets are enabled + ids := tsg.EnabledToolsetIDs() + if len(ids) != 2 { + t.Fatalf("Expected 2 enabled toolset IDs, got %d", len(ids)) + } + + // With filter + filtered := tsg.WithToolsets([]string{"toolset1"}) + filteredIDs := filtered.EnabledToolsetIDs() + if len(filteredIDs) != 1 { + t.Fatalf("Expected 1 enabled toolset ID, got %d", len(filteredIDs)) + } + if filteredIDs[0] != "toolset1" { + t.Errorf("Expected toolset1, got %s", filteredIDs[0]) } } -func TestIsEnabledWithEverythingOn(t *testing.T) { - tsg := NewToolsetGroup(false) +func TestAllTools(t *testing.T) { + tools := []ServerTool{ + mockTool("read_tool", "toolset1", true), + mockTool("write_tool", "toolset1", false), + } - // Enable "all" - err := tsg.EnableToolsets([]string{"all"}, &EnableToolsetsOptions{}) - if err != nil { - t.Errorf("Expected no error when enabling 'all', got: %v", err) + tsg := NewToolsetGroup(tools, nil, nil) + + // Even with read-only filter, AllTools returns everything + readOnlyTsg := tsg.WithReadOnly(true) + + allTools := readOnlyTsg.AllTools() + if len(allTools) != 2 { + t.Fatalf("Expected 2 tools from AllTools, got %d", len(allTools)) } - // Test that any toolset name returns true with IsEnabled - if !tsg.IsEnabled("some-toolset") { - t.Error("Expected IsEnabled to return true for any toolset when everythingOn is true") + // But AvailableTools respects the filter + availableTools := readOnlyTsg.AvailableTools(context.Background()) + if len(availableTools) != 1 { + t.Fatalf("Expected 1 tool from AvailableTools, got %d", len(availableTools)) } +} + +func TestServerToolIsReadOnly(t *testing.T) { + readTool := mockTool("read_tool", "toolset1", true) + writeTool := mockTool("write_tool", "toolset1", false) - if !tsg.IsEnabled("another-toolset") { - t.Error("Expected IsEnabled to return true for any toolset when everythingOn is true") + if !readTool.IsReadOnly() { + t.Error("Expected read tool to be read-only") + } + if writeTool.IsReadOnly() { + t.Error("Expected write tool to not be read-only") } } -func TestToolsetGroup_GetToolset(t *testing.T) { - tsg := NewToolsetGroup(false) - toolset := NewToolset("my-toolset", "desc") - tsg.AddToolset(toolset) +// mockResource creates a minimal ServerResourceTemplate for testing +func mockResource(name string, toolsetID string, uriTemplate string) ServerResourceTemplate { + return NewServerResourceTemplate( + testToolsetMetadata(toolsetID), + mcp.ResourceTemplate{ + Name: name, + URITemplate: uriTemplate, + }, + func(_ context.Context, _ *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return nil, nil + }, + ) +} - // Should find the toolset - got, err := tsg.GetToolset("my-toolset") - if err != nil { - t.Fatalf("expected no error, got %v", err) +// mockPrompt creates a minimal ServerPrompt for testing +func mockPrompt(name string, toolsetID string) ServerPrompt { + return NewServerPrompt( + testToolsetMetadata(toolsetID), + mcp.Prompt{Name: name}, + func(_ context.Context, _ *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { + return nil, nil + }, + ) +} + +func TestForMCPRequest_Initialize(t *testing.T) { + tools := []ServerTool{ + mockTool("tool1", "repos", true), + mockTool("tool2", "issues", false), } - if got != toolset { - t.Errorf("expected to get the same toolset instance") + resources := []ServerResourceTemplate{ + mockResource("res1", "repos", "repo://{owner}/{repo}"), + } + prompts := []ServerPrompt{ + mockPrompt("prompt1", "repos"), } - // Should not find a non-existent toolset - _, err = tsg.GetToolset("does-not-exist") - if err == nil { - t.Error("expected error for missing toolset, got nil") + tsg := NewToolsetGroup(tools, resources, prompts) + filtered := tsg.ForMCPRequest(MCPMethodInitialize, "") + + // Initialize should return empty - capabilities come from ServerOptions + if len(filtered.AvailableTools(context.Background())) != 0 { + t.Errorf("Expected 0 tools for initialize, got %d", len(filtered.AvailableTools(context.Background()))) + } + if len(filtered.AvailableResourceTemplates(context.Background())) != 0 { + t.Errorf("Expected 0 resources for initialize, got %d", len(filtered.AvailableResourceTemplates(context.Background()))) } - if !errors.Is(err, NewToolsetDoesNotExistError("does-not-exist")) { - t.Errorf("expected error to be ToolsetDoesNotExistError, got %v", err) + if len(filtered.AvailablePrompts(context.Background())) != 0 { + t.Errorf("Expected 0 prompts for initialize, got %d", len(filtered.AvailablePrompts(context.Background()))) } } -func TestAddDeprecatedToolAliases(t *testing.T) { - tsg := NewToolsetGroup(false) +func TestForMCPRequest_ToolsList(t *testing.T) { + tools := []ServerTool{ + mockTool("tool1", "repos", true), + mockTool("tool2", "issues", true), + } + resources := []ServerResourceTemplate{ + mockResource("res1", "repos", "repo://{owner}/{repo}"), + } + prompts := []ServerPrompt{ + mockPrompt("prompt1", "repos"), + } - // Test adding aliases - tsg.AddDeprecatedToolAliases(map[string]string{ - "old_name": "new_name", - "get_issue": "issue_read", - "create_pr": "pull_request_create", - }) + tsg := NewToolsetGroup(tools, resources, prompts) + filtered := tsg.ForMCPRequest(MCPMethodToolsList, "") - if len(tsg.deprecatedAliases) != 3 { - t.Errorf("expected 3 aliases, got %d", len(tsg.deprecatedAliases)) + // tools/list should return all tools, no resources or prompts + if len(filtered.AvailableTools(context.Background())) != 2 { + t.Errorf("Expected 2 tools for tools/list, got %d", len(filtered.AvailableTools(context.Background()))) } - if tsg.deprecatedAliases["old_name"] != "new_name" { - t.Errorf("expected alias 'old_name' -> 'new_name', got '%s'", tsg.deprecatedAliases["old_name"]) + if len(filtered.AvailableResourceTemplates(context.Background())) != 0 { + t.Errorf("Expected 0 resources for tools/list, got %d", len(filtered.AvailableResourceTemplates(context.Background()))) + } + if len(filtered.AvailablePrompts(context.Background())) != 0 { + t.Errorf("Expected 0 prompts for tools/list, got %d", len(filtered.AvailablePrompts(context.Background()))) } - if tsg.deprecatedAliases["get_issue"] != "issue_read" { - t.Errorf("expected alias 'get_issue' -> 'issue_read'") +} + +func TestForMCPRequest_ToolsCall(t *testing.T) { + tools := []ServerTool{ + mockTool("get_me", "context", true), + mockTool("create_issue", "issues", false), + mockTool("list_repos", "repos", true), + } + + tsg := NewToolsetGroup(tools, nil, nil) + filtered := tsg.ForMCPRequest(MCPMethodToolsCall, "get_me") + + available := filtered.AvailableTools(context.Background()) + if len(available) != 1 { + t.Fatalf("Expected 1 tool for tools/call with name, got %d", len(available)) } - if tsg.deprecatedAliases["create_pr"] != "pull_request_create" { - t.Errorf("expected alias 'create_pr' -> 'pull_request_create'") + if available[0].Tool.Name != "get_me" { + t.Errorf("Expected tool name 'get_me', got %q", available[0].Tool.Name) } } -func TestResolveToolAliases(t *testing.T) { - tsg := NewToolsetGroup(false) +func TestForMCPRequest_ToolsCall_NotFound(t *testing.T) { + tools := []ServerTool{ + mockTool("get_me", "context", true), + } + + tsg := NewToolsetGroup(tools, nil, nil) + filtered := tsg.ForMCPRequest(MCPMethodToolsCall, "nonexistent") + + if len(filtered.AvailableTools(context.Background())) != 0 { + t.Errorf("Expected 0 tools for nonexistent tool, got %d", len(filtered.AvailableTools(context.Background()))) + } +} + +func TestForMCPRequest_ToolsCall_DeprecatedAlias(t *testing.T) { + tools := []ServerTool{ + mockTool("get_me", "context", true), + mockTool("list_commits", "repos", true), + } + + tsg := NewToolsetGroup(tools, nil, nil) tsg.AddDeprecatedToolAliases(map[string]string{ - "get_issue": "issue_read", - "create_pr": "pull_request_create", + "old_get_me": "get_me", }) - // Test resolving a mix of aliases and canonical names - input := []string{"get_issue", "some_tool", "create_pr"} - resolved, aliasesUsed := tsg.ResolveToolAliases(input) + // Request using the deprecated alias + filtered := tsg.ForMCPRequest(MCPMethodToolsCall, "old_get_me") - // Verify resolved names - if len(resolved) != 3 { - t.Fatalf("expected 3 resolved names, got %d", len(resolved)) + available := filtered.AvailableTools(context.Background()) + if len(available) != 1 { + t.Fatalf("Expected 1 tool when using deprecated alias, got %d", len(available)) } - if resolved[0] != "issue_read" { - t.Errorf("expected 'issue_read', got '%s'", resolved[0]) + if available[0].Tool.Name != "get_me" { + t.Errorf("Expected canonical name 'get_me', got %q", available[0].Tool.Name) } - if resolved[1] != "some_tool" { - t.Errorf("expected 'some_tool' (unchanged), got '%s'", resolved[1]) +} + +func TestForMCPRequest_ToolsCall_RespectsFilters(t *testing.T) { + tools := []ServerTool{ + mockTool("create_issue", "issues", false), // write tool } - if resolved[2] != "pull_request_create" { - t.Errorf("expected 'pull_request_create', got '%s'", resolved[2]) + + tsg := NewToolsetGroup(tools, nil, nil) + // Apply read-only filter, then ForMCPRequest + filtered := tsg.WithReadOnly(true).ForMCPRequest(MCPMethodToolsCall, "create_issue") + + // The tool exists in the filtered group, but AvailableTools respects read-only + available := filtered.AvailableTools(context.Background()) + if len(available) != 0 { + t.Errorf("Expected 0 tools - write tool should be filtered by read-only, got %d", len(available)) } +} - // Verify aliasesUsed map - if len(aliasesUsed) != 2 { - t.Fatalf("expected 2 aliases used, got %d", len(aliasesUsed)) +func TestForMCPRequest_ResourcesList(t *testing.T) { + tools := []ServerTool{ + mockTool("tool1", "repos", true), } - if aliasesUsed["get_issue"] != "issue_read" { - t.Errorf("expected aliasesUsed['get_issue'] = 'issue_read', got '%s'", aliasesUsed["get_issue"]) + resources := []ServerResourceTemplate{ + mockResource("res1", "repos", "repo://{owner}/{repo}"), + mockResource("res2", "repos", "branch://{owner}/{repo}/{branch}"), } - if aliasesUsed["create_pr"] != "pull_request_create" { - t.Errorf("expected aliasesUsed['create_pr'] = 'pull_request_create', got '%s'", aliasesUsed["create_pr"]) + prompts := []ServerPrompt{ + mockPrompt("prompt1", "repos"), + } + + tsg := NewToolsetGroup(tools, resources, prompts) + filtered := tsg.ForMCPRequest(MCPMethodResourcesList, "") + + if len(filtered.AvailableTools(context.Background())) != 0 { + t.Errorf("Expected 0 tools for resources/list, got %d", len(filtered.AvailableTools(context.Background()))) + } + if len(filtered.AvailableResourceTemplates(context.Background())) != 2 { + t.Errorf("Expected 2 resources for resources/list, got %d", len(filtered.AvailableResourceTemplates(context.Background()))) + } + if len(filtered.AvailablePrompts(context.Background())) != 0 { + t.Errorf("Expected 0 prompts for resources/list, got %d", len(filtered.AvailablePrompts(context.Background()))) } } -func TestFindToolByName(t *testing.T) { - tsg := NewToolsetGroup(false) +func TestForMCPRequest_ResourcesRead(t *testing.T) { + resources := []ServerResourceTemplate{ + mockResource("res1", "repos", "repo://{owner}/{repo}"), + mockResource("res2", "repos", "branch://{owner}/{repo}/{branch}"), + } - // Create a toolset with a tool - toolset := NewToolset("test-toolset", "Test toolset") - toolset.readTools = append(toolset.readTools, mockTool("issue_read", true)) - tsg.AddToolset(toolset) + tsg := NewToolsetGroup(nil, resources, nil) + filtered := tsg.ForMCPRequest(MCPMethodResourcesRead, "repo://{owner}/{repo}") - // Find by canonical name - tool, toolsetName, err := tsg.FindToolByName("issue_read") - if err != nil { - t.Fatalf("expected no error, got %v", err) + available := filtered.AvailableResourceTemplates(context.Background()) + if len(available) != 1 { + t.Fatalf("Expected 1 resource for resources/read, got %d", len(available)) } - if tool.Tool.Name != "issue_read" { - t.Errorf("expected tool name 'issue_read', got '%s'", tool.Tool.Name) + if available[0].Template.URITemplate != "repo://{owner}/{repo}" { + t.Errorf("Expected URI template 'repo://{owner}/{repo}', got %q", available[0].Template.URITemplate) + } +} + +func TestForMCPRequest_PromptsList(t *testing.T) { + tools := []ServerTool{ + mockTool("tool1", "repos", true), + } + resources := []ServerResourceTemplate{ + mockResource("res1", "repos", "repo://{owner}/{repo}"), } - if toolsetName != "test-toolset" { - t.Errorf("expected toolset name 'test-toolset', got '%s'", toolsetName) + prompts := []ServerPrompt{ + mockPrompt("prompt1", "repos"), + mockPrompt("prompt2", "issues"), } - // FindToolByName does NOT resolve aliases - it expects canonical names - _, _, err = tsg.FindToolByName("get_issue") - if err == nil { - t.Error("expected error when using alias directly with FindToolByName") + tsg := NewToolsetGroup(tools, resources, prompts) + filtered := tsg.ForMCPRequest(MCPMethodPromptsList, "") + + if len(filtered.AvailableTools(context.Background())) != 0 { + t.Errorf("Expected 0 tools for prompts/list, got %d", len(filtered.AvailableTools(context.Background()))) + } + if len(filtered.AvailableResourceTemplates(context.Background())) != 0 { + t.Errorf("Expected 0 resources for prompts/list, got %d", len(filtered.AvailableResourceTemplates(context.Background()))) + } + if len(filtered.AvailablePrompts(context.Background())) != 2 { + t.Errorf("Expected 2 prompts for prompts/list, got %d", len(filtered.AvailablePrompts(context.Background()))) } } -func TestRegisterSpecificTools(t *testing.T) { - tsg := NewToolsetGroup(false) +func TestForMCPRequest_PromptsGet(t *testing.T) { + prompts := []ServerPrompt{ + mockPrompt("prompt1", "repos"), + mockPrompt("prompt2", "issues"), + } + + tsg := NewToolsetGroup(nil, nil, prompts) + filtered := tsg.ForMCPRequest(MCPMethodPromptsGet, "prompt1") - // Create a toolset with both read and write tools - toolset := NewToolset("test-toolset", "Test toolset") - toolset.readTools = append(toolset.readTools, mockTool("issue_read", true)) - toolset.writeTools = append(toolset.writeTools, mockTool("issue_write", false)) - tsg.AddToolset(toolset) + available := filtered.AvailablePrompts(context.Background()) + if len(available) != 1 { + t.Fatalf("Expected 1 prompt for prompts/get, got %d", len(available)) + } + if available[0].Prompt.Name != "prompt1" { + t.Errorf("Expected prompt name 'prompt1', got %q", available[0].Prompt.Name) + } +} - // deps is typed as any in toolsets package (to avoid circular deps) - var deps any +func TestForMCPRequest_UnknownMethod(t *testing.T) { + tools := []ServerTool{ + mockTool("tool1", "repos", true), + } + resources := []ServerResourceTemplate{ + mockResource("res1", "repos", "repo://{owner}/{repo}"), + } + prompts := []ServerPrompt{ + mockPrompt("prompt1", "repos"), + } - // Create a real server for testing - server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "1.0.0"}, nil) + tsg := NewToolsetGroup(tools, resources, prompts) + filtered := tsg.ForMCPRequest("unknown/method", "") - // Test registering with canonical names - err := tsg.RegisterSpecificTools(server, []string{"issue_read"}, false, deps) - if err != nil { - t.Errorf("expected no error registering tool, got %v", err) + // Unknown methods should return empty + if len(filtered.AvailableTools(context.Background())) != 0 { + t.Errorf("Expected 0 tools for unknown method, got %d", len(filtered.AvailableTools(context.Background()))) + } + if len(filtered.AvailableResourceTemplates(context.Background())) != 0 { + t.Errorf("Expected 0 resources for unknown method, got %d", len(filtered.AvailableResourceTemplates(context.Background()))) } + if len(filtered.AvailablePrompts(context.Background())) != 0 { + t.Errorf("Expected 0 prompts for unknown method, got %d", len(filtered.AvailablePrompts(context.Background()))) + } +} - // Test registering write tool in read-only mode (should skip but not error) - err = tsg.RegisterSpecificTools(server, []string{"issue_write"}, true, deps) - if err != nil { - t.Errorf("expected no error when skipping write tool in read-only mode, got %v", err) +func TestForMCPRequest_Immutability(t *testing.T) { + tools := []ServerTool{ + mockTool("tool1", "repos", true), + mockTool("tool2", "issues", true), + } + resources := []ServerResourceTemplate{ + mockResource("res1", "repos", "repo://{owner}/{repo}"), + } + prompts := []ServerPrompt{ + mockPrompt("prompt1", "repos"), } - // Test registering non-existent tool (should error) - err = tsg.RegisterSpecificTools(server, []string{"nonexistent"}, false, deps) - if err == nil { - t.Error("expected error for non-existent tool") + original := NewToolsetGroup(tools, resources, prompts) + filtered := original.ForMCPRequest(MCPMethodToolsCall, "tool1") + + // Original should be unchanged + if len(original.AvailableTools(context.Background())) != 2 { + t.Errorf("Original was mutated! Expected 2 tools, got %d", len(original.AvailableTools(context.Background()))) + } + if len(original.AvailableResourceTemplates(context.Background())) != 1 { + t.Errorf("Original was mutated! Expected 1 resource, got %d", len(original.AvailableResourceTemplates(context.Background()))) + } + if len(original.AvailablePrompts(context.Background())) != 1 { + t.Errorf("Original was mutated! Expected 1 prompt, got %d", len(original.AvailablePrompts(context.Background()))) + } + + // Filtered should have only the requested tool + if len(filtered.AvailableTools(context.Background())) != 1 { + t.Errorf("Expected 1 tool in filtered, got %d", len(filtered.AvailableTools(context.Background()))) + } + if len(filtered.AvailableResourceTemplates(context.Background())) != 0 { + t.Errorf("Expected 0 resources in filtered, got %d", len(filtered.AvailableResourceTemplates(context.Background()))) + } + if len(filtered.AvailablePrompts(context.Background())) != 0 { + t.Errorf("Expected 0 prompts in filtered, got %d", len(filtered.AvailablePrompts(context.Background()))) + } +} + +func TestForMCPRequest_ChainedWithOtherFilters(t *testing.T) { + tools := []ServerTool{ + mockTool("get_me", "context", true), + mockTool("create_issue", "issues", false), + mockTool("list_repos", "repos", true), + mockTool("delete_repo", "repos", false), + } + + tsg := NewToolsetGroup(tools, nil, nil) + tsg.SetDefaultToolsetIDs([]ToolsetID{"context", "repos"}) + + // Chain: default toolsets -> read-only -> specific method + filtered := tsg. + WithToolsets([]string{"default"}). + WithReadOnly(true). + ForMCPRequest(MCPMethodToolsList, "") + + available := filtered.AvailableTools(context.Background()) + + // Should have: get_me (context, read), list_repos (repos, read) + // Should NOT have: create_issue (issues not in default), delete_repo (write) + if len(available) != 2 { + t.Fatalf("Expected 2 tools after filter chain, got %d", len(available)) + } + + toolNames := make(map[string]bool) + for _, tool := range available { + toolNames[tool.Tool.Name] = true + } + + if !toolNames["get_me"] { + t.Error("Expected get_me to be available") + } + if !toolNames["list_repos"] { + t.Error("Expected list_repos to be available") + } + if toolNames["create_issue"] { + t.Error("create_issue should not be available (toolset not enabled)") + } + if toolNames["delete_repo"] { + t.Error("delete_repo should not be available (write tool in read-only mode)") + } +} + +func TestForMCPRequest_ResourcesTemplatesList(t *testing.T) { + tools := []ServerTool{ + mockTool("tool1", "repos", true), + } + resources := []ServerResourceTemplate{ + mockResource("res1", "repos", "repo://{owner}/{repo}"), + } + + tsg := NewToolsetGroup(tools, resources, nil) + filtered := tsg.ForMCPRequest(MCPMethodResourcesTemplatesList, "") + + // Same behavior as resources/list + if len(filtered.AvailableTools(context.Background())) != 0 { + t.Errorf("Expected 0 tools, got %d", len(filtered.AvailableTools(context.Background()))) + } + if len(filtered.AvailableResourceTemplates(context.Background())) != 1 { + t.Errorf("Expected 1 resource, got %d", len(filtered.AvailableResourceTemplates(context.Background()))) + } +} + +func TestMCPMethodConstants(t *testing.T) { + // Verify constants match expected MCP method names + tests := []struct { + constant string + expected string + }{ + {MCPMethodInitialize, "initialize"}, + {MCPMethodToolsList, "tools/list"}, + {MCPMethodToolsCall, "tools/call"}, + {MCPMethodResourcesList, "resources/list"}, + {MCPMethodResourcesRead, "resources/read"}, + {MCPMethodResourcesTemplatesList, "resources/templates/list"}, + {MCPMethodPromptsList, "prompts/list"}, + {MCPMethodPromptsGet, "prompts/get"}, + } + + for _, tt := range tests { + if tt.constant != tt.expected { + t.Errorf("Constant mismatch: got %q, expected %q", tt.constant, tt.expected) + } + } +} + +// mockToolWithFlags creates a ServerTool with feature flags for testing +func mockToolWithFlags(name string, toolsetID string, readOnly bool, enableFlag, disableFlag string) ServerTool { + tool := mockTool(name, toolsetID, readOnly) + tool.FeatureFlagEnable = enableFlag + tool.FeatureFlagDisable = disableFlag + return tool +} + +func TestFeatureFlagEnable(t *testing.T) { + tools := []ServerTool{ + mockTool("always_available", "toolset1", true), + mockToolWithFlags("needs_flag", "toolset1", true, "my_feature", ""), + } + + tsg := NewToolsetGroup(tools, nil, nil) + + // Without feature checker, tool with FeatureFlagEnable should be excluded + available := tsg.AvailableTools(context.Background()) + if len(available) != 1 { + t.Fatalf("Expected 1 tool without feature checker, got %d", len(available)) + } + if available[0].Tool.Name != "always_available" { + t.Errorf("Expected always_available, got %s", available[0].Tool.Name) + } + + // With feature checker returning false, tool should still be excluded + checkerFalse := func(_ context.Context, _ string) (bool, error) { return false, nil } + filteredFalse := tsg.WithFeatureChecker(checkerFalse) + availableFalse := filteredFalse.AvailableTools(context.Background()) + if len(availableFalse) != 1 { + t.Fatalf("Expected 1 tool with false checker, got %d", len(availableFalse)) + } + + // With feature checker returning true for "my_feature", tool should be included + checkerTrue := func(_ context.Context, flag string) (bool, error) { + return flag == "my_feature", nil + } + filteredTrue := tsg.WithFeatureChecker(checkerTrue) + availableTrue := filteredTrue.AvailableTools(context.Background()) + if len(availableTrue) != 2 { + t.Fatalf("Expected 2 tools with true checker, got %d", len(availableTrue)) + } +} + +func TestFeatureFlagDisable(t *testing.T) { + tools := []ServerTool{ + mockTool("always_available", "toolset1", true), + mockToolWithFlags("disabled_by_flag", "toolset1", true, "", "kill_switch"), + } + + tsg := NewToolsetGroup(tools, nil, nil) + + // Without feature checker, tool with FeatureFlagDisable should be included (flag is false) + available := tsg.AvailableTools(context.Background()) + if len(available) != 2 { + t.Fatalf("Expected 2 tools without feature checker, got %d", len(available)) + } + + // With feature checker returning true for "kill_switch", tool should be excluded + checkerTrue := func(_ context.Context, flag string) (bool, error) { + return flag == "kill_switch", nil + } + filtered := tsg.WithFeatureChecker(checkerTrue) + availableFiltered := filtered.AvailableTools(context.Background()) + if len(availableFiltered) != 1 { + t.Fatalf("Expected 1 tool with kill_switch enabled, got %d", len(availableFiltered)) + } + if availableFiltered[0].Tool.Name != "always_available" { + t.Errorf("Expected always_available, got %s", availableFiltered[0].Tool.Name) + } +} + +func TestFeatureFlagBoth(t *testing.T) { + // Tool that requires "new_feature" AND is disabled by "kill_switch" + tools := []ServerTool{ + mockToolWithFlags("complex_tool", "toolset1", true, "new_feature", "kill_switch"), + } + + tsg := NewToolsetGroup(tools, nil, nil) + + // Enable flag not set -> excluded + checker1 := func(_ context.Context, _ string) (bool, error) { return false, nil } + if len(tsg.WithFeatureChecker(checker1).AvailableTools(context.Background())) != 0 { + t.Error("Tool should be excluded when enable flag is false") + } + + // Enable flag set, disable flag not set -> included + checker2 := func(_ context.Context, flag string) (bool, error) { return flag == "new_feature", nil } + if len(tsg.WithFeatureChecker(checker2).AvailableTools(context.Background())) != 1 { + t.Error("Tool should be included when enable flag is true and disable flag is false") + } + + // Enable flag set, disable flag also set -> excluded (disable wins) + checker3 := func(_ context.Context, _ string) (bool, error) { return true, nil } + if len(tsg.WithFeatureChecker(checker3).AvailableTools(context.Background())) != 0 { + t.Error("Tool should be excluded when both flags are true (disable wins)") + } +} + +func TestFeatureFlagError(t *testing.T) { + tools := []ServerTool{ + mockToolWithFlags("needs_flag", "toolset1", true, "my_feature", ""), + } + + tsg := NewToolsetGroup(tools, nil, nil) + + // Checker that returns error should treat as false (tool excluded) + checkerError := func(_ context.Context, _ string) (bool, error) { + return false, fmt.Errorf("simulated error") + } + filtered := tsg.WithFeatureChecker(checkerError) + available := filtered.AvailableTools(context.Background()) + if len(available) != 0 { + t.Errorf("Expected 0 tools when checker errors, got %d", len(available)) + } +} + +func TestFeatureFlagResources(t *testing.T) { + resources := []ServerResourceTemplate{ + mockResource("always_available", "toolset1", "uri1"), + { + Template: mcp.ResourceTemplate{Name: "needs_flag", URITemplate: "uri2"}, + Toolset: testToolsetMetadata("toolset1"), + FeatureFlagEnable: "my_feature", + }, + } + + tsg := NewToolsetGroup(nil, resources, nil) + + // Without checker, resource with enable flag should be excluded + available := tsg.AvailableResourceTemplates(context.Background()) + if len(available) != 1 { + t.Fatalf("Expected 1 resource without checker, got %d", len(available)) + } + + // With checker returning true, both should be included + checker := func(_ context.Context, _ string) (bool, error) { return true, nil } + filtered := tsg.WithFeatureChecker(checker) + if len(filtered.AvailableResourceTemplates(context.Background())) != 2 { + t.Errorf("Expected 2 resources with checker, got %d", len(filtered.AvailableResourceTemplates(context.Background()))) + } +} + +func TestFeatureFlagPrompts(t *testing.T) { + prompts := []ServerPrompt{ + mockPrompt("always_available", "toolset1"), + { + Prompt: mcp.Prompt{Name: "needs_flag"}, + Toolset: testToolsetMetadata("toolset1"), + FeatureFlagEnable: "my_feature", + }, + } + + tsg := NewToolsetGroup(nil, nil, prompts) + + // Without checker, prompt with enable flag should be excluded + available := tsg.AvailablePrompts(context.Background()) + if len(available) != 1 { + t.Fatalf("Expected 1 prompt without checker, got %d", len(available)) + } + + // With checker returning true, both should be included + checker := func(_ context.Context, _ string) (bool, error) { return true, nil } + filtered := tsg.WithFeatureChecker(checker) + if len(filtered.AvailablePrompts(context.Background())) != 2 { + t.Errorf("Expected 2 prompts with checker, got %d", len(filtered.AvailablePrompts(context.Background()))) } } From 7a78ec6f72e8e0c9def6c7699a79230d7d0a0a34 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Sun, 14 Dec 2025 00:05:26 +0100 Subject: [PATCH 02/10] Add validation tests for tools, resources, and prompts metadata This commit adds comprehensive validation tests to ensure all MCP items have required metadata: - TestAllToolsHaveRequiredMetadata: Validates Toolset.ID and Annotations - TestAllToolsHaveValidToolsetID: Ensures toolsets are in AvailableToolsets() - TestAllResourcesHaveRequiredMetadata: Validates resource metadata - TestAllPromptsHaveRequiredMetadata: Validates prompt metadata - TestToolReadOnlyHintConsistency: Validates IsReadOnly() matches annotation - TestNoDuplicate*Names: Ensures unique names across tools/resources/prompts - TestAllToolsHaveHandlerFunc: Ensures all tools have handlers - TestDefaultToolsetsAreValid: Validates default toolset IDs - TestToolsetMetadataConsistency: Ensures consistent descriptions per toolset Also fixes a bug discovered by these tests: ToolsetMetadataGit was defined but not added to AvailableToolsets(), causing get_repository_tree to have an invalid toolset ID. --- pkg/github/tools.go | 1 + pkg/github/tools_validation_test.go | 219 ++++++++++++++++++++++++++++ 2 files changed, 220 insertions(+) create mode 100644 pkg/github/tools_validation_test.go diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 1fe23dfd2..f39ae43c1 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -111,6 +111,7 @@ func AvailableToolsets() []toolsets.ToolsetMetadata { return []toolsets.ToolsetMetadata{ ToolsetMetadataContext, ToolsetMetadataRepos, + ToolsetMetadataGit, ToolsetMetadataIssues, ToolsetMetadataPullRequests, ToolsetMetadataUsers, diff --git a/pkg/github/tools_validation_test.go b/pkg/github/tools_validation_test.go new file mode 100644 index 000000000..e8ce93f67 --- /dev/null +++ b/pkg/github/tools_validation_test.go @@ -0,0 +1,219 @@ +package github + +import ( + "testing" + + "github.com/github/github-mcp-server/pkg/toolsets" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stubTranslation is a simple translation function for testing +func stubTranslation(_, fallback string) string { + return fallback +} + +// TestAllToolsHaveRequiredMetadata validates that all tools have mandatory metadata: +// - Toolset must be set (non-empty ID) +// - ReadOnlyHint annotation must be explicitly set (not nil) +func TestAllToolsHaveRequiredMetadata(t *testing.T) { + tools := AllTools(stubTranslation) + + require.NotEmpty(t, tools, "AllTools should return at least one tool") + + for _, tool := range tools { + t.Run(tool.Tool.Name, func(t *testing.T) { + // Toolset ID must be set + assert.NotEmpty(t, tool.Toolset.ID, + "Tool %q must have a Toolset.ID", tool.Tool.Name) + + // Toolset description should be set for documentation + assert.NotEmpty(t, tool.Toolset.Description, + "Tool %q should have a Toolset.Description", tool.Tool.Name) + + // Annotations must exist and have ReadOnlyHint explicitly set + require.NotNil(t, tool.Tool.Annotations, + "Tool %q must have Annotations set (for ReadOnlyHint)", tool.Tool.Name) + + // We can't distinguish between "not set" and "set to false" for a bool, + // but having Annotations non-nil confirms the developer thought about it. + // The ReadOnlyHint value itself is validated by ensuring Annotations exist. + }) + } +} + +// TestAllToolsHaveValidToolsetID validates that all tools belong to known toolsets +func TestAllToolsHaveValidToolsetID(t *testing.T) { + tools := AllTools(stubTranslation) + validToolsetIDs := GetValidToolsetIDs() + + for _, tool := range tools { + t.Run(tool.Tool.Name, func(t *testing.T) { + assert.True(t, validToolsetIDs[tool.Toolset.ID], + "Tool %q has invalid Toolset.ID %q - must be one of the defined toolsets", + tool.Tool.Name, tool.Toolset.ID) + }) + } +} + +// TestAllResourcesHaveRequiredMetadata validates that all resources have mandatory metadata +func TestAllResourcesHaveRequiredMetadata(t *testing.T) { + // Resources need client functions, but we can pass nil for validation + // since we're not actually calling handlers + resources := AllResources(stubTranslation, nil, nil) + + require.NotEmpty(t, resources, "AllResources should return at least one resource") + + for _, res := range resources { + t.Run(res.Template.Name, func(t *testing.T) { + // Toolset ID must be set + assert.NotEmpty(t, res.Toolset.ID, + "Resource %q must have a Toolset.ID", res.Template.Name) + + // Handler must be set + assert.NotNil(t, res.Handler, + "Resource %q must have a Handler", res.Template.Name) + }) + } +} + +// TestAllPromptsHaveRequiredMetadata validates that all prompts have mandatory metadata +func TestAllPromptsHaveRequiredMetadata(t *testing.T) { + prompts := AllPrompts(stubTranslation) + + require.NotEmpty(t, prompts, "AllPrompts should return at least one prompt") + + for _, prompt := range prompts { + t.Run(prompt.Prompt.Name, func(t *testing.T) { + // Toolset ID must be set + assert.NotEmpty(t, prompt.Toolset.ID, + "Prompt %q must have a Toolset.ID", prompt.Prompt.Name) + + // Handler must be set + assert.NotNil(t, prompt.Handler, + "Prompt %q must have a Handler", prompt.Prompt.Name) + }) + } +} + +// TestAllResourcesHaveValidToolsetID validates that all resources belong to known toolsets +func TestAllResourcesHaveValidToolsetID(t *testing.T) { + resources := AllResources(stubTranslation, nil, nil) + validToolsetIDs := GetValidToolsetIDs() + + for _, res := range resources { + t.Run(res.Template.Name, func(t *testing.T) { + assert.True(t, validToolsetIDs[res.Toolset.ID], + "Resource %q has invalid Toolset.ID %q", res.Template.Name, res.Toolset.ID) + }) + } +} + +// TestAllPromptsHaveValidToolsetID validates that all prompts belong to known toolsets +func TestAllPromptsHaveValidToolsetID(t *testing.T) { + prompts := AllPrompts(stubTranslation) + validToolsetIDs := GetValidToolsetIDs() + + for _, prompt := range prompts { + t.Run(prompt.Prompt.Name, func(t *testing.T) { + assert.True(t, validToolsetIDs[prompt.Toolset.ID], + "Prompt %q has invalid Toolset.ID %q", prompt.Prompt.Name, prompt.Toolset.ID) + }) + } +} + +// TestToolReadOnlyHintConsistency validates that read-only tools are correctly annotated +func TestToolReadOnlyHintConsistency(t *testing.T) { + tools := AllTools(stubTranslation) + + for _, tool := range tools { + t.Run(tool.Tool.Name, func(t *testing.T) { + require.NotNil(t, tool.Tool.Annotations, + "Tool %q must have Annotations", tool.Tool.Name) + + // Verify IsReadOnly() method matches the annotation + assert.Equal(t, tool.Tool.Annotations.ReadOnlyHint, tool.IsReadOnly(), + "Tool %q: IsReadOnly() should match Annotations.ReadOnlyHint", tool.Tool.Name) + }) + } +} + +// TestNoDuplicateToolNames ensures all tools have unique names +func TestNoDuplicateToolNames(t *testing.T) { + tools := AllTools(stubTranslation) + seen := make(map[string]bool) + + for _, tool := range tools { + name := tool.Tool.Name + assert.False(t, seen[name], + "Duplicate tool name found: %q", name) + seen[name] = true + } +} + +// TestNoDuplicateResourceNames ensures all resources have unique names +func TestNoDuplicateResourceNames(t *testing.T) { + resources := AllResources(stubTranslation, nil, nil) + seen := make(map[string]bool) + + for _, res := range resources { + name := res.Template.Name + assert.False(t, seen[name], + "Duplicate resource name found: %q", name) + seen[name] = true + } +} + +// TestNoDuplicatePromptNames ensures all prompts have unique names +func TestNoDuplicatePromptNames(t *testing.T) { + prompts := AllPrompts(stubTranslation) + seen := make(map[string]bool) + + for _, prompt := range prompts { + name := prompt.Prompt.Name + assert.False(t, seen[name], + "Duplicate prompt name found: %q", name) + seen[name] = true + } +} + +// TestAllToolsHaveHandlerFunc ensures all tools have a handler function +func TestAllToolsHaveHandlerFunc(t *testing.T) { + tools := AllTools(stubTranslation) + + for _, tool := range tools { + t.Run(tool.Tool.Name, func(t *testing.T) { + assert.NotNil(t, tool.HandlerFunc, + "Tool %q must have a HandlerFunc", tool.Tool.Name) + }) + } +} + +// TestDefaultToolsetsAreValid ensures default toolset IDs are all valid +func TestDefaultToolsetsAreValid(t *testing.T) { + defaults := GetDefaultToolsetIDs() + valid := GetValidToolsetIDs() + + for _, id := range defaults { + assert.True(t, valid[id], + "Default toolset ID %q is not in the valid toolset list", id) + } +} + +// TestToolsetMetadataConsistency ensures tools in the same toolset have consistent descriptions +func TestToolsetMetadataConsistency(t *testing.T) { + tools := AllTools(stubTranslation) + toolsetDescriptions := make(map[toolsets.ToolsetID]string) + + for _, tool := range tools { + id := tool.Toolset.ID + desc := tool.Toolset.Description + + if existing, ok := toolsetDescriptions[id]; ok { + assert.Equal(t, existing, desc, + "Toolset %q has inconsistent descriptions across tools", id) + } else { + toolsetDescriptions[id] = desc + } + } +} From 689a040fe38ab9a82181268db1ae6c37ce10065c Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Sun, 14 Dec 2025 00:09:45 +0100 Subject: [PATCH 03/10] Fix default toolsets behavior when not in dynamic mode When no toolsets are specified and dynamic mode is disabled, the server should use the default toolsets. The bug was introduced when adding dynamic toolsets support: 1. CleanToolsets(nil) was converting nil to empty slice 2. Empty slice passed to WithToolsets means 'no toolsets' 3. This resulted in zero tools being registered Fix: Preserve nil for non-dynamic mode (nil = use defaults in WithToolsets) and only set empty slice when dynamic mode is enabled without explicit toolsets. --- cmd/github-mcp-server/main.go | 10 ++++++++-- internal/ghmcp/server.go | 32 +++++++++++++++++--------------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/cmd/github-mcp-server/main.go b/cmd/github-mcp-server/main.go index 84c974dad..034b0e238 100644 --- a/cmd/github-mcp-server/main.go +++ b/cmd/github-mcp-server/main.go @@ -41,10 +41,16 @@ var ( // it's because viper doesn't handle comma-separated values correctly for env // vars when using GetStringSlice. // https://github.com/spf13/viper/issues/380 + // + // Additionally, viper.UnmarshalKey returns an empty slice even when the flag + // is not set, but we need nil to indicate "use defaults". So we check IsSet first. var enabledToolsets []string - if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil { - return fmt.Errorf("failed to unmarshal toolsets: %w", err) + if viper.IsSet("toolsets") { + if err := viper.UnmarshalKey("toolsets", &enabledToolsets); err != nil { + return fmt.Errorf("failed to unmarshal toolsets: %w", err) + } } + // else: enabledToolsets stays nil, meaning "use defaults" // Parse tools (similar to toolsets) var enabledTools []string diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 0edca88ed..99898f8b1 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -103,14 +103,24 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { repoAccessCache = lockdown.GetInstance(gqlClient, repoAccessOpts...) } - enabledToolsets := cfg.EnabledToolsets - - // Clean up the passed toolsets (removes duplicates, whitespace) - enabledToolsets, invalidToolsets := github.CleanToolsets(enabledToolsets) - - if len(invalidToolsets) > 0 { - fmt.Fprintf(os.Stderr, "Invalid toolsets ignored: %s\n", strings.Join(invalidToolsets, ", ")) + // Determine enabled toolsets based on configuration: + // - nil means "use defaults" (unless dynamic mode without explicit toolsets) + // - empty slice means "no toolsets" (for dynamic mode to enable on demand) + // - explicit list means "use these toolsets" + var enabledToolsets []string + if cfg.EnabledToolsets != nil { + // Clean up explicitly passed toolsets (removes duplicates, whitespace) + var invalidToolsets []string + enabledToolsets, invalidToolsets = github.CleanToolsets(cfg.EnabledToolsets) + if len(invalidToolsets) > 0 { + fmt.Fprintf(os.Stderr, "Invalid toolsets ignored: %s\n", strings.Join(invalidToolsets, ", ")) + } + } else if cfg.DynamicToolsets { + // Dynamic mode with no toolsets specified: start with no toolsets enabled + // so users can enable them on demand via the dynamic tools + enabledToolsets = []string{} } + // else: enabledToolsets stays nil, which means "use defaults" in WithToolsets // Generate instructions based on enabled toolsets instructions := github.GenerateInstructions(enabledToolsets) @@ -162,14 +172,6 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { // Clean tool names (WithTools will resolve any deprecated aliases) enabledTools := github.CleanTools(cfg.EnabledTools) - // For dynamic toolsets mode: - // - If toolsets are explicitly provided (including "default"), honor them - // - If no toolsets are specified (nil), start with no toolsets enabled (empty slice) - // so users can enable them on demand via the dynamic tools - if cfg.DynamicToolsets && cfg.EnabledToolsets == nil { - enabledToolsets = []string{} - } - // Apply filters based on configuration // - WithReadOnly: filters out write tools when true // - WithToolsets: nil=defaults, empty=none, handles "all"/"default" keywords From a03b3bf0bfbd4338c4280ad09150795e66cddf08 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Sun, 14 Dec 2025 12:43:04 +0100 Subject: [PATCH 04/10] refactor: address PR review feedback for toolsets - Rename AddDeprecatedToolAliases to WithDeprecatedToolAliases for immutable filter chain consistency (returns new ToolsetGroup) - Remove unused mockGetRawClient from generate_docs.go (use nil instead) - Remove legacy ServerTool functions (NewServerToolLegacy and NewServerToolFromHandlerLegacy) - no usages - Add panic in Handler()/RegisterFunc() when HandlerFunc is nil - Add HasHandler() method for checking if tool has a handler - Add tests for HasHandler and nil handler panic behavior - Update all tests to use new WithDeprecatedToolAliases pattern --- cmd/github-mcp-server/generate_docs.go | 10 +--- internal/ghmcp/server.go | 6 +-- pkg/github/tools_validation_test.go | 2 + pkg/toolsets/server_tool.go | 44 ++++------------ pkg/toolsets/toolsets.go | 15 ++++-- pkg/toolsets/toolsets_test.go | 73 +++++++++++++++++++------- 6 files changed, 81 insertions(+), 69 deletions(-) diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index 785bd3ff0..dd3e183cb 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -10,7 +10,6 @@ import ( "strings" "github.com/github/github-mcp-server/pkg/github" - "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/toolsets" "github.com/github/github-mcp-server/pkg/translations" gogithub "github.com/google/go-github/v79/github" @@ -37,11 +36,6 @@ func mockGetClient(_ context.Context) (*gogithub.Client, error) { return gogithub.NewClient(nil), nil } -// mockGetRawClient returns a mock raw client for documentation generation -func mockGetRawClient(_ context.Context) (*raw.Client, error) { - return nil, nil -} - func generateAllDocs() error { if err := generateReadmeDocs("README.md"); err != nil { return fmt.Errorf("failed to generate README docs: %w", err) @@ -63,7 +57,7 @@ func generateReadmeDocs(readmePath string) error { t, _ := translations.TranslationHelper() // Create toolset group with mock clients (no deps needed for doc generation) - tsg := github.NewToolsetGroup(t, mockGetClient, mockGetRawClient) + tsg := github.NewToolsetGroup(t, mockGetClient, nil) // Generate toolsets documentation toolsetsDoc := generateToolsetsDoc(tsg) @@ -306,7 +300,7 @@ func generateRemoteToolsetsDoc() string { t, _ := translations.TranslationHelper() // Create toolset group with mock clients - tsg := github.NewToolsetGroup(t, mockGetClient, mockGetRawClient) + tsg := github.NewToolsetGroup(t, mockGetClient, nil) // Generate table header buf.WriteString("| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |\n") diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 99898f8b1..54f10290d 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -165,19 +165,17 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { // Create toolset group with all tools, resources, and prompts tsg := github.NewToolsetGroup(cfg.Translator, getClient, getRawClient) - // Add deprecated tool aliases for backward compatibility - // See docs/deprecated-tool-aliases.md for the full list of renames - tsg.AddDeprecatedToolAliases(github.DeprecatedToolAliases) - // Clean tool names (WithTools will resolve any deprecated aliases) enabledTools := github.CleanTools(cfg.EnabledTools) // Apply filters based on configuration + // - WithDeprecatedToolAliases: adds backward compatibility aliases // - WithReadOnly: filters out write tools when true // - WithToolsets: nil=defaults, empty=none, handles "all"/"default" keywords // - WithTools: additional tools that bypass toolset filtering (additive, resolves aliases) // - WithFeatureChecker: filters based on feature flags filteredTsg := tsg. + WithDeprecatedToolAliases(github.DeprecatedToolAliases). WithReadOnly(cfg.ReadOnly). WithToolsets(enabledToolsets). WithTools(enabledTools). diff --git a/pkg/github/tools_validation_test.go b/pkg/github/tools_validation_test.go index e8ce93f67..3b956326a 100644 --- a/pkg/github/tools_validation_test.go +++ b/pkg/github/tools_validation_test.go @@ -185,6 +185,8 @@ func TestAllToolsHaveHandlerFunc(t *testing.T) { t.Run(tool.Tool.Name, func(t *testing.T) { assert.NotNil(t, tool.HandlerFunc, "Tool %q must have a HandlerFunc", tool.Tool.Name) + assert.True(t, tool.HasHandler(), + "Tool %q HasHandler() should return true", tool.Tool.Name) }) } } diff --git a/pkg/toolsets/server_tool.go b/pkg/toolsets/server_tool.go index 334492c74..0e782e631 100644 --- a/pkg/toolsets/server_tool.go +++ b/pkg/toolsets/server_tool.go @@ -57,17 +57,24 @@ func (st *ServerTool) IsReadOnly() bool { return st.Tool.Annotations != nil && st.Tool.Annotations.ReadOnlyHint } +// HasHandler returns true if this tool has a handler function. +func (st *ServerTool) HasHandler() bool { + return st.HandlerFunc != nil +} + // Handler returns a tool handler by calling HandlerFunc with the given dependencies. +// Panics if HandlerFunc is nil - all tools should have handlers. func (st *ServerTool) Handler(deps any) mcp.ToolHandler { if st.HandlerFunc == nil { - return nil + panic("HandlerFunc is nil for tool: " + st.Tool.Name) } return st.HandlerFunc(deps) } // RegisterFunc registers the tool with the server using the provided dependencies. +// Panics if the tool has no handler - all tools should have handlers. func (st *ServerTool) RegisterFunc(s *mcp.Server, deps any) { - handler := st.Handler(deps) + handler := st.Handler(deps) // This will panic if HandlerFunc is nil s.AddTool(&st.Tool, handler) } @@ -97,36 +104,3 @@ func NewServerTool[In any, Out any](tool mcp.Tool, toolset ToolsetMetadata, hand func NewServerToolFromHandler(tool mcp.Tool, toolset ToolsetMetadata, handlerFn func(deps any) mcp.ToolHandler) ServerTool { return ServerTool{Tool: tool, Toolset: toolset, HandlerFunc: handlerFn} } - -// NewServerToolLegacy creates a ServerTool from a tool definition, toolset metadata, and an already-bound typed handler. -// This is for backward compatibility during the refactor - the handler doesn't use dependencies. -// Deprecated: Use NewServerTool instead for new code. -func NewServerToolLegacy[In any, Out any](tool mcp.Tool, toolset ToolsetMetadata, handler mcp.ToolHandlerFor[In, Out]) ServerTool { - return ServerTool{ - Tool: tool, - Toolset: toolset, - HandlerFunc: func(_ any) mcp.ToolHandler { - return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { - var arguments In - if err := json.Unmarshal(req.Params.Arguments, &arguments); err != nil { - return nil, err - } - resp, _, err := handler(ctx, req, arguments) - return resp, err - } - }, - } -} - -// NewServerToolFromHandlerLegacy creates a ServerTool from a tool definition, toolset metadata, and an already-bound raw handler. -// This is for backward compatibility during the refactor - the handler doesn't use dependencies. -// Deprecated: Use NewServerToolFromHandler instead for new code. -func NewServerToolFromHandlerLegacy(tool mcp.Tool, toolset ToolsetMetadata, handler mcp.ToolHandler) ServerTool { - return ServerTool{ - Tool: tool, - Toolset: toolset, - HandlerFunc: func(_ any) mcp.ToolHandler { - return handler - }, - } -} diff --git a/pkg/toolsets/toolsets.go b/pkg/toolsets/toolsets.go index e42a9bcae..5b1df37ff 100644 --- a/pkg/toolsets/toolsets.go +++ b/pkg/toolsets/toolsets.go @@ -413,12 +413,19 @@ func (tg *ToolsetGroup) filterPromptsByName(name string) []ServerPrompt { return []ServerPrompt{} } -// AddDeprecatedToolAliases adds mappings from old tool names to new canonical names. -func (tg *ToolsetGroup) AddDeprecatedToolAliases(aliases map[string]string) *ToolsetGroup { +// WithDeprecatedToolAliases returns a new ToolsetGroup with the given deprecated aliases added. +// Aliases map old tool names to new canonical names. +func (tg *ToolsetGroup) WithDeprecatedToolAliases(aliases map[string]string) *ToolsetGroup { + newTG := tg.copy() + // Ensure we have a fresh map + newTG.deprecatedAliases = make(map[string]string, len(tg.deprecatedAliases)+len(aliases)) + for k, v := range tg.deprecatedAliases { + newTG.deprecatedAliases[k] = v + } for oldName, newName := range aliases { - tg.deprecatedAliases[oldName] = newName + newTG.deprecatedAliases[oldName] = newName } - return tg + return newTG } // isToolsetEnabled checks if a toolset is enabled based on current filters. diff --git a/pkg/toolsets/toolsets_test.go b/pkg/toolsets/toolsets_test.go index 7bec55848..34d9ba753 100644 --- a/pkg/toolsets/toolsets_test.go +++ b/pkg/toolsets/toolsets_test.go @@ -252,22 +252,27 @@ func TestToolsForToolset(t *testing.T) { } } -func TestAddDeprecatedToolAliases(t *testing.T) { +func TestWithDeprecatedToolAliases(t *testing.T) { tools := []ServerTool{ mockTool("new_name", "toolset1", true), } tsg := NewToolsetGroup(tools, nil, nil) - tsg.AddDeprecatedToolAliases(map[string]string{ + tsgWithAliases := tsg.WithDeprecatedToolAliases(map[string]string{ "old_name": "new_name", "get_issue": "issue_read", }) - if len(tsg.deprecatedAliases) != 2 { - t.Errorf("expected 2 aliases, got %d", len(tsg.deprecatedAliases)) + // Original should be unchanged (immutable) + if len(tsg.deprecatedAliases) != 0 { + t.Errorf("original should have 0 aliases, got %d", len(tsg.deprecatedAliases)) } - if tsg.deprecatedAliases["old_name"] != "new_name" { - t.Errorf("expected alias 'old_name' -> 'new_name', got '%s'", tsg.deprecatedAliases["old_name"]) + + if len(tsgWithAliases.deprecatedAliases) != 2 { + t.Errorf("expected 2 aliases, got %d", len(tsgWithAliases.deprecatedAliases)) + } + if tsgWithAliases.deprecatedAliases["old_name"] != "new_name" { + t.Errorf("expected alias 'old_name' -> 'new_name', got '%s'", tsgWithAliases.deprecatedAliases["old_name"]) } } @@ -277,10 +282,10 @@ func TestResolveToolAliases(t *testing.T) { mockTool("some_tool", "toolset1", true), } - tsg := NewToolsetGroup(tools, nil, nil) - tsg.AddDeprecatedToolAliases(map[string]string{ - "get_issue": "issue_read", - }) + tsg := NewToolsetGroup(tools, nil, nil). + WithDeprecatedToolAliases(map[string]string{ + "get_issue": "issue_read", + }) // Test resolving a mix of aliases and canonical names input := []string{"get_issue", "some_tool"} @@ -384,10 +389,10 @@ func TestWithToolsResolvesAliases(t *testing.T) { mockTool("issue_read", "toolset1", true), } - tsg := NewToolsetGroup(tools, nil, nil) - tsg.AddDeprecatedToolAliases(map[string]string{ - "get_issue": "issue_read", - }) + tsg := NewToolsetGroup(tools, nil, nil). + WithDeprecatedToolAliases(map[string]string{ + "get_issue": "issue_read", + }) // Using deprecated alias should resolve to canonical name filtered := tsg.WithToolsets([]string{}).WithTools([]string{"get_issue"}) @@ -593,10 +598,10 @@ func TestForMCPRequest_ToolsCall_DeprecatedAlias(t *testing.T) { mockTool("list_commits", "repos", true), } - tsg := NewToolsetGroup(tools, nil, nil) - tsg.AddDeprecatedToolAliases(map[string]string{ - "old_get_me": "get_me", - }) + tsg := NewToolsetGroup(tools, nil, nil). + WithDeprecatedToolAliases(map[string]string{ + "old_get_me": "get_me", + }) // Request using the deprecated alias filtered := tsg.ForMCPRequest(MCPMethodToolsCall, "old_get_me") @@ -1033,3 +1038,35 @@ func TestFeatureFlagPrompts(t *testing.T) { t.Errorf("Expected 2 prompts with checker, got %d", len(filtered.AvailablePrompts(context.Background()))) } } + +func TestServerToolHasHandler(t *testing.T) { + // Tool with handler + toolWithHandler := mockTool("has_handler", "toolset1", true) + if !toolWithHandler.HasHandler() { + t.Error("Expected HasHandler() to return true for tool with handler") + } + + // Tool without handler + toolWithoutHandler := ServerTool{ + Tool: mcp.Tool{Name: "no_handler"}, + Toolset: testToolsetMetadata("toolset1"), + } + if toolWithoutHandler.HasHandler() { + t.Error("Expected HasHandler() to return false for tool without handler") + } +} + +func TestServerToolHandlerPanicOnNil(t *testing.T) { + tool := ServerTool{ + Tool: mcp.Tool{Name: "no_handler"}, + Toolset: testToolsetMetadata("toolset1"), + } + + defer func() { + if r := recover(); r == nil { + t.Error("Expected Handler() to panic when HandlerFunc is nil") + } + }() + + tool.Handler(nil) +} From 8b850d00f2d966d7a2be0a84402115d2a42e0474 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Sun, 14 Dec 2025 19:06:00 +0100 Subject: [PATCH 05/10] refactor: Apply HandlerFunc pattern to resources for stateless NewToolsetGroup This change applies the same HandlerFunc pattern used by tools to resources, allowing NewToolsetGroup to be fully stateless (only requiring translations). Key changes: - Add ResourceHandlerFunc type to toolsets package - Update ServerResourceTemplate to use HandlerFunc instead of direct Handler - Add HasHandler() and Handler(deps) methods to ServerResourceTemplate - Update RegisterResourceTemplates to take deps parameter - Refactor repository resource definitions to use HandlerFunc pattern - Make AllResources(t) stateless (only takes translations) - Make NewToolsetGroup(t) stateless (only takes translations) - Update generate_docs.go - no longer needs mock clients - Update tests to use new patterns This resolves the concern about mixed concerns in doc generation - the toolset metadata and resource templates can now be created without any runtime dependencies, while handlers are generated on-demand when deps are provided during registration. --- cmd/github-mcp-server/generate_docs.go | 15 +++------ internal/ghmcp/server.go | 4 +-- pkg/github/repository_resource.go | 38 +++++++++++++--------- pkg/github/repository_resource_test.go | 45 +++++++++++++------------- pkg/github/resources.go | 15 ++++----- pkg/github/tools_validation_test.go | 15 ++++----- pkg/github/toolset_group.go | 9 +++--- pkg/toolsets/toolsets.go | 38 +++++++++++++++++----- pkg/toolsets/toolsets_test.go | 6 ++-- 9 files changed, 104 insertions(+), 81 deletions(-) diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index dd3e183cb..1e3f3252b 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -1,7 +1,6 @@ package main import ( - "context" "fmt" "net/url" "os" @@ -12,7 +11,6 @@ import ( "github.com/github/github-mcp-server/pkg/github" "github.com/github/github-mcp-server/pkg/toolsets" "github.com/github/github-mcp-server/pkg/translations" - gogithub "github.com/google/go-github/v79/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/spf13/cobra" @@ -31,11 +29,6 @@ func init() { rootCmd.AddCommand(generateDocsCmd) } -// mockGetClient returns a mock GitHub client for documentation generation -func mockGetClient(_ context.Context) (*gogithub.Client, error) { - return gogithub.NewClient(nil), nil -} - func generateAllDocs() error { if err := generateReadmeDocs("README.md"); err != nil { return fmt.Errorf("failed to generate README docs: %w", err) @@ -56,8 +49,8 @@ func generateReadmeDocs(readmePath string) error { // Create translation helper t, _ := translations.TranslationHelper() - // Create toolset group with mock clients (no deps needed for doc generation) - tsg := github.NewToolsetGroup(t, mockGetClient, nil) + // Create toolset group - stateless, no dependencies needed for doc generation + tsg := github.NewToolsetGroup(t) // Generate toolsets documentation toolsetsDoc := generateToolsetsDoc(tsg) @@ -299,8 +292,8 @@ func generateRemoteToolsetsDoc() string { // Create translation helper t, _ := translations.TranslationHelper() - // Create toolset group with mock clients - tsg := github.NewToolsetGroup(t, mockGetClient, nil) + // Create toolset group - stateless + tsg := github.NewToolsetGroup(t) // Generate table header buf.WriteString("| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |\n") diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 54f10290d..1dec59381 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -162,8 +162,8 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { ContentWindowSize: cfg.ContentWindowSize, } - // Create toolset group with all tools, resources, and prompts - tsg := github.NewToolsetGroup(cfg.Translator, getClient, getRawClient) + // Create toolset group with all tools, resources, and prompts (stateless) + tsg := github.NewToolsetGroup(cfg.Translator) // Clean tool names (WithTools will resolve any deprecated aliases) enabledTools := github.CleanTools(cfg.EnabledTools) diff --git a/pkg/github/repository_resource.go b/pkg/github/repository_resource.go index d8fd13963..6dbbe90ec 100644 --- a/pkg/github/repository_resource.go +++ b/pkg/github/repository_resource.go @@ -29,8 +29,8 @@ var ( repositoryResourcePrContentURITemplate = uritemplate.MustNew("repo://{owner}/{repo}/refs/pull/{prNumber}/head/contents{/path*}") ) -// GetRepositoryResourceContent defines the resource template and handler for getting repository content. -func GetRepositoryResourceContent(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) toolsets.ServerResourceTemplate { +// GetRepositoryResourceContent defines the resource template for getting repository content. +func GetRepositoryResourceContent(t translations.TranslationHelperFunc) toolsets.ServerResourceTemplate { return toolsets.NewServerResourceTemplate( ToolsetMetadataRepos, mcp.ResourceTemplate{ @@ -38,12 +38,12 @@ func GetRepositoryResourceContent(getClient GetClientFn, getRawClient raw.GetRaw URITemplate: repositoryResourceContentURITemplate.Raw(), Description: t("RESOURCE_REPOSITORY_CONTENT_DESCRIPTION", "Repository Content"), }, - RepositoryResourceContentsHandler(getClient, getRawClient, repositoryResourceContentURITemplate), + repositoryResourceContentsHandlerFunc(repositoryResourceContentURITemplate), ) } -// GetRepositoryResourceBranchContent defines the resource template and handler for getting repository content for a branch. -func GetRepositoryResourceBranchContent(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) toolsets.ServerResourceTemplate { +// GetRepositoryResourceBranchContent defines the resource template for getting repository content for a branch. +func GetRepositoryResourceBranchContent(t translations.TranslationHelperFunc) toolsets.ServerResourceTemplate { return toolsets.NewServerResourceTemplate( ToolsetMetadataRepos, mcp.ResourceTemplate{ @@ -51,12 +51,12 @@ func GetRepositoryResourceBranchContent(getClient GetClientFn, getRawClient raw. URITemplate: repositoryResourceBranchContentURITemplate.Raw(), Description: t("RESOURCE_REPOSITORY_CONTENT_BRANCH_DESCRIPTION", "Repository Content for specific branch"), }, - RepositoryResourceContentsHandler(getClient, getRawClient, repositoryResourceBranchContentURITemplate), + repositoryResourceContentsHandlerFunc(repositoryResourceBranchContentURITemplate), ) } -// GetRepositoryResourceCommitContent defines the resource template and handler for getting repository content for a commit. -func GetRepositoryResourceCommitContent(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) toolsets.ServerResourceTemplate { +// GetRepositoryResourceCommitContent defines the resource template for getting repository content for a commit. +func GetRepositoryResourceCommitContent(t translations.TranslationHelperFunc) toolsets.ServerResourceTemplate { return toolsets.NewServerResourceTemplate( ToolsetMetadataRepos, mcp.ResourceTemplate{ @@ -64,12 +64,12 @@ func GetRepositoryResourceCommitContent(getClient GetClientFn, getRawClient raw. URITemplate: repositoryResourceCommitContentURITemplate.Raw(), Description: t("RESOURCE_REPOSITORY_CONTENT_COMMIT_DESCRIPTION", "Repository Content for specific commit"), }, - RepositoryResourceContentsHandler(getClient, getRawClient, repositoryResourceCommitContentURITemplate), + repositoryResourceContentsHandlerFunc(repositoryResourceCommitContentURITemplate), ) } -// GetRepositoryResourceTagContent defines the resource template and handler for getting repository content for a tag. -func GetRepositoryResourceTagContent(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) toolsets.ServerResourceTemplate { +// GetRepositoryResourceTagContent defines the resource template for getting repository content for a tag. +func GetRepositoryResourceTagContent(t translations.TranslationHelperFunc) toolsets.ServerResourceTemplate { return toolsets.NewServerResourceTemplate( ToolsetMetadataRepos, mcp.ResourceTemplate{ @@ -77,12 +77,12 @@ func GetRepositoryResourceTagContent(getClient GetClientFn, getRawClient raw.Get URITemplate: repositoryResourceTagContentURITemplate.Raw(), Description: t("RESOURCE_REPOSITORY_CONTENT_TAG_DESCRIPTION", "Repository Content for specific tag"), }, - RepositoryResourceContentsHandler(getClient, getRawClient, repositoryResourceTagContentURITemplate), + repositoryResourceContentsHandlerFunc(repositoryResourceTagContentURITemplate), ) } -// GetRepositoryResourcePrContent defines the resource template and handler for getting repository content for a pull request. -func GetRepositoryResourcePrContent(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) toolsets.ServerResourceTemplate { +// GetRepositoryResourcePrContent defines the resource template for getting repository content for a pull request. +func GetRepositoryResourcePrContent(t translations.TranslationHelperFunc) toolsets.ServerResourceTemplate { return toolsets.NewServerResourceTemplate( ToolsetMetadataRepos, mcp.ResourceTemplate{ @@ -90,10 +90,18 @@ func GetRepositoryResourcePrContent(getClient GetClientFn, getRawClient raw.GetR URITemplate: repositoryResourcePrContentURITemplate.Raw(), Description: t("RESOURCE_REPOSITORY_CONTENT_PR_DESCRIPTION", "Repository Content for specific pull request"), }, - RepositoryResourceContentsHandler(getClient, getRawClient, repositoryResourcePrContentURITemplate), + repositoryResourceContentsHandlerFunc(repositoryResourcePrContentURITemplate), ) } +// repositoryResourceContentsHandlerFunc returns a ResourceHandlerFunc that creates handlers on-demand. +func repositoryResourceContentsHandlerFunc(resourceURITemplate *uritemplate.Template) toolsets.ResourceHandlerFunc { + return func(deps any) mcp.ResourceHandler { + d := deps.(ToolDependencies) + return RepositoryResourceContentsHandler(d.GetClient, d.GetRawClient, resourceURITemplate) + } +} + // RepositoryResourceContentsHandler returns a handler function for repository content requests. func RepositoryResourceContentsHandler(getClient GetClientFn, getRawClient raw.GetRawClientFn, resourceURITemplate *uritemplate.Template) mcp.ResourceHandler { return func(ctx context.Context, request *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { diff --git a/pkg/github/repository_resource_test.go b/pkg/github/repository_resource_test.go index 1b4120ff0..f938a57f5 100644 --- a/pkg/github/repository_resource_test.go +++ b/pkg/github/repository_resource_test.go @@ -7,7 +7,6 @@ import ( "testing" "github.com/github/github-mcp-server/pkg/raw" - "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v79/github" "github.com/migueleliasweb/go-github-mock/src/mock" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -28,7 +27,7 @@ func Test_repositoryResourceContents(t *testing.T) { name string mockedClient *http.Client uri string - handlerFn func(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) mcp.ResourceHandler + handlerFn func(getClient GetClientFn, getRawClient raw.GetRawClientFn) mcp.ResourceHandler expectedResponseType resourceResponseType expectError string expectedResult *mcp.ReadResourceResult @@ -46,8 +45,8 @@ func Test_repositoryResourceContents(t *testing.T) { ), ), uri: "repo:///repo/contents/README.md", - handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) mcp.ResourceHandler { - return GetRepositoryResourceContent(getClient, getRawClient, t).Handler + handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn) mcp.ResourceHandler { + return RepositoryResourceContentsHandler(getClient, getRawClient, repositoryResourceContentURITemplate) }, expectedResponseType: resourceResponseTypeText, // Ignored as error is expected expectError: "owner is required", @@ -65,8 +64,8 @@ func Test_repositoryResourceContents(t *testing.T) { ), ), uri: "repo://owner//refs/heads/main/contents/README.md", - handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) mcp.ResourceHandler { - return GetRepositoryResourceBranchContent(getClient, getRawClient, t).Handler + handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn) mcp.ResourceHandler { + return RepositoryResourceContentsHandler(getClient, getRawClient, repositoryResourceBranchContentURITemplate) }, expectedResponseType: resourceResponseTypeText, // Ignored as error is expected expectError: "repo is required", @@ -84,8 +83,8 @@ func Test_repositoryResourceContents(t *testing.T) { ), ), uri: "repo://owner/repo/contents/data.png", - handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) mcp.ResourceHandler { - return GetRepositoryResourceContent(getClient, getRawClient, t).Handler + handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn) mcp.ResourceHandler { + return RepositoryResourceContentsHandler(getClient, getRawClient, repositoryResourceContentURITemplate) }, expectedResponseType: resourceResponseTypeBlob, expectedResult: &mcp.ReadResourceResult{ @@ -108,8 +107,8 @@ func Test_repositoryResourceContents(t *testing.T) { ), ), uri: "repo://owner/repo/contents/README.md", - handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) mcp.ResourceHandler { - return GetRepositoryResourceContent(getClient, getRawClient, t).Handler + handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn) mcp.ResourceHandler { + return RepositoryResourceContentsHandler(getClient, getRawClient, repositoryResourceContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -134,8 +133,8 @@ func Test_repositoryResourceContents(t *testing.T) { ), ), uri: "repo://owner/repo/contents/pkg/github/actions.go", - handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) mcp.ResourceHandler { - return GetRepositoryResourceContent(getClient, getRawClient, t).Handler + handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn) mcp.ResourceHandler { + return RepositoryResourceContentsHandler(getClient, getRawClient, repositoryResourceContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -158,8 +157,8 @@ func Test_repositoryResourceContents(t *testing.T) { ), ), uri: "repo://owner/repo/refs/heads/main/contents/README.md", - handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) mcp.ResourceHandler { - return GetRepositoryResourceBranchContent(getClient, getRawClient, t).Handler + handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn) mcp.ResourceHandler { + return RepositoryResourceContentsHandler(getClient, getRawClient, repositoryResourceBranchContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -182,8 +181,8 @@ func Test_repositoryResourceContents(t *testing.T) { ), ), uri: "repo://owner/repo/refs/tags/v1.0.0/contents/README.md", - handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) mcp.ResourceHandler { - return GetRepositoryResourceTagContent(getClient, getRawClient, t).Handler + handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn) mcp.ResourceHandler { + return RepositoryResourceContentsHandler(getClient, getRawClient, repositoryResourceTagContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -206,8 +205,8 @@ func Test_repositoryResourceContents(t *testing.T) { ), ), uri: "repo://owner/repo/sha/abc123/contents/README.md", - handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) mcp.ResourceHandler { - return GetRepositoryResourceCommitContent(getClient, getRawClient, t).Handler + handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn) mcp.ResourceHandler { + return RepositoryResourceContentsHandler(getClient, getRawClient, repositoryResourceCommitContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -238,8 +237,8 @@ func Test_repositoryResourceContents(t *testing.T) { ), ), uri: "repo://owner/repo/refs/pull/42/head/contents/README.md", - handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) mcp.ResourceHandler { - return GetRepositoryResourcePrContent(getClient, getRawClient, t).Handler + handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn) mcp.ResourceHandler { + return RepositoryResourceContentsHandler(getClient, getRawClient, repositoryResourcePrContentURITemplate) }, expectedResponseType: resourceResponseTypeText, expectedResult: &mcp.ReadResourceResult{ @@ -261,8 +260,8 @@ func Test_repositoryResourceContents(t *testing.T) { ), ), uri: "repo://owner/repo/contents/nonexistent.md", - handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn, t translations.TranslationHelperFunc) mcp.ResourceHandler { - return GetRepositoryResourceContent(getClient, getRawClient, t).Handler + handlerFn: func(getClient GetClientFn, getRawClient raw.GetRawClientFn) mcp.ResourceHandler { + return RepositoryResourceContentsHandler(getClient, getRawClient, repositoryResourceContentURITemplate) }, expectedResponseType: resourceResponseTypeText, // Ignored as error is expected expectError: "404 Not Found", @@ -273,7 +272,7 @@ func Test_repositoryResourceContents(t *testing.T) { t.Run(tc.name, func(t *testing.T) { client := github.NewClient(tc.mockedClient) mockRawClient := raw.NewClient(client, base) - handler := tc.handlerFn(stubGetClientFn(client), stubGetRawClientFn(mockRawClient), translations.NullTranslationHelper) + handler := tc.handlerFn(stubGetClientFn(client), stubGetRawClientFn(mockRawClient)) request := &mcp.ReadResourceRequest{ Params: &mcp.ReadResourceParams{ diff --git a/pkg/github/resources.go b/pkg/github/resources.go index f0b07e831..253c4bc11 100644 --- a/pkg/github/resources.go +++ b/pkg/github/resources.go @@ -1,20 +1,19 @@ package github import ( - "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/toolsets" "github.com/github/github-mcp-server/pkg/translations" ) // AllResources returns all resource templates with their embedded toolset metadata. -// Resource template functions return ServerResourceTemplate directly with toolset info. -func AllResources(t translations.TranslationHelperFunc, getClient GetClientFn, getRawClient raw.GetRawClientFn) []toolsets.ServerResourceTemplate { +// Resource definitions are stateless - handlers are generated on-demand during registration. +func AllResources(t translations.TranslationHelperFunc) []toolsets.ServerResourceTemplate { return []toolsets.ServerResourceTemplate{ // Repository resources - GetRepositoryResourceContent(getClient, getRawClient, t), - GetRepositoryResourceBranchContent(getClient, getRawClient, t), - GetRepositoryResourceCommitContent(getClient, getRawClient, t), - GetRepositoryResourceTagContent(getClient, getRawClient, t), - GetRepositoryResourcePrContent(getClient, getRawClient, t), + GetRepositoryResourceContent(t), + GetRepositoryResourceBranchContent(t), + GetRepositoryResourceCommitContent(t), + GetRepositoryResourceTagContent(t), + GetRepositoryResourcePrContent(t), } } diff --git a/pkg/github/tools_validation_test.go b/pkg/github/tools_validation_test.go index 3b956326a..ae79c455a 100644 --- a/pkg/github/tools_validation_test.go +++ b/pkg/github/tools_validation_test.go @@ -58,9 +58,8 @@ func TestAllToolsHaveValidToolsetID(t *testing.T) { // TestAllResourcesHaveRequiredMetadata validates that all resources have mandatory metadata func TestAllResourcesHaveRequiredMetadata(t *testing.T) { - // Resources need client functions, but we can pass nil for validation - // since we're not actually calling handlers - resources := AllResources(stubTranslation, nil, nil) + // Resources are now stateless - no client functions needed + resources := AllResources(stubTranslation) require.NotEmpty(t, resources, "AllResources should return at least one resource") @@ -70,9 +69,9 @@ func TestAllResourcesHaveRequiredMetadata(t *testing.T) { assert.NotEmpty(t, res.Toolset.ID, "Resource %q must have a Toolset.ID", res.Template.Name) - // Handler must be set - assert.NotNil(t, res.Handler, - "Resource %q must have a Handler", res.Template.Name) + // HandlerFunc must be set + assert.True(t, res.HasHandler(), + "Resource %q must have a HandlerFunc", res.Template.Name) }) } } @@ -98,7 +97,7 @@ func TestAllPromptsHaveRequiredMetadata(t *testing.T) { // TestAllResourcesHaveValidToolsetID validates that all resources belong to known toolsets func TestAllResourcesHaveValidToolsetID(t *testing.T) { - resources := AllResources(stubTranslation, nil, nil) + resources := AllResources(stubTranslation) validToolsetIDs := GetValidToolsetIDs() for _, res := range resources { @@ -153,7 +152,7 @@ func TestNoDuplicateToolNames(t *testing.T) { // TestNoDuplicateResourceNames ensures all resources have unique names func TestNoDuplicateResourceNames(t *testing.T) { - resources := AllResources(stubTranslation, nil, nil) + resources := AllResources(stubTranslation) seen := make(map[string]bool) for _, res := range resources { diff --git a/pkg/github/toolset_group.go b/pkg/github/toolset_group.go index bca1f7ca4..68db02db6 100644 --- a/pkg/github/toolset_group.go +++ b/pkg/github/toolset_group.go @@ -1,18 +1,19 @@ package github import ( - "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/toolsets" "github.com/github/github-mcp-server/pkg/translations" ) // NewToolsetGroup creates a ToolsetGroup with all available tools, resources, and prompts. -// Tools are self-describing with their toolset metadata embedded. +// Tools, resources, and prompts are self-describing with their toolset metadata embedded. +// This function is stateless - no dependencies are captured. +// Handlers are generated on-demand during registration via RegisterAll(ctx, server, deps). // The "default" keyword in WithToolsets will expand to GetDefaultToolsetIDs(). -func NewToolsetGroup(t translations.TranslationHelperFunc, getClient GetClientFn, getRawClient raw.GetRawClientFn) *toolsets.ToolsetGroup { +func NewToolsetGroup(t translations.TranslationHelperFunc) *toolsets.ToolsetGroup { tsg := toolsets.NewToolsetGroup( AllTools(t), - AllResources(t, getClient, getRawClient), + AllResources(t), AllPrompts(t), ) tsg.SetDefaultToolsetIDs(GetDefaultToolsetIDs()) diff --git a/pkg/toolsets/toolsets.go b/pkg/toolsets/toolsets.go index 5b1df37ff..960b7e8a4 100644 --- a/pkg/toolsets/toolsets.go +++ b/pkg/toolsets/toolsets.go @@ -48,10 +48,18 @@ func NewToolDoesNotExistError(name string) *ToolDoesNotExistError { // ServerTool is defined in server_tool.go +// ResourceHandlerFunc is a function that takes dependencies and returns an MCP resource handler. +// This allows resources to be defined statically while their handlers are generated +// on-demand with the appropriate dependencies. +type ResourceHandlerFunc func(deps any) mcp.ResourceHandler + // ServerResourceTemplate pairs a resource template with its toolset metadata. type ServerResourceTemplate struct { Template mcp.ResourceTemplate - Handler mcp.ResourceHandler + // HandlerFunc generates the handler when given dependencies. + // This allows resources to be passed around without handlers being set up, + // and handlers are only created when needed. + HandlerFunc ResourceHandlerFunc // Toolset identifies which toolset this resource belongs to Toolset ToolsetMetadata // FeatureFlagEnable specifies a feature flag that must be enabled for this resource @@ -62,12 +70,26 @@ type ServerResourceTemplate struct { FeatureFlagDisable string } +// HasHandler returns true if this resource has a handler function. +func (sr *ServerResourceTemplate) HasHandler() bool { + return sr.HandlerFunc != nil +} + +// Handler returns a resource handler by calling HandlerFunc with the given dependencies. +// Panics if HandlerFunc is nil - all resources should have handlers. +func (sr *ServerResourceTemplate) Handler(deps any) mcp.ResourceHandler { + if sr.HandlerFunc == nil { + panic("HandlerFunc is nil for resource: " + sr.Template.Name) + } + return sr.HandlerFunc(deps) +} + // NewServerResourceTemplate creates a new ServerResourceTemplate with toolset metadata. -func NewServerResourceTemplate(toolset ToolsetMetadata, resourceTemplate mcp.ResourceTemplate, handler mcp.ResourceHandler) ServerResourceTemplate { +func NewServerResourceTemplate(toolset ToolsetMetadata, resourceTemplate mcp.ResourceTemplate, handlerFn ResourceHandlerFunc) ServerResourceTemplate { return ServerResourceTemplate{ - Template: resourceTemplate, - Handler: handler, - Toolset: toolset, + Template: resourceTemplate, + HandlerFunc: handlerFn, + Toolset: toolset, } } @@ -643,9 +665,9 @@ func (tg *ToolsetGroup) RegisterTools(ctx context.Context, s *mcp.Server, deps a // RegisterResourceTemplates registers all available resource templates with the server. // The context is used for feature flag evaluation. -func (tg *ToolsetGroup) RegisterResourceTemplates(ctx context.Context, s *mcp.Server) { +func (tg *ToolsetGroup) RegisterResourceTemplates(ctx context.Context, s *mcp.Server, deps any) { for _, res := range tg.AvailableResourceTemplates(ctx) { - s.AddResourceTemplate(&res.Template, res.Handler) + s.AddResourceTemplate(&res.Template, res.Handler(deps)) } } @@ -661,7 +683,7 @@ func (tg *ToolsetGroup) RegisterPrompts(ctx context.Context, s *mcp.Server) { // The context is used for feature flag evaluation. func (tg *ToolsetGroup) RegisterAll(ctx context.Context, s *mcp.Server, deps any) { tg.RegisterTools(ctx, s, deps) - tg.RegisterResourceTemplates(ctx, s) + tg.RegisterResourceTemplates(ctx, s, deps) tg.RegisterPrompts(ctx, s) } diff --git a/pkg/toolsets/toolsets_test.go b/pkg/toolsets/toolsets_test.go index 34d9ba753..317dafbf6 100644 --- a/pkg/toolsets/toolsets_test.go +++ b/pkg/toolsets/toolsets_test.go @@ -489,8 +489,10 @@ func mockResource(name string, toolsetID string, uriTemplate string) ServerResou Name: name, URITemplate: uriTemplate, }, - func(_ context.Context, _ *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { - return nil, nil + func(_ any) mcp.ResourceHandler { + return func(_ context.Context, _ *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { + return nil, nil + } }, ) } From b13d9fecb8b4c31a0712de9cc9a03fe0b7ab8541 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Sun, 14 Dec 2025 19:29:13 +0100 Subject: [PATCH 06/10] refactor: simplify ForMCPRequest switch cases --- pkg/toolsets/toolsets.go | 45 ++++++++++++---------------------------- 1 file changed, 13 insertions(+), 32 deletions(-) diff --git a/pkg/toolsets/toolsets.go b/pkg/toolsets/toolsets.go index 960b7e8a4..32105dbe8 100644 --- a/pkg/toolsets/toolsets.go +++ b/pkg/toolsets/toolsets.go @@ -337,58 +337,39 @@ const ( func (tg *ToolsetGroup) ForMCPRequest(method string, itemName string) *ToolsetGroup { result := tg.copy() - switch method { - case MCPMethodInitialize: - // Capabilities only - no items need to be registered - // The server capabilities (tools, resources, prompts support) are set via ServerOptions + // Helper to clear all item types + clearAll := func() { result.tools = []ServerTool{} result.resourceTemplates = []ServerResourceTemplate{} result.prompts = []ServerPrompt{} + } + switch method { + case MCPMethodInitialize: + clearAll() case MCPMethodToolsList: - // All available tools, but no resources or prompts - result.resourceTemplates = []ServerResourceTemplate{} - result.prompts = []ServerPrompt{} - + result.resourceTemplates, result.prompts = nil, nil case MCPMethodToolsCall: - // Only the specific tool (if found), no resources or prompts - result.resourceTemplates = []ServerResourceTemplate{} - result.prompts = []ServerPrompt{} + result.resourceTemplates, result.prompts = nil, nil if itemName != "" { result.tools = tg.filterToolsByName(itemName) } - case MCPMethodResourcesList, MCPMethodResourcesTemplatesList: - // All available resources, but no tools or prompts - result.tools = []ServerTool{} - result.prompts = []ServerPrompt{} - + result.tools, result.prompts = nil, nil case MCPMethodResourcesRead: - // Only the specific resource template, no tools or prompts - result.tools = []ServerTool{} - result.prompts = []ServerPrompt{} + result.tools, result.prompts = nil, nil if itemName != "" { result.resourceTemplates = tg.filterResourcesByURI(itemName) } - case MCPMethodPromptsList: - // All available prompts, but no tools or resources - result.tools = []ServerTool{} - result.resourceTemplates = []ServerResourceTemplate{} - + result.tools, result.resourceTemplates = nil, nil case MCPMethodPromptsGet: - // Only the specific prompt, no tools or resources - result.tools = []ServerTool{} - result.resourceTemplates = []ServerResourceTemplate{} + result.tools, result.resourceTemplates = nil, nil if itemName != "" { result.prompts = tg.filterPromptsByName(itemName) } - default: - // Unknown method - register nothing - result.tools = []ServerTool{} - result.resourceTemplates = []ServerResourceTemplate{} - result.prompts = []ServerPrompt{} + clearAll() } return result From 74d2c56c3056a375004297cf44da56bb3146b0a8 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Sun, 14 Dec 2025 22:30:13 +0100 Subject: [PATCH 07/10] refactor(generate_docs): use strings.Builder and AllTools() iteration - Replace slice joining with strings.Builder for all doc generation - Iterate AllTools() directly instead of ToolsetIDs()/ToolsForToolset() - Removes need for special 'dynamic' toolset handling (no tools = no output) - Context toolset still explicitly handled for custom description - Consistent pattern across generateToolsetsDoc, generateToolsDoc, generateRemoteToolsetsDoc, and generateDeprecatedAliasesTable --- cmd/github-mcp-server/generate_docs.go | 226 +++++++++++++------------ docs/remote-server.md | 1 - 2 files changed, 120 insertions(+), 107 deletions(-) diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index 1e3f3252b..6a3100ac2 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -107,64 +107,73 @@ func generateRemoteServerDocs(docsPath string) error { } func generateToolsetsDoc(tsg *toolsets.ToolsetGroup) string { - var lines []string + var buf strings.Builder // Add table header and separator - lines = append(lines, "| Toolset | Description |") - lines = append(lines, "| ----------------------- | ------------------------------------------------------------- |") - - // Add the context toolset row (handled separately in README) - lines = append(lines, "| `context` | **Strongly recommended**: Tools that provide context about the current user and GitHub context you are operating in |") - - // Get toolset IDs and descriptions - toolsetIDs := tsg.ToolsetIDs() - descriptions := tsg.ToolsetDescriptions() - - // Filter out context and dynamic toolsets (handled separately) - for _, id := range toolsetIDs { - if id != "context" && id != "dynamic" { - description := descriptions[id] - lines = append(lines, fmt.Sprintf("| `%s` | %s |", id, description)) + buf.WriteString("| Toolset | Description |\n") + buf.WriteString("| ----------------------- | ------------------------------------------------------------- |\n") + + // Add the context toolset row with custom description (strongly recommended) + buf.WriteString("| `context` | **Strongly recommended**: Tools that provide context about the current user and GitHub context you are operating in |\n") + + // AllTools() is sorted by toolset ID then tool name. + // We iterate once, collecting unique toolsets (skipping context which has custom description above). + tools := tsg.AllTools() + var lastToolsetID toolsets.ToolsetID + for _, tool := range tools { + if tool.Toolset.ID != lastToolsetID { + lastToolsetID = tool.Toolset.ID + // Skip context (handled above with custom description) + if lastToolsetID == "context" { + continue + } + fmt.Fprintf(&buf, "| `%s` | %s |\n", lastToolsetID, tool.Toolset.Description) } } - return strings.Join(lines, "\n") + return strings.TrimSuffix(buf.String(), "\n") } func generateToolsDoc(tsg *toolsets.ToolsetGroup) string { - var sections []string - - // Get toolset IDs (already sorted deterministically) - toolsetIDs := tsg.ToolsetIDs() + // AllTools() returns tools sorted by toolset ID then tool name. + // We iterate once, grouping by toolset as we encounter them. + tools := tsg.AllTools() + if len(tools) == 0 { + return "" + } - for _, toolsetID := range toolsetIDs { - if toolsetID == "dynamic" { // Skip dynamic toolset as it's handled separately - continue - } + var buf strings.Builder + var toolBuf strings.Builder + var currentToolsetID toolsets.ToolsetID + firstSection := true - // Get tools for this toolset (already sorted deterministically) - tools := tsg.ToolsForToolset(toolsetID) - if len(tools) == 0 { - continue + writeSection := func() { + if toolBuf.Len() == 0 { + return } - - // Generate section header - capitalize first letter and replace underscores - sectionName := formatToolsetName(string(toolsetID)) - - var toolDocs []string - for _, serverTool := range tools { - toolDoc := generateToolDoc(serverTool.Tool) - toolDocs = append(toolDocs, toolDoc) + if !firstSection { + buf.WriteString("\n\n") } + firstSection = false + sectionName := formatToolsetName(string(currentToolsetID)) + fmt.Fprintf(&buf, "
\n\n%s\n\n%s\n\n
", sectionName, strings.TrimSuffix(toolBuf.String(), "\n\n")) + toolBuf.Reset() + } - if len(toolDocs) > 0 { - section := fmt.Sprintf("
\n\n%s\n\n%s\n\n
", - sectionName, strings.Join(toolDocs, "\n\n")) - sections = append(sections, section) + for _, tool := range tools { + // When toolset changes, emit the previous section + if tool.Toolset.ID != currentToolsetID { + writeSection() + currentToolsetID = tool.Toolset.ID } + writeToolDoc(&toolBuf, tool.Tool) + toolBuf.WriteString("\n\n") } - return strings.Join(sections, "\n\n") + // Emit the last section + writeSection() + + return buf.String() } func formatToolsetName(name string) string { @@ -191,21 +200,19 @@ func formatToolsetName(name string) string { } } -func generateToolDoc(tool mcp.Tool) string { - var lines []string - +func writeToolDoc(buf *strings.Builder, tool mcp.Tool) { // Tool name only (using annotation name instead of verbose description) - lines = append(lines, fmt.Sprintf("- **%s** - %s", tool.Name, tool.Annotations.Title)) + fmt.Fprintf(buf, "- **%s** - %s\n", tool.Name, tool.Annotations.Title) // Parameters if tool.InputSchema == nil { - lines = append(lines, " - No parameters required") - return strings.Join(lines, "\n") + buf.WriteString(" - No parameters required") + return } schema, ok := tool.InputSchema.(*jsonschema.Schema) if !ok || schema == nil { - lines = append(lines, " - No parameters required") - return strings.Join(lines, "\n") + buf.WriteString(" - No parameters required") + return } if len(schema.Properties) > 0 { @@ -216,7 +223,7 @@ func generateToolDoc(tool mcp.Tool) string { } sort.Strings(paramNames) - for _, propName := range paramNames { + for i, propName := range paramNames { prop := schema.Properties[propName] required := contains(schema.Required, propName) requiredStr := "optional" @@ -224,7 +231,7 @@ func generateToolDoc(tool mcp.Tool) string { requiredStr = "required" } - var typeStr, description string + var typeStr string // Get the type and description switch prop.Type { @@ -238,19 +245,17 @@ func generateToolDoc(tool mcp.Tool) string { typeStr = prop.Type } - description = prop.Description - // Indent any continuation lines in the description to maintain markdown formatting - description = indentMultilineDescription(description, " ") + description := indentMultilineDescription(prop.Description, " ") - paramLine := fmt.Sprintf(" - `%s`: %s (%s, %s)", propName, description, typeStr, requiredStr) - lines = append(lines, paramLine) + fmt.Fprintf(buf, " - `%s`: %s (%s, %s)", propName, description, typeStr, requiredStr) + if i < len(paramNames)-1 { + buf.WriteString("\n") + } } } else { - lines = append(lines, " - No parameters required") + buf.WriteString(" - No parameters required") } - - return strings.Join(lines, "\n") } func contains(slice []string, item string) bool { @@ -265,14 +270,18 @@ func contains(slice []string, item string) bool { // indentMultilineDescription adds the specified indent to all lines after the first line. // This ensures that multi-line descriptions maintain proper markdown list formatting. func indentMultilineDescription(description, indent string) string { - lines := strings.Split(description, "\n") - if len(lines) <= 1 { + if !strings.Contains(description, "\n") { return description } + var buf strings.Builder + lines := strings.Split(description, "\n") + buf.WriteString(lines[0]) for i := 1; i < len(lines); i++ { - lines[i] = indent + lines[i] + buf.WriteString("\n") + buf.WriteString(indent) + buf.WriteString(lines[i]) } - return strings.Join(lines, "\n") + return buf.String() } func replaceSection(content, startMarker, endMarker, newContent string) string { @@ -299,47 +308,49 @@ func generateRemoteToolsetsDoc() string { buf.WriteString("| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |\n") buf.WriteString("|----------------|--------------------------------------------------|-------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|\n") - // Get toolset IDs and descriptions - toolsetIDs := tsg.ToolsetIDs() - descriptions := tsg.ToolsetDescriptions() - // Add "all" toolset first (special case) buf.WriteString("| all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) |\n") - // Add individual toolsets - for _, id := range toolsetIDs { - idStr := string(id) - if idStr == "context" || idStr == "dynamic" { // Skip context and dynamic toolsets as they're handled separately - continue - } + // AllTools() is sorted by toolset ID then tool name. + // We iterate once, collecting unique toolsets (skipping context which is handled separately). + tools := tsg.AllTools() + var lastToolsetID toolsets.ToolsetID + for _, tool := range tools { + if tool.Toolset.ID != lastToolsetID { + lastToolsetID = tool.Toolset.ID + idStr := string(lastToolsetID) + // Skip context toolset (handled separately) + if idStr == "context" { + continue + } - description := descriptions[id] - formattedName := formatToolsetName(idStr) - apiURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s", idStr) - readonlyURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s/readonly", idStr) - - // Create install config JSON (URL encoded) - installConfig := url.QueryEscape(fmt.Sprintf(`{"type": "http","url": "%s"}`, apiURL)) - readonlyConfig := url.QueryEscape(fmt.Sprintf(`{"type": "http","url": "%s"}`, readonlyURL)) - - // Fix URL encoding to use %20 instead of + for spaces - installConfig = strings.ReplaceAll(installConfig, "+", "%20") - readonlyConfig = strings.ReplaceAll(readonlyConfig, "+", "%20") - - installLink := fmt.Sprintf("[Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", idStr, installConfig) - readonlyInstallLink := fmt.Sprintf("[Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", idStr, readonlyConfig) - - buf.WriteString(fmt.Sprintf("| %-14s | %-48s | %-53s | %-218s | %-110s | %-288s |\n", - formattedName, - description, - apiURL, - installLink, - fmt.Sprintf("[read-only](%s)", readonlyURL), - readonlyInstallLink, - )) + formattedName := formatToolsetName(idStr) + apiURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s", idStr) + readonlyURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s/readonly", idStr) + + // Create install config JSON (URL encoded) + installConfig := url.QueryEscape(fmt.Sprintf(`{"type": "http","url": "%s"}`, apiURL)) + readonlyConfig := url.QueryEscape(fmt.Sprintf(`{"type": "http","url": "%s"}`, readonlyURL)) + + // Fix URL encoding to use %20 instead of + for spaces + installConfig = strings.ReplaceAll(installConfig, "+", "%20") + readonlyConfig = strings.ReplaceAll(readonlyConfig, "+", "%20") + + installLink := fmt.Sprintf("[Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", idStr, installConfig) + readonlyInstallLink := fmt.Sprintf("[Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", idStr, readonlyConfig) + + fmt.Fprintf(&buf, "| %-14s | %-48s | %-53s | %-218s | %-110s | %-288s |\n", + formattedName, + tool.Toolset.Description, + apiURL, + installLink, + fmt.Sprintf("[read-only](%s)", readonlyURL), + readonlyInstallLink, + ) + } } - return buf.String() + return strings.TrimSuffix(buf.String(), "\n") } func generateDeprecatedAliasesDocs(docsPath string) error { @@ -366,15 +377,15 @@ func generateDeprecatedAliasesDocs(docsPath string) error { } func generateDeprecatedAliasesTable() string { - var lines []string + var buf strings.Builder // Add table header - lines = append(lines, "| Old Name | New Name |") - lines = append(lines, "|----------|----------|") + buf.WriteString("| Old Name | New Name |\n") + buf.WriteString("|----------|----------|\n") aliases := github.DeprecatedToolAliases if len(aliases) == 0 { - lines = append(lines, "| *(none currently)* | |") + buf.WriteString("| *(none currently)* | |") } else { // Sort keys for deterministic output var oldNames []string @@ -383,11 +394,14 @@ func generateDeprecatedAliasesTable() string { } sort.Strings(oldNames) - for _, oldName := range oldNames { + for i, oldName := range oldNames { newName := aliases[oldName] - lines = append(lines, fmt.Sprintf("| `%s` | `%s` |", oldName, newName)) + fmt.Fprintf(&buf, "| `%s` | `%s` |", oldName, newName) + if i < len(oldNames)-1 { + buf.WriteString("\n") + } } } - return strings.Join(lines, "\n") + return buf.String() } diff --git a/docs/remote-server.md b/docs/remote-server.md index ffdf526a4..53fe36127 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -37,7 +37,6 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | Security Advisories | Security advisories related tools | https://api.githubcopilot.com/mcp/x/security_advisories | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/security_advisories/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-security_advisories&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fsecurity_advisories%2Freadonly%22%7D) | | Stargazers | GitHub Stargazers related tools | https://api.githubcopilot.com/mcp/x/stargazers | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-stargazers&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fstargazers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/stargazers/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-stargazers&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fstargazers%2Freadonly%22%7D) | | Users | GitHub User related tools | https://api.githubcopilot.com/mcp/x/users | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/users/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-users&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fusers%2Freadonly%22%7D) | - ### Additional _Remote_ Server Toolsets From 9bcf526d726e061ff374009999bc8e24daa60292 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Sun, 14 Dec 2025 22:42:50 +0100 Subject: [PATCH 08/10] feat(toolsets): add AvailableToolsets() with exclude filter - Add AvailableToolsets() method that returns toolsets with actual tools - Support variadic exclude parameter for filtering out specific toolsets - Simplifies doc generation by removing manual skip logic - Naturally excludes empty toolsets (like 'dynamic') without special cases --- cmd/github-mcp-server/generate_docs.go | 82 ++++++++++---------------- pkg/toolsets/toolsets.go | 29 +++++++++ 2 files changed, 61 insertions(+), 50 deletions(-) diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index 6a3100ac2..a473787c6 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -116,19 +116,10 @@ func generateToolsetsDoc(tsg *toolsets.ToolsetGroup) string { // Add the context toolset row with custom description (strongly recommended) buf.WriteString("| `context` | **Strongly recommended**: Tools that provide context about the current user and GitHub context you are operating in |\n") - // AllTools() is sorted by toolset ID then tool name. - // We iterate once, collecting unique toolsets (skipping context which has custom description above). - tools := tsg.AllTools() - var lastToolsetID toolsets.ToolsetID - for _, tool := range tools { - if tool.Toolset.ID != lastToolsetID { - lastToolsetID = tool.Toolset.ID - // Skip context (handled above with custom description) - if lastToolsetID == "context" { - continue - } - fmt.Fprintf(&buf, "| `%s` | %s |\n", lastToolsetID, tool.Toolset.Description) - } + // AvailableToolsets() returns toolsets that have tools, sorted by ID + // Exclude context (custom description above) and dynamic (internal only) + for _, ts := range tsg.AvailableToolsets("context", "dynamic") { + fmt.Fprintf(&buf, "| `%s` | %s |\n", ts.ID, ts.Description) } return strings.TrimSuffix(buf.String(), "\n") @@ -311,43 +302,34 @@ func generateRemoteToolsetsDoc() string { // Add "all" toolset first (special case) buf.WriteString("| all | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) |\n") - // AllTools() is sorted by toolset ID then tool name. - // We iterate once, collecting unique toolsets (skipping context which is handled separately). - tools := tsg.AllTools() - var lastToolsetID toolsets.ToolsetID - for _, tool := range tools { - if tool.Toolset.ID != lastToolsetID { - lastToolsetID = tool.Toolset.ID - idStr := string(lastToolsetID) - // Skip context toolset (handled separately) - if idStr == "context" { - continue - } - - formattedName := formatToolsetName(idStr) - apiURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s", idStr) - readonlyURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s/readonly", idStr) - - // Create install config JSON (URL encoded) - installConfig := url.QueryEscape(fmt.Sprintf(`{"type": "http","url": "%s"}`, apiURL)) - readonlyConfig := url.QueryEscape(fmt.Sprintf(`{"type": "http","url": "%s"}`, readonlyURL)) - - // Fix URL encoding to use %20 instead of + for spaces - installConfig = strings.ReplaceAll(installConfig, "+", "%20") - readonlyConfig = strings.ReplaceAll(readonlyConfig, "+", "%20") - - installLink := fmt.Sprintf("[Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", idStr, installConfig) - readonlyInstallLink := fmt.Sprintf("[Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", idStr, readonlyConfig) - - fmt.Fprintf(&buf, "| %-14s | %-48s | %-53s | %-218s | %-110s | %-288s |\n", - formattedName, - tool.Toolset.Description, - apiURL, - installLink, - fmt.Sprintf("[read-only](%s)", readonlyURL), - readonlyInstallLink, - ) - } + // AvailableToolsets() returns toolsets that have tools, sorted by ID + // Exclude context (handled separately) and dynamic (internal only) + for _, ts := range tsg.AvailableToolsets("context", "dynamic") { + idStr := string(ts.ID) + + formattedName := formatToolsetName(idStr) + apiURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s", idStr) + readonlyURL := fmt.Sprintf("https://api.githubcopilot.com/mcp/x/%s/readonly", idStr) + + // Create install config JSON (URL encoded) + installConfig := url.QueryEscape(fmt.Sprintf(`{"type": "http","url": "%s"}`, apiURL)) + readonlyConfig := url.QueryEscape(fmt.Sprintf(`{"type": "http","url": "%s"}`, readonlyURL)) + + // Fix URL encoding to use %20 instead of + for spaces + installConfig = strings.ReplaceAll(installConfig, "+", "%20") + readonlyConfig = strings.ReplaceAll(readonlyConfig, "+", "%20") + + installLink := fmt.Sprintf("[Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", idStr, installConfig) + readonlyInstallLink := fmt.Sprintf("[Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-%s&config=%s)", idStr, readonlyConfig) + + fmt.Fprintf(&buf, "| %-14s | %-48s | %-53s | %-218s | %-110s | %-288s |\n", + formattedName, + ts.Description, + apiURL, + installLink, + fmt.Sprintf("[read-only](%s)", readonlyURL), + readonlyInstallLink, + ) } return strings.TrimSuffix(buf.String(), "\n") diff --git a/pkg/toolsets/toolsets.go b/pkg/toolsets/toolsets.go index 32105dbe8..82896510e 100644 --- a/pkg/toolsets/toolsets.go +++ b/pkg/toolsets/toolsets.go @@ -767,3 +767,32 @@ func (tg *ToolsetGroup) AllTools() []ServerTool { return result } + +// AvailableToolsets returns the unique toolsets that have tools, in sorted order. +// This is the ordered intersection of toolsets with reality - only toolsets that +// actually contain tools are returned, sorted by toolset ID. +// Optional exclude parameter filters out specific toolset IDs from the result. +func (tg *ToolsetGroup) AvailableToolsets(exclude ...ToolsetID) []ToolsetMetadata { + tools := tg.AllTools() + if len(tools) == 0 { + return nil + } + + // Build exclude set for O(1) lookup + excludeSet := make(map[ToolsetID]bool, len(exclude)) + for _, id := range exclude { + excludeSet[id] = true + } + + var result []ToolsetMetadata + var lastID ToolsetID + for _, tool := range tools { + if tool.Toolset.ID != lastID { + lastID = tool.Toolset.ID + if !excludeSet[lastID] { + result = append(result, tool.Toolset) + } + } + } + return result +} From 2142b025c8386866552a731eb1b7e2f4bc8f59ec Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Sun, 14 Dec 2025 22:47:13 +0100 Subject: [PATCH 09/10] refactor(generate_docs): hoist success logging to generateAllDocs --- cmd/github-mcp-server/generate_docs.go | 81 ++++++++++++++------------ 1 file changed, 45 insertions(+), 36 deletions(-) diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index a473787c6..30a5667b3 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -4,7 +4,6 @@ import ( "fmt" "net/url" "os" - "regexp" "sort" "strings" @@ -30,18 +29,20 @@ func init() { } func generateAllDocs() error { - if err := generateReadmeDocs("README.md"); err != nil { - return fmt.Errorf("failed to generate README docs: %w", err) - } - - if err := generateRemoteServerDocs("docs/remote-server.md"); err != nil { - return fmt.Errorf("failed to generate remote-server docs: %w", err) - } - - if err := generateDeprecatedAliasesDocs("docs/deprecated-tool-aliases.md"); err != nil { - return fmt.Errorf("failed to generate deprecated aliases docs: %w", err) + for _, doc := range []struct { + path string + fn func(string) error + }{ + // File to edit, function to generate its docs + {"README.md", generateReadmeDocs}, + {"docs/remote-server.md", generateRemoteServerDocs}, + {"docs/deprecated-tool-aliases.md", generateDeprecatedAliasesDocs}, + } { + if err := doc.fn(doc.path); err != nil { + return fmt.Errorf("failed to generate docs for %s: %w", doc.path, err) + } + fmt.Printf("Successfully updated %s with automated documentation\n", doc.path) } - return nil } @@ -66,10 +67,16 @@ func generateReadmeDocs(readmePath string) error { } // Replace toolsets section - updatedContent := replaceSection(string(content), "START AUTOMATED TOOLSETS", "END AUTOMATED TOOLSETS", toolsetsDoc) + updatedContent, err := replaceSection(string(content), "START AUTOMATED TOOLSETS", "END AUTOMATED TOOLSETS", toolsetsDoc) + if err != nil { + return err + } // Replace tools section - updatedContent = replaceSection(updatedContent, "START AUTOMATED TOOLS", "END AUTOMATED TOOLS", toolsDoc) + updatedContent, err = replaceSection(updatedContent, "START AUTOMATED TOOLS", "END AUTOMATED TOOLS", toolsDoc) + if err != nil { + return err + } // Write back to file err = os.WriteFile(readmePath, []byte(updatedContent), 0600) @@ -77,7 +84,6 @@ func generateReadmeDocs(readmePath string) error { return fmt.Errorf("failed to write README.md: %w", err) } - fmt.Println("Successfully updated README.md with automated documentation") return nil } @@ -90,20 +96,12 @@ func generateRemoteServerDocs(docsPath string) error { toolsetsDoc := generateRemoteToolsetsDoc() // Replace content between markers - startMarker := "" - endMarker := "" - - contentStr := string(content) - startIndex := strings.Index(contentStr, startMarker) - endIndex := strings.Index(contentStr, endMarker) - - if startIndex == -1 || endIndex == -1 { - return fmt.Errorf("automation markers not found in %s", docsPath) + updatedContent, err := replaceSection(string(content), "START AUTOMATED TOOLSETS", "END AUTOMATED TOOLSETS", toolsetsDoc) + if err != nil { + return err } - newContent := contentStr[:startIndex] + startMarker + "\n" + toolsetsDoc + "\n" + endMarker + contentStr[endIndex+len(endMarker):] - - return os.WriteFile(docsPath, []byte(newContent), 0600) //#nosec G306 + return os.WriteFile(docsPath, []byte(updatedContent), 0600) //#nosec G306 } func generateToolsetsDoc(tsg *toolsets.ToolsetGroup) string { @@ -275,15 +273,24 @@ func indentMultilineDescription(description, indent string) string { return buf.String() } -func replaceSection(content, startMarker, endMarker, newContent string) string { - startPattern := fmt.Sprintf(``, regexp.QuoteMeta(startMarker)) - endPattern := fmt.Sprintf(``, regexp.QuoteMeta(endMarker)) - - re := regexp.MustCompile(fmt.Sprintf(`(?s)%s.*?%s`, startPattern, endPattern)) +func replaceSection(content, startMarker, endMarker, newContent string) (string, error) { + start := fmt.Sprintf("", startMarker) + end := fmt.Sprintf("", endMarker) - replacement := fmt.Sprintf("\n%s\n", startMarker, newContent, endMarker) + startIdx := strings.Index(content, start) + endIdx := strings.Index(content, end) + if startIdx == -1 || endIdx == -1 { + return "", fmt.Errorf("markers not found: %s / %s", start, end) + } - return re.ReplaceAllString(content, replacement) + var buf strings.Builder + buf.WriteString(content[:startIdx]) + buf.WriteString(start) + buf.WriteString("\n") + buf.WriteString(newContent) + buf.WriteString("\n") + buf.WriteString(content[endIdx:]) + return buf.String(), nil } func generateRemoteToolsetsDoc() string { @@ -346,7 +353,10 @@ func generateDeprecatedAliasesDocs(docsPath string) error { aliasesDoc := generateDeprecatedAliasesTable() // Replace content between markers - updatedContent := replaceSection(string(content), "START AUTOMATED ALIASES", "END AUTOMATED ALIASES", aliasesDoc) + updatedContent, err := replaceSection(string(content), "START AUTOMATED ALIASES", "END AUTOMATED ALIASES", aliasesDoc) + if err != nil { + return err + } // Write back to file err = os.WriteFile(docsPath, []byte(updatedContent), 0600) @@ -354,7 +364,6 @@ func generateDeprecatedAliasesDocs(docsPath string) error { return fmt.Errorf("failed to write deprecated aliases docs: %w", err) } - fmt.Println("Successfully updated docs/deprecated-tool-aliases.md with automated documentation") return nil } From 6b6a87455e3bb45da241c9919521fe69c4e7956a Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Mon, 15 Dec 2025 00:20:16 +0100 Subject: [PATCH 10/10] refactor: consolidate toolset validation into ToolsetGroup - Add Default field to ToolsetMetadata and derive defaults from metadata - Move toolset validation into WithToolsets (trims whitespace, dedupes, tracks unrecognized) - Add UnrecognizedToolsets() method for warning about typos - Add DefaultToolsetIDs() method to derive defaults from metadata - Remove redundant functions: CleanToolsets, GetValidToolsetIDs, AvailableToolsets, GetDefaultToolsetIDs - Update DynamicTools to take ToolsetGroup for schema enum generation - Add stubTranslator for cases needing ToolsetGroup without translations This eliminates hardcoded toolset lists - everything is now derived from the actual registered tools and their metadata. --- cmd/github-mcp-server/generate_docs.go | 18 +- e2e/e2e_test.go | 2 +- internal/ghmcp/server.go | 28 +- pkg/github/dynamic_tools.go | 56 ++-- pkg/github/tools.go | 104 ++----- pkg/github/tools_test.go | 129 -------- pkg/github/tools_validation_test.go | 51 ---- pkg/github/toolset_group.go | 17 +- pkg/toolsets/server_tool.go | 2 + pkg/toolsets/toolsets.go | 406 +++++++++++++++---------- pkg/toolsets/toolsets_test.go | 291 +++++++++++++++--- 11 files changed, 556 insertions(+), 548 deletions(-) diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index 30a5667b3..ddfcd10ba 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -51,13 +51,13 @@ func generateReadmeDocs(readmePath string) error { t, _ := translations.TranslationHelper() // Create toolset group - stateless, no dependencies needed for doc generation - tsg := github.NewToolsetGroup(t) + r := github.NewRegistry(t) // Generate toolsets documentation - toolsetsDoc := generateToolsetsDoc(tsg) + toolsetsDoc := generateToolsetsDoc(r) // Generate tools documentation - toolsDoc := generateToolsDoc(tsg) + toolsDoc := generateToolsDoc(r) // Read the current README.md // #nosec G304 - readmePath is controlled by command line flag, not user input @@ -104,7 +104,7 @@ func generateRemoteServerDocs(docsPath string) error { return os.WriteFile(docsPath, []byte(updatedContent), 0600) //#nosec G306 } -func generateToolsetsDoc(tsg *toolsets.ToolsetGroup) string { +func generateToolsetsDoc(r *toolsets.Registry) string { var buf strings.Builder // Add table header and separator @@ -116,17 +116,17 @@ func generateToolsetsDoc(tsg *toolsets.ToolsetGroup) string { // AvailableToolsets() returns toolsets that have tools, sorted by ID // Exclude context (custom description above) and dynamic (internal only) - for _, ts := range tsg.AvailableToolsets("context", "dynamic") { + for _, ts := range r.AvailableToolsets("context", "dynamic") { fmt.Fprintf(&buf, "| `%s` | %s |\n", ts.ID, ts.Description) } return strings.TrimSuffix(buf.String(), "\n") } -func generateToolsDoc(tsg *toolsets.ToolsetGroup) string { +func generateToolsDoc(r *toolsets.Registry) string { // AllTools() returns tools sorted by toolset ID then tool name. // We iterate once, grouping by toolset as we encounter them. - tools := tsg.AllTools() + tools := r.AllTools() if len(tools) == 0 { return "" } @@ -300,7 +300,7 @@ func generateRemoteToolsetsDoc() string { t, _ := translations.TranslationHelper() // Create toolset group - stateless - tsg := github.NewToolsetGroup(t) + r := github.NewRegistry(t) // Generate table header buf.WriteString("| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |\n") @@ -311,7 +311,7 @@ func generateRemoteToolsetsDoc() string { // AvailableToolsets() returns toolsets that have tools, sorted by ID // Exclude context (handled separately) and dynamic (internal only) - for _, ts := range tsg.AvailableToolsets("context", "dynamic") { + for _, ts := range r.AvailableToolsets("context", "dynamic") { idStr := string(ts.ID) formattedName := formatToolsetName(idStr) diff --git a/e2e/e2e_test.go b/e2e/e2e_test.go index 5f67fb84c..ad9ebb190 100644 --- a/e2e/e2e_test.go +++ b/e2e/e2e_test.go @@ -178,7 +178,7 @@ func setupMCPClient(t *testing.T, options ...clientOption) *mcp.ClientSession { // so that there is a shared setup mechanism, but let's wait till we feel more friction. enabledToolsets := opts.enabledToolsets if enabledToolsets == nil { - enabledToolsets = github.GetDefaultToolsetIDs() + enabledToolsets = github.NewRegistry(translations.NullTranslationHelper).DefaultToolsetIDs() } ghServer, err := ghmcp.NewMCPServer(ghmcp.MCPServerConfig{ diff --git a/internal/ghmcp/server.go b/internal/ghmcp/server.go index 1dec59381..67fcad4a7 100644 --- a/internal/ghmcp/server.go +++ b/internal/ghmcp/server.go @@ -109,12 +109,7 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { // - explicit list means "use these toolsets" var enabledToolsets []string if cfg.EnabledToolsets != nil { - // Clean up explicitly passed toolsets (removes duplicates, whitespace) - var invalidToolsets []string - enabledToolsets, invalidToolsets = github.CleanToolsets(cfg.EnabledToolsets) - if len(invalidToolsets) > 0 { - fmt.Fprintf(os.Stderr, "Invalid toolsets ignored: %s\n", strings.Join(invalidToolsets, ", ")) - } + enabledToolsets = cfg.EnabledToolsets } else if cfg.DynamicToolsets { // Dynamic mode with no toolsets specified: start with no toolsets enabled // so users can enable them on demand via the dynamic tools @@ -163,7 +158,7 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { } // Create toolset group with all tools, resources, and prompts (stateless) - tsg := github.NewToolsetGroup(cfg.Translator) + r := github.NewRegistry(cfg.Translator) // Clean tool names (WithTools will resolve any deprecated aliases) enabledTools := github.CleanTools(cfg.EnabledTools) @@ -174,16 +169,21 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { // - WithToolsets: nil=defaults, empty=none, handles "all"/"default" keywords // - WithTools: additional tools that bypass toolset filtering (additive, resolves aliases) // - WithFeatureChecker: filters based on feature flags - filteredTsg := tsg. + filteredReg := r. WithDeprecatedToolAliases(github.DeprecatedToolAliases). WithReadOnly(cfg.ReadOnly). WithToolsets(enabledToolsets). WithTools(enabledTools). WithFeatureChecker(createFeatureChecker(cfg.EnabledFeatures)) + // Warn about unrecognized toolset names (likely typos) + if unrecognized := filteredReg.UnrecognizedToolsets(); len(unrecognized) > 0 { + fmt.Fprintf(os.Stderr, "Warning: unrecognized toolsets ignored: %s\n", strings.Join(unrecognized, ", ")) + } + // Register all mcp functionality with the server // Use background context for local server (no per-request actor context) - filteredTsg.RegisterAll(context.Background(), ghServer, deps) + filteredReg.RegisterAll(context.Background(), ghServer, deps) // Register dynamic toolset management if configured // Dynamic tools get access to the filtered toolset group which tracks enabled state. @@ -191,12 +191,12 @@ func NewMCPServer(cfg MCPServerConfig) (*mcp.Server, error) { // so dynamic tools can enable any toolset at runtime. if cfg.DynamicToolsets { dynamicDeps := github.DynamicToolDependencies{ - Server: ghServer, - ToolsetGroup: filteredTsg, - ToolDeps: deps, - T: cfg.Translator, + Server: ghServer, + Registry: filteredReg, + ToolDeps: deps, + T: cfg.Translator, } - dynamicTools := github.DynamicTools() + dynamicTools := github.DynamicTools(filteredReg) for _, tool := range dynamicTools { tool.RegisterFunc(ghServer, dynamicDeps) } diff --git a/pkg/github/dynamic_tools.go b/pkg/github/dynamic_tools.go index cc44e85f5..93c24a07b 100644 --- a/pkg/github/dynamic_tools.go +++ b/pkg/github/dynamic_tools.go @@ -13,13 +13,13 @@ import ( ) // DynamicToolDependencies contains dependencies for dynamic toolset management tools. -// It includes the managed ToolsetGroup, the server for registration, and the deps +// It includes the managed Registry, the server for registration, and the deps // that will be passed to tools when they are dynamically enabled. type DynamicToolDependencies struct { // Server is the MCP server to register tools with Server *mcp.Server - // ToolsetGroup contains all available tools that can be enabled dynamically - ToolsetGroup *toolsets.ToolsetGroup + // Registry contains all available tools that can be enabled dynamically + Registry *toolsets.Registry // ToolDeps are the dependencies passed to tools when they are registered ToolDeps any // T is the translation helper function @@ -33,20 +33,9 @@ func NewDynamicTool(toolset toolsets.ToolsetMetadata, tool mcp.Tool, handler fun }) } -// AllToolsetIDsEnum returns all available toolset IDs as an enum for JSON Schema. -func AllToolsetIDsEnum() []any { - toolsets := AvailableToolsets() - result := make([]any, len(toolsets)) - for i, ts := range toolsets { - result[i] = ts.ID - } - return result -} - -// ToolsetEnum returns the list of toolset IDs as an enum for JSON Schema. -// Deprecated: Use AllToolsetIDsEnum() instead. -func ToolsetEnum(toolsetGroup *toolsets.ToolsetGroup) []any { - toolsetIDs := toolsetGroup.ToolsetIDs() +// toolsetIDsEnum returns the list of toolset IDs as an enum for JSON Schema. +func toolsetIDsEnum(r *toolsets.Registry) []any { + toolsetIDs := r.ToolsetIDs() result := make([]any, len(toolsetIDs)) for i, id := range toolsetIDs { result[i] = id @@ -56,16 +45,17 @@ func ToolsetEnum(toolsetGroup *toolsets.ToolsetGroup) []any { // DynamicTools returns the tools for dynamic toolset management. // These tools allow runtime discovery and enablement of toolsets. -func DynamicTools() []toolsets.ServerTool { +// The r parameter provides the available toolset IDs for JSON Schema enums. +func DynamicTools(r *toolsets.Registry) []toolsets.ServerTool { return []toolsets.ServerTool{ ListAvailableToolsets(), - GetToolsetsTools(), - EnableToolset(), + GetToolsetsTools(r), + EnableToolset(r), } } // EnableToolset creates a tool that enables a toolset at runtime. -func EnableToolset() toolsets.ServerTool { +func EnableToolset(r *toolsets.Registry) toolsets.ServerTool { return NewDynamicTool( ToolsetMetadataDynamic, mcp.Tool{ @@ -81,7 +71,7 @@ func EnableToolset() toolsets.ServerTool { "toolset": { Type: "string", Description: "The name of the toolset to enable", - Enum: AllToolsetIDsEnum(), + Enum: toolsetIDsEnum(r), }, }, Required: []string{"toolset"}, @@ -96,19 +86,19 @@ func EnableToolset() toolsets.ServerTool { toolsetID := toolsets.ToolsetID(toolsetName) - if !deps.ToolsetGroup.HasToolset(toolsetID) { + if !deps.Registry.HasToolset(toolsetID) { return utils.NewToolResultError(fmt.Sprintf("Toolset %s not found", toolsetName)), nil, nil } - if deps.ToolsetGroup.IsToolsetEnabled(toolsetID) { + if deps.Registry.IsToolsetEnabled(toolsetID) { return utils.NewToolResultText(fmt.Sprintf("Toolset %s is already enabled", toolsetName)), nil, nil } // Mark the toolset as enabled so IsToolsetEnabled returns true - deps.ToolsetGroup.EnableToolset(toolsetID) + deps.Registry.EnableToolset(toolsetID) // Get tools for this toolset and register them with the managed deps - toolsForToolset := deps.ToolsetGroup.ToolsForToolset(toolsetID) + toolsForToolset := deps.Registry.ToolsForToolset(toolsetID) for _, st := range toolsForToolset { st.RegisterFunc(deps.Server, deps.ToolDeps) } @@ -137,8 +127,8 @@ func ListAvailableToolsets() toolsets.ServerTool { }, func(deps DynamicToolDependencies) mcp.ToolHandlerFor[map[string]any, any] { return func(_ context.Context, _ *mcp.CallToolRequest, _ map[string]any) (*mcp.CallToolResult, any, error) { - toolsetIDs := deps.ToolsetGroup.ToolsetIDs() - descriptions := deps.ToolsetGroup.ToolsetDescriptions() + toolsetIDs := deps.Registry.ToolsetIDs() + descriptions := deps.Registry.ToolsetDescriptions() payload := make([]map[string]string, 0, len(toolsetIDs)) for _, id := range toolsetIDs { @@ -146,7 +136,7 @@ func ListAvailableToolsets() toolsets.ServerTool { "name": string(id), "description": descriptions[id], "can_enable": "true", - "currently_enabled": fmt.Sprintf("%t", deps.ToolsetGroup.IsToolsetEnabled(id)), + "currently_enabled": fmt.Sprintf("%t", deps.Registry.IsToolsetEnabled(id)), } payload = append(payload, t) } @@ -163,7 +153,7 @@ func ListAvailableToolsets() toolsets.ServerTool { } // GetToolsetsTools creates a tool that lists all tools in a specific toolset. -func GetToolsetsTools() toolsets.ServerTool { +func GetToolsetsTools(r *toolsets.Registry) toolsets.ServerTool { return NewDynamicTool( ToolsetMetadataDynamic, mcp.Tool{ @@ -179,7 +169,7 @@ func GetToolsetsTools() toolsets.ServerTool { "toolset": { Type: "string", Description: "The name of the toolset you want to get the tools for", - Enum: AllToolsetIDsEnum(), + Enum: toolsetIDsEnum(r), }, }, Required: []string{"toolset"}, @@ -194,12 +184,12 @@ func GetToolsetsTools() toolsets.ServerTool { toolsetID := toolsets.ToolsetID(toolsetName) - if !deps.ToolsetGroup.HasToolset(toolsetID) { + if !deps.Registry.HasToolset(toolsetID) { return utils.NewToolResultError(fmt.Sprintf("Toolset %s not found", toolsetName)), nil, nil } // Get all tools for this toolset (ignoring current filters for discovery) - toolsInToolset := deps.ToolsetGroup.ToolsForToolset(toolsetID) + toolsInToolset := deps.Registry.ToolsForToolset(toolsetID) payload := make([]map[string]string, 0, len(toolsInToolset)) for _, st := range toolsInToolset { diff --git a/pkg/github/tools.go b/pkg/github/tools.go index f39ae43c1..dd2ad4ff4 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -28,10 +28,12 @@ var ( ToolsetMetadataContext = toolsets.ToolsetMetadata{ ID: "context", Description: "Tools that provide context about the current user and GitHub context you are operating in", + Default: true, } ToolsetMetadataRepos = toolsets.ToolsetMetadata{ ID: "repos", Description: "GitHub Repository related tools", + Default: true, } ToolsetMetadataGit = toolsets.ToolsetMetadata{ ID: "git", @@ -40,14 +42,17 @@ var ( ToolsetMetadataIssues = toolsets.ToolsetMetadata{ ID: "issues", Description: "GitHub Issues related tools", + Default: true, } ToolsetMetadataPullRequests = toolsets.ToolsetMetadata{ ID: "pull_requests", Description: "GitHub Pull Request related tools", + Default: true, } ToolsetMetadataUsers = toolsets.ToolsetMetadata{ ID: "users", Description: "GitHub User related tools", + Default: true, } ToolsetMetadataOrgs = toolsets.ToolsetMetadata{ ID: "orgs", @@ -107,53 +112,6 @@ var ( } ) -func AvailableToolsets() []toolsets.ToolsetMetadata { - return []toolsets.ToolsetMetadata{ - ToolsetMetadataContext, - ToolsetMetadataRepos, - ToolsetMetadataGit, - ToolsetMetadataIssues, - ToolsetMetadataPullRequests, - ToolsetMetadataUsers, - ToolsetMetadataOrgs, - ToolsetMetadataActions, - ToolsetMetadataCodeSecurity, - ToolsetMetadataSecretProtection, - ToolsetMetadataDependabot, - ToolsetMetadataNotifications, - ToolsetMetadataExperiments, - ToolsetMetadataDiscussions, - ToolsetMetadataGists, - ToolsetMetadataSecurityAdvisories, - ToolsetMetadataProjects, - ToolsetMetadataStargazers, - ToolsetMetadataDynamic, - ToolsetLabels, - } -} - -// GetValidToolsetIDs returns a map of all valid toolset IDs for quick lookup -func GetValidToolsetIDs() map[toolsets.ToolsetID]bool { - validIDs := make(map[toolsets.ToolsetID]bool) - for _, toolset := range AvailableToolsets() { - validIDs[toolset.ID] = true - } - // Add special keywords - validIDs[ToolsetMetadataAll.ID] = true - validIDs[ToolsetMetadataDefault.ID] = true - return validIDs -} - -func GetDefaultToolsetIDs() []toolsets.ToolsetID { - return []toolsets.ToolsetID{ - ToolsetMetadataContext.ID, - ToolsetMetadataRepos.ID, - ToolsetMetadataIssues.ID, - ToolsetMetadataPullRequests.ID, - ToolsetMetadataUsers.ID, - } -} - // AllTools returns all tools with their embedded toolset metadata. // Tool functions return ServerTool directly with toolset info. func AllTools(t translations.TranslationHelperFunc) []toolsets.ServerTool { @@ -304,16 +262,19 @@ func ToStringPtr(s string) *string { // GenerateToolsetsHelp generates the help text for the toolsets flag func GenerateToolsetsHelp() string { - // Format default tools - defaultIDs := GetDefaultToolsetIDs() + // Get toolset group to derive defaults and available toolsets + r := NewRegistry(stubTranslator) + + // Format default tools from metadata + defaultIDs := r.DefaultToolsetIDs() defaultStrings := make([]string, len(defaultIDs)) for i, id := range defaultIDs { defaultStrings[i] = string(id) } defaultTools := strings.Join(defaultStrings, ", ") - // Format available tools with line breaks for better readability - allToolsets := AvailableToolsets() + // Get all available toolsets (excludes context and dynamic for display) + allToolsets := r.AvailableToolsets("context", "dynamic") var availableToolsLines []string const maxLineLength = 70 currentLine := "" @@ -349,6 +310,10 @@ func GenerateToolsetsHelp() string { return toolsetsHelp } +// stubTranslator is a passthrough translator for cases where we need a Registry +// but don't need actual translations (e.g., getting toolset IDs for CLI help). +func stubTranslator(_, fallback string) string { return fallback } + // AddDefaultToolset removes the default toolset and expands it to the actual default toolset IDs func AddDefaultToolset(result []string) []string { hasDefault := false @@ -367,43 +332,16 @@ func AddDefaultToolset(result []string) []string { result = RemoveToolset(result, string(ToolsetMetadataDefault.ID)) - for _, defaultToolset := range GetDefaultToolsetIDs() { - if !seen[string(defaultToolset)] { - result = append(result, string(defaultToolset)) + // Get default toolset IDs from the Registry + r := NewRegistry(stubTranslator) + for _, id := range r.DefaultToolsetIDs() { + if !seen[string(id)] { + result = append(result, string(id)) } } return result } -// cleanToolsets cleans and handles special toolset keywords: -// - Duplicates are removed from the result -// - Removes whitespaces -// - Validates toolset names and returns invalid ones separately - for warning reporting -// Returns: (toolsets, invalidToolsets) -func CleanToolsets(enabledToolsets []string) ([]string, []string) { - seen := make(map[string]bool) - result := make([]string, 0, len(enabledToolsets)) - invalid := make([]string, 0) - validIDs := 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 - result = append(result, trimmed) - if !validIDs[toolsets.ToolsetID(trimmed)] { - invalid = append(invalid, trimmed) - } - } - } - - return result, invalid -} - func RemoveToolset(tools []string, toRemove string) []string { result := make([]string, 0, len(tools)) for _, tool := range tools { diff --git a/pkg/github/tools_test.go b/pkg/github/tools_test.go index 45c1e746f..4e6d91980 100644 --- a/pkg/github/tools_test.go +++ b/pkg/github/tools_test.go @@ -7,135 +7,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestCleanToolsets(t *testing.T) { - tests := []struct { - name string - input []string - expected []string - expectedInvalid []string - }{ - { - name: "empty slice", - input: []string{}, - expected: []string{}, - }, - { - name: "nil input slice", - input: nil, - expected: []string{}, - }, - // CleanToolsets only cleans - it does NOT filter out special keywords - { - name: "default keyword preserved", - input: []string{"default"}, - expected: []string{"default"}, - }, - { - name: "default with additional toolsets", - input: []string{"default", "actions", "gists"}, - expected: []string{"default", "actions", "gists"}, - }, - { - name: "all keyword preserved", - input: []string{"all", "actions"}, - expected: []string{"all", "actions"}, - }, - { - name: "no special keywords", - input: []string{"actions", "gists", "notifications"}, - expected: []string{"actions", "gists", "notifications"}, - }, - { - name: "duplicate toolsets without special keywords", - input: []string{"actions", "gists", "actions"}, - expected: []string{"actions", "gists"}, - }, - { - name: "duplicate toolsets with default", - input: []string{"context", "repos", "issues", "pull_requests", "users", "default"}, - expected: []string{"context", "repos", "issues", "pull_requests", "users", "default"}, - }, - { - name: "default appears multiple times - duplicates removed", - input: []string{"default", "actions", "default", "gists", "default"}, - expected: []string{"default", "actions", "gists"}, - }, - // Whitespace test cases - { - name: "whitespace check - leading and trailing whitespace on regular toolsets", - input: []string{" actions ", " gists ", "notifications"}, - expected: []string{"actions", "gists", "notifications"}, - }, - { - name: "whitespace check - default toolset with whitespace", - input: []string{" actions ", " default ", "notifications"}, - expected: []string{"actions", "default", "notifications"}, - }, - { - name: "whitespace check - all toolset with whitespace", - input: []string{" all ", " actions "}, - expected: []string{"all", "actions"}, - }, - // Invalid toolset test cases - { - name: "mix of valid and invalid toolsets", - input: []string{"actions", "invalid_toolset", "gists", "typo_repo"}, - expected: []string{"actions", "invalid_toolset", "gists", "typo_repo"}, - expectedInvalid: []string{"invalid_toolset", "typo_repo"}, - }, - { - name: "invalid with whitespace", - input: []string{" invalid_tool ", " actions ", " typo_gist "}, - expected: []string{"invalid_tool", "actions", "typo_gist"}, - expectedInvalid: []string{"invalid_tool", "typo_gist"}, - }, - { - name: "empty string in toolsets", - input: []string{"", "actions", " ", "gists"}, - expected: []string{"actions", "gists"}, - expectedInvalid: []string{}, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, invalid := CleanToolsets(tt.input) - - 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") - }) - } -} - func TestAddDefaultToolset(t *testing.T) { tests := []struct { name string diff --git a/pkg/github/tools_validation_test.go b/pkg/github/tools_validation_test.go index ae79c455a..d53243b42 100644 --- a/pkg/github/tools_validation_test.go +++ b/pkg/github/tools_validation_test.go @@ -42,20 +42,6 @@ func TestAllToolsHaveRequiredMetadata(t *testing.T) { } } -// TestAllToolsHaveValidToolsetID validates that all tools belong to known toolsets -func TestAllToolsHaveValidToolsetID(t *testing.T) { - tools := AllTools(stubTranslation) - validToolsetIDs := GetValidToolsetIDs() - - for _, tool := range tools { - t.Run(tool.Tool.Name, func(t *testing.T) { - assert.True(t, validToolsetIDs[tool.Toolset.ID], - "Tool %q has invalid Toolset.ID %q - must be one of the defined toolsets", - tool.Tool.Name, tool.Toolset.ID) - }) - } -} - // TestAllResourcesHaveRequiredMetadata validates that all resources have mandatory metadata func TestAllResourcesHaveRequiredMetadata(t *testing.T) { // Resources are now stateless - no client functions needed @@ -95,32 +81,6 @@ func TestAllPromptsHaveRequiredMetadata(t *testing.T) { } } -// TestAllResourcesHaveValidToolsetID validates that all resources belong to known toolsets -func TestAllResourcesHaveValidToolsetID(t *testing.T) { - resources := AllResources(stubTranslation) - validToolsetIDs := GetValidToolsetIDs() - - for _, res := range resources { - t.Run(res.Template.Name, func(t *testing.T) { - assert.True(t, validToolsetIDs[res.Toolset.ID], - "Resource %q has invalid Toolset.ID %q", res.Template.Name, res.Toolset.ID) - }) - } -} - -// TestAllPromptsHaveValidToolsetID validates that all prompts belong to known toolsets -func TestAllPromptsHaveValidToolsetID(t *testing.T) { - prompts := AllPrompts(stubTranslation) - validToolsetIDs := GetValidToolsetIDs() - - for _, prompt := range prompts { - t.Run(prompt.Prompt.Name, func(t *testing.T) { - assert.True(t, validToolsetIDs[prompt.Toolset.ID], - "Prompt %q has invalid Toolset.ID %q", prompt.Prompt.Name, prompt.Toolset.ID) - }) - } -} - // TestToolReadOnlyHintConsistency validates that read-only tools are correctly annotated func TestToolReadOnlyHintConsistency(t *testing.T) { tools := AllTools(stubTranslation) @@ -190,17 +150,6 @@ func TestAllToolsHaveHandlerFunc(t *testing.T) { } } -// TestDefaultToolsetsAreValid ensures default toolset IDs are all valid -func TestDefaultToolsetsAreValid(t *testing.T) { - defaults := GetDefaultToolsetIDs() - valid := GetValidToolsetIDs() - - for _, id := range defaults { - assert.True(t, valid[id], - "Default toolset ID %q is not in the valid toolset list", id) - } -} - // TestToolsetMetadataConsistency ensures tools in the same toolset have consistent descriptions func TestToolsetMetadataConsistency(t *testing.T) { tools := AllTools(stubTranslation) diff --git a/pkg/github/toolset_group.go b/pkg/github/toolset_group.go index 68db02db6..7330e08d3 100644 --- a/pkg/github/toolset_group.go +++ b/pkg/github/toolset_group.go @@ -5,17 +5,14 @@ import ( "github.com/github/github-mcp-server/pkg/translations" ) -// NewToolsetGroup creates a ToolsetGroup with all available tools, resources, and prompts. +// NewRegistry creates a Registry with all available tools, resources, and prompts. // Tools, resources, and prompts are self-describing with their toolset metadata embedded. // This function is stateless - no dependencies are captured. // Handlers are generated on-demand during registration via RegisterAll(ctx, server, deps). -// The "default" keyword in WithToolsets will expand to GetDefaultToolsetIDs(). -func NewToolsetGroup(t translations.TranslationHelperFunc) *toolsets.ToolsetGroup { - tsg := toolsets.NewToolsetGroup( - AllTools(t), - AllResources(t), - AllPrompts(t), - ) - tsg.SetDefaultToolsetIDs(GetDefaultToolsetIDs()) - return tsg +// The "default" keyword in WithToolsets will expand to toolsets marked with Default: true. +func NewRegistry(t translations.TranslationHelperFunc) *toolsets.Registry { + return toolsets.NewRegistry(). + SetTools(AllTools(t)). + SetResources(AllResources(t)). + SetPrompts(AllPrompts(t)) } diff --git a/pkg/toolsets/server_tool.go b/pkg/toolsets/server_tool.go index 0e782e631..eb30f01f4 100644 --- a/pkg/toolsets/server_tool.go +++ b/pkg/toolsets/server_tool.go @@ -24,6 +24,8 @@ type ToolsetMetadata struct { ID ToolsetID // Description provides a human-readable description of the toolset Description string + // Default indicates this toolset should be enabled by default + Default bool } // ServerTool represents an MCP tool with metadata and a handler generator function. diff --git a/pkg/toolsets/toolsets.go b/pkg/toolsets/toolsets.go index 82896510e..34e5fa923 100644 --- a/pkg/toolsets/toolsets.go +++ b/pkg/toolsets/toolsets.go @@ -6,6 +6,7 @@ import ( "os" "slices" "sort" + "strings" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -116,14 +117,14 @@ func NewServerPrompt(toolset ToolsetMetadata, prompt mcp.Prompt, handler mcp.Pro } } -// ToolsetGroup holds a collection of tools, resources, and prompts. -// It supports immutable filtering operations that return new ToolsetGroups +// Registry holds a collection of tools, resources, and prompts. +// It supports immutable filtering operations that return new Registrys // without modifying the original. This design allows for: // - Building a full set of tools/resources/prompts once // - Applying filters (read-only, feature flags, enabled toolsets) without mutation // - Deterministic ordering for documentation generation // - Lazy dependency injection only when registering with a server -type ToolsetGroup struct { +type Registry struct { // tools holds all tools in this group tools []ServerTool // resourceTemplates holds all resource templates in this group @@ -132,8 +133,6 @@ type ToolsetGroup struct { prompts []ServerPrompt // deprecatedAliases maps old tool names to new canonical names deprecatedAliases map[string]string - // defaultToolsetIDs are the toolset IDs that "default" expands to - defaultToolsetIDs []ToolsetID // Filters - these control what's returned by Available* methods // readOnly when true filters out write tools @@ -148,6 +147,8 @@ type ToolsetGroup struct { // Takes context and flag name, returns (enabled, error). If error, log and treat as false. // If checker is nil, all flag checks return false. featureChecker FeatureFlagChecker + // unrecognizedToolsets holds toolset IDs that were requested but don't match any registered toolsets + unrecognizedToolsets []string } // FeatureFlagChecker is a function that checks if a feature flag is enabled. @@ -155,78 +156,99 @@ type ToolsetGroup struct { // Returns (enabled, error). If error occurs, the caller should log and treat as false. type FeatureFlagChecker func(ctx context.Context, flagName string) (bool, error) -// NewToolsetGroup creates a new ToolsetGroup from the provided tools, resources, and prompts. -// The group is created with no filters applied. -func NewToolsetGroup(tools []ServerTool, resources []ServerResourceTemplate, prompts []ServerPrompt) *ToolsetGroup { - return &ToolsetGroup{ - tools: tools, - resourceTemplates: resources, - prompts: prompts, +// NewRegistry creates a new empty Registry. +// Use SetTools, SetResources, SetPrompts to populate it. +func NewRegistry() *Registry { + return &Registry{ deprecatedAliases: make(map[string]string), - readOnly: false, - enabledToolsets: nil, - additionalTools: nil, - featureChecker: nil, } } -// copy creates a shallow copy of the ToolsetGroup for immutable operations. -func (tg *ToolsetGroup) copy() *ToolsetGroup { - newTG := &ToolsetGroup{ - tools: tg.tools, // slices are shared (immutable) - resourceTemplates: tg.resourceTemplates, - prompts: tg.prompts, - deprecatedAliases: tg.deprecatedAliases, - defaultToolsetIDs: tg.defaultToolsetIDs, - readOnly: tg.readOnly, - featureChecker: tg.featureChecker, +// SetTools sets the tools for this group. Returns self for chaining. +func (r *Registry) SetTools(tools []ServerTool) *Registry { + r.tools = tools + return r +} + +// SetResources sets the resource templates for this group. Returns self for chaining. +func (r *Registry) SetResources(resources []ServerResourceTemplate) *Registry { + r.resourceTemplates = resources + return r +} + +// SetPrompts sets the prompts for this group. Returns self for chaining. +func (r *Registry) SetPrompts(prompts []ServerPrompt) *Registry { + r.prompts = prompts + return r +} + +// copy creates a shallow copy of the Registry for immutable operations. +func (r *Registry) copy() *Registry { + newTG := &Registry{ + tools: r.tools, // slices are shared (immutable) + resourceTemplates: r.resourceTemplates, + prompts: r.prompts, + deprecatedAliases: r.deprecatedAliases, + readOnly: r.readOnly, + featureChecker: r.featureChecker, } // Copy maps if they exist - if tg.enabledToolsets != nil { - newTG.enabledToolsets = make(map[ToolsetID]bool, len(tg.enabledToolsets)) - for k, v := range tg.enabledToolsets { + if r.enabledToolsets != nil { + newTG.enabledToolsets = make(map[ToolsetID]bool, len(r.enabledToolsets)) + for k, v := range r.enabledToolsets { newTG.enabledToolsets[k] = v } } - if tg.additionalTools != nil { - newTG.additionalTools = make(map[string]bool, len(tg.additionalTools)) - for k, v := range tg.additionalTools { + if r.additionalTools != nil { + newTG.additionalTools = make(map[string]bool, len(r.additionalTools)) + for k, v := range r.additionalTools { newTG.additionalTools[k] = v } } + newTG.unrecognizedToolsets = r.unrecognizedToolsets return newTG } -// WithReadOnly returns a new ToolsetGroup with read-only mode set. +// WithReadOnly returns a new Registry with read-only mode set. // When true, write tools are filtered out from Available* methods. -func (tg *ToolsetGroup) WithReadOnly(readOnly bool) *ToolsetGroup { - newTG := tg.copy() +func (r *Registry) WithReadOnly(readOnly bool) *Registry { + newTG := r.copy() newTG.readOnly = readOnly return newTG } -// SetDefaultToolsetIDs configures which toolset IDs the "default" keyword expands to. -// This should be called before WithToolsets if you want "default" to be recognized. -func (tg *ToolsetGroup) SetDefaultToolsetIDs(ids []ToolsetID) *ToolsetGroup { - tg.defaultToolsetIDs = ids - return tg -} - -// WithToolsets returns a new ToolsetGroup that only includes items from the specified toolsets. +// WithToolsets returns a new Registry that only includes items from the specified toolsets. // Special keywords: // - "all": enables all toolsets -// - "default": expands to the default toolset IDs (set via SetDefaultToolsetIDs) +// - "default": expands to toolsets marked with Default: true in their metadata +// +// Input strings are trimmed of whitespace and duplicates are removed. +// Toolset IDs that don't match any registered toolsets are tracked and can be +// retrieved via UnrecognizedToolsets() for warning purposes. // // Pass nil to use default toolsets. Pass an empty slice to disable all toolsets // (useful for dynamic toolsets mode where tools are enabled on demand). -func (tg *ToolsetGroup) WithToolsets(toolsetIDs []string) *ToolsetGroup { - newTG := tg.copy() +func (r *Registry) WithToolsets(toolsetIDs []string) *Registry { + newTG := r.copy() + newTG.unrecognizedToolsets = nil // reset for fresh calculation + + // Build a set of valid toolset IDs for validation + validIDs := make(map[ToolsetID]bool) + for _, t := range r.tools { + validIDs[t.Toolset.ID] = true + } + for _, r := range r.resourceTemplates { + validIDs[r.Toolset.ID] = true + } + for _, p := range r.prompts { + validIDs[p.Toolset.ID] = true + } // Check for "all" keyword - enables all toolsets for _, id := range toolsetIDs { - if id == "all" { + if strings.TrimSpace(id) == "all" { newTG.enabledToolsets = nil return newTG } @@ -237,26 +259,38 @@ func (tg *ToolsetGroup) WithToolsets(toolsetIDs []string) *ToolsetGroup { toolsetIDs = []string{"default"} } - // Expand "default" keyword and collect other IDs + // Expand "default" keyword, trim whitespace, collect other IDs, and track unrecognized seen := make(map[ToolsetID]bool) expanded := make([]ToolsetID, 0, len(toolsetIDs)) + var unrecognized []string + for _, id := range toolsetIDs { - if id == "default" { - for _, defaultID := range tg.defaultToolsetIDs { + trimmed := strings.TrimSpace(id) + if trimmed == "" { + continue + } + if trimmed == "default" { + for _, defaultID := range r.DefaultToolsetIDs() { if !seen[defaultID] { seen[defaultID] = true expanded = append(expanded, defaultID) } } } else { - tsID := ToolsetID(id) + tsID := ToolsetID(trimmed) if !seen[tsID] { seen[tsID] = true expanded = append(expanded, tsID) + // Track if this toolset doesn't exist + if !validIDs[tsID] { + unrecognized = append(unrecognized, trimmed) + } } } } + newTG.unrecognizedToolsets = unrecognized + if len(expanded) == 0 { newTG.enabledToolsets = make(map[ToolsetID]bool) return newTG @@ -269,13 +303,19 @@ func (tg *ToolsetGroup) WithToolsets(toolsetIDs []string) *ToolsetGroup { return newTG } -// WithTools returns a new ToolsetGroup with additional tools that bypass toolset filtering. +// UnrecognizedToolsets returns toolset IDs that were passed to WithToolsets but don't +// match any registered toolsets. This is useful for warning users about typos. +func (r *Registry) UnrecognizedToolsets() []string { + return r.unrecognizedToolsets +} + +// WithTools returns a new Registry with 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. // Deprecated tool aliases are automatically resolved to their canonical names. // Pass nil or empty slice to clear additional tools. -func (tg *ToolsetGroup) WithTools(toolNames []string) *ToolsetGroup { - newTG := tg.copy() +func (r *Registry) WithTools(toolNames []string) *Registry { + newTG := r.copy() if len(toolNames) == 0 { newTG.additionalTools = nil return newTG @@ -283,7 +323,7 @@ func (tg *ToolsetGroup) WithTools(toolNames []string) *ToolsetGroup { newTG.additionalTools = make(map[string]bool, len(toolNames)) for _, name := range toolNames { // Resolve deprecated aliases to canonical names - if canonical, isAlias := tg.deprecatedAliases[name]; isAlias { + if canonical, isAlias := r.deprecatedAliases[name]; isAlias { newTG.additionalTools[canonical] = true } else { newTG.additionalTools[name] = true @@ -292,13 +332,13 @@ func (tg *ToolsetGroup) WithTools(toolNames []string) *ToolsetGroup { return newTG } -// WithFeatureChecker returns a new ToolsetGroup with a feature checker function. +// WithFeatureChecker returns a new Registry with a feature checker function. // The checker receives a context (for actor extraction) and feature flag name, returns (enabled, error). // If error occurs, it will be logged and treated as false. // If checker is nil, all feature flag checks return false (items with FeatureFlagEnable are excluded, // items with FeatureFlagDisable are included). -func (tg *ToolsetGroup) WithFeatureChecker(checker FeatureFlagChecker) *ToolsetGroup { - newTG := tg.copy() +func (r *Registry) WithFeatureChecker(checker FeatureFlagChecker) *Registry { + newTG := r.copy() newTG.featureChecker = checker return newTG } @@ -315,7 +355,7 @@ const ( MCPMethodPromptsGet = "prompts/get" ) -// ForMCPRequest returns a ToolsetGroup optimized for a specific MCP request. +// ForMCPRequest returns a Registry optimized for a specific MCP request. // This is designed for servers that create a new instance per request (like the remote server), // allowing them to only register the items needed for that specific request rather than all ~90 tools. // @@ -323,7 +363,7 @@ const ( // - method: The MCP method being called (use MCP* constants) // - itemName: Name of specific item for call/get methods (tool name, resource URI, or prompt name) // -// Returns a new ToolsetGroup containing only the items relevant to the request: +// Returns a new Registry containing only the items relevant to the request: // - MCPMethodInitialize: Empty (capabilities are set via ServerOptions, not registration) // - MCPMethodToolsList: All available tools (no resources/prompts) // - MCPMethodToolsCall: Only the named tool @@ -334,8 +374,8 @@ const ( // - Unknown methods: Empty (no items registered) // // All existing filters (read-only, toolsets, etc.) still apply to the returned items. -func (tg *ToolsetGroup) ForMCPRequest(method string, itemName string) *ToolsetGroup { - result := tg.copy() +func (r *Registry) ForMCPRequest(method string, itemName string) *Registry { + result := r.copy() // Helper to clear all item types clearAll := func() { @@ -352,21 +392,21 @@ func (tg *ToolsetGroup) ForMCPRequest(method string, itemName string) *ToolsetGr case MCPMethodToolsCall: result.resourceTemplates, result.prompts = nil, nil if itemName != "" { - result.tools = tg.filterToolsByName(itemName) + result.tools = r.filterToolsByName(itemName) } case MCPMethodResourcesList, MCPMethodResourcesTemplatesList: result.tools, result.prompts = nil, nil case MCPMethodResourcesRead: result.tools, result.prompts = nil, nil if itemName != "" { - result.resourceTemplates = tg.filterResourcesByURI(itemName) + result.resourceTemplates = r.filterResourcesByURI(itemName) } case MCPMethodPromptsList: result.tools, result.resourceTemplates = nil, nil case MCPMethodPromptsGet: result.tools, result.resourceTemplates = nil, nil if itemName != "" { - result.prompts = tg.filterPromptsByName(itemName) + result.prompts = r.filterPromptsByName(itemName) } default: clearAll() @@ -377,18 +417,18 @@ func (tg *ToolsetGroup) ForMCPRequest(method string, itemName string) *ToolsetGr // filterToolsByName returns tools matching the given name, checking deprecated aliases. // Returns from the current tools slice (respects existing filter chain). -func (tg *ToolsetGroup) filterToolsByName(name string) []ServerTool { +func (r *Registry) filterToolsByName(name string) []ServerTool { // First check for exact match - for i := range tg.tools { - if tg.tools[i].Tool.Name == name { - return []ServerTool{tg.tools[i]} + for i := range r.tools { + if r.tools[i].Tool.Name == name { + return []ServerTool{r.tools[i]} } } // Check if name is a deprecated alias - if canonical, isAlias := tg.deprecatedAliases[name]; isAlias { - for i := range tg.tools { - if tg.tools[i].Tool.Name == canonical { - return []ServerTool{tg.tools[i]} + if canonical, isAlias := r.deprecatedAliases[name]; isAlias { + for i := range r.tools { + if r.tools[i].Tool.Name == canonical { + return []ServerTool{r.tools[i]} } } } @@ -396,33 +436,33 @@ func (tg *ToolsetGroup) filterToolsByName(name string) []ServerTool { } // filterResourcesByURI returns resource templates matching the given URI pattern. -func (tg *ToolsetGroup) filterResourcesByURI(uri string) []ServerResourceTemplate { - for i := range tg.resourceTemplates { +func (r *Registry) filterResourcesByURI(uri string) []ServerResourceTemplate { + for i := range r.resourceTemplates { // Check if URI matches the template pattern (exact match on URITemplate string) - if tg.resourceTemplates[i].Template.URITemplate == uri { - return []ServerResourceTemplate{tg.resourceTemplates[i]} + if r.resourceTemplates[i].Template.URITemplate == uri { + return []ServerResourceTemplate{r.resourceTemplates[i]} } } return []ServerResourceTemplate{} } // filterPromptsByName returns prompts matching the given name. -func (tg *ToolsetGroup) filterPromptsByName(name string) []ServerPrompt { - for i := range tg.prompts { - if tg.prompts[i].Prompt.Name == name { - return []ServerPrompt{tg.prompts[i]} +func (r *Registry) filterPromptsByName(name string) []ServerPrompt { + for i := range r.prompts { + if r.prompts[i].Prompt.Name == name { + return []ServerPrompt{r.prompts[i]} } } return []ServerPrompt{} } -// WithDeprecatedToolAliases returns a new ToolsetGroup with the given deprecated aliases added. +// WithDeprecatedToolAliases returns a new Registry with the given deprecated aliases added. // Aliases map old tool names to new canonical names. -func (tg *ToolsetGroup) WithDeprecatedToolAliases(aliases map[string]string) *ToolsetGroup { - newTG := tg.copy() +func (r *Registry) WithDeprecatedToolAliases(aliases map[string]string) *Registry { + newTG := r.copy() // Ensure we have a fresh map - newTG.deprecatedAliases = make(map[string]string, len(tg.deprecatedAliases)+len(aliases)) - for k, v := range tg.deprecatedAliases { + newTG.deprecatedAliases = make(map[string]string, len(r.deprecatedAliases)+len(aliases)) + for k, v := range r.deprecatedAliases { newTG.deprecatedAliases[k] = v } for oldName, newName := range aliases { @@ -432,21 +472,21 @@ func (tg *ToolsetGroup) WithDeprecatedToolAliases(aliases map[string]string) *To } // isToolsetEnabled checks if a toolset is enabled based on current filters. -func (tg *ToolsetGroup) isToolsetEnabled(toolsetID ToolsetID) bool { +func (r *Registry) isToolsetEnabled(toolsetID ToolsetID) bool { // Check enabled toolsets filter - if tg.enabledToolsets != nil { - return tg.enabledToolsets[toolsetID] + if r.enabledToolsets != nil { + return r.enabledToolsets[toolsetID] } return true } // checkFeatureFlag checks a feature flag using the feature checker. // Returns false if checker is nil or returns an error (errors are logged). -func (tg *ToolsetGroup) checkFeatureFlag(ctx context.Context, flagName string) bool { - if tg.featureChecker == nil || flagName == "" { +func (r *Registry) checkFeatureFlag(ctx context.Context, flagName string) bool { + if r.featureChecker == nil || flagName == "" { return false } - enabled, err := tg.featureChecker(ctx, flagName) + enabled, err := r.featureChecker(ctx, flagName) if err != nil { fmt.Fprintf(os.Stderr, "Feature flag check error for %q: %v\n", flagName, err) return false @@ -457,34 +497,34 @@ func (tg *ToolsetGroup) checkFeatureFlag(ctx context.Context, flagName string) b // isFeatureFlagAllowed checks if an item passes feature flag filtering. // - If FeatureFlagEnable is set, the item is only allowed if the flag is enabled // - If FeatureFlagDisable is set, the item is excluded if the flag is enabled -func (tg *ToolsetGroup) isFeatureFlagAllowed(ctx context.Context, enableFlag, disableFlag string) bool { +func (r *Registry) isFeatureFlagAllowed(ctx context.Context, enableFlag, disableFlag string) bool { // Check enable flag - item requires this flag to be on - if enableFlag != "" && !tg.checkFeatureFlag(ctx, enableFlag) { + if enableFlag != "" && !r.checkFeatureFlag(ctx, enableFlag) { return false } // Check disable flag - item is excluded if this flag is on - if disableFlag != "" && tg.checkFeatureFlag(ctx, disableFlag) { + if disableFlag != "" && r.checkFeatureFlag(ctx, disableFlag) { return false } return true } // isToolEnabled checks if a specific tool is enabled based on current filters. -func (tg *ToolsetGroup) isToolEnabled(ctx context.Context, tool *ServerTool) bool { +func (r *Registry) isToolEnabled(ctx context.Context, tool *ServerTool) bool { // Check read-only filter first (applies to all tools) - if tg.readOnly && !tool.IsReadOnly() { + if r.readOnly && !tool.IsReadOnly() { return false } // Check feature flags - if !tg.isFeatureFlagAllowed(ctx, tool.FeatureFlagEnable, tool.FeatureFlagDisable) { + if !r.isFeatureFlagAllowed(ctx, tool.FeatureFlagEnable, tool.FeatureFlagDisable) { return false } // Check if tool is in additionalTools (bypasses toolset filter) - if tg.additionalTools != nil && tg.additionalTools[tool.Tool.Name] { + if r.additionalTools != nil && r.additionalTools[tool.Tool.Name] { return true } // Check toolset filter - if !tg.isToolsetEnabled(tool.Toolset.ID) { + if !r.isToolsetEnabled(tool.Toolset.ID) { return false } return true @@ -493,11 +533,11 @@ func (tg *ToolsetGroup) isToolEnabled(ctx context.Context, tool *ServerTool) boo // AvailableTools returns the tools that pass all current filters, // sorted deterministically by toolset ID, then tool name. // The context is used for feature flag evaluation. -func (tg *ToolsetGroup) AvailableTools(ctx context.Context) []ServerTool { +func (r *Registry) AvailableTools(ctx context.Context) []ServerTool { var result []ServerTool - for i := range tg.tools { - tool := &tg.tools[i] - if tg.isToolEnabled(ctx, tool) { + for i := range r.tools { + tool := &r.tools[i] + if r.isToolEnabled(ctx, tool) { result = append(result, *tool) } } @@ -516,15 +556,15 @@ func (tg *ToolsetGroup) AvailableTools(ctx context.Context) []ServerTool { // AvailableResourceTemplates returns resource templates that pass all current filters, // sorted deterministically by toolset ID, then template name. // The context is used for feature flag evaluation. -func (tg *ToolsetGroup) AvailableResourceTemplates(ctx context.Context) []ServerResourceTemplate { +func (r *Registry) AvailableResourceTemplates(ctx context.Context) []ServerResourceTemplate { var result []ServerResourceTemplate - for i := range tg.resourceTemplates { - res := &tg.resourceTemplates[i] + for i := range r.resourceTemplates { + res := &r.resourceTemplates[i] // Check feature flags - if !tg.isFeatureFlagAllowed(ctx, res.FeatureFlagEnable, res.FeatureFlagDisable) { + if !r.isFeatureFlagAllowed(ctx, res.FeatureFlagEnable, res.FeatureFlagDisable) { continue } - if tg.isToolsetEnabled(res.Toolset.ID) { + if r.isToolsetEnabled(res.Toolset.ID) { result = append(result, *res) } } @@ -543,15 +583,15 @@ func (tg *ToolsetGroup) AvailableResourceTemplates(ctx context.Context) []Server // AvailablePrompts returns prompts that pass all current filters, // sorted deterministically by toolset ID, then prompt name. // The context is used for feature flag evaluation. -func (tg *ToolsetGroup) AvailablePrompts(ctx context.Context) []ServerPrompt { +func (r *Registry) AvailablePrompts(ctx context.Context) []ServerPrompt { var result []ServerPrompt - for i := range tg.prompts { - prompt := &tg.prompts[i] + for i := range r.prompts { + prompt := &r.prompts[i] // Check feature flags - if !tg.isFeatureFlagAllowed(ctx, prompt.FeatureFlagEnable, prompt.FeatureFlagDisable) { + if !r.isFeatureFlagAllowed(ctx, prompt.FeatureFlagEnable, prompt.FeatureFlagDisable) { continue } - if tg.isToolsetEnabled(prompt.Toolset.ID) { + if r.isToolsetEnabled(prompt.Toolset.ID) { result = append(result, *prompt) } } @@ -568,16 +608,44 @@ func (tg *ToolsetGroup) AvailablePrompts(ctx context.Context) []ServerPrompt { } // ToolsetIDs returns a sorted list of unique toolset IDs from all tools in this group. -func (tg *ToolsetGroup) ToolsetIDs() []ToolsetID { +func (r *Registry) ToolsetIDs() []ToolsetID { + seen := make(map[ToolsetID]bool) + for i := range r.tools { + seen[r.tools[i].Toolset.ID] = true + } + for i := range r.resourceTemplates { + seen[r.resourceTemplates[i].Toolset.ID] = true + } + for i := range r.prompts { + seen[r.prompts[i].Toolset.ID] = true + } + + ids := make([]ToolsetID, 0, len(seen)) + for id := range seen { + ids = append(ids, id) + } + sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] }) + return ids +} + +// DefaultToolsetIDs returns the IDs of toolsets marked as Default in their metadata. +// The IDs are returned in sorted order for deterministic output. +func (r *Registry) DefaultToolsetIDs() []ToolsetID { seen := make(map[ToolsetID]bool) - for i := range tg.tools { - seen[tg.tools[i].Toolset.ID] = true + for i := range r.tools { + if r.tools[i].Toolset.Default { + seen[r.tools[i].Toolset.ID] = true + } } - for i := range tg.resourceTemplates { - seen[tg.resourceTemplates[i].Toolset.ID] = true + for i := range r.resourceTemplates { + if r.resourceTemplates[i].Toolset.Default { + seen[r.resourceTemplates[i].Toolset.ID] = true + } } - for i := range tg.prompts { - seen[tg.prompts[i].Toolset.ID] = true + for i := range r.prompts { + if r.prompts[i].Toolset.Default { + seen[r.prompts[i].Toolset.ID] = true + } } ids := make([]ToolsetID, 0, len(seen)) @@ -589,22 +657,22 @@ func (tg *ToolsetGroup) ToolsetIDs() []ToolsetID { } // ToolsetDescriptions returns a map of toolset ID to description for all toolsets. -func (tg *ToolsetGroup) ToolsetDescriptions() map[ToolsetID]string { +func (r *Registry) ToolsetDescriptions() map[ToolsetID]string { descriptions := make(map[ToolsetID]string) - for i := range tg.tools { - t := &tg.tools[i] + for i := range r.tools { + t := &r.tools[i] if t.Toolset.Description != "" { descriptions[t.Toolset.ID] = t.Toolset.Description } } - for i := range tg.resourceTemplates { - r := &tg.resourceTemplates[i] + for i := range r.resourceTemplates { + r := &r.resourceTemplates[i] if r.Toolset.Description != "" { descriptions[r.Toolset.ID] = r.Toolset.Description } } - for i := range tg.prompts { - p := &tg.prompts[i] + for i := range r.prompts { + p := &r.prompts[i] if p.Toolset.Description != "" { descriptions[p.Toolset.ID] = p.Toolset.Description } @@ -615,13 +683,13 @@ func (tg *ToolsetGroup) ToolsetDescriptions() map[ToolsetID]string { // ToolsForToolset returns all tools belonging to a specific toolset. // This method bypasses the toolset enabled filter (for dynamic toolset registration), // but still respects the read-only filter. -func (tg *ToolsetGroup) ToolsForToolset(toolsetID ToolsetID) []ServerTool { +func (r *Registry) ToolsForToolset(toolsetID ToolsetID) []ServerTool { var result []ServerTool - for i := range tg.tools { - tool := &tg.tools[i] + for i := range r.tools { + tool := &r.tools[i] // Only check read-only filter, not toolset enabled filter if tool.Toolset.ID == toolsetID { - if tg.readOnly && !tool.IsReadOnly() { + if r.readOnly && !tool.IsReadOnly() { continue } result = append(result, *tool) @@ -638,34 +706,34 @@ func (tg *ToolsetGroup) ToolsForToolset(toolsetID ToolsetID) []ServerTool { // RegisterTools registers all available tools with the server using the provided dependencies. // The context is used for feature flag evaluation. -func (tg *ToolsetGroup) RegisterTools(ctx context.Context, s *mcp.Server, deps any) { - for _, tool := range tg.AvailableTools(ctx) { +func (r *Registry) RegisterTools(ctx context.Context, s *mcp.Server, deps any) { + for _, tool := range r.AvailableTools(ctx) { tool.RegisterFunc(s, deps) } } // RegisterResourceTemplates registers all available resource templates with the server. // The context is used for feature flag evaluation. -func (tg *ToolsetGroup) RegisterResourceTemplates(ctx context.Context, s *mcp.Server, deps any) { - for _, res := range tg.AvailableResourceTemplates(ctx) { +func (r *Registry) RegisterResourceTemplates(ctx context.Context, s *mcp.Server, deps any) { + for _, res := range r.AvailableResourceTemplates(ctx) { s.AddResourceTemplate(&res.Template, res.Handler(deps)) } } // RegisterPrompts registers all available prompts with the server. // The context is used for feature flag evaluation. -func (tg *ToolsetGroup) RegisterPrompts(ctx context.Context, s *mcp.Server) { - for _, prompt := range tg.AvailablePrompts(ctx) { +func (r *Registry) RegisterPrompts(ctx context.Context, s *mcp.Server) { + for _, prompt := range r.AvailablePrompts(ctx) { s.AddPrompt(&prompt.Prompt, prompt.Handler) } } // RegisterAll registers all available tools, resources, and prompts with the server. // The context is used for feature flag evaluation. -func (tg *ToolsetGroup) RegisterAll(ctx context.Context, s *mcp.Server, deps any) { - tg.RegisterTools(ctx, s, deps) - tg.RegisterResourceTemplates(ctx, s, deps) - tg.RegisterPrompts(ctx, s) +func (r *Registry) RegisterAll(ctx context.Context, s *mcp.Server, deps any) { + r.RegisterTools(ctx, s, deps) + r.RegisterResourceTemplates(ctx, s, deps) + r.RegisterPrompts(ctx, s) } // ResolveToolAliases resolves deprecated tool aliases to their canonical names. @@ -673,11 +741,11 @@ func (tg *ToolsetGroup) RegisterAll(ctx context.Context, s *mcp.Server, deps any // Returns: // - resolved: tool names with aliases replaced by canonical names // - aliasesUsed: map of oldName → newName for each alias that was resolved -func (tg *ToolsetGroup) ResolveToolAliases(toolNames []string) (resolved []string, aliasesUsed map[string]string) { +func (r *Registry) ResolveToolAliases(toolNames []string) (resolved []string, aliasesUsed map[string]string) { resolved = make([]string, 0, len(toolNames)) aliasesUsed = make(map[string]string) for _, toolName := range toolNames { - if canonicalName, isAlias := tg.deprecatedAliases[toolName]; isAlias { + if canonicalName, isAlias := r.deprecatedAliases[toolName]; isAlias { fmt.Fprintf(os.Stderr, "Warning: tool %q is deprecated, use %q instead\n", toolName, canonicalName) aliasesUsed[toolName] = canonicalName resolved = append(resolved, canonicalName) @@ -691,9 +759,9 @@ func (tg *ToolsetGroup) ResolveToolAliases(toolNames []string) (resolved []strin // FindToolByName searches all tools for one matching the given name. // Returns the tool, its toolset ID, and an error if not found. // This searches ALL tools regardless of filters. -func (tg *ToolsetGroup) FindToolByName(toolName string) (*ServerTool, ToolsetID, error) { - for i := range tg.tools { - tool := &tg.tools[i] +func (r *Registry) FindToolByName(toolName string) (*ServerTool, ToolsetID, error) { + for i := range r.tools { + tool := &r.tools[i] if tool.Tool.Name == toolName { return tool, tool.Toolset.ID, nil } @@ -702,19 +770,19 @@ func (tg *ToolsetGroup) FindToolByName(toolName string) (*ServerTool, ToolsetID, } // HasToolset checks if any tool/resource/prompt belongs to the given toolset. -func (tg *ToolsetGroup) HasToolset(toolsetID ToolsetID) bool { - for i := range tg.tools { - if tg.tools[i].Toolset.ID == toolsetID { +func (r *Registry) HasToolset(toolsetID ToolsetID) bool { + for i := range r.tools { + if r.tools[i].Toolset.ID == toolsetID { return true } } - for i := range tg.resourceTemplates { - if tg.resourceTemplates[i].Toolset.ID == toolsetID { + for i := range r.resourceTemplates { + if r.resourceTemplates[i].Toolset.ID == toolsetID { return true } } - for i := range tg.prompts { - if tg.prompts[i].Toolset.ID == toolsetID { + for i := range r.prompts { + if r.prompts[i].Toolset.ID == toolsetID { return true } } @@ -723,14 +791,14 @@ func (tg *ToolsetGroup) HasToolset(toolsetID ToolsetID) bool { // EnabledToolsetIDs returns the list of enabled toolset IDs based on current filters. // Returns all toolset IDs if no filter is set. -func (tg *ToolsetGroup) EnabledToolsetIDs() []ToolsetID { - if tg.enabledToolsets == nil { - return tg.ToolsetIDs() +func (r *Registry) EnabledToolsetIDs() []ToolsetID { + if r.enabledToolsets == nil { + return r.ToolsetIDs() } - ids := make([]ToolsetID, 0, len(tg.enabledToolsets)) - for id := range tg.enabledToolsets { - if tg.HasToolset(id) { + ids := make([]ToolsetID, 0, len(r.enabledToolsets)) + for id := range r.enabledToolsets { + if r.HasToolset(id) { ids = append(ids, id) } } @@ -739,23 +807,23 @@ func (tg *ToolsetGroup) EnabledToolsetIDs() []ToolsetID { } // IsToolsetEnabled checks if a toolset is currently enabled based on filters. -func (tg *ToolsetGroup) IsToolsetEnabled(toolsetID ToolsetID) bool { - return tg.isToolsetEnabled(toolsetID) +func (r *Registry) IsToolsetEnabled(toolsetID ToolsetID) bool { + return r.isToolsetEnabled(toolsetID) } // EnableToolset marks a toolset as enabled in this group. // This is used by dynamic toolset management to track which toolsets have been enabled. -func (tg *ToolsetGroup) EnableToolset(toolsetID ToolsetID) { - if tg.enabledToolsets == nil { +func (r *Registry) EnableToolset(toolsetID ToolsetID) { + if r.enabledToolsets == nil { // nil means all enabled, so nothing to do return } - tg.enabledToolsets[toolsetID] = true + r.enabledToolsets[toolsetID] = true } // AllTools returns all tools without any filtering, sorted deterministically. -func (tg *ToolsetGroup) AllTools() []ServerTool { - result := slices.Clone(tg.tools) +func (r *Registry) AllTools() []ServerTool { + result := slices.Clone(r.tools) // Sort deterministically: by toolset ID, then by tool name sort.Slice(result, func(i, j int) bool { @@ -772,8 +840,8 @@ func (tg *ToolsetGroup) AllTools() []ServerTool { // This is the ordered intersection of toolsets with reality - only toolsets that // actually contain tools are returned, sorted by toolset ID. // Optional exclude parameter filters out specific toolset IDs from the result. -func (tg *ToolsetGroup) AvailableToolsets(exclude ...ToolsetID) []ToolsetMetadata { - tools := tg.AllTools() +func (r *Registry) AvailableToolsets(exclude ...ToolsetID) []ToolsetMetadata { + tools := r.AllTools() if len(tools) == 0 { return nil } diff --git a/pkg/toolsets/toolsets_test.go b/pkg/toolsets/toolsets_test.go index 317dafbf6..0d1c35e2e 100644 --- a/pkg/toolsets/toolsets_test.go +++ b/pkg/toolsets/toolsets_test.go @@ -17,6 +17,34 @@ func testToolsetMetadata(id string) ToolsetMetadata { } } +// testToolsetMetadataWithDefault returns a ToolsetMetadata with Default flag for testing +func testToolsetMetadataWithDefault(id string, isDefault bool) ToolsetMetadata { + return ToolsetMetadata{ + ID: ToolsetID(id), + Description: "Test toolset: " + id, + Default: isDefault, + } +} + +// mockToolWithDefault creates a mock tool with a default toolset flag +func mockToolWithDefault(name string, toolsetID string, readOnly bool, isDefault bool) ServerTool { + return NewServerToolFromHandler( + mcp.Tool{ + Name: name, + Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: readOnly, + }, + InputSchema: json.RawMessage(`{"type":"object","properties":{}}`), + }, + testToolsetMetadataWithDefault(toolsetID, isDefault), + func(_ any) mcp.ToolHandler { + return func(_ context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) { + return nil, nil + } + }, + ) +} + // mockTool creates a minimal ServerTool for testing func mockTool(name string, toolsetID string, readOnly bool) ServerTool { return NewServerToolFromHandler( @@ -36,8 +64,8 @@ func mockTool(name string, toolsetID string, readOnly bool) ServerTool { ) } -func TestNewToolsetGroupEmpty(t *testing.T) { - tsg := NewToolsetGroup(nil, nil, nil) +func TestNewRegistryEmpty(t *testing.T) { + tsg := NewRegistry() if len(tsg.tools) != 0 { t.Fatalf("Expected tools to be empty, got %d items", len(tsg.tools)) } @@ -49,14 +77,14 @@ func TestNewToolsetGroupEmpty(t *testing.T) { } } -func TestNewToolsetGroupWithTools(t *testing.T) { +func TestNewRegistryWithTools(t *testing.T) { tools := []ServerTool{ mockTool("tool1", "toolset1", true), mockTool("tool2", "toolset1", false), mockTool("tool3", "toolset2", true), } - tsg := NewToolsetGroup(tools, nil, nil) + tsg := NewRegistry().SetTools(tools) if len(tsg.tools) != 3 { t.Errorf("Expected 3 tools, got %d", len(tsg.tools)) @@ -70,7 +98,7 @@ func TestAvailableTools_NoFilters(t *testing.T) { mockTool("tool_c", "toolset2", true), } - tsg := NewToolsetGroup(tools, nil, nil) + tsg := NewRegistry().SetTools(tools) available := tsg.AvailableTools(context.Background()) if len(available) != 3 { @@ -92,7 +120,7 @@ func TestWithReadOnly(t *testing.T) { mockTool("write_tool", "toolset1", false), } - tsg := NewToolsetGroup(tools, nil, nil) + tsg := NewRegistry().SetTools(tools) // Original should have both tools allTools := tsg.AvailableTools(context.Background()) @@ -124,11 +152,11 @@ func TestWithToolsets(t *testing.T) { mockTool("tool3", "toolset3", true), } - tsg := NewToolsetGroup(tools, nil, nil) + tsg := NewRegistry().SetTools(tools) // Filter to specific toolsets - filteredTsg := tsg.WithToolsets([]string{"toolset1", "toolset3"}) - filteredTools := filteredTsg.AvailableTools(context.Background()) + filteredReg := tsg.WithToolsets([]string{"toolset1", "toolset3"}) + filteredTools := filteredReg.AvailableTools(context.Background()) if len(filteredTools) != 2 { t.Fatalf("Expected 2 filtered tools, got %d", len(filteredTools)) @@ -150,6 +178,114 @@ func TestWithToolsets(t *testing.T) { } } +func TestWithToolsetsTrimsWhitespace(t *testing.T) { + tools := []ServerTool{ + mockTool("tool1", "toolset1", true), + mockTool("tool2", "toolset2", true), + } + + tsg := NewRegistry().SetTools(tools) + + // Whitespace should be trimmed + filteredReg := tsg.WithToolsets([]string{" toolset1 ", " toolset2 "}) + filteredTools := filteredReg.AvailableTools(context.Background()) + + if len(filteredTools) != 2 { + t.Fatalf("Expected 2 tools after whitespace trimming, got %d", len(filteredTools)) + } +} + +func TestWithToolsetsDeduplicates(t *testing.T) { + tools := []ServerTool{ + mockTool("tool1", "toolset1", true), + } + + tsg := NewRegistry().SetTools(tools) + + // Duplicates should be removed + filteredReg := tsg.WithToolsets([]string{"toolset1", "toolset1", " toolset1 "}) + filteredTools := filteredReg.AvailableTools(context.Background()) + + if len(filteredTools) != 1 { + t.Fatalf("Expected 1 tool after deduplication, got %d", len(filteredTools)) + } +} + +func TestWithToolsetsIgnoresEmptyStrings(t *testing.T) { + tools := []ServerTool{ + mockTool("tool1", "toolset1", true), + } + + tsg := NewRegistry().SetTools(tools) + + // Empty strings should be ignored + filteredReg := tsg.WithToolsets([]string{"", "toolset1", " ", ""}) + filteredTools := filteredReg.AvailableTools(context.Background()) + + if len(filteredTools) != 1 { + t.Fatalf("Expected 1 tool, got %d", len(filteredTools)) + } +} + +func TestUnrecognizedToolsets(t *testing.T) { + tools := []ServerTool{ + mockTool("tool1", "toolset1", true), + mockTool("tool2", "toolset2", true), + } + + tsg := NewRegistry().SetTools(tools) + + tests := []struct { + name string + input []string + expectedUnrecognized []string + }{ + { + name: "all valid", + input: []string{"toolset1", "toolset2"}, + expectedUnrecognized: nil, + }, + { + name: "one invalid", + input: []string{"toolset1", "invalid_toolset"}, + expectedUnrecognized: []string{"invalid_toolset"}, + }, + { + name: "multiple invalid", + input: []string{"typo1", "toolset1", "typo2"}, + expectedUnrecognized: []string{"typo1", "typo2"}, + }, + { + name: "invalid with whitespace trimmed", + input: []string{" invalid_tool "}, + expectedUnrecognized: []string{"invalid_tool"}, + }, + { + name: "empty input", + input: []string{}, + expectedUnrecognized: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + filtered := tsg.WithToolsets(tt.input) + unrecognized := filtered.UnrecognizedToolsets() + + if len(unrecognized) != len(tt.expectedUnrecognized) { + t.Fatalf("Expected %d unrecognized, got %d: %v", + len(tt.expectedUnrecognized), len(unrecognized), unrecognized) + } + + for i, expected := range tt.expectedUnrecognized { + if unrecognized[i] != expected { + t.Errorf("Expected unrecognized[%d] = %q, got %q", i, expected, unrecognized[i]) + } + } + }) + } +} + func TestWithTools(t *testing.T) { tools := []ServerTool{ mockTool("tool1", "toolset1", true), @@ -157,12 +293,12 @@ func TestWithTools(t *testing.T) { mockTool("tool3", "toolset2", true), } - tsg := NewToolsetGroup(tools, nil, nil) + tsg := NewRegistry().SetTools(tools) // WithTools adds additional tools that bypass toolset filtering // When combined with WithToolsets([]), only the additional tools should be available - filteredTsg := tsg.WithToolsets([]string{}).WithTools([]string{"tool1", "tool3"}) - filteredTools := filteredTsg.AvailableTools(context.Background()) + filteredReg := tsg.WithToolsets([]string{}).WithTools([]string{"tool1", "tool3"}) + filteredTools := filteredReg.AvailableTools(context.Background()) if len(filteredTools) != 2 { t.Fatalf("Expected 2 filtered tools, got %d", len(filteredTools)) @@ -185,7 +321,7 @@ func TestChainedFilters(t *testing.T) { mockTool("write2", "toolset2", false), } - tsg := NewToolsetGroup(tools, nil, nil) + tsg := NewRegistry().SetTools(tools) // Chain read-only and toolset filter filtered := tsg.WithReadOnly(true).WithToolsets([]string{"toolset1"}) @@ -206,7 +342,7 @@ func TestToolsetIDs(t *testing.T) { mockTool("tool3", "toolset_b", true), // duplicate toolset } - tsg := NewToolsetGroup(tools, nil, nil) + tsg := NewRegistry().SetTools(tools) ids := tsg.ToolsetIDs() if len(ids) != 2 { @@ -225,7 +361,7 @@ func TestToolsetDescriptions(t *testing.T) { mockTool("tool2", "toolset2", true), } - tsg := NewToolsetGroup(tools, nil, nil) + tsg := NewRegistry().SetTools(tools) descriptions := tsg.ToolsetDescriptions() if len(descriptions) != 2 { @@ -244,7 +380,7 @@ func TestToolsForToolset(t *testing.T) { mockTool("tool3", "toolset2", true), } - tsg := NewToolsetGroup(tools, nil, nil) + tsg := NewRegistry().SetTools(tools) toolset1Tools := tsg.ToolsForToolset("toolset1") if len(toolset1Tools) != 2 { @@ -257,7 +393,7 @@ func TestWithDeprecatedToolAliases(t *testing.T) { mockTool("new_name", "toolset1", true), } - tsg := NewToolsetGroup(tools, nil, nil) + tsg := NewRegistry().SetTools(tools) tsgWithAliases := tsg.WithDeprecatedToolAliases(map[string]string{ "old_name": "new_name", "get_issue": "issue_read", @@ -282,7 +418,7 @@ func TestResolveToolAliases(t *testing.T) { mockTool("some_tool", "toolset1", true), } - tsg := NewToolsetGroup(tools, nil, nil). + tsg := NewRegistry().SetTools(tools). WithDeprecatedToolAliases(map[string]string{ "get_issue": "issue_read", }) @@ -314,7 +450,7 @@ func TestFindToolByName(t *testing.T) { mockTool("issue_read", "toolset1", true), } - tsg := NewToolsetGroup(tools, nil, nil) + tsg := NewRegistry().SetTools(tools) // Find by name tool, toolsetID, err := tsg.FindToolByName("issue_read") @@ -342,7 +478,7 @@ func TestWithToolsAdditive(t *testing.T) { mockTool("repo_read", "toolset2", true), } - tsg := NewToolsetGroup(tools, nil, nil) + tsg := NewRegistry().SetTools(tools) // Test WithTools bypasses toolset filtering // Enable only toolset2, but add issue_read as additional tool @@ -389,7 +525,7 @@ func TestWithToolsResolvesAliases(t *testing.T) { mockTool("issue_read", "toolset1", true), } - tsg := NewToolsetGroup(tools, nil, nil). + tsg := NewRegistry().SetTools(tools). WithDeprecatedToolAliases(map[string]string{ "get_issue": "issue_read", }) @@ -411,7 +547,7 @@ func TestHasToolset(t *testing.T) { mockTool("tool1", "toolset1", true), } - tsg := NewToolsetGroup(tools, nil, nil) + tsg := NewRegistry().SetTools(tools) if !tsg.HasToolset("toolset1") { t.Error("expected HasToolset to return true for existing toolset") @@ -427,7 +563,7 @@ func TestEnabledToolsetIDs(t *testing.T) { mockTool("tool2", "toolset2", true), } - tsg := NewToolsetGroup(tools, nil, nil) + tsg := NewRegistry().SetTools(tools) // Without filter, all toolsets are enabled ids := tsg.EnabledToolsetIDs() @@ -452,7 +588,7 @@ func TestAllTools(t *testing.T) { mockTool("write_tool", "toolset1", false), } - tsg := NewToolsetGroup(tools, nil, nil) + tsg := NewRegistry().SetTools(tools) // Even with read-only filter, AllTools returns everything readOnlyTsg := tsg.WithReadOnly(true) @@ -520,7 +656,7 @@ func TestForMCPRequest_Initialize(t *testing.T) { mockPrompt("prompt1", "repos"), } - tsg := NewToolsetGroup(tools, resources, prompts) + tsg := NewRegistry().SetTools(tools).SetResources(resources).SetPrompts(prompts) filtered := tsg.ForMCPRequest(MCPMethodInitialize, "") // Initialize should return empty - capabilities come from ServerOptions @@ -547,7 +683,7 @@ func TestForMCPRequest_ToolsList(t *testing.T) { mockPrompt("prompt1", "repos"), } - tsg := NewToolsetGroup(tools, resources, prompts) + tsg := NewRegistry().SetTools(tools).SetResources(resources).SetPrompts(prompts) filtered := tsg.ForMCPRequest(MCPMethodToolsList, "") // tools/list should return all tools, no resources or prompts @@ -569,7 +705,7 @@ func TestForMCPRequest_ToolsCall(t *testing.T) { mockTool("list_repos", "repos", true), } - tsg := NewToolsetGroup(tools, nil, nil) + tsg := NewRegistry().SetTools(tools) filtered := tsg.ForMCPRequest(MCPMethodToolsCall, "get_me") available := filtered.AvailableTools(context.Background()) @@ -586,7 +722,7 @@ func TestForMCPRequest_ToolsCall_NotFound(t *testing.T) { mockTool("get_me", "context", true), } - tsg := NewToolsetGroup(tools, nil, nil) + tsg := NewRegistry().SetTools(tools) filtered := tsg.ForMCPRequest(MCPMethodToolsCall, "nonexistent") if len(filtered.AvailableTools(context.Background())) != 0 { @@ -600,7 +736,7 @@ func TestForMCPRequest_ToolsCall_DeprecatedAlias(t *testing.T) { mockTool("list_commits", "repos", true), } - tsg := NewToolsetGroup(tools, nil, nil). + tsg := NewRegistry().SetTools(tools). WithDeprecatedToolAliases(map[string]string{ "old_get_me": "get_me", }) @@ -622,7 +758,7 @@ func TestForMCPRequest_ToolsCall_RespectsFilters(t *testing.T) { mockTool("create_issue", "issues", false), // write tool } - tsg := NewToolsetGroup(tools, nil, nil) + tsg := NewRegistry().SetTools(tools) // Apply read-only filter, then ForMCPRequest filtered := tsg.WithReadOnly(true).ForMCPRequest(MCPMethodToolsCall, "create_issue") @@ -645,7 +781,7 @@ func TestForMCPRequest_ResourcesList(t *testing.T) { mockPrompt("prompt1", "repos"), } - tsg := NewToolsetGroup(tools, resources, prompts) + tsg := NewRegistry().SetTools(tools).SetResources(resources).SetPrompts(prompts) filtered := tsg.ForMCPRequest(MCPMethodResourcesList, "") if len(filtered.AvailableTools(context.Background())) != 0 { @@ -665,7 +801,7 @@ func TestForMCPRequest_ResourcesRead(t *testing.T) { mockResource("res2", "repos", "branch://{owner}/{repo}/{branch}"), } - tsg := NewToolsetGroup(nil, resources, nil) + tsg := NewRegistry().SetResources(resources) filtered := tsg.ForMCPRequest(MCPMethodResourcesRead, "repo://{owner}/{repo}") available := filtered.AvailableResourceTemplates(context.Background()) @@ -689,7 +825,7 @@ func TestForMCPRequest_PromptsList(t *testing.T) { mockPrompt("prompt2", "issues"), } - tsg := NewToolsetGroup(tools, resources, prompts) + tsg := NewRegistry().SetTools(tools).SetResources(resources).SetPrompts(prompts) filtered := tsg.ForMCPRequest(MCPMethodPromptsList, "") if len(filtered.AvailableTools(context.Background())) != 0 { @@ -709,7 +845,7 @@ func TestForMCPRequest_PromptsGet(t *testing.T) { mockPrompt("prompt2", "issues"), } - tsg := NewToolsetGroup(nil, nil, prompts) + tsg := NewRegistry().SetPrompts(prompts) filtered := tsg.ForMCPRequest(MCPMethodPromptsGet, "prompt1") available := filtered.AvailablePrompts(context.Background()) @@ -732,7 +868,7 @@ func TestForMCPRequest_UnknownMethod(t *testing.T) { mockPrompt("prompt1", "repos"), } - tsg := NewToolsetGroup(tools, resources, prompts) + tsg := NewRegistry().SetTools(tools).SetResources(resources).SetPrompts(prompts) filtered := tsg.ForMCPRequest("unknown/method", "") // Unknown methods should return empty @@ -759,7 +895,7 @@ func TestForMCPRequest_Immutability(t *testing.T) { mockPrompt("prompt1", "repos"), } - original := NewToolsetGroup(tools, resources, prompts) + original := NewRegistry().SetTools(tools).SetResources(resources).SetPrompts(prompts) filtered := original.ForMCPRequest(MCPMethodToolsCall, "tool1") // Original should be unchanged @@ -787,14 +923,13 @@ func TestForMCPRequest_Immutability(t *testing.T) { func TestForMCPRequest_ChainedWithOtherFilters(t *testing.T) { tools := []ServerTool{ - mockTool("get_me", "context", true), - mockTool("create_issue", "issues", false), - mockTool("list_repos", "repos", true), - mockTool("delete_repo", "repos", false), + mockToolWithDefault("get_me", "context", true, true), // default toolset + mockToolWithDefault("create_issue", "issues", false, false), // not default + mockToolWithDefault("list_repos", "repos", true, true), // default toolset + mockToolWithDefault("delete_repo", "repos", false, true), // default but write } - tsg := NewToolsetGroup(tools, nil, nil) - tsg.SetDefaultToolsetIDs([]ToolsetID{"context", "repos"}) + tsg := NewRegistry().SetTools(tools) // Chain: default toolsets -> read-only -> specific method filtered := tsg. @@ -837,7 +972,7 @@ func TestForMCPRequest_ResourcesTemplatesList(t *testing.T) { mockResource("res1", "repos", "repo://{owner}/{repo}"), } - tsg := NewToolsetGroup(tools, resources, nil) + tsg := NewRegistry().SetTools(tools).SetResources(resources) filtered := tsg.ForMCPRequest(MCPMethodResourcesTemplatesList, "") // Same behavior as resources/list @@ -886,7 +1021,7 @@ func TestFeatureFlagEnable(t *testing.T) { mockToolWithFlags("needs_flag", "toolset1", true, "my_feature", ""), } - tsg := NewToolsetGroup(tools, nil, nil) + tsg := NewRegistry().SetTools(tools) // Without feature checker, tool with FeatureFlagEnable should be excluded available := tsg.AvailableTools(context.Background()) @@ -922,7 +1057,7 @@ func TestFeatureFlagDisable(t *testing.T) { mockToolWithFlags("disabled_by_flag", "toolset1", true, "", "kill_switch"), } - tsg := NewToolsetGroup(tools, nil, nil) + tsg := NewRegistry().SetTools(tools) // Without feature checker, tool with FeatureFlagDisable should be included (flag is false) available := tsg.AvailableTools(context.Background()) @@ -950,7 +1085,7 @@ func TestFeatureFlagBoth(t *testing.T) { mockToolWithFlags("complex_tool", "toolset1", true, "new_feature", "kill_switch"), } - tsg := NewToolsetGroup(tools, nil, nil) + tsg := NewRegistry().SetTools(tools) // Enable flag not set -> excluded checker1 := func(_ context.Context, _ string) (bool, error) { return false, nil } @@ -976,7 +1111,7 @@ func TestFeatureFlagError(t *testing.T) { mockToolWithFlags("needs_flag", "toolset1", true, "my_feature", ""), } - tsg := NewToolsetGroup(tools, nil, nil) + tsg := NewRegistry().SetTools(tools) // Checker that returns error should treat as false (tool excluded) checkerError := func(_ context.Context, _ string) (bool, error) { @@ -999,7 +1134,7 @@ func TestFeatureFlagResources(t *testing.T) { }, } - tsg := NewToolsetGroup(nil, resources, nil) + tsg := NewRegistry().SetResources(resources) // Without checker, resource with enable flag should be excluded available := tsg.AvailableResourceTemplates(context.Background()) @@ -1025,7 +1160,7 @@ func TestFeatureFlagPrompts(t *testing.T) { }, } - tsg := NewToolsetGroup(nil, nil, prompts) + tsg := NewRegistry().SetPrompts(prompts) // Without checker, prompt with enable flag should be excluded available := tsg.AvailablePrompts(context.Background()) @@ -1072,3 +1207,61 @@ func TestServerToolHandlerPanicOnNil(t *testing.T) { tool.Handler(nil) } + +// TestRegistryCopyCopiesAllFields ensures the copy() method stays in sync with the struct. +// If you add a new field to Registry, this test will fail until you update copy(). +func TestRegistryCopyCopiesAllFields(t *testing.T) { + // Create a Registry with non-zero/non-nil values for ALL fields + original := &Registry{ + tools: []ServerTool{mockTool("t1", "ts1", true)}, + resourceTemplates: []ServerResourceTemplate{{Template: mcp.ResourceTemplate{Name: "r1"}}}, + prompts: []ServerPrompt{{Prompt: mcp.Prompt{Name: "p1"}}}, + deprecatedAliases: map[string]string{"old": "new"}, + readOnly: true, + enabledToolsets: map[ToolsetID]bool{"ts1": true}, + additionalTools: map[string]bool{"extra": true}, + featureChecker: func(_ context.Context, _ string) (bool, error) { return true, nil }, + unrecognizedToolsets: []string{"unknown"}, + } + + copied := original.copy() + + // Verify all fields are copied correctly + if len(copied.tools) != len(original.tools) || (len(copied.tools) > 0 && copied.tools[0].Tool.Name != original.tools[0].Tool.Name) { + t.Error("tools not copied correctly") + } + if len(copied.resourceTemplates) != len(original.resourceTemplates) { + t.Error("resourceTemplates not copied correctly") + } + if len(copied.prompts) != len(original.prompts) { + t.Error("prompts not copied correctly") + } + if len(copied.deprecatedAliases) != len(original.deprecatedAliases) || copied.deprecatedAliases["old"] != "new" { + t.Error("deprecatedAliases not copied correctly") + } + if copied.readOnly != original.readOnly { + t.Error("readOnly not copied correctly") + } + if len(copied.enabledToolsets) != len(original.enabledToolsets) || !copied.enabledToolsets["ts1"] { + t.Error("enabledToolsets not copied correctly") + } + if len(copied.additionalTools) != len(original.additionalTools) || !copied.additionalTools["extra"] { + t.Error("additionalTools not copied correctly") + } + if copied.featureChecker == nil { + t.Error("featureChecker not copied correctly") + } + if len(copied.unrecognizedToolsets) != len(original.unrecognizedToolsets) || copied.unrecognizedToolsets[0] != "unknown" { + t.Error("unrecognizedToolsets not copied correctly") + } + + // Verify maps are deep copied (mutations don't affect original) + copied.enabledToolsets["ts2"] = true + if original.enabledToolsets["ts2"] { + t.Error("enabledToolsets should be deep copied, not shared") + } + copied.additionalTools["another"] = true + if original.additionalTools["another"] { + t.Error("additionalTools should be deep copied, not shared") + } +}