1package mcp
2
3import (
4 "encoding/json"
5 "strings"
6 "testing"
7)
8
9func TestSanitizeAndQualifiedName(t *testing.T) {
10 cases := []struct{ server, tool, want string }{
11 {"fake", "echo", "mcp__fake__echo"},
12 {"my server", "do.it!", "mcp__my_server__do_it_"},
13 {"a.b", "c", "mcp__a_b__c"},
14 {"ok-name_1", "Tool-2", "mcp__ok-name_1__Tool-2"},
15 {"", "", "mcp__" + "_" + "__" + "_"},
16 {"é", "x", "mcp__" + "_" + "__x"},
17 }
18 for _, c := range cases {
19 got := QualifiedName(c.server, c.tool)
20 if got != c.want {
21 t.Errorf("QualifiedName(%q,%q) = %q, want %q", c.server, c.tool, got, c.want)
22 }
23 }
24 if got := sanitizeName(""); got != "_" {
25 t.Errorf("sanitizeName(\"\") = %q", got)
26 }
27}
28
29func TestQualifiedNameLengthBound(t *testing.T) {
30 server := strings.Repeat("s", 40)
31 toolA := strings.Repeat("t", 50) + "A"
32 toolB := strings.Repeat("t", 50) + "B"
33 a := QualifiedName(server, toolA)
34 b := QualifiedName(server, toolB)
35 for _, n := range []string{a, b} {
36 if len(n) != maxToolNameLength {
37 t.Errorf("name %q has length %d, want %d", n, len(n), maxToolNameLength)
38 }
39 if !strings.HasPrefix(n, ToolPrefix) {
40 t.Errorf("name %q lost its prefix", n)
41 }
42 }
43 if a == b {
44 t.Fatalf("overflowing names with different tools collided: %q", a)
45 }
46 if a != QualifiedName(server, toolA) {
47 t.Fatal("QualifiedName is not stable")
48 }
49 // A name that fits exactly is left alone.
50 exact := QualifiedName("s", strings.Repeat("x", maxToolNameLength-len("mcp__s__")))
51 if want := "mcp__s__" + strings.Repeat("x", maxToolNameLength-len("mcp__s__")); exact != want {
52 t.Errorf("exact-fit name unexpectedly altered: %q", exact)
53 }
54}
55
56func TestAssignNamesCollisions(t *testing.T) {
57 pairs := [][2]string{{"a.b", "t"}, {"a_b", "t"}, {"solo", "t"}}
58 names := assignNames(pairs, nil)
59 if len(names) != 3 {
60 t.Fatalf("got %d names", len(names))
61 }
62 seen := map[string]bool{}
63 for p, n := range names {
64 if seen[n] {
65 t.Errorf("duplicate name %q", n)
66 }
67 seen[n] = true
68 if len(n) > maxToolNameLength {
69 t.Errorf("name %q too long for %v", n, p)
70 }
71 }
72 if names[[2]string{"solo", "t"}] != "mcp__solo__t" {
73 t.Errorf("non-colliding name changed: %q", names[[2]string{"solo", "t"}])
74 }
75 for _, p := range pairs[:2] {
76 n := names[p]
77 if n == "mcp__a_b__t" || !strings.HasPrefix(n, "mcp__a_b__t_") {
78 t.Errorf("colliding name %q should carry a hash suffix", n)
79 }
80 }
81 // Stable across calls and independent of input order.
82 again := assignNames([][2]string{pairs[2], pairs[1], pairs[0]}, nil)
83 for _, p := range pairs {
84 if again[p] != names[p] {
85 t.Errorf("name for %v not stable: %q vs %q", p, again[p], names[p])
86 }
87 }
88}
89
90func TestAssignNamesReserved(t *testing.T) {
91 reserved := func(n string) bool { return n == "mcp__s__t" }
92 names := assignNames([][2]string{{"s", "t"}}, reserved)
93 got := names[[2]string{"s", "t"}]
94 if got == "mcp__s__t" || reserved(got) || len(got) > maxToolNameLength {
95 t.Fatalf("reserved name not avoided: %q", got)
96 }
97 // Reserving the first hashed candidate forces a further distinct one.
98 first := got
99 reserved2 := func(n string) bool { return n == "mcp__s__t" || n == first }
100 got2 := assignNames([][2]string{{"s", "t"}}, reserved2)[[2]string{"s", "t"}]
101 if got2 == first || got2 == "mcp__s__t" {
102 t.Fatalf("second reservation not avoided: %q", got2)
103 }
104}
105
106func TestAssignNamesLongCollisionsStayUnique(t *testing.T) {
107 long := strings.Repeat("x", 80)
108 pairs := [][2]string{{long, "a"}, {long, "a"}, {long, "b"}}
109 names := assignNames(pairs, nil)
110 // Duplicate input pairs map to the same key; the distinct pairs differ.
111 if names[[2]string{long, "a"}] == names[[2]string{long, "b"}] {
112 t.Fatal("distinct tools share a name")
113 }
114}
115
116func TestNormalizeSchema(t *testing.T) {
117 empty := `{"type":"object","properties":{}}`
118 for name, in := range map[string]string{
119 "nil": "",
120 "blank": " \n",
121 "invalid": "{not json",
122 "null": "null",
123 "array": "[1,2]",
124 } {
125 got := string(NormalizeSchema(json.RawMessage(in)))
126 if got != empty {
127 t.Errorf("%s: got %s, want %s", name, got, empty)
128 }
129 }
130
131 in := `{
132 "$schema":"http://json-schema.org/draft-07/schema#","$id":"x",
133 "type":"string",
134 "anyOf":[{"required":["a"]}],"oneOf":[{}],"allOf":[{}],"not":{},
135 "required":[],
136 "properties":{"a":{"type":"integer","description":"d"}}
137 }`
138 var got map[string]any
139 if err := json.Unmarshal(NormalizeSchema(json.RawMessage(in)), &got); err != nil {
140 t.Fatal(err)
141 }
142 for _, k := range []string{"anyOf", "oneOf", "allOf", "not", "$schema", "$id", "required"} {
143 if _, ok := got[k]; ok {
144 t.Errorf("key %q should have been removed", k)
145 }
146 }
147 if got["type"] != "object" {
148 t.Errorf("type = %v, want object", got["type"])
149 }
150 props, _ := got["properties"].(map[string]any)
151 a, _ := props["a"].(map[string]any)
152 if a["type"] != "integer" || a["description"] != "d" {
153 t.Errorf("properties not preserved: %v", got["properties"])
154 }
155
156 // Missing properties are added; non-empty required is kept.
157 var got2 map[string]any
158 _ = json.Unmarshal(NormalizeSchema(json.RawMessage(`{"required":["x"]}`)), &got2)
159 if p, ok := got2["properties"].(map[string]any); !ok || p == nil {
160 t.Errorf("properties not defaulted: %v", got2)
161 }
162 if req, _ := got2["required"].([]any); len(req) != 1 {
163 t.Errorf("required dropped: %v", got2)
164 }
165}