Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions pkg/gateway/capabilitites.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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,
}
Expand Down
72 changes: 71 additions & 1 deletion pkg/gateway/tool_names.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
29 changes: 29 additions & 0 deletions pkg/gateway/tool_names_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package gateway
import (
"context"
"encoding/json"
"strings"
"testing"

"github.com/modelcontextprotocol/go-sdk/mcp"
Expand All @@ -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{
{
Expand Down