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:
- 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.
- 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.
- 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:
terminate() does not clear sessionID (L558-L580), so handleInitializationRequest rejects the new initialize at the transport level (L247-L254).
terminate() calls incomingContinuation.finish() (L579). The client→server stream is over; no message can reach the Server again regardless of what handleRequest returns.
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.
Python — StreamableHTTPSessionManager:
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.
Go — StreamableHTTPHandler 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.
Summary
Once a session is terminated,
StatefulHTTPServerTransport.handleRequestanswers 404 to every request, before method dispatch and before the validation pipeline — including anInitializeRequestthat carries no session ID.A spec-conformant client sends
DELETEwhen 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 newInitializeRequestwith no session ID — draws 404 too, forever.Affected:
0.12.1andmain(a0ae212).Where
StatefulHTTPServerTransport.swift#L170-L177:The guard does not look at the request at all — not its method, not its
MCP-Session-Idheader.What the specification says
Streamable HTTP → Session Management, items 3–5:
Two divergences:
Reproduction
Package.swiftpinned tomain(a0ae212), then:Output:
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
ifThe obvious narrow fix — scope the guard to requests that actually carry a session ID — does not restore service. Patching exactly that on
a0ae212:and re-running the reproduction gives:
404 becomes 400; the endpoint is just as dead. Three independent pieces of state make the
Server+ transport pair single-use:terminate()does not clearsessionID(L558-L580), sohandleInitializationRequestrejects the newinitializeat the transport level (L247-L254).terminate()callsincomingContinuation.finish()(L579). The client→server stream is over; no message can reach theServeragain regardless of whathandleRequestreturns.Server.isInitializedis set once and never reset (L175, L969), so a secondinitializereaching thatServerwould drawServer 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.
handleRequestis simultaneously the session and the HTTP endpoint, so a request belonging to no session — a session-lessinitialize— has nowhere to go except into a dead one. The same conflation is why a second concurrent client'sinitializedraws400 Bad Request: Session already initializedwhile 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.
Python —
StreamableHTTPSessionManager:A session-less
initializenever reaches a terminated transport, because it is routed to a newly admitted one.Go —
StreamableHTTPHandleris anhttp.Handlerholdingsessions map[string]*sessionInfo // keyed by session ID, with idle-session timeout.C# —
StreamableHttpHandlerresolves each request through asessionManagerkeyed by theMcp-Session-Idheader (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), andStatefulHTTPServerTransport'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 ofServer+StatefulHTTPServerTransportpairs that is itself the endpoint handler, matching the Python/Go/C# shape:initializeadmits a new pair and is served by it;DELETEdiscards the pair, so the ID answers 404 afterwards and a freshinitializegets a fresh pair;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 Goneor503, 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 theServerbound 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.