Summary
In StatelessHTTPServerTransport (0.12.1), a request that is cancelled via notifications/cancelled leaves its original HTTP POST hanging indefinitely.
The cancellation flow correctly suppresses the JSON-RPC response (per base-protocol cancellation semantics, the server must not reply to a cancelled request). But the transport's HTTP waiter for that id is only ever resumed by two paths:
- a matching JSON-RPC response arriving through
send(_:) (responseWaiters.removeValue(forKey: id) + resume), or
- full transport
terminate() (responseWaiters.removeAll() after failing them).
Since a cancelled request produces no response and nothing else resumes the waiter, the withCheckedThrowingContinuation in handleJSONRPCRequest (…/StatelessHTTPServerTransport.swift:230-233) never returns, and the original POST's HTTP exchange never completes.
Spec conflict
MCP 2025-11-25, Streamable HTTP, Sending Messages to the Server #5:
"If the input is a JSON-RPC request, the server MUST either return Content-Type: text/event-stream, to initiate an SSE stream, or Content-Type: application/json, to return one JSON object."
And cancellation is the spec's own recommended flow (same section, #6):
"To cancel, the client SHOULD explicitly send an MCP CancelledNotification."
So a spec-conformant client that cancels an in-flight request drives the server into violating the MUST above — the POST receives neither an SSE stream nor a JSON object, ever.
Reproduction sketch
- Start a server on
StatelessHTTPServerTransport with a slow tool handler.
POST a tools/call request with id X; while it is in flight, POST a notifications/cancelled for id X (202 as expected).
- Observe: the first POST never completes.
Suggested direction
- When processing
notifications/cancelled, resume the matching responseWaiters entry so the HTTP exchange completes (e.g. with a JSON-RPC error object such as "request cancelled", keeping the HTTP layer satisfied while the JSON-RPC layer stays silent-by-design is admittedly awkward — an error response for the cancelled id seems the pragmatic choice given the transport MUST above).
- Independently, a waiter deadline would bound any orphaned exchange.
Related: filed separately, concurrent requests sharing a JSON-RPC id also orphan waiters via map overwrite.
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), a request that is cancelled vianotifications/cancelledleaves its original HTTP POST hanging indefinitely.The cancellation flow correctly suppresses the JSON-RPC response (per base-protocol cancellation semantics, the server must not reply to a cancelled request). But the transport's HTTP waiter for that id is only ever resumed by two paths:
send(_:)(responseWaiters.removeValue(forKey: id)+ resume), orterminate()(responseWaiters.removeAll()after failing them).Since a cancelled request produces no response and nothing else resumes the waiter, the
withCheckedThrowingContinuationinhandleJSONRPCRequest(…/StatelessHTTPServerTransport.swift:230-233) never returns, and the original POST's HTTP exchange never completes.Spec conflict
MCP 2025-11-25, Streamable HTTP, Sending Messages to the Server #5:
And cancellation is the spec's own recommended flow (same section, #6):
So a spec-conformant client that cancels an in-flight request drives the server into violating the MUST above — the POST receives neither an SSE stream nor a JSON object, ever.
Reproduction sketch
StatelessHTTPServerTransportwith a slow tool handler.POSTatools/callrequest with id X; while it is in flight,POSTanotifications/cancelledfor id X (202 as expected).Suggested direction
notifications/cancelled, resume the matchingresponseWaitersentry so the HTTP exchange completes (e.g. with a JSON-RPC error object such as "request cancelled", keeping the HTTP layer satisfied while the JSON-RPC layer stays silent-by-design is admittedly awkward — an error response for the cancelled id seems the pragmatic choice given the transport MUST above).Related: filed separately, concurrent requests sharing a JSON-RPC id also orphan waiters via map overwrite.
Found during an MCP 2025-11-25 conformance audit of an app embedding this SDK. Happy to provide more detail.