Files
Jesse Vincent ea0a29e230 fix(mcp-server): kill orphaned npm install children and prevent install pile-up (#161)
mcp-server-wrapper.js ran `npm install` during the MCP handshake with no
signal handlers registered — a client-side connect-timeout kill left the
install running as an orphan, and repeated wrapper launches stacked
unbounded concurrent installs (#161 observed 5).

Factor spawn+lock+cleanup into cli/install-runner.js:
- runNpmInstall() tracks the spawned child and registers
  process.on('exit'|SIGTERM|SIGINT|SIGHUP) handlers for the duration of the
  install, removing them once the child exits. On POSIX the child is
  spawned detached so termination can process.kill(-pid, signal) the whole
  process group (npm's node-gyp grandchildren included), falling back to
  child.kill() if the group kill fails; Windows uses child.kill() only,
  since it can't kill by negative pid.
- acquireInstallLock()/releaseInstallLock() are a built-in-fs-only
  (atomic mkdirSync, EEXIST = held, mtime-based stale steal) exclusive lock
  in the plugin dir, deliberately not using proper-lockfile since that
  package is itself one of the things the guarded install is supposed to
  provide. A second wrapper invocation that sees the lock held waits
  (waitForInstallLock, bounded poll) instead of starting a competing
  install, then re-probes findMissingDeps before continuing.

Claude-Session: https://claude.ai/code/session_0112vdwZphiWzfCYfaXMes4C
2026-09-08 19:45:58 +00:00

100 lines
3.5 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* Cross-platform wrapper script for MCP server that ensures dependencies are installed
* This runs before the MCP server starts and works on Windows, macOS, and Linux
*/
import { spawn } from 'child_process';
import { existsSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { findMissingDeps } from './install-check.js';
import { acquireInstallLock, runNpmInstall, waitForInstallLock } from './install-runner.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Determine plugin root directory
const PLUGIN_ROOT = process.env.CLAUDE_PLUGIN_ROOT || join(__dirname, '..');
async function main() {
try {
// Probe each required runtime dependency's package.json — not just the
// node_modules directory. A partial extraction (folder exists but the
// package is missing its manifest and lib/) would slip past an existsSync
// check on node_modules alone and crash the server with ERR_MODULE_NOT_FOUND
// *after* the wrapper has handed off to dist/mcp-server.js (#95 Bug 1).
const missing = findMissingDeps(PLUGIN_ROOT);
if (missing.length > 0) {
console.error(`Missing dependencies under node_modules: ${missing.join(', ')}`);
// Single-flight: a second wrapper launched while one is already
// installing must not start a competing install (#161) — repeated
// wrapper launches within the client's connect-timeout window would
// otherwise stack unbounded concurrent `npm install`s.
const lock = acquireInstallLock(PLUGIN_ROOT);
if (lock) {
const install = runNpmInstall(PLUGIN_ROOT, { lockHandle: lock });
await install.promise;
} else {
console.error('Another install is already in progress; waiting for it to finish...');
await waitForInstallLock(PLUGIN_ROOT);
// Re-probe: the other install may have completed the deps by now.
const stillMissing = findMissingDeps(PLUGIN_ROOT);
if (stillMissing.length > 0) {
console.error(`Still missing after waiting: ${stillMissing.join(', ')}. Continuing anyway.`);
}
}
}
// Start the MCP server
const mcpServerPath = join(PLUGIN_ROOT, 'dist', 'mcp-server.js');
if (!existsSync(mcpServerPath)) {
console.error(`ERROR: MCP server not found at ${mcpServerPath}`);
console.error('Please run: npm run build');
process.exit(1);
}
// Use spawn with shell: false for better cross-platform compatibility
const child = spawn(process.execPath, [mcpServerPath], {
stdio: 'inherit',
shell: false
});
// Forward signals to the child process
process.on('SIGTERM', () => child.kill('SIGTERM'));
process.on('SIGINT', () => child.kill('SIGINT'));
process.on('SIGHUP', () => child.kill('SIGHUP'));
// Detect parent process death via stdin close
// When Claude exits (normally or abnormally), stdin will close
process.stdin.on('end', () => {
child.kill();
process.exit(0);
});
child.on('exit', (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
} else {
process.exit(code || 0);
}
});
child.on('error', (err) => {
console.error(`ERROR: Failed to start MCP server: ${err.message}`);
process.exit(1);
});
} catch (error) {
console.error(`ERROR: ${error.message}`);
process.exit(1);
}
}
main().catch((error) => {
console.error(`Unexpected error: ${error.message}`);
process.exit(1);
});