What happened?
MCP Streamable HTTP continues resuming a tool's response stream after the tool call has been cancelled or timed out. The caller has already received AbortError or McpTimeoutError, and the server has received notifications/cancelled, but Pi still starts new resumption GETs. A late result then produces:
Received response for unknown MCP request 2Each test made only one tools/call POST. This is unnecessary response-stream recovery, not repeated tool execution.
Steps to reproduce
Using the real McpClient and StreamableHttpTransport with a local HTTP fixture:
-
Complete MCP initialization. Set
openGetStream: falseto isolate response resumption from the independent notification stream. -
Start
client.callTool(). -
Return a complete SSE priming event, then end the response normally:
id: initial retry: 100 data: -
Abort the call before its first resumption, or set
timeoutMs: 40. -
Record call settlement, cancellation notifications, and subsequent GETs. Have the fixture return further priming events on the first two GETs and the original result on the third.
Three runs against the unmodified implementation gave the same results:
| Scenario | Call outcome | New resumption GETs after settlement | Late error |
|---|---|---|---|
| Abort | AbortError | 3 | Unknown request |
| Timeout | McpTimeoutError | 3 | Unknown request |
| No cancellation | Success after resumption | 0 | None |
| Close the whole connection | McpConnectionClosedError | 0 | None |
The three GETs reflect the fixture's response sequence, not a universal retry count. A subsequent ping succeeds after both abort and timeout. Cancellation is best-effort on the server; the problem here is that the client initiates new recovery requests after local cancellation has completed.
These are local HTTP integration tests without model APIs or external services, not full Pi TUI or third-party-server tests.
Expected behavior
Stop recovery for an abandoned request without closing the shared transport or disrupting other calls and the independent notification stream.
In client.ts, removing the pending request aborts its send controller. However, streamable-http.ts starts consumeResponseStream() without that signal; its recovery loop checks whether the result arrived or the transport closed, not whether the request ended.
I plan to propagate request cancellation through the response stream's lifecycle:
- End the reconnect wait and prevent later resumption GETs.
- Stop the request's active response reader or resumption GET and release its resources.
- Avoid dispatching late results or synthetic errors for the removed request.
- Preserve other concurrent requests and the notification stream.
A temporary copy with cancellation checks before resumption reduced subsequent GETs from 3 to 0 for abort and timeout, removed the late errors, and preserved successful normal resumption. This validates the cause, but is not a complete fix: cancellation during an active read or an already-started GET still needs coverage.
I will add regression tests for normal resumption, cancellation during backoff and active reads, timeout, concurrent-request isolation, and full transport close. Assertions will use request traces and resource completion rather than precise timing thresholds.
I am confident I can complete this focused implementation and its regression tests. Would you be willing to give this work an lgtm so I can submit the PR?
Version
Source commit 58c0b02ce552adab76014e7ed712ab7981359aa8; Node.js 24.20.0; macOS. Dynamic tests used that local revision. Upstream commit 676af1d777ef6f04b6bc8daf4cf3b9b1799cff4d still has the same missing signal propagation (source inspection only).
Activity
0No activity yet.