// Package mcpserver is the Model Context Protocol surface: a second way into // the tool layer, for clients that speak MCP rather than HTTP+cookie. // // It is an ADDITIONAL interface and nothing else. It owns no business logic, no // SQL and no authorization rules. Every call it serves ends up in // tools.Registry.Dispatch — the same entry point the agent loop uses — so a // question asked through MCP is answered by the same handler, under the same // policy table, behind the same org pre-filter as the same question asked by // Owliver. That is the whole design, and the reason this package is small. // // What lives here: // // - JSON-RPC 2.0 framing (this file) // - the three methods MCP needs to be useful: initialize, tools/list, // tools/call (server.go) // - which tools are published, derived from the registry (tools.go) // - bearer authentication, as a seam an OAuth implementation plugs into // (auth.go) // - the Streamable HTTP binding (transport.go) // // Identity is established by this package's own bearer authentication and is // PASSED to the handlers, never read from the ambient request context. That is // what stops a browser cookie from authenticating an MCP call — see auth.go. package mcpserver import ( "encoding/json" "errors" "fmt" "time" ) // jsonRPCVersion is the only version this server speaks. A request naming // anything else is malformed rather than merely unsupported: "2.0" is a // constant in the spec, not a negotiation. const jsonRPCVersion = "2.0" /* ── Wire types ─────────────────────────────────────────────────────────── */ // request is one inbound JSON-RPC message. // // ID is json.RawMessage rather than any, because the spec allows a string, a // number or null, and the response MUST echo it back byte-for-byte. Decoding it // into an `any` turns 1 into 1.0 on the way back out, which is a different id to // a client matching responses to requests. type request struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id,omitempty"` Method string `json:"method"` Params json.RawMessage `json:"params,omitempty"` } // isNotification reports that no response is expected. // // A notification is a request with no id. The spec is explicit that a server // must not answer one, so the transport drops the response and returns 202. func (r request) isNotification() bool { return len(r.ID) == 0 || string(r.ID) == "null" } // response is one outbound JSON-RPC message. // // Result and Error are pointers so exactly one is ever serialised: the spec // forbids both together, and a non-pointer Result would emit `"result":null` // alongside an error. type response struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id,omitempty"` Result any `json:"result,omitempty"` Error *rpcError `json:"error,omitempty"` } // rpcError is a JSON-RPC error object. // // retryAfter is NOT serialised. It exists so a rate-limited refusal produced // deep in the handler can reach the transport, which is the only layer that can // set an HTTP status and a Retry-After header. The alternative — returning a // 200 with a JSON-RPC error and no header — would give a client no way to know // how long to wait. type rpcError struct { Code int `json:"code"` Message string `json:"message"` Data any `json:"data,omitempty"` retryAfter time.Duration } func (e *rpcError) Error() string { return fmt.Sprintf("jsonrpc %d: %s", e.Code, e.Message) } /* ── Error codes ────────────────────────────────────────────────────────── */ // The standard JSON-RPC 2.0 codes. Reserved range is -32768..-32000; anything // this server invents lives outside it. const ( codeParseError = -32700 codeInvalidRequest = -32600 codeMethodNotFound = -32601 codeInvalidParams = -32602 codeInternalError = -32603 ) // codeUnauthorized is outside the JSON-RPC reserved range (-32768..-32000), // because it is this server's own condition rather than a protocol fault. It // accompanies an HTTP 401: the transport layer carries the authoritative // signal, and this gives a client reading only the JSON-RPC body the same // answer. const codeUnauthorized = -32001 // codeRateLimited is this server's own condition, outside the reserved range. // It accompanies an HTTP 429 and a Retry-After header. const codeRateLimited = -32002 // errRateLimited refuses a call that exceeded its organisation's ceiling. // // The message names no number and no organisation. How much quota a tenant has // and how much of it they have spent is not something one caller should learn // from a refusal — it is the same reasoning as the opaque tool denial. func errRateLimited(retryAfter time.Duration) *rpcError { return &rpcError{ Code: codeRateLimited, Message: "too many requests for this organisation; retry after the interval in the Retry-After header", retryAfter: retryAfter, } } func errParse(detail string) *rpcError { return &rpcError{Code: codeParseError, Message: "invalid JSON", Data: detail} } func errInvalidRequest(detail string) *rpcError { return &rpcError{Code: codeInvalidRequest, Message: "invalid JSON-RPC request", Data: detail} } func errMethodNotFound(method string) *rpcError { return &rpcError{ Code: codeMethodNotFound, Message: "method not found", Data: fmt.Sprintf("this server implements initialize, tools/list and tools/call; it does not implement %q", method), } } func errInvalidParams(detail string) *rpcError { return &rpcError{Code: codeInvalidParams, Message: "invalid params", Data: detail} } // errInternal deliberately carries no detail. // // An internal failure is the one case where the thing that went wrong is this // server's business and not the caller's: a wrapped database error or a panic // message is reconnaissance. The detail goes to the log, where the operator is. func errInternal() *rpcError { return &rpcError{Code: codeInternalError, Message: "internal error"} } /* ── Parsing ────────────────────────────────────────────────────────────── */ // errBatch marks a batch request, which this server does not accept. // // Rejecting it explicitly rather than failing to parse it is the point: a // client that batches and gets a parse error will retry the same batch, where // one told that batching is unsupported can fall back to sending messages // singly. The current MCP transport binding sends one message per POST, so // nothing a compliant client does requires batching. var errBatch = errors.New("batch requests are not supported") // parseRequest decodes one JSON-RPC message and validates its envelope. // // The two are separate returns because they have different fates: a message // that could not be parsed has no id, so its error answers with a null id, // while a message that parsed but is invalid answers with the id it carried. func parseRequest(body []byte) (request, *rpcError) { trimmed := skipSpace(body) if len(trimmed) == 0 { return request{}, errInvalidRequest("the request body was empty") } if trimmed[0] == '[' { return request{}, errInvalidRequest(errBatch.Error()) } var req request if err := json.Unmarshal(trimmed, &req); err != nil { return request{}, errParse(err.Error()) } if req.JSONRPC != jsonRPCVersion { return req, errInvalidRequest(fmt.Sprintf( "jsonrpc must be %q, got %q", jsonRPCVersion, req.JSONRPC)) } if req.Method == "" { return req, errInvalidRequest("method is required") } // An id, when present, must be a string or a number. Objects and arrays are // forbidden by the spec, and echoing one back would propagate the mistake. if len(req.ID) > 0 && !isValidID(req.ID) { return req, errInvalidRequest("id must be a string, a number or null") } return req, nil } // isValidID reports whether a raw id is a string, a number or null. func isValidID(raw json.RawMessage) bool { t := skipSpace(raw) if len(t) == 0 { return false } switch t[0] { case '{', '[': return false } var v any return json.Unmarshal(t, &v) == nil } // decodeParams unmarshals params into dst, treating absent params as an empty // object so a method with only optional fields can be called with none. func decodeParams(raw json.RawMessage, dst any) *rpcError { t := skipSpace(raw) if len(t) == 0 || string(t) == "null" { return nil } // Arrays are legal JSON-RPC (positional params) and are not used by MCP, // whose methods all take an object. Saying so beats a confusing type error. if t[0] == '[' { return errInvalidParams("params must be an object; positional params are not supported") } if err := json.Unmarshal(t, dst); err != nil { return errInvalidParams(err.Error()) } return nil } func skipSpace(b []byte) []byte { i := 0 for i < len(b) { switch b[i] { case ' ', '\t', '\r', '\n': i++ default: return b[i:] } } return nil }