import { spawn } from 'node:child_process'; import { existsSync, writeFileSync, unlinkSync, readFileSync } from 'node:fs'; import { networkInterfaces } from 'node:os'; import { join, resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { tmpdir } from 'node:os'; // ESM-compatible __dirname polyfill const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // PID/Port files for tracking the detached MCP server process across restarts const MCP_PID_FILE = join(tmpdir(), 'openpencil-mcp-server.pid'); const MCP_PORT_FILE = join(tmpdir(), 'openpencil-mcp-server.port'); /** Resolve the MCP server script path across dev, web build, and Electron production. */ function resolveMcpServerScript(): string { // Electron production: extraResources const electronResources = process.env.ELECTRON_RESOURCES_PATH; if (electronResources) { const p = join(electronResources, 'mcp-server.cjs'); if (existsSync(p)) return p; } // dev + web build (from cwd) — monorepo outputs to out/ // In dev mode, CWD is apps/web/ (due to "cd apps/web && ..." in dev script), // so also check ../../out/ to reach the monorepo root. for (const base of [ resolve(process.cwd(), 'out', 'mcp-server.cjs'), resolve(process.cwd(), '..', '..', 'out', 'mcp-server.cjs'), ]) { if (existsSync(base)) return base; } // Fallback: relative to this file (Nitro bundled output) const fromFile = resolve(__dirname, '..', '..', '..', 'out', 'mcp-server.cjs'); if (existsSync(fromFile)) return fromFile; return resolve(process.cwd(), 'out', 'mcp-server.cjs'); } /** Get the first non-internal IPv4 address (LAN IP). */ export function getLocalIp(): string | null { const nets = networkInterfaces(); for (const name of Object.keys(nets)) { for (const net of nets[name] ?? []) { if (net.family === 'IPv4' && !net.internal) { return net.address; } } } return null; } /** Check if a process with the given PID is running. */ function isProcessRunning(pid: number): boolean { try { // Signal 0 checks existence without actually sending a signal process.kill(pid, 0); return true; } catch { return false; } } /** Read PID from file if it exists and process is still running. */ function getRunningPid(): { pid: number; port: number } | null { try { if (!existsSync(MCP_PID_FILE)) return null; const pid = parseInt(readFileSync(MCP_PID_FILE, 'utf-8').trim(), 10); if (isNaN(pid) || !isProcessRunning(pid)) { // Stale PID file - clean up try { unlinkSync(MCP_PID_FILE); } catch { /* ignore */ } try { unlinkSync(MCP_PORT_FILE); } catch { /* ignore */ } return null; } const port = existsSync(MCP_PORT_FILE) ? parseInt(readFileSync(MCP_PORT_FILE, 'utf-8').trim(), 10) : 3100; return { pid, port: isNaN(port) ? 3100 : port }; } catch { return null; } } export function getMcpServerStatus(): { running: boolean; port: number | null; localIp: string | null; } { const info = getRunningPid(); if (!info) { return { running: false, port: null, localIp: null }; } return { running: true, port: info.port, localIp: getLocalIp() }; } export function startMcpHttpServer(port: number): { running: boolean; port: number; localIp: string | null; error?: string; } { // Check if already running const existing = getRunningPid(); if (existing) { return { running: true, port: existing.port, localIp: getLocalIp() }; } const serverScript = resolveMcpServerScript(); try { // CRITICAL: Use detached mode with unref() so the MCP server survives // independently of the parent Nitro process. This prevents the server // from dying when: // 1. The UI settings dialog is closed // 2. The user interacts with the editor canvas // 3. The Nitro server restarts or hot-reloads // 4. The Electron app sends SIGTERM to Nitro on window close // // The process runs in its own session and writes its PID to a file // for later tracking and graceful shutdown. const child = spawn(process.execPath, [serverScript, '--http', '--port', String(port)], { stdio: ['ignore', 'ignore', 'ignore'], env: { ...process.env, // In Electron, process.execPath is the Electron binary. // ELECTRON_RUN_AS_NODE makes it behave as plain Node.js. ELECTRON_RUN_AS_NODE: '1', }, detached: true, windowsHide: true, }); // Allow parent to exit independently of child child.unref(); // Write PID to file for later tracking (after brief delay to ensure startup) const childPid = child.pid; if (childPid) { setTimeout(() => { try { if (isProcessRunning(childPid)) { writeFileSync(MCP_PID_FILE, String(childPid), 'utf-8'); writeFileSync(MCP_PORT_FILE, String(port), 'utf-8'); } } catch { /* ignore write errors */ } }, 100); } return { running: true, port, localIp: getLocalIp() }; } catch (err) { return { running: false, port, localIp: null, error: err instanceof Error ? err.message : String(err), }; } } export function stopMcpHttpServer(): { running: false } { const info = getRunningPid(); if (info) { try { // Use process.kill which is cross-platform and safe process.kill(info.pid, 'SIGTERM'); } catch { /* process may have already exited */ } // Clean up PID/Port files try { unlinkSync(MCP_PID_FILE); } catch { /* ignore */ } try { unlinkSync(MCP_PORT_FILE); } catch { /* ignore */ } } return { running: false }; }