diff --git a/docs/superpowers/specs/2026-09-08-mcp-server-design.md b/docs/superpowers/specs/2026-09-08-mcp-server-design.md index 0089d82..e7a5ee7 100644 --- a/docs/superpowers/specs/2026-09-08-mcp-server-design.md +++ b/docs/superpowers/specs/2026-09-08-mcp-server-design.md @@ -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. diff --git a/server/cmd/main.go b/server/cmd/main.go index 4c1b9bd..e2325ff 100644 --- a/server/cmd/main.go +++ b/server/cmd/main.go @@ -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() { diff --git a/server/internal/api/handlers.go b/server/internal/api/handlers.go index be93ac3..42dd5db 100644 --- a/server/internal/api/handlers.go +++ b/server/internal/api/handlers.go @@ -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()) diff --git a/server/internal/mcp/transport.go b/server/internal/mcp/transport.go index b9671e7..575025b 100644 --- a/server/internal/mcp/transport.go +++ b/server/internal/mcp/transport.go @@ -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)