Skip to content

[v2] Legacy Streamable HTTP reconnects continue after the client request times out #2615

Description

@teamleaderleo

What happened?

In @modelcontextprotocol/client@2.0.0, a request using the legacy 2025-11-25 Streamable HTTP path can continue issuing resumable GET requests after the request has already timed out.

The sequence is:

  1. A default v2 Client negotiates protocol version 2025-11-25.

  2. The client sends a request with a short timeout.

  3. The POST response starts an SSE stream, provides an event ID and retry interval, and closes.

  4. The transport begins resuming the stream through GET requests carrying Last-Event-ID.

  5. The request timeout expires.

  6. The request promise rejects with Request timed out, and the client sends notifications/cancelled.

  7. Despite that settlement, the transport continues issuing resumed GET requests.

  8. If a later resumed GET contains the original JSON-RPC response, the client reports Received a response for an unknown message ID.

This reproduced both against the current main source revision and the published @modelcontextprotocol/client@2.0.0 package on Node 20, 22, and 24.

The behavior appears to result from split lifecycle ownership:

  • the protocol layer owns request timeout, cancellation and response-handler cleanup;

  • the transport owns the SSE reconnect chain;

  • legacy requests do not give that reconnect chain a request-scoped abort signal.

This is separate from #2098. That issue concerns failed reconnect exhaustion leaving the standalone response channel unusable, whereas this report concerns a reconnect chain remaining active after its originating request has already settled through timeout.

Runnable published-package reproducer:

https://github.com/teamleaderleo/fieldwork/tree/main/campaigns/0004-mcp-streamable-http-reconnect/lanes/L02-reconnect-budget-terminal-state/probe

Published-package Node 20/22/24 run:

https://github.com/teamleaderleo/fieldwork/actions/runs/30479661274

Source-tree Node 20/22/24 run:

https://github.com/teamleaderleo/typescript-sdk/actions/runs/30479313714

The compliant 2026-07-28 Streamable HTTP path didn't reproduce this mechanism because it does not use the legacy GET/Last-Event-ID resumption path.

What did you expect?

When a request settles through timeout or caller cancellation, any reconnect chain owned by that request should stop.

A response racing with local cancellation should either be ignored or handled according to an explicitly documented cancellation-race policy, rather than being treated as a generic response for an unknown message ID.

I am filing this before proposing an implementation because the appropriate ownership boundary requires maintainer guidance. Possible approaches include giving legacy request streams a request-lifecycle abort signal, adding explicit per-request reconnect disposal, or providing a transport teardown hook when the protocol layer settles a request.

Code to reproduce

See the runnable published-package reproducer linked above.

SDK version

@modelcontextprotocol/client@2.0.0 Source revision also tested: cc4b416

Area

Transports

Metadata

Metadata

Assignees

No one assigned

    Labels

    v2Ideas, 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