Skip to content

[@ai-sdk/mcp] Streamable HTTP transport crashes the process (unhandled rejection) on mid-stream server disconnect #16541

Description

@jouve

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

  1. createMCPClient({ transport: { type: 'http', url, headers }, onUncaughtError })
  2. const tools = await client.tools({ schemas })
  3. await tools[name].execute(args, { abortSignal })
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Type

    Fields

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions