Skip to content

Release the SSE request when a remote MCP connection closes - #1716

Open
xav-ie wants to merge 1 commit into
UsefulSoftwareCo:mainfrom
xav-ie:fix/mcp-abort-releases-response-body
Open

Release the SSE request when a remote MCP connection closes#1716
xav-ie wants to merge 1 commit into
UsefulSoftwareCo:mainfrom
xav-ie:fix/mcp-abort-releases-response-body

Conversation

@xav-ie

@xav-ie xav-ie commented Aug 21, 2026

Copy link
Copy Markdown

The bug

fetchFromHttpClientLayer wires the caller's AbortSignal into a Promise.race on the response promise only. The body it hands the SDK is Stream.toReadableStream(response.stream) — a detached fiber that outlives the effect that promise settles — so nothing connects the signal to the body.

sequenceDiagram
    participant SDK as MCP SDK
    participant A as fetch adapter
    participant S as MCP server

    SDK->>A: fetch(GET, signal)
    A->>S: GET /mcp
    S-->>A: SSE stream (never ends)
    A-->>SDK: Response(detached body)

    SDK->>A: close() aborts signal
    Note over A: signal reaches the promise,<br/>not the body
    A--xS: GET still in flight
Loading

For a short POST that is invisible. For streamable-http's long-lived SSE GET it means close() reaches nothing: one abandoned request per health check, discovery, or invocation.

Why it bites

Under Bun each abandoned request also holds one of the 256 BUN_CONFIG_MAX_HTTP_REQUESTS slots. Once a long-running process exhausts the pool, every outbound fetch queues forever and every connection reports MCP discovery timed out after 15000ms — while curl against the same endpoint answers in ~30 ms. Connections with a cached catalog log plugin returned an incomplete tool catalog and keep serving stale data, so the first visible symptom is usually one unrelated-looking connection going degraded.

The fix

Interrupt the response stream at the source when the signal aborts:

const stream =
  init?.signal == null
    ? response.stream
    : Stream.interruptWhen(response.stream, awaitAbort(init.signal));

Cancelling the ReadableStream instead does not work: the SDK holds a locked reader on exactly the SSE channel that leaks, so body.cancel() rejects with TypeError: Invalid state: ReadableStream is locked. The POST bodies cancel fine; the one that matters does not.

Separate from #1654 — that fixes the interrupted-connect path, this leaks on the successful path.

Verification

New connection-socket-release.test.ts, asserting on a new inFlightRequests() on the test server. sessionCount() cannot see this (session gone, request not), and counting sockets cannot either, since a keep-alive pool holds idle sockets open regardless.

1 connect/close 5 connect/close
before 1 leaked 5 leaked
after 0 0

Confirmed to fail with the connection.ts change stashed.

format:check, lint, typecheck (44/44) clean. test: 37/38 packages — @executor-js/mcp-apps-shell fails only on mcp-app.browser.test.ts (no Chromium at /usr/bin/google-chrome on this machine), unrelated.

@xav-ie xav-ie changed the title fix(mcp): release the SSE request when a remote connection closes Release the SSE request when a remote MCP connection closes Aug 21, 2026
@xav-ie
xav-ie force-pushed the fix/mcp-abort-releases-response-body branch from 271d5ea to 15878ae Compare August 21, 2026 19:19
The fetch adapter wired the caller's AbortSignal only to the response
promise, never to the body, so streamable-http's long-lived GET stayed in
flight after close(): one abandoned request per dial, each holding one of
Bun's 256 concurrent-request slots until discovery times out everywhere.
@xav-ie
xav-ie force-pushed the fix/mcp-abort-releases-response-body branch from 15878ae to 9f76528 Compare August 21, 2026 19:30
@xav-ie
xav-ie marked this pull request as ready for review August 21, 2026 19:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant