History

fix(claude): recover OAuth tool aliases for repeated prefixes and malformed IDs

- 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.

Luis Pater committed 3 months ago

Commit f2d272d1 parent 7ea9c672 files · +106 −19Browse files

Unified · Split

2 files
  1. internal/runtime/executor/claude_executor_request.go +27 −10
  2. internal/runtime/executor/claude_executor_request_remap_test.go +79 −9
internal/runtime/executor/claude_executor_request.go+27−10
@@ -1586,9 +1586,17 @@ func (resolver claudeMCPAliasResolver) resolve(name string) (string, bool, error · 1585 unmodified lines
1586 return "", false, nil1586 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, nil1600 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.original1623 matchedOriginal = entry.original
1616 matchCount++1624 matchCount++
1617 }1625 }
1618 }1626 }
1619 if matchCount == 1 {1627 } else {
1620 return matchedOriginal, true, nil1628 // 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}
internal/runtime/executor/claude_executor_request_remap_test.go+79−9
@@ -163,18 +163,83 @@ func TestReverseRemapOAuthToolNamesRecoversMangledAliases(t *testing.T) { · 162 unmodified lines
163 }163 }
164}164}
165 165
166func TestReverseRemapOAuthToolNamesRecoversRepeatedServerAlias(t *testing.T) {166func 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
209func 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",