clone

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

add a stateless MCP transport with header validation and a tool registry

Dami committed 3580048
commit
358004850b5244298ce85ec35ef2dfb2368a67a0
parent
36c8638065eec81092c892f62640ffec3bccb86e
author
Dami <dami.etoile@gmail.com>
4 files changed, +1092 -0
Ainternal/mcp/mcp.go159+++++++++++++++
Ainternal/mcp/mcp_test.go301++++++++++++++++++++++++++++
Ainternal/mcp/server.go317++++++++++++++++++++++++++++++
Ainternal/mcp/tools.go315++++++++++++++++++++++++++++++
A internal/mcp/mcp.go +159 -0
@@ -0,0 +1,159 @@
1+// Package mcp implements the Model Context Protocol, revision 2026-07-28, in2+// the stateless form of its Streamable HTTP binding: one endpoint that3+// accepts POST, no sessions, no initialize handshake, and every request4+// carrying its protocol version in both the headers and the body.5+package mcp6+7+import (8+ "encoding/base64"9+ "encoding/json"10+ "fmt"11+ "strings"12+)13+14+// Version is the protocol revision this package implements.15+const Version = "2026-07-28"16+17+// SupportedVersions lists the protocol revisions the server accepts. Older18+// revisions negotiated a session with an initialize handshake and are not19+// served.20+var SupportedVersions = []string{Version}21+22+// Header names mirrored from the JSON-RPC body so intermediaries can route23+// without parsing it. Comparison of names is case-insensitive; values are24+// case-sensitive.25+const (26+ HeaderProtocolVersion = "MCP-Protocol-Version"27+ HeaderMethod = "Mcp-Method"28+ HeaderName = "Mcp-Name"29+)30+31+// Reserved _meta keys carried on every request and result.32+const (33+ MetaProtocolVersion = "io.modelcontextprotocol/protocolVersion"34+ MetaClientCapabilities = "io.modelcontextprotocol/clientCapabilities"35+ MetaClientInfo = "io.modelcontextprotocol/clientInfo"36+ MetaServerInfo = "io.modelcontextprotocol/serverInfo"37+)38+39+// JSON-RPC error codes. The -32020 to -32099 range is reserved for the MCP40+// specification and only the defined codes may be emitted.41+const (42+ CodeParse = -3270043+ CodeInvalidRequest = -3260044+ CodeMethodNotFound = -3260145+ CodeInvalidParams = -3260246+ CodeInternal = -3260347+ CodeHeaderMismatch = -3202048+ CodeMissingClientCapability = -3202149+ CodeUnsupportedProtocolVersion = -3202250+)51+52+// Error is a JSON-RPC error object. Status is the HTTP status the transport53+// answers with and is never serialized.54+type Error struct {55+ Code int `json:"code"`56+ Message string `json:"message"`57+ Data any `json:"data,omitempty"`58+ Status int `json:"-"`59+}60+61+func (e *Error) Error() string { return fmt.Sprintf("mcp: %d %s", e.Code, e.Message) }62+63+// Request is an incoming JSON-RPC request or notification. ID is nil for a64+// notification.65+type Request struct {66+ JSONRPC string `json:"jsonrpc"`67+ ID json.RawMessage `json:"id,omitempty"`68+ Method string `json:"method"`69+ Params json.RawMessage `json:"params,omitempty"`70+}71+72+// Notification reports whether the message carries no id and therefore73+// expects no response.74+func (r Request) Notification() bool { return len(r.ID) == 0 }75+76+// Response is an outgoing JSON-RPC result or error.77+type Response struct {78+ JSONRPC string `json:"jsonrpc"`79+ ID json.RawMessage `json:"id,omitempty"`80+ Result any `json:"result,omitempty"`81+ Error *Error `json:"error,omitempty"`82+}83+84+// Implementation names a client or server.85+type Implementation struct {86+ Name string `json:"name"`87+ Version string `json:"version,omitempty"`88+}89+90+// params is the subset of request parameters the transport inspects.91+type params struct {92+ Meta map[string]json.RawMessage `json:"_meta"`93+ Name string `json:"name"`94+ URI string `json:"uri"`95+ Cursor string `json:"cursor"`96+ Arguments json.RawMessage `json:"arguments"`97+}98+99+const (100+ sentinelPrefix = "=?base64?"101+ sentinelSuffix = "?="102+)103+104+// EncodeHeaderValue renders a body value for the Mcp-Name or Mcp-Param105+// headers. Values outside the safe ASCII set, with surrounding whitespace, or106+// that already look like the sentinel are Base64 encoded.107+func EncodeHeaderValue(value string) string {108+ if headerSafe(value) && !sentinel(value) {109+ return value110+ }111+ return sentinelPrefix + base64.StdEncoding.EncodeToString([]byte(value)) + sentinelSuffix112+}113+114+// DecodeHeaderValue reverses EncodeHeaderValue. Plain values must consist of115+// visible ASCII, space and horizontal tab.116+func DecodeHeaderValue(value string) (string, error) {117+ if sentinel(value) {118+ raw, err := base64.StdEncoding.DecodeString(strings.TrimSuffix(strings.TrimPrefix(value, sentinelPrefix), sentinelSuffix))119+ if err != nil {120+ return "", fmt.Errorf("invalid Base64 header value")121+ }122+ return string(raw), nil123+ }124+ if !headerSafe(value) {125+ return "", fmt.Errorf("header value contains invalid characters")126+ }127+ return value, nil128+}129+130+func sentinel(value string) bool {131+ return strings.HasPrefix(value, sentinelPrefix) && strings.HasSuffix(value, sentinelSuffix) && len(value) >= len(sentinelPrefix)+len(sentinelSuffix)132+}133+134+func headerSafe(value string) bool {135+ if value != strings.TrimSpace(value) {136+ return false137+ }138+ for i := 0; i < len(value); i++ {139+ c := value[i]140+ if c == ' ' || c == '\t' || (c >= 0x21 && c <= 0x7e) {141+ continue142+ }143+ return false144+ }145+ return true146+}147+148+func errorf(code, status int, format string, args ...any) *Error {149+ return &Error{Code: code, Status: status, Message: fmt.Sprintf(format, args...)}150+}151+152+func supported(version string) bool {153+ for _, v := range SupportedVersions {154+ if v == version {155+ return true156+ }157+ }158+ return false159+}
A internal/mcp/mcp_test.go +301 -0
@@ -0,0 +1,301 @@
1+package mcp2+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")}), 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+}33+34+type call struct {35+ method, name, version, metaVersion, body string36+ headers map[string]string37+ skipHeaders bool38+}39+40+func (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+}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 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+}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 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+}156+157+func 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+}191+192+func TestRoutingAndResults(t *testing.T) {193+ s := testServer(t)194+ w, response, obs := serve(t, s, call{method: "server/discover"}.request())195+ result, _ := response.Result.(map[string]any)196+ if w.Code != http.StatusOK || result["resultType"] != "complete" || obs.Outcome != OutcomeOK {197+ t.Fatalf("discover: %d %s", w.Code, w.Body.String())198+ }199+ if versions, _ := result["supportedVersions"].([]any); len(versions) != 1 || versions[0] != Version {200+ t.Fatalf("discover versions %v", result["supportedVersions"])201+ }202+ if meta, _ := result["_meta"].(map[string]any); meta[MetaServerInfo].(map[string]any)["name"] != "test" {203+ t.Fatalf("discover meta %v", result["_meta"])204+ }205+ w, response, _ = serve(t, s, call{method: "tools/list"}.request())206+ result, _ = response.Result.(map[string]any)207+ tools, _ := result["tools"].([]any)208+ if w.Code != http.StatusOK || len(tools) != 1 || result["cacheScope"] != "private" || result["ttlMs"] == nil || w.Header().Get("Content-Type") != "application/json" {209+ t.Fatalf("list: %d %s", w.Code, w.Body.String())210+ }211+ if tool := tools[0].(map[string]any); tool["name"] != "echo" || tool["inputSchema"].(map[string]any)["type"] != "object" || tool["annotations"].(map[string]any)["readOnlyHint"] != true {212+ t.Fatalf("tool %v", tool)213+ }214+ w, response, obs = serve(t, s, call{method: "tools/call", name: "echo"}.request())215+ result, _ = response.Result.(map[string]any)216+ if w.Code != http.StatusOK || result["isError"] != false || obs.Tool != "echo" || obs.Outcome != OutcomeOK {217+ t.Fatalf("call: %d %s", w.Code, w.Body.String())218+ }219+ structured, _ := result["structuredContent"].(map[string]any)220+ content, _ := result["content"].([]any)221+ if structured["actor"] != "actor" || structured["message"] != "hi" || len(content) != 1 || !strings.Contains(content[0].(map[string]any)["text"].(string), `"message":"hi"`) {222+ t.Fatalf("call result %v", result)223+ }224+ w, response, _ = serve(t, s, call{method: "resources/list"}.request())225+ if w.Code != http.StatusNotFound || response.Error == nil || response.Error.Code != CodeMethodNotFound {226+ t.Fatalf("unknown method: %d %s", w.Code, w.Body.String())227+ }228+ w, response, _ = serve(t, s, call{method: "tools/call", name: "missing"}.request())229+ if w.Code != http.StatusBadRequest || response.Error == nil || response.Error.Code != CodeInvalidParams {230+ t.Fatalf("unknown tool: %d %s", w.Code, w.Body.String())231+ }232+}233+234+func TestToolErrorsAreExecutionErrors(t *testing.T) {235+ s := testServer(t)236+ body := func(arguments string) string {237+ return `{"jsonrpc":"2.0","id":"a","method":"tools/call","params":{"name":"echo","arguments":` + arguments + `,"_meta":{"` + MetaProtocolVersion + `":"` + Version + `","` + MetaClientCapabilities + `":{}}}}`238+ }239+ for name, tc := range map[string]struct{ arguments, message string }{240+ "missing required": {`{}`, "message is required"},241+ "wrong type": {`{"message":1}`, "message must be a string"},242+ "unknown argument": {`{"message":"x","extra":true}`, "extra is not a known argument"},243+ "out of range": {`{"message":"x","count":9}`, "count must be at most 3"},244+ "not integer": {`{"message":"x","count":1.5}`, "count must be an integer"},245+ "handler error": {`{"message":"fail"}`, "restricted: the message may not fail"},246+ } {247+ t.Run(name, func(t *testing.T) {248+ r := call{method: "tools/call", body: body(tc.arguments)}.request()249+ r.Header.Set(HeaderName, "echo")250+ w, response, obs := serve(t, s, r)251+ result, _ := response.Result.(map[string]any)252+ if w.Code != http.StatusOK || result["isError"] != true || obs.Outcome != OutcomeInvalid {253+ t.Fatalf("%d %s", w.Code, w.Body.String())254+ }255+ if text := result["content"].([]any)[0].(map[string]any)["text"].(string); !strings.Contains(text, tc.message) {256+ t.Fatalf("message %q", text)257+ }258+ })259+ }260+}261+262+func TestHeaderValueEncoding(t *testing.T) {263+ for original, encoded := range map[string]string{264+ "us-west1": "us-west1",265+ "Hello, 世界": "=?base64?SGVsbG8sIOS4lueVjA==?=",266+ " padded ": "=?base64?IHBhZGRlZCA=?=",267+ "line1\nline2": "=?base64?bGluZTEKbGluZTI=?=",268+ "=?base64?literal?=": "=?base64?PT9iYXNlNjQ/bGl0ZXJhbD89?=",269+ } {270+ if got := EncodeHeaderValue(original); got != encoded {271+ t.Fatalf("encode %q: %q", original, got)272+ }273+ decoded, err := DecodeHeaderValue(encoded)274+ if err != nil || decoded != original {275+ t.Fatalf("decode %q: %q %v", encoded, decoded, err)276+ }277+ }278+ if _, err := DecodeHeaderValue("bad\x7fvalue"); err == nil {279+ t.Fatal("control character accepted")280+ }281+}282+283+func TestRegistryRejectsBadTools(t *testing.T) {284+ registry := &Registry{}285+ handler := func(context.Context, Call) (Result, error) { return Result{}, nil }286+ if err := registry.Add(Tool{Name: "has space", Handler: handler}); err == nil {287+ t.Fatal("bad name accepted")288+ }289+ if err := registry.Add(Tool{Name: "ok"}); err == nil {290+ t.Fatal("missing handler accepted")291+ }292+ if err := registry.Add(Tool{Name: "ok", Handler: handler}); err != nil {293+ t.Fatal(err)294+ }295+ if err := registry.Add(Tool{Name: "ok", Handler: handler}); err == nil {296+ t.Fatal("duplicate accepted")297+ }298+ if tools := registry.Tools(); len(tools) != 1 || tools[0].InputSchema["type"] != "object" {299+ t.Fatalf("tools %v", tools)300+ }301+}
A internal/mcp/server.go +317 -0
@@ -0,0 +1,317 @@
1+package mcp2+3+import (4+ "bytes"5+ "context"6+ "encoding/json"7+ "errors"8+ "io"9+ "mime"10+ "net/http"11+ "strings"12+)13+14+// Server serves the MCP endpoint. Every request is authenticated, validated15+// against its mirrored headers and routed independently; no state survives16+// between requests.17+type Server struct {18+ // Tools is the tool table served by tools/list and tools/call.19+ Tools *Registry20+ // Info identifies this server in every result's _meta.21+ Info Implementation22+ // Instructions is optional guidance returned by server/discover.23+ Instructions string24+ // Authenticate resolves the caller from the request and its body. An25+ // error answers 401 with Challenge in WWW-Authenticate.26+ Authenticate func(r *http.Request, body []byte) (string, error)27+ // Challenge is the WWW-Authenticate value sent with 401.28+ Challenge string29+ // Origin returns the origin this endpoint is served from. A request whose30+ // Origin header names another origin is refused with 403.31+ Origin func(r *http.Request) string32+ // MaxBodyBytes bounds the request body. Zero means one megabyte.33+ MaxBodyBytes int6434+}35+36+// Observation summarizes one handled request for telemetry. It never carries37+// the caller's identity.38+type Observation struct {39+ Method string40+ Tool string41+ Outcome string42+ Status int43+}44+45+const (46+ OutcomeOK = "ok"47+ OutcomeInvalid = "invalid"48+ OutcomeUnauthorized = "unauthorized"49+ OutcomeError = "error"50+)51+52+const (53+ toolListTTL = 5 * 60 * 100054+ discoverTTL = 60 * 60 * 100055+ defaultBody = 1 << 2056+)57+58+// ServeHTTP handles one POST to the MCP endpoint.59+func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.Handle(w, r) }60+61+// Handle serves the request and reports what happened.62+func (s *Server) Handle(w http.ResponseWriter, r *http.Request) Observation {63+ w.Header().Set("Cache-Control", "no-store")64+ if r.Method != http.MethodPost {65+ // GET opened a server stream and DELETE ended a session in earlier66+ // revisions. Neither exists in this one.67+ w.Header().Set("Allow", "POST")68+ http.Error(w, "method not allowed: the MCP endpoint accepts POST", http.StatusMethodNotAllowed)69+ return Observation{Outcome: OutcomeInvalid, Status: http.StatusMethodNotAllowed}70+ }71+ if origin := r.Header.Get("Origin"); origin != "" && s.Origin != nil && !strings.EqualFold(origin, s.Origin(r)) {72+ return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusForbidden, "origin %s is not allowed", origin), Observation{})73+ }74+ limit := s.MaxBodyBytes75+ if limit <= 0 {76+ limit = defaultBody77+ }78+ body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, limit))79+ if err != nil {80+ var tooLarge *http.MaxBytesError81+ if errors.As(err, &tooLarge) {82+ return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusRequestEntityTooLarge, "request body exceeds %d bytes", limit), Observation{})83+ }84+ return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusBadRequest, "request body could not be read"), Observation{})85+ }86+ actor := ""87+ if s.Authenticate != nil {88+ actor, err = s.Authenticate(r, body)89+ if err != nil {90+ if s.Challenge != "" {91+ w.Header().Set("WWW-Authenticate", s.Challenge)92+ }93+ return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusUnauthorized, "%s", err.Error()), Observation{})94+ }95+ }96+ if accept := r.Header.Get("Accept"); accept != "" && !accepts(accept, "application/json") {97+ return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusNotAcceptable, "Accept must include application/json"), Observation{})98+ }99+ if media, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")); media != "application/json" {100+ return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusUnsupportedMediaType, "Content-Type must be application/json"), Observation{})101+ }102+ var request Request103+ if err := json.Unmarshal(body, &request); err != nil || !bytes.HasPrefix(bytes.TrimSpace(body), []byte("{")) {104+ return s.fail(w, nil, errorf(CodeParse, http.StatusBadRequest, "body must be a single JSON-RPC request or notification"), Observation{})105+ }106+ obs := Observation{Method: request.Method}107+ if request.JSONRPC != "2.0" || request.Method == "" {108+ return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusBadRequest, "jsonrpc must be \"2.0\" and method is required"), obs)109+ }110+ if !request.Notification() && !validID(request.ID) {111+ return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusBadRequest, "id must be a string or a number"), obs)112+ }113+ if request.Notification() {114+ w.WriteHeader(http.StatusAccepted)115+ obs.Outcome, obs.Status = OutcomeOK, http.StatusAccepted116+ return obs117+ }118+ var p params119+ if len(request.Params) > 0 {120+ if err := json.Unmarshal(request.Params, &p); err != nil {121+ return s.fail(w, request.ID, errorf(CodeInvalidParams, http.StatusBadRequest, "params must be an object"), obs)122+ }123+ }124+ if err := validateHeaders(r, request, p); err != nil {125+ return s.fail(w, request.ID, err, obs)126+ }127+ var result any128+ var rpcErr *Error129+ switch request.Method {130+ case "server/discover":131+ result = s.discover()132+ case "tools/list":133+ result = s.list()134+ case "tools/call":135+ obs.Tool = p.Name136+ var tool Result137+ tool, rpcErr = s.call(r.Context(), r, actor, p)138+ if rpcErr == nil {139+ result = s.result(map[string]any{"content": tool.Content, "structuredContent": tool.StructuredContent, "isError": tool.IsError})140+ if tool.IsError {141+ obs.Outcome = OutcomeInvalid142+ }143+ }144+ default:145+ rpcErr = errorf(CodeMethodNotFound, http.StatusNotFound, "method %s is not supported", request.Method)146+ }147+ if rpcErr != nil {148+ return s.fail(w, request.ID, rpcErr, obs)149+ }150+ if obs.Outcome == "" {151+ obs.Outcome = OutcomeOK152+ }153+ obs.Status = http.StatusOK154+ write(w, http.StatusOK, Response{JSONRPC: "2.0", ID: request.ID, Result: result})155+ return obs156+}157+158+func (s *Server) fail(w http.ResponseWriter, id json.RawMessage, err *Error, obs Observation) Observation {159+ status := err.Status160+ if status == 0 {161+ status = http.StatusBadRequest162+ }163+ obs.Status = status164+ switch {165+ case status == http.StatusUnauthorized:166+ obs.Outcome = OutcomeUnauthorized167+ case status >= 500:168+ obs.Outcome = OutcomeError169+ default:170+ obs.Outcome = OutcomeInvalid171+ }172+ write(w, status, Response{JSONRPC: "2.0", ID: id, Error: err})173+ return obs174+}175+176+// validateHeaders enforces the request metadata rules of the Streamable HTTP177+// binding: the protocol version in the header and in _meta must agree and be178+// supported, Mcp-Method must match the method, and Mcp-Name must match the179+// tool name of a tools/call request.180+func validateHeaders(r *http.Request, request Request, p params) *Error {181+ version := r.Header.Get(HeaderProtocolVersion)182+ if version == "" {183+ return errorf(CodeHeaderMismatch, http.StatusBadRequest, "missing %s header; this server speaks %s", HeaderProtocolVersion, strings.Join(SupportedVersions, ", "))184+ }185+ if !supported(version) {186+ return &Error{Code: CodeUnsupportedProtocolVersion, Status: http.StatusBadRequest, Message: "Unsupported protocol version", Data: map[string]any{"supported": SupportedVersions, "requested": version}}187+ }188+ metaVersion, ok := metaString(p.Meta, MetaProtocolVersion)189+ if !ok {190+ return errorf(CodeInvalidParams, http.StatusBadRequest, "params._meta[%q] is required", MetaProtocolVersion)191+ }192+ if metaVersion != version {193+ return errorf(CodeHeaderMismatch, http.StatusBadRequest, "Header mismatch: %s header value '%s' does not match body value '%s'", HeaderProtocolVersion, version, metaVersion)194+ }195+ if capabilities, present := p.Meta[MetaClientCapabilities]; !present || !bytes.HasPrefix(bytes.TrimSpace(capabilities), []byte("{")) {196+ return errorf(CodeInvalidParams, http.StatusBadRequest, "params._meta[%q] is required; send {} when the client has none", MetaClientCapabilities)197+ }198+ method := r.Header.Get(HeaderMethod)199+ if method == "" {200+ return errorf(CodeHeaderMismatch, http.StatusBadRequest, "missing %s header", HeaderMethod)201+ }202+ if method != request.Method {203+ return errorf(CodeHeaderMismatch, http.StatusBadRequest, "Header mismatch: %s header value '%s' does not match body value '%s'", HeaderMethod, method, request.Method)204+ }205+ source := ""206+ switch request.Method {207+ case "tools/call", "prompts/get":208+ source = p.Name209+ case "resources/read":210+ source = p.URI211+ default:212+ return nil213+ }214+ raw := r.Header.Get(HeaderName)215+ if raw == "" {216+ return errorf(CodeHeaderMismatch, http.StatusBadRequest, "missing %s header", HeaderName)217+ }218+ name, err := DecodeHeaderValue(raw)219+ if err != nil {220+ return errorf(CodeHeaderMismatch, http.StatusBadRequest, "Header mismatch: %s %s", HeaderName, err.Error())221+ }222+ if name != source {223+ return errorf(CodeHeaderMismatch, http.StatusBadRequest, "Header mismatch: %s header value '%s' does not match body value '%s'", HeaderName, name, source)224+ }225+ return nil226+}227+228+func (s *Server) discover() map[string]any {229+ return s.result(map[string]any{230+ "supportedVersions": SupportedVersions,231+ "capabilities": map[string]any{"tools": map[string]any{}},232+ "instructions": s.Instructions,233+ "ttlMs": discoverTTL,234+ "cacheScope": "private",235+ })236+}237+238+func (s *Server) list() map[string]any {239+ tools := s.Tools.Tools()240+ if tools == nil {241+ tools = []Tool{}242+ }243+ return s.result(map[string]any{"tools": tools, "ttlMs": toolListTTL, "cacheScope": "private"})244+}245+246+func (s *Server) call(ctx context.Context, r *http.Request, actor string, p params) (Result, *Error) {247+ if p.Name == "" {248+ return Result{}, errorf(CodeInvalidParams, http.StatusBadRequest, "params.name is required")249+ }250+ tool, ok := s.Tools.Lookup(p.Name)251+ if !ok {252+ return Result{}, errorf(CodeInvalidParams, http.StatusBadRequest, "Unknown tool: %s", p.Name)253+ }254+ arguments := map[string]any{}255+ if len(p.Arguments) > 0 && string(p.Arguments) != "null" {256+ if err := json.Unmarshal(p.Arguments, &arguments); err != nil {257+ return Result{}, errorf(CodeInvalidParams, http.StatusBadRequest, "params.arguments must be an object")258+ }259+ }260+ if err := Validate(tool.InputSchema, arguments); err != nil {261+ return Failure(err.Error(), map[string]any{"inputSchema": tool.InputSchema}).filled(), nil262+ }263+ result, err := tool.Handler(ctx, Call{Actor: actor, Name: tool.Name, Arguments: arguments, Request: r})264+ if err != nil {265+ return Failure(err.Error(), nil).filled(), nil266+ }267+ return result.filled(), nil268+}269+270+func (s *Server) result(fields map[string]any) map[string]any {271+ fields["resultType"] = "complete"272+ if s.Info.Name != "" {273+ fields["_meta"] = map[string]any{MetaServerInfo: s.Info}274+ }275+ return fields276+}277+278+func metaString(meta map[string]json.RawMessage, key string) (string, bool) {279+ raw, ok := meta[key]280+ if !ok {281+ return "", false282+ }283+ var value string284+ if err := json.Unmarshal(raw, &value); err != nil {285+ return "", false286+ }287+ return value, true288+}289+290+func validID(id json.RawMessage) bool {291+ trimmed := bytes.TrimSpace(id)292+ if len(trimmed) == 0 || string(trimmed) == "null" {293+ return false294+ }295+ return trimmed[0] == '"' || trimmed[0] == '-' || (trimmed[0] >= '0' && trimmed[0] <= '9')296+}297+298+func accepts(header, media string) bool {299+ for _, part := range strings.Split(header, ",") {300+ value := strings.TrimSpace(strings.Split(part, ";")[0])301+ if value == media || value == "*/*" || value == strings.Split(media, "/")[0]+"/*" {302+ return true303+ }304+ }305+ return false306+}307+308+func write(w http.ResponseWriter, status int, response Response) {309+ raw, err := json.Marshal(response)310+ if err != nil {311+ http.Error(w, "could not encode response", http.StatusInternalServerError)312+ return313+ }314+ w.Header().Set("Content-Type", "application/json")315+ w.WriteHeader(status)316+ _, _ = w.Write(raw)317+}
A internal/mcp/tools.go +315 -0
@@ -0,0 +1,315 @@
1+package mcp2+3+import (4+ "context"5+ "encoding/json"6+ "errors"7+ "fmt"8+ "math"9+ "net/http"10+ "regexp"11+ "sort"12+ "strings"13+)14+15+// Tool is one entry in a server's tool table.16+type Tool struct {17+ Name string `json:"name"`18+ Title string `json:"title,omitempty"`19+ Description string `json:"description"`20+ InputSchema map[string]any `json:"inputSchema"`21+ OutputSchema map[string]any `json:"outputSchema,omitempty"`22+ Annotations map[string]any `json:"annotations,omitempty"`23+ Handler Handler `json:"-"`24+}25+26+// Handler executes a tool. A returned error is reported to the client as a27+// tool execution error the model can act on; protocol errors are raised by28+// the transport before the handler runs.29+type Handler func(ctx context.Context, call Call) (Result, error)30+31+// Call carries the validated arguments of a tools/call request together with32+// the authenticated actor.33+type Call struct {34+ Actor string35+ Name string36+ Arguments map[string]any37+ Request *http.Request38+}39+40+// String returns a string argument or "".41+func (c Call) String(key string) string {42+ value, _ := c.Arguments[key].(string)43+ return value44+}45+46+// Int returns an integer argument or 0.47+func (c Call) Int(key string) int {48+ value, _ := c.Arguments[key].(float64)49+ return int(value)50+}51+52+// Content is one unstructured content block of a tool result.53+type Content struct {54+ Type string `json:"type"`55+ Text string `json:"text"`56+}57+58+// Result is what a tool returns. StructuredContent is mirrored into a text59+// block when Content is empty so every client sees the same data.60+type Result struct {61+ Content []Content `json:"content"`62+ StructuredContent any `json:"structuredContent,omitempty"`63+ IsError bool `json:"isError,omitempty"`64+}65+66+// Value builds a successful result from a JSON value.67+func Value(value any) Result {68+ return Result{StructuredContent: value}69+}70+71+// Failure builds a tool execution error. Structured data, when given, helps72+// the client correct the call.73+func Failure(message string, structured any) Result {74+ return Result{Content: []Content{{Type: "text", Text: message}}, StructuredContent: structured, IsError: true}75+}76+77+func (r Result) filled() Result {78+ if len(r.Content) == 0 {79+ text, err := json.Marshal(r.StructuredContent)80+ if err != nil {81+ text = []byte("null")82+ }83+ r.Content = []Content{{Type: "text", Text: string(text)}}84+ }85+ return r86+}87+88+// Registry is an ordered tool table. Tools list in the order they were89+// added so clients can cache the list.90+type Registry struct {91+ tools []Tool92+ index map[string]int93+}94+95+var toolName = regexp.MustCompile(`^[A-Za-z0-9._-]{1,128}$`)96+97+// Add registers a tool. Names must be unique and header safe.98+func (r *Registry) Add(tool Tool) error {99+ if !toolName.MatchString(tool.Name) {100+ return fmt.Errorf("mcp: invalid tool name %q", tool.Name)101+ }102+ if tool.Handler == nil {103+ return fmt.Errorf("mcp: tool %q has no handler", tool.Name)104+ }105+ if tool.InputSchema == nil {106+ tool.InputSchema = Object(nil)107+ }108+ if r.index == nil {109+ r.index = map[string]int{}110+ }111+ if _, exists := r.index[tool.Name]; exists {112+ return fmt.Errorf("mcp: duplicate tool %q", tool.Name)113+ }114+ r.index[tool.Name] = len(r.tools)115+ r.tools = append(r.tools, tool)116+ return nil117+}118+119+// Tools returns the table in registration order.120+func (r *Registry) Tools() []Tool {121+ if r == nil {122+ return nil123+ }124+ return append([]Tool(nil), r.tools...)125+}126+127+// Lookup finds a tool by name.128+func (r *Registry) Lookup(name string) (Tool, bool) {129+ if r == nil || r.index == nil {130+ return Tool{}, false131+ }132+ i, ok := r.index[name]133+ if !ok {134+ return Tool{}, false135+ }136+ return r.tools[i], true137+}138+139+// Object builds a closed object schema. Properties are sorted when listed so140+// the schema serializes deterministically.141+func Object(properties map[string]any, required ...string) map[string]any {142+ if properties == nil {143+ properties = map[string]any{}144+ }145+ schema := map[string]any{"type": "object", "properties": properties, "additionalProperties": false}146+ if len(required) > 0 {147+ schema["required"] = required148+ }149+ return schema150+}151+152+// Validate checks a value against the subset of JSON Schema 2020-12 the tool153+// tables use: type, properties, required, additionalProperties, enum,154+// pattern, minimum, maximum, minLength, maxLength, minProperties and items.155+func Validate(schema map[string]any, value any) error {156+ return validate(schema, value, "")157+}158+159+func validate(schema map[string]any, value any, path string) error {160+ where := func() string {161+ if path == "" {162+ return "argument"163+ }164+ return path165+ }166+ if typ, ok := schema["type"].(string); ok {167+ if err := checkType(typ, value); err != nil {168+ return fmt.Errorf("%s %s", where(), err)169+ }170+ }171+ if options, ok := schema["enum"].([]string); ok {172+ text, _ := value.(string)173+ if !contains(options, text) {174+ return fmt.Errorf("%s must be one of %s", where(), strings.Join(options, ", "))175+ }176+ }177+ if pattern, ok := schema["pattern"].(string); ok {178+ text, _ := value.(string)179+ if re, err := regexp.Compile(pattern); err == nil && !re.MatchString(text) {180+ return fmt.Errorf("%s does not match %s", where(), pattern)181+ }182+ }183+ if text, ok := value.(string); ok {184+ if min, ok := number(schema["minLength"]); ok && float64(len(text)) < min {185+ return fmt.Errorf("%s must not be empty", where())186+ }187+ if max, ok := number(schema["maxLength"]); ok && float64(len(text)) > max {188+ return fmt.Errorf("%s is longer than %d characters", where(), int(max))189+ }190+ }191+ if n, ok := value.(float64); ok {192+ if min, ok := number(schema["minimum"]); ok && n < min {193+ return fmt.Errorf("%s must be at least %v", where(), min)194+ }195+ if max, ok := number(schema["maximum"]); ok && n > max {196+ return fmt.Errorf("%s must be at most %v", where(), max)197+ }198+ }199+ if object, ok := value.(map[string]any); ok {200+ properties, _ := schema["properties"].(map[string]any)201+ for _, key := range required(schema) {202+ if _, present := object[key]; !present {203+ return fmt.Errorf("%s is required", join(path, key))204+ }205+ }206+ if min, ok := number(schema["minProperties"]); ok && float64(len(object)) < min {207+ return fmt.Errorf("%s must not be empty", where())208+ }209+ keys := make([]string, 0, len(object))210+ for key := range object {211+ keys = append(keys, key)212+ }213+ sort.Strings(keys)214+ for _, key := range keys {215+ property, known := properties[key].(map[string]any)216+ if !known {217+ if closed, _ := schema["additionalProperties"].(bool); schema["additionalProperties"] != nil && !closed {218+ return fmt.Errorf("%s is not a known argument", join(path, key))219+ }220+ continue221+ }222+ if object[key] == nil {223+ continue224+ }225+ if err := validate(property, object[key], join(path, key)); err != nil {226+ return err227+ }228+ }229+ }230+ if list, ok := value.([]any); ok {231+ if items, ok := schema["items"].(map[string]any); ok {232+ for i, item := range list {233+ if err := validate(items, item, fmt.Sprintf("%s[%d]", where(), i)); err != nil {234+ return err235+ }236+ }237+ }238+ }239+ return nil240+}241+242+func checkType(typ string, value any) error {243+ switch typ {244+ case "string":245+ if _, ok := value.(string); !ok {246+ return errors.New("must be a string")247+ }248+ case "integer":249+ n, ok := value.(float64)250+ if !ok || n != math.Trunc(n) {251+ return errors.New("must be an integer")252+ }253+ case "number":254+ if _, ok := value.(float64); !ok {255+ return errors.New("must be a number")256+ }257+ case "boolean":258+ if _, ok := value.(bool); !ok {259+ return errors.New("must be true or false")260+ }261+ case "object":262+ if _, ok := value.(map[string]any); !ok {263+ return errors.New("must be an object")264+ }265+ case "array":266+ if _, ok := value.([]any); !ok {267+ return errors.New("must be an array")268+ }269+ }270+ return nil271+}272+273+func required(schema map[string]any) []string {274+ switch list := schema["required"].(type) {275+ case []string:276+ return list277+ case []any:278+ out := make([]string, 0, len(list))279+ for _, item := range list {280+ if text, ok := item.(string); ok {281+ out = append(out, text)282+ }283+ }284+ return out285+ }286+ return nil287+}288+289+func number(value any) (float64, bool) {290+ switch n := value.(type) {291+ case int:292+ return float64(n), true293+ case int64:294+ return float64(n), true295+ case float64:296+ return n, true297+ }298+ return 0, false299+}300+301+func join(path, key string) string {302+ if path == "" {303+ return key304+ }305+ return path + "." + key306+}307+308+func contains(values []string, want string) bool {309+ for _, value := range values {310+ if value == want {311+ return true312+ }313+ }314+ return false315+}