StatefulHTTPServerTransport: a terminated session 404s every subsequent request, including a session-less InitializeRequest — the endpoint cannot recover
Los mantenedores suelen responder en 7 días
Nadie ha tomado este issue todavía.
Evaluación
- Dificultad
- 5/5
- Tiempo estimado
- Más de una semana
- Aptitud para principiantes
- 35/100
- Tipo de issue
- Error
- Claridad
- Bastante claro
- Estado de actividad
- Activo
- Stack tecnológico
- swift
- Área
- backend-api-design
Línea de trabajo
Start with Sources/MCP/Base/Transports/HTTPServer/StatefulHTTPServerTransport.swift, especially handleRequest, handleInitializationRequest, and terminate(), then inspect Sources/MCP/Server/Server.swift for initialization state. Compare the transport's single-session lifecycle with the session-routing patterns described for the other SDKs. Done means session-less initialization can create a usable session while terminated or unknown session IDs receive the specified response, with lifecycle and eviction behavior defined.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
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 newInitializeRequestwithout 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-Idheader, 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 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. 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
initializeadmits 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
DELETEdiscards the pair, so the ID answers 404 afterwards and a freshinitializegets 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.
- Lenguaje dominante
- Swift
- Estrellas
- 1.5k
- Forks
- 252
- Métricas de merge de PR
- Sin PR fusionados en 30 d
Preparar el entorno
- Sin Dockerfile ni archivo de Docker Compose
- Sin plantilla de pull request
- Leer la guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de modelcontextprotocol/swift-sdk
-
NetworkTransport leaks the underlying socket (CLOSE_WAIT) when reconnection is disabled and the receive loop terminatesPosiblemente ocupada @skirrellyjones la tomó hace 35 días. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 88/100
modelcontextprotocol/swift-sdk#282 ·
Los mantenedores suelen responder en 7 días
-
Value decoding changes data-URL-looking JSON strings into Value.dataPosiblemente ocupada @bitbemol la tomó hace 45 días. Abiertobug
Dificultad 2/5 1-3 horas Aptitud para principiantes 76/100
modelcontextprotocol/swift-sdk#277 ·
Los mantenedores suelen responder en 7 días
-
enhancement
Dificultad 1/5 Menos de una hora Aptitud para principiantes 78/100
modelcontextprotocol/swift-sdk#274 ·
Los mantenedores suelen responder en 7 días
-
`HTTPClientTransport` fails to build on Windows: `#if !os(Linux)` guards no longer match the `EventSource` platform conditionQuizá libre de nuevo Un pull request para esta issue se cerró sin fusionarse. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 85/100
modelcontextprotocol/swift-sdk#261 ·
Los mantenedores suelen responder en 7 días
-
Value: add numberValue (Double?) that coerces .int and .doublePosiblemente ocupada @gsdali la tomó hace 157 días. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 76/100
modelcontextprotocol/swift-sdk#225 ·
Los mantenedores suelen responder en 7 días
Todos los issues de modelcontextprotocol/swift-sdk
Issues similares
-
area: sidebar bug S2: major
Dificultad 2/5 1-3 horas Aptitud para principiantes 72/100
manaflow-ai/cmux#20281 · 1 comentario ·
Los mantenedores suelen responder en 1 día
-
If a snippet replacement starts with a newline, it truncates the first parts of the replacement.Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 75/100
-
iOS: recorded video location is written without ISO 6709 fixed-width degrees, Photos drops itAbierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 80/100
margelo/react-native-vision-camera#4216 ·
Los mantenedores suelen responder en 4 días
-
documentation good first issue
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
-
bug
Dificultad 2/5 1-3 horas Aptitud para principiantes 62/100
compiler-explorer/compiler-explorer#9272 ·
Los mantenedores suelen responder en 1 día