From a46592ba9079d80aaf7a99bb6a785bf37ad1e4fe Mon Sep 17 00:00:00 2001 From: syf2211 Date: Sat, 27 Jun 2026 19:39:37 +0000 Subject: [PATCH] fix(gateway): sanitize exposed MCP tool names for strict clients Replace invalid characters in upstream tool names when exposing them through the gateway so clients like Claude Desktop accept tools/list responses. Backend tool calls still use the original upstream names. Fixes #434 --- pkg/gateway/capabilitites.go | 7 +++- pkg/gateway/tool_names.go | 72 +++++++++++++++++++++++++++++++++- pkg/gateway/tool_names_test.go | 29 ++++++++++++++ 3 files changed, 105 insertions(+), 3 deletions(-) diff --git a/pkg/gateway/capabilitites.go b/pkg/gateway/capabilitites.go index a9e15b32..fa92b238 100644 --- a/pkg/gateway/capabilitites.go +++ b/pkg/gateway/capabilitites.go @@ -164,6 +164,7 @@ func (g *Gateway) listCapabilities(ctx context.Context, serverNames []string, cl // Determine the prefix to use for this server's tools prefix := g.getToolNamePrefix(serverConfig) + exposedToolNames := make(map[string]struct{}) for _, tool := range tools.Tools { if !isToolEnabled(g.configuration, serverConfig.Name, serverConfig.Spec.Image, tool.Name, g.ToolNames) { @@ -172,7 +173,7 @@ func (g *Gateway) listCapabilities(ctx context.Context, serverNames []string, cl // Create a copy of the tool and apply prefix to its name prefixedTool := *tool - prefixedTool.Name = prefixToolName(prefix, tool.Name) + prefixedTool.Name = uniqueExposeToolName(prefix, tool.Name, exposedToolNames) capabilities.Tools = append(capabilities.Tools, ToolRegistration{ ServerName: serverConfig.Name, @@ -258,6 +259,8 @@ func (g *Gateway) listCapabilities(ctx context.Context, serverNames []string, cl prefix = serverName } + exposedToolNames := make(map[string]struct{}) + for _, tool := range *toolGroup { if !isToolEnabled(g.configuration, serverName, "", tool.Name, g.ToolNames) { continue @@ -277,7 +280,7 @@ func (g *Gateway) listCapabilities(ctx context.Context, serverNames []string, cl } mcpTool := mcp.Tool{ - Name: prefixToolName(prefix, tool.Name), + Name: uniqueExposeToolName(prefix, tool.Name, exposedToolNames), Description: tool.Description, InputSchema: schema, } diff --git a/pkg/gateway/tool_names.go b/pkg/gateway/tool_names.go index 2e67db8f..fcbd65ba 100644 --- a/pkg/gateway/tool_names.go +++ b/pkg/gateway/tool_names.go @@ -5,10 +5,18 @@ import ( "fmt" "sort" "strings" + "unicode" "github.com/docker/mcp-gateway/pkg/catalog" ) +const maxMcpToolNameLength = 64 + +var validMcpToolNameRune = map[rune]bool{ + '_': true, + '-': true, +} + var ( errToolNameCollision = errors.New("tool name collision") errCapabilityNameCollision = errors.New("capability name collision") @@ -117,6 +125,68 @@ func sortedToolRegistrations(registrations []ToolRegistration) []ToolRegistratio return sorted } +// sanitizeMcpToolName normalizes upstream tool names to the MCP pattern +// accepted by strict clients (^[a-zA-Z0-9_-]{1,64}$). +func sanitizeMcpToolName(name string) string { + var b strings.Builder + b.Grow(len(name)) + + prevUnderscore := false + for _, r := range name { + valid := unicode.IsLetter(r) || unicode.IsDigit(r) || validMcpToolNameRune[r] + if valid { + b.WriteRune(r) + prevUnderscore = false + continue + } + if !prevUnderscore { + b.WriteRune('_') + prevUnderscore = true + } + } + + s := strings.Trim(b.String(), "_") + if s == "" { + return "tool" + } + if len(s) > maxMcpToolNameLength { + s = strings.TrimRight(s[:maxMcpToolNameLength], "_") + if s == "" { + return "tool" + } + } + return s +} + +// exposeToolName applies the configured prefix and sanitizes the exposed name. +func exposeToolName(prefix, toolName string) string { + return sanitizeMcpToolName(prefixToolName(prefix, toolName)) +} + +// uniqueExposeToolName returns a sanitized exposed name that is unique within seen. +func uniqueExposeToolName(prefix, toolName string, seen map[string]struct{}) string { + base := exposeToolName(prefix, toolName) + name := base + for i := 2; ; i++ { + if _, ok := seen[name]; !ok { + seen[name] = struct{}{} + return name + } + + suffix := fmt.Sprintf("_%d", i) + maxBase := maxMcpToolNameLength - len(suffix) + trimmed := base + if len(trimmed) > maxBase { + trimmed = strings.TrimRight(trimmed[:maxBase], "_") + } + if trimmed == "" { + name = fmt.Sprintf("tool%s", suffix) + continue + } + name = trimmed + suffix + } +} + func toolOwner(registration ToolRegistration) string { if registration.ServerName == "" { return "gateway internal tools" @@ -294,7 +364,7 @@ func (g *Gateway) catalogToolNameWarnings(serverName string, server catalog.Serv continue } - exposedName := prefixToolName(prefix, toolName) + exposedName := exposeToolName(prefix, toolName) if previousRawName, ok := seen[exposedName]; ok { warnings = append(warnings, fmt.Sprintf("tool %q would be exposed as %q, which duplicates tool %q in this server", toolName, exposedName, previousRawName)) continue diff --git a/pkg/gateway/tool_names_test.go b/pkg/gateway/tool_names_test.go index 4bd8f110..578aabb8 100644 --- a/pkg/gateway/tool_names_test.go +++ b/pkg/gateway/tool_names_test.go @@ -3,6 +3,7 @@ package gateway import ( "context" "encoding/json" + "strings" "testing" "github.com/modelcontextprotocol/go-sdk/mcp" @@ -13,6 +14,34 @@ import ( "github.com/docker/mcp-gateway/pkg/policy" ) +func TestSanitizeMcpToolNameReplacesInvalidCharacters(t *testing.T) { + assert.Equal(t, "Husqvarna_Automowers_Status", sanitizeMcpToolName("Husqvarna Automowers Status")) + assert.Equal(t, "foo_bar_baz", sanitizeMcpToolName("foo:bar.baz")) + assert.Equal(t, "search", sanitizeMcpToolName("search")) +} + +func TestSanitizeMcpToolNameEnforcesMaxLength(t *testing.T) { + longName := strings.Repeat("a", 80) + sanitized := sanitizeMcpToolName(longName) + require.Len(t, sanitized, maxMcpToolNameLength) + assert.Equal(t, strings.Repeat("a", maxMcpToolNameLength), sanitized) +} + +func TestExposeToolNameAppliesPrefixAndSanitization(t *testing.T) { + assert.Equal(t, "husqvarna-automower__Husqvarna_Automowers_Status", exposeToolName("husqvarna-automower", "Husqvarna Automowers Status")) + assert.Equal(t, "Husqvarna_Automowers_Status", exposeToolName("", "Husqvarna Automowers Status")) +} + +func TestUniqueExposeToolNameDedupesSanitizedCollisions(t *testing.T) { + seen := make(map[string]struct{}) + + first := uniqueExposeToolName("", "Foo Bar", seen) + second := uniqueExposeToolName("", "Foo_Bar", seen) + + assert.Equal(t, "Foo_Bar", first) + assert.Equal(t, "Foo_Bar_2", second) +} + func TestValidateExternalToolNameCollisionsRejectsDuplicateBaseServers(t *testing.T) { err := validateExternalToolNameCollisions([]ToolRegistration{ {