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/server-host-watchdog.md
Original file line number Diff line number Diff line change
@@ -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.
90 changes: 86 additions & 4 deletions packages/server/src/server/stdio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
*
Expand All @@ -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<typeof setInterval>;
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;
Expand Down Expand Up @@ -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() {
Expand All @@ -69,6 +149,8 @@ export class StdioServerTransport implements Transport {
}

async close(): Promise<void> {
this._stopHostWatchdog();

// Remove our event listeners first
this._stdin.off('data', this._ondata);
this._stdin.off('error', this._onerror);
Expand Down
78 changes: 78 additions & 0 deletions packages/server/test/server/stdio.test.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<void>(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));
});
});
Loading