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.

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