History

fix(claude): preserve semantics in MCP tool aliases

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.

sususu committed 3 months ago

Commit afdd2511 parent ef89c6a4 files · +113 −34Browse files

Unified · Split

4 files
  1. internal/runtime/executor/claude_executor_request.go +4 −16
  2. internal/runtime/executor/claude_executor_test.go +39 −8
  3. internal/runtime/executor/helps/claude_mcp_alias.go +35 −6
  4. internal/runtime/executor/helps/claude_mcp_alias_test.go +35 −4
internal/runtime/executor/claude_executor_request.go+4−16
@@ -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 an515// remapOAuthToolNames represents every declared third-party client tool as a
516// opaque Claude Code MCP extension. Existing valid MCP names and explicit typed516// 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 declared519// 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
530func 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
542func remapOAuthToolNamesWithOptions(body []byte, mcpAliases claudeMCPAliasOptions) ([]byte, map[string]string) {530func 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 continue572 continue
585 }573 }
586 forwardMap[name] = alias574 forwardMap[name] = alias
internal/runtime/executor/claude_executor_test.go+39−8
@@ -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
4122func 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
4119func TestPrepareClaudeOAuthToolNamesForUpstream_PreservesMCPConvention(t *testing.T) {4150func 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)
internal/runtime/executor/helps/claude_mcp_alias.go+35−6
@@ -31,16 +31,45 @@ func IsClaudeMCPToolName(name string) bool { · 30 unmodified lines
31 return true31 return true
32}32}
33 33
34// ClaudeMCPToolAlias derives an opaque Claude Code-style MCP tool name. All34// 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 changes36// 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.
38func ClaudeMCPToolAlias(secret, original string, attempt uint32) string {39func 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 + "__" + tool44 semantic := claudeMCPToolSemanticSuffix(original, 32)
45 return "mcp__" + server + "__" + toolID + "_" + semantic
46}
47
48func 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
46func claudeMCPAliasDigest(secret, purpose, original string, attempt uint32) [sha256.Size]byte {75func claudeMCPAliasDigest(secret, purpose, original string, attempt uint32) [sha256.Size]byte {
internal/runtime/executor/helps/claude_mcp_alias_test.go+35−4
@@ -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
66func 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}