openpencil/apps/web/server/api/ai/mcp-install.ts
Kayshen-X a7d73ebb62 feat(ai): pencil-style agentic design tool-loop, multi-chat tabs, #27 panel restyle
Built-in design generation now runs as an agentic MCP tool-loop (reusing the
agent-rs BuiltInProvider), gated behind OPENPENCIL_DESIGN_AGENT_LOOP / the
Settings experimental toggle; the orchestrator stays the default.

- design-agent system prompt + in-process design toolset (parity-locked with
  the MCP surface) + flag-gated Intent::Design routing
- spawn_agents execution as sequential sub-loops + live creation-mode badges
  (per-agent glow + 'N/M designing...' header)
- new MCP tools: get_guidelines, ToolSearch, get_screenshot, get_editor_state,
  export_nodes, spawn_agents; style-guide local audit
- #27 AI panel restyle: rounded tool cards + green check-rings, gray user
  bubbles, model-pill bottom toolbar, header, empty-state pills, the
  PARALLEL AGENTS (agent_team_size) 1x-6x chip dropdown
- multi-chat tabs: ChatSessions model (Deref-to-active) + tab row UI
  (switch / close / + / Cmd+T) with each run bound to its tab

Large checkpoint commit spanning the working tree (Rust shell crates).
2026-07-02 21:21:06 +08:00

