Skip to content

StatefulHTTPServerTransport: a terminated session 404s every subsequent request, including a session-less InitializeRequest — the endpoint cannot recover #286

Description

@ianegordon

Summary

Once a session is terminated, StatefulHTTPServerTransport.handleRequest answers 404 to every request, before method dispatch and before the validation pipeline — including an InitializeRequest that carries no session ID.

A spec-conformant client sends DELETE when it no longer needs its session (spec: SHOULD). That single well-behaved act takes the MCP endpoint out of service for the lifetime of the host process: the recovery the spec requires the client to attempt — a new InitializeRequest with no session ID — draws 404 too, forever.

Affected: 0.12.1 and main (a0ae212).

Where

StatefulHTTPServerTransport.swift#L170-L177:

public func handleRequest(_ request: HTTPRequest) async -> HTTPResponse {
    if terminated {
        return .error(
            statusCode: 404,
            .invalidRequest("Not Found: Session has been terminated"),
            sessionID: sessionID
        )
    }

    switch request.method.uppercased() {
    

The guard does not look at the request at all — not its method, not its MCP-Session-Id header.

What the specification says

Streamable HTTP → Session Management, items 3–5:

  1. The server MAY terminate the session at any time, after which it MUST respond to requests containing that session ID with HTTP 404 Not Found.
  2. When a client receives HTTP 404 in response to a request containing an MCP-Session-Id, it MUST start a new session by sending a new InitializeRequest without a session ID attached.
  3. Clients that no longer need a particular session (e.g., because the user is leaving the client application) SHOULD send an HTTP DELETE to the MCP endpoint with the MCP-Session-Id header, to explicitly terminate the session.

Two divergences:

  • The 404 is specified for requests containing that session ID. The transport returns it for requests that carry no session ID.
  • Item 4's mandated recovery is unsatisfiable. The client does exactly what it MUST do, and the server answers 404 again. Following item 5 (a SHOULD) is what puts it in that state.

Reproduction

Package.swift pinned to main (a0ae212), then:

import Foundation
import MCP

let headers = [
    "Accept": "application/json, text/event-stream",
    "Content-Type": "application/json",
]

func initializeBody() -> Data {
    Data("""
    {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"repro","version":"1.0"}}}
    """.utf8)
}

let transport = StatefulHTTPServerTransport()
let server = Server(name: "repro", version: "1.0.0", capabilities: .init(tools: .init()))
try await server.start(transport: transport)

// 1. A client initializes.
let first = await transport.handleRequest(
    HTTPRequest(method: "POST", headers: headers, body: initializeBody()))
let sessionID = first.headers["MCP-Session-Id"] ?? "<none>"
print("1. initialize            -> \(first.statusCode), MCP-Session-Id: \(sessionID)")

// 2. That client ends its session (spec item 5: SHOULD send DELETE).
var deleteHeaders = headers
deleteHeaders["MCP-Session-Id"] = sessionID
let deleted = await transport.handleRequest(HTTPRequest(method: "DELETE", headers: deleteHeaders))
print("2. DELETE (session id)   -> \(deleted.statusCode)")

// 3. Spec item 4's mandated recovery: a NEW InitializeRequest, no session id.
let recovery = await transport.handleRequest(
    HTTPRequest(method: "POST", headers: headers, body: initializeBody()))
let body = recovery.bodyData.flatMap { String(data: $0, encoding: .utf8) } ?? "<no body>"
print("3. initialize (no id)    -> \(recovery.statusCode) \(body)")

Output:

1. initialize            -> 200, MCP-Session-Id: 93B6CFC0-0483-43A7-8ECC-BE1AA43316E5
2. DELETE (session id)   -> 200
3. initialize (no id)    -> 404 {"id":null,"jsonrpc":"2.0","error":{"message":"Invalid Request: Not Found: Session has been terminated","code":-32600}}

Step 3 is the whole bug: a brand-new client, no session ID, told the endpoint does not exist. No further request of any kind will ever be served by that transport.

This is not a misplaced if

The obvious narrow fix — scope the guard to requests that actually carry a session ID — does not restore service. Patching exactly that on a0ae212:

if terminated, request.header(HTTPHeaderName.sessionID) != nil {

and re-running the reproduction gives:

3. initialize (no id)    -> 400 {"jsonrpc":"2.0","error":{"code":-32600,"message":"Invalid Request: Bad Request: Session already initialized"},"id":null}

404 becomes 400; the endpoint is just as dead. Three independent pieces of state make the Server + transport pair single-use:

  1. terminate() does not clear sessionID (L558-L580), so handleInitializationRequest rejects the new initialize at the transport level (L247-L254).
  2. terminate() calls incomingContinuation.finish() (L579). The client→server stream is over; no message can reach the Server again regardless of what handleRequest returns.
  3. Server.isInitialized is set once and never reset (L175, L969), so a second initialize reaching that Server would draw Server is already initialized (L929).

That is all defensible: the transport is one session, and a terminated session should stay terminated. The defect is that nothing sits above it. handleRequest is simultaneously the session and the HTTP endpoint, so a request belonging to no session — a session-less initialize — has nowhere to go except into a dead one. The same conflation is why a second concurrent client's initialize draws 400 Bad Request: Session already initialized while the first is connected.

How the other MCP SDKs solve it

They all put a session map above the per-session transport, and the endpoint handler is that map — not a transport.

PythonStreamableHTTPSessionManager:

if request_mcp_session_id is not None and request_mcp_session_id in self._server_instances:
    transport = self._server_instances[request_mcp_session_id]
    ...
    await transport.handle_request(scope, receive, send)
    if transport.is_terminated:
        # The client ended the session (DELETE): forget it now rather
        # than when its server task winds down.
        await self._discard_session(request_mcp_session_id, transport)
    return

if request_mcp_session_id is None:
    # New session case. ...
    async with self._session_creation_lock:
        http_transport = self._admit_session(requestor)
    ...
else:
    # Unknown or expired session ID - return 404 per MCP spec
    await _error_response("Session not found", 404)(scope, receive, send)

A session-less initialize never reaches a terminated transport, because it is routed to a newly admitted one.

GoStreamableHTTPHandler is an http.Handler holding sessions map[string]*sessionInfo // keyed by session ID, with idle-session timeout.

C#StreamableHttpHandler resolves each request through a sessionManager keyed by the Mcp-Session-Id header (sessionManager.TryGetValue(sessionId, out var session)), and removes the entry on DELETE.

TypeScript leaves the map to the embedder and documents that explicitly — which is a defensible choice, but it is a documented one, and this SDK currently neither ships the layer nor says that the embedder must.

The Swift SDK ships no such type (git grep -l SessionManager Sources/ is empty), and StatefulHTTPServerTransport's own usage documentation shows one transport wired directly into the framework handler — the exact arrangement that this bug kills.

Recommendation

1. Ship the missing layer (the real fix). An MCP-Session-Id-keyed router of Server + StatefulHTTPServerTransport pairs that is itself the endpoint handler, matching the Python/Go/C# shape:

  • a session-less initialize admits a new pair and is served by it;
  • a request carrying a known session ID is forwarded to that pair;
  • a request carrying an unknown, evicted or terminated session ID gets the spec's 404 — and only that case gets it;
  • a 2xx DELETE discards the pair, so the ID answers 404 afterwards and a fresh initialize gets a fresh pair;
  • idle-session eviction and a cap on concurrent sessions, since sessions are now created on demand (Python and Go both carry these; note also that the replay event store grows for the life of a session, so an eviction policy is what bounds it);
  • one place to configure validation, so the Origin/authentication policy cannot drift between the router's own responses and each pair's pipeline.

Because pairs become disposable, this resolves the 404-blanket, the second-client 400 Session already initialized, and the unbounded per-session event store in one move — none of which can be fixed inside a type that is definitionally one session.

2. Independently, make the terminated response accurate. Even with (1), the transport should answer per spec when driven directly: 404 only for a request carrying the terminated session ID. For a session-less request the honest answer from a single-session transport is not "Not Found" — it cannot serve the request because it is spent, which is 410 Gone or 503, with a message that says so. And whichever way this lands, StatefulHTTPServerTransport's documentation should state plainly that a terminated transport is not reusable and that the Server bound to it cannot be re-initialized, so embedders wiring one transport per endpoint know what they are signing up for.

Happy to prototype either piece if the shape above looks right.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions