mirror of
https://github.com/obra/episodic-memory.git
synced 2026-09-14 13:43:14 +08:00
ea0a29e230
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
100 lines
3.5 KiB
JavaScript
Executable File
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);
|
|
});
|