| 1 | package mcp |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "net/http" |
| 8 | "net/http/httptest" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | ) |
| 12 | |
| 13 | func 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 | |
| 34 | type call struct { |
| 35 | method, name, version, metaVersion, body string |
| 36 | headers map[string]string |
| 37 | skipHeaders bool |
| 38 | } |
| 39 | |
| 40 | func (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 | |
| 79 | func 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 | |
| 92 | func 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 | |
| 102 | func 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 | |
| 118 | func 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 | |
| 157 | func 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 | |
| 192 | func 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 | |
| 219 | func 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 | |
| 261 | func 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 | |
| 289 | func 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 | |
| 310 | func 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 | } |