Files
Aravind f2aa3b3ad8
Some checks failed
CI / fixture (push) Has been cancelled
CI / test (push) Has been cancelled
mcp connection
2026-09-22 10:58:02 +05:30

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)
}