- Normalize repeated `mcp__<server>__` alias prefixes during reverse remapping to resolve stacked aliases. - Add a semantic-suffix fallback when parsing fails, allowing unambiguous recovery from malformed tool IDs.
Unified · Split
| @@ -1586,9 +1586,17 @@ func (resolver claudeMCPAliasResolver) resolve(name string) (string, bool, error · 1585 unmodified lines | |||
| 1586 | return "", false, nil | 1586 | return "", false, nil |
| 1587 | } | 1587 | } |
| 1588 | 1588 | ||
| 1589 | repeatedServerPrefix := "mcp__" + server + "__" + server + "__" | 1589 | canonicalServerPrefix := "mcp__" + server + "__" |
| 1590 | if suffix, repeatedServer := strings.CutPrefix(name, repeatedServerPrefix); repeatedServer { | 1590 | normalizedName := name |
| 1591 | if original, exact := resolver.exact["mcp__"+server+"__"+suffix]; exact { | 1591 | suffix := strings.TrimPrefix(name, canonicalServerPrefix) |
| 1592 | for { | ||
| 1593 | strippedSuffix, repeatedServer := strings.CutPrefix(suffix, server+"__") | ||
| 1594 | if !repeatedServer { | ||
| 1595 | break | ||
| 1596 | } | ||
| 1597 | suffix = strippedSuffix | ||
| 1598 | normalizedName = canonicalServerPrefix + suffix | ||
| 1599 | if original, exact := resolver.exact[normalizedName]; exact { | ||
| 1592 | return original, true, nil | 1600 | return original, true, nil |
| 1593 | } | 1601 | } |
| 1594 | } | 1602 | } |
| @@ -1608,21 +1616,30 @@ func (resolver claudeMCPAliasResolver) resolve(name string) (string, bool, error · 13 unmodified lines | |||
| 1608 | return "", false, fmt.Errorf("cannot restore Claude OAuth MCP tool alias %q: matched multiple declared aliases", name) | 1616 | return "", false, fmt.Errorf("cannot restore Claude OAuth MCP tool alias %q: matched multiple declared aliases", name) |
| 1609 | } | 1617 | } |
| 1610 | 1618 | ||
| 1611 | parts, ok := parseClaudeMCPAlias(name) | 1619 | parts, validAlias := parseClaudeMCPAlias(normalizedName) |
| 1612 | if ok { | 1620 | if validAlias { |
| 1613 | for _, entry := range resolver.aliases { | 1621 | for _, entry := range resolver.aliases { |
| 1614 | if entry.parts.server == parts.server && entry.parts.semantic == parts.semantic { | 1622 | if entry.parts.server == parts.server && entry.parts.semantic == parts.semantic { |
| 1615 | matchedOriginal = entry.original | 1623 | matchedOriginal = entry.original |
| 1616 | matchCount++ | 1624 | matchCount++ |
| 1617 | } | 1625 | } |
| 1618 | } | 1626 | } |
| 1619 | if matchCount == 1 { | 1627 | } else { |
| 1620 | return matchedOriginal, true, nil | 1628 | // Keep generated aliases strict while allowing a malformed response tool ID |
| 1621 | } | 1629 | // to recover only when its request-local semantic suffix is unambiguous. |
| 1622 | if matchCount > 1 { | 1630 | for _, entry := range resolver.aliases { |
| 1623 | return "", false, fmt.Errorf("cannot restore Claude OAuth MCP tool alias %q: semantic suffix matches multiple declared tools", name) | 1631 | if entry.parts.server == server && strings.HasSuffix(normalizedName, "_"+entry.parts.semantic) { |
| 1632 | matchedOriginal = entry.original | ||
| 1633 | matchCount++ | ||
| 1634 | } | ||
| 1624 | } | 1635 | } |
| 1625 | } | 1636 | } |
| 1637 | if matchCount == 1 { | ||
| 1638 | return matchedOriginal, true, nil | ||
| 1639 | } | ||
| 1640 | if matchCount > 1 { | ||
| 1641 | return "", false, fmt.Errorf("cannot restore Claude OAuth MCP tool alias %q: semantic suffix matches multiple declared tools", name) | ||
| 1642 | } | ||
| 1626 | 1643 | ||
| 1627 | return "", false, fmt.Errorf("cannot restore Claude OAuth MCP tool alias %q: no unique request-local match", name) | 1644 | return "", false, fmt.Errorf("cannot restore Claude OAuth MCP tool alias %q: no unique request-local match", name) |
| 1628 | } | 1645 | } |
| @@ -163,18 +163,83 @@ func TestReverseRemapOAuthToolNamesRecoversMangledAliases(t *testing.T) { · 162 unmodified lines | |||
| 163 | } | 163 | } |
| 164 | } | 164 | } |
| 165 | 165 | ||
| 166 | func TestReverseRemapOAuthToolNamesRecoversRepeatedServerAlias(t *testing.T) { | 166 | func TestReverseRemapOAuthToolNamesRecoversRepeatedServerAliases(t *testing.T) { |
| 167 | const alias = "mcp__hmzqrngkulqv__xuo7jlxlpzee_Bash" | 167 | const alias = "mcp__hmzqrngkulqv__xuo7jlxlpzee_Bash" |
| 168 | reverseMap := map[string]string{alias: "Bash"} | 168 | reverseMap := map[string]string{ |
| 169 | responseAlias := "mcp__hmzqrngkulqv__hmzqrngkulqv__xuo7jlxlpzee_Bash" | 169 | alias: "Bash", |
| 170 | response := []byte(fmt.Sprintf(`{"content":[{"type":"tool_use","id":"toolu_1","name":%q,"input":{}}]}`, responseAlias)) | 170 | "mcp__hmzqrngkulqv__aaaaaaaaaaaa_Bash": "OtherBash", |
| 171 | } | ||
| 172 | tests := []struct { | ||
| 173 | name string | ||
| 174 | responseAlias string | ||
| 175 | }{ | ||
| 176 | { | ||
| 177 | name: "single repetition", | ||
| 178 | responseAlias: "mcp__hmzqrngkulqv__hmzqrngkulqv__xuo7jlxlpzee_Bash", | ||
| 179 | }, | ||
| 180 | { | ||
| 181 | name: "multiple repetitions", | ||
| 182 | responseAlias: "mcp__hmzqrngkulqv__hmzqrngkulqv__hmzqrngkulqv__xuo7jlxlpzee_Bash", | ||
| 183 | }, | ||
| 184 | } | ||
| 171 | 185 | ||
| 172 | restored, errReverse := reverseRemapOAuthToolNames(response, reverseMap) | 186 | for _, test := range tests { |
| 173 | if errReverse != nil { | 187 | t.Run(test.name, func(t *testing.T) { |
| 174 | t.Fatalf("reverseRemapOAuthToolNames() error = %v", errReverse) | 188 | response := []byte(fmt.Sprintf(`{"content":[{"type":"tool_use","id":"toolu_1","name":%q,"input":{}}]}`, test.responseAlias)) |
| 189 | restored, errReverse := reverseRemapOAuthToolNames(response, reverseMap) | ||
| 190 | if errReverse != nil { | ||
| 191 | t.Fatalf("reverseRemapOAuthToolNames() error = %v", errReverse) | ||
| 192 | } | ||
| 193 | if got := gjson.GetBytes(restored, "content.0.name").String(); got != "Bash" { | ||
| 194 | t.Fatalf("repeated server alias restored to %q, want Bash", got) | ||
| 195 | } | ||
| 196 | |||
| 197 | line := []byte(fmt.Sprintf(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":%q,"input":{}}}`, test.responseAlias)) | ||
| 198 | restoredLine, errStream := reverseRemapOAuthToolNamesFromStreamLine(line, reverseMap) | ||
| 199 | if errStream != nil { | ||
| 200 | t.Fatalf("reverseRemapOAuthToolNamesFromStreamLine() error = %v", errStream) | ||
| 201 | } | ||
| 202 | if got := gjson.GetBytes(helps.JSONPayload(restoredLine), "content_block.name").String(); got != "Bash" { | ||
| 203 | t.Fatalf("stream repeated server alias restored to %q, want Bash", got) | ||
| 204 | } | ||
| 205 | }) | ||
| 175 | } | 206 | } |
| 176 | if got := gjson.GetBytes(restored, "content.0.name").String(); got != "Bash" { | 207 | } |
| 177 | t.Fatalf("repeated server alias restored to %q, want Bash", got) | 208 | |
| 209 | func TestReverseRemapOAuthToolNamesRecoversMalformedToolIDBySemanticSuffix(t *testing.T) { | ||
| 210 | const alias = "mcp__hmzqrngkulqv__xuo7jlxlpzee_Bash" | ||
| 211 | reverseMap := map[string]string{alias: "Bash"} | ||
| 212 | tests := []struct { | ||
| 213 | name string | ||
| 214 | responseAlias string | ||
| 215 | }{ | ||
| 216 | {name: "short tool ID", responseAlias: "mcp__hmzqrngkulqv__xuo7jlxlpze_Bash"}, | ||
| 217 | {name: "long tool ID", responseAlias: "mcp__hmzqrngkulqv__xuo7jlxlpzeea_Bash"}, | ||
| 218 | {name: "invalid base32 tool ID", responseAlias: "mcp__hmzqrngkulqv__xuo7jlxlpze0_Bash"}, | ||
| 219 | {name: "substituted base32 tool ID", responseAlias: "mcp__hmzqrngkulqv__auo7jlxlpzee_Bash"}, | ||
| 220 | {name: "repeated server and short tool ID", responseAlias: "mcp__hmzqrngkulqv__hmzqrngkulqv__xuo7jlxlpze_Bash"}, | ||
| 221 | } | ||
| 222 | |||
| 223 | for _, test := range tests { | ||
| 224 | t.Run(test.name, func(t *testing.T) { | ||
| 225 | response := []byte(fmt.Sprintf(`{"content":[{"type":"tool_use","id":"toolu_1","name":%q,"input":{}}]}`, test.responseAlias)) | ||
| 226 | restored, errReverse := reverseRemapOAuthToolNames(response, reverseMap) | ||
| 227 | if errReverse != nil { | ||
| 228 | t.Fatalf("reverseRemapOAuthToolNames() error = %v", errReverse) | ||
| 229 | } | ||
| 230 | if got := gjson.GetBytes(restored, "content.0.name").String(); got != "Bash" { | ||
| 231 | t.Fatalf("malformed tool ID alias restored to %q, want Bash", got) | ||
| 232 | } | ||
| 233 | |||
| 234 | line := []byte(fmt.Sprintf(`data: {"type":"content_block_start","index":0,"content_block":{"type":"tool_use","id":"toolu_1","name":%q,"input":{}}}`, test.responseAlias)) | ||
| 235 | restoredLine, errStream := reverseRemapOAuthToolNamesFromStreamLine(line, reverseMap) | ||
| 236 | if errStream != nil { | ||
| 237 | t.Fatalf("reverseRemapOAuthToolNamesFromStreamLine() error = %v", errStream) | ||
| 238 | } | ||
| 239 | if got := gjson.GetBytes(helps.JSONPayload(restoredLine), "content_block.name").String(); got != "Bash" { | ||
| 240 | t.Fatalf("stream malformed tool ID alias restored to %q, want Bash", got) | ||
| 241 | } | ||
| 242 | }) | ||
| 178 | } | 243 | } |
| 179 | } | 244 | } |
| 180 | 245 | ||
| @@ -209,6 +274,11 @@ func TestReverseRemapOAuthToolNamesRejectsUnsafeMangledAliases(t *testing.T) { · 28 unmodified lines | |||
| 209 | alias: "mcp__" + firstParts.server + "__" + unknownToolID + "_" + firstParts.semantic, | 274 | alias: "mcp__" + firstParts.server + "__" + unknownToolID + "_" + firstParts.semantic, |
| 210 | wantError: "semantic suffix matches multiple declared tools", | 275 | wantError: "semantic suffix matches multiple declared tools", |
| 211 | }, | 276 | }, |
| 277 | { | ||
| 278 | name: "ambiguous semantic suffix with malformed tool ID", | ||
| 279 | alias: "mcp__" + firstParts.server + "__" + unknownToolID[:len(unknownToolID)-1] + "_" + firstParts.semantic, | ||
| 280 | wantError: "semantic suffix matches multiple declared tools", | ||
| 281 | }, | ||
| 212 | { | 282 | { |
| 213 | name: "unrecoverable semantic suffix", | 283 | name: "unrecoverable semantic suffix", |
| 214 | alias: "mcp__" + firstParts.server + "__" + unknownToolID + "_missing_tool", | 284 | alias: "mcp__" + firstParts.server + "__" + unknownToolID + "_missing_tool", |