Skip to content

fix: handle EPIPE and ERR_STREAM_DESTROYED in StdioServerTransport#1679

Open
kai-agent-free wants to merge 2 commits intomodelcontextprotocol:mainfrom
kai-agent-free:fix/stdio-epipe-handling
Open

fix: handle EPIPE and ERR_STREAM_DESTROYED in StdioServerTransport#1679
kai-agent-free wants to merge 2 commits intomodelcontextprotocol:mainfrom
kai-agent-free:fix/stdio-epipe-handling

Conversation

@kai-agent-free
Copy link

Problem

When an MCP client disconnects abruptly (closes stdin/stdout pipes), StdioServerTransport.send() throws an unhandled EPIPE error that fatally crashes the Node.js process. This is a DoS vector for any MCP server using stdio transport.

Solution

Minimal change to StdioServerTransport:

  1. Added _onstdouterror listener — registered on stdout in start(), catches EPIPE and ERR_STREAM_DESTROYED errors and calls close() gracefully instead of letting them propagate as unhandled exceptions. Other errors are forwarded to onerror.

  2. Added error handling in send() — checks stdout.writable before writing, and wraps write() in try/catch for synchronous throw protection.

  3. Cleanup in close() — removes the stdout error listener to prevent leaks.

Tests

Added 5 new tests:

  • EPIPE on stdout triggers graceful close
  • ERR_STREAM_DESTROYED on stdout triggers graceful close
  • Non-EPIPE stdout errors forwarded to onerror
  • send() rejects when stdout is not writable
  • stdout error listener removed on close

All existing tests continue to pass.

Fixes #1564

When a client disconnects abruptly, stdout.write() can emit an EPIPE
or ERR_STREAM_DESTROYED error that crashes the server process. This
adds:

- An error listener on stdout that catches EPIPE/ERR_STREAM_DESTROYED
  and triggers graceful close() instead of crashing
- try/catch in send() for synchronous write errors
- A writable check before writing
- Cleanup of the stdout error listener in close()

Fixes modelcontextprotocol#1564
@kai-agent-free kai-agent-free requested a review from a team as a code owner March 13, 2026 11:35
@changeset-bot
Copy link

changeset-bot bot commented Mar 13, 2026

🦋 Changeset detected

Latest commit: 9a96b23

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

This PR includes changesets to release 4 packages
Name Type
@modelcontextprotocol/server Patch
@modelcontextprotocol/express Patch
@modelcontextprotocol/hono Patch
@modelcontextprotocol/node 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

pkg-pr-new bot commented Mar 13, 2026

Open in StackBlitz

@modelcontextprotocol/client

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/client@1679

@modelcontextprotocol/server

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/server@1679

@modelcontextprotocol/express

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/express@1679

@modelcontextprotocol/hono

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/hono@1679

@modelcontextprotocol/node

npm i https://pkg.pr.new/modelcontextprotocol/typescript-sdk/@modelcontextprotocol/node@1679

commit: 6808f97

Copy link

@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.

This fixes a real problem. EPIPE on abrupt client disconnect is a known crash vector and the issue (#1564) has a clean repro. A few things worth addressing before merge:

1. close() is not idempotent, but can be called twice

If send() catches a synchronous EPIPE and calls void this.close(), then the stdout error event fires for the same underlying fault, _onstdouterror calls void this.close() again. The close() method calls this.onclose?.() unconditionally and removes listeners that may already be removed. In most Node.js scenarios a synchronous throw from write() won't also emit error, but this isn't guaranteed across all Writable implementations (custom streams, edge cases with destroy()). A _closed guard in close() would make this bulletproof:

async close(): Promise<void> {
    if (this._closed) return;
    this._closed = true;
    // ... rest
}

This is a small change and removes a category of bugs entirely.

2. Pending drain promise can hang forever

Consider this sequence:

  1. send() calls write(), which returns false (backpressure)
  2. send() registers this._stdout.once('drain', resolve) and returns the promise
  3. The client disconnects, stdout emits error with EPIPE
  4. _onstdouterror calls close(), which calls this._stdout.off('error', ...)
  5. The drain event never fires. The promise from send() never settles.

The caller is now holding a promise that will never resolve or reject. If they're awaiting it (which is the normal pattern), that async context leaks. You probably want the error listener to also reject any pending send, or the close() path should emit a synthetic drain / clean up pending writes. One approach: track the pending reject in send() and call it from close().

3. Test mocks don't exercise the drain + error interaction

The tests cover the happy paths well (EPIPE triggers close, non-EPIPE forwards, writable check). But the scenario in point 2 above is the one most likely to cause subtle production issues, and it's not tested. A test where write() returns false and then an EPIPE error is emitted would validate the fix.

4. Minor: the em dash in the comment

Line with // Client disconnected — close gracefully instead of crashing. uses an em dash. The SDK's existing comments don't use them. Nitpick, but consistency is good.

Overall direction is right. The writable guard, the error categorization (EPIPE/ERR_STREAM_DESTROYED as graceful, everything else forwarded), and the listener cleanup in close() are all correct patterns. The idempotency and drain-hang issues are the substantive ones to address.

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.

Unhandled 'EPIPE' in 'StdioServerTransport' causes fatal process crash on client disconnect

2 participants