mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 06:35:00 +08:00
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
145 lines
4.9 KiB
Go
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
|
|
}
|