217 lines
9.0 KiB
Go
217 lines
9.0 KiB
Go
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)
|
|
}
|