Skip to content
Draft
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/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.
19 changes: 19 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 @@ 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' });
Expand Down Expand Up @@ -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`.
Expand All @@ -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);
}

Expand Down Expand Up @@ -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
Expand Down
94 changes: 94 additions & 0 deletions 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 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);
};
45 changes: 45 additions & 0 deletions test/integration/test/processCleanup.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { spawn } from 'node:child_process';
import path from 'node:path';
import { Readable, Writable } from 'node:stream';

Expand Down Expand Up @@ -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<number | null>(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<void>(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',
Expand Down
Loading