Keep the opaque MCP alias reversible and meaning-preserving so a cloaked caller's tool declarations, choices and history survive the round trip without changing tool ownership.
Unified · Split
| @@ -512,9 +512,9 @@ func restoreClaudeOAuthToolNamesFromStreamLine(line []byte, reverseMap map[strin · 511 unmodified lines | |||
| 512 | return reverseRemapOAuthToolNamesFromStreamLine(line, reverseMap) | 512 | return reverseRemapOAuthToolNamesFromStreamLine(line, reverseMap) |
| 513 | } | 513 | } |
| 514 | 514 | ||
| 515 | // remapOAuthToolNames represents every declared third-party client tool as an | 515 | // remapOAuthToolNames represents every declared third-party client tool as a |
| 516 | // opaque Claude Code MCP extension. Existing valid MCP names and explicit typed | 516 | // semantic Claude Code MCP extension. Existing valid MCP names and explicit |
| 517 | // Anthropic tools remain unchanged. | 517 | // typed Anthropic tools remain unchanged. |
| 518 | // | 518 | // |
| 519 | // It operates on tools[].name, tool_choice.name, and all declared | 519 | // It operates on tools[].name, tool_choice.name, and all declared |
| 520 | // tool_use/tool_reference references in messages. | 520 | // tool_use/tool_reference references in messages. |
| @@ -527,18 +527,6 @@ func remapOAuthToolNames(body []byte) ([]byte, map[string]string) { · 6 unmodified lines | |||
| 527 | return remapOAuthToolNamesWithOptions(body, claudeMCPAliasOptions{secret: "cpa-claude-mcp-default-caller"}) | 527 | return remapOAuthToolNamesWithOptions(body, claudeMCPAliasOptions{secret: "cpa-claude-mcp-default-caller"}) |
| 528 | } | 528 | } |
| 529 | 529 | ||
| 530 | func claudeMCPAliasRevealsOriginal(alias, original string) bool { | ||
| 531 | alias = strings.ToLower(alias) | ||
| 532 | for _, fragment := range strings.FieldsFunc(strings.ToLower(original), func(char rune) bool { | ||
| 533 | return !((char >= 'a' && char <= 'z') || (char >= '0' && char <= '9')) | ||
| 534 | }) { | ||
| 535 | if len(fragment) >= 4 && strings.Contains(alias, fragment) { | ||
| 536 | return true | ||
| 537 | } | ||
| 538 | } | ||
| 539 | return false | ||
| 540 | } | ||
| 541 | |||
| 542 | func remapOAuthToolNamesWithOptions(body []byte, mcpAliases claudeMCPAliasOptions) ([]byte, map[string]string) { | 530 | func remapOAuthToolNamesWithOptions(body []byte, mcpAliases claudeMCPAliasOptions) ([]byte, map[string]string) { |
| 543 | reverseMap := make(map[string]string) | 531 | reverseMap := make(map[string]string) |
| 544 | recordRename := func(original, renamed string) { | 532 | recordRename := func(original, renamed string) { |
| @@ -580,7 +568,7 @@ func remapOAuthToolNamesWithOptions(body []byte, mcpAliases claudeMCPAliasOption · 35 unmodified lines | |||
| 580 | } | 568 | } |
| 581 | for attempt := uint32(0); ; attempt++ { | 569 | for attempt := uint32(0); ; attempt++ { |
| 582 | alias := helps.ClaudeMCPToolAlias(mcpAliases.secret, name, attempt) | 570 | alias := helps.ClaudeMCPToolAlias(mcpAliases.secret, name, attempt) |
| 583 | if reservedNames[alias] || claudeMCPAliasRevealsOriginal(alias, name) { | 571 | if reservedNames[alias] { |
| 584 | continue | 572 | continue |
| 585 | } | 573 | } |
| 586 | forwardMap[name] = alias | 574 | forwardMap[name] = alias |
| @@ -3991,8 +3991,11 @@ func TestRemapOAuthToolNames_AllClientToolsAsMCP(t *testing.T) { · 3990 unmodified lines | |||
| 3991 | if got := gjson.GetBytes(out, "tools.6.name").String(); got != searchAlias { | 3991 | if got := gjson.GetBytes(out, "tools.6.name").String(); got != searchAlias { |
| 3992 | t.Fatalf("repeated declaration alias = %q, want %q", got, searchAlias) | 3992 | t.Fatalf("repeated declaration alias = %q, want %q", got, searchAlias) |
| 3993 | } | 3993 | } |
| 3994 | if strings.Contains(searchAlias, "search") || strings.Contains(searchAlias, "web") { | 3994 | if !strings.HasSuffix(searchAlias, "_search_web") || !strings.HasSuffix(caseAlias, "_Search_Web") { |
| 3995 | t.Fatalf("alias %q reveals original name", searchAlias) | 3995 | t.Fatalf("generated aliases lost semantic suffixes: %q, %q", searchAlias, caseAlias) |
| 3996 | } | ||
| 3997 | if len(searchAlias) > 64 || len(caseAlias) > 64 { | ||
| 3998 | t.Fatalf("generated aliases exceed 64 characters: %q, %q", searchAlias, caseAlias) | ||
| 3996 | } | 3999 | } |
| 3997 | if got := gjson.GetBytes(out, "tools.4.description").String(); got != "unknown one" { | 4000 | if got := gjson.GetBytes(out, "tools.4.description").String(); got != "unknown one" { |
| 3998 | t.Fatalf("description = %q, want preserved", got) | 4001 | t.Fatalf("description = %q, want preserved", got) |
| @@ -4116,6 +4119,34 @@ func TestRemapOAuthToolNames_MCPAliasIsMandatory(t *testing.T) { · 117 unmodified lines | |||
| 4116 | } | 4119 | } |
| 4117 | } | 4120 | } |
| 4118 | 4121 | ||
| 4122 | func TestRemapOAuthToolNames_SemanticAliasRestoresLongOriginal(t *testing.T) { | ||
| 4123 | original := "Read.file/with a very long semantic name and Unicode 网页内容 that exceeds the wire limit" | ||
| 4124 | body := []byte(`{"tools":[{"name":` + fmt.Sprintf("%q", original) + `,"input_schema":{"type":"object"}}]}`) | ||
| 4125 | options := claudeMCPAliasOptions{secret: "stable-caller"} | ||
| 4126 | |||
| 4127 | out, reverseMap := remapOAuthToolNamesWithOptions(body, options) | ||
| 4128 | alias := gjson.GetBytes(out, "tools.0.name").String() | ||
| 4129 | if !helps.IsClaudeMCPToolName(alias) || len(alias) > 64 { | ||
| 4130 | t.Fatalf("semantic alias is invalid or too long: len=%d name=%q", len(alias), alias) | ||
| 4131 | } | ||
| 4132 | if !strings.Contains(alias, "_Read_file_with_a_very_long") { | ||
| 4133 | t.Fatalf("semantic alias %q does not expose the truncated original meaning", alias) | ||
| 4134 | } | ||
| 4135 | if reverseMap[alias] != original { | ||
| 4136 | t.Fatalf("reverseMap lost exact original: got %q, want %q", reverseMap[alias], original) | ||
| 4137 | } | ||
| 4138 | |||
| 4139 | second, _ := remapOAuthToolNamesWithOptions(body, options) | ||
| 4140 | if got := gjson.GetBytes(second, "tools.0.name").String(); got != alias { | ||
| 4141 | t.Fatalf("semantic alias is not stable across requests: %q != %q", got, alias) | ||
| 4142 | } | ||
| 4143 | response := []byte(`{"content":[{"type":"tool_use","id":"toolu_1","name":` + fmt.Sprintf("%q", alias) + `,"input":{}}]}`) | ||
| 4144 | restored := reverseRemapOAuthToolNames(response, reverseMap) | ||
| 4145 | if got := gjson.GetBytes(restored, "content.0.name").String(); got != original { | ||
| 4146 | t.Fatalf("restored tool name = %q, want exact original %q", got, original) | ||
| 4147 | } | ||
| 4148 | } | ||
| 4149 | |||
| 4119 | func TestPrepareClaudeOAuthToolNamesForUpstream_PreservesMCPConvention(t *testing.T) { | 4150 | func TestPrepareClaudeOAuthToolNamesForUpstream_PreservesMCPConvention(t *testing.T) { |
| 4120 | body := []byte(`{"tools":[ | 4151 | body := []byte(`{"tools":[ |
| 4121 | {"name":"search_web","input_schema":{"type":"object"}}, | 4152 | {"name":"search_web","input_schema":{"type":"object"}}, |
| @@ -4292,8 +4323,8 @@ func TestClaudeExecutor_ExecuteOpenAINonStreamRestoresOAuthToolNames(t *testing. · 170 unmodified lines | |||
| 4292 | if !upstream.stream { | 4323 | if !upstream.stream { |
| 4293 | t.Fatal("upstream stream = false, want true") | 4324 | t.Fatal("upstream stream = false, want true") |
| 4294 | } | 4325 | } |
| 4295 | if !helps.IsClaudeMCPToolName(upstream.toolName) { | 4326 | if !helps.IsClaudeMCPToolName(upstream.toolName) || !strings.HasSuffix(upstream.toolName, "_bash") { |
| 4296 | t.Fatalf("upstream tools.0.name = %q, want MCP alias", upstream.toolName) | 4327 | t.Fatalf("upstream tools.0.name = %q, want semantic MCP alias", upstream.toolName) |
| 4297 | } | 4328 | } |
| 4298 | if got := gjson.GetBytes(resp.Payload, "choices.0.message.tool_calls.0.function.name").String(); got != "bash" { | 4329 | if got := gjson.GetBytes(resp.Payload, "choices.0.message.tool_calls.0.function.name").String(); got != "bash" { |
| 4299 | t.Fatalf("tool_calls.0.function.name = %q, want %q; payload=%s", got, "bash", string(resp.Payload)) | 4330 | 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) { · 30 unmodified lines | |||
| 4330 | if errExecute != nil { | 4361 | if errExecute != nil { |
| 4331 | t.Fatalf("Execute() error = %v", errExecute) | 4362 | t.Fatalf("Execute() error = %v", errExecute) |
| 4332 | } | 4363 | } |
| 4333 | if !helps.IsClaudeMCPToolName(upstreamAlias) || strings.HasPrefix(upstreamAlias, "proxy_") { | 4364 | if !helps.IsClaudeMCPToolName(upstreamAlias) || strings.HasPrefix(upstreamAlias, "proxy_") || !strings.HasSuffix(upstreamAlias, "_search_web") { |
| 4334 | t.Fatalf("upstream tool name = %q, want mcp__ alias", upstreamAlias) | 4365 | t.Fatalf("upstream tool name = %q, want semantic mcp__ alias", upstreamAlias) |
| 4335 | } | 4366 | } |
| 4336 | if got := gjson.GetBytes(resp.Payload, "content.0.name").String(); got != "search_web" { | 4367 | if got := gjson.GetBytes(resp.Payload, "content.0.name").String(); got != "search_web" { |
| 4337 | t.Fatalf("client response tool name = %q, want search_web; payload=%s", got, resp.Payload) | 4368 | t.Fatalf("client response tool name = %q, want search_web; payload=%s", got, resp.Payload) |
| @@ -4410,8 +4441,8 @@ func TestClaudeExecutor_ExecuteStreamOAuthCustomToolMCPAliasRoundTrip(t *testing · 72 unmodified lines | |||
| 4410 | } | 4441 | } |
| 4411 | downstream.Write(chunk.Payload) | 4442 | downstream.Write(chunk.Payload) |
| 4412 | } | 4443 | } |
| 4413 | if !helps.IsClaudeMCPToolName(upstreamAlias) { | 4444 | if !helps.IsClaudeMCPToolName(upstreamAlias) || !strings.HasSuffix(upstreamAlias, "_fetch_url") { |
| 4414 | t.Fatalf("upstream tool name = %q, want mcp__ alias", upstreamAlias) | 4445 | t.Fatalf("upstream tool name = %q, want semantic mcp__ alias", upstreamAlias) |
| 4415 | } | 4446 | } |
| 4416 | if _, ok := claudeBillingCCHDigitsOffset(upstreamBody); !ok { | 4447 | if _, ok := claudeBillingCCHDigitsOffset(upstreamBody); !ok { |
| 4417 | t.Fatalf("streaming Claude OAuth custom BaseURL body is missing CCH: %s", upstreamBody) | 4448 | t.Fatalf("streaming Claude OAuth custom BaseURL body is missing CCH: %s", upstreamBody) |
| @@ -31,16 +31,45 @@ func IsClaudeMCPToolName(name string) bool { · 30 unmodified lines | |||
| 31 | return true | 31 | return true |
| 32 | } | 32 | } |
| 33 | 33 | ||
| 34 | // ClaudeMCPToolAlias derives an opaque Claude Code-style MCP tool name. All | 34 | // ClaudeMCPToolAlias derives a Claude Code-style MCP tool name. Aliases from |
| 35 | // aliases created with the same caller secret share one virtual server name; | 35 | // one caller share a virtual server component. The tool component combines a |
| 36 | // original tool names affect only the tool component. A higher attempt changes | 36 | // stable keyed ID with a truncated semantic suffix so the model can distinguish |
| 37 | // the tool component when a request-local collision must be avoided. | 37 | // tools by name while the request-local symbol table restores the exact original. |
| 38 | // A higher attempt changes the stable ID when a collision must be avoided. | ||
| 38 | func ClaudeMCPToolAlias(secret, original string, attempt uint32) string { | 39 | func ClaudeMCPToolAlias(secret, original string, attempt uint32) string { |
| 39 | serverDigest := claudeMCPAliasDigest(secret, "server", "", 0) | 40 | serverDigest := claudeMCPAliasDigest(secret, "server", "", 0) |
| 40 | toolDigest := claudeMCPAliasDigest(secret, "tool", original, attempt) | 41 | toolDigest := claudeMCPAliasDigest(secret, "tool", original, attempt) |
| 41 | server := claudeMCPBase32.EncodeToString(serverDigest[:])[:12] | 42 | server := claudeMCPBase32.EncodeToString(serverDigest[:])[:12] |
| 42 | tool := claudeMCPBase32.EncodeToString(toolDigest[:])[:16] | 43 | toolID := claudeMCPBase32.EncodeToString(toolDigest[:])[:12] |
| 43 | return "mcp__" + server + "__" + tool | 44 | semantic := claudeMCPToolSemanticSuffix(original, 32) |
| 45 | return "mcp__" + server + "__" + toolID + "_" + semantic | ||
| 46 | } | ||
| 47 | |||
| 48 | func claudeMCPToolSemanticSuffix(original string, maxLength int) string { | ||
| 49 | var semantic strings.Builder | ||
| 50 | semantic.Grow(min(len(original), maxLength)) | ||
| 51 | pendingSeparator := false | ||
| 52 | for _, char := range original { | ||
| 53 | valid := (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || | ||
| 54 | (char >= '0' && char <= '9') || char == '_' || char == '-' | ||
| 55 | if !valid { | ||
| 56 | pendingSeparator = semantic.Len() > 0 | ||
| 57 | continue | ||
| 58 | } | ||
| 59 | if pendingSeparator && semantic.Len()+1 < maxLength { | ||
| 60 | semantic.WriteByte('_') | ||
| 61 | } | ||
| 62 | pendingSeparator = false | ||
| 63 | if semantic.Len() >= maxLength { | ||
| 64 | break | ||
| 65 | } | ||
| 66 | semantic.WriteRune(char) | ||
| 67 | } | ||
| 68 | result := strings.Trim(semantic.String(), "_-") | ||
| 69 | if result == "" { | ||
| 70 | return "tool" | ||
| 71 | } | ||
| 72 | return result | ||
| 44 | } | 73 | } |
| 45 | 74 | ||
| 46 | func claudeMCPAliasDigest(secret, purpose, original string, attempt uint32) [sha256.Size]byte { | 75 | func claudeMCPAliasDigest(secret, purpose, original string, attempt uint32) [sha256.Size]byte { |
| @@ -45,11 +45,11 @@ func TestClaudeMCPToolAlias(t *testing.T) { · 44 unmodified lines | |||
| 45 | if !IsClaudeMCPToolName(first) { | 45 | if !IsClaudeMCPToolName(first) { |
| 46 | t.Fatalf("generated alias %q is not a valid MCP tool name", first) | 46 | t.Fatalf("generated alias %q is not a valid MCP tool name", first) |
| 47 | } | 47 | } |
| 48 | if strings.Contains(first, "search") || strings.Contains(first, "web") { | 48 | if !strings.HasSuffix(first, "_search_web") { |
| 49 | t.Fatalf("generated alias %q reveals the original tool name", first) | 49 | t.Fatalf("generated alias %q does not preserve the semantic suffix", first) |
| 50 | } | 50 | } |
| 51 | if matched, _ := regexp.MatchString(`^mcp__[a-z2-7]{12}__[a-z2-7]{16}$`, first); !matched { | 51 | if matched, _ := regexp.MatchString(`^mcp__[a-z2-7]{12}__[a-z2-7]{12}_search_web$`, first); !matched { |
| 52 | t.Fatalf("generated alias %q is not keyed lowercase Base32", first) | 52 | t.Fatalf("generated alias %q does not contain keyed IDs plus semantics", first) |
| 53 | } | 53 | } |
| 54 | server := strings.Split(first, "__")[1] | 54 | server := strings.Split(first, "__")[1] |
| 55 | if got := strings.Split(caseDistinct, "__")[1]; got != server { | 55 | if got := strings.Split(caseDistinct, "__")[1]; got != server { |
| @@ -62,3 +62,34 @@ func TestClaudeMCPToolAlias(t *testing.T) { · 6 unmodified lines | |||
| 62 | t.Fatalf("different caller unexpectedly shared server %q", server) | 62 | t.Fatalf("different caller unexpectedly shared server %q", server) |
| 63 | } | 63 | } |
| 64 | } | 64 | } |
| 65 | |||
| 66 | func TestClaudeMCPToolAlias_SemanticSuffixIsSafeAndBounded(t *testing.T) { | ||
| 67 | tests := []struct { | ||
| 68 | name string | ||
| 69 | original string | ||
| 70 | wantSuffix string | ||
| 71 | wantAliasLen int | ||
| 72 | }{ | ||
| 73 | {name: "invalid separators", original: "browser.open URL", wantSuffix: "_browser_open_URL"}, | ||
| 74 | {name: "unicode mixed", original: "search.网页/tool with spaces", wantSuffix: "_search_tool_with_spaces"}, | ||
| 75 | {name: "unicode only", original: "搜索网页", wantSuffix: "_tool"}, | ||
| 76 | {name: "maximum length", original: strings.Repeat("a", 100), wantSuffix: "_" + strings.Repeat("a", 32), wantAliasLen: 64}, | ||
| 77 | } | ||
| 78 | for _, tt := range tests { | ||
| 79 | t.Run(tt.name, func(t *testing.T) { | ||
| 80 | alias := ClaudeMCPToolAlias("credential-secret", tt.original, 0) | ||
| 81 | if !IsClaudeMCPToolName(alias) { | ||
| 82 | t.Fatalf("generated alias %q is not a valid MCP tool name", alias) | ||
| 83 | } | ||
| 84 | if len(alias) > 64 { | ||
| 85 | t.Fatalf("generated alias length = %d, want <= 64: %q", len(alias), alias) | ||
| 86 | } | ||
| 87 | if tt.wantAliasLen > 0 && len(alias) != tt.wantAliasLen { | ||
| 88 | t.Fatalf("generated alias length = %d, want %d", len(alias), tt.wantAliasLen) | ||
| 89 | } | ||
| 90 | if !strings.HasSuffix(alias, tt.wantSuffix) { | ||
| 91 | t.Fatalf("generated alias %q does not end in %q", alias, tt.wantSuffix) | ||
| 92 | } | ||
| 93 | }) | ||
| 94 | } | ||
| 95 | } | ||