Skip to content
Open
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.
18 changes: 18 additions & 0 deletions docs/serving/stdio.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,28 @@ 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.
- stdout is the protocol channel; log with `console.error`.
- One `console.log` puts a line no JSON-RPC parser accepts into the stream the host parses.
- `npx @modelcontextprotocol/inspector <command>` 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.
23 changes: 22 additions & 1 deletion examples/guides/serving/stdio.examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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).
//
Expand Down
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
65 changes: 64 additions & 1 deletion packages/server/src/server/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,25 @@ 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`.
*
* 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.
* ```ts source="./stdio.examples.ts#StdioServerTransport_basicUsage"
* const server = new McpServer({ name: 'my-server', version: '1.0.0' });
Expand Down Expand Up @@ -55,11 +69,31 @@ 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
});
};
_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 @@ -72,8 +106,29 @@ 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);
}
}
// 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);
this._stdin.on('close', this._onstdinclose);
this._stdout.on('error', this._onstdouterror);
Comment thread
claude[bot] marked this conversation as resolved.
}

Comment thread
claude[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -101,7 +156,15 @@ export class StdioServerTransport implements Transport {
// Remove our event listeners first
this._stdin.off('data', this._ondata);
this._stdin.off('error', this._onerror);
this._stdout.off('error', this._onstdouterror);
this._stdin.off('end', this._onstdinclose);
this._stdin.off('close', this._onstdinclose);
// 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');
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
Loading
Loading