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:
-
A default v2 Client negotiates protocol version 2025-11-25.
-
The client sends a request with a short timeout.
-
The POST response starts an SSE stream, provides an event ID and retry interval, and closes.
-
The transport begins resuming the stream through GET requests carrying Last-Event-ID.
-
The request timeout expires.
-
The request promise rejects with Request timed out, and the client sends notifications/cancelled.
-
Despite that settlement, the transport continues issuing resumed GET requests.
-
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
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:
A default v2 Client negotiates protocol version 2025-11-25.
The client sends a request with a short timeout.
The POST response starts an SSE stream, provides an event ID and retry interval, and closes.
The transport begins resuming the stream through GET requests carrying Last-Event-ID.
The request timeout expires.
The request promise rejects with Request timed out, and the client sends notifications/cancelled.
Despite that settlement, the transport continues issuing resumed GET requests.
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