You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Streamable HTTP: JSON-response-mode stream mappings never complete their lifecycle #2559
With enableJsonResponse: true on a stateful transport, send() resolves the pending Response via resolveJson but never runs the mapping's cleanup, so every completed POST leaks one _streamMapping entry until close(). Conversely, cleanup only deletes the entry without settling the promise, so close() during an in-flight JSON-mode POST leaves the HTTP request hanging.
Pre-existing on both main and v1.x. Split out from the review discussion on #2547.
I'd like to work on this. I confirmed there is no linked or open implementation PR.\n\nOn the current main branch, the successful JSON-response path already calls stream.cleanup() after resolving the response, so I would focus the v2 fix and regression coverage on settling an in-flight JSON response during transport shutdown, then verify the remaining lifecycle behavior on �1.x separately.\n\nPlease assign this to me if that scope is available.
Reading it as two halves of one lifecycle bug: send() resolves via resolveJson without running the mapping's cleanup (so a completed POST leaks its _streamMapping entry until close()), while cleanup deletes the entry without settling the promise (so close() during an in-flight JSON-mode POST leaves the request hanging). Fix is to make both paths run the full lifecycle — settle and delete — rather than one each.
Since you note it's pre-existing on both main and v1.x, should the v1.x fix be a separate PR against that branch, or do you prefer main first and a backport after?
Up as #2584 (draft — one-open-PR cap, #2582 holds it).
One note: only the second half reproduced. The cleanup-never-settles hang is real and fixed there. The _streamMapping leak doesn't reproduce on main — send() has called stream.cleanup() on both branches since #2286 (merged 2026-06-26), and measuring directly gives _streamMapping.size === 0 after a completed JSON-mode POST. I left that half alone rather than write a change that does nothing; happy to look again if you're seeing it on a path I didn't hit.
The _streamMapping leak half of this issue is still live on v1.x, which is what currently ships as 1.30.0 (npm dist-tags → latest: 1.30.0).
@axits-lab's measurement was correct for main: packages/server/src/server/streamableHttp.ts L1251 calls stream.cleanup() in the JSON branch, so main is clean. But that call was never carried to v1.x — src/server/webStandardStreamableHttp.ts L1242-1261 on v1.x still resolves without cleaning up:
if(this._enableJsonResponse&&stream.resolveJson){// ...if(responses.length===1){stream.resolveJson(newResponse(JSON.stringify(responses[0]),{status: 200, headers }));}else{stream.resolveJson(newResponse(JSON.stringify(responses),{status: 200, headers }));}}else{stream.cleanup();// SSE path only}// _requestResponseMap / _requestToStreamMapping / _resumableStreams are cleaned below,// but _streamMapping is not.
Repro (clean install of the published version)
npm i @modelcontextprotocol/sdk@1.30.0
node repro.mjs
1.30.0 as published → _streamMapping.size: 21
1.30.0 + stream.cleanup() in JSON branch → _streamMapping.size: 0
Retained memory
This is not just a stale Map key — the entry retains the whole response body. Heap snapshot diff on a long-running stateful server (transport kept per session, enableJsonResponse: true):
Measured ~14.7 KB retained per tools/list on a server with a moderate tool catalog. It grows with response size and with session lifetime, so servers that hold one transport per session and answer large tools/list responses accumulate steadily until close().
To be precise about what this does and doesn't explain: in our production deployment this accounts for only a few MB/day of a larger growth we're still tracing. So I'd characterise it as a real, unbounded-per-session leak rather than the sole cause of any given server's memory growth.
Fix
The one-line change matching main (stream.cleanup() after resolveJson) is sufficient for this half on v1.x; cleanup in JSON mode is just this._streamMapping.delete(streamId), so it is safe after the promise is settled. Happy to open a PR against v1.x if that's useful — I see #2588 already targets that branch for the close-time half.
Opened #2747 against v1.x, covering both halves for the shipping line:
the _streamMapping leak: the JSON-ready branch of send() now calls stream.cleanup() after resolving (the equivalent of Implement MCP 2026-07-28 #2286, which never reached v1.x);
Two regression tests, both failing on unpatched v1.x: close-mid-handler settles with 503, and a completed POST leaves _streamMapping empty (currently 2, initialize's entry included). Full suite green; the two unhandled stdio SyntaxErrors reproduce on pristine v1.x and are unrelated.
Activity
KXHXK commentedon Jul 28, 2026
I'd like to work on this. I confirmed there is no linked or open implementation PR.\n\nOn the current main branch, the successful JSON-response path already calls stream.cleanup() after resolving the response, so I would focus the v2 fix and regression coverage on settling an in-flight JSON response during transport shutdown, then verify the remaining lifecycle behavior on �1.x separately.\n\nPlease assign this to me if that scope is available.
axits-lab commentedon Jul 30, 2026
@felixweinberger Happy to take this one too, after the #2562 items.
Reading it as two halves of one lifecycle bug:
send()resolves viaresolveJsonwithout running the mapping'scleanup(so a completed POST leaks its_streamMappingentry untilclose()), whilecleanupdeletes the entry without settling the promise (soclose()during an in-flight JSON-mode POST leaves the request hanging). Fix is to make both paths run the full lifecycle — settle and delete — rather than one each.Since you note it's pre-existing on both
mainandv1.x, should the v1.x fix be a separate PR against that branch, or do you prefer main first and a backport after?axits-lab commentedon Jul 30, 2026
Up as #2584 (draft — one-open-PR cap, #2582 holds it).
One note: only the second half reproduced. The
cleanup-never-settles hang is real and fixed there. The_streamMappingleak doesn't reproduce onmain—send()has calledstream.cleanup()on both branches since #2286 (merged 2026-06-26), and measuring directly gives_streamMapping.size === 0after a completed JSON-mode POST. I left that half alone rather than write a change that does nothing; happy to look again if you're seeing it on a path I didn't hit.opaopa6969 commentedon Aug 29, 2026
The
_streamMappingleak half of this issue is still live onv1.x, which is what currently ships as1.30.0(npm dist-tags→latest: 1.30.0).@axits-lab's measurement was correct for
main:packages/server/src/server/streamableHttp.tsL1251 callsstream.cleanup()in the JSON branch, somainis clean. But that call was never carried tov1.x—src/server/webStandardStreamableHttp.tsL1242-1261 onv1.xstill resolves without cleaning up:Repro (clean install of the published version)
Retained memory
This is not just a stale Map key — the entry retains the whole response body. Heap snapshot diff on a long-running stateful server (transport kept per session,
enableJsonResponse: true):Measured ~14.7 KB retained per
tools/liston a server with a moderate tool catalog. It grows with response size and with session lifetime, so servers that hold one transport per session and answer largetools/listresponses accumulate steadily untilclose().To be precise about what this does and doesn't explain: in our production deployment this accounts for only a few MB/day of a larger growth we're still tracing. So I'd characterise it as a real, unbounded-per-session leak rather than the sole cause of any given server's memory growth.
Fix
The one-line change matching
main(stream.cleanup()afterresolveJson) is sufficient for this half onv1.x;cleanupin JSON mode is justthis._streamMapping.delete(streamId), so it is safe after the promise is settled. Happy to open a PR againstv1.xif that's useful — I see #2588 already targets that branch for the close-time half.lvyanyan commentedon Sep 2, 2026
Opened #2747 against
v1.x, covering both halves for the shipping line:_streamMappingleak: the JSON-ready branch ofsend()now callsstream.cleanup()after resolving (the equivalent of Implement MCP 2026-07-28 #2286, which never reached v1.x);cleanup()now settles the pendingPromise<Response>with a 503 JSON-RPC error, matching the approach drafted for main in fix(server): settle in-flight JSON-mode requests on transport close #2584.Two regression tests, both failing on unpatched v1.x: close-mid-handler settles with 503, and a completed POST leaves
_streamMappingempty (currently2, initialize's entry included). Full suite green; the two unhandled stdioSyntaxErrors reproduce on pristine v1.x and are unrelated.