mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-09-03 06:35:00 +08:00
fix(claude): use BIP-39 words for OAuth MCP tool aliases
Replace high-entropy Base32 alias IDs with request-local BIP-39 English words so weaker models are less likely to drift tool names. Keep a two-word virtual server plus one-word tool ID, linearly probe wordlist space on collision without self-overlap, and fall through to unambiguous longest semantic-suffix recovery after a successful but wrong parse. Fixes #4916
This commit is contained in:
@@ -1100,15 +1100,12 @@ func remapOAuthToolNamesWithBatchedEdits(body []byte, mcpAliases claudeMCPAliasO
|
||||
if _, exists := forwardMap[name]; exists {
|
||||
return true
|
||||
}
|
||||
for attempt := uint32(0); ; attempt++ {
|
||||
alias := helps.ClaudeMCPToolAlias(mcpAliases.secret, name, attempt)
|
||||
if reservedNames[alias] {
|
||||
continue
|
||||
}
|
||||
forwardMap[name] = alias
|
||||
reservedNames[alias] = true
|
||||
break
|
||||
alias, allocated := helps.AllocateClaudeMCPToolAlias(mcpAliases.secret, name, reservedNames)
|
||||
if !allocated {
|
||||
return true
|
||||
}
|
||||
forwardMap[name] = alias
|
||||
reservedNames[alias] = true
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -1354,15 +1351,12 @@ func remapOAuthToolNamesWithOptionsLegacy(body []byte, mcpAliases claudeMCPAlias
|
||||
if _, exists := forwardMap[name]; exists {
|
||||
return true
|
||||
}
|
||||
for attempt := uint32(0); ; attempt++ {
|
||||
alias := helps.ClaudeMCPToolAlias(mcpAliases.secret, name, attempt)
|
||||
if reservedNames[alias] {
|
||||
continue
|
||||
}
|
||||
forwardMap[name] = alias
|
||||
reservedNames[alias] = true
|
||||
break
|
||||
alias, allocated := helps.AllocateClaudeMCPToolAlias(mcpAliases.secret, name, reservedNames)
|
||||
if !allocated {
|
||||
return true
|
||||
}
|
||||
forwardMap[name] = alias
|
||||
reservedNames[alias] = true
|
||||
return true
|
||||
})
|
||||
}
|
||||
@@ -1537,33 +1531,24 @@ func newClaudeMCPAliasResolver(reverseMap map[string]string) claudeMCPAliasResol
|
||||
}
|
||||
|
||||
func parseClaudeMCPAlias(name string) (claudeMCPAliasParts, bool) {
|
||||
if !helps.IsClaudeMCPToolName(name) {
|
||||
return claudeMCPAliasParts{}, false
|
||||
}
|
||||
rest, ok := strings.CutPrefix(name, "mcp__")
|
||||
if !ok {
|
||||
return claudeMCPAliasParts{}, false
|
||||
}
|
||||
server, tool, ok := strings.Cut(rest, "__")
|
||||
if !ok || !isClaudeMCPAliasDigest(server) {
|
||||
if !ok || server == "" {
|
||||
return claudeMCPAliasParts{}, false
|
||||
}
|
||||
toolID, semantic, ok := strings.Cut(tool, "_")
|
||||
if !ok || !isClaudeMCPAliasDigest(toolID) || semantic == "" {
|
||||
if !ok || toolID == "" || semantic == "" {
|
||||
return claudeMCPAliasParts{}, false
|
||||
}
|
||||
return claudeMCPAliasParts{server: server, toolID: toolID, semantic: semantic}, true
|
||||
}
|
||||
|
||||
func isClaudeMCPAliasDigest(value string) bool {
|
||||
if len(value) != 12 {
|
||||
return false
|
||||
}
|
||||
for _, char := range value {
|
||||
if (char < 'a' || char > 'z') && (char < '2' || char > '7') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func claudeMCPAliasServer(name string) string {
|
||||
rest, ok := strings.CutPrefix(name, "mcp__")
|
||||
if !ok {
|
||||
@@ -1624,13 +1609,38 @@ func (resolver claudeMCPAliasResolver) resolve(name string) (string, bool, error
|
||||
matchCount++
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Keep generated aliases strict while allowing a malformed response tool ID
|
||||
// to recover only when its request-local semantic suffix is unambiguous.
|
||||
}
|
||||
// Extra words in the tool component still parse, but the semantic field
|
||||
// is then wrong. Fall through to an unambiguous suffix match so word-level
|
||||
// repeats do not become restore 500s.
|
||||
if matchCount == 0 {
|
||||
var suffixMatches []claudeMCPAliasEntry
|
||||
for _, entry := range resolver.aliases {
|
||||
if entry.parts.server == server && strings.HasSuffix(normalizedName, "_"+entry.parts.semantic) {
|
||||
matchedOriginal = entry.original
|
||||
matchCount++
|
||||
suffixMatches = append(suffixMatches, entry)
|
||||
}
|
||||
}
|
||||
if len(suffixMatches) == 1 {
|
||||
matchedOriginal = suffixMatches[0].original
|
||||
matchCount = 1
|
||||
} else if len(suffixMatches) > 1 {
|
||||
// If multiple candidates match (e.g. "_file" and "_read_file"),
|
||||
// choose the strictly longest semantic match when unambiguous.
|
||||
longest := suffixMatches[0]
|
||||
tie := false
|
||||
for _, candidate := range suffixMatches[1:] {
|
||||
if len(candidate.parts.semantic) > len(longest.parts.semantic) {
|
||||
longest = candidate
|
||||
tie = false
|
||||
} else if len(candidate.parts.semantic) == len(longest.parts.semantic) {
|
||||
tie = true
|
||||
}
|
||||
}
|
||||
if !tie {
|
||||
matchedOriginal = longest.original
|
||||
matchCount = 1
|
||||
} else {
|
||||
matchCount = len(suffixMatches)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,6 +243,61 @@ func TestReverseRemapOAuthToolNamesRecoversMalformedToolIDBySemanticSuffix(t *te
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseRemapOAuthToolNamesWithBIP39Aliases(t *testing.T) {
|
||||
body := []byte(`{"tools":[{"name":"Bash","input_schema":{"type":"object"}},{"name":"fetch_url","input_schema":{"type":"object"}}]}`)
|
||||
remapped, reverseMap := remapOAuthToolNamesWithOptions(body, claudeMCPAliasOptions{secret: "bip39-caller"})
|
||||
bashAlias := gjson.GetBytes(remapped, "tools.0.name").String()
|
||||
fetchAlias := gjson.GetBytes(remapped, "tools.1.name").String()
|
||||
|
||||
if !helps.IsClaudeMCPToolName(bashAlias) {
|
||||
t.Fatalf("generated bash alias is invalid: %q", bashAlias)
|
||||
}
|
||||
if !helps.IsClaudeMCPToolName(fetchAlias) {
|
||||
t.Fatalf("generated fetch alias is invalid: %q", fetchAlias)
|
||||
}
|
||||
|
||||
bashParts, ok := parseClaudeMCPAlias(bashAlias)
|
||||
if !ok {
|
||||
t.Fatalf("parseClaudeMCPAlias(%q) failed", bashAlias)
|
||||
}
|
||||
if bashParts.semantic != "Bash" {
|
||||
t.Fatalf("bashParts.semantic = %q, want Bash", bashParts.semantic)
|
||||
}
|
||||
|
||||
repeatedAlias := "mcp__" + bashParts.server + "__" + bashParts.server + "__" + bashParts.toolID + "_Bash"
|
||||
mangledToolIDAlias := "mcp__" + bashParts.server + "__corruptedword_Bash"
|
||||
repeatedToolIDAlias := "mcp__" + bashParts.server + "__" + bashParts.toolID + "_" + bashParts.toolID + "_Bash"
|
||||
extraWordAlias := "mcp__" + bashParts.server + "__" + bashParts.toolID + "_cabin_Bash"
|
||||
|
||||
response := []byte(fmt.Sprintf(`{"content":[
|
||||
{"type":"tool_use","id":"toolu_1","name":%q,"input":{}},
|
||||
{"type":"tool_use","id":"toolu_2","name":%q,"input":{}},
|
||||
{"type":"tool_reference","tool_name":%q},
|
||||
{"type":"tool_use","id":"toolu_3","name":%q,"input":{}},
|
||||
{"type":"tool_use","id":"toolu_4","name":%q,"input":{}}
|
||||
]}`, bashAlias, repeatedAlias, mangledToolIDAlias, repeatedToolIDAlias, extraWordAlias))
|
||||
|
||||
restored, errReverse := reverseRemapOAuthToolNames(response, reverseMap)
|
||||
if errReverse != nil {
|
||||
t.Fatalf("reverseRemapOAuthToolNames() error = %v", errReverse)
|
||||
}
|
||||
if got := gjson.GetBytes(restored, "content.0.name").String(); got != "Bash" {
|
||||
t.Fatalf("exact alias restored to %q, want Bash", got)
|
||||
}
|
||||
if got := gjson.GetBytes(restored, "content.1.name").String(); got != "Bash" {
|
||||
t.Fatalf("repeated alias restored to %q, want Bash", got)
|
||||
}
|
||||
if got := gjson.GetBytes(restored, "content.2.tool_name").String(); got != "Bash" {
|
||||
t.Fatalf("mangled toolID alias restored to %q, want Bash", got)
|
||||
}
|
||||
if got := gjson.GetBytes(restored, "content.3.name").String(); got != "Bash" {
|
||||
t.Fatalf("repeated toolID alias restored to %q, want Bash", got)
|
||||
}
|
||||
if got := gjson.GetBytes(restored, "content.4.name").String(); got != "Bash" {
|
||||
t.Fatalf("extra-word alias restored to %q, want Bash", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseRemapOAuthToolNamesRejectsUnsafeMangledAliases(t *testing.T) {
|
||||
body := []byte(`{"tools":[{"name":"tool.name"},{"name":"tool/name"}]}`)
|
||||
remapped, reverseMap := remapOAuthToolNamesWithOptions(body, claudeMCPAliasOptions{secret: "ambiguous-alias-caller"})
|
||||
@@ -300,6 +355,44 @@ func TestReverseRemapOAuthToolNamesRejectsUnsafeMangledAliases(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseRemapOAuthToolNames_OverlappingSemanticSuffix(t *testing.T) {
|
||||
body := []byte(`{"tools":[{"name":"file"},{"name":"read_file"}]}`)
|
||||
remapped, reverseMap := remapOAuthToolNamesWithOptions(body, claudeMCPAliasOptions{secret: "overlapping-caller"})
|
||||
fileAlias := gjson.GetBytes(remapped, "tools.0.name").String()
|
||||
readFileAlias := gjson.GetBytes(remapped, "tools.1.name").String()
|
||||
|
||||
if !helps.IsClaudeMCPToolName(fileAlias) {
|
||||
t.Fatalf("fileAlias is invalid: %q", fileAlias)
|
||||
}
|
||||
readFileParts, ok := parseClaudeMCPAlias(readFileAlias)
|
||||
if !ok {
|
||||
t.Fatalf("parseClaudeMCPAlias(%q) failed", readFileAlias)
|
||||
}
|
||||
|
||||
// Model generates repeated toolID for read_file: mcp__<server>__<toolID>_<toolID>_read_file
|
||||
// Even though "_file" is a suffix of "_read_file", longest match should resolve to "read_file"
|
||||
driftedReadFile := "mcp__" + readFileParts.server + "__" + readFileParts.toolID + "_" + readFileParts.toolID + "_read_file"
|
||||
response := []byte(fmt.Sprintf(`{"content":[{"type":"tool_use","id":"toolu_1","name":%q,"input":{}}]}`, driftedReadFile))
|
||||
|
||||
restored, errReverse := reverseRemapOAuthToolNames(response, reverseMap)
|
||||
if errReverse != nil {
|
||||
t.Fatalf("reverseRemapOAuthToolNames() error = %v", errReverse)
|
||||
}
|
||||
if got := gjson.GetBytes(restored, "content.0.name").String(); got != "read_file" {
|
||||
t.Fatalf("restored tool name = %q, want read_file", got)
|
||||
}
|
||||
|
||||
// Exact file alias still resolves to file
|
||||
responseFile := []byte(fmt.Sprintf(`{"content":[{"type":"tool_use","id":"toolu_2","name":%q,"input":{}}]}`, fileAlias))
|
||||
restoredFile, errFile := reverseRemapOAuthToolNames(responseFile, reverseMap)
|
||||
if errFile != nil {
|
||||
t.Fatalf("reverseRemapOAuthToolNames(file) error = %v", errFile)
|
||||
}
|
||||
if got := gjson.GetBytes(restoredFile, "content.0.name").String(); got != "file" {
|
||||
t.Fatalf("restored tool name = %q, want file", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReverseRemapOAuthToolNamesPreservesUnrelatedMCPName(t *testing.T) {
|
||||
body := []byte(`{"tools":[{"name":"glob"}]}`)
|
||||
_, reverseMap := remapOAuthToolNamesWithOptions(body, claudeMCPAliasOptions{secret: "unrelated-mcp-caller"})
|
||||
|
||||
2048
internal/runtime/executor/helps/claude_bip39_words.txt
Normal file
2048
internal/runtime/executor/helps/claude_bip39_words.txt
Normal file
File diff suppressed because it is too large
Load Diff
@@ -3,13 +3,10 @@ package helps
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base32"
|
||||
"encoding/binary"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var claudeMCPBase32 = base32.NewEncoding("abcdefghijklmnopqrstuvwxyz234567").WithPadding(base32.NoPadding)
|
||||
|
||||
// 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 {
|
||||
@@ -31,18 +28,73 @@ func IsClaudeMCPToolName(name string) bool {
|
||||
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 changes the stable ID when a collision must be avoided.
|
||||
// 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 {
|
||||
serverDigest := claudeMCPAliasDigest(secret, "server", "", 0)
|
||||
toolDigest := claudeMCPAliasDigest(secret, "tool", original, attempt)
|
||||
server := claudeMCPBase32.EncodeToString(serverDigest[:])[:12]
|
||||
toolID := claudeMCPBase32.EncodeToString(toolDigest[:])[:12]
|
||||
semantic := claudeMCPToolSemanticSuffix(original, 32)
|
||||
return "mcp__" + server + "__" + toolID + "_" + semantic
|
||||
serverDigest := claudeMCPAliasDigest(secret, "server", "")
|
||||
toolDigest := claudeMCPAliasDigest(secret, "tool", original)
|
||||
server := claudeMCPAliasWord(serverDigest[:], 0, 0) + "_" + claudeMCPAliasWord(serverDigest[:], 2, 0)
|
||||
toolID := claudeMCPAliasWord(toolDigest[:], 0, attempt)
|
||||
|
||||
prefix := "mcp__" + server + "__" + toolID + "_"
|
||||
maxSemanticLen := 64 - len(prefix)
|
||||
if maxSemanticLen < 1 {
|
||||
maxSemanticLen = 1
|
||||
}
|
||||
semantic := claudeMCPToolSemanticSuffix(original, maxSemanticLen)
|
||||
return prefix + semantic
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return "", false
|
||||
}
|
||||
serverDigest := claudeMCPAliasDigest(secret, "server", "")
|
||||
toolDigest := claudeMCPAliasDigest(secret, "tool", original)
|
||||
server := claudeMCPAliasWord(serverDigest[:], 0, 0) + "_" + claudeMCPAliasWord(serverDigest[:], 2, 0)
|
||||
baseIndex := int(binary.BigEndian.Uint16(toolDigest[0:2])) % totalWords
|
||||
|
||||
for attempt := 0; attempt < totalWords; attempt++ {
|
||||
toolID := words[(baseIndex+attempt)%totalWords]
|
||||
prefix := "mcp__" + server + "__" + toolID + "_"
|
||||
maxSemanticLen := 64 - len(prefix)
|
||||
if maxSemanticLen < 1 {
|
||||
maxSemanticLen = 1
|
||||
}
|
||||
semantic := claudeMCPToolSemanticSuffix(original, maxSemanticLen)
|
||||
alias := prefix + semantic
|
||||
if reserved != nil && reserved[alias] {
|
||||
continue
|
||||
}
|
||||
return alias, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -72,15 +124,12 @@ func claudeMCPToolSemanticSuffix(original string, maxLength int) string {
|
||||
return result
|
||||
}
|
||||
|
||||
func claudeMCPAliasDigest(secret, purpose, original string, attempt uint32) [sha256.Size]byte {
|
||||
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 counter [4]byte
|
||||
binary.BigEndian.PutUint32(counter[:], attempt)
|
||||
_, _ = mac.Write(counter[:])
|
||||
var digest [sha256.Size]byte
|
||||
copy(digest[:], mac.Sum(nil))
|
||||
return digest
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package helps
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -48,9 +49,10 @@ func TestClaudeMCPToolAlias(t *testing.T) {
|
||||
if !strings.HasSuffix(first, "_search_web") {
|
||||
t.Fatalf("generated alias %q does not preserve the semantic suffix", first)
|
||||
}
|
||||
if matched, _ := regexp.MatchString(`^mcp__[a-z2-7]{12}__[a-z2-7]{12}_search_web$`, first); !matched {
|
||||
t.Fatalf("generated alias %q does not contain keyed IDs plus semantics", first)
|
||||
if matched, _ := regexp.MatchString(`^mcp__[a-z]+_[a-z]+__[a-z]+_search_web$`, first); !matched {
|
||||
t.Fatalf("generated alias %q does not contain word-based IDs plus semantics", first)
|
||||
}
|
||||
assertClaudeMCPAliasWords(t, first)
|
||||
server := strings.Split(first, "__")[1]
|
||||
if got := strings.Split(caseDistinct, "__")[1]; got != server {
|
||||
t.Fatalf("case-distinct tool server = %q, want shared caller server %q", got, server)
|
||||
@@ -65,15 +67,13 @@ func TestClaudeMCPToolAlias(t *testing.T) {
|
||||
|
||||
func TestClaudeMCPToolAlias_SemanticSuffixIsSafeAndBounded(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
original string
|
||||
wantSuffix string
|
||||
wantAliasLen int
|
||||
name string
|
||||
original string
|
||||
wantSuffix string
|
||||
}{
|
||||
{name: "invalid separators", original: "browser.open URL", wantSuffix: "_browser_open_URL"},
|
||||
{name: "unicode mixed", original: "search.网页/tool with spaces", wantSuffix: "_search_tool_with_spaces"},
|
||||
{name: "unicode only", original: "搜索网页", wantSuffix: "_tool"},
|
||||
{name: "maximum length", original: strings.Repeat("a", 100), wantSuffix: "_" + strings.Repeat("a", 32), wantAliasLen: 64},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -84,12 +84,139 @@ func TestClaudeMCPToolAlias_SemanticSuffixIsSafeAndBounded(t *testing.T) {
|
||||
if len(alias) > 64 {
|
||||
t.Fatalf("generated alias length = %d, want <= 64: %q", len(alias), alias)
|
||||
}
|
||||
if tt.wantAliasLen > 0 && len(alias) != tt.wantAliasLen {
|
||||
t.Fatalf("generated alias length = %d, want %d", len(alias), tt.wantAliasLen)
|
||||
}
|
||||
if !strings.HasSuffix(alias, tt.wantSuffix) {
|
||||
t.Fatalf("generated alias %q does not end in %q", alias, tt.wantSuffix)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const original = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
||||
alias := ClaudeMCPToolAlias("credential-secret", original, 0)
|
||||
underscore := strings.LastIndex(alias, "_")
|
||||
if underscore < 0 {
|
||||
t.Fatalf("generated alias %q has no semantic separator", alias)
|
||||
}
|
||||
prefixLen := underscore + 1
|
||||
wantSemanticLen := 64 - prefixLen
|
||||
if wantSemanticLen < 1 {
|
||||
wantSemanticLen = 1
|
||||
}
|
||||
if got := alias[prefixLen:]; got != strings.Repeat("a", wantSemanticLen) {
|
||||
t.Fatalf("semantic suffix = %q, want %d a's", got, wantSemanticLen)
|
||||
}
|
||||
if len(alias) != 64 {
|
||||
t.Fatalf("generated alias length = %d, want 64: %q", len(alias), alias)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeMCPToolAlias_Strict64CharLimitUnderAllWordCombinations(t *testing.T) {
|
||||
for i := 0; i < ClaudeMCPAliasWordCount(); i++ {
|
||||
secret := fmt.Sprintf("test-secret-%d", i)
|
||||
original := strings.Repeat(fmt.Sprintf("tool_%d_long_name_", i), 50)
|
||||
alias := ClaudeMCPToolAlias(secret, original, uint32(i))
|
||||
if len(alias) > 64 {
|
||||
t.Fatalf("alias length %d exceeds Anthropic 64-char limit: %q", len(alias), alias)
|
||||
}
|
||||
if !IsClaudeMCPToolName(alias) {
|
||||
t.Fatalf("alias %q is not a valid MCP tool name", alias)
|
||||
}
|
||||
assertClaudeMCPAliasWords(t, alias)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateClaudeMCPToolAlias_StopsWhenAttemptsExhausted(t *testing.T) {
|
||||
const secret = "exhaust-space"
|
||||
const original = "tool.name"
|
||||
reserved := make(map[string]bool, ClaudeMCPAliasWordCount())
|
||||
for attempt := 0; attempt < ClaudeMCPAliasWordCount(); attempt++ {
|
||||
reserved[ClaudeMCPToolAlias(secret, original, uint32(attempt))] = true
|
||||
}
|
||||
if _, ok := AllocateClaudeMCPToolAlias(secret, original, reserved); ok {
|
||||
t.Fatal("allocate succeeded after every attempt alias was reserved")
|
||||
}
|
||||
if alias, ok := AllocateClaudeMCPToolAlias(secret, original, nil); !ok || alias == "" {
|
||||
t.Fatal("allocate failed with an empty reserved set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClaudeMCPToolAlias_ProbesAllWordsWithoutDuplicates(t *testing.T) {
|
||||
const secret = "test-secret"
|
||||
const original = "tool.name"
|
||||
totalWords := ClaudeMCPAliasWordCount()
|
||||
seen := make(map[string]bool, totalWords)
|
||||
|
||||
for attempt := 0; attempt < totalWords; attempt++ {
|
||||
alias := ClaudeMCPToolAlias(secret, original, uint32(attempt))
|
||||
parts := strings.Split(alias, "__")
|
||||
toolID, _, _ := strings.Cut(parts[2], "_")
|
||||
if seen[toolID] {
|
||||
t.Fatalf("attempt %d generated duplicate toolID %q", attempt, toolID)
|
||||
}
|
||||
seen[toolID] = true
|
||||
}
|
||||
if len(seen) != totalWords {
|
||||
t.Fatalf("covered %d words in %d attempts, want 100%% (%d words)", len(seen), totalWords, totalWords)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllocateClaudeMCPToolAlias_AllocatesEveryDistinctWord(t *testing.T) {
|
||||
const secret = "allocate-full-space"
|
||||
const original = "tool.name"
|
||||
totalWords := ClaudeMCPAliasWordCount()
|
||||
reserved := make(map[string]bool, totalWords)
|
||||
|
||||
for i := 0; i < totalWords; i++ {
|
||||
alias, ok := AllocateClaudeMCPToolAlias(secret, original, reserved)
|
||||
if !ok {
|
||||
t.Fatalf("failed to allocate at step %d with %d words reserved", i, len(reserved))
|
||||
}
|
||||
if reserved[alias] {
|
||||
t.Fatalf("allocated duplicate alias %q at step %d", alias, i)
|
||||
}
|
||||
reserved[alias] = true
|
||||
}
|
||||
if len(reserved) != totalWords {
|
||||
t.Fatalf("reserved count = %d, want %d", len(reserved), totalWords)
|
||||
}
|
||||
if _, ok := AllocateClaudeMCPToolAlias(secret, original, reserved); ok {
|
||||
t.Fatal("allocate succeeded when all 2048 words are reserved")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkAllocateClaudeMCPToolAlias_Collision(b *testing.B) {
|
||||
const secret = "test-secret"
|
||||
const original = "tool.name"
|
||||
reserved := make(map[string]bool)
|
||||
for attempt := 0; attempt < 100; attempt++ {
|
||||
reserved[ClaudeMCPToolAlias(secret, original, uint32(attempt))] = true
|
||||
}
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = AllocateClaudeMCPToolAlias(secret, original, reserved)
|
||||
}
|
||||
}
|
||||
|
||||
func assertClaudeMCPAliasWords(t *testing.T, alias string) {
|
||||
t.Helper()
|
||||
parts := strings.Split(alias, "__")
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("alias %q does not have mcp/server/tool parts", alias)
|
||||
}
|
||||
serverWords := strings.Split(parts[1], "_")
|
||||
if len(serverWords) != 2 {
|
||||
t.Fatalf("alias %q server %q is not two BIP-39 words", alias, parts[1])
|
||||
}
|
||||
toolID, _, ok := strings.Cut(parts[2], "_")
|
||||
if !ok {
|
||||
t.Fatalf("alias %q tool component %q has no semantic suffix", alias, parts[2])
|
||||
}
|
||||
allowed := make(map[string]struct{}, len(claudeMCPAliasEnglishWords))
|
||||
for _, word := range claudeMCPAliasEnglishWords {
|
||||
allowed[word] = struct{}{}
|
||||
}
|
||||
for _, word := range append(append([]string{}, serverWords...), toolID) {
|
||||
if _, exists := allowed[word]; !exists {
|
||||
t.Fatalf("alias %q uses non-BIP39 word %q", alias, word)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
12
internal/runtime/executor/helps/claude_mcp_alias_wordlist.go
Normal file
12
internal/runtime/executor/helps/claude_mcp_alias_wordlist.go
Normal file
@@ -0,0 +1,12 @@
|
||||
package helps
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"strings"
|
||||
)
|
||||
|
||||
//go:embed claude_bip39_words.txt
|
||||
var rawBIP39EnglishWords string
|
||||
|
||||
// claudeMCPAliasEnglishWords contains the standard BIP-39 English wordlist (2048 words).
|
||||
var claudeMCPAliasEnglishWords = strings.Fields(rawBIP39EnglishWords)
|
||||
Reference in New Issue
Block a user