Summary
In StatelessHTTPServerTransport (0.12.1), response waiters are correlated by the raw JSON-RPC id:
// Sources/MCP/Base/Transports/HTTPServer/StatelessHTTPServerTransport.swift:53
private var responseWaiters: [String: CheckedContinuation<Data, any Error>] = [:]
// …:232 (handleJSONRPCRequest)
responseWaiters[requestID] = continuation
JSON-RPC ids are client-scoped, and this transport is explicitly multi-client ("can handle multiple client connections"), so id collisions across concurrent POSTs are legal and common — most clients start their id sequence at 1. The dictionary assignment at line 232 silently overwrites any waiter already registered for that id.
Two consequences:
- Hang — the overwritten request's continuation is never resumed. Nothing else resumes a waiter except a matching response in
send(_:) or full transport terminate(), and no waiter timeout exists (the "No waiter for response, may have timed out" log message suggests one, but none is implemented). The first POST hangs until connection teardown.
- Leaked
CheckedContinuation — the abandoned continuation trips Swift's continuation-misuse diagnostics. In our test harness, 8 concurrent tools/list POSTs sharing "id": 7 against a single transport crashed the Swift Testing runner outright rather than merely hanging.
A related conflation: ids are stringified into the map key, so a string "1" and an integer 1 from different clients collide as well.
Reproduction sketch
- Start a server on
StatelessHTTPServerTransport with any tool registered.
- Issue N concurrent
POST requests whose bodies share a JSON-RPC id, e.g. {"jsonrpc":"2.0","id":7,"method":"tools/list"}.
- Observe: some requests never receive a response (hang), and continuation-misuse diagnostics fire as overwritten continuations are dropped.
Suggested direction
- Correlate waiters by a per-exchange token (e.g. a UUID assigned per
handleRequest call) rather than the wire-level JSON-RPC id, mapping outgoing responses back through a wire-id → exchange-token association that is scoped to the exchange rather than global.
- Independently, a waiter deadline would convert any residual correlation failure from an indefinite hang into a bounded error response.
Found during an MCP 2025-11-25 conformance audit of an app embedding this SDK. Happy to provide more detail.
Summary
In
StatelessHTTPServerTransport(0.12.1), response waiters are correlated by the raw JSON-RPC id:JSON-RPC ids are client-scoped, and this transport is explicitly multi-client ("can handle multiple client connections"), so id collisions across concurrent POSTs are legal and common — most clients start their id sequence at 1. The dictionary assignment at line 232 silently overwrites any waiter already registered for that id.
Two consequences:
send(_:)or full transportterminate(), and no waiter timeout exists (the"No waiter for response, may have timed out"log message suggests one, but none is implemented). The first POST hangs until connection teardown.CheckedContinuation— the abandoned continuation trips Swift's continuation-misuse diagnostics. In our test harness, 8 concurrenttools/listPOSTs sharing"id": 7against a single transport crashed the Swift Testing runner outright rather than merely hanging.A related conflation: ids are stringified into the map key, so a string
"1"and an integer1from different clients collide as well.Reproduction sketch
StatelessHTTPServerTransportwith any tool registered.POSTrequests whose bodies share a JSON-RPC id, e.g.{"jsonrpc":"2.0","id":7,"method":"tools/list"}.Suggested direction
handleRequestcall) rather than the wire-level JSON-RPC id, mapping outgoing responses back through a wire-id → exchange-token association that is scoped to the exchange rather than global.Found during an MCP 2025-11-25 conformance audit of an app embedding this SDK. Happy to provide more detail.