clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/standalone-relay
tinyrelay / internal / mcp / mcp_test.go
mcp_test.go 14.2K
1package mcp23import (4 "context"5 "encoding/json"6 "errors"7 "net/http"8 "net/http/httptest"9 "strings"10 "testing"11)1213func 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")}), nil21 }}); 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", nil30 },31 Origin: func(*http.Request) string { return "http://relay.test" }}32}3334type call struct {35 method, name, version, metaVersion, body string36 headers map[string]string37 skipHeaders bool38}3940func (c call) request() *http.Request {41 body := c.body42 if body == "" {43 params := map[string]any{"_meta": map[string]any{MetaClientCapabilities: map[string]any{}}}44 if c.metaVersion != "-" {45 version := c.metaVersion46 if version == "" {47 version = Version48 }49 params["_meta"].(map[string]any)[MetaProtocolVersion] = version50 }51 if c.name != "" {52 params["name"] = c.name53 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.version64 if version == "" {65 version = Version66 }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 r77}7879func 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 Response84 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, obs90}9192func 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}101102func 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}117118func TestHeaderValidation(t *testing.T) {119 s := testServer(t)120 for _, tc := range []struct {121 name string122 call call123 code int124 }{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}156157func TestBodyValidation(t *testing.T) {158 s := testServer(t)159 for _, tc := range []struct {160 name string161 body string162 status int163 code int164 }{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}191192func 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 string201 value float64202 valid bool203 }{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}218219func 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}260261func 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}288289func 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}309310func 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}