Skip to content
Open
Show file tree
Hide file tree
Changes from all 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