StatefulHTTPServerTransport: a terminated session 404s every subsequent request, including a session-less InitializeRequest — the endpoint cannot recover
I maintainer di solito rispondono entro 7 giorni
Nessuno ha ancora preso questa issue.
Valutazione
- Difficoltà
- 5/5
- Tempo stimato
- Più di una settimana
- Idoneità per principianti
- 35/100
- Tipo di issue
- Bug
- Chiarezza
- Abbastanza chiara
- Stato di attività
- Attiva
- Stack tecnologico
- swift
- Ambito
- backend-api-design
Direzione di ricerca
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.
Scritto dal modello di indicizzazione a partire dal testo della issue.
Descrizione
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.
- Lingua principale
- Swift
- Stelle
- 1.5k
- Fork
- 252
- Metriche di merge delle PR
- Nessuna PR unita negli ultimi 30g
Preparare l'ambiente
- Nessun Dockerfile né file Docker Compose
- Nessun modello di pull request
- Leggi la guida per i contributori
Come iniziare
- Leggi tutta la issue e poi la guida ai contributi del progetto.
- Commenta sulla issue per dire che te ne occupi tu — evita che due persone facciano lo stesso lavoro.
- Fai un fork del repository e lavora su un branch.
- Apri una pull request che faccia riferimento al numero della issue.
Altre issue di modelcontextprotocol/swift-sdk
-
NetworkTransport leaks the underlying socket (CLOSE_WAIT) when reconnection is disabled and the receive loop terminatesForse già presa @skirrellyjones l’ha presa 34 giorni fa. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 88/100
modelcontextprotocol/swift-sdk#282 ·
I maintainer di solito rispondono entro 7 giorni
-
Value decoding changes data-URL-looking JSON strings into Value.dataForse già presa @bitbemol l’ha presa 45 giorni fa. Apertabug
Difficoltà 2/5 1-3 ore Idoneità per principianti 76/100
modelcontextprotocol/swift-sdk#277 ·
I maintainer di solito rispondono entro 7 giorni
-
enhancement
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 78/100
modelcontextprotocol/swift-sdk#274 ·
I maintainer di solito rispondono entro 7 giorni
-
`HTTPClientTransport` fails to build on Windows: `#if !os(Linux)` guards no longer match the `EventSource` platform conditionForse di nuovo libera Una pull request per questa issue è stata chiusa senza essere unita. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 85/100
modelcontextprotocol/swift-sdk#261 ·
I maintainer di solito rispondono entro 7 giorni
-
Value: add numberValue (Double?) that coerces .int and .doubleForse già presa @gsdali l’ha presa 156 giorni fa. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 76/100
modelcontextprotocol/swift-sdk#225 ·
I maintainer di solito rispondono entro 7 giorni
Tutte le issue di modelcontextprotocol/swift-sdk
Issue simili
-
enhancement
Difficoltà 2/5 1-3 ore Idoneità per principianti 65/100
altic-dev/FluidVoice#1117 ·
I maintainer di solito rispondono entro 2 giorni
-
Difficoltà 1/5 Meno di un'ora Idoneità per principianti 90/100
omacom/try-omarchy#343 ·
I maintainer di solito rispondono entro 1 giorno
-
install.ps1: Windows PowerShell 5.1 aborts after a successful cosign verification (NativeCommandError on 'Verified OK')Forse già presa @sly321-pixel l’ha presa oggi. Aperta
Difficoltà 2/5 1-3 ore Idoneità per principianti 76/100
I maintainer di solito rispondono entro 1 giorno
-
Difficoltà 2/5 1-3 ore Idoneità per principianti 68/100
OneBusAway/onebusaway-ios#1518 ·
I maintainer di solito rispondono entro 2 giorni
-
bug
Difficoltà 2/5 1-3 ore Idoneità per principianti 61/100
vorssaint/vorssaint-utils#3030 ·
I maintainer di solito rispondono entro 1 giorno