From 92f66b9a415e477332b9a2658d629a1f95991c69 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 14 Jul 2026 13:53:16 +0000 Subject: [PATCH 1/5] 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', From f98dd1523cde4c8a18fdbf8bda44d11885658273 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 16:24:12 +0000 Subject: [PATCH 2/5] fix(server): re-check teardown after tryServeListen in serveStdio's processMessage Review nit on #2494: with the transport now closing on stdin EOF, the two await tryServeListen(message) sites in processMessage were the only awaits not followed by the isTornDown() re-check, so a buffered final message racing stdin EOF on a modern-pinned connection dereferenced state.instance.channel after the state had been reassigned to closed, surfacing TypeError: Cannot read properties of undefined (reading 'channel') through options.onerror. Guard both sites like every other await in the function, and add a deterministic regression test (final 'data' chunk with 'end' on the next tick, emitted from a macrotask so the EOF handler beats the pump's microtask continuation, as it does with real stream events). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016gag9FFr6udcsF8qvMvMV8 --- packages/server/src/server/serveStdio.ts | 10 +++ .../server/test/server/serveStdio.test.ts | 61 +++++++++++++++++++ 2 files changed, 71 insertions(+) diff --git a/packages/server/src/server/serveStdio.ts b/packages/server/src/server/serveStdio.ts index 6c46671b50..1cabf8ed9b 100644 --- a/packages/server/src/server/serveStdio.ts +++ b/packages/server/src/server/serveStdio.ts @@ -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; } @@ -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; } diff --git a/packages/server/test/server/serveStdio.test.ts b/packages/server/test/server/serveStdio.test.ts index 0a23f5afc4..eea38beb08 100644 --- a/packages/server/test/server/serveStdio.test.ts +++ b/packages/server/test/server/serveStdio.test.ts @@ -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, @@ -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'; @@ -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', () => { From 049610c5343021d7925c1309edb6783e6425987c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 17:36:24 +0000 Subject: [PATCH 3/5] fix(server): keep a swallow-only stdout error listener after close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit close() removed the transport-level stdout 'error' listener, but a write accepted before close (write() returned true, per-send listener already detached) can still fail asynchronously at the OS level — e.g. EPIPE when the client that hung up also stops reading our stdout. With the new stdin-EOF close, that left stdout listener-less at exactly the moment a late EPIPE is most likely, turning it into an unhandled 'error' event that crashes the process with exit code 1 instead of the clean exit this change promises. Leave a swallow-only 'error' listener attached during close() so a post-close stream error is absorbed, and add a regression test that fails without the fix. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016gag9FFr6udcsF8qvMvMV8 --- packages/server/src/server/stdio.ts | 13 ++++++++++ packages/server/test/server/stdio.test.ts | 30 ++++++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/stdio.ts b/packages/server/src/server/stdio.ts index 4485964acd..8d5054e68b 100644 --- a/packages/server/src/server/stdio.ts +++ b/packages/server/src/server/stdio.ts @@ -65,6 +65,14 @@ export class StdioServerTransport implements Transport { // 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 @@ -121,6 +129,11 @@ export class StdioServerTransport implements Transport { this._stdin.off('end', this._onstdinclose); this._stdin.off('close', this._onstdinclose); this._stdout.off('error', this._onstdouterror); + // 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 if we were the only data listener const remainingDataListeners = this._stdin.listenerCount('data'); diff --git a/packages/server/test/server/stdio.test.ts b/packages/server/test/server/stdio.test.ts index 70b5b4d254..6ffe299f1c 100644 --- a/packages/server/test/server/stdio.test.ts +++ b/packages/server/test/server/stdio.test.ts @@ -250,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(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 () => { From a8b799d43d73ca66a6be484a13377e490ed6f062 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 18:24:00 +0000 Subject: [PATCH 4/5] fix(server): stop accumulating stdout error listeners across transport lifecycles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review nit on #2494: the swallow-only stdout 'error' listener attached in close() was per-instance and never removed, and _stdout defaults to the process-global process.stdout — so repeated create->close transport cycles in one process piled up listeners (MaxListenersExceededWarning after 11 cycles). Drop the extra listener entirely: make _onstdouterror a no-op once the transport is closed and keep it attached through close(), so a late write failure (e.g. EPIPE from a write accepted before close) is still swallowed with no second listener needed. Tag the kept listener so the next transport to start on the same stream sweeps stale ones away — the listener count on a shared stream stays bounded at one per lifecycle instead of growing without limit. Add a regression test covering repeated cycles on shared streams. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016gag9FFr6udcsF8qvMvMV8 --- packages/server/src/server/stdio.ts | 50 ++++++++++++++++------- packages/server/test/server/stdio.test.ts | 32 ++++++++++++++- 2 files changed, 66 insertions(+), 16 deletions(-) diff --git a/packages/server/src/server/stdio.ts b/packages/server/src/server/stdio.ts index 8d5054e68b..b92ce7e2d6 100644 --- a/packages/server/src/server/stdio.ts +++ b/packages/server/src/server/stdio.ts @@ -4,6 +4,15 @@ import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core-inter import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/core-internal'; import { process } from '@modelcontextprotocol/server/_shims'; +/** + * Marks a stdout `'error'` listener that a closed transport left attached purely to + * swallow late write failures (see {@link StdioServerTransport.close}). A fresh + * transport taking over the same stream removes listeners carrying this tag in + * `start()`, so repeated create→close cycles on the process-global `process.stdout` + * don't accumulate listeners. + */ +const swallowsErrorsAfterClose = Symbol('swallowsErrorsAfterClose'); + /** * Server transport for stdio: this communicates with an MCP client by reading from the current process' `stdin` and writing to `stdout`. * @@ -60,19 +69,21 @@ export class StdioServerTransport implements Transport { this.onerror?.(error); }; _onstdouterror = (error: Error) => { + if (this._closed) { + // 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 — which is why close() leaves this + // listener attached. + return; + } this.onerror?.(error); this.close().catch(() => { // 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 @@ -95,6 +106,16 @@ export class StdioServerTransport implements Transport { } this._started = true; + // Remove swallow-only 'error' listeners left behind by previously closed + // transports on this stream (see close()). Our own listener covers the + // stream from here on, so the stale ones are no longer needed — without + // this sweep, repeated create→close cycles on the process-global + // process.stdout would accumulate listeners. + for (const listener of this._stdout.listeners('error')) { + if ((listener as { [swallowsErrorsAfterClose]?: boolean })[swallowsErrorsAfterClose]) { + this._stdout.off('error', listener as (error: Error) => void); + } + } this._stdin.on('data', this._ondata); this._stdin.on('error', this._onerror); this._stdin.on('end', this._onstdinclose); @@ -128,12 +149,13 @@ export class StdioServerTransport implements Transport { this._stdin.off('error', this._onerror); this._stdin.off('end', this._onstdinclose); this._stdin.off('close', this._onstdinclose); - this._stdout.off('error', this._onstdouterror); - // 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); + // Deliberately keep _onstdouterror attached (it is a no-op now that _closed + // is set): 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. Tag it so the next transport to start on this stream can sweep + // it away instead of letting closed transports' listeners accumulate. + (this._onstdouterror as { [swallowsErrorsAfterClose]?: boolean })[swallowsErrorsAfterClose] = true; // Check if we were the only data listener const remainingDataListeners = this._stdin.listenerCount('data'); diff --git a/packages/server/test/server/stdio.test.ts b/packages/server/test/server/stdio.test.ts index 6ffe299f1c..5f911fedaf 100644 --- a/packages/server/test/server/stdio.test.ts +++ b/packages/server/test/server/stdio.test.ts @@ -250,8 +250,9 @@ test('should reject send() when stdout errors before drain', async () => { await expect(sendPromise).rejects.toThrow('write EPIPE'); expect(slowOutput.listenerCount('drain')).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()). + // send()'s per-send listeners are cleaned up; only the transport's own 'error' + // listener remains — the stdout error triggered close(), which keeps it + // attached (as a no-op) to swallow late write failures. expect(slowOutput.listenerCount('error')).toBe(1); }); @@ -281,6 +282,33 @@ test('should swallow stdout errors that surface after close (late EPIPE from a p expect(() => output.emit('error', new Error('write EPIPE'))).not.toThrow(); }); +test('repeated create→close cycles on shared streams should not accumulate stdout listeners', async () => { + // _stdin/_stdout default to the process-global process.stdin/process.stdout, so + // any listener a closed transport leaves behind would pile up across transport + // lifecycles in one process (MaxListenersExceededWarning after 11 cycles). Use + // fake shared streams to model that without touching the real process streams. + const sharedInput = new Readable({ read: () => {} }); + const sharedOutput = new Writable({ + write(_chunk, _encoding, callback) { + callback(); + } + }); + + for (let cycle = 0; cycle < 2; cycle++) { + const server = new StdioServerTransport(sharedInput, sharedOutput); + await server.start(); + await server.close(); + + // Exactly one 'error' listener stays behind after each cycle — the closed + // transport's swallow-only listener — and it is swept when the next + // transport starts, so the count never grows across cycles. + expect(sharedOutput.listenerCount('error')).toBe(1); + } + + // The leftover listener still swallows late write failures from pending writes. + expect(() => sharedOutput.emit('error', new Error('write EPIPE'))).not.toThrow(); +}); + test('should reject send() after transport is closed', async () => { const server = new StdioServerTransport(input, output); await server.start(); From f9d3eed712eb32d6bc52e09410aa4a1416c76669 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 27 Jul 2026 19:06:42 +0000 Subject: [PATCH 5/5] fix(server): close the transport when stdin is already dead at start() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit start() registered stdin 'end'/'close' listeners but never checked whether the stream had already ended or been destroyed before start() ran. Node emits those events once, so a custom stream (e.g. a net.Socket the peer reset during async setup) handed the transport a dead stream that never fired onclose — a zombie connection. Now start() detects the already-dead stream via readableEnded/destroyed and defers _onstdinclose in a microtask. Also document in the stdio serving guide that stdin EOF tears the connection down automatically (no signal handler needed) and that servers holding their own keep-alive handles should release them on close, with a synced snippet showing the onclose-release pattern. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_016gag9FFr6udcsF8qvMvMV8 --- docs/serving/stdio.md | 18 +++++++ examples/guides/serving/stdio.examples.ts | 23 ++++++++- packages/server/src/server/stdio.ts | 9 ++++ packages/server/test/server/stdio.test.ts | 61 +++++++++++++++++++++++ 4 files changed, 110 insertions(+), 1 deletion(-) diff --git a/docs/serving/stdio.md b/docs/serving/stdio.md index 2855626eca..8472aec3ec 100644 --- a/docs/serving/stdio.md +++ b/docs/serving/stdio.md @@ -70,6 +70,23 @@ process.on('SIGINT', () => { `close()` resolves once the instance the factory built and the underlying transport are both shut down. +A signal handler is one path to teardown; the pipe itself is the other. When the client closes its end (stdin reaches end-of-file), the transport closes itself and the connection tears down automatically — no signal handler needed. A server that holds nothing else keeping the event loop alive then exits on its own. If yours does hold a keep-alive handle — a timer, a connection pool, a file watcher — release it when the connection closes so the process can exit: + +```ts source="../../examples/guides/serving/stdio.examples.ts#serveStdio_releaseKeepAlive" +serveStdio(() => { + const server = new McpServer({ name: 'notes', version: '1.0.0' }); + + // A handle that keeps the event loop alive: a heartbeat timer, a + // connection pool, a file watcher, ... + const heartbeat = setInterval(() => console.error('notes server: still serving'), 60_000); + + // Release it when the connection closes, so the process can exit. + server.server.onclose = () => clearInterval(heartbeat); + + return server; +}); +``` + ## Recap - `serveStdio(factory)` is the stdio entry point: it owns the transport and calls your factory to build the instance that serves the connection. @@ -77,3 +94,4 @@ process.on('SIGINT', () => { - One `console.log` puts a line no JSON-RPC parser accepts into the stream the host parses. - `npx @modelcontextprotocol/inspector ` exercises a stdio server without configuring it in a host. - The returned `StdioServerHandle`'s `close()` tears down the pinned instance and the transport. +- When the client closes the pipe (stdin EOF) the connection tears down by itself; release your own keep-alive handles on close so the process can exit. diff --git a/examples/guides/serving/stdio.examples.ts b/examples/guides/serving/stdio.examples.ts index 18a445c5a5..c0590ebf8c 100644 --- a/examples/guides/serving/stdio.examples.ts +++ b/examples/guides/serving/stdio.examples.ts @@ -3,7 +3,8 @@ * * Each `//#region` block is synced byte-for-byte into that page's `ts` fences by * `pnpm sync:snippets` (`pnpm sync:snippets --check` reports drift). The regions - * are one linear stdio server program. The file runs in two modes: + * are one linear stdio server program, plus one typechecked-but-not-run variant + * (the keep-alive release pattern). The file runs in two modes: * * - `node --import tsx stdio.examples.ts --serve` — be that stdio server, plus * the one deliberate `console.log` the page's gotcha section describes, and @@ -39,6 +40,26 @@ process.on('SIGINT', () => { }); //#endregion serveStdio_shutdown +// "Shut down cleanly" — the keep-alive release pattern: typechecked, not run +// (running it here would start a second stdio transport on this process's +// streams alongside the server above). +export function notesServerWithKeepAlive() { + //#region serveStdio_releaseKeepAlive + serveStdio(() => { + const server = new McpServer({ name: 'notes', version: '1.0.0' }); + + // A handle that keeps the event loop alive: a heartbeat timer, a + // connection pool, a file watcher, ... + const heartbeat = setInterval(() => console.error('notes server: still serving'), 60_000); + + // Release it when the connection closes, so the process can exit. + server.server.onclose = () => clearInterval(heartbeat); + + return server; + }); + //#endregion serveStdio_releaseKeepAlive +} + // --------------------------------------------------------------------------- // Harness (not shown on the page). // diff --git a/packages/server/src/server/stdio.ts b/packages/server/src/server/stdio.ts index b92ce7e2d6..d173ead5a1 100644 --- a/packages/server/src/server/stdio.ts +++ b/packages/server/src/server/stdio.ts @@ -116,6 +116,15 @@ export class StdioServerTransport implements Transport { this._stdout.off('error', listener as (error: Error) => void); } } + // A stream that already ended or was destroyed before start() ran has + // emitted its one 'end'/'close' event; the listeners below would never + // fire. This can happen with a custom stream (e.g. a net.Socket the + // peer reset during async setup) — treat it as the client having hung + // up, deferred by a microtask so onclose isn't invoked before start() + // returns. + if (this._stdin.readableEnded || this._stdin.destroyed) { + queueMicrotask(this._onstdinclose); + } this._stdin.on('data', this._ondata); this._stdin.on('error', this._onerror); this._stdin.on('end', this._onstdinclose); diff --git a/packages/server/test/server/stdio.test.ts b/packages/server/test/server/stdio.test.ts index 5f911fedaf..2bb49f51f0 100644 --- a/packages/server/test/server/stdio.test.ts +++ b/packages/server/test/server/stdio.test.ts @@ -183,6 +183,67 @@ test('should close and fire onclose when stdin closes', async () => { expect(closeCount).toBe(1); }); +test('should fire onclose when stdin was already destroyed before start()', async () => { + // A custom stream (e.g. a net.Socket the peer reset during async setup) can + // be dead before start() runs: its one 'close' event already fired, so a + // listener registered in start() would never see it. + input.destroy(); + await new Promise(resolve => { + input.once('close', resolve); + }); + expect(input.destroyed).toBe(true); + + 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(); + + await closed; + expect(closeCount).toBe(1); +}); + +test('should fire onclose when stdin had already ended before start()', async () => { + // `autoDestroy: false, emitClose: false` so consuming the stream to EOF + // leaves it ended but NOT destroyed — its one 'end' event fired before + // start(), so only the `readableEnded` check can catch this shape. + const endedInput = new Readable({ read: () => {}, autoDestroy: false, emitClose: false }); + endedInput.push(null); // EOF + endedInput.resume(); // flow, so 'end' actually fires + await new Promise(resolve => { + endedInput.once('end', resolve); + }); + expect(endedInput.readableEnded).toBe(true); + expect(endedInput.destroyed).toBe(false); + + const server = new StdioServerTransport(endedInput, output); + server.onerror = error => { + throw error; + }; + + let closeCount = 0; + const closed = new Promise(resolve => { + server.onclose = () => { + closeCount++; + resolve(); + }; + }); + + await server.start(); + + 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 => {