import { defineEventHandler, readBody, setResponseHeaders } from 'h3' import { homedir } from 'node:os' import { join, resolve, dirname } from 'node:path' import { readFile, writeFile, mkdir } from 'node:fs/promises' import { existsSync } from 'node:fs' import { execSync } from 'node:child_process' import { fileURLToPath } from 'node:url' // ESM-compatible __dirname polyfill const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) const MCP_DEFAULT_PORT = 3100 interface InstallBody { tool: string action: 'install' | 'uninstall' transportMode?: 'stdio' | 'http' | 'both' httpPort?: number } interface InstallResult { success: boolean error?: string configPath?: string /** True when node was not found and HTTP URL fallback was used */ fallbackHttp?: boolean } const MCP_SERVER_NAME = 'openpencil' /** * Resolve the absolute path to the compiled MCP server. * In dev: /dist/mcp-server.cjs * In production (Electron): /mcp-server.cjs */ function resolveMcpServerPath(): string { // Electron production: extraResources places it in resourcesPath const electronResources = process.env.ELECTRON_RESOURCES_PATH if (electronResources) { const electronPath = join(electronResources, 'mcp-server.cjs') if (existsSync(electronPath)) return electronPath } // Try dist/ in the project root (dev + web build) const projectDist = resolve(process.cwd(), 'dist', 'mcp-server.cjs') if (existsSync(projectDist)) return projectDist // Fallback: try relative to this file const serverDist = resolve(__dirname, '..', '..', '..', 'dist', 'mcp-server.cjs') if (existsSync(serverDist)) return serverDist return projectDist // Return expected path even if not yet compiled } /** * Detect if `node` is available on the system. * Checks PATH first, then common install locations (the Nitro/Electron * process may run with a stripped PATH that doesn't include the user's * node installation). * Caches the result for the lifetime of the process. */ let _nodeAvailable: boolean | null = null function isNodeAvailable(): boolean { if (_nodeAvailable !== null) return _nodeAvailable // Try PATH first try { execSync('node --version', { stdio: 'ignore', timeout: 5000 }) _nodeAvailable = true return true } catch { /* not on PATH */ } // Check common absolute paths (macOS/Linux + Windows) const candidates = process.platform === 'win32' ? [ join(process.env.ProgramFiles ?? 'C:\\Program Files', 'nodejs', 'node.exe'), join(process.env.LOCALAPPDATA ?? '', 'fnm_multishells', '**', 'node.exe'), join(homedir(), '.nvm', 'current', 'bin', 'node.exe'), ] : [ '/usr/local/bin/node', '/usr/bin/node', join(homedir(), '.nvm', 'versions', 'node'), // nvm directory '/opt/homebrew/bin/node', ] for (const p of candidates) { if (existsSync(p)) { _nodeAvailable = true return true } } _nodeAvailable = false return false } function buildMcpServerEntry( serverPath: string, transportMode: 'stdio' | 'http' | 'both' = 'stdio', httpPort = MCP_DEFAULT_PORT, ): { command: string; args: string[] } { switch (transportMode) { case 'http': return { command: 'node', args: [serverPath, '--http', '--port', String(httpPort)] } case 'both': return { command: 'node', args: [serverPath, '--http', '--port', String(httpPort), '--stdio'] } default: return { command: 'node', args: [serverPath] } } } /** Build an HTTP URL-based MCP server entry (no local node required). */ function buildMcpHttpUrlEntry(httpPort = MCP_DEFAULT_PORT): { type: 'http'; url: string } { return { type: 'http', url: `http://127.0.0.1:${httpPort}/mcp` } } /** Config file locations and formats for each CLI tool. */ interface CliConfigDef { configPath: () => string read: (filePath: string) => Promise> write: (filePath: string, config: Record) => Promise } function installMcpServer( config: Record, serverPath: string, transportMode?: 'stdio' | 'http' | 'both', httpPort?: number, ): Record { return { ...config, mcpServers: { ...(config.mcpServers ?? {}), [MCP_SERVER_NAME]: buildMcpServerEntry(serverPath, transportMode, httpPort), }, } } /** Install MCP server using HTTP URL (for environments without node). */ function installMcpServerHttpUrl( config: Record, httpPort?: number, ): Record { return { ...config, mcpServers: { ...(config.mcpServers ?? {}), [MCP_SERVER_NAME]: buildMcpHttpUrlEntry(httpPort), }, } } function uninstallMcpServer(config: Record): Record { const servers = { ...(config.mcpServers ?? {}) } delete servers[MCP_SERVER_NAME] return { ...config, mcpServers: Object.keys(servers).length > 0 ? servers : undefined } } const CLI_CONFIGS: Record = { 'claude-code': { configPath: () => join(homedir(), '.claude.json'), read: readJsonConfig, write: writeJsonConfig, }, 'codex-cli': { configPath: () => join(homedir(), '.codex', 'config.json'), read: readJsonConfig, write: writeJsonConfig, }, 'gemini-cli': { configPath: () => join(homedir(), '.gemini', 'settings.json'), read: readJsonConfig, write: writeJsonConfig, }, 'opencode-cli': { configPath: () => join(homedir(), '.opencode', 'config.json'), read: readJsonConfig, write: writeJsonConfig, }, 'kiro-cli': { configPath: () => join(homedir(), '.kiro', 'settings.json'), read: readJsonConfig, write: writeJsonConfig, }, 'copilot-cli': { configPath: () => join(homedir(), '.config', 'github-copilot', 'mcp.json'), read: readJsonConfig, write: writeJsonConfig, }, } async function readJsonConfig(filePath: string): Promise> { try { const text = await readFile(filePath, 'utf-8') return JSON.parse(text) } catch { return {} } } async function writeJsonConfig( filePath: string, config: Record, ): Promise { const dir = join(filePath, '..') if (!existsSync(dir)) { await mkdir(dir, { recursive: true }) } await writeFile(filePath, JSON.stringify(config, null, 2) + '\n', 'utf-8') } /** * POST /api/ai/mcp-install * Install or uninstall the openpencil MCP server into a CLI tool's config. */ export default defineEventHandler(async (event) => { const body = await readBody(event) setResponseHeaders(event, { 'Content-Type': 'application/json' }) if (!body?.tool || !body?.action) { return { success: false, error: 'Missing tool or action field' } satisfies InstallResult } const cliConfig = CLI_CONFIGS[body.tool] if (!cliConfig) { return { success: false, error: `Unknown CLI tool: ${body.tool}` } satisfies InstallResult } try { const configPath = cliConfig.configPath() const config = await cliConfig.read(configPath) let updated: Record let fallbackHttp = false if (body.action === 'uninstall') { updated = uninstallMcpServer(config) } else if (!isNodeAvailable()) { // No node on this machine — fall back to HTTP URL config // and ensure the MCP HTTP server is running const httpPort = body.httpPort ?? MCP_DEFAULT_PORT updated = installMcpServerHttpUrl(config, httpPort) fallbackHttp = true // Auto-start the MCP HTTP server so the URL is reachable try { const { startMcpHttpServer } = await import('../../utils/mcp-server-manager') startMcpHttpServer(httpPort) } catch { // Non-fatal: server may already be running or will be started manually } } else { const serverPath = resolveMcpServerPath() updated = installMcpServer(config, serverPath, body.transportMode, body.httpPort) } await cliConfig.write(configPath, updated) return { success: true, configPath, ...(fallbackHttp ? { fallbackHttp } : {}) } satisfies InstallResult } catch (err) { return { success: false, error: err instanceof Error ? err.message : String(err), } satisfies InstallResult } })