tinyrelaycommit

Commit 358004850b52

browse files at this commit | history

1diff --git a/internal/mcp/mcp.go b/internal/mcp/mcp.go
2new file mode 100644
3index 0000000..086cc4d
4--- /dev/null
5+++ b/internal/mcp/mcp.go
6@@ -0,0 +1,159 @@
7+// Package mcp implements the Model Context Protocol, revision 2026-07-28, in
8+// the stateless form of its Streamable HTTP binding: one endpoint that
9+// accepts POST, no sessions, no initialize handshake, and every request
10+// carrying its protocol version in both the headers and the body.
11+package mcp
12+
13+import (
14+ "encoding/base64"
15+ "encoding/json"
16+ "fmt"
17+ "strings"
18+)
19+
20+// Version is the protocol revision this package implements.
21+const Version = "2026-07-28"
22+
23+// SupportedVersions lists the protocol revisions the server accepts. Older
24+// revisions negotiated a session with an initialize handshake and are not
25+// served.
26+var SupportedVersions = []string{Version}
27+
28+// Header names mirrored from the JSON-RPC body so intermediaries can route
29+// without parsing it. Comparison of names is case-insensitive; values are
30+// case-sensitive.
31+const (
32+ HeaderProtocolVersion = "MCP-Protocol-Version"
33+ HeaderMethod = "Mcp-Method"
34+ HeaderName = "Mcp-Name"
35+)
36+
37+// Reserved _meta keys carried on every request and result.
38+const (
39+ MetaProtocolVersion = "io.modelcontextprotocol/protocolVersion"
40+ MetaClientCapabilities = "io.modelcontextprotocol/clientCapabilities"
41+ MetaClientInfo = "io.modelcontextprotocol/clientInfo"
42+ MetaServerInfo = "io.modelcontextprotocol/serverInfo"
43+)
44+
45+// JSON-RPC error codes. The -32020 to -32099 range is reserved for the MCP
46+// specification and only the defined codes may be emitted.
47+const (
48+ CodeParse = -32700
49+ CodeInvalidRequest = -32600
50+ CodeMethodNotFound = -32601
51+ CodeInvalidParams = -32602
52+ CodeInternal = -32603
53+ CodeHeaderMismatch = -32020
54+ CodeMissingClientCapability = -32021
55+ CodeUnsupportedProtocolVersion = -32022
56+)
57+
58+// Error is a JSON-RPC error object. Status is the HTTP status the transport
59+// answers with and is never serialized.
60+type Error struct {
61+ Code int `json:"code"`
62+ Message string `json:"message"`
63+ Data any `json:"data,omitempty"`
64+ Status int `json:"-"`
65+}
66+
67+func (e *Error) Error() string { return fmt.Sprintf("mcp: %d %s", e.Code, e.Message) }
68+
69+// Request is an incoming JSON-RPC request or notification. ID is nil for a
70+// notification.
71+type Request struct {
72+ JSONRPC string `json:"jsonrpc"`
73+ ID json.RawMessage `json:"id,omitempty"`
74+ Method string `json:"method"`
75+ Params json.RawMessage `json:"params,omitempty"`
76+}
77+
78+// Notification reports whether the message carries no id and therefore
79+// expects no response.
80+func (r Request) Notification() bool { return len(r.ID) == 0 }
81+
82+// Response is an outgoing JSON-RPC result or error.
83+type Response struct {
84+ JSONRPC string `json:"jsonrpc"`
85+ ID json.RawMessage `json:"id,omitempty"`
86+ Result any `json:"result,omitempty"`
87+ Error *Error `json:"error,omitempty"`
88+}
89+
90+// Implementation names a client or server.
91+type Implementation struct {
92+ Name string `json:"name"`
93+ Version string `json:"version,omitempty"`
94+}
95+
96+// params is the subset of request parameters the transport inspects.
97+type params struct {
98+ Meta map[string]json.RawMessage `json:"_meta"`
99+ Name string `json:"name"`
100+ URI string `json:"uri"`
101+ Cursor string `json:"cursor"`
102+ Arguments json.RawMessage `json:"arguments"`
103+}
104+
105+const (
106+ sentinelPrefix = "=?base64?"
107+ sentinelSuffix = "?="
108+)
109+
110+// EncodeHeaderValue renders a body value for the Mcp-Name or Mcp-Param
111+// headers. Values outside the safe ASCII set, with surrounding whitespace, or
112+// that already look like the sentinel are Base64 encoded.
113+func EncodeHeaderValue(value string) string {
114+ if headerSafe(value) && !sentinel(value) {
115+ return value
116+ }
117+ return sentinelPrefix + base64.StdEncoding.EncodeToString([]byte(value)) + sentinelSuffix
118+}
119+
120+// DecodeHeaderValue reverses EncodeHeaderValue. Plain values must consist of
121+// visible ASCII, space and horizontal tab.
122+func DecodeHeaderValue(value string) (string, error) {
123+ if sentinel(value) {
124+ raw, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(value, sentinelPrefix), sentinelSuffix))
125+ if err != nil {
126+ return "", fmt.Errorf("invalid Base64 header value")
127+ }
128+ return string(raw), nil
129+ }
130+ if !headerSafe(value) {
131+ return "", fmt.Errorf("header value contains invalid characters")
132+ }
133+ return value, nil
134+}
135+
136+func sentinel(value string) bool {
137+ return strings.HasPrefix(value, sentinelPrefix) && strings.HasSuffix(value, sentinelSuffix) && len(value) >= len(sentinelPrefix)+len(sentinelSuffix)
138+}
139+
140+func headerSafe(value string) bool {
141+ if value != strings.TrimSpace(value) {
142+ return false
143+ }
144+ for i := 0; i < len(value); i++ {
145+ c := value[i]
146+ if c == ' ' || c == '\t' || (c >= 0x21 && c <= 0x7e) {
147+ continue
148+ }
149+ return false
150+ }
151+ return true
152+}
153+
154+func errorf(code, status int, format string, args ...any) *Error {
155+ return &Error{Code: code, Status: status, Message: fmt.Sprintf(format, args...)}
156+}
157+
158+func supported(version string) bool {
159+ for _, v := range SupportedVersions {
160+ if v == version {
161+ return true
162+ }
163+ }
164+ return false
165+}
166diff --git a/internal/mcp/mcp_test.go b/internal/mcp/mcp_test.go
167new file mode 100644
168index 0000000..f8d9073
169--- /dev/null
170+++ b/internal/mcp/mcp_test.go
171@@ -0,0 +1,301 @@
172+package mcp
173+
174+import (
175+ "context"
176+ "encoding/json"
177+ "errors"
178+ "net/http"
179+ "net/http/httptest"
180+ "strings"
181+ "testing"
182+)
183+
184+func testServer(t *testing.T) *Server {
185+ t.Helper()
186+ registry := &Registry{}
187+ 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) {
188+ if call.String("message") == "fail" {
189+ return Result{}, errors.New("restricted: the message may not fail")
190+ }
191+ return Value(map[string]any{"actor": call.Actor, "message": call.String("message")}), nil
192+ }}); err != nil {
193+ t.Fatal(err)
194+ }
195+ return &Server{Tools: registry, Info: Implementation{Name: "test", Version: "1"}, Challenge: `Nostr realm="tiny"`,
196+ Authenticate: func(r *http.Request, body []byte) (string, error) {
197+ if r.Header.Get("Authorization") != "Nostr ok" {
198+ return "", errors.New("auth-required: sign this request")
199+ }
200+ return "actor", nil
201+ },
202+ Origin: func(*http.Request) string { return "http://relay.test" }}
203+}
204+
205+type call struct {
206+ method, name, version, metaVersion, body string
207+ headers map[string]string
208+ skipHeaders bool
209+}
210+
211+func (c call) request() *http.Request {
212+ body := c.body
213+ if body == "" {
214+ params := map[string]any{"_meta": map[string]any{MetaClientCapabilities: map[string]any{}}}
215+ if c.metaVersion != "-" {
216+ version := c.metaVersion
217+ if version == "" {
218+ version = Version
219+ }
220+ params["_meta"].(map[string]any)[MetaProtocolVersion] = version
221+ }
222+ if c.name != "" {
223+ params["name"] = c.name
224+ params["arguments"] = map[string]any{"message": "hi"}
225+ }
226+ raw, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": c.method, "params": params})
227+ body = string(raw)
228+ }
229+ r := httptest.NewRequest(http.MethodPost, "http://relay.test/mcp", strings.NewReader(body))
230+ r.Header.Set("Content-Type", "application/json")
231+ r.Header.Set("Accept", "application/json, text/event-stream")
232+ r.Header.Set("Authorization", "Nostr ok")
233+ if !c.skipHeaders {
234+ version := c.version
235+ if version == "" {
236+ version = Version
237+ }
238+ r.Header.Set(HeaderProtocolVersion, version)
239+ r.Header.Set(HeaderMethod, c.method)
240+ if c.name != "" {
241+ r.Header.Set(HeaderName, EncodeHeaderValue(c.name))
242+ }
243+ }
244+ for key, value := range c.headers {
245+ r.Header.Set(key, value)
246+ }
247+ return r
248+}
249+
250+func serve(t *testing.T, s *Server, r *http.Request) (*httptest.ResponseRecorder, Response, Observation) {
251+ t.Helper()
252+ w := httptest.NewRecorder()
253+ obs := s.Handle(w, r)
254+ var response Response
255+ if w.Body.Len() > 0 && strings.HasPrefix(w.Header().Get("Content-Type"), "application/json") {
256+ if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
257+ t.Fatalf("decode %s: %v", w.Body.String(), err)
258+ }
259+ }
260+ return w, response, obs
261+}
262+
263+func TestOnlyPostIsServed(t *testing.T) {
264+ s := testServer(t)
265+ for _, method := range []string{http.MethodGet, http.MethodDelete, http.MethodPut} {
266+ w, _, obs := serve(t, s, httptest.NewRequest(method, "http://relay.test/mcp", nil))
267+ if w.Code != http.StatusMethodNotAllowed || w.Header().Get("Allow") != "POST" || obs.Outcome != OutcomeInvalid {
268+ t.Fatalf("%s: %d %q %+v", method, w.Code, w.Header().Get("Allow"), obs)
269+ }
270+ }
271+}
272+
273+func TestOriginAndAuthorization(t *testing.T) {
274+ s := testServer(t)
275+ w, response, obs := serve(t, s, call{method: "tools/list", headers: map[string]string{"Origin": "https://evil.test"}}.request())
276+ if w.Code != http.StatusForbidden || response.Error == nil || len(response.ID) != 0 || obs.Outcome != OutcomeInvalid {
277+ t.Fatalf("foreign origin: %d %s", w.Code, w.Body.String())
278+ }
279+ w, _, _ = serve(t, s, call{method: "tools/list", headers: map[string]string{"Origin": "HTTP://relay.test"}}.request())
280+ if w.Code != http.StatusOK {
281+ t.Fatalf("same origin: %d %s", w.Code, w.Body.String())
282+ }
283+ w, response, obs = serve(t, s, call{method: "tools/list", headers: map[string]string{"Authorization": "Nostr bad"}}.request())
284+ if w.Code != http.StatusUnauthorized || w.Header().Get("WWW-Authenticate") != `Nostr realm="tiny"` || response.Error == nil || obs.Outcome != OutcomeUnauthorized {
285+ t.Fatalf("unauthorized: %d %s %q", w.Code, w.Body.String(), w.Header().Get("WWW-Authenticate"))
286+ }
287+}
288+
289+func TestHeaderValidation(t *testing.T) {
290+ s := testServer(t)
291+ for _, tc := range []struct {
292+ name string
293+ call call
294+ code int
295+ }{
296+ {"missing version header", call{method: "tools/list", headers: map[string]string{HeaderProtocolVersion: ""}}, CodeHeaderMismatch},
297+ {"unsupported version", call{method: "tools/list", version: "2025-11-25", metaVersion: "2025-11-25"}, CodeUnsupportedProtocolVersion},
298+ {"meta version mismatch", call{method: "tools/list", metaVersion: "2025-11-25"}, CodeHeaderMismatch},
299+ {"meta version missing", call{method: "tools/list", metaVersion: "-"}, CodeInvalidParams},
300+ {"method header mismatch", call{method: "tools/list", headers: map[string]string{HeaderMethod: "tools/call"}}, CodeHeaderMismatch},
301+ {"method header missing", call{method: "tools/list", headers: map[string]string{HeaderMethod: ""}}, CodeHeaderMismatch},
302+ {"name header missing", call{method: "tools/call", name: "echo", headers: map[string]string{HeaderName: ""}}, CodeHeaderMismatch},
303+ {"name header mismatch", call{method: "tools/call", name: "echo", headers: map[string]string{HeaderName: "other"}}, CodeHeaderMismatch},
304+ {"name header bad base64", call{method: "tools/call", name: "echo", headers: map[string]string{HeaderName: "=?base64?***?="}}, CodeHeaderMismatch},
305+ {"no headers at all", call{method: "tools/list", skipHeaders: true}, CodeHeaderMismatch},
306+ {"missing capabilities", call{method: "tools/list", body: `{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"` + MetaProtocolVersion + `":"` + Version + `"}}}`}, CodeInvalidParams},
307+ } {
308+ t.Run(tc.name, func(t *testing.T) {
309+ w, response, obs := serve(t, s, tc.call.request())
310+ if w.Code != http.StatusBadRequest || response.Error == nil || response.Error.Code != tc.code || obs.Outcome != OutcomeInvalid {
311+ t.Fatalf("%d %s", w.Code, w.Body.String())
312+ }
313+ if tc.code == CodeUnsupportedProtocolVersion {
314+ data, _ := response.Error.Data.(map[string]any)
315+ if data["requested"] != "2025-11-25" || len(data["supported"].([]any)) != 1 {
316+ t.Fatalf("unsupported version data %v", response.Error.Data)
317+ }
318+ }
319+ })
320+ }
321+ // Encoded names decode before comparison, and a stale session header is ignored.
322+ 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())
323+ if w.Code != http.StatusOK || w.Header().Get("Mcp-Session-Id") != "" {
324+ t.Fatalf("encoded name: %d %s", w.Code, w.Body.String())
325+ }
326+}
327+
328+func TestBodyValidation(t *testing.T) {
329+ s := testServer(t)
330+ for _, tc := range []struct {
331+ name string
332+ body string
333+ status int
334+ code int
335+ }{
336+ {"parse error", `{"jsonrpc":`, http.StatusBadRequest, CodeParse},
337+ {"batch", `[{"jsonrpc":"2.0","id":1,"method":"tools/list"}]`, http.StatusBadRequest, CodeParse},
338+ {"wrong version", `{"jsonrpc":"1.0","id":1,"method":"tools/list"}`, http.StatusBadRequest, CodeInvalidRequest},
339+ {"null id", `{"jsonrpc":"2.0","id":null,"method":"tools/list"}`, http.StatusBadRequest, CodeInvalidRequest},
340+ {"params not object", `{"jsonrpc":"2.0","id":1,"method":"tools/list","params":[]}`, http.StatusBadRequest, CodeInvalidParams},
341+ } {
342+ t.Run(tc.name, func(t *testing.T) {
343+ w, response, _ := serve(t, s, call{method: "tools/list", body: tc.body}.request())
344+ if w.Code != tc.status || response.Error == nil || response.Error.Code != tc.code {
345+ t.Fatalf("%d %s", w.Code, w.Body.String())
346+ }
347+ })
348+ }
349+ w, _, obs := serve(t, s, call{method: "notifications/cancelled", body: `{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}`}.request())
350+ if w.Code != http.StatusAccepted || w.Body.Len() != 0 || obs.Outcome != OutcomeOK {
351+ t.Fatalf("notification: %d %q", w.Code, w.Body.String())
352+ }
353+ w, _, _ = serve(t, s, call{method: "tools/list", headers: map[string]string{"Accept": "text/html"}}.request())
354+ if w.Code != http.StatusNotAcceptable {
355+ t.Fatalf("accept: %d", w.Code)
356+ }
357+ w, _, _ = serve(t, s, call{method: "tools/list", headers: map[string]string{"Content-Type": "text/plain"}}.request())
358+ if w.Code != http.StatusUnsupportedMediaType {
359+ t.Fatalf("content type: %d", w.Code)
360+ }
361+}
362+
363+func TestRoutingAndResults(t *testing.T) {
364+ s := testServer(t)
365+ w, response, obs := serve(t, s, call{method: "server/discover"}.request())
366+ result, _ := response.Result.(map[string]any)
367+ if w.Code != http.StatusOK || result["resultType"] != "complete" || obs.Outcome != OutcomeOK {
368+ t.Fatalf("discover: %d %s", w.Code, w.Body.String())
369+ }
370+ if versions, _ := result["supportedVersions"].([]any); len(versions) != 1 || versions[0] != Version {
371+ t.Fatalf("discover versions %v", result["supportedVersions"])
372+ }
373+ if meta, _ := result["_meta"].(map[string]any); meta[MetaServerInfo].(map[string]any)["name"] != "test" {
374+ t.Fatalf("discover meta %v", result["_meta"])
375+ }
376+ w, response, _ = serve(t, s, call{method: "tools/list"}.request())
377+ result, _ = response.Result.(map[string]any)
378+ tools, _ := result["tools"].([]any)
379+ if w.Code != http.StatusOK || len(tools) != 1 || result["cacheScope"] != "private" || result["ttlMs"] == nil || w.Header().Get("Content-Type") != "application/json" {
380+ t.Fatalf("list: %d %s", w.Code, w.Body.String())
381+ }
382+ if tool := tools[0].(map[string]any); tool["name"] != "echo" || tool["inputSchema"].(map[string]any)["type"] != "object" || tool["annotations"].(map[string]any)["readOnlyHint"] != true {
383+ t.Fatalf("tool %v", tool)
384+ }
385+ w, response, obs = serve(t, s, call{method: "tools/call", name: "echo"}.request())
386+ result, _ = response.Result.(map[string]any)
387+ if w.Code != http.StatusOK || result["isError"] != false || obs.Tool != "echo" || obs.Outcome != OutcomeOK {
388+ t.Fatalf("call: %d %s", w.Code, w.Body.String())
389+ }
390+ structured, _ := result["structuredContent"].(map[string]any)
391+ content, _ := result["content"].([]any)
392+ if structured["actor"] != "actor" || structured["message"] != "hi" || len(content) != 1 || !strings.Contains(content[0].(map[string]any)["text"].(string), `"message":"hi"`) {
393+ t.Fatalf("call result %v", result)
394+ }
395+ w, response, _ = serve(t, s, call{method: "resources/list"}.request())
396+ if w.Code != http.StatusNotFound || response.Error == nil || response.Error.Code != CodeMethodNotFound {
397+ t.Fatalf("unknown method: %d %s", w.Code, w.Body.String())
398+ }
399+ w, response, _ = serve(t, s, call{method: "tools/call", name: "missing"}.request())
400+ if w.Code != http.StatusBadRequest || response.Error == nil || response.Error.Code != CodeInvalidParams {
401+ t.Fatalf("unknown tool: %d %s", w.Code, w.Body.String())
402+ }
403+}
404+
405+func TestToolErrorsAreExecutionErrors(t *testing.T) {
406+ s := testServer(t)
407+ body := func(arguments string) string {
408+ return `{"jsonrpc":"2.0","id":"a","method":"tools/call","params":{"name":"echo","arguments":` + arguments + `,"_meta":{"` + MetaProtocolVersion + `":"` + Version + `","` + MetaClientCapabilities + `":{}}}}`
409+ }
410+ for name, tc := range map[string]struct{ arguments, message string }{
411+ "missing required": {`{}`, "message is required"},
412+ "wrong type": {`{"message":1}`, "message must be a string"},
413+ "unknown argument": {`{"message":"x","extra":true}`, "extra is not a known argument"},
414+ "out of range": {`{"message":"x","count":9}`, "count must be at most 3"},
415+ "not integer": {`{"message":"x","count":1.5}`, "count must be an integer"},
416+ "handler error": {`{"message":"fail"}`, "restricted: the message may not fail"},
417+ } {
418+ t.Run(name, func(t *testing.T) {
419+ r := call{method: "tools/call", body: body(tc.arguments)}.request()
420+ r.Header.Set(HeaderName, "echo")
421+ w, response, obs := serve(t, s, r)
422+ result, _ := response.Result.(map[string]any)
423+ if w.Code != http.StatusOK || result["isError"] != true || obs.Outcome != OutcomeInvalid {
424+ t.Fatalf("%d %s", w.Code, w.Body.String())
425+ }
426+ if text := result["content"].([]any)[0].(map[string]any)["text"].(string); !strings.Contains(text, tc.message) {
427+ t.Fatalf("message %q", text)
428+ }
429+ })
430+ }
431+}
432+
433+func TestHeaderValueEncoding(t *testing.T) {
434+ for original, encoded := range map[string]string{
435+ "us-west1": "us-west1",
436+ "Hello, 世界": "=?base64?SGVsbG8sIOS4lueVjA==?=",
437+ " padded ": "=?base64?IHBhZGRlZCA=?=",
438+ "line1\nline2": "=?base64?bGluZTEKbGluZTI=?=",
439+ "=?base64?literal?=": "=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?=",
440+ } {
441+ if got := EncodeHeaderValue(original); got != encoded {
442+ t.Fatalf("encode %q: %q", original, got)
443+ }
444+ decoded, err := DecodeHeaderValue(encoded)
445+ if err != nil || decoded != original {
446+ t.Fatalf("decode %q: %q %v", encoded, decoded, err)
447+ }
448+ }
449+ if _, err := DecodeHeaderValue("bad\x7fvalue"); err == nil {
450+ t.Fatal("control character accepted")
451+ }
452+}
453+
454+func TestRegistryRejectsBadTools(t *testing.T) {
455+ registry := &Registry{}
456+ handler := func(context.Context, Call) (Result, error) { return Result{}, nil }
457+ if err := registry.Add(Tool{Name: "has space", Handler: handler}); err == nil {
458+ t.Fatal("bad name accepted")
459+ }
460+ if err := registry.Add(Tool{Name: "ok"}); err == nil {
461+ t.Fatal("missing handler accepted")
462+ }
463+ if err := registry.Add(Tool{Name: "ok", Handler: handler}); err != nil {
464+ t.Fatal(err)
465+ }
466+ if err := registry.Add(Tool{Name: "ok", Handler: handler}); err == nil {
467+ t.Fatal("duplicate accepted")
468+ }
469+ if tools := registry.Tools(); len(tools) != 1 || tools[0].InputSchema["type"] != "object" {
470+ t.Fatalf("tools %v", tools)
471+ }
472+}
473diff --git a/internal/mcp/server.go b/internal/mcp/server.go
474new file mode 100644
475index 0000000..f65cddc
476--- /dev/null
477+++ b/internal/mcp/server.go
478@@ -0,0 +1,317 @@
479+package mcp
480+
481+import (
482+ "bytes"
483+ "context"
484+ "encoding/json"
485+ "errors"
486+ "io"
487+ "mime"
488+ "net/http"
489+ "strings"
490+)
491+
492+// Server serves the MCP endpoint. Every request is authenticated, validated
493+// against its mirrored headers and routed independently; no state survives
494+// between requests.
495+type Server struct {
496+ // Tools is the tool table served by tools/list and tools/call.
497+ Tools *Registry
498+ // Info identifies this server in every result's _meta.
499+ Info Implementation
500+ // Instructions is optional guidance returned by server/discover.
501+ Instructions string
502+ // Authenticate resolves the caller from the request and its body. An
503+ // error answers 401 with Challenge in WWW-Authenticate.
504+ Authenticate func(r *http.Request, body []byte) (string, error)
505+ // Challenge is the WWW-Authenticate value sent with 401.
506+ Challenge string
507+ // Origin returns the origin this endpoint is served from. A request whose
508+ // Origin header names another origin is refused with 403.
509+ Origin func(r *http.Request) string
510+ // MaxBodyBytes bounds the request body. Zero means one megabyte.
511+ MaxBodyBytes int64
512+}
513+
514+// Observation summarizes one handled request for telemetry. It never carries
515+// the caller's identity.
516+type Observation struct {
517+ Method string
518+ Tool string
519+ Outcome string
520+ Status int
521+}
522+
523+const (
524+ OutcomeOK = "ok"
525+ OutcomeInvalid = "invalid"
526+ OutcomeUnauthorized = "unauthorized"
527+ OutcomeError = "error"
528+)
529+
530+const (
531+ toolListTTL = 5 * 60 * 1000
532+ discoverTTL = 60 * 60 * 1000
533+ defaultBody = 1 << 20
534+)
535+
536+// ServeHTTP handles one POST to the MCP endpoint.
537+func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.Handle(w, r) }
538+
539+// Handle serves the request and reports what happened.
540+func (s *Server) Handle(w http.ResponseWriter, r *http.Request) Observation {
541+ w.Header().Set("Cache-Control", "no-store")
542+ if r.Method != http.MethodPost {
543+ // GET opened a server stream and DELETE ended a session in earlier
544+ // revisions. Neither exists in this one.
545+ w.Header().Set("Allow", "POST")
546+ http.Error(w, "method not allowed: the MCP endpoint accepts POST", http.StatusMethodNotAllowed)
547+ return Observation{Outcome: OutcomeInvalid, Status: http.StatusMethodNotAllowed}
548+ }
549+ if origin := r.Header.Get("Origin"); origin != "" && s.Origin != nil && !strings.EqualFold(origin, s.Origin(r)) {
550+ return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusForbidden, "origin %s is not allowed", origin), Observation{})
551+ }
552+ limit := s.MaxBodyBytes
553+ if limit <= 0 {
554+ limit = defaultBody
555+ }
556+ body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, limit))
557+ if err != nil {
558+ var tooLarge *http.MaxBytesError
559+ if errors.As(err, &tooLarge) {
560+ return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusRequestEntityTooLarge, "request body exceeds %d bytes", limit), Observation{})
561+ }
562+ return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusBadRequest, "request body could not be read"), Observation{})
563+ }
564+ actor := ""
565+ if s.Authenticate != nil {
566+ actor, err = s.Authenticate(r, body)
567+ if err != nil {
568+ if s.Challenge != "" {
569+ w.Header().Set("WWW-Authenticate", s.Challenge)
570+ }
571+ return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusUnauthorized, "%s", err.Error()), Observation{})
572+ }
573+ }
574+ if accept := r.Header.Get("Accept"); accept != "" && !accepts(accept, "application/json") {
575+ return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusNotAcceptable, "Accept must include application/json"), Observation{})
576+ }
577+ if media, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")); media != "application/json" {
578+ return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusUnsupportedMediaType, "Content-Type must be application/json"), Observation{})
579+ }
580+ var request Request
581+ if err := json.Unmarshal(body, &request); err != nil || !bytes.HasPrefix(bytes.TrimSpace(body), []byte("{")) {
582+ return s.fail(w, nil, errorf(CodeParse, http.StatusBadRequest, "body must be a single JSON-RPC request or notification"), Observation{})
583+ }
584+ obs := Observation{Method: request.Method}
585+ if request.JSONRPC != "2.0" || request.Method == "" {
586+ return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusBadRequest, "jsonrpc must be \"2.0\" and method is required"), obs)
587+ }
588+ if !request.Notification() && !validID(request.ID) {
589+ return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusBadRequest, "id must be a string or a number"), obs)
590+ }
591+ if request.Notification() {
592+ w.WriteHeader(http.StatusAccepted)
593+ obs.Outcome, obs.Status = OutcomeOK, http.StatusAccepted
594+ return obs
595+ }
596+ var p params
597+ if len(request.Params) > 0 {
598+ if err := json.Unmarshal(request.Params, &p); err != nil {
599+ return s.fail(w, request.ID, errorf(CodeInvalidParams, http.StatusBadRequest, "params must be an object"), obs)
600+ }
601+ }
602+ if err := validateHeaders(r, request, p); err != nil {
603+ return s.fail(w, request.ID, err, obs)
604+ }
605+ var result any
606+ var rpcErr *Error
607+ switch request.Method {
608+ case "server/discover":
609+ result = s.discover()
610+ case "tools/list":
611+ result = s.list()
612+ case "tools/call":
613+ obs.Tool = p.Name
614+ var tool Result
615+ tool, rpcErr = s.call(r.Context(), r, actor, p)
616+ if rpcErr == nil {
617+ result = s.result(map[string]any{"content": tool.Content, "structuredContent": tool.StructuredContent, "isError": tool.IsError})
618+ if tool.IsError {
619+ obs.Outcome = OutcomeInvalid
620+ }
621+ }
622+ default:
623+ rpcErr = errorf(CodeMethodNotFound, http.StatusNotFound, "method %s is not supported", request.Method)
624+ }
625+ if rpcErr != nil {
626+ return s.fail(w, request.ID, rpcErr, obs)
627+ }
628+ if obs.Outcome == "" {
629+ obs.Outcome = OutcomeOK
630+ }
631+ obs.Status = http.StatusOK
632+ write(w, http.StatusOK, Response{JSONRPC: "2.0", ID: request.ID, Result: result})
633+ return obs
634+}
635+
636+func (s *Server) fail(w http.ResponseWriter, id json.RawMessage, err *Error, obs Observation) Observation {
637+ status := err.Status
638+ if status == 0 {
639+ status = http.StatusBadRequest
640+ }
641+ obs.Status = status
642+ switch {
643+ case status == http.StatusUnauthorized:
644+ obs.Outcome = OutcomeUnauthorized
645+ case status >= 500:
646+ obs.Outcome = OutcomeError
647+ default:
648+ obs.Outcome = OutcomeInvalid
649+ }
650+ write(w, status, Response{JSONRPC: "2.0", ID: id, Error: err})
651+ return obs
652+}
653+
654+// validateHeaders enforces the request metadata rules of the Streamable HTTP
655+// binding: the protocol version in the header and in _meta must agree and be
656+// supported, Mcp-Method must match the method, and Mcp-Name must match the
657+// tool name of a tools/call request.
658+func validateHeaders(r *http.Request, request Request, p params) *Error {
659+ version := r.Header.Get(HeaderProtocolVersion)
660+ if version == "" {
661+ return errorf(CodeHeaderMismatch, http.StatusBadRequest, "missing %s header; this server speaks %s", HeaderProtocolVersion, strings.Join(SupportedVersions, ", "))
662+ }
663+ if !supported(version) {
664+ return &Error{Code: CodeUnsupportedProtocolVersion, Status: http.StatusBadRequest, Message: "Unsupported protocol version", Data: map[string]any{"supported": SupportedVersions, "requested": version}}
665+ }
666+ metaVersion, ok := metaString(p.Meta, MetaProtocolVersion)
667+ if !ok {
668+ return errorf(CodeInvalidParams, http.StatusBadRequest, "params._meta[%q] is required", MetaProtocolVersion)
669+ }
670+ if metaVersion != version {
671+ return errorf(CodeHeaderMismatch, http.StatusBadRequest, "Header mismatch: %s header value '%s' does not match body value '%s'", HeaderProtocolVersion, version, metaVersion)
672+ }
673+ if capabilities, present := p.Meta[MetaClientCapabilities]; !present || !bytes.HasPrefix(bytes.TrimSpace(capabilities), []byte("{")) {
674+ return errorf(CodeInvalidParams, http.StatusBadRequest, "params._meta[%q] is required; send {} when the client has none", MetaClientCapabilities)
675+ }
676+ method := r.Header.Get(HeaderMethod)
677+ if method == "" {
678+ return errorf(CodeHeaderMismatch, http.StatusBadRequest, "missing %s header", HeaderMethod)
679+ }
680+ if method != request.Method {
681+ return errorf(CodeHeaderMismatch, http.StatusBadRequest, "Header mismatch: %s header value '%s' does not match body value '%s'", HeaderMethod, method, request.Method)
682+ }
683+ source := ""
684+ switch request.Method {
685+ case "tools/call", "prompts/get":
686+ source = p.Name
687+ case "resources/read":
688+ source = p.URI
689+ default:
690+ return nil
691+ }
692+ raw := r.Header.Get(HeaderName)
693+ if raw == "" {
694+ return errorf(CodeHeaderMismatch, http.StatusBadRequest, "missing %s header", HeaderName)
695+ }
696+ name, err := DecodeHeaderValue(raw)
697+ if err != nil {
698+ return errorf(CodeHeaderMismatch, http.StatusBadRequest, "Header mismatch: %s %s", HeaderName, err.Error())
699+ }
700+ if name != source {
701+ return errorf(CodeHeaderMismatch, http.StatusBadRequest, "Header mismatch: %s header value '%s' does not match body value '%s'", HeaderName, name, source)
702+ }
703+ return nil
704+}
705+
706+func (s *Server) discover() map[string]any {
707+ return s.result(map[string]any{
708+ "supportedVersions": SupportedVersions,
709+ "capabilities": map[string]any{"tools": map[string]any{}},
710+ "instructions": s.Instructions,
711+ "ttlMs": discoverTTL,
712+ "cacheScope": "private",
713+ })
714+}
715+
716+func (s *Server) list() map[string]any {
717+ tools := s.Tools.Tools()
718+ if tools == nil {
719+ tools = []Tool{}
720+ }
721+ return s.result(map[string]any{"tools": tools, "ttlMs": toolListTTL, "cacheScope": "private"})
722+}
723+
724+func (s *Server) call(ctx context.Context, r *http.Request, actor string, p params) (Result, *Error) {
725+ if p.Name == "" {
726+ return Result{}, errorf(CodeInvalidParams, http.StatusBadRequest, "params.name is required")
727+ }
728+ tool, ok := s.Tools.Lookup(p.Name)
729+ if !ok {
730+ return Result{}, errorf(CodeInvalidParams, http.StatusBadRequest, "Unknown tool: %s", p.Name)
731+ }
732+ arguments := map[string]any{}
733+ if len(p.Arguments) > 0 && string(p.Arguments) != "null" {
734+ if err := json.Unmarshal(p.Arguments, &arguments); err != nil {
735+ return Result{}, errorf(CodeInvalidParams, http.StatusBadRequest, "params.arguments must be an object")
736+ }
737+ }
738+ if err := Validate(tool.InputSchema, arguments); err != nil {
739+ return Failure(err.Error(), map[string]any{"inputSchema": tool.InputSchema}).filled(), nil
740+ }
741+ result, err := tool.Handler(ctx, Call{Actor: actor, Name: tool.Name, Arguments: arguments, Request: r})
742+ if err != nil {
743+ return Failure(err.Error(), nil).filled(), nil
744+ }
745+ return result.filled(), nil
746+}
747+
748+func (s *Server) result(fields map[string]any) map[string]any {
749+ fields["resultType"] = "complete"
750+ if s.Info.Name != "" {
751+ fields["_meta"] = map[string]any{MetaServerInfo: s.Info}
752+ }
753+ return fields
754+}
755+
756+func metaString(meta map[string]json.RawMessage, key string) (string, bool) {
757+ raw, ok := meta[key]
758+ if !ok {
759+ return "", false
760+ }
761+ var value string
762+ if err := json.Unmarshal(raw, &value); err != nil {
763+ return "", false
764+ }
765+ return value, true
766+}
767+
768+func validID(id json.RawMessage) bool {
769+ trimmed := bytes.TrimSpace(id)
770+ if len(trimmed) == 0 || string(trimmed) == "null" {
771+ return false
772+ }
773+ return trimmed[0] == '"' || trimmed[0] == '-' || (trimmed[0] >= '0' && trimmed[0] <= '9')
774+}
775+
776+func accepts(header, media string) bool {
777+ for _, part := range strings.Split(header, ",") {
778+ value := strings.TrimSpace(strings.Split(part, ";")[0])
779+ if value == media || value == "*/*" || value == strings.Split(media, "/")[0]+"/*" {
780+ return true
781+ }
782+ }
783+ return false
784+}
785+
786+func write(w http.ResponseWriter, status int, response Response) {
787+ raw, err := json.Marshal(response)
788+ if err != nil {
789+ http.Error(w, "could not encode response", http.StatusInternalServerError)
790+ return
791+ }
792+ w.Header().Set("Content-Type", "application/json")
793+ w.WriteHeader(status)
794+ _, _ = w.Write(raw)
795+}
796diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go
797new file mode 100644
798index 0000000..c79d29f
799--- /dev/null
800+++ b/internal/mcp/tools.go
801@@ -0,0 +1,315 @@
802+package mcp
803+
804+import (
805+ "context"
806+ "encoding/json"
807+ "errors"
808+ "fmt"
809+ "math"
810+ "net/http"
811+ "regexp"
812+ "sort"
813+ "strings"
814+)
815+
816+// Tool is one entry in a server's tool table.
817+type Tool struct {
818+ Name string `json:"name"`
819+ Title string `json:"title,omitempty"`
820+ Description string `json:"description"`
821+ InputSchema map[string]any `json:"inputSchema"`
822+ OutputSchema map[string]any `json:"outputSchema,omitempty"`
823+ Annotations map[string]any `json:"annotations,omitempty"`
824+ Handler Handler `json:"-"`
825+}
826+
827+// Handler executes a tool. A returned error is reported to the client as a
828+// tool execution error the model can act on; protocol errors are raised by
829+// the transport before the handler runs.
830+type Handler func(ctx context.Context, call Call) (Result, error)
831+
832+// Call carries the validated arguments of a tools/call request together with
833+// the authenticated actor.
834+type Call struct {
835+ Actor string
836+ Name string
837+ Arguments map[string]any
838+ Request *http.Request
839+}
840+
841+// String returns a string argument or "".
842+func (c Call) String(key string) string {
843+ value, _ := c.Arguments[key].(string)
844+ return value
845+}
846+
847+// Int returns an integer argument or 0.
848+func (c Call) Int(key string) int {
849+ value, _ := c.Arguments[key].(float64)
850+ return int(value)
851+}
852+
853+// Content is one unstructured content block of a tool result.
854+type Content struct {
855+ Type string `json:"type"`
856+ Text string `json:"text"`
857+}
858+
859+// Result is what a tool returns. StructuredContent is mirrored into a text
860+// block when Content is empty so every client sees the same data.
861+type Result struct {
862+ Content []Content `json:"content"`
863+ StructuredContent any `json:"structuredContent,omitempty"`
864+ IsError bool `json:"isError,omitempty"`
865+}
866+
867+// Value builds a successful result from a JSON value.
868+func Value(value any) Result {
869+ return Result{StructuredContent: value}
870+}
871+
872+// Failure builds a tool execution error. Structured data, when given, helps
873+// the client correct the call.
874+func Failure(message string, structured any) Result {
875+ return Result{Content: []Content{{Type: "text", Text: message}}, StructuredContent: structured, IsError: true}
876+}
877+
878+func (r Result) filled() Result {
879+ if len(r.Content) == 0 {
880+ text, err := json.Marshal(r.StructuredContent)
881+ if err != nil {
882+ text = []byte("null")
883+ }
884+ r.Content = []Content{{Type: "text", Text: string(text)}}
885+ }
886+ return r
887+}
888+
889+// Registry is an ordered tool table. Tools list in the order they were
890+// added so clients can cache the list.
891+type Registry struct {
892+ tools []Tool
893+ index map[string]int
894+}
895+
896+var toolName = regexp.MustCompile(`^[A-Za-z0-9._-]{1,128}$`)
897+
898+// Add registers a tool. Names must be unique and header safe.
899+func (r *Registry) Add(tool Tool) error {
900+ if !toolName.MatchString(tool.Name) {
901+ return fmt.Errorf("mcp: invalid tool name %q", tool.Name)
902+ }
903+ if tool.Handler == nil {
904+ return fmt.Errorf("mcp: tool %q has no handler", tool.Name)
905+ }
906+ if tool.InputSchema == nil {
907+ tool.InputSchema = Object(nil)
908+ }
909+ if r.index == nil {
910+ r.index = map[string]int{}
911+ }
912+ if _, exists := r.index[tool.Name]; exists {
913+ return fmt.Errorf("mcp: duplicate tool %q", tool.Name)
914+ }
915+ r.index[tool.Name] = len(r.tools)
916+ r.tools = append(r.tools, tool)
917+ return nil
918+}
919+
920+// Tools returns the table in registration order.
921+func (r *Registry) Tools() []Tool {
922+ if r == nil {
923+ return nil
924+ }
925+ return append([]Tool(nil), r.tools...)
926+}
927+
928+// Lookup finds a tool by name.
929+func (r *Registry) Lookup(name string) (Tool, bool) {
930+ if r == nil || r.index == nil {
931+ return Tool{}, false
932+ }
933+ i, ok := r.index[name]
934+ if !ok {
935+ return Tool{}, false
936+ }
937+ return r.tools[i], true
938+}
939+
940+// Object builds a closed object schema. Properties are sorted when listed so
941+// the schema serializes deterministically.
942+func Object(properties map[string]any, required ...string) map[string]any {
943+ if properties == nil {
944+ properties = map[string]any{}
945+ }
946+ schema := map[string]any{"type": "object", "properties": properties, "additionalProperties": false}
947+ if len(required) > 0 {
948+ schema["required"] = required
949+ }
950+ return schema
951+}
952+
953+// Validate checks a value against the subset of JSON Schema 2020-12 the tool
954+// tables use: type, properties, required, additionalProperties, enum,
955+// pattern, minimum, maximum, minLength, maxLength, minProperties and items.
956+func Validate(schema map[string]any, value any) error {
957+ return validate(schema, value, "")
958+}
959+
960+func validate(schema map[string]any, value any, path string) error {
961+ where := func() string {
962+ if path == "" {
963+ return "argument"
964+ }
965+ return path
966+ }
967+ if typ, ok := schema["type"].(string); ok {
968+ if err := checkType(typ, value); err != nil {
969+ return fmt.Errorf("%s %s", where(), err)
970+ }
971+ }
972+ if options, ok := schema["enum"].([]string); ok {
973+ text, _ := value.(string)
974+ if !contains(options, text) {
975+ return fmt.Errorf("%s must be one of %s", where(), strings.Join(options, ", "))
976+ }
977+ }
978+ if pattern, ok := schema["pattern"].(string); ok {
979+ text, _ := value.(string)
980+ if re, err := regexp.Compile(pattern); err == nil && !re.MatchString(text) {
981+ return fmt.Errorf("%s does not match %s", where(), pattern)
982+ }
983+ }
984+ if text, ok := value.(string); ok {
985+ if min, ok := number(schema["minLength"]); ok && float64(len(text)) < min {
986+ return fmt.Errorf("%s must not be empty", where())
987+ }
988+ if max, ok := number(schema["maxLength"]); ok && float64(len(text)) > max {
989+ return fmt.Errorf("%s is longer than %d characters", where(), int(max))
990+ }
991+ }
992+ if n, ok := value.(float64); ok {
993+ if min, ok := number(schema["minimum"]); ok && n < min {
994+ return fmt.Errorf("%s must be at least %v", where(), min)
995+ }
996+ if max, ok := number(schema["maximum"]); ok && n > max {
997+ return fmt.Errorf("%s must be at most %v", where(), max)
998+ }
999+ }
1000+ if object, ok := value.(map[string]any); ok {
1001+ properties, _ := schema["properties"].(map[string]any)
1002+ for _, key := range required(schema) {
1003+ if _, present := object[key]; !present {
1004+ return fmt.Errorf("%s is required", join(path, key))
1005+ }
1006+ }
1007+ if min, ok := number(schema["minProperties"]); ok && float64(len(object)) < min {
1008+ return fmt.Errorf("%s must not be empty", where())
1009+ }
1010+ keys := make([]string, 0, len(object))
1011+ for key := range object {
1012+ keys = append(keys, key)
1013+ }
1014+ sort.Strings(keys)
1015+ for _, key := range keys {
1016+ property, known := properties[key].(map[string]any)
1017+ if !known {
1018+ if closed, _ := schema["additionalProperties"].(bool); schema["additionalProperties"] != nil && !closed {
1019+ return fmt.Errorf("%s is not a known argument", join(path, key))
1020+ }
1021+ continue
1022+ }
1023+ if object[key] == nil {
1024+ continue
1025+ }
1026+ if err := validate(property, object[key], join(path, key)); err != nil {
1027+ return err
1028+ }
1029+ }
1030+ }
1031+ if list, ok := value.([]any); ok {
1032+ if items, ok := schema["items"].(map[string]any); ok {
1033+ for i, item := range list {
1034+ if err := validate(items, item, fmt.Sprintf("%s[%d]", where(), i)); err != nil {
1035+ return err
1036+ }
1037+ }
1038+ }
1039+ }
1040+ return nil
1041+}
1042+
1043+func checkType(typ string, value any) error {
1044+ switch typ {
1045+ case "string":
1046+ if _, ok := value.(string); !ok {
1047+ return errors.New("must be a string")
1048+ }
1049+ case "integer":
1050+ n, ok := value.(float64)
1051+ if !ok || n != math.Trunc(n) {
1052+ return errors.New("must be an integer")
1053+ }
1054+ case "number":
1055+ if _, ok := value.(float64); !ok {
1056+ return errors.New("must be a number")
1057+ }
1058+ case "boolean":
1059+ if _, ok := value.(bool); !ok {
1060+ return errors.New("must be true or false")
1061+ }
1062+ case "object":
1063+ if _, ok := value.(map[string]any); !ok {
1064+ return errors.New("must be an object")
1065+ }
1066+ case "array":
1067+ if _, ok := value.([]any); !ok {
1068+ return errors.New("must be an array")
1069+ }
1070+ }
1071+ return nil
1072+}
1073+
1074+func required(schema map[string]any) []string {
1075+ switch list := schema["required"].(type) {
1076+ case []string:
1077+ return list
1078+ case []any:
1079+ out := make([]string, 0, len(list))
1080+ for _, item := range list {
1081+ if text, ok := item.(string); ok {
1082+ out = append(out, text)
1083+ }
1084+ }
1085+ return out
1086+ }
1087+ return nil
1088+}
1089+
1090+func number(value any) (float64, bool) {
1091+ switch n := value.(type) {
1092+ case int:
1093+ return float64(n), true
1094+ case int64:
1095+ return float64(n), true
1096+ case float64:
1097+ return n, true
1098+ }
1099+ return 0, false
1100+}
1101+
1102+func join(path, key string) string {
1103+ if path == "" {
1104+ return key
1105+ }
1106+ return path + "." + key
1107+}
1108+
1109+func contains(values []string, want string) bool {
1110+ for _, value := range values {
1111+ if value == want {
1112+ return true
1113+ }
1114+ }
1115+ return false
1116+}

About

ownerc01503e8035f
visibilitypublic
head358004850b52

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs