Skip to content

Streamable HTTP: JSON-response-mode stream mappings never complete their lifecycle #2559

Open

Description

@felixweinberger

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.

Activity

  1. KXHXK commented on Jul 28, 2026

    @KXHXK

    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.

  2. axits-lab commented on Jul 30, 2026

    @axits-lab

    @felixweinberger Happy to take this one too, after the #2562 items.

    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?

  3. axits-lab commented on Jul 30, 2026

    @axits-lab

    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.

  4. added
    bugSomething isn't working
    P2Moderate issues affecting some users, edge cases, potentially valuable feature
    ready for workEnough information for someone to start working on
    on Aug 17, 2026
  5. added
    v2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes
    v1Issues / PRs related to v1.x
    on Aug 18, 2026
  6. opaopa6969 commented on Aug 29, 2026

    @opaopa6969

    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(new Response(JSON.stringify(responses[0]), { status: 200, headers }));
        } else {
            stream.resolveJson(new Response(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
    import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
    
    const PROTOCOL = '2025-06-18';
    const post = (body, sid) => new Request('http://localhost/mcp', {
      method: 'POST',
      headers: {
        'content-type': 'application/json',
        accept: 'application/json, text/event-stream',
        ...(sid ? { 'mcp-session-id': sid, 'mcp-protocol-version': PROTOCOL } : {})
      },
      body: JSON.stringify(body)
    });
    
    const t = new WebStandardStreamableHTTPServerTransport({
      sessionIdGenerator: () => 'repro-session',
      enableJsonResponse: true
    });
    t.onmessage = m => {
      if (m.id === undefined) return;
      t.send({ jsonrpc: '2.0', id: m.id, result: m.method === 'initialize'
        ? { protocolVersion: PROTOCOL, capabilities: {}, serverInfo: { name: 'repro', version: '0' } }
        : { ok: true } });
    };
    await t.start();
    
    const init = await t.handleRequest(post({
      jsonrpc: '2.0', id: 0, method: 'initialize',
      params: { protocolVersion: PROTOCOL, capabilities: {}, clientInfo: { name: 'repro', version: '0' } }
    }));
    await init.text();
    const sid = init.headers.get('mcp-session-id');
    await t.handleRequest(post({ jsonrpc: '2.0', method: 'notifications/initialized' }, sid));
    
    for (let i = 1; i <= 20; i++) {
      const r = await t.handleRequest(post({ jsonrpc: '2.0', id: i, method: 'ping', params: {} }, sid));
      await r.text();
    }
    
    console.log('_streamMapping.size after 21 completed JSON-mode POSTs:', t._streamMapping.size);
    await t.close();
    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):

    _streamMapping (Map) -> resolveJson closure -> Promise -> _Response -> source (JSON string)
    

    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.

  7. lvyanyan commented on Sep 2, 2026

    @lvyanyan

    Opened #2747 against v1.x, covering both halves for the shipping line:

    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.

  8. NICEXAI commented on Oct 29, 2026

    @NICEXAI

    Confirming this is still present in the latest v1 release, 1.32.2 (v1.x @ 3b73021), and that #2747 fixes it.

    We found it while investigating repeated V8 out-of-memory crashes in the main process of a long-running Electron app. The app runs a stateful StreamableHTTPServerTransport with enableJsonResponse: true and only closes a session on DELETE or shutdown. So every tool result stays reachable from _streamMapping for as long as its session lives, and the crashes came after about a day of heavy tool use. This leak is the retainer we have confirmed so far.

    Measurement on Node v24.18.0. The SDK's own Client + StreamableHTTPClientTransport call StreamableHTTPServerTransport on node:http. Each tool result is 512 KB. Heap is measured after forced GC:

    build calls _streamMapping.size retained freed by _streamMapping.clear() alone
    1.32.2 (npm) 100 103 52.7 MB 50.5 MB
    1.32.2 (npm) 200 203 102.1 MB 101.0 MB
    1.32.2 (npm) 400 403 203.2 MB 201.9 MB
    1.32.2 + #2747 200 1 1.0 MB 0 MB

    Retention is linear in the number of calls, one full response body per call, and _streamMapping is the only retainer. With #2747 the one remaining entry is the client's standalone GET stream.

    #2747 cherry-picks cleanly onto 1.32.2. On that tree test/server/streamableHttp.test.ts passes (189/189), and the PR's two new tests fail on unpatched 1.32.2 (expected 2 to be +0, and a 5 s timeout in the close-mid-handler case).

    Could #2747 be reviewed and shipped in a 1.32.x patch? Its only red check is Prettier on the changeset file. I left the exact fix as a suggestion on the PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Moderate issues affecting some users, edge cases, potentially valuable featurebugSomething isn't workingready for workEnough information for someone to start working onv1Issues / PRs related to v1.xv2Ideas, requests and plans for v2 of the SDK which will incorporate major changes and fixes

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions