Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-stdio-epipe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/server': patch
---

Handle EPIPE errors gracefully in stdio transport to prevent crashes when the connected process terminates unexpectedly.
34 changes: 29 additions & 5 deletions packages/server/src/server/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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() {
Expand All @@ -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');
Expand All @@ -87,12 +97,26 @@ export class StdioServerTransport implements Transport {
}

send(message: JSONRPCMessage): Promise<void> {
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);
}
}
});
}
Expand Down
84 changes: 84 additions & 0 deletions packages/server/test/server/stdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading