docs: explain why GET /api/mcp deliberately answers 405 in stateless mode
This commit is contained in:
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user