| 1 | package mcp |
| 2 | |
| 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, validated |
| 15 | // against its mirrored headers and routed independently; no state survives |
| 16 | // between requests. |
| 17 | type Server struct { |
| 18 | // Tools is the tool table served by tools/list and tools/call. |
| 19 | Tools *Registry |
| 20 | // Info identifies this server in every result's _meta. |
| 21 | Info Implementation |
| 22 | // Instructions is optional guidance returned by server/discover. |
| 23 | Instructions string |
| 24 | // Authenticate resolves the caller from the request and its body. An |
| 25 | // 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 string |
| 29 | // Origin returns the origin this endpoint is served from. A request whose |
| 30 | // Origin header names another origin is refused with 403. |
| 31 | Origin func(r *http.Request) string |
| 32 | // MaxBodyBytes bounds the request body. Zero means one megabyte. |
| 33 | MaxBodyBytes int64 |
| 34 | } |
| 35 | |
| 36 | // Observation summarizes one handled request for telemetry. It never carries |
| 37 | // the caller's identity. |
| 38 | type Observation struct { |
| 39 | Method string |
| 40 | Tool string |
| 41 | Outcome string |
| 42 | Status int |
| 43 | } |
| 44 | |
| 45 | const ( |
| 46 | OutcomeOK = "ok" |
| 47 | OutcomeInvalid = "invalid" |
| 48 | OutcomeUnauthorized = "unauthorized" |
| 49 | OutcomeError = "error" |
| 50 | ) |
| 51 | |
| 52 | const ( |
| 53 | toolListTTL = 5 * 60 * 1000 |
| 54 | discoverTTL = 60 * 60 * 1000 |
| 55 | defaultBody = 1 << 20 |
| 56 | ) |
| 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 | 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.MaxBodyBytes |
| 73 | if limit <= 0 { |
| 74 | limit = defaultBody |
| 75 | } |
| 76 | body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, limit)) |
| 77 | if err != nil { |
| 78 | var tooLarge *http.MaxBytesError |
| 79 | 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 Request |
| 101 | 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.StatusAccepted |
| 114 | return obs |
| 115 | } |
| 116 | var p params |
| 117 | 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 any |
| 126 | var rpcErr *Error |
| 127 | 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.Name |
| 134 | var tool Result |
| 135 | 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 = OutcomeInvalid |
| 140 | } |
| 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 = OutcomeOK |
| 150 | } |
| 151 | obs.Status = http.StatusOK |
| 152 | write(w, http.StatusOK, Response{JSONRPC: "2.0" , ID: request.ID, Result: result}) |
| 153 | return obs |
| 154 | } |
| 155 | |
| 156 | func (s *Server) fail(w http.ResponseWriter, id json.RawMessage, err *Error, obs Observation) Observation { |
| 157 | status := err.Status |
| 158 | if status == 0 { |
| 159 | status = http.StatusBadRequest |
| 160 | } |
| 161 | obs.Status = status |
| 162 | switch { |
| 163 | case status == http.StatusUnauthorized: |
| 164 | obs.Outcome = OutcomeUnauthorized |
| 165 | case status >= 500: |
| 166 | obs.Outcome = OutcomeError |
| 167 | default: |
| 168 | obs.Outcome = OutcomeInvalid |
| 169 | } |
| 170 | write(w, status, Response{JSONRPC: "2.0" , ID: id, Error: err}) |
| 171 | return obs |
| 172 | } |
| 173 | |
| 174 | // validateHeaders enforces the request metadata rules of the Streamable HTTP |
| 175 | // binding: the protocol version in the header and in _meta must agree and be |
| 176 | // supported, Mcp-Method must match the method, and Mcp-Name must match the |
| 177 | // tool name of a tools/call request. |
| 178 | func 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.Name |
| 207 | case "resources/read" : |
| 208 | source = p.URI |
| 209 | default: |
| 210 | return nil |
| 211 | } |
| 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 nil |
| 224 | } |
| 225 | |
| 226 | func (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 | } |
| 235 | |
| 236 | func (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 | } |
| 243 | |
| 244 | func (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(), nil |
| 260 | } |
| 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(), nil |
| 264 | } |
| 265 | return result.filled(), nil |
| 266 | } |
| 267 | |
| 268 | func (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 fields |
| 274 | } |
| 275 | |
| 276 | func metaString(meta map[string]json.RawMessage, key string) (string, bool) { |
| 277 | raw, ok := meta[key] |
| 278 | if !ok { |
| 279 | return "" , false |
| 280 | } |
| 281 | var value string |
| 282 | if err := json.Unmarshal(raw, &value); err != nil { |
| 283 | return "" , false |
| 284 | } |
| 285 | return value, true |
| 286 | } |
| 287 | |
| 288 | func validID(id json.RawMessage) bool { |
| 289 | trimmed := bytes.TrimSpace(id) |
| 290 | if len(trimmed) == 0 || string(trimmed) == "null" { |
| 291 | return false |
| 292 | } |
| 293 | return trimmed[0] == '"' || trimmed[0] == '-' || (trimmed[0] >= '0' && trimmed[0] <= '9' ) |
| 294 | } |
| 295 | |
| 296 | func 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 true |
| 301 | } |
| 302 | } |
| 303 | return false |
| 304 | } |
| 305 | |
| 306 | func 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 | return |
| 311 | } |
| 312 | w.Header().Set("Content-Type" , "application/json" ) |
| 313 | w.WriteHeader(status) |
| 314 | _, _ = w.Write(raw) |
| 315 | } |