diff --git a/tool/mcp/mangle.go b/tool/mcp/mangle.go new file mode 100644 index 0000000..966646e --- /dev/null +++ b/tool/mcp/mangle.go @@ -0,0 +1,74 @@ +// Copyright 2026 Redpanda Data, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mcp + +import ( + "crypto/sha256" + "math/big" +) + +// maxToolNameLen is the maximum allowed length for tool names sent to LLM +// providers. Bedrock enforces 64 characters; this appears to be a common +// limit across providers. +const maxToolNameLen = 64 + +// mangleHeadIfTooLong truncates a tool name that exceeds maxLen by replacing +// the head with a deterministic hash prefix and keeping the tail (the most +// specific / human-readable part). +// +// The algorithm matches protoc-gen-go-mcp's MangleHeadIfTooLong so that names +// mangled at either layer use the same scheme. +// +// Output format for names that exceed maxLen: +// +// {10-char-base36-hash}_{tail} +// +// The hash is derived from the full original name, so the mapping is stable +// and collision-resistant (~31 bits of entropy, birthday bound ~46k). +func mangleHeadIfTooLong(name string, maxLen int) string { + if maxLen <= 0 { + return "" + } + + if len(name) <= maxLen { + return name + } + + hash := sha256.Sum256([]byte(name)) + fullHash := base36String(hash[:]) + + hashPrefix := fullHash + if len(hashPrefix) > 10 { + hashPrefix = hashPrefix[:10] + } + + if maxLen <= len(hashPrefix) { + return hashPrefix[:maxLen] + } + + available := maxLen - len(hashPrefix) - 1 // -1 for separator + if available <= 0 { + return hashPrefix + } + + tail := name[len(name)-available:] + + return hashPrefix + "_" + tail +} + +func base36String(b []byte) string { + n := new(big.Int).SetBytes(b) + return n.Text(36) +} diff --git a/tool/mcp/mangle_test.go b/tool/mcp/mangle_test.go new file mode 100644 index 0000000..396deae --- /dev/null +++ b/tool/mcp/mangle_test.go @@ -0,0 +1,137 @@ +// Copyright 2026 Redpanda Data, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package mcp + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMangleHeadIfTooLong(t *testing.T) { + t.Parallel() + + t.Run("short names pass through unchanged", func(t *testing.T) { + t.Parallel() + + assert.Equal(t, "github__create-issue", mangleHeadIfTooLong("github__create-issue", 64)) + }) + + t.Run("exact length passes through", func(t *testing.T) { + t.Parallel() + + name := strings.Repeat("a", 64) + assert.Equal(t, name, mangleHeadIfTooLong(name, 64)) + }) + + t.Run("one over gets mangled", func(t *testing.T) { + t.Parallel() + + name := strings.Repeat("a", 65) + result := mangleHeadIfTooLong(name, 64) + assert.Len(t, result, 64) + assert.NotEqual(t, name, result) + }) + + t.Run("mangled output never exceeds maxLen", func(t *testing.T) { + t.Parallel() + + names := []string{ + "servicenow-catalog__delete_api_sn_sc_servicecatalog_cart_by_sys_id_empty", + "servicenow-catalog__post_api_sn_sc_servicecatalog_items_by_sys_id_versioning_checkout", + "long-server-id__very_long_tool_name_that_exceeds_the_sixty_four_character_limit_by_quite_a_bit", + } + for _, name := range names { + result := mangleHeadIfTooLong(name, 64) + assert.LessOrEqual(t, len(result), 64, "name %q mangled to %q (%d chars)", name, result, len(result)) + } + }) + + t.Run("deterministic", func(t *testing.T) { + t.Parallel() + + name := "servicenow-catalog__delete_api_sn_sc_servicecatalog_cart_by_sys_id_empty" + a := mangleHeadIfTooLong(name, 64) + b := mangleHeadIfTooLong(name, 64) + assert.Equal(t, a, b) + }) + + t.Run("preserves tail", func(t *testing.T) { + t.Parallel() + + name := "servicenow-catalog__delete_api_sn_sc_servicecatalog_cart_by_sys_id_empty" + result := mangleHeadIfTooLong(name, 64) + assert.True(t, strings.HasSuffix(result, "sys_id_empty"), "result %q should end with tail of original", result) + }) + + t.Run("different inputs produce different outputs", func(t *testing.T) { + t.Parallel() + + a := mangleHeadIfTooLong("servicenow-catalog__delete_api_sn_sc_servicecatalog_cart_by_sys_id_empty", 64) + b := mangleHeadIfTooLong("servicenow-catalog__post_api_sn_sc_servicecatalog_items_by_sys_id_versioning_checkout", 64) + assert.NotEqual(t, a, b) + }) + + t.Run("zero maxLen", func(t *testing.T) { + t.Parallel() + + assert.Empty(t, mangleHeadIfTooLong("anything", 0)) + }) + + t.Run("tiny maxLen", func(t *testing.T) { + t.Parallel() + + result := mangleHeadIfTooLong(strings.Repeat("x", 100), 5) + assert.LessOrEqual(t, len(result), 5) + }) +} + +func TestNamespaceToolMangling(t *testing.T) { + t.Parallel() + + t.Run("short names are prefixed normally", func(t *testing.T) { + t.Parallel() + + c := &clientImpl{serverID: "github"} + result := c.namespaceTool("create-issue") + assert.Equal(t, "github__create-issue", result) + assert.LessOrEqual(t, len(result), maxToolNameLen) + }) + + t.Run("long names get mangled to fit", func(t *testing.T) { + t.Parallel() + + c := &clientImpl{serverID: "servicenow-catalog"} + name := "delete_api_sn_sc_servicecatalog_cart_by_sys_id_empty" + + full := "servicenow-catalog__" + name + require.Greater(t, len(full), maxToolNameLen, "test setup: full name should exceed limit") + + result := c.namespaceTool(name) + assert.LessOrEqual(t, len(result), maxToolNameLen) + assert.NotEqual(t, full, result, "should be mangled") + }) + + t.Run("mangling is deterministic", func(t *testing.T) { + t.Parallel() + + c := &clientImpl{serverID: "servicenow-catalog"} + a := c.namespaceTool("post_api_sn_sc_servicecatalog_items_by_sys_id_versioning_checkout") + b := c.namespaceTool("post_api_sn_sc_servicecatalog_items_by_sys_id_versioning_checkout") + assert.Equal(t, a, b) + }) +} diff --git a/tool/mcp/tools.go b/tool/mcp/tools.go index f26d998..f820252 100644 --- a/tool/mcp/tools.go +++ b/tool/mcp/tools.go @@ -73,11 +73,22 @@ func (c *clientImpl) ExecuteTool(ctx context.Context, toolName string, args json return nil, err } - serverID := c.serverID + // Resolve the server-side tool name. Prefer the stored mapping (required + // when the namespaced name was mangled to fit the 64-char limit), falling + // back to prefix-stripping for direct ExecuteTool calls. + c.mu.RLock() + wrapper := c.tools[toolName] + c.mu.RUnlock() + + var serverToolName string - // Strip namespace prefix to get the server's tool name - // "github__create-issue" → "create-issue" - serverToolName := strings.TrimPrefix(toolName, serverID+"__") + if wrapper != nil { + wrapper.mu.RLock() + serverToolName = wrapper.serverToolName + wrapper.mu.RUnlock() + } else { + serverToolName = strings.TrimPrefix(toolName, c.serverID+"__") + } // Parse arguments directly into map var argsMap map[string]any @@ -105,7 +116,7 @@ func (c *clientImpl) ExecuteTool(ctx context.Context, toolName string, args json if result.IsError { c.logger.Debug("tool execution returned error", "tool", toolName, - "serverID", serverID) + "serverID", c.serverID) } return json.Marshal(result.Content) @@ -189,6 +200,7 @@ type preparedTool struct { mcpTool *sdkmcp.Tool paramsJSON json.RawMessage namespacedName string + serverToolName string // original name on the MCP server (pre-namespace) } // prepareTools pre-processes fetched tools by marshalling JSON and applying filters. @@ -217,6 +229,7 @@ func (c *clientImpl) prepareTools(fetched map[string]*sdkmcp.Tool) map[string]*p mcpTool: mcpTool, paramsJSON: paramsJSON, namespacedName: namespaced, + serverToolName: mcpTool.Name, } } @@ -254,13 +267,15 @@ func (c *clientImpl) computeToolDiff(prepared map[string]*preparedTool) []regist // Update existing tool definition w.mu.Lock() w.definition = def + w.serverToolName = prep.serverToolName w.mu.Unlock() c.logger.Debug("updated tool", "tool", namespaced) } else { // Create new tool wrapper w := &toolWrapper{ - client: c, - definition: def, + client: c, + definition: def, + serverToolName: prep.serverToolName, } c.tools[namespaced] = w @@ -354,16 +369,19 @@ func (c *clientImpl) autoSyncLoop() { // namespaceTool creates a namespaced tool name to prevent collisions. // Format: serverID__toolName (double underscore for LLM API compatibility). +// Names exceeding maxToolNameLen (64) are mangled to fit. func (c *clientImpl) namespaceTool(name string) string { - return fmt.Sprintf("%s__%s", c.serverID, name) + full := fmt.Sprintf("%s__%s", c.serverID, name) + return mangleHeadIfTooLong(full, maxToolNameLen) } // toolWrapper wraps an MCP tool and implements the tool.Tool interface. type toolWrapper struct { client *clientImpl - mu sync.RWMutex - definition llm.ToolDefinition + mu sync.RWMutex + definition llm.ToolDefinition + serverToolName string // original name on the MCP server (pre-namespace) } // Ensure toolWrapper implements tool.Tool at compile time.