package mcpserver import ( "encoding/json" "io" "net/http" "strconv" "strings" "time" "github.com/krow/krow-backend/go-api/internal/authctx" ) // The Streamable HTTP binding: one endpoint, one message per POST. // // The current MCP spec defines two standard transports — stdio and Streamable // HTTP — and only the second can serve a client that is not launching a // subprocess. Each message is an HTTP POST to a single MCP endpoint, and the // reply is a JSON object or a request-scoped SSE stream. // // This implementation answers with JSON objects and no stream, which is a // complete implementation of the binding for this server's methods rather than // a shortcut: tools/list is a fixed list, and a tool result is structured data // already bounded by MaxResultBytes. There is nothing to deliver incrementally. // Streaming becomes worth adding if a long-running method is ever exposed. // // STATELESS. No session is minted and no Mcp-Session-Id is required, because // nothing here is worth remembering between calls: every request carries its own // bearer credential and every method is independent. Adding session // state now would be state to expire, to bind to a token, to revalidate and to // leak — for no behaviour this server has. // MaxRequestBytes bounds an inbound MCP message. // // Well above any legitimate tools/call — arguments are a few scalars — and far // below anything that would be worth sending here. Enforced with // http.MaxBytesReader so the body is refused as it arrives rather than after it // has been buffered. const MaxRequestBytes = 1 << 20 // 1 MiB // Handler returns the HTTP handler for the MCP endpoint. // // Deliberately an http.Handler rather than a registered route: this package // does not know its own path, and the server that mounts it decides where it // lives. Note that it does NOT decide what authenticates it — this handler // authenticates its own callers from the Authorization header, and ignores // whatever middleware sits in front. Mounting it behind the cookie middleware // therefore does not make a cookie sufficient to call it. func (s *Server) Handler() http.Handler { return http.HandlerFunc(s.serveHTTP) } func (s *Server) serveHTTP(w http.ResponseWriter, r *http.Request) { // One method. GET is what the binding uses for a server-initiated stream, // which this server does not open; saying 405 with an Allow header is more // use to a client than a 404 that suggests the endpoint is absent. if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) writeRPCError(w, s, http.StatusMethodNotAllowed, nil, errInvalidRequest("this endpoint accepts POST only")) return } // Content-Type is checked rather than assumed. A form post or a stray // upload that happened to be valid JSON would otherwise be processed as a // protocol message. if ct := r.Header.Get("Content-Type"); ct != "" && !isJSONContentType(ct) { writeRPCError(w, s, http.StatusUnsupportedMediaType, nil, errInvalidRequest("Content-Type must be application/json")) return } body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, MaxRequestBytes)) if err != nil { // MaxBytesReader's error is indistinguishable from a truncated upload // without type assertions that buy nothing here: both mean the body is // unusable, and 413 is the more actionable of the two answers. writeRPCError(w, s, http.StatusRequestEntityTooLarge, nil, errInvalidRequest("the request body was too large or could not be read")) return } req, rpcErr := parseRequest(body) if rpcErr != nil { // A parse failure has no usable id, so the response carries the id the // message did parse with — null when it parsed with none. HTTP stays // 200: the transport succeeded and the JSON-RPC error IS the answer. writeRPCError(w, s, http.StatusOK, req.ID, rpcErr) return } // Authentication, for every method including the handshake — see // methodRequiresAuth. The 401 below is not merely a refusal: its // WWW-Authenticate header is the first step of the MCP authorization flow, // and a client's very first request is what should produce it. var ident *authctx.Identity if resolved, err := s.authenticate(r); err == nil { ident = &resolved } else if methodRequiresAuth(req.Method) { s.log.Warn("mcp request refused", "method", req.Method, "reason", authFailureReason(err)) // WWW-Authenticate is not decoration: RFC 9728 has the client read the // resource-metadata URL from this header to find the authorization // server, and from there where to get a token. It is the difference // between "authentication failed" and "here is how to authenticate". w.Header().Set("WWW-Authenticate", s.challenge()) writeRPCError(w, s, http.StatusUnauthorized, req.ID, &rpcError{Code: codeUnauthorized, Message: "authentication required"}) return } // A panic in a handler must not take the process down or leak a stack into // the response. The registry has its own recover around each tool; this is // the outer net for everything else in this package. result, rpcErr := s.handleRecovered(r, ident, req) // A notification gets no response body at all — the spec forbids one. if req.isNotification() { w.WriteHeader(http.StatusAccepted) return } if rpcErr != nil { // A rate-limited refusal is the one JSON-RPC error that also carries an // HTTP status, because 429 and Retry-After are how a client knows to // back off. Everything else is 200 with an error body: the transport // succeeded and the error IS the answer. if rpcErr.Code == codeRateLimited { if rpcErr.retryAfter > 0 { w.Header().Set("Retry-After", retryAfterSeconds(rpcErr.retryAfter)) } writeRPCError(w, s, http.StatusTooManyRequests, req.ID, rpcErr) return } writeRPCError(w, s, http.StatusOK, req.ID, rpcErr) return } writeJSON(w, s, http.StatusOK, response{ JSONRPC: jsonRPCVersion, ID: req.ID, Result: result, }) } // methodRequiresAuth reports whether a method may only run for a known caller. // // EVERY method does, including the handshake. An earlier revision left // initialize, ping and notifications/initialized open, on the reasoning that a // client needs somewhere to start — and that was wrong in a way worth // recording, because it is the kind of mistake that looks like helpfulness. // // The MCP authorization flow begins with the client making an MCP request // WITHOUT a token and reading the 401's WWW-Authenticate header. A client's // first request is usually initialize. Answering that one with a cheerful 200 // means the client never sees the challenge, believes it is connected, and // discovers otherwise only when the first real call fails — by which time it // has no 401 in hand to discover from. Requiring a token everywhere means the // very first request, whatever it is, produces the challenge that starts the // flow. // // Nothing is lost. The handshake is not information a stranger needs: it // returns this server's name and capabilities, which are only useful to a // client that intends to authenticate anyway. // // Kept as a function rather than inlined because it is the single place that // decision lives, and a future method that genuinely must be open should have // to be written down here to become so. func methodRequiresAuth(method string) bool { return true } // handleRecovered runs Handle with a recover, converting a panic into an // internal error whose detail goes to the log and not to the caller. func (s *Server) handleRecovered(r *http.Request, ident *authctx.Identity, req request) (result any, rpcErr *rpcError) { defer func() { if p := recover(); p != nil { s.log.Error("mcp handler panicked", "method", req.Method, "panic", p) result, rpcErr = nil, errInternal() } }() return s.Handle(r.Context(), ident, req) } // retryAfterSeconds renders a duration for the Retry-After header, rounded up // and never below one second — "Retry-After: 0" invites an immediate retry, // which is the one thing a limited client must not do. func retryAfterSeconds(d time.Duration) string { secs := int(d.Round(time.Second) / time.Second) if secs < 1 { secs = 1 } return strconv.Itoa(secs) } // isJSONContentType reports whether a Content-Type header names JSON, // tolerating parameters such as "; charset=utf-8". func isJSONContentType(ct string) bool { media := strings.TrimSpace(strings.SplitN(ct, ";", 2)[0]) return strings.EqualFold(media, "application/json") } func writeRPCError(w http.ResponseWriter, s *Server, status int, id json.RawMessage, e *rpcError) { writeJSON(w, s, status, response{JSONRPC: jsonRPCVersion, ID: id, Error: e}) } func writeJSON(w http.ResponseWriter, s *Server, status int, payload response) { encoded, err := json.Marshal(payload) if err != nil { // Encoding our own response failed, so there is nothing safe left to // say in JSON. Log it and send a bare 500. s.log.Error("mcp response could not be encoded", "error", err) http.Error(w, "internal error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Cache-Control", "no-store") w.WriteHeader(status) _, _ = w.Write(encoded) }