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
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"postbuild": "chmod +x dist/index.js",
"dev": "tsx watch src/index.ts",
"start": "node dist/index.js",
"test": "node -e \"console.log('mcp-local-wp build ok')\"",
"test": "tsx --test tests/lifecycle.test.ts",
"prepublishOnly": "npm run build && npm run postbuild",
"lint": "eslint \"src/**/*.{ts,js}\"",
"lint:fix": "eslint \"src/**/*.{ts,js}\" --fix",
Expand Down
24 changes: 13 additions & 11 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,
Expand All @@ -11,13 +12,16 @@
import { getLocalMySQLConfig, listAvailableSites } from './local-detector.js';
import type { SiteSelectionResult } from './types.js';

// Pin before sync startup (dotenv + Local detection) — process.ppid is dynamic.
const parentPid = process.ppid;

// Load environment variables
config();

// Check if write operations are enabled
const allowWrites = process.env.MYSQL_ALLOW_WRITES === 'true';

function debugLog(...args: any[]) {

Check warning on line 24 in src/index.ts

View workflow job for this annotation

GitHub Actions / test

Unexpected any. Specify a different type
if (process.env.DEBUG && process.env.DEBUG.includes('mcp-local-wp')) {
// eslint-disable-next-line no-console
console.error('[mcp-local-wp]', ...args);
Expand Down Expand Up @@ -314,21 +318,19 @@
// Start the server
async function main() {
const transport = new StdioServerTransport();
installStdioLifecycle({
transport,
onCloseAssignable: server,
envName: 'LOCALWP_PARENT_WATCHDOG_MS',
parentPid,
onShutdown: () => {
void mysql.disconnect();
Comment thread
jack-arturo marked this conversation as resolved.
},
});
await server.connect(transport);
console.error('WordPress Local MCP Server running...');
}

// Cleanup on exit
process.on('SIGINT', async () => {
await mysql.disconnect();
process.exit(0);
});

process.on('SIGTERM', async () => {
await mysql.disconnect();
process.exit(0);
});

main().catch((error) => {
console.error('Server error:', error);
process.exit(1);
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 LOCALWP_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;
Comment thread
jack-arturo marked this conversation as resolved.
}

/**
* 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 ?? 'LOCALWP_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 assert from 'node:assert/strict';
import { afterEach, describe, it, mock } from 'node:test';
import {
DEFAULT_PARENT_WATCHDOG_MS,
installStdioLifecycle,
parseWatchdogIntervalMs,
startParentWatchdog,
} from '../src/lifecycle.ts';

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

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

describe('startParentWatchdog', () => {
it('fires once when the probe reports parent gone', async () => {
let calls = 0;
const timer = startParentWatchdog(1, 100, () => {
calls += 1;
}, () => true);
assert.equal(calls, 0);
await new Promise((r) => setTimeout(r, 150));
assert.equal(calls, 1);
await new Promise((r) => setTimeout(r, 250));
assert.equal(calls, 1);
clearInterval(timer);
});
});

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

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

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

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

assert.equal(transport.close.mock.callCount(), 0);
assert.equal(exitMock.mock.callCount(), 0);
});
});
Loading