diff --git a/internal/runtime/executor/claude_executor_request.go b/internal/runtime/executor/claude_executor_request.go index 1364494a5..b5a6a121f 100644 --- a/internal/runtime/executor/claude_executor_request.go +++ b/internal/runtime/executor/claude_executor_request.go @@ -512,9 +512,9 @@ func restoreClaudeOAuthToolNamesFromStreamLine(line []byte, reverseMap map[strin return reverseRemapOAuthToolNamesFromStreamLine(line, reverseMap) } -// remapOAuthToolNames represents every declared third-party client tool as an -// opaque Claude Code MCP extension. Existing valid MCP names and explicit typed -// Anthropic tools remain unchanged. +// remapOAuthToolNames represents every declared third-party client tool as a +// semantic Claude Code MCP extension. Existing valid MCP names and explicit +// typed Anthropic tools remain unchanged. // // It operates on tools[].name, tool_choice.name, and all declared // tool_use/tool_reference references in messages. @@ -527,18 +527,6 @@ func remapOAuthToolNames(body []byte) ([]byte, map[string]string) { return remapOAuthToolNamesWithOptions(body, claudeMCPAliasOptions{secret: "cpa-claude-mcp-default-caller"}) } -func claudeMCPAliasRevealsOriginal(alias, original string) bool { - alias = strings.ToLower(alias) - for _, fragment := range strings.FieldsFunc(strings.ToLower(original), func(char rune) bool { - return !((char >= 'a' && char <= 'z') || (char >= '0' && char <= '9')) - }) { - if len(fragment) >= 4 && strings.Contains(alias, fragment) { - return true - } - } - return false -} - func remapOAuthToolNamesWithOptions(body []byte, mcpAliases claudeMCPAliasOptions) ([]byte, map[string]string) { reverseMap := make(map[string]string) recordRename := func(original, renamed string) { @@ -580,7 +568,7 @@ func remapOAuthToolNamesWithOptions(body []byte, mcpAliases claudeMCPAliasOption } for attempt := uint32(0); ; attempt++ { alias := helps.ClaudeMCPToolAlias(mcpAliases.secret, name, attempt) - if reservedNames[alias] || claudeMCPAliasRevealsOriginal(alias, name) { + if reservedNames[alias] { continue } forwardMap[name] = alias diff --git a/internal/runtime/executor/claude_executor_test.go b/internal/runtime/executor/claude_executor_test.go index 3f51b065a..e270fc8c5 100644 --- a/internal/runtime/executor/claude_executor_test.go +++ b/internal/runtime/executor/claude_executor_test.go @@ -3991,8 +3991,11 @@ func TestRemapOAuthToolNames_AllClientToolsAsMCP(t *testing.T) { if got := gjson.GetBytes(out, "tools.6.name").String(); got != searchAlias { t.Fatalf("repeated declaration alias = %q, want %q", got, searchAlias) } - if strings.Contains(searchAlias, "search") || strings.Contains(searchAlias, "web") { - t.Fatalf("alias %q reveals original name", searchAlias) + if !strings.HasSuffix(searchAlias, "_search_web") || !strings.HasSuffix(caseAlias, "_Search_Web") { + t.Fatalf("generated aliases lost semantic suffixes: %q, %q", searchAlias, caseAlias) + } + if len(searchAlias) > 64 || len(caseAlias) > 64 { + t.Fatalf("generated aliases exceed 64 characters: %q, %q", searchAlias, caseAlias) } if got := gjson.GetBytes(out, "tools.4.description").String(); got != "unknown one" { t.Fatalf("description = %q, want preserved", got) @@ -4116,6 +4119,34 @@ func TestRemapOAuthToolNames_MCPAliasIsMandatory(t *testing.T) { } } +func TestRemapOAuthToolNames_SemanticAliasRestoresLongOriginal(t *testing.T) { + original := "Read.file/with a very long semantic name and Unicode 网页内容 that exceeds the wire limit" + body := []byte(`{"tools":[{"name":` + fmt.Sprintf("%q", original) + `,"input_schema":{"type":"object"}}]}`) + options := claudeMCPAliasOptions{secret: "stable-caller"} + + out, reverseMap := remapOAuthToolNamesWithOptions(body, options) + alias := gjson.GetBytes(out, "tools.0.name").String() + if !helps.IsClaudeMCPToolName(alias) || len(alias) > 64 { + t.Fatalf("semantic alias is invalid or too long: len=%d name=%q", len(alias), alias) + } + if !strings.Contains(alias, "_Read_file_with_a_very_long") { + t.Fatalf("semantic alias %q does not expose the truncated original meaning", alias) + } + if reverseMap[alias] != original { + t.Fatalf("reverseMap lost exact original: got %q, want %q", reverseMap[alias], original) + } + + second, _ := remapOAuthToolNamesWithOptions(body, options) + if got := gjson.GetBytes(second, "tools.0.name").String(); got != alias { + t.Fatalf("semantic alias is not stable across requests: %q != %q", got, alias) + } + response := []byte(`{"content":[{"type":"tool_use","id":"toolu_1","name":` + fmt.Sprintf("%q", alias) + `,"input":{}}]}`) + restored := reverseRemapOAuthToolNames(response, reverseMap) + if got := gjson.GetBytes(restored, "content.0.name").String(); got != original { + t.Fatalf("restored tool name = %q, want exact original %q", got, original) + } +} + func TestPrepareClaudeOAuthToolNamesForUpstream_PreservesMCPConvention(t *testing.T) { body := []byte(`{"tools":[ {"name":"search_web","input_schema":{"type":"object"}}, @@ -4292,8 +4323,8 @@ func TestClaudeExecutor_ExecuteOpenAINonStreamRestoresOAuthToolNames(t *testing. if !upstream.stream { t.Fatal("upstream stream = false, want true") } - if !helps.IsClaudeMCPToolName(upstream.toolName) { - t.Fatalf("upstream tools.0.name = %q, want MCP alias", upstream.toolName) + if !helps.IsClaudeMCPToolName(upstream.toolName) || !strings.HasSuffix(upstream.toolName, "_bash") { + t.Fatalf("upstream tools.0.name = %q, want semantic MCP alias", upstream.toolName) } if got := gjson.GetBytes(resp.Payload, "choices.0.message.tool_calls.0.function.name").String(); got != "bash" { t.Fatalf("tool_calls.0.function.name = %q, want %q; payload=%s", got, "bash", string(resp.Payload)) @@ -4330,8 +4361,8 @@ func TestClaudeExecutor_ExecuteOAuthCustomToolMCPAliasRoundTrip(t *testing.T) { if errExecute != nil { t.Fatalf("Execute() error = %v", errExecute) } - if !helps.IsClaudeMCPToolName(upstreamAlias) || strings.HasPrefix(upstreamAlias, "proxy_") { - t.Fatalf("upstream tool name = %q, want mcp__ alias", upstreamAlias) + if !helps.IsClaudeMCPToolName(upstreamAlias) || strings.HasPrefix(upstreamAlias, "proxy_") || !strings.HasSuffix(upstreamAlias, "_search_web") { + t.Fatalf("upstream tool name = %q, want semantic mcp__ alias", upstreamAlias) } if got := gjson.GetBytes(resp.Payload, "content.0.name").String(); got != "search_web" { t.Fatalf("client response tool name = %q, want search_web; payload=%s", got, resp.Payload) @@ -4410,8 +4441,8 @@ func TestClaudeExecutor_ExecuteStreamOAuthCustomToolMCPAliasRoundTrip(t *testing } downstream.Write(chunk.Payload) } - if !helps.IsClaudeMCPToolName(upstreamAlias) { - t.Fatalf("upstream tool name = %q, want mcp__ alias", upstreamAlias) + if !helps.IsClaudeMCPToolName(upstreamAlias) || !strings.HasSuffix(upstreamAlias, "_fetch_url") { + t.Fatalf("upstream tool name = %q, want semantic mcp__ alias", upstreamAlias) } if _, ok := claudeBillingCCHDigitsOffset(upstreamBody); !ok { t.Fatalf("streaming Claude OAuth custom BaseURL body is missing CCH: %s", upstreamBody) diff --git a/internal/runtime/executor/helps/claude_mcp_alias.go b/internal/runtime/executor/helps/claude_mcp_alias.go index 10d79a4a1..59d673896 100644 --- a/internal/runtime/executor/helps/claude_mcp_alias.go +++ b/internal/runtime/executor/helps/claude_mcp_alias.go @@ -31,16 +31,45 @@ func IsClaudeMCPToolName(name string) bool { return true } -// ClaudeMCPToolAlias derives an opaque Claude Code-style MCP tool name. All -// aliases created with the same caller secret share one virtual server name; -// original tool names affect only the tool component. A higher attempt changes -// the tool component when a request-local collision must be avoided. +// ClaudeMCPToolAlias derives a Claude Code-style MCP tool name. Aliases from +// one caller share a virtual server component. The tool component combines a +// stable keyed ID with a truncated semantic suffix so the model can distinguish +// tools by name while the request-local symbol table restores the exact original. +// A higher attempt changes the stable ID when a collision must be avoided. func ClaudeMCPToolAlias(secret, original string, attempt uint32) string { serverDigest := claudeMCPAliasDigest(secret, "server", "", 0) toolDigest := claudeMCPAliasDigest(secret, "tool", original, attempt) server := claudeMCPBase32.EncodeToString(serverDigest[:])[:12] - tool := claudeMCPBase32.EncodeToString(toolDigest[:])[:16] - return "mcp__" + server + "__" + tool + toolID := claudeMCPBase32.EncodeToString(toolDigest[:])[:12] + semantic := claudeMCPToolSemanticSuffix(original, 32) + return "mcp__" + server + "__" + toolID + "_" + semantic +} + +func claudeMCPToolSemanticSuffix(original string, maxLength int) string { + var semantic strings.Builder + semantic.Grow(min(len(original), maxLength)) + pendingSeparator := false + for _, char := range original { + valid := (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || + (char >= '0' && char <= '9') || char == '_' || char == '-' + if !valid { + pendingSeparator = semantic.Len() > 0 + continue + } + if pendingSeparator && semantic.Len()+1 < maxLength { + semantic.WriteByte('_') + } + pendingSeparator = false + if semantic.Len() >= maxLength { + break + } + semantic.WriteRune(char) + } + result := strings.Trim(semantic.String(), "_-") + if result == "" { + return "tool" + } + return result } func claudeMCPAliasDigest(secret, purpose, original string, attempt uint32) [sha256.Size]byte { diff --git a/internal/runtime/executor/helps/claude_mcp_alias_test.go b/internal/runtime/executor/helps/claude_mcp_alias_test.go index 2b19b947a..ad5939976 100644 --- a/internal/runtime/executor/helps/claude_mcp_alias_test.go +++ b/internal/runtime/executor/helps/claude_mcp_alias_test.go @@ -45,11 +45,11 @@ func TestClaudeMCPToolAlias(t *testing.T) { if !IsClaudeMCPToolName(first) { t.Fatalf("generated alias %q is not a valid MCP tool name", first) } - if strings.Contains(first, "search") || strings.Contains(first, "web") { - t.Fatalf("generated alias %q reveals the original tool name", first) + if !strings.HasSuffix(first, "_search_web") { + t.Fatalf("generated alias %q does not preserve the semantic suffix", first) } - if matched, _ := regexp.MatchString(`^mcp__[a-z2-7]{12}__[a-z2-7]{16}$`, first); !matched { - t.Fatalf("generated alias %q is not keyed lowercase Base32", first) + if matched, _ := regexp.MatchString(`^mcp__[a-z2-7]{12}__[a-z2-7]{12}_search_web$`, first); !matched { + t.Fatalf("generated alias %q does not contain keyed IDs plus semantics", first) } server := strings.Split(first, "__")[1] if got := strings.Split(caseDistinct, "__")[1]; got != server { @@ -62,3 +62,34 @@ func TestClaudeMCPToolAlias(t *testing.T) { t.Fatalf("different caller unexpectedly shared server %q", server) } } + +func TestClaudeMCPToolAlias_SemanticSuffixIsSafeAndBounded(t *testing.T) { + tests := []struct { + name string + original string + wantSuffix string + wantAliasLen int + }{ + {name: "invalid separators", original: "browser.open URL", wantSuffix: "_browser_open_URL"}, + {name: "unicode mixed", original: "search.网页/tool with spaces", wantSuffix: "_search_tool_with_spaces"}, + {name: "unicode only", original: "搜索网页", wantSuffix: "_tool"}, + {name: "maximum length", original: strings.Repeat("a", 100), wantSuffix: "_" + strings.Repeat("a", 32), wantAliasLen: 64}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + alias := ClaudeMCPToolAlias("credential-secret", tt.original, 0) + if !IsClaudeMCPToolName(alias) { + t.Fatalf("generated alias %q is not a valid MCP tool name", alias) + } + if len(alias) > 64 { + t.Fatalf("generated alias length = %d, want <= 64: %q", len(alias), alias) + } + if tt.wantAliasLen > 0 && len(alias) != tt.wantAliasLen { + t.Fatalf("generated alias length = %d, want %d", len(alias), tt.wantAliasLen) + } + if !strings.HasSuffix(alias, tt.wantSuffix) { + t.Fatalf("generated alias %q does not end in %q", alias, tt.wantSuffix) + } + }) + } +}