fix: handle EPIPE and ERR_STREAM_DESTROYED in StdioServerTransport#1679
fix: handle EPIPE and ERR_STREAM_DESTROYED in StdioServerTransport#1679kai-agent-free wants to merge 2 commits intomodelcontextprotocol:mainfrom
Conversation
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
🦋 Changeset detectedLatest commit: 9a96b23 The changes in this PR will be included in the next version bump. This PR includes changesets to release 4 packages
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 |
@modelcontextprotocol/client
@modelcontextprotocol/server
@modelcontextprotocol/express
@modelcontextprotocol/hono
@modelcontextprotocol/node
commit: |
travisbreaks
left a comment
There was a problem hiding this comment.
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:
send()callswrite(), which returnsfalse(backpressure)send()registersthis._stdout.once('drain', resolve)and returns the promise- The client disconnects, stdout emits
errorwith EPIPE _onstdouterrorcallsclose(), which callsthis._stdout.off('error', ...)- The
drainevent never fires. The promise fromsend()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.
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:Added
_onstdouterrorlistener — registered onstdoutinstart(), catchesEPIPEandERR_STREAM_DESTROYEDerrors and callsclose()gracefully instead of letting them propagate as unhandled exceptions. Other errors are forwarded toonerror.Added error handling in
send()— checksstdout.writablebefore writing, and wrapswrite()in try/catch for synchronous throw protection.Cleanup in
close()— removes the stdout error listener to prevent leaks.Tests
Added 5 new tests:
All existing tests continue to pass.
Fixes #1564