1package mcp
2
3import (
4 "crypto/sha1"
5 "encoding/hex"
6 "encoding/json"
7 "strings"
8)
9
10const (
11 // ToolPrefix starts every model-facing MCP tool name.
12 ToolPrefix = "mcp__"
13 // maxToolNameLength is the strictest provider limit (Anthropic and OpenAI
14 // accept ^[a-zA-Z0-9_-]{1,64}$).
15 maxToolNameLength = 64
16 nameHashLength = 8
17)
18
19// sanitizeName keeps ASCII letters, digits, '_' and '-'; everything else
20// becomes '_'.
21func sanitizeName(s string) string {
22 var b strings.Builder
23 for _, r := range s {
24 switch {
25 case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '_', r == '-':
26 b.WriteRune(r)
27 default:
28 b.WriteByte('_')
29 }
30 }
31 if b.Len() == 0 {
32 return "_"
33 }
34 return b.String()
35}
36
37func shortHash(parts ...string) string {
38 sum := sha1.Sum([]byte(strings.Join(parts, "\x00")))
39 return hex.EncodeToString(sum[:])[:nameHashLength]
40}
41
42// QualifiedName returns the model-facing name for server/tool before
43// collision handling: mcp__<server>__<tool>, sanitized and length-bounded.
44func QualifiedName(server, tool string) string {
45 name := ToolPrefix + sanitizeName(server) + "__" + sanitizeName(tool)
46 if len(name) <= maxToolNameLength {
47 return name
48 }
49 return truncateWithHash(name, server, tool)
50}
51
52func truncateWithHash(name, server, tool string, salt ...string) string {
53 suffix := "_" + shortHash(append([]string{server, tool}, salt...)...)
54 keep := maxToolNameLength - len(suffix)
55 if keep > len(name) {
56 keep = len(name)
57 }
58 return name[:keep] + suffix
59}
60
61// assignNames maps each (server, tool) pair to a unique model-facing name.
62// Pairs whose sanitized names collide receive a stable hash suffix.
63func assignNames(pairs [][2]string, reserved func(string) bool) map[[2]string]string {
64 counts := map[string]int{}
65 for _, p := range pairs {
66 counts[QualifiedName(p[0], p[1])]++
67 }
68 out := make(map[[2]string]string, len(pairs))
69 used := map[string]bool{}
70 for _, p := range pairs {
71 name := QualifiedName(p[0], p[1])
72 if counts[name] > 1 || used[name] || (reserved != nil && reserved(name)) {
73 name = truncateWithHash(name, p[0], p[1])
74 for attempt := 1; used[name] || (reserved != nil && reserved(name)); attempt++ {
75 name = truncateWithHash(name, p[0], p[1], strings.Repeat("x", attempt))
76 }
77 }
78 used[name] = true
79 out[p] = name
80 }
81 return out
82}
83
84// NormalizeSchema converts an MCP inputSchema into a provider-safe function
85// schema: an object root without top-level composition keywords, which some
86// providers reject.
87func NormalizeSchema(raw json.RawMessage) json.RawMessage {
88 empty := json.RawMessage(`{"type":"object","properties":{}}`)
89 if len(trimSpace(raw)) == 0 {
90 return empty
91 }
92 var schema map[string]any
93 if err := json.Unmarshal(raw, &schema); err != nil || schema == nil {
94 return empty
95 }
96 for _, key := range []string{"oneOf", "anyOf", "allOf", "not", "$schema", "$id"} {
97 delete(schema, key)
98 }
99 schema["type"] = "object"
100 if props, ok := schema["properties"].(map[string]any); !ok || props == nil {
101 schema["properties"] = map[string]any{}
102 }
103 if req, ok := schema["required"]; ok {
104 if list, ok := req.([]any); !ok || len(list) == 0 {
105 delete(schema, "required")
106 }
107 }
108 out, err := json.Marshal(schema)
109 if err != nil {
110 return empty
111 }
112 return out
113}