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