tinyrelayfile
tinyrelay/internal/mcp/mcp_test.go/

internal/mcp/mcp_test.go raw
1package mcp
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "testing"
11)
12
13func testServer(t *testing.T) *Server {
14 t.Helper()
15 registry := &Registry{}
16 if err := registry.Add(Tool{Name: "echo", Description: "Echo the message.", InputSchema: Object(map[string]any{"message": map[string]any{"type": "string", "minLength": 1}, "count": map[string]any{"type": "integer", "minimum": 1, "maximum": 3}}, "message"), Annotations: map[string]any{"readOnlyHint": true}, Handler: func(ctx context.Context, call Call) (Result, error) {
17 if call.String("message") == "fail" {
18 return Result{}, errors.New("restricted: the message may not fail")
19 }
20 return Value(map[string]any{"actor": call.Actor, "message": call.String("message")}), nil
21 }}); err != nil {
22 t.Fatal(err)
23 }
24 return &Server{Tools: registry, Info: Implementation{Name: "test", Version: "1"}, Challenge: `Nostr realm="tiny"`,
25 Authenticate: func(r *http.Request, body []byte) (string, error) {
26 if r.Header.Get("Authorization") != "Nostr ok" {
27 return "", errors.New("auth-required: sign this request")
28 }
29 return "actor", nil
30 },
31 Origin: func(*http.Request) string { return "http://relay.test" }}
32}
33
34type call struct {
35 method, name, version, metaVersion, body string
36 headers map[string]string
37 skipHeaders bool
38}
39
40func (c call) request() *http.Request {
41 body := c.body
42 if body == "" {
43 params := map[string]any{"_meta": map[string]any{MetaClientCapabilities: map[string]any{}}}
44 if c.metaVersion != "-" {
45 version := c.metaVersion
46 if version == "" {
47 version = Version
48 }
49 params["_meta"].(map[string]any)[MetaProtocolVersion] = version
50 }
51 if c.name != "" {
52 params["name"] = c.name
53 params["arguments"] = map[string]any{"message": "hi"}
54 }
55 raw, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": c.method, "params": params})
56 body = string(raw)
57 }
58 r := httptest.NewRequest(http.MethodPost, "http://relay.test/mcp", strings.NewReader(body))
59 r.Header.Set("Content-Type", "application/json")
60 r.Header.Set("Accept", "application/json, text/event-stream")
61 r.Header.Set("Authorization", "Nostr ok")
62 if !c.skipHeaders {
63 version := c.version
64 if version == "" {
65 version = Version
66 }
67 r.Header.Set(HeaderProtocolVersion, version)
68 r.Header.Set(HeaderMethod, c.method)
69 if c.name != "" {
70 r.Header.Set(HeaderName, EncodeHeaderValue(c.name))
71 }
72 }
73 for key, value := range c.headers {
74 r.Header.Set(key, value)
75 }
76 return r
77}
78
79func serve(t *testing.T, s *Server, r *http.Request) (*httptest.ResponseRecorder, Response, Observation) {
80 t.Helper()
81 w := httptest.NewRecorder()
82 obs := s.Handle(w, r)
83 var response Response
84 if w.Body.Len() > 0 && strings.HasPrefix(w.Header().Get("Content-Type"), "application/json") {
85 if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
86 t.Fatalf("decode %s: %v", w.Body.String(), err)
87 }
88 }
89 return w, response, obs
90}
91
92func TestOnlyPostIsServed(t *testing.T) {
93 s := testServer(t)
94 for _, method := range []string{http.MethodGet, http.MethodDelete, http.MethodPut} {
95 w, _, obs := serve(t, s, httptest.NewRequest(method, "http://relay.test/mcp", nil))
96 if w.Code != http.StatusMethodNotAllowed || w.Header().Get("Allow") != "POST" || obs.Outcome != OutcomeInvalid {
97 t.Fatalf("%s: %d %q %+v", method, w.Code, w.Header().Get("Allow"), obs)
98 }
99 }
100}
101
102func TestOriginAndAuthorization(t *testing.T) {
103 s := testServer(t)
104 w, response, obs := serve(t, s, call{method: "tools/list", headers: map[string]string{"Origin": "https://evil.test"}}.request())
105 if w.Code != http.StatusForbidden || response.Error == nil || len(response.ID) != 0 || obs.Outcome != OutcomeInvalid {
106 t.Fatalf("foreign origin: %d %s", w.Code, w.Body.String())
107 }
108 w, _, _ = serve(t, s, call{method: "tools/list", headers: map[string]string{"Origin": "HTTP://relay.test"}}.request())
109 if w.Code != http.StatusOK {
110 t.Fatalf("same origin: %d %s", w.Code, w.Body.String())
111 }
112 w, response, obs = serve(t, s, call{method: "tools/list", headers: map[string]string{"Authorization": "Nostr bad"}}.request())
113 if w.Code != http.StatusUnauthorized || w.Header().Get("WWW-Authenticate") != `Nostr realm="tiny"` || response.Error == nil || obs.Outcome != OutcomeUnauthorized {
114 t.Fatalf("unauthorized: %d %s %q", w.Code, w.Body.String(), w.Header().Get("WWW-Authenticate"))
115 }
116}
117
118func TestHeaderValidation(t *testing.T) {
119 s := testServer(t)
120 for _, tc := range []struct {
121 name string
122 call call
123 code int
124 }{
125 {"missing version header", call{method: "tools/list", headers: map[string]string{HeaderProtocolVersion: ""}}, CodeHeaderMismatch},
126 {"unsupported version", call{method: "tools/list", version: "2025-11-25", metaVersion: "2025-11-25"}, CodeUnsupportedProtocolVersion},
127 {"meta version mismatch", call{method: "tools/list", metaVersion: "2025-11-25"}, CodeHeaderMismatch},
128 {"meta version missing", call{method: "tools/list", metaVersion: "-"}, CodeInvalidParams},
129 {"method header mismatch", call{method: "tools/list", headers: map[string]string{HeaderMethod: "tools/call"}}, CodeHeaderMismatch},
130 {"method header missing", call{method: "tools/list", headers: map[string]string{HeaderMethod: ""}}, CodeHeaderMismatch},
131 {"name header missing", call{method: "tools/call", name: "echo", headers: map[string]string{HeaderName: ""}}, CodeHeaderMismatch},
132 {"name header mismatch", call{method: "tools/call", name: "echo", headers: map[string]string{HeaderName: "other"}}, CodeHeaderMismatch},
133 {"name header bad base64", call{method: "tools/call", name: "echo", headers: map[string]string{HeaderName: "=?base64?***?="}}, CodeHeaderMismatch},
134 {"no headers at all", call{method: "tools/list", skipHeaders: true}, CodeHeaderMismatch},
135 {"missing capabilities", call{method: "tools/list", body: `{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"` + MetaProtocolVersion + `":"` + Version + `"}}}`}, CodeInvalidParams},
136 } {
137 t.Run(tc.name, func(t *testing.T) {
138 w, response, obs := serve(t, s, tc.call.request())
139 if w.Code != http.StatusBadRequest || response.Error == nil || response.Error.Code != tc.code || obs.Outcome != OutcomeInvalid {
140 t.Fatalf("%d %s", w.Code, w.Body.String())
141 }
142 if tc.code == CodeUnsupportedProtocolVersion {
143 data, _ := response.Error.Data.(map[string]any)
144 if data["requested"] != "2025-11-25" || len(data["supported"].([]any)) != 1 {
145 t.Fatalf("unsupported version data %v", response.Error.Data)
146 }
147 }
148 })
149 }
150 // Encoded names decode before comparison, and a stale session header is ignored.
151 w, _, _ := serve(t, s, call{method: "tools/call", name: "echo", headers: map[string]string{HeaderName: "=?base64?ZWNobw==?=", "Mcp-Session-Id": "stale", "Last-Event-ID": "3"}}.request())
152 if w.Code != http.StatusOK || w.Header().Get("Mcp-Session-Id") != "" {
153 t.Fatalf("encoded name: %d %s", w.Code, w.Body.String())
154 }
155}
156
157func TestBodyValidation(t *testing.T) {
158 s := testServer(t)
159 for _, tc := range []struct {
160 name string
161 body string
162 status int
163 code int
164 }{
165 {"parse error", `{"jsonrpc":`, http.StatusBadRequest, CodeParse},
166 {"batch", `[{"jsonrpc":"2.0","id":1,"method":"tools/list"}]`, http.StatusBadRequest, CodeParse},
167 {"wrong version", `{"jsonrpc":"1.0","id":1,"method":"tools/list"}`, http.StatusBadRequest, CodeInvalidRequest},
168 {"null id", `{"jsonrpc":"2.0","id":null,"method":"tools/list"}`, http.StatusBadRequest, CodeInvalidRequest},
169 {"params not object", `{"jsonrpc":"2.0","id":1,"method":"tools/list","params":[]}`, http.StatusBadRequest, CodeInvalidParams},
170 } {
171 t.Run(tc.name, func(t *testing.T) {
172 w, response, _ := serve(t, s, call{method: "tools/list", body: tc.body}.request())
173 if w.Code != tc.status || response.Error == nil || response.Error.Code != tc.code {
174 t.Fatalf("%d %s", w.Code, w.Body.String())
175 }
176 })
177 }
178 w, _, obs := serve(t, s, call{method: "notifications/cancelled", body: `{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}`}.request())
179 if w.Code != http.StatusAccepted || w.Body.Len() != 0 || obs.Outcome != OutcomeOK {
180 t.Fatalf("notification: %d %q", w.Code, w.Body.String())
181 }
182 w, _, _ = serve(t, s, call{method: "tools/list", headers: map[string]string{"Accept": "text/html"}}.request())
183 if w.Code != http.StatusNotAcceptable {
184 t.Fatalf("accept: %d", w.Code)
185 }
186 w, _, _ = serve(t, s, call{method: "tools/list", headers: map[string]string{"Content-Type": "text/plain"}}.request())
187 if w.Code != http.StatusUnsupportedMediaType {
188 t.Fatalf("content type: %d", w.Code)
189 }
190}
191
192func TestValidateNotSchema(t *testing.T) {
193 schema := map[string]any{
194 "type": "integer",
195 "minimum": 5000,
196 "maximum": 5999,
197 "not": map[string]any{"enum": []int{5128}},
198 }
199 for _, tc := range []struct {
200 name string
201 value float64
202 valid bool
203 }{
204 {name: "below range", value: 4999, valid: false},
205 {name: "before excluded value", value: 5127, valid: true},
206 {name: "excluded value", value: 5128, valid: false},
207 {name: "after excluded value", value: 5129, valid: true},
208 {name: "above range", value: 6000, valid: false},
209 } {
210 t.Run(tc.name, func(t *testing.T) {
211 err := Validate(schema, tc.value)
212 if (err == nil) != tc.valid {
213 t.Fatalf("Validate(%v) error = %v, valid = %v", tc.value, err, tc.valid)
214 }
215 })
216 }
217}
218
219func TestRoutingAndResults(t *testing.T) {
220 s := testServer(t)
221 w, response, obs := serve(t, s, call{method: "server/discover"}.request())
222 result, _ := response.Result.(map[string]any)
223 if w.Code != http.StatusOK || result["resultType"] != "complete" || obs.Outcome != OutcomeOK {
224 t.Fatalf("discover: %d %s", w.Code, w.Body.String())
225 }
226 if versions, _ := result["supportedVersions"].([]any); len(versions) != 1 || versions[0] != Version {
227 t.Fatalf("discover versions %v", result["supportedVersions"])
228 }
229 if meta, _ := result["_meta"].(map[string]any); meta[MetaServerInfo].(map[string]any)["name"] != "test" {
230 t.Fatalf("discover meta %v", result["_meta"])
231 }
232 w, response, _ = serve(t, s, call{method: "tools/list"}.request())
233 result, _ = response.Result.(map[string]any)
234 tools, _ := result["tools"].([]any)
235 if w.Code != http.StatusOK || len(tools) != 1 || result["cacheScope"] != "private" || result["ttlMs"] == nil || w.Header().Get("Content-Type") != "application/json" {
236 t.Fatalf("list: %d %s", w.Code, w.Body.String())
237 }
238 if tool := tools[0].(map[string]any); tool["name"] != "echo" || tool["inputSchema"].(map[string]any)["type"] != "object" || tool["annotations"].(map[string]any)["readOnlyHint"] != true {
239 t.Fatalf("tool %v", tool)
240 }
241 w, response, obs = serve(t, s, call{method: "tools/call", name: "echo"}.request())
242 result, _ = response.Result.(map[string]any)
243 if w.Code != http.StatusOK || result["isError"] != false || obs.Tool != "echo" || obs.Outcome != OutcomeOK {
244 t.Fatalf("call: %d %s", w.Code, w.Body.String())
245 }
246 structured, _ := result["structuredContent"].(map[string]any)
247 content, _ := result["content"].([]any)
248 if structured["actor"] != "actor" || structured["message"] != "hi" || len(content) != 1 || !strings.Contains(content[0].(map[string]any)["text"].(string), `"message":"hi"`) {
249 t.Fatalf("call result %v", result)
250 }
251 w, response, _ = serve(t, s, call{method: "resources/list"}.request())
252 if w.Code != http.StatusNotFound || response.Error == nil || response.Error.Code != CodeMethodNotFound {
253 t.Fatalf("unknown method: %d %s", w.Code, w.Body.String())
254 }
255 w, response, _ = serve(t, s, call{method: "tools/call", name: "missing"}.request())
256 if w.Code != http.StatusBadRequest || response.Error == nil || response.Error.Code != CodeInvalidParams {
257 t.Fatalf("unknown tool: %d %s", w.Code, w.Body.String())
258 }
259}
260
261func TestToolErrorsAreExecutionErrors(t *testing.T) {
262 s := testServer(t)
263 body := func(arguments string) string {
264 return `{"jsonrpc":"2.0","id":"a","method":"tools/call","params":{"name":"echo","arguments":` + arguments + `,"_meta":{"` + MetaProtocolVersion + `":"` + Version + `","` + MetaClientCapabilities + `":{}}}}`
265 }
266 for name, tc := range map[string]struct{ arguments, message string }{
267 "missing required": {`{}`, "message is required"},
268 "wrong type": {`{"message":1}`, "message must be a string"},
269 "unknown argument": {`{"message":"x","extra":true}`, "extra is not a known argument"},
270 "out of range": {`{"message":"x","count":9}`, "count must be at most 3"},
271 "not integer": {`{"message":"x","count":1.5}`, "count must be an integer"},
272 "handler error": {`{"message":"fail"}`, "restricted: the message may not fail"},
273 } {
274 t.Run(name, func(t *testing.T) {
275 r := call{method: "tools/call", body: body(tc.arguments)}.request()
276 r.Header.Set(HeaderName, "echo")
277 w, response, obs := serve(t, s, r)
278 result, _ := response.Result.(map[string]any)
279 if w.Code != http.StatusOK || result["isError"] != true || obs.Outcome != OutcomeInvalid {
280 t.Fatalf("%d %s", w.Code, w.Body.String())
281 }
282 if text := result["content"].([]any)[0].(map[string]any)["text"].(string); !strings.Contains(text, tc.message) {
283 t.Fatalf("message %q", text)
284 }
285 })
286 }
287}
288
289func TestHeaderValueEncoding(t *testing.T) {
290 for original, encoded := range map[string]string{
291 "us-west1": "us-west1",
292 "Hello, 世界": "=?base64?SGVsbG8sIOS4lueVjA==?=",
293 " padded ": "=?base64?IHBhZGRlZCA=?=",
294 "line1\nline2": "=?base64?bGluZTEKbGluZTI=?=",
295 "=?base64?literal?=": "=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?=",
296 } {
297 if got := EncodeHeaderValue(original); got != encoded {
298 t.Fatalf("encode %q: %q", original, got)
299 }
300 decoded, err := DecodeHeaderValue(encoded)
301 if err != nil || decoded != original {
302 t.Fatalf("decode %q: %q %v", encoded, decoded, err)
303 }
304 }
305 if _, err := DecodeHeaderValue("bad\x7fvalue"); err == nil {
306 t.Fatal("control character accepted")
307 }
308}
309
310func TestRegistryRejectsBadTools(t *testing.T) {
311 registry := &Registry{}
312 handler := func(context.Context, Call) (Result, error) { return Result{}, nil }
313 if err := registry.Add(Tool{Name: "has space", Handler: handler}); err == nil {
314 t.Fatal("bad name accepted")
315 }
316 if err := registry.Add(Tool{Name: "ok"}); err == nil {
317 t.Fatal("missing handler accepted")
318 }
319 if err := registry.Add(Tool{Name: "ok", Handler: handler}); err != nil {
320 t.Fatal(err)
321 }
322 if err := registry.Add(Tool{Name: "ok", Handler: handler}); err == nil {
323 t.Fatal("duplicate accepted")
324 }
325 if tools := registry.Tools(); len(tools) != 1 || tools[0].InputSchema["type"] != "object" {
326 t.Fatalf("tools %v", tools)
327 }
328}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs