Summary
When an MCP server closes the connection mid-stream, the streamable HTTP transport (HttpMCPTransport) surfaces the socket error as an unhandled promise rejection, which terminates the Node process (default --unhandled-rejections=throw). onUncaughtError fires and logs the error, but does not prevent the crash — a floating rejection escapes it.
Observed with @ai-sdk/mcp@1.0.50; the same fire-and-forget architecture is present on main (2.x), so it likely affects v6 as well.
Reproduction
createMCPClient({ transport: { type: 'http', url, headers }, onUncaughtError })
const tools = await client.tools({ schemas })
await tools[name].execute(args, { abortSignal })
- While a request is in flight (or just after), the server closes the socket (server restart, upstream timeout, LB idle cut…).
onUncaughtError logs the error(s), then the process crashes:
TypeError: terminated
at Fetch.onAborted (node:internal/deps/undici/undici)
cause: SocketError: other side closed { code: 'UND_ERR_SOCKET' }
TypeError: fetch failed (cause: Error: read ECONNRESET)
node:internal/process/promises:324
triggerUncaughtException(err, true /* fromPromise */); // -> process exits
Root cause
packages/mcp/src/tool/mcp-http-transport.ts:
- The inbound SSE stream is opened fire-and-forget:
void this.openInboundSse() (in start() and after responses), with no .catch().
- On stream error,
openInboundSse routes to this.onerror and calls scheduleInboundSseReconnection(), which runs:
setTimeout(async () => {
if (this.abortController?.signal.aborted) return;
await this.openInboundSse(false, this.lastInboundEventId);
}, delay);
This async callback is not awaited and has no .catch() — any rejection that escapes the inner try/catch (e.g. a stream teardown / abort race when the socket is cut, whose error name is TypeError terminated / ECONNRESET, not AbortError) becomes an unhandled rejection that onUncaughtError never sees.
Expected behavior
Errors from the background inbound SSE stream (initial open, reader loop, and reconnection) should never surface as unhandled rejections — they should be fully funneled to onUncaughtError / onerror. Concretely: attach a .catch() to the fire-and-forget void this.openInboundSse(...) call sites and to the setTimeout reconnection callback, routing any error to onerror.
Impact / current workaround
For a long-running server (a BFF calling an MCP backend), a transient downstream disconnect crashes the whole process — a crash-loop under load. The only consumer-side mitigation is a global process.on('unhandledRejection') that filters socket-drop errors (same approach taken in danny-avila/LibreChat#12812), which is a blunt net rather than a fix.
Happy to open a PR wrapping the fire-and-forget calls if that's welcome.
Summary
When an MCP server closes the connection mid-stream, the streamable HTTP transport (
HttpMCPTransport) surfaces the socket error as an unhandled promise rejection, which terminates the Node process (default--unhandled-rejections=throw).onUncaughtErrorfires and logs the error, but does not prevent the crash — a floating rejection escapes it.Observed with
@ai-sdk/mcp@1.0.50; the same fire-and-forget architecture is present onmain(2.x), so it likely affects v6 as well.Reproduction
createMCPClient({ transport: { type: 'http', url, headers }, onUncaughtError })const tools = await client.tools({ schemas })await tools[name].execute(args, { abortSignal })onUncaughtErrorlogs the error(s), then the process crashes:Root cause
packages/mcp/src/tool/mcp-http-transport.ts:void this.openInboundSse()(instart()and after responses), with no.catch().openInboundSseroutes tothis.onerrorand callsscheduleInboundSseReconnection(), which runs:.catch()— any rejection that escapes the innertry/catch(e.g. a stream teardown / abort race when the socket is cut, whose errornameisTypeErrorterminated/ECONNRESET, notAbortError) becomes an unhandled rejection thatonUncaughtErrornever sees.Expected behavior
Errors from the background inbound SSE stream (initial open, reader loop, and reconnection) should never surface as unhandled rejections — they should be fully funneled to
onUncaughtError/onerror. Concretely: attach a.catch()to the fire-and-forgetvoid this.openInboundSse(...)call sites and to thesetTimeoutreconnection callback, routing any error toonerror.Impact / current workaround
For a long-running server (a BFF calling an MCP backend), a transient downstream disconnect crashes the whole process — a crash-loop under load. The only consumer-side mitigation is a global
process.on('unhandledRejection')that filters socket-drop errors (same approach taken in danny-avila/LibreChat#12812), which is a blunt net rather than a fix.Happy to open a PR wrapping the fire-and-forget calls if that's welcome.