Repository navigation
Streamable HTTP: the POST for a cancelled request never settles #3053
Description
After a client cancels a request with notifications/cancelled, WebStandardStreamableHTTPServerTransport never settles the HTTP response of the original POST. Protocol drops the cancelled request's result (if (abortController.signal.aborted) return in _onrequest), so the transport never gets a response for that id:
- With
enableJsonResponse: true, thePromise<Response>fromhandleRequeststays pending forever. - In SSE mode, the per-request stream stays open with no events.
Either way, the request's entries in _streamMapping and _requestToStreamMapping are kept until close(). A server that tracks in-flight requests (for idle expiry or a session cap, say) sees that session as permanently busy.
This is separate from #2559 / #2584, which cover close() during an in-flight JSON-mode POST. Here the session stays open.
Reproduced on @modelcontextprotocol/server 2.0.0 and 2.3.1 (Node 24). The same code path is on main at 9bc40e9.
import { randomUUID } from 'node:crypto'
import { McpServer, WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/server'
const server = new McpServer({ name: 'repro', version: '1.0.0' })
server.registerTool('slow', { description: 'Waits 200 ms' }, async () => {
await new Promise((resolve) => setTimeout(resolve, 200))
return { content: [{ type: 'text', text: 'done' }] }
})
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: randomUUID,
enableJsonResponse: true // with false, the 200 SSE response arrives but its stream never ends
})
await server.connect(transport)
let sessionId
const post = (body) =>
transport.handleRequest(
new Request('http://localhost/mcp', {
method: 'POST',
headers: {
accept: 'application/json, text/event-stream',
'content-type': 'application/json',
'mcp-protocol-version': '2025-06-18',
...(sessionId && { 'mcp-session-id': sessionId })
},
body: JSON.stringify(body)
})
)
const init = await post({
jsonrpc: '2.0', id: 1, method: 'initialize',
params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'repro', version: '1.0.0' } }
})
sessionId = init.headers.get('mcp-session-id')
await post({ jsonrpc: '2.0', method: 'notifications/initialized' })
const call = post({ jsonrpc: '2.0', id: 2, method: 'tools/call', params: { name: 'slow' } })
await new Promise((resolve) => setTimeout(resolve, 50))
await post({ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: 2 } })
const timeout = new Promise((resolve) => setTimeout(() => resolve('still pending'), 2000))
console.log(await Promise.race([call.then((r) => r.status), timeout])) // "still pending"
console.log(transport._streamMapping.size, transport._requestToStreamMapping.size) // 1 1The tool handler finishes after 200 ms, but the POST is still pending after 2 s.
Expected: once a request is cancelled, the transport releases it: it ends the JSON-mode POST without a JSON-RPC body (for example 202), closes its SSE stream, and deletes its mappings. One way to do this is for Protocol to tell the transport when it drops a cancelled response, so the transport can run the same cleanup that send() runs for a delivered one.
We currently work around this by sending a synthetic -32800 error response for the cancelled id after answering the HTTP request ourselves. That depends on the transport's internal bookkeeping.
Activity
FelixFangX commentedon Oct 20, 2026
The POST hangs because the cancellation never reaches the transport.
Protocol._oncancel(packages/core-internal/src/shared/protocol.ts:735-744) only aborts the handler's controller, and_onrequestthen drops both the result and the error once the signal is aborted (:1118-1120,:1148-1150), sosend()is never called for that id.WebStandardStreamableHTTPServerTransportresolves the JSON response or closes the SSE stream, and clears_requestToStreamMapping/_requestResponseMap, only fromsend()once every id on that stream has a response (packages/server/src/server/streamableHttp.ts:1242-1307). A cancelled id never gets one, so the POST stays open, and any ids batched with it are stuck too.closeSSEStream()doesn't help here: it's meant for resumability, keeps the id bookkeeping, and in JSON mode drops the resolver without resolving it.Proposed fix: an optional
cancelRequest?(requestId: RequestId): Promise<void>onTransport. Protocol calls it on the transport that owns the request right when it aborts the controller, not after the handler settles, since a handler can ignore the signal. The streamable HTTP transport retires that id and reruns the same completion checksend()uses: deliver finished siblings, keep waiting on pending ones, and when nothing is left close the SSE stream or end the JSON POST with an empty body. No JSON-RPC response is sent for the cancelled id, per the cancellation spec. The Node wrapper inpackages/middleware/nodeforwards the call. Tests would go inprotocol.test.tsandstreamableHttp.test.ts, covering JSON and SSE mode, ids0and'', duplicate cancels and a mixed batch.#2434 also deletes the mapping entries for cancelled ids, but deleting them alone doesn't settle the stream, so the two changes would need to line up.
Since this changes the
Transportinterface, I'd like agreement on the shape first (including which empty status the JSON POST should end with). Happy to take it if you assign me.nosmile99 commentedon Oct 20, 2026
I can reproduce and verify this on
main@9bc40e9(the commit this issue cites).Repro (issue's script,
tsxagainst the built workspace):"still pending"after 2s;streamMapping: 1,requestToStream: 1— matches the reported output exactly.Root cause (agrees with @FelixFangX's diagnosis above, plus a second abort-drop site):
Protocol._onrequestdrops both the handler result (protocol.ts:1118) and the handler error (:1148) when the abort signal is set, sotransport.send()never runs for a cancelled id and the streamable HTTP transport has no settling path for that exchange.Verified fix (regression test fails pre-fix, passes post-fix):
Transport.onRequestCancelled(requestId)inpackages/core-internal/src/shared/transport.ts, called at both abort-drop sites inProtocol._onrequest;WebStandardStreamableHTTPServerTransportimplements it — JSON mode resolves the pending POST with an empty 202 (the transport's established no-content signal), SSE mode closes the per-request stream, and both release the_streamMapping/_requestToStreamMappingentries.202,0/0; SSE variant →stream closed,0/0. New testsettles the JSON-mode POST with an empty 202 and releases mappings after notifications/cancelledfails on pristine code (verified via stash) and passes with the fix; fullstreamableHttp.test.ts58/58 green;typecheckclean on@modelcontextprotocol/core-internaland@modelcontextprotocol/server.protocol.ts,transport.ts,streamableHttp.ts, test file). Fix verified locally, not pushed — PR waits for assignment per CONTRIBUTING.One design note vs the
cancelRequestproposal above: I call the hook when the handler settles after cancellation rather than at abort time, since a handler can ignore the signal and legitimately complete — the hook only fires on the actual drop. Happy to rebase onto whatever shape the maintainers prefer.Disclosure: this diagnosis and fix were produced with AI assistance and verified empirically by running the code, as described above.
May I be assigned this issue? Once assigned I'll push the PR.
FelixFangX commentedon Oct 20, 2026
The result and the error are both dropped once the signal is aborted (
protocol.ts:1118,:1148), so a handler that completes after the cancel never gets a response either way. Firing the hook at settle only changes when the POST closes: after the handler finishes, or never if it ignores the signal and doesn't settle, which is the same hang this issue is about. That's why I'd retire the id when the controller is aborted. An empty 202 for the JSON POST matches what the transport already returns for notification-only POSTs (streamableHttp.ts:915), so that works for me.nosmile99 commentedon Oct 20, 2026
You're right — that's a real hole in the settle-time variant. If the handler ignores the signal and never settles, the hook never fires and the POST hangs exactly the way this issue describes. The hook has to fire at abort time.
Rebased design I'll implement: optional
Transport.onRequestCancelled(requestId), called fromProtocol._oncancelwhen the controller is aborted (not from the settle path in_onrequest). The streamable HTTP transport retires the id on first call — empty 202 for the JSON POST (matching the transport's existing no-content signal atstreamableHttp.ts:915), close the per-request stream in SSE mode — then reruns the same completion checksend()uses, so batched siblings deliver and pending ids keep waiting. Repeat cancels for an already-retired id are no-ops, which also keeps a later handler settle harmless.One honest correction to my earlier comment: the verification I reported (repro + 58/58 tests) was for the settle-time variant, so it doesn't transfer to this shape — I'll re-run everything against the rebased design before the PR goes up.
Disclosure: this comment was written with AI assistance.
princejain756 commentedon Oct 23, 2026
A third shape for this, verified on
main: noTransportinterface change needed, since the transport already sees every inbound cancellation at its own delivery point.Branch: https://github.com/princejain756/typescript-sdk/tree/fix-streamable-http-cancelled-post-settle
What it does:
handlePostRequestroutes inbound messages through a small_deliverMessagewrapper. When anotifications/cancelledarrives, the transport retires that id from_requestToStreamMappingand_requestResponseMap, then reruns the same all-responses-ready checksend()uses. When no other in-flight id still needs the stream it resolves the pending JSON-mode POST with an empty202(the same no-content signal the notifications-only path already returns) or closes the per-request SSE stream. Siblings batched on the same POST keep waiting for their own responses.vs the
onRequestCancelled/cancelRequesthook: same observable behavior for this transport, because every inbound cancellation reaches it throughonmessage, including a cancel batched with sibling requests (all three dispatch sites route through the wrapper). The interface hook would still be needed if a cancellation could be raised somewhere the transport never sees the notification; I did not find such a path on main.Verified: 3 new tests in
streamableHttp.test.tsfail pre-fix (JSON-mode POST still pending after 2s; SSE stream stays open) and pass post-fix; server suite 580/580, typecheck and lint clean. 1 source file changed, no new API.Happy to open the PR if maintainers prefer this shape, or to port the settle semantics onto whichever hook design gets picked.
nosmile99 commentedon Oct 23, 2026
Reviewed this against the hook shape I proposed. One load-bearing claim I verified on
main:Protocol._oncancelhas exactly one call site — thenotifications/cancelledhandler (packages/core-internal/src/shared/protocol.ts~L735, registered at ~L621). Connection teardown aborts handler controllers directly, but there the whole stream is dead anyway, so there is no cancellation path that leaves a POST/SSE stream needing settlement without the notification crossing the transport first. Your argument that every relevant cancellation is visible at the transport holds up on currentmain.That reduces the difference between the two shapes to scope, not behavior: yours fixes the one broken transport with no interface change; the hook gives other transports the same settle point at the cost of new API surface. No other transport is reported hanging, so I no longer have a strong argument for the hook.
If the maintainers prefer your branch, I will withdraw my claim — no point in two PRs racing for the same fix. My assignment request from earlier stands in the meantime.
Disclosure: written with AI assistance.
princejain756 commentedon Oct 23, 2026
Thanks for checking the call-site claim on main, that is the load-bearing part and it matches what I found.
One edge worth noting for whichever shape lands: a
notifications/cancelledcan arrive inside a batched POST alongside requests, so the settlement has to sit at everyonmessagedispatch, not only the 202 notification-only branch. The branch linked above covers all three sites.Happy for the maintainers to pick whichever design fits the API direction.
nosmile99 commentedon Oct 23, 2026
Verified the three-site claim on your branch against the dispatch paths.
All three
onmessagedispatch sites inhandlePostRequestroute through_deliverMessage: the notification-only 202 branch (streamableHttp.ts:922), the JSON-mode branch (:956), and the SSE branch (:1036) — so thenotifications/cancelledcheck in_deliverMessagefires at every site, not only the 202 branch. Nothing else dispatches inbound POST messages, and GET/DELETE carry no message bodies, so no cancellation can arrive at the transport without crossing it.I also ran the batched edge empirically (cloned your branch,
bf2b3ec): a single POST carrying[tools/call (never-resolving tool), notifications/cancelled for that id]— JSON mode: the POST settles with an empty 202; SSE mode: the per-request stream closes. Both within the 2s race window; without the branch they'd hang. The branch's three new tests cover the cancel-on-a-separate-POST shape; the cancel-inside-the-batch shape isn't in the suite yet — might be worth adding since it's the edge you called out.This design (transport-visible cancellation, no interface change) holds up on current
main.