From 75c8800236b7e56ecccb01cba356f1cb21ca1b76 Mon Sep 17 00:00:00 2001 From: Maxwell Calkin <101308415+MaxwellCalkin@users.noreply.github.com> Date: Sun, 8 Mar 2026 01:53:14 -0500 Subject: [PATCH] fix: handle EPIPE errors in StdioServerTransport gracefully When a client disconnects abruptly (closes stdio pipes before the server can write a response), stdout.write() throws an unhandled EPIPE error that crashes the entire Node.js process. This commit: - Adds an error event listener on stdout that forwards errors to onerror and triggers a clean close() - Wraps stdout.write() in try/catch in send() to catch synchronous write errors and reject the promise instead of crashing - Removes the stdout error listener in close() to prevent leaks Fixes #1564 --- .changeset/fix-stdio-epipe-crash.md | 5 ++ packages/server/src/server/stdio.ts | 20 ++++++-- packages/server/test/server/stdio.test.ts | 61 +++++++++++++++++++++++ 3 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 .changeset/fix-stdio-epipe-crash.md diff --git a/.changeset/fix-stdio-epipe-crash.md b/.changeset/fix-stdio-epipe-crash.md new file mode 100644 index 000000000..cde1f5433 --- /dev/null +++ b/.changeset/fix-stdio-epipe-crash.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +Handle EPIPE errors in StdioServerTransport gracefully instead of crashing. When a client disconnects abruptly, the transport now catches stdout write errors, forwards them to `onerror`, and closes cleanly. diff --git a/packages/server/src/server/stdio.ts b/packages/server/src/server/stdio.ts index 562c6861c..710ed0be2 100644 --- a/packages/server/src/server/stdio.ts +++ b/packages/server/src/server/stdio.ts @@ -37,6 +37,10 @@ export class StdioServerTransport implements Transport { _onerror = (error: Error) => { this.onerror?.(error); }; + _onstdouterror = (error: Error) => { + this.onerror?.(error); + void this.close(); + }; /** * Starts listening for messages on `stdin`. @@ -51,6 +55,7 @@ export class StdioServerTransport implements Transport { this._started = true; this._stdin.on('data', this._ondata); this._stdin.on('error', this._onerror); + this._stdout.on('error', this._onstdouterror); } private processReadBuffer() { @@ -72,6 +77,7 @@ export class StdioServerTransport implements Transport { // Remove our event listeners first this._stdin.off('data', this._ondata); this._stdin.off('error', this._onerror); + this._stdout.off('error', this._onstdouterror); // Check if we were the only data listener const remainingDataListeners = this._stdin.listenerCount('data'); @@ -87,12 +93,16 @@ export class StdioServerTransport implements Transport { } send(message: JSONRPCMessage): Promise { - return new Promise(resolve => { + return new Promise((resolve, reject) => { const json = serializeMessage(message); - if (this._stdout.write(json)) { - resolve(); - } else { - this._stdout.once('drain', resolve); + try { + if (this._stdout.write(json)) { + resolve(); + } else { + this._stdout.once('drain', resolve); + } + } catch (error) { + reject(error); } }); } diff --git a/packages/server/test/server/stdio.test.ts b/packages/server/test/server/stdio.test.ts index 8b1f234b9..3e789c631 100644 --- a/packages/server/test/server/stdio.test.ts +++ b/packages/server/test/server/stdio.test.ts @@ -102,3 +102,64 @@ test('should read multiple messages', async () => { await finished; expect(readMessages).toEqual(messages); }); + +test('should handle stdout write errors gracefully', async () => { + const brokenOutput = new Writable({ + write(_chunk, _encoding, callback) { + callback(new Error('write EPIPE')); + } + }); + + const server = new StdioServerTransport(input, brokenOutput); + + const errors: Error[] = []; + server.onerror = error => { + errors.push(error); + }; + + let didClose = false; + server.onclose = () => { + didClose = true; + }; + + await server.start(); + + const message: JSONRPCMessage = { + jsonrpc: '2.0', + id: 1, + result: {} + }; + + // The send itself should resolve (write returns true before async error), + // but the error handler on the stream should fire and trigger close. + await server.send(message); + + // Allow the async error callback to fire + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(errors.length).toBeGreaterThan(0); + expect(errors[0]!.message).toContain('EPIPE'); + expect(didClose).toBe(true); +}); + +test('should handle synchronous stdout write throws gracefully', async () => { + const throwingOutput = new Writable({ + write() { + throw new Error('write EPIPE'); + } + }); + + const server = new StdioServerTransport(input, throwingOutput); + server.onerror = () => {}; + + await server.start(); + + const message: JSONRPCMessage = { + jsonrpc: '2.0', + id: 1, + result: {} + }; + + // send() should reject instead of crashing the process + await expect(server.send(message)).rejects.toThrow('EPIPE'); +});