Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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/stdio-server-stdin-eof-close.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@modelcontextprotocol/server': patch
---

`StdioServerTransport` now closes itself and fires `onclose` when its stdin ends or closes. The stdio binding says servers "SHOULD exit promptly when their standard input is closed" — stdin EOF is the primary graceful-shutdown signal, and on some platforms (notably Windows, where no signal is delivered when the parent goes away) the only reliable one. Previously the transport listened only for `data` and `error`, so when an MCP client hung up its end of the pipe (window closed, session restarted, host crashed) the server never noticed: `onclose` never fired, nothing tore down, and server processes accumulated as zombies until killed by hand. The transport now attaches `end`/`close` listeners on stdin that close the transport (idempotently — `onclose` still fires exactly once if `close()` is also called), so `Server`/`McpServer` and `serveStdio` tear down through the existing `onclose` chain and a well-behaved server process exits naturally.
10 changes: 10 additions & 0 deletions packages/server/src/server/serveStdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -598,6 +598,11 @@ export function serveStdio(factory: McpServerFactory, options: ServeStdioOptions
if (state.era === 'modern' && (await tryServeListen(message))) {
return;
}
if (isTornDown()) {
// Closed while entry listen routing was being checked (a
// buffered final message can race stdin EOF): stay closed.
return;
}
state.instance.channel.deliver(message);
return;
}
Expand Down Expand Up @@ -692,6 +697,11 @@ export function serveStdio(factory: McpServerFactory, options: ServeStdioOptions
if (await tryServeListen(message)) {
return;
}
if (isTornDown()) {
// Closed while entry listen routing was being checked (a
// buffered final message can race stdin EOF): stay closed.
return;
}
state.instance.channel.deliver(message, { classification: opening.classification });
return;
}
Expand Down
32 changes: 32 additions & 0 deletions packages/server/src/server/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
*
* This transport is only available in Node.js environments.
*
* When the client closes its end of the pipe (stdin reaches end-of-file), the transport
* closes itself and fires `onclose`, per the MCP stdio binding's guidance that servers
* should exit promptly when their standard input is closed. A server that holds no other
* keep-alive handles will then exit naturally.
*
* @example
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
* ```ts source="./stdio.examples.ts#StdioServerTransport_basicUsage"
* const server = new McpServer({ name: 'my-server', version: '1.0.0' });
Expand Down Expand Up @@ -60,9 +65,27 @@
// Ignore errors during close — we're already in an error path
});
};
_onstdouterrorafterclose = () => {
// Swallow stdout errors that surface after close. A write accepted before
// close (`write()` returned true) can still fail asynchronously at the OS
// level — e.g. EPIPE when the client that hung up also stops reading our
// stdout. The transport is already closed, so there is nothing to do; but
// with no listener attached, that late 'error' event would crash the
// process with an uncaught exception.
};
_onstdinclose = () => {
// stdin reaching EOF (or being destroyed) means the client has hung up and no
// further input can ever arrive. The MCP stdio binding says servers should exit
// promptly when their standard input closes — this is the primary graceful
// shutdown signal, and on some platforms (e.g. Windows) the only reliable one.
// Close the transport so `onclose` fires and the process can exit naturally.
this.close().catch(() => {
// Ignore errors during close — nothing more can be read anyway
});
};

/**
* Starts listening for messages on `stdin`.

Check failure on line 88 in packages/server/src/server/stdio.ts

View check run for this annotation

Claude / Claude Code Review

stdin EOF hard-closes the transport, aborting in-flight handlers and dropping their responses on a half-closed pipe

Closing the transport immediately on stdin `end` aborts every in-flight request handler (via `Protocol._onclose`) and flips `_closed` so their responses can never be written — but on a half-closed pipe the client is still reading stdout, so requests whose bytes arrived before EOF (the classic `cat requests.jsonl | mcp-server > responses.jsonl` batch idiom, or any client that pipelines its final request and half-closes) now silently lose their responses. This is a regression: an A/B run at PR hea
Comment thread
felixweinberger marked this conversation as resolved.
Resolved
*/
async start(): Promise<void> {
if (this._started) {
Expand All @@ -74,6 +97,8 @@
this._started = true;
this._stdin.on('data', this._ondata);
this._stdin.on('error', this._onerror);
this._stdin.on('end', this._onstdinclose);
this._stdin.on('close', this._onstdinclose);
this._stdout.on('error', this._onstdouterror);
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
}

Comment thread
claude[bot] marked this conversation as resolved.
Resolved
Expand Down Expand Up @@ -101,7 +126,14 @@
// Remove our event listeners first
this._stdin.off('data', this._ondata);
this._stdin.off('error', this._onerror);
this._stdin.off('end', this._onstdinclose);
this._stdin.off('close', this._onstdinclose);
this._stdout.off('error', this._onstdouterror);
Comment thread
claude[bot] marked this conversation as resolved.
Resolved
// Keep a swallow-only 'error' listener on stdout: a write that was accepted
// before close may still be pending at the OS level and can fail after close
// (e.g. EPIPE when the client hangs up), and an 'error' event on a
// listener-less stream would crash the process.
this._stdout.on('error', this._onstdouterrorafterclose);

Check warning on line 136 in packages/server/src/server/stdio.ts

View check run for this annotation

Claude / Claude Code Review

close() permanently leaks a swallow-only stdout error listener per transport lifecycle

The swallow-only 'error' listener added in `close()` (`this._stdout.on('error', this._onstdouterrorafterclose)`, stdio.ts:136) is never removed, and `_stdout` defaults to the process-global `process.stdout` — so every create→close transport lifecycle in one process permanently adds one more listener to the shared stream, hitting Node's `MaxListenersExceededWarning` after 11 cycles (e.g. reconnect loops or sequential stdio sessions). Consider making `_onstdouterror` a no-op once `_closed` (so no
Comment thread
claude[bot] marked this conversation as resolved.
Resolved

// Check if we were the only data listener
const remainingDataListeners = this._stdin.listenerCount('data');
Expand Down
61 changes: 61 additions & 0 deletions packages/server/test/server/serveStdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
* - malformed and unsupported envelope claims are answered by the entry,
* consistent with the HTTP entry's treatment, without pinning.
*/
import { Readable, Writable } from 'node:stream';

import type {
JSONRPCErrorResponse,
JSONRPCMessage,
Expand Down Expand Up @@ -47,6 +49,7 @@ import type { McpServerFactory } from '../../src/server/createMcpHandler';
import { McpServer } from '../../src/server/mcp';
import type { ServeStdioOptions } from '../../src/server/serveStdio';
import { serveStdio } from '../../src/server/serveStdio';
import { StdioServerTransport } from '../../src/server/stdio';

const MODERN = '2026-07-28';

Expand Down Expand Up @@ -811,6 +814,64 @@ describe('teardown', () => {
expect(closed[0]).toBe(true);
expect(peerClosed).toBe(true);
});

it('a buffered final message racing stdin EOF on a modern-pinned connection is dropped cleanly (no TypeError through onerror)', async () => {
// Over the real StdioServerTransport: stdin 'end' fires on the next
// tick behind a final 'data' chunk, so the transport's onclose (which
// reassigns the entry state to closed) runs BEFORE the pump's
// microtask continuation inside processMessage resumes from
// `await tryServeListen(message)`. The continuation must re-check
// teardown instead of dereferencing the pinned instance.
const { factory, closed } = trackingFactory();

const stdin = new Readable({ read() {} });
const outLines: string[] = [];
const stdout = new Writable({
write(chunk: Buffer, _encoding, callback) {
outLines.push(chunk.toString());
callback();
}
});
const transport = new StdioServerTransport(stdin, stdout);

const errors: Error[] = [];
const handle = serveStdio(factory, { transport, onerror: error => void errors.push(error) });

const line = (message: JSONRPCMessage) => Buffer.from(`${JSON.stringify(message)}\n`);

// Pin the modern era with a first enveloped request and wait for its answer.
stdin.emit('data', line({ jsonrpc: '2.0', id: 1, method: 'tools/list', params: { _meta: envelope() } }));
while (outLines.length === 0) {
await new Promise(resolve => setTimeout(resolve, 5));
}

// The race: a final enveloped request with stdin EOF right behind it.
// Emitted from a timer callback so the events originate from a
// macrotask exactly as real stream events do — there, the
// nextTick'd 'end' runs BEFORE the pump's promise continuation
// resumes inside processMessage (a test body itself is a microtask
// context, where the continuation would win and mask the race).
setTimeout(() => {
stdin.emit(
'data',
line({
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'echo', arguments: { text: 'late' }, _meta: envelope() }
})
);
process.nextTick(() => stdin.emit('end'));
}, 0);
await new Promise(resolve => setTimeout(resolve, 20));

// The connection tore down (EOF closed the pinned instance) and the
// raced message was dropped without any error surfacing to onerror.
expect(closed[0]).toBe(true);
expect(errors).toEqual([]);

await handle.close();
});
});

describe('legacy input_required shim through the stdio entry', () => {
Expand Down
124 changes: 123 additions & 1 deletion packages/server/test/server/stdio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,100 @@ test('should not fire onclose twice when close() is called after stdout error',
expect(closeCount).toBe(1);
});

test('should close and fire onclose when stdin ends (client hung up)', async () => {
// `autoDestroy: false, emitClose: false` so that pushing EOF emits only 'end',
// proving the 'end' listener works on its own (without relying on 'close').
const endOnlyInput = new Readable({ read: () => {}, autoDestroy: false, emitClose: false });
const server = new StdioServerTransport(endOnlyInput, output);
server.onerror = error => {
throw error;
};

let closeCount = 0;
const closed = new Promise<void>(resolve => {
server.onclose = () => {
closeCount++;
resolve();
};
});

await server.start();
endOnlyInput.push(null); // EOF — the client closed its end of the pipe

await closed;
expect(closeCount).toBe(1);
});

test('should close and fire onclose when stdin closes', async () => {
const server = new StdioServerTransport(input, output);
server.onerror = error => {
throw error;
};

let closeCount = 0;
const closed = new Promise<void>(resolve => {
server.onclose = () => {
closeCount++;
resolve();
};
});

await server.start();
input.destroy(); // emits 'close'

await closed;
expect(closeCount).toBe(1);
});

test('should not fire onclose twice when close() is called after stdin ends', async () => {
const server = new StdioServerTransport(input, output);
server.onerror = error => {
throw error;
};

let closeCount = 0;
const closed = new Promise<void>(resolve => {
server.onclose = () => {
closeCount++;
resolve();
};
});

await server.start();
input.push(null); // EOF fires 'end', and stream teardown may fire 'close' too

await closed;
await server.close();
// Allow any late 'close' event from the stream teardown to be delivered
await new Promise(resolve => setTimeout(resolve, 10));

expect(closeCount).toBe(1);
});

test('should still deliver messages that arrived before stdin ended', async () => {
const server = new StdioServerTransport(input, output);
server.onerror = error => {
throw error;
};

const messages: JSONRPCMessage[] = [];
const closed = new Promise<void>(resolve => {
server.onclose = () => resolve();
});
server.onmessage = message => {
messages.push(message);
};

const message: JSONRPCMessage = { jsonrpc: '2.0', id: 1, method: 'ping' };
input.push(serializeMessage(message));
input.push(null); // EOF right behind the message

await server.start();
await closed;

expect(messages).toEqual([message]);
});

test('should reject send() when stdout errors before drain', async () => {
let completeWrite: ((error?: Error | null) => void) | undefined;
const slowOutput = new Writable({
Expand All @@ -156,7 +250,35 @@ test('should reject send() when stdout errors before drain', async () => {

await expect(sendPromise).rejects.toThrow('write EPIPE');
expect(slowOutput.listenerCount('drain')).toBe(0);
expect(slowOutput.listenerCount('error')).toBe(0);
// send()'s per-send listeners are cleaned up; only the transport's post-close
// swallow-only 'error' listener remains (the stdout error triggered close()).
expect(slowOutput.listenerCount('error')).toBe(1);
});

test('should swallow stdout errors that surface after close (late EPIPE from a pending write)', async () => {
const server = new StdioServerTransport(input, output);
server.onerror = error => {
throw error;
};

const closed = new Promise<void>(resolve => {
server.onclose = () => resolve();
});

await server.start();

// Fast-path send: write() returns true, so the per-send 'error' listener is
// detached immediately — but the OS-level write may still be pending.
await server.send({ jsonrpc: '2.0', id: 1, method: 'ping' });

// The client hangs up: stdin EOF closes the transport.
input.push(null);
await closed;

// The pending write now fails (e.g. EPIPE because the client's read end is
// gone). If stdout had no 'error' listener left, this emit would throw
// ERR_UNHANDLED_ERROR — in a real process, an uncaught exception and exit 1.
expect(() => output.emit('error', new Error('write EPIPE'))).not.toThrow();
});

test('should reject send() after transport is closed', async () => {
Expand Down
12 changes: 8 additions & 4 deletions test/integration/test/__fixtures__/serverThatHangs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,14 @@ transport.onclose = () => {
};

const doNotExitImmediately = async (signal: NodeJS.Signals) => {
await server.sendLoggingMessage({
level: 'debug',
data: `received signal ${signal}`
});
// The transport closes itself when the client hangs up stdin, so this send may
// reject — ignore that; this fixture intentionally keeps hanging regardless.
await server
.sendLoggingMessage({
level: 'debug',
data: `received signal ${signal}`
})
.catch(() => {});
// Clear keepalive but delay exit to simulate slow shutdown
clearInterval(keepAlive);
setInterval(() => {}, 30_000);
Expand Down
22 changes: 22 additions & 0 deletions test/integration/test/__fixtures__/serverWithKeepAlive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { setInterval } from 'node:timers';

import { McpServer } from '@modelcontextprotocol/server';
import { StdioServerTransport } from '@modelcontextprotocol/server/stdio';

const transport = new StdioServerTransport();

const server = new McpServer({
name: 'server-with-keep-alive',
version: '1.0.0'
});

await server.connect(transport);

// Simulates a real server holding a keep-alive handle (connection pool, file
// watcher, heartbeat timer, ...). A well-behaved server releases its handles
// when the connection closes, and the process then exits naturally.
const keepAlive = setInterval(() => {}, 60_000);

server.server.onclose = () => {
clearInterval(keepAlive);
};
Loading
Loading