406 lines
13 KiB
TypeScript

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, readdirSync, statSync } from 'node:fs';
import { execSync, execFileSync } 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';
const CODEX_CONFIG_PATH = join(homedir(), '.codex', 'config.toml');
/**
* Resolve the absolute path to the compiled MCP server.
* In dev: <project>/dist/mcp-server.cjs
* In production (Electron): <resources>/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;
}
// Monorepo root: cwd may be apps/web (dev) or project root (Electron)
// Walk up from cwd to find monorepo root (has package.json with workspaces)
let root = process.cwd();
for (let i = 0; i < 5; i++) {
const candidate = join(root, 'out', 'mcp-server.cjs');
if (existsSync(candidate)) return candidate;
const parent = dirname(root);
if (parent === root) break;
root = parent;
}
// Fallback: try relative to this file (Nitro bundles server code)
const fromFile = resolve(__dirname, '..', '..', '..', 'out', 'mcp-server.cjs');
if (existsSync(fromFile)) return fromFile;
// Return expected monorepo root path
return join(root, 'out', 'mcp-server.cjs');
}
/**
* 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;
let _nodeCommand: string | null = null;
function nodeCandidates(): string[] {
if (process.platform === 'win32') {
return [
'node.exe',
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'),
];
}
const candidates = ['node', '/usr/local/bin/node', '/usr/bin/node', '/opt/homebrew/bin/node'];
// NVM: resolve the active node version via the symlink or by reading
// .nvm/alias/default, then constructing the versioned bin path.
// The old path (`.nvm/versions/node`) is a directory, not a binary —
// existsSync would return true but executing it gives "Permission denied".
const nvmDir = join(homedir(), '.nvm');
const nvmCurrent = join(nvmDir, 'current', 'bin', 'node');
candidates.push(nvmCurrent);
// Fallback: if NVM_DIR/current doesn't exist, find the highest installed version
if (!existsSync(nvmCurrent)) {
const versionsDir = join(nvmDir, 'versions', 'node');
if (existsSync(versionsDir)) {
try {
const versions = readdirSync(versionsDir)
.filter((d) => d.startsWith('v'))
.sort();
if (versions.length > 0) {
candidates.push(join(versionsDir, versions[versions.length - 1], 'bin', 'node'));
}
} catch {
/* ignore */
}
}
}
return candidates;
}
function isNodeAvailable(): boolean {
if (_nodeAvailable !== null) return _nodeAvailable;
// Try PATH first
try {
const whichCmd = process.platform === 'win32' ? 'where node 2>nul' : 'which node 2>/dev/null';
const resolved = execSync(whichCmd, { encoding: 'utf-8', timeout: 5000 })
.trim()
.split(/\r?\n/)[0]
?.trim();
_nodeCommand = resolved || 'node';
_nodeAvailable = true;
return true;
} catch {
/* not on PATH */
}
// Check common absolute paths (macOS/Linux + Windows).
// Must verify the path is a file, not a directory — existsSync returns
// true for directories, which caused the NVM versions dir to be treated
// as a node binary.
for (const p of nodeCandidates().slice(1)) {
try {
if (existsSync(p) && statSync(p).isFile()) {
_nodeCommand = p;
_nodeAvailable = true;
return true;
}
} catch {
/* ignore stat errors */
}
}
_nodeAvailable = false;
return false;
}
function resolveNodeCommand(): string {
if (isNodeAvailable()) return _nodeCommand ?? 'node';
throw new Error('Node.js not found');
}
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<Record<string, any>>;
write: (filePath: string, config: Record<string, any>) => Promise<void>;
}
function installMcpServer(
config: Record<string, any>,
serverPath: string,
transportMode?: 'stdio' | 'http' | 'both',
httpPort?: number,
): Record<string, any> {
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<string, any>,
httpPort?: number,
): Record<string, any> {
return {
...config,
mcpServers: {
...config.mcpServers,
[MCP_SERVER_NAME]: buildMcpHttpUrlEntry(httpPort),
},
};
}
function uninstallMcpServer(config: Record<string, any>): Record<string, any> {
const servers = { ...config.mcpServers };
delete servers[MCP_SERVER_NAME];
return { ...config, mcpServers: Object.keys(servers).length > 0 ? servers : undefined };
}
const CLI_CONFIGS: Record<string, CliConfigDef> = {
'claude-code': {
configPath: () => join(homedir(), '.claude.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<Record<string, any>> {
try {
const text = await readFile(filePath, 'utf-8');
return JSON.parse(text);
} catch {
return {};
}
}
async function writeJsonConfig(filePath: string, config: Record<string, any>): Promise<void> {
const dir = join(filePath, '..');
if (!existsSync(dir)) {
await mkdir(dir, { recursive: true });
}
await writeFile(filePath, JSON.stringify(config, null, 2) + '\n', 'utf-8');
}
/**
* Run the codex CLI with args, handling Windows shim spawning.
*
* Since Node 18.20/20.12 (CVE-2024-27980), execFileSync refuses to spawn
* .cmd/.bat files directly (throws EINVAL). On Windows we route through the
* shell so PATHEXT resolves whichever shim exists (codex.exe / codex.cmd /
* codex.ps1). Args here are internal (no user input) — we just quote paths
* that contain spaces.
*/
function runCodex(args: string[]): void {
if (process.platform === 'win32') {
const cmdLine = ['codex', ...args].map(quoteWinArg).join(' ');
execSync(cmdLine, { encoding: 'utf-8', timeout: 15_000, stdio: 'pipe' });
return;
}
execFileSync('codex', args, {
encoding: 'utf-8',
timeout: 15_000,
stdio: 'pipe',
});
}
function quoteWinArg(arg: string): string {
if (arg.length === 0) return '""';
if (!/[\s"&|<>^%]/.test(arg)) return arg;
return `"${arg.replace(/"/g, '""')}"`;
}
async function installCodexMcp(
transportMode?: 'stdio' | 'http' | 'both',
httpPort?: number,
): Promise<{ configPath: string; fallbackHttp: boolean }> {
const serverPath = resolveMcpServerPath();
const port = httpPort ?? MCP_DEFAULT_PORT;
const useHttp = transportMode === 'http' || !isNodeAvailable();
try {
uninstallCodexMcp();
} catch {
// Ignore missing-entry cleanup failures; install below is the real operation.
}
if (useHttp) {
try {
const { startMcpHttpServer } = await import('../../utils/mcp-server-manager');
startMcpHttpServer(port);
} catch {
// Non-fatal: server may already be running or will be started manually
}
runCodex(['mcp', 'add', MCP_SERVER_NAME, '--url', `http://127.0.0.1:${port}/mcp`]);
return { configPath: CODEX_CONFIG_PATH, fallbackHttp: true };
}
runCodex(['mcp', 'add', MCP_SERVER_NAME, '--', resolveNodeCommand(), serverPath]);
return { configPath: CODEX_CONFIG_PATH, fallbackHttp: false };
}
function uninstallCodexMcp(): { configPath: string } {
runCodex(['mcp', 'remove', MCP_SERVER_NAME]);
return { configPath: CODEX_CONFIG_PATH };
}
/**
* 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<InstallBody>(event);
setResponseHeaders(event, { 'Content-Type': 'application/json' });
if (!body?.tool || !body?.action) {
return { success: false, error: 'Missing tool or action field' } satisfies InstallResult;
}
// Codex CLI uses its own `codex mcp add/remove` commands (writes ~/.codex/config.toml)
if (body.tool === 'codex-cli') {
try {
const result =
body.action === 'uninstall'
? uninstallCodexMcp()
: await installCodexMcp(body.transportMode, body.httpPort);
return {
success: true,
configPath: result.configPath,
...('fallbackHttp' in result && result.fallbackHttp ? { fallbackHttp: true } : {}),
} satisfies InstallResult;
} catch (err) {
return {
success: false,
error: err instanceof Error ? err.message : String(err),
} 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<string, any>;
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;
}
});