Files
CLIProxyAPI/internal/runtime/executor/helps/claude_mcp_alias.go
sususu bdde638c27 fix(claude): pass through caller MCP tools on alias server collision
The word-based virtual server spans only ~2048^2 names, and plausible
real MCP server names such as file_system or web_search are valid BIP-39
word pairs. When a caller's own server matched the derived one, its tools
stopped passing through and entered alias recovery instead: they were
silently restored to an unrelated proxied tool, or failed the request
with a 500 when no semantic suffix matched.

Record untouched caller MCP tool names as identity entries in the reverse
map, skip those entries when collecting virtual servers and recovery
candidates, and forward them unchanged on an exact hit. Recording is
skipped when nothing was aliased, so an untouched request still keeps an
empty reverse map and a no-op restore path.

Also warn instead of silently forwarding an original name when the alias
space is exhausted, report an empty embedded wordlist, trace the
semantic-suffix fallback because it guesses rather than fails, and build
both alias entry points through one shared constructor so the exhaustion
tests cannot drift away from the production path.

Refs #4916
2026-08-14 22:45:09 +08:00

145 lines
4.9 KiB
Go

package helps
import (
"crypto/hmac"
"crypto/sha256"
"encoding/binary"
"strings"
log "github.com/sirupsen/logrus"
)
// IsClaudeMCPToolName reports whether name follows Claude Code's MCP tool
// convention and contains only characters accepted by Anthropic tool names.
func IsClaudeMCPToolName(name string) bool {
if len(name) == 0 || len(name) > 64 || !strings.HasPrefix(name, "mcp__") {
return false
}
rest := strings.TrimPrefix(name, "mcp__")
separator := strings.Index(rest, "__")
if separator <= 0 || separator+2 >= len(rest) {
return false
}
for _, char := range name {
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') ||
(char >= '0' && char <= '9') || char == '_' || char == '-' {
continue
}
return false
}
return true
}
// ClaudeMCPAliasWordCount is the BIP-39 English dictionary size used for the
// virtual server pair and the one-word tool ID.
func ClaudeMCPAliasWordCount() int {
return len(claudeMCPAliasEnglishWords)
}
// 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 linearly probes the next word when a collision must be avoided.
// Server and tool IDs use BIP-39 English words so weak models are less likely
// to drift high-entropy Base32 fragments.
func ClaudeMCPToolAlias(secret, original string, attempt uint32) string {
toolDigest := claudeMCPAliasDigest(secret, "tool", original)
return claudeMCPAliasFor(
claudeMCPAliasServerComponent(secret),
claudeMCPAliasWord(toolDigest[:], 0, attempt),
original,
)
}
// AllocateClaudeMCPToolAlias picks an alias that is not already reserved.
// Attempts are capped at the wordlist size so names that sanitize to the same
// suffix cannot spin forever. ok is false only when every one-word tool ID for
// this semantic is already reserved.
func AllocateClaudeMCPToolAlias(secret, original string, reserved map[string]bool) (string, bool) {
words := claudeMCPAliasEnglishWords
totalWords := len(words)
if totalWords == 0 {
log.Error("claude oauth mcp alias: embedded BIP-39 wordlist is empty, tool aliasing is disabled")
return "", false
}
server := claudeMCPAliasServerComponent(secret)
toolDigest := claudeMCPAliasDigest(secret, "tool", original)
baseIndex := int(binary.BigEndian.Uint16(toolDigest[0:2])) % totalWords
for attempt := 0; attempt < totalWords; attempt++ {
alias := claudeMCPAliasFor(server, words[(baseIndex+attempt)%totalWords], original)
if reserved != nil && reserved[alias] {
continue
}
return alias, true
}
return "", false
}
// claudeMCPAliasFor assembles the final alias for one server/tool word pair.
// Both the single-shot and the allocating entry point must build names here so
// the two cannot drift apart.
func claudeMCPAliasFor(server, toolID, original string) string {
prefix := "mcp__" + server + "__" + toolID + "_"
maxSemanticLen := 64 - len(prefix)
if maxSemanticLen < 1 {
maxSemanticLen = 1
}
return prefix + claudeMCPToolSemanticSuffix(original, maxSemanticLen)
}
// claudeMCPAliasServerComponent derives the caller-stable two-word virtual
// server shared by every alias generated for one credential.
func claudeMCPAliasServerComponent(secret string) string {
serverDigest := claudeMCPAliasDigest(secret, "server", "")
return claudeMCPAliasWord(serverDigest[:], 0, 0) + "_" + claudeMCPAliasWord(serverDigest[:], 2, 0)
}
func claudeMCPAliasWord(digest []byte, offset int, attempt uint32) string {
words := claudeMCPAliasEnglishWords
if len(words) == 0 || offset < 0 || offset+2 > len(digest) {
return "tool"
}
base := int(binary.BigEndian.Uint16(digest[offset : offset+2]))
return words[(base+int(attempt))%len(words)]
}
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) [sha256.Size]byte {
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte("cpa-claude-mcp-alias-v2\x00"))
_, _ = mac.Write([]byte(purpose))
_, _ = mac.Write([]byte{0})
_, _ = mac.Write([]byte(original))
var digest [sha256.Size]byte
copy(digest[:], mac.Sum(nil))
return digest
}