Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
74 changes: 74 additions & 0 deletions tool/mcp/mangle.go
Original file line number Diff line number Diff line change
@@ -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)
}
137 changes: 137 additions & 0 deletions tool/mcp/mangle_test.go
Original file line number Diff line number Diff line change
@@ -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.Equal(t, "", mangleHeadIfTooLong("anything", 0))

Check failure on line 92 in tool/mcp/mangle_test.go

View workflow job for this annotation

GitHub Actions / Golangci Lint

empty: use assert.Empty (testifylint)
})

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)
})
}
38 changes: 28 additions & 10 deletions tool/mcp/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
}
}

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
Loading