diff --git a/.changeset/stdio-server-stdin-eof-close.md b/.changeset/stdio-server-stdin-eof-close.md new file mode 100644 index 0000000000..c113fdafd8 --- /dev/null +++ b/.changeset/stdio-server-stdin-eof-close.md @@ -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. diff --git a/packages/server/src/server/stdio.ts b/packages/server/src/server/stdio.ts index e8fda03257..4485964acd 100644 --- a/packages/server/src/server/stdio.ts +++ b/packages/server/src/server/stdio.ts @@ -9,6 +9,11 @@ import { process } from '@modelcontextprotocol/server/_shims'; * * 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 * ```ts source="./stdio.examples.ts#StdioServerTransport_basicUsage" * const server = new McpServer({ name: 'my-server', version: '1.0.0' }); @@ -60,6 +65,16 @@ export class StdioServerTransport implements Transport { // Ignore errors during close — we're already in an error path }); }; + _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`. @@ -74,6 +89,8 @@ export class StdioServerTransport implements Transport { 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); } @@ -101,6 +118,8 @@ export class StdioServerTransport implements Transport { // 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); // Check if we were the only data listener diff --git a/packages/server/test/server/stdio.test.ts b/packages/server/test/server/stdio.test.ts index fe79e3679c..70b5b4d254 100644 --- a/packages/server/test/server/stdio.test.ts +++ b/packages/server/test/server/stdio.test.ts @@ -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(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(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(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(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({ diff --git a/test/integration/test/__fixtures__/serverThatHangs.ts b/test/integration/test/__fixtures__/serverThatHangs.ts index dbaf198974..39b177e8f3 100644 --- a/test/integration/test/__fixtures__/serverThatHangs.ts +++ b/test/integration/test/__fixtures__/serverThatHangs.ts @@ -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); diff --git a/test/integration/test/__fixtures__/serverWithKeepAlive.ts b/test/integration/test/__fixtures__/serverWithKeepAlive.ts new file mode 100644 index 0000000000..2abdb6f718 --- /dev/null +++ b/test/integration/test/__fixtures__/serverWithKeepAlive.ts @@ -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); +}; diff --git a/test/integration/test/processCleanup.test.ts b/test/integration/test/processCleanup.test.ts index 3554d99361..87b7ee91a2 100644 --- a/test/integration/test/processCleanup.test.ts +++ b/test/integration/test/processCleanup.test.ts @@ -1,3 +1,4 @@ +import { spawn } from 'node:child_process'; import path from 'node:path'; import { Readable, Writable } from 'node:stream'; @@ -77,6 +78,50 @@ describe('Process cleanup', () => { expect(onCloseWasCalled).toBe(1); }); + it('server process should exit on its own when the client closes stdin', async () => { + // Regression test for zombie stdio servers: when the client drops its end of + // the pipe (window closed, session restarted) without sending any signal, the + // server must notice stdin EOF, close its transport, and fire `onclose` so the + // server can release its keep-alive handles and the process exits naturally. + const child = spawn('node', ['--import', 'tsx', 'serverWithKeepAlive.ts'], { + cwd: FIXTURES_DIR, + stdio: ['pipe', 'pipe', 'inherit'] + }); + + const exited = new Promise(resolve => { + child.on('exit', code => resolve(code)); + }); + + // Confirm the server is up by completing an initialize round-trip over raw stdio. + const initialized = new Promise(resolve => { + child.stdout.on('data', () => resolve()); + }); + child.stdin.write( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { name: 'stdin-eof-test', version: '1.0.0' } + } + }) + '\n' + ); + await initialized; + + // Hang up: close our end of the pipe without any signal. + child.stdin.end(); + + // The server must exit on its own — no SIGTERM/SIGKILL involved. Bound the + // wait so a regression fails the test instead of leaking the child. + const exitCode = await Promise.race([exited, new Promise<'zombie'>(resolve => setTimeout(() => resolve('zombie'), 8000))]); + if (exitCode === 'zombie') { + child.kill('SIGKILL'); + } + expect(exitCode).toBe(0); + }); + it('should exit cleanly for a server that hangs', async () => { const client = new Client({ name: 'test-client',