diff --git a/.changeset/server-host-watchdog.md b/.changeset/server-host-watchdog.md new file mode 100644 index 000000000..e2841454f --- /dev/null +++ b/.changeset/server-host-watchdog.md @@ -0,0 +1,5 @@ +--- +'@modelcontextprotocol/server': minor +--- + +Add host process watchdog to StdioServerTransport. When `clientProcessId` is provided via the new options object constructor, the transport periodically checks if the host process is still alive and self-terminates if it is gone, preventing orphaned server processes. diff --git a/packages/server/src/server/stdio.ts b/packages/server/src/server/stdio.ts index 562c6861c..fd916f3d7 100644 --- a/packages/server/src/server/stdio.ts +++ b/packages/server/src/server/stdio.ts @@ -4,6 +4,37 @@ import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core'; import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/core'; import { process } from '@modelcontextprotocol/server/_shims'; +/** + * Options for configuring `StdioServerTransport`. + */ +export interface StdioServerTransportOptions { + /** + * The readable stream to use for input. Defaults to `process.stdin`. + */ + stdin?: Readable; + + /** + * The writable stream to use for output. Defaults to `process.stdout`. + */ + stdout?: Writable; + + /** + * The PID of the client (host) process. When set, the transport periodically + * checks if the host process is still alive and self-terminates if it is gone. + * + * This prevents orphaned server processes when the host crashes or is killed + * without cleanly shutting down the server. Follows the same pattern used by + * the Language Server Protocol in vscode-languageserver-node. + */ + clientProcessId?: number; + + /** + * How often (in milliseconds) to check if the host process is alive. + * Only used when `clientProcessId` is set. Defaults to 3000 (3 seconds). + */ + watchdogIntervalMs?: number; +} + /** * Server transport for stdio: this communicates with an MCP client by reading from the current process' `stdin` and writing to `stdout`. * @@ -19,11 +50,29 @@ import { process } from '@modelcontextprotocol/server/_shims'; export class StdioServerTransport implements Transport { private _readBuffer: ReadBuffer = new ReadBuffer(); private _started = false; + private _clientProcessId?: number; + private _watchdogInterval?: ReturnType; + private _watchdogIntervalMs: number; + private _stdin: Readable; + private _stdout: Writable; - constructor( - private _stdin: Readable = process.stdin, - private _stdout: Writable = process.stdout - ) {} + constructor(options?: StdioServerTransportOptions); + constructor(stdin?: Readable, stdout?: Writable); + constructor(stdinOrOptions?: Readable | StdioServerTransportOptions, stdout?: Writable) { + if (stdinOrOptions && typeof stdinOrOptions === 'object' && !('read' in stdinOrOptions)) { + // Options object form + const options = stdinOrOptions as StdioServerTransportOptions; + this._stdin = options.stdin ?? process.stdin; + this._stdout = options.stdout ?? process.stdout; + this._clientProcessId = options.clientProcessId; + this._watchdogIntervalMs = options.watchdogIntervalMs ?? 3000; + } else { + // Legacy positional args form + this._stdin = (stdinOrOptions as Readable) ?? process.stdin; + this._stdout = stdout ?? process.stdout; + this._watchdogIntervalMs = 3000; + } + } onclose?: () => void; onerror?: (error: Error) => void; @@ -51,6 +100,37 @@ export class StdioServerTransport implements Transport { this._started = true; this._stdin.on('data', this._ondata); this._stdin.on('error', this._onerror); + this._startHostWatchdog(); + } + + private _startHostWatchdog(): void { + if (this._clientProcessId === undefined || this._watchdogInterval) { + return; + } + + const pid = this._clientProcessId; + this._watchdogInterval = setInterval(() => { + try { + // Signal 0 does not kill the process; it checks if it exists. + process.kill(pid, 0); + } catch { + // Host process is gone. Self-terminate. + this._stopHostWatchdog(); + void this.close(); + } + }, this._watchdogIntervalMs); + + // Ensure the watchdog timer does not prevent the process from exiting. + if (typeof this._watchdogInterval === 'object' && 'unref' in this._watchdogInterval) { + this._watchdogInterval.unref(); + } + } + + private _stopHostWatchdog(): void { + if (this._watchdogInterval) { + clearInterval(this._watchdogInterval); + this._watchdogInterval = undefined; + } } private processReadBuffer() { @@ -69,6 +149,8 @@ export class StdioServerTransport implements Transport { } async close(): Promise { + this._stopHostWatchdog(); + // Remove our event listeners first this._stdin.off('data', this._ondata); this._stdin.off('error', this._onerror); diff --git a/packages/server/test/server/stdio.test.ts b/packages/server/test/server/stdio.test.ts index 8b1f234b9..42ef87aa2 100644 --- a/packages/server/test/server/stdio.test.ts +++ b/packages/server/test/server/stdio.test.ts @@ -1,8 +1,10 @@ +import process from 'node:process'; import { Readable, Writable } from 'node:stream'; import type { JSONRPCMessage } from '@modelcontextprotocol/core'; import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/core'; +import type { StdioServerTransportOptions } from '../../src/server/stdio.js'; import { StdioServerTransport } from '../../src/server/stdio.js'; let input: Readable; @@ -102,3 +104,79 @@ test('should read multiple messages', async () => { await finished; expect(readMessages).toEqual(messages); }); + +test('should accept options object constructor', async () => { + const server = new StdioServerTransport({ stdin: input, stdout: output }); + server.onerror = error => { + throw error; + }; + + let didClose = false; + server.onclose = () => { + didClose = true; + }; + + await server.start(); + await server.close(); + expect(didClose).toBeTruthy(); +}); + +describe('host process watchdog', () => { + test('should close transport when host process is gone', async () => { + // Use a PID that does not exist + const deadPid = 2147483647; + const server = new StdioServerTransport({ + stdin: input, + stdout: output, + clientProcessId: deadPid, + watchdogIntervalMs: 100 + }); + + const closed = new Promise(resolve => { + server.onclose = () => resolve(); + }); + + await server.start(); + + // Watchdog should detect the dead PID and close + await closed; + }, 10000); + + test('should not close when host process is alive', async () => { + // Use our own PID, which is always alive + const server = new StdioServerTransport({ + stdin: input, + stdout: output, + clientProcessId: process.pid, + watchdogIntervalMs: 100 + }); + + let didClose = false; + server.onclose = () => { + didClose = true; + }; + + await server.start(); + + // Wait for several watchdog cycles + await new Promise(resolve => setTimeout(resolve, 350)); + expect(didClose).toBe(false); + + await server.close(); + }); + + test('should stop watchdog on close', async () => { + const server = new StdioServerTransport({ + stdin: input, + stdout: output, + clientProcessId: process.pid, + watchdogIntervalMs: 100 + }); + + await server.start(); + await server.close(); + + // If watchdog was not stopped, it would keep running. Verify no errors after close. + await new Promise(resolve => setTimeout(resolve, 300)); + }); +});