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 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); +});