144 lines
4.9 KiB
Go
144 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
|
|
}
|