From edd75f7e67c38a2ea1ecdda147f8c6ed936b1565 Mon Sep 17 00:00:00 2001 From: Kripa Dev Date: Sun, 29 Mar 2026 19:39:39 +0530 Subject: [PATCH] server: add optional stdio parent process monitoring --- packages/server/src/index.ts | 1 + packages/server/src/server/stdio.examples.ts | 14 ++++ packages/server/src/server/stdio.ts | 69 +++++++++++++++++++- packages/server/src/shimsWorkerd.ts | 3 + packages/server/test/server/stdio.test.ts | 53 +++++++++++++++ 5 files changed, 138 insertions(+), 2 deletions(-) diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index c680dffe7..987d4d7b3 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -28,6 +28,7 @@ export type { HostHeaderValidationResult } from './server/middleware/hostHeaderV export { hostHeaderValidationResponse, localhostAllowedHostnames, validateHostHeader } from './server/middleware/hostHeaderValidation.js'; export type { ServerOptions } from './server/server.js'; export { Server } from './server/server.js'; +export type { StdioServerTransportOptions } from './server/stdio.js'; export { StdioServerTransport } from './server/stdio.js'; export type { EventId, diff --git a/packages/server/src/server/stdio.examples.ts b/packages/server/src/server/stdio.examples.ts index de4603eaa..235c9a42a 100644 --- a/packages/server/src/server/stdio.examples.ts +++ b/packages/server/src/server/stdio.examples.ts @@ -20,3 +20,17 @@ async function StdioServerTransport_basicUsage() { await server.connect(transport); //#endregion StdioServerTransport_basicUsage } + +/** + * Example: Stdio transport with parent process monitoring. + */ +async function StdioServerTransport_parentProcessMonitoring() { + //#region StdioServerTransport_parentProcessMonitoring + const server = new McpServer({ name: 'my-server', version: '1.0.0' }); + const transport = new StdioServerTransport(process.stdin, process.stdout, { + parentPid: process.ppid, + parentCheckInterval: 3000 + }); + await server.connect(transport); + //#endregion StdioServerTransport_parentProcessMonitoring +} diff --git a/packages/server/src/server/stdio.ts b/packages/server/src/server/stdio.ts index ac2dd3f78..d2e3c7728 100644 --- a/packages/server/src/server/stdio.ts +++ b/packages/server/src/server/stdio.ts @@ -4,6 +4,27 @@ import type { JSONRPCMessage, Transport } from '@modelcontextprotocol/core'; import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/core'; import { process } from '@modelcontextprotocol/server/_shims'; +/** + * Options for StdioServerTransport + */ +export interface StdioServerTransportOptions { + /** + * Optional parent process ID to monitor. If provided, the transport will periodically check + * if the parent process is still alive and close itself when the parent exits. + * This helps prevent zombie processes. + * + * @default undefined (no monitoring) + */ + parentPid?: number; + + /** + * Interval in milliseconds for checking parent process liveness + * + * @default 3000 + */ + parentCheckInterval?: number; +} + /** * Server transport for stdio: this communicates with an MCP client by reading from the current process' `stdin` and writing to `stdout`. * @@ -20,11 +41,18 @@ export class StdioServerTransport implements Transport { private _readBuffer: ReadBuffer = new ReadBuffer(); private _started = false; private _closed = false; + private _parentCheckTimer?: NodeJS.Timeout; + private _parentPid?: number; + private _parentCheckInterval: number; constructor( private _stdin: Readable = process.stdin, - private _stdout: Writable = process.stdout - ) {} + private _stdout: Writable = process.stdout, + options?: StdioServerTransportOptions + ) { + this._parentPid = options?.parentPid; + this._parentCheckInterval = options?.parentCheckInterval ?? 3000; + } onclose?: () => void; onerror?: (error: Error) => void; @@ -59,6 +87,38 @@ export class StdioServerTransport implements Transport { this._stdin.on('data', this._ondata); this._stdin.on('error', this._onerror); this._stdout.on('error', this._onstdouterror); + + // Start parent process monitoring if parentPid was provided + if (this._parentPid !== undefined) { + this._startParentMonitoring(); + } + } + + /** + * Starts periodic checks to see if the parent process is still alive. + * If the parent process has exited, this transport will close itself. + */ + private _startParentMonitoring(): void { + this._parentCheckTimer = setInterval(() => { + try { + // process.kill with signal 0 checks if process exists without actually killing it + process.kill(this._parentPid!, 0); + } catch (error) { + const errno = error as NodeJS.ErrnoException; + if (errno.code === 'EPERM') { + // Process exists but we don't have permission to signal it. + return; + } + + // Parent process no longer exists - close this transport + this.close().catch(() => { + // Ignore errors during close + }); + } + }, this._parentCheckInterval); + + // Prevent the timer from keeping the process alive + this._parentCheckTimer.unref(); } private processReadBuffer() { @@ -82,6 +142,11 @@ export class StdioServerTransport implements Transport { } this._closed = true; + if (this._parentCheckTimer !== undefined) { + clearInterval(this._parentCheckTimer); + this._parentCheckTimer = undefined; + } + // Remove our event listeners first this._stdin.off('data', this._ondata); this._stdin.off('error', this._onerror); diff --git a/packages/server/src/shimsWorkerd.ts b/packages/server/src/shimsWorkerd.ts index 565908be8..3fdce5a01 100644 --- a/packages/server/src/shimsWorkerd.ts +++ b/packages/server/src/shimsWorkerd.ts @@ -19,5 +19,8 @@ export const process = { }, get stdout(): never { return notSupported(); + }, + kill(): never { + return notSupported(); } }; diff --git a/packages/server/test/server/stdio.test.ts b/packages/server/test/server/stdio.test.ts index 92671cacd..bb10f2ab0 100644 --- a/packages/server/test/server/stdio.test.ts +++ b/packages/server/test/server/stdio.test.ts @@ -2,6 +2,7 @@ import { Readable, Writable } from 'node:stream'; import type { JSONRPCMessage } from '@modelcontextprotocol/core'; import { ReadBuffer, serializeMessage } from '@modelcontextprotocol/core'; +import { vi } from 'vitest'; import { StdioServerTransport } from '../../src/server/stdio.js'; @@ -179,3 +180,55 @@ test('should fire onerror before onclose on stdout error', async () => { expect(events).toEqual(['error', 'close']); }); + +test('should close transport when monitored parent process exits', async () => { + const killSpy = vi.spyOn(process, 'kill').mockImplementation((_pid, _signal) => { + const err = new Error('No such process') as NodeJS.ErrnoException; + err.code = 'ESRCH'; + throw err; + }); + + try { + const server = new StdioServerTransport(input, output, { + parentPid: 424242, + parentCheckInterval: 1 + }); + + let didClose = false; + server.onclose = () => { + didClose = true; + }; + + await server.start(); + + // Wait briefly for parent monitor tick + await new Promise(resolve => setTimeout(resolve, 10)); + + expect(killSpy).toHaveBeenCalledWith(424242, 0); + expect(didClose).toBeTruthy(); + } finally { + killSpy.mockRestore(); + } +}); + +test('should clear parent monitor timer on close', async () => { + const killSpy = vi.spyOn(process, 'kill').mockReturnValue(true); + + try { + const server = new StdioServerTransport(input, output, { + parentPid: 99999, + parentCheckInterval: 5 + }); + + await server.start(); + await server.close(); + + const callsAtClose = killSpy.mock.calls.length; + await new Promise(resolve => setTimeout(resolve, 20)); + + // No additional kill checks should happen after close + expect(killSpy.mock.calls.length).toBe(callsAtClose); + } finally { + killSpy.mockRestore(); + } +});