From f6ab2ac5d272bf840578b456eb5f6cb738794374 Mon Sep 17 00:00:00 2001 From: OpSpawn Date: Sat, 21 Feb 2026 20:16:27 +0000 Subject: [PATCH 1/2] fix: handle EPIPE errors in StdioServerTransport to prevent crash on client disconnect When an MCP client disconnects abruptly before the server finishes writing its response, stdout.write() throws an unhandled EPIPE error that crashes the entire server process. This commit adds proper error handling for the stdout stream in StdioServerTransport: - Register an 'error' event listener on stdout during start(), matching the existing pattern for stdin error handling - Clean up the stdout error listener in close() - Reject the send() promise when a write error occurs during backpressure (when waiting for 'drain') This ensures EPIPE and other stdout write errors are routed through the transport's onerror callback instead of becoming unhandled exceptions. Fixes #1564 Signed-off-by: opspawn Signed-off-by: OpSpawn --- packages/server/src/server/stdio.ts | 5 +- packages/server/test/server/stdio.test.ts | 65 ++++++++++++++++++++++- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/packages/server/src/server/stdio.ts b/packages/server/src/server/stdio.ts index 562c6861c..ed139b2eb 100644 --- a/packages/server/src/server/stdio.ts +++ b/packages/server/src/server/stdio.ts @@ -51,6 +51,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._onerror); } private processReadBuffer() { @@ -72,6 +73,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._onerror); // Check if we were the only data listener const remainingDataListeners = this._stdin.listenerCount('data'); @@ -87,12 +89,13 @@ 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); + this._stdout.once('error', reject); } }); } diff --git a/packages/server/test/server/stdio.test.ts b/packages/server/test/server/stdio.test.ts index 8b1f234b9..7a9f9320c 100644 --- a/packages/server/test/server/stdio.test.ts +++ b/packages/server/test/server/stdio.test.ts @@ -1,4 +1,4 @@ -import { Readable, Writable } from 'node:stream'; +import { Readable, Writable, PassThrough } from 'node:stream'; import type { JSONRPCMessage } from '@modelcontextprotocol/core'; import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/core'; @@ -102,3 +102,66 @@ test('should read multiple messages', async () => { await finished; expect(readMessages).toEqual(messages); }); + +test('should forward stdout errors to onerror', async () => { + const server = new StdioServerTransport(input, output); + + const errorReceived = new Promise(resolve => { + server.onerror = error => { + resolve(error); + }; + }); + + await server.start(); + + // Simulate an EPIPE error on stdout + const epipeError = new Error('write EPIPE'); + (epipeError as NodeJS.ErrnoException).code = 'EPIPE'; + output.destroy(epipeError); + + const receivedError = await errorReceived; + expect(receivedError.message).toBe('write EPIPE'); +}); + +test('should not crash when stdout emits error after client disconnect', async () => { + // Create a writable that will emit an EPIPE error on write + const brokenOutput = new Writable({ + write(_chunk, _encoding, callback) { + const error = new Error('write EPIPE') as NodeJS.ErrnoException; + error.code = 'EPIPE'; + callback(error); + } + }); + + const server = new StdioServerTransport(input, brokenOutput); + + const errors: Error[] = []; + server.onerror = error => { + errors.push(error); + }; + + await server.start(); + + const message: JSONRPCMessage = { + jsonrpc: '2.0', + id: 1, + result: {} + }; + + // This should not throw an unhandled error + await expect(server.send(message)).rejects.toThrow('write EPIPE'); + await server.close(); +}); + +test('should clean up stdout error listener on close', async () => { + const server = new StdioServerTransport(input, output); + server.onerror = () => {}; + + await server.start(); + const listenersBeforeClose = output.listenerCount('error'); + + await server.close(); + const listenersAfterClose = output.listenerCount('error'); + + expect(listenersAfterClose).toBeLessThan(listenersBeforeClose); +}); From e1913ee86fb26c116d4c4ff7ad75e49a8a8e8c38 Mon Sep 17 00:00:00 2001 From: OpSpawn Date: Sat, 21 Feb 2026 21:50:01 +0000 Subject: [PATCH 2/2] Add changeset for EPIPE fix Signed-off-by: Sean --- .changeset/itchy-singers-speak.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/itchy-singers-speak.md diff --git a/.changeset/itchy-singers-speak.md b/.changeset/itchy-singers-speak.md new file mode 100644 index 000000000..921236c80 --- /dev/null +++ b/.changeset/itchy-singers-speak.md @@ -0,0 +1,5 @@ +--- +"@modelcontextprotocol/server": patch +--- + +fix(stdio): handle EPIPE errors gracefully in StdioServerTransport