From 92f66b9a415e477332b9a2658d629a1f95991c69 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 13:53:16 +0000 Subject: [PATCH] fix(server): close StdioServerTransport when stdin ends or closes Per the MCP stdio binding, servers should exit promptly when their standard input is closed - stdin EOF is the primary graceful-shutdown signal, and on Windows (where no signal is delivered when the host goes away) the only reliable one. StdioServerTransport previously listened only for 'data' and 'error' on stdin, so when an MCP client hung up its end of the pipe (window closed, session restarted, host crashed) the transport never noticed: onclose never fired, nothing tore down, and server processes accumulated as zombies until killed by hand. The transport now attaches 'end' and '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. The serverThatHangs fixture now swallows the sendLoggingMessage rejection its signal handler hits once the transport closes itself on stdin EOF; the fixture keeps hanging on purpose either way. Fixes #2002 --- .changeset/stdio-server-stdin-eof-close.md | 5 + packages/server/src/server/stdio.ts | 19 ++++ packages/server/test/server/stdio.test.ts | 94 +++++++++++++++++++ .../test/__fixtures__/serverThatHangs.ts | 12 ++- .../test/__fixtures__/serverWithKeepAlive.ts | 22 +++++ test/integration/test/processCleanup.test.ts | 45 +++++++++ 6 files changed, 193 insertions(+), 4 deletions(-) create mode 100644 .changeset/stdio-server-stdin-eof-close.md create mode 100644 test/integration/test/__fixtures__/serverWithKeepAlive.ts 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',