docs: explain why GET /api/mcp deliberately answers 405 in stateless mode

This commit is contained in:
2026-09-08 14:07:54 +00:00
parent aedc388535
commit a0b5565a63
4 changed files with 30 additions and 5 deletions
@@ -181,10 +181,14 @@ endpoint, so enabling the feature grants nothing by itself.
## Transport and protocol
Streamable HTTP, stateless. `POST /api/mcp` carries the JSON-RPC request and
returns either a JSON response or an SSE stream; `GET /api/mcp` opens the
server-to-client stream where a client asks for one. No session resumption in
v1 — each request stands alone, which is what lets the endpoint sit behind
ordinary request middleware with no special-casing.
returns either a JSON response or an SSE stream. The transport is stateless
rather than session-resuming precisely so each request can stand alone and
sit behind ordinary request middleware with no special-casing, and that
stateless mode leaves no session for a server-to-client stream to resume
against — so `GET /api/mcp` is registered but answers the protocol's 405
rather than opening a stream. A client probing the endpoint therefore learns
"POST-only here" rather than seeing a bare 404, which is what the MCP spec
expects from a server that does not offer the GET/SSE leg.
Protocol framing comes from `github.com/modelcontextprotocol/go-sdk`. Everything
below the framing is the existing service layer, called directly in-process.
+4 -1
View File
@@ -199,7 +199,10 @@ func runSchemaSetup() {
// apiVersion mirrors the @version annotation on the swagger block above,
// which is the only version string this server already establishes — there is
// no separate runtime build-version constant to reuse instead.
// no separate runtime build-version constant to reuse instead. Nothing ties
// the two together mechanically, so change them in the same commit: this is
// the value mcp.SetVersion reports to MCP clients, and it must keep agreeing
// with "// @version" above or the two will read as two different servers.
const apiVersion = "1.0"
func serve() {
+9
View File
@@ -126,6 +126,15 @@ func RegisterRoutes(r *gin.Engine) {
// activity and RequireScopes all apply from where it lives rather than
// because someone remembered. The route-level scope is a floor: one route
// serves many tools, so per-tool scopes are enforced inside the handler.
//
// GET is registered deliberately even though the transport runs stateless
// and therefore never serves it usefully: mcp.Handler's underlying SDK
// handler answers every GET with a hardcoded 405, because a stateless
// server has no session to open the server-to-client SSE stream against.
// That 405 is the protocol-correct response for an MCP server that offers
// no SSE leg — an unregistered GET would 404 instead, which a client reads
// as "no MCP endpoint here at all" rather than "this one is POST-only".
// This route is not a working GET; it exists solely to produce that 405.
mcpGroup := apiGroup.Group("/mcp", RequireFeature(license.FeatureMCP))
mcpGroup.POST("", mcp.Handler())
mcpGroup.GET("", mcp.Handler())
+9
View File
@@ -25,6 +25,15 @@ func callerFromContext(c *gin.Context) Caller {
// Handler serves the MCP endpoint. It is stateless: no session resumption, each
// request self-contained, which is what lets it sit behind ordinary request
// middleware with no special casing.
//
// Stateless mode also means this handler is POST-only in practice: the SDK's
// StreamableHTTPHandler hardcodes a 405 for GET whenever Stateless is true,
// because a stateless server has no session to open the server-to-client SSE
// stream against. The GET route is still registered deliberately (see
// handlers.go) so a client probing for the endpoint sees a protocol-correct
// 405 rather than gin's 404 — the MCP spec expects exactly that response from
// a server that does not offer the GET/SSE leg. Nothing here should route GET
// requests differently or try to make them do anything else.
func Handler() gin.HandlerFunc {
return func(c *gin.Context) {
caller := callerFromContext(c)