diff --git a/.changeset/fix-stdio-epipe.md b/.changeset/fix-stdio-epipe.md new file mode 100644 index 000000000..41aaec975 --- /dev/null +++ b/.changeset/fix-stdio-epipe.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': patch +--- + +Handle EPIPE errors gracefully in stdio transport to prevent crashes when the connected process terminates unexpectedly. diff --git a/packages/server/src/server/stdio.ts b/packages/server/src/server/stdio.ts index 562c6861c..8b3b011bd 100644 --- a/packages/server/src/server/stdio.ts +++ b/packages/server/src/server/stdio.ts @@ -37,6 +37,14 @@ export class StdioServerTransport implements Transport { _onerror = (error: Error) => { this.onerror?.(error); }; + _onstdouterror = (error: NodeJS.ErrnoException) => { + if (error.code === 'EPIPE' || error.code === 'ERR_STREAM_DESTROYED') { + // Client disconnected — close gracefully instead of crashing. + void this.close(); + } else { + this.onerror?.(error); + } + }; /** * Starts listening for messages on `stdin`. @@ -51,6 +59,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 +81,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 +97,26 @@ 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); + if (!this._stdout.writable) { + reject(new Error('stdout is not writable')); + return; + } + try { + if (this._stdout.write(json)) { + resolve(); + } else { + this._stdout.once('drain', resolve); + } + } catch (error: unknown) { + const errno = error as NodeJS.ErrnoException; + if (errno.code === 'EPIPE' || errno.code === 'ERR_STREAM_DESTROYED') { + void this.close(); + reject(error); + } else { + reject(error); + } } }); } diff --git a/packages/server/test/server/stdio.test.ts b/packages/server/test/server/stdio.test.ts index 8b1f234b9..8a967a682 100644 --- a/packages/server/test/server/stdio.test.ts +++ b/packages/server/test/server/stdio.test.ts @@ -102,3 +102,87 @@ test('should read multiple messages', async () => { await finished; expect(readMessages).toEqual(messages); }); + +test('should handle EPIPE error on stdout gracefully', async () => { + const server = new StdioServerTransport(input, output); + + let didClose = false; + server.onclose = () => { + didClose = true; + }; + + await server.start(); + + // Simulate EPIPE error on stdout + const epipeError = new Error('write EPIPE') as NodeJS.ErrnoException; + epipeError.code = 'EPIPE'; + output.emit('error', epipeError); + + // Should trigger graceful close, not crash + expect(didClose).toBeTruthy(); +}); + +test('should handle ERR_STREAM_DESTROYED error on stdout gracefully', async () => { + const server = new StdioServerTransport(input, output); + + let didClose = false; + server.onclose = () => { + didClose = true; + }; + + await server.start(); + + const destroyedError = new Error('stream destroyed') as NodeJS.ErrnoException; + destroyedError.code = 'ERR_STREAM_DESTROYED'; + output.emit('error', destroyedError); + + expect(didClose).toBeTruthy(); +}); + +test('should forward non-EPIPE stdout errors to onerror', async () => { + const server = new StdioServerTransport(input, output); + + let reportedError: Error | undefined; + server.onerror = error => { + reportedError = error; + }; + + await server.start(); + + const otherError = new Error('some other error') as NodeJS.ErrnoException; + otherError.code = 'ENOSPC'; + output.emit('error', otherError); + + expect(reportedError).toBe(otherError); +}); + +test('should reject send when stdout is not writable', async () => { + const closedOutput = new Writable({ + write(_chunk, _encoding, callback) { + callback(); + } + }); + closedOutput.destroy(); + + const server = new StdioServerTransport(input, closedOutput); + await server.start(); + + const message: JSONRPCMessage = { + jsonrpc: '2.0', + id: 1, + method: 'ping' + }; + + await expect(server.send(message)).rejects.toThrow('stdout is not writable'); +}); + +test('should remove stdout error listener on close', async () => { + const server = new StdioServerTransport(input, output); + await server.start(); + + const listenersBefore = output.listenerCount('error'); + await server.close(); + const listenersAfter = output.listenerCount('error'); + + expect(listenersAfter).toBeLessThan(listenersBefore); +});