Skip to content

fix: prevent unhandled rejections on close and detect stdin EOF#1853

Open
EtanHey wants to merge 3 commits intomodelcontextprotocol:mainfrom
EtanHey:fix/connection-closed-unhandled-rejection
Open

fix: prevent unhandled rejections on close and detect stdin EOF#1853
EtanHey wants to merge 3 commits intomodelcontextprotocol:mainfrom
EtanHey:fix/connection-closed-unhandled-rejection

Conversation

@EtanHey
Copy link
Copy Markdown

@EtanHey EtanHey commented Apr 5, 2026

Summary

Fixes two related bugs that together cause the "MCP error -32000: Connection closed" crash (#1049):

  • Defer pending response handler rejections in Protocol._onclose() — When the transport closes with in-flight requests, _onclose() rejects all pending promises. Previously this happened synchronously, which could trigger unhandled promise rejections if callers hadn't attached .catch() handlers yet (especially on Node.js 24's strict unhandled-rejection behavior). Now rejections are deferred to the next microtask via Promise.resolve().then(), giving callers time to attach handlers.

  • Detect stdin EOF in StdioServerTransport — The server transport only listened for data and error events on stdin, but never end. When the client process exits, stdin emits end (EOF), but the server had no listener for it — leaving the server running indefinitely with pending requests that would never resolve. Now StdioServerTransport listens for the end event and triggers a clean close().

What

File Change
packages/core/src/shared/protocol.ts Defer handler(error) calls in _onclose() to next microtask; wrap in try/catch routing to onerror
packages/server/src/server/stdio.ts Add _onend handler for stdin end event; register/unregister in start()/close()

Why

When a stdio-based MCP server's client process exits unexpectedly:

  1. The server has no way to detect the disconnection (no stdin EOF listener)
  2. If the server does eventually close, all pending request promises are rejected synchronously
  3. If any of those promises don't have .catch() attached yet, Node.js triggers unhandledRejection
  4. On Node.js 24 (default --unhandled-rejections=throw), this kills the entire process

This crashes BrainLayer, VoiceLayer, and other MCP servers in production environments daily.

Test Plan

  • New test: should reject pending requests with ConnectionClosed when transport closes
  • New test: should not cause unhandled promise rejections when transport closes with pending requests
  • New test: should close transport when stdin emits end (EOF)
  • New test: should not fire onclose twice when stdin EOF followed by explicit close()
  • New test: should process remaining messages before closing on stdin EOF
  • All existing tests pass (491 core + 58 server + 350 client + integration)

Related

🤖 Generated with Claude Code

Two fixes for the "Connection closed" crash (MCP error -32000):

1. Protocol._onclose() now defers pending response handler rejections
   to the next microtask via Promise.resolve().then(), giving callers
   time to attach .catch() handlers. This prevents Node.js from
   triggering unhandled promise rejections when the transport closes
   unexpectedly while requests are in-flight.

2. StdioServerTransport now listens for the stdin 'end' event (EOF)
   and triggers a clean close(). Previously, when the client process
   exited, the server had no way to detect the disconnection, leaving
   it running indefinitely with pending requests that would never
   resolve.

Together these fixes address the crash reported in modelcontextprotocol#1049 where
stdio-based MCP servers would die with "MCP error -32000: Connection
closed" unhandled rejections when the client process exits.

Also addresses the root cause described in modelcontextprotocol#392 (promise/async
handling causes unhandled rejections).

Fixes modelcontextprotocol#1049
Refs modelcontextprotocol#392
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 5, 2026

🦋 Changeset detected

Latest commit: 10c6e6c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@modelcontextprotocol/core Patch
@modelcontextprotocol/server Patch
@modelcontextprotocol/node Patch
@modelcontextprotocol/express Patch
@modelcontextprotocol/fastify Patch
@modelcontextprotocol/hono Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 5, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/@modelcontextprotocol/client@1853

@modelcontextprotocol/server

npm i https://pkg.pr.new/@modelcontextprotocol/server@1853

@modelcontextprotocol/express

npm i https://pkg.pr.new/@modelcontextprotocol/express@1853

@modelcontextprotocol/fastify

npm i https://pkg.pr.new/@modelcontextprotocol/fastify@1853

@modelcontextprotocol/hono

npm i https://pkg.pr.new/@modelcontextprotocol/hono@1853

@modelcontextprotocol/node

npm i https://pkg.pr.new/@modelcontextprotocol/node@1853

commit: 10c6e6c

The handler variable using PromiseRejectionEvent was dead code — the
test already uses the Node.js process.on('unhandledRejection') handler.
Removing it fixes the TS2304 type error in CI.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@EtanHey EtanHey requested a review from a team as a code owner April 5, 2026 16:32
@EtanHey
Copy link
Copy Markdown
Author

EtanHey commented Apr 5, 2026

Note: I noticed PR #1814 also touches stdin EOF detection and Protocol._onclose(). This PR takes a complementary approach — deferring handler rejections via Promise.resolve().then() and adding a simpler stdin 'end' listener. Happy to rebase onto #1814 if maintainers prefer to land that first, or we can coordinate to avoid duplicate behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@travisbreaks travisbreaks left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good fix for a real production issue. The microtask deferral pattern and stdin EOF detection are both needed. Notes:

1. Microtask timing changes observable behavior

The Promise.resolve().then() deferral means pending request rejections now happen asynchronously after _onclose() returns. Any code that previously assumed rejection was synchronous with close will now have a subtle ordering difference. The AbortController.abort() calls in the same finally block still run synchronously, so there's a timing gap between abort and rejection. Worth documenting this in the changeset.

2. Error swallowing in _onend

this.close().catch(() => {
    // Ignore errors during close
});

Silent error swallowing is understandable on the shutdown path, but routing to _onerror instead of () => {} would preserve observability for server authors who have error handlers configured:

this.close().catch((err) => {
    this.onerror?.(err instanceof Error ? err : new Error(String(err)));
});

3. Timer-based microtask flush in tests

The setTimeout(resolve, 50) approach works but is technically racy under heavy CI load. await Promise.resolve(); await Promise.resolve(); (chained) would be a more deterministic microtask flush. Minor, but avoids flaky test failures.

4. Missing test for handler-throws path

The try/catch in the microtask routes handler errors to _onerror:

try {
    handler(error);
} catch (handlerError) {
    this._onerror(handlerError instanceof Error ? handlerError : new Error(String(handlerError)));
}

But no test exercises this path (where handler(error) itself throws). A test that installs a throwing handler and verifies _onerror receives the thrown error would complete the coverage.


The 5 tests are well-structured, especially the idempotent close test and the message-processing-before-EOF test. Good work.

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.

[MCP-SDK] stdio client crashes with "MCP error -32000: Connection closed" when spawned server exits unexpectedly

2 participants