#11763

MCP HTTP resumes response streams after tool calls are cancelled or time out

Openlishuangqiangopened 4d ago

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 2

Each 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:

  1. Complete MCP initialization. Set openGetStream: false to isolate response resumption from the independent notification stream.

  2. Start client.callTool().

  3. Return a complete SSE priming event, then end the response normally:

    id: initial
    retry: 100
    data:
    
  4. Abort the call before its first resumption, or set timeoutMs: 40.

  5. 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:

ScenarioCall outcomeNew resumption GETs after settlementLate error
AbortAbortError3Unknown request
TimeoutMcpTimeoutError3Unknown request
No cancellationSuccess after resumption0None
Close the whole connectionMcpConnectionClosedError0None

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

0

No activity yet.