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:
sususu
2026-08-14 16:23:50 +08:00
committed by sususu98
parent 7cf92793c1
commit f6f03e4de9
6 changed files with 2398 additions and 59 deletions

View File

@@ -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)
}
}
}

View File

@@ -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"})

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -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)
}
}
}

View 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)