Skip to content

Commit e253410

Browse files
travisbreaksclaude
andcommitted
feat(server): add host process watchdog to StdioServerTransport
When clientProcessId is provided, the transport periodically checks if the host process is still alive using signal 0 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 pattern used by vscode-languageserver-node as described in the issue. Fixes #208 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ccb78f2 commit e253410

File tree

3 files changed

+169
-4
lines changed

3 files changed

+169
-4
lines changed

.changeset/server-host-watchdog.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@modelcontextprotocol/server': minor
3+
---
4+
5+
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.

packages/server/src/server/stdio.ts

Lines changed: 86 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,37 @@ import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core';
44
import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/core';
55
import { process } from '@modelcontextprotocol/server/_shims';
66

7+
/**
8+
* Options for configuring `StdioServerTransport`.
9+
*/
10+
export interface StdioServerTransportOptions {
11+
/**
12+
* The readable stream to use for input. Defaults to `process.stdin`.
13+
*/
14+
stdin?: Readable;
15+
16+
/**
17+
* The writable stream to use for output. Defaults to `process.stdout`.
18+
*/
19+
stdout?: Writable;
20+
21+
/**
22+
* The PID of the client (host) process. When set, the transport periodically
23+
* checks if the host process is still alive and self-terminates if it is gone.
24+
*
25+
* This prevents orphaned server processes when the host crashes or is killed
26+
* without cleanly shutting down the server. Follows the same pattern used by
27+
* the Language Server Protocol in vscode-languageserver-node.
28+
*/
29+
clientProcessId?: number;
30+
31+
/**
32+
* How often (in milliseconds) to check if the host process is alive.
33+
* Only used when `clientProcessId` is set. Defaults to 3000 (3 seconds).
34+
*/
35+
watchdogIntervalMs?: number;
36+
}
37+
738
/**
839
* Server transport for stdio: this communicates with an MCP client by reading from the current process' `stdin` and writing to `stdout`.
940
*
@@ -19,11 +50,29 @@ import { process } from '@modelcontextprotocol/server/_shims';
1950
export class StdioServerTransport implements Transport {
2051
private _readBuffer: ReadBuffer = new ReadBuffer();
2152
private _started = false;
53+
private _clientProcessId?: number;
54+
private _watchdogInterval?: ReturnType<typeof setInterval>;
55+
private _watchdogIntervalMs: number;
56+
private _stdin: Readable;
57+
private _stdout: Writable;
2258

23-
constructor(
24-
private _stdin: Readable = process.stdin,
25-
private _stdout: Writable = process.stdout
26-
) {}
59+
constructor(options?: StdioServerTransportOptions);
60+
constructor(stdin?: Readable, stdout?: Writable);
61+
constructor(stdinOrOptions?: Readable | StdioServerTransportOptions, stdout?: Writable) {
62+
if (stdinOrOptions && typeof stdinOrOptions === 'object' && !('read' in stdinOrOptions)) {
63+
// Options object form
64+
const options = stdinOrOptions as StdioServerTransportOptions;
65+
this._stdin = options.stdin ?? process.stdin;
66+
this._stdout = options.stdout ?? process.stdout;
67+
this._clientProcessId = options.clientProcessId;
68+
this._watchdogIntervalMs = options.watchdogIntervalMs ?? 3000;
69+
} else {
70+
// Legacy positional args form
71+
this._stdin = (stdinOrOptions as Readable) ?? process.stdin;
72+
this._stdout = stdout ?? process.stdout;
73+
this._watchdogIntervalMs = 3000;
74+
}
75+
}
2776

2877
onclose?: () => void;
2978
onerror?: (error: Error) => void;
@@ -51,6 +100,37 @@ export class StdioServerTransport implements Transport {
51100
this._started = true;
52101
this._stdin.on('data', this._ondata);
53102
this._stdin.on('error', this._onerror);
103+
this._startHostWatchdog();
104+
}
105+
106+
private _startHostWatchdog(): void {
107+
if (this._clientProcessId === undefined || this._watchdogInterval) {
108+
return;
109+
}
110+
111+
const pid = this._clientProcessId;
112+
this._watchdogInterval = setInterval(() => {
113+
try {
114+
// Signal 0 does not kill the process; it checks if it exists.
115+
process.kill(pid, 0);
116+
} catch {
117+
// Host process is gone. Self-terminate.
118+
this._stopHostWatchdog();
119+
void this.close();
120+
}
121+
}, this._watchdogIntervalMs);
122+
123+
// Ensure the watchdog timer does not prevent the process from exiting.
124+
if (typeof this._watchdogInterval === 'object' && 'unref' in this._watchdogInterval) {
125+
this._watchdogInterval.unref();
126+
}
127+
}
128+
129+
private _stopHostWatchdog(): void {
130+
if (this._watchdogInterval) {
131+
clearInterval(this._watchdogInterval);
132+
this._watchdogInterval = undefined;
133+
}
54134
}
55135

56136
private processReadBuffer() {
@@ -69,6 +149,8 @@ export class StdioServerTransport implements Transport {
69149
}
70150

71151
async close(): Promise<void> {
152+
this._stopHostWatchdog();
153+
72154
// Remove our event listeners first
73155
this._stdin.off('data', this._ondata);
74156
this._stdin.off('error', this._onerror);

packages/server/test/server/stdio.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import process from 'node:process';
12
import { Readable, Writable } from 'node:stream';
23

34
import type { JSONRPCMessage } from '@modelcontextprotocol/core';
45
import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/core';
56

7+
import type { StdioServerTransportOptions } from '../../src/server/stdio.js';
68
import { StdioServerTransport } from '../../src/server/stdio.js';
79

810
let input: Readable;
@@ -102,3 +104,79 @@ test('should read multiple messages', async () => {
102104
await finished;
103105
expect(readMessages).toEqual(messages);
104106
});
107+
108+
test('should accept options object constructor', async () => {
109+
const server = new StdioServerTransport({ stdin: input, stdout: output });
110+
server.onerror = error => {
111+
throw error;
112+
};
113+
114+
let didClose = false;
115+
server.onclose = () => {
116+
didClose = true;
117+
};
118+
119+
await server.start();
120+
await server.close();
121+
expect(didClose).toBeTruthy();
122+
});
123+
124+
describe('host process watchdog', () => {
125+
test('should close transport when host process is gone', async () => {
126+
// Use a PID that does not exist
127+
const deadPid = 2147483647;
128+
const server = new StdioServerTransport({
129+
stdin: input,
130+
stdout: output,
131+
clientProcessId: deadPid,
132+
watchdogIntervalMs: 100
133+
});
134+
135+
const closed = new Promise<void>(resolve => {
136+
server.onclose = () => resolve();
137+
});
138+
139+
await server.start();
140+
141+
// Watchdog should detect the dead PID and close
142+
await closed;
143+
}, 10000);
144+
145+
test('should not close when host process is alive', async () => {
146+
// Use our own PID, which is always alive
147+
const server = new StdioServerTransport({
148+
stdin: input,
149+
stdout: output,
150+
clientProcessId: process.pid,
151+
watchdogIntervalMs: 100
152+
});
153+
154+
let didClose = false;
155+
server.onclose = () => {
156+
didClose = true;
157+
};
158+
159+
await server.start();
160+
161+
// Wait for several watchdog cycles
162+
await new Promise(resolve => setTimeout(resolve, 350));
163+
expect(didClose).toBe(false);
164+
165+
await server.close();
166+
});
167+
168+
test('should stop watchdog on close', async () => {
169+
const server = new StdioServerTransport({
170+
stdin: input,
171+
stdout: output,
172+
clientProcessId: process.pid,
173+
watchdogIntervalMs: 100
174+
});
175+
176+
await server.start();
177+
await server.close();
178+
179+
// If watchdog was not stopped, it would keep running. Verify no errors after close.
180+
await new Promise(resolve => setTimeout(resolve, 300));
181+
});
182+
});

0 commit comments

Comments
 (0)