clone

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

feat/standalone-relay
tinyrelay / internal / mcp / server.go
server.go 11.1K
1package mcp23import (4 "bytes"5 "context"6 "encoding/json"7 "errors"8 "io"9 "mime"10 "net/http"11 "strings"12)1314// Server serves the MCP endpoint. Every request is authenticated, validated15// against its mirrored headers and routed independently; no state survives16// between requests.17type 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}3536// Observation summarizes one handled request for telemetry. It never carries37// the caller's identity.38type Observation struct {39 Method string40 Tool string41 Outcome string42 Status int43}4445const (46 OutcomeOK = "ok"47 OutcomeInvalid = "invalid"48 OutcomeUnauthorized = "unauthorized"49 OutcomeError = "error"50)5152const (53 toolListTTL = 5 * 60 * 100054 discoverTTL = 60 * 60 * 100055 defaultBody = 1 << 2056)5758// ServeHTTP handles one POST to the MCP endpoint.59func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.Handle(w, r) }6061// Handle serves the request and reports what happened.62func (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 w.Header().Set("Allow", "POST")66 http.Error(w, "method not allowed: the MCP endpoint accepts POST", http.StatusMethodNotAllowed)67 return Observation{Outcome: OutcomeInvalid, Status: http.StatusMethodNotAllowed}68 }69 if origin := r.Header.Get("Origin"); origin != "" && s.Origin != nil && !strings.EqualFold(origin, s.Origin(r)) {70 return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusForbidden, "origin %s is not allowed", origin), Observation{})71 }72 limit := s.MaxBodyBytes73 if limit <= 0 {74 limit = defaultBody75 }76 body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, limit))77 if err != nil {78 var tooLarge *http.MaxBytesError79 if errors.As(err, &tooLarge) {80 return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusRequestEntityTooLarge, "request body exceeds %d bytes", limit), Observation{})81 }82 return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusBadRequest, "request body could not be read"), Observation{})83 }84 actor := ""85 if s.Authenticate != nil {86 actor, err = s.Authenticate(r, body)87 if err != nil {88 if s.Challenge != "" {89 w.Header().Set("WWW-Authenticate", s.Challenge)90 }91 return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusUnauthorized, "%s", err.Error()), Observation{})92 }93 }94 if accept := r.Header.Get("Accept"); accept != "" && !accepts(accept, "application/json") {95 return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusNotAcceptable, "Accept must include application/json"), Observation{})96 }97 if media, _, _ := mime.ParseMediaType(r.Header.Get("Content-Type")); media != "application/json" {98 return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusUnsupportedMediaType, "Content-Type must be application/json"), Observation{})99 }100 var request Request101 if err := json.Unmarshal(body, &request); err != nil || !bytes.HasPrefix(bytes.TrimSpace(body), []byte("{")) {102 return s.fail(w, nil, errorf(CodeParse, http.StatusBadRequest, "body must be a single JSON-RPC request or notification"), Observation{})103 }104 obs := Observation{Method: request.Method}105 if request.JSONRPC != "2.0" || request.Method == "" {106 return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusBadRequest, "jsonrpc must be \"2.0\" and method is required"), obs)107 }108 if !request.Notification() && !validID(request.ID) {109 return s.fail(w, nil, errorf(CodeInvalidRequest, http.StatusBadRequest, "id must be a string or a number"), obs)110 }111 if request.Notification() {112 w.WriteHeader(http.StatusAccepted)113 obs.Outcome, obs.Status = OutcomeOK, http.StatusAccepted114 return obs115 }116 var p params117 if len(request.Params) > 0 {118 if err := json.Unmarshal(request.Params, &p); err != nil {119 return s.fail(w, request.ID, errorf(CodeInvalidParams, http.StatusBadRequest, "params must be an object"), obs)120 }121 }122 if err := validateHeaders(r, request, p); err != nil {123 return s.fail(w, request.ID, err, obs)124 }125 var result any126 var rpcErr *Error127 switch request.Method {128 case "server/discover":129 result = s.discover()130 case "tools/list":131 result = s.list()132 case "tools/call":133 obs.Tool = p.Name134 var tool Result135 tool, rpcErr = s.call(r.Context(), r, actor, p)136 if rpcErr == nil {137 result = s.result(map[string]any{"content": tool.Content, "structuredContent": tool.StructuredContent, "isError": tool.IsError})138 if tool.IsError {139 obs.Outcome = OutcomeInvalid140 }141 }142 default:143 rpcErr = errorf(CodeMethodNotFound, http.StatusNotFound, "method %s is not supported", request.Method)144 }145 if rpcErr != nil {146 return s.fail(w, request.ID, rpcErr, obs)147 }148 if obs.Outcome == "" {149 obs.Outcome = OutcomeOK150 }151 obs.Status = http.StatusOK152 write(w, http.StatusOK, Response{JSONRPC: "2.0", ID: request.ID, Result: result})153 return obs154}155156func (s *Server) fail(w http.ResponseWriter, id json.RawMessage, err *Error, obs Observation) Observation {157 status := err.Status158 if status == 0 {159 status = http.StatusBadRequest160 }161 obs.Status = status162 switch {163 case status == http.StatusUnauthorized:164 obs.Outcome = OutcomeUnauthorized165 case status >= 500:166 obs.Outcome = OutcomeError167 default:168 obs.Outcome = OutcomeInvalid169 }170 write(w, status, Response{JSONRPC: "2.0", ID: id, Error: err})171 return obs172}173174// validateHeaders enforces the request metadata rules of the Streamable HTTP175// binding: the protocol version in the header and in _meta must agree and be176// supported, Mcp-Method must match the method, and Mcp-Name must match the177// tool name of a tools/call request.178func validateHeaders(r *http.Request, request Request, p params) *Error {179 version := r.Header.Get(HeaderProtocolVersion)180 if version == "" {181 return errorf(CodeHeaderMismatch, http.StatusBadRequest, "missing %s header; this server speaks %s", HeaderProtocolVersion, strings.Join(SupportedVersions, ", "))182 }183 if !supported(version) {184 return &Error{Code: CodeUnsupportedProtocolVersion, Status: http.StatusBadRequest, Message: "Unsupported protocol version", Data: map[string]any{"supported": SupportedVersions, "requested": version}}185 }186 metaVersion, ok := metaString(p.Meta, MetaProtocolVersion)187 if !ok {188 return errorf(CodeInvalidParams, http.StatusBadRequest, "params._meta[%q] is required", MetaProtocolVersion)189 }190 if metaVersion != version {191 return errorf(CodeHeaderMismatch, http.StatusBadRequest, "Header mismatch: %s header value '%s' does not match body value '%s'", HeaderProtocolVersion, version, metaVersion)192 }193 if capabilities, present := p.Meta[MetaClientCapabilities]; !present || !bytes.HasPrefix(bytes.TrimSpace(capabilities), []byte("{")) {194 return errorf(CodeInvalidParams, http.StatusBadRequest, "params._meta[%q] is required; send {} when the client has none", MetaClientCapabilities)195 }196 method := r.Header.Get(HeaderMethod)197 if method == "" {198 return errorf(CodeHeaderMismatch, http.StatusBadRequest, "missing %s header", HeaderMethod)199 }200 if method != request.Method {201 return errorf(CodeHeaderMismatch, http.StatusBadRequest, "Header mismatch: %s header value '%s' does not match body value '%s'", HeaderMethod, method, request.Method)202 }203 source := ""204 switch request.Method {205 case "tools/call", "prompts/get":206 source = p.Name207 case "resources/read":208 source = p.URI209 default:210 return nil211 }212 raw := r.Header.Get(HeaderName)213 if raw == "" {214 return errorf(CodeHeaderMismatch, http.StatusBadRequest, "missing %s header", HeaderName)215 }216 name, err := DecodeHeaderValue(raw)217 if err != nil {218 return errorf(CodeHeaderMismatch, http.StatusBadRequest, "Header mismatch: %s %s", HeaderName, err.Error())219 }220 if name != source {221 return errorf(CodeHeaderMismatch, http.StatusBadRequest, "Header mismatch: %s header value '%s' does not match body value '%s'", HeaderName, name, source)222 }223 return nil224}225226func (s *Server) discover() map[string]any {227 return s.result(map[string]any{228 "supportedVersions": SupportedVersions,229 "capabilities": map[string]any{"tools": map[string]any{}},230 "instructions": s.Instructions,231 "ttlMs": discoverTTL,232 "cacheScope": "private",233 })234}235236func (s *Server) list() map[string]any {237 tools := s.Tools.Tools()238 if tools == nil {239 tools = []Tool{}240 }241 return s.result(map[string]any{"tools": tools, "ttlMs": toolListTTL, "cacheScope": "private"})242}243244func (s *Server) call(ctx context.Context, r *http.Request, actor string, p params) (Result, *Error) {245 if p.Name == "" {246 return Result{}, errorf(CodeInvalidParams, http.StatusBadRequest, "params.name is required")247 }248 tool, ok := s.Tools.Lookup(p.Name)249 if !ok {250 return Result{}, errorf(CodeInvalidParams, http.StatusBadRequest, "Unknown tool: %s", p.Name)251 }252 arguments := map[string]any{}253 if len(p.Arguments) > 0 && string(p.Arguments) != "null" {254 if err := json.Unmarshal(p.Arguments, &arguments); err != nil {255 return Result{}, errorf(CodeInvalidParams, http.StatusBadRequest, "params.arguments must be an object")256 }257 }258 if err := Validate(tool.InputSchema, arguments); err != nil {259 return Failure(err.Error(), map[string]any{"inputSchema": tool.InputSchema}).filled(), nil260 }261 result, err := tool.Handler(ctx, Call{Actor: actor, Name: tool.Name, Arguments: arguments, Request: r})262 if err != nil {263 return Failure(err.Error(), nil).filled(), nil264 }265 return result.filled(), nil266}267268func (s *Server) result(fields map[string]any) map[string]any {269 fields["resultType"] = "complete"270 if s.Info.Name != "" {271 fields["_meta"] = map[string]any{MetaServerInfo: s.Info}272 }273 return fields274}275276func metaString(meta map[string]json.RawMessage, key string) (string, bool) {277 raw, ok := meta[key]278 if !ok {279 return "", false280 }281 var value string282 if err := json.Unmarshal(raw, &value); err != nil {283 return "", false284 }285 return value, true286}287288func validID(id json.RawMessage) bool {289 trimmed := bytes.TrimSpace(id)290 if len(trimmed) == 0 || string(trimmed) == "null" {291 return false292 }293 return trimmed[0] == '"' || trimmed[0] == '-' || (trimmed[0] >= '0' && trimmed[0] <= '9')294}295296func accepts(header, media string) bool {297 for _, part := range strings.Split(header, ",") {298 value := strings.TrimSpace(strings.Split(part, ";")[0])299 if value == media || value == "*/*" || value == strings.Split(media, "/")[0]+"/*" {300 return true301 }302 }303 return false304}305306func write(w http.ResponseWriter, status int, response Response) {307 raw, err := json.Marshal(response)308 if err != nil {309 http.Error(w, "could not encode response", http.StatusInternalServerError)310 return311 }312 w.Header().Set("Content-Type", "application/json")313 w.WriteHeader(status)314 _, _ = w.Write(raw)315}