192 lines
5.7 KiB
TypeScript
192 lines
5.7 KiB
TypeScript
|
|
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 };
|
||
|
|
}
|