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
9 changes: 9 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { installStdioLifecycle } from './lifecycle.js';
import { CallToolRequestSchema, ListToolsRequestSchema, Tool } from '@modelcontextprotocol/sdk/types.js';
import { config } from 'dotenv';
import { PirschAPI } from './pirsch-api.js';
Expand Down Expand Up @@ -620,7 +621,15 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
});

async function main() {
// Capture before any await — process.ppid is dynamic.
const parentPid = process.ppid;
const transport = new StdioServerTransport();
installStdioLifecycle({
transport,
onCloseAssignable: server,
envName: 'PIRSCH_PARENT_WATCHDOG_MS',
parentPid,
});
await server.connect(transport);
console.error('Pirsch MCP server running');
}
Expand Down
128 changes: 128 additions & 0 deletions src/lifecycle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* Parent-liveness watchdog for the stdio MCP server.
*
* When an intermediate wrapper (npx → npm exec → node bin → server) keeps the
* server's stdin write-end open, a dead client never delivers EOF, so the leaf
* sits in the event loop forever and can thrash swap (multi-GB). Soft stdin
* EOF alone is not enough to catch that orphan case. The watchdog catches it
* by noticing the original parent is gone.
*
* Soft stdin EOF is intentional: do NOT call `transport.close()` and do NOT
* `process.exit` on `end`/`close`. Closing the transport trips the MCP SDK
* `onclose` → hard exit and breaks one-shot `echo … | npm start` flows.
* Hard exit is reserved for parent-watchdog reparent + OS signals.
*
* Kept side-effect-free on import so unit tests can cover it without spawning
* the full server.
*/

export type ParentLivenessProbe = (parentPid: number) => boolean;

/** Poll interval (ms) used when the env override is unset or invalid. */
export const DEFAULT_PARENT_WATCHDOG_MS = 30_000;

const MIN_PARENT_WATCHDOG_MS = 100;

/**
* Parse PIRSCH_PARENT_WATCHDOG_MS into a safe poll interval.
*
* Non-finite / zero / negative → default. No disable value: an unparseable
* knob must never silently turn orphan protection off. Floored at
* MIN_PARENT_WATCHDOG_MS so tiny inputs can't spin the CPU.
*/
export function parseWatchdogIntervalMs(raw: string | undefined): number {
const n = Number(raw);
if (!Number.isFinite(n) || n <= 0) return DEFAULT_PARENT_WATCHDOG_MS;
return Math.max(n, MIN_PARENT_WATCHDOG_MS);
Comment thread
jack-arturo marked this conversation as resolved.
}

/**
* Default probe: the server has been reparented away from its original parent.
*
* POSIX only: this relies on the kernel reparenting an orphan (to pid 1 or a
* subreaper) when its parent dies. Windows does not reparent orphans, so
* `process.ppid` never changes there and this probe never fires — the watchdog
* is a no-op on win32. Orphan mitigation on Windows would need a different
* mechanism (e.g. a job object or a stdin heartbeat). Matches mcp-automem.
*/
export function parentReparented(parentPid: number): boolean {
return process.ppid !== parentPid;
}

/**
* Poll for the original parent's death and invoke `onDead` exactly once.
* Interval is unref'd so the watchdog alone never keeps the event loop alive.
*/
export function startParentWatchdog(
parentPid: number,
intervalMs: number,
onDead: () => void,
isParentGone: ParentLivenessProbe = parentReparented
): NodeJS.Timeout {
let fired = false;
const timer = setInterval(() => {
if (fired) return;
if (isParentGone(parentPid)) {
fired = true;
onDead();
}
}, intervalMs);
timer.unref();
return timer;
}

/**
* Install stdin/transport/signal/parent-watchdog shutdown hooks.
* Call before `server.connect(transport)`.
*
* If `main()` awaits anything before this call, pass `parentPid` captured
* synchronously at the top of `main()` — `process.ppid` is dynamic, and a
* late capture after reparent would make the watchdog a no-op (mcp-automem #137).
*/
export function installStdioLifecycle(options: {
transport: { close?: () => unknown };
onCloseAssignable?: { onclose?: (() => void) | null };
envName?: string;
onShutdown?: () => void;
/** Prefer a pid captured before any `await` in `main()`. */
parentPid?: number;
}): () => void {
const parentPid = options.parentPid ?? process.ppid;
let shuttingDown = false;
const shutdown = (code = 0) => {
if (shuttingDown) return;
shuttingDown = true;
try {
options.onShutdown?.();
} catch {
/* best effort */
}
try {
void options.transport.close?.();
} catch {
/* best effort */
}
process.exit(code);
};

// Soft stdin EOF → no-op. Do not transport.close() (SDK onclose → hard exit)
// and do not process.exit. One-shot pipes must drain in-flight handlers;
// orphans that keep stdin open are killed by the parent watchdog / signals.
process.stdin.on('end', () => {});
process.stdin.on('close', () => {});
if (options.onCloseAssignable) {
options.onCloseAssignable.onclose = () => shutdown(0);
}
for (const sig of ['SIGTERM', 'SIGINT', 'SIGHUP'] as const) {
process.on(sig, () => shutdown(0));
}

const envName = options.envName ?? 'PIRSCH_PARENT_WATCHDOG_MS';
startParentWatchdog(
parentPid,
parseWatchdogIntervalMs(process.env[envName]),
() => shutdown(0)
);

return shutdown;
}
61 changes: 61 additions & 0 deletions tests/lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
DEFAULT_PARENT_WATCHDOG_MS,
installStdioLifecycle,
parseWatchdogIntervalMs,
startParentWatchdog,
} from '../src/lifecycle.js';

describe('parseWatchdogIntervalMs', () => {
it('defaults on unset/invalid/non-positive', () => {
expect(parseWatchdogIntervalMs(undefined)).toBe(DEFAULT_PARENT_WATCHDOG_MS);
expect(parseWatchdogIntervalMs('')).toBe(DEFAULT_PARENT_WATCHDOG_MS);
expect(parseWatchdogIntervalMs('nope')).toBe(DEFAULT_PARENT_WATCHDOG_MS);
expect(parseWatchdogIntervalMs('0')).toBe(DEFAULT_PARENT_WATCHDOG_MS);
expect(parseWatchdogIntervalMs('-1')).toBe(DEFAULT_PARENT_WATCHDOG_MS);
});

it('honours positive values floored at 100ms', () => {
expect(parseWatchdogIntervalMs('250')).toBe(250);
expect(parseWatchdogIntervalMs('50')).toBe(100);
});
});

describe('startParentWatchdog', () => {
it('fires once when the probe reports parent gone', async () => {
vi.useFakeTimers();
const onDead = vi.fn();
startParentWatchdog(1, 100, onDead, () => true);
expect(onDead).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(100);
expect(onDead).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(500);
expect(onDead).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
});

describe('installStdioLifecycle soft stdin EOF', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('does not close transport or exit on stdin end/close', () => {
const transport = { close: vi.fn() };
const exitSpy = vi
.spyOn(process, 'exit')
.mockImplementation((() => undefined) as never);

installStdioLifecycle({
transport,
parentPid: process.ppid,
envName: 'PIRSCH_PARENT_WATCHDOG_MS',
});

process.stdin.emit('end');
process.stdin.emit('close');

expect(transport.close).not.toHaveBeenCalled();
expect(exitSpy).not.toHaveBeenCalled();
});
});
Loading