From cd4e1a2271fcbf2250c7e1987e235dcfde6978fb Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Mon, 13 Apr 2026 10:11:23 +0300 Subject: [PATCH] feat(mcp): add stdio transport for Claude Code / Cursor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add packages/mcp/src/stdio.ts — proper stdio MCP server that connects to the running app via WebSocket - openpencil-mcp bin now points to stdio entry (was HTTP) - openpencil-mcp-http bin added for the HTTP server - Extract registerTools() from server.ts to share between transports - Move index.ts banner output to stderr (prevents stdout corruption) - Update Tauri shell spawn to use openpencil-mcp-http - Update all docs (EN + 6 translations): correct binary names, source paths, port number Fixes #194 --- CHANGELOG.md | 5 + desktop/capabilities/default.json | 2 +- packages/docs/de/programmable/mcp-server.md | 8 +- packages/docs/es/programmable/mcp-server.md | 8 +- packages/docs/fr/programmable/mcp-server.md | 8 +- packages/docs/it/programmable/mcp-server.md | 8 +- packages/docs/pl/programmable/mcp-server.md | 8 +- packages/docs/programmable/mcp-server.md | 14 +- packages/docs/ru/programmable/mcp-server.md | 8 +- packages/mcp/package.json | 3 +- packages/mcp/src/index.ts | 8 +- packages/mcp/src/server.ts | 152 +++++++------- packages/mcp/src/stdio.ts | 99 ++++++++++ src/automation/spawn-mcp.ts | 4 +- tests/engine/mcp-stdio.test.ts | 207 ++++++++++++++++++++ 15 files changed, 435 insertions(+), 107 deletions(-) create mode 100644 packages/mcp/src/stdio.ts create mode 100644 tests/engine/mcp-stdio.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fae1c1b6c..3ada0d505 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,14 @@ ## Unreleased +### Features + +- Add stdio transport for MCP server — `openpencil-mcp` now works as a proper stdio MCP server for Claude Code, Cursor, etc. HTTP server available as `openpencil-mcp-http`. + ### Fixes - Fix color picker dragging flooding the undo stack — fill/stroke/effect color and opacity drags now collapse into a single undo entry per interaction via debounced batching in `PropertyListRoot` +- Fix .fig import crash on alias variables without a GUID ## 0.11.6 — 2026-04-08 diff --git a/desktop/capabilities/default.json b/desktop/capabilities/default.json index e105b04b1..1527af7d6 100644 --- a/desktop/capabilities/default.json +++ b/desktop/capabilities/default.json @@ -25,7 +25,7 @@ { "name": "claude-agent-acp", "cmd": "claude-agent-acp", "args": true }, { "name": "codex-acp", "cmd": "codex-acp", "args": true }, { "name": "gemini", "cmd": "gemini", "args": true }, - { "name": "openpencil-mcp", "cmd": "openpencil-mcp", "args": true } + { "name": "openpencil-mcp-http", "cmd": "openpencil-mcp-http", "args": true } ] }, "shell:allow-stdin-write", diff --git a/packages/docs/de/programmable/mcp-server.md b/packages/docs/de/programmable/mcp-server.md index e296a6447..d6d4fad71 100644 --- a/packages/docs/de/programmable/mcp-server.md +++ b/packages/docs/de/programmable/mcp-server.md @@ -32,7 +32,7 @@ Or run from source without installing: "mcpServers": { "open-pencil": { "command": "bun", - "args": ["/path/to/open-pencil/packages/mcp/src/index.ts"] + "args": ["/path/to/open-pencil/packages/mcp/src/stdio.ts"] } } } @@ -42,7 +42,7 @@ Or run from source without installing: "mcpServers": { "open-pencil": { "command": "npx", - "args": ["tsx", "/path/to/open-pencil/packages/mcp/src/index.ts"] + "args": ["tsx", "/path/to/open-pencil/packages/mcp/src/stdio.ts"] } } } @@ -57,7 +57,7 @@ For browser extensions, scripts, CI, or any HTTP client: openpencil-mcp-http ``` -Or from source: `bun packages/mcp/src/http.ts` / `npx tsx packages/mcp/src/http.ts` +Or from source: `bun packages/mcp/src/index.ts` / `npx tsx packages/mcp/src/index.ts` Security defaults (HTTP transport): @@ -67,7 +67,7 @@ Security defaults (HTTP transport): - CORS is disabled by default; set `OPENPENCIL_MCP_CORS_ORIGIN` to allow one origin - Optional auth token: `OPENPENCIL_MCP_AUTH_TOKEN` (client sends `Authorization: Bearer ` or `x-mcp-token`) -Server starts on port 3100 (override with `PORT` env var). Endpoints: +Server starts on port 7600 (override with `PORT` env var). Endpoints: - `GET /health` — server status - `POST /mcp` — MCP Streamable HTTP (SSE). Sessions via `mcp-session-id` header. diff --git a/packages/docs/es/programmable/mcp-server.md b/packages/docs/es/programmable/mcp-server.md index e296a6447..d6d4fad71 100644 --- a/packages/docs/es/programmable/mcp-server.md +++ b/packages/docs/es/programmable/mcp-server.md @@ -32,7 +32,7 @@ Or run from source without installing: "mcpServers": { "open-pencil": { "command": "bun", - "args": ["/path/to/open-pencil/packages/mcp/src/index.ts"] + "args": ["/path/to/open-pencil/packages/mcp/src/stdio.ts"] } } } @@ -42,7 +42,7 @@ Or run from source without installing: "mcpServers": { "open-pencil": { "command": "npx", - "args": ["tsx", "/path/to/open-pencil/packages/mcp/src/index.ts"] + "args": ["tsx", "/path/to/open-pencil/packages/mcp/src/stdio.ts"] } } } @@ -57,7 +57,7 @@ For browser extensions, scripts, CI, or any HTTP client: openpencil-mcp-http ``` -Or from source: `bun packages/mcp/src/http.ts` / `npx tsx packages/mcp/src/http.ts` +Or from source: `bun packages/mcp/src/index.ts` / `npx tsx packages/mcp/src/index.ts` Security defaults (HTTP transport): @@ -67,7 +67,7 @@ Security defaults (HTTP transport): - CORS is disabled by default; set `OPENPENCIL_MCP_CORS_ORIGIN` to allow one origin - Optional auth token: `OPENPENCIL_MCP_AUTH_TOKEN` (client sends `Authorization: Bearer ` or `x-mcp-token`) -Server starts on port 3100 (override with `PORT` env var). Endpoints: +Server starts on port 7600 (override with `PORT` env var). Endpoints: - `GET /health` — server status - `POST /mcp` — MCP Streamable HTTP (SSE). Sessions via `mcp-session-id` header. diff --git a/packages/docs/fr/programmable/mcp-server.md b/packages/docs/fr/programmable/mcp-server.md index e296a6447..d6d4fad71 100644 --- a/packages/docs/fr/programmable/mcp-server.md +++ b/packages/docs/fr/programmable/mcp-server.md @@ -32,7 +32,7 @@ Or run from source without installing: "mcpServers": { "open-pencil": { "command": "bun", - "args": ["/path/to/open-pencil/packages/mcp/src/index.ts"] + "args": ["/path/to/open-pencil/packages/mcp/src/stdio.ts"] } } } @@ -42,7 +42,7 @@ Or run from source without installing: "mcpServers": { "open-pencil": { "command": "npx", - "args": ["tsx", "/path/to/open-pencil/packages/mcp/src/index.ts"] + "args": ["tsx", "/path/to/open-pencil/packages/mcp/src/stdio.ts"] } } } @@ -57,7 +57,7 @@ For browser extensions, scripts, CI, or any HTTP client: openpencil-mcp-http ``` -Or from source: `bun packages/mcp/src/http.ts` / `npx tsx packages/mcp/src/http.ts` +Or from source: `bun packages/mcp/src/index.ts` / `npx tsx packages/mcp/src/index.ts` Security defaults (HTTP transport): @@ -67,7 +67,7 @@ Security defaults (HTTP transport): - CORS is disabled by default; set `OPENPENCIL_MCP_CORS_ORIGIN` to allow one origin - Optional auth token: `OPENPENCIL_MCP_AUTH_TOKEN` (client sends `Authorization: Bearer ` or `x-mcp-token`) -Server starts on port 3100 (override with `PORT` env var). Endpoints: +Server starts on port 7600 (override with `PORT` env var). Endpoints: - `GET /health` — server status - `POST /mcp` — MCP Streamable HTTP (SSE). Sessions via `mcp-session-id` header. diff --git a/packages/docs/it/programmable/mcp-server.md b/packages/docs/it/programmable/mcp-server.md index e296a6447..d6d4fad71 100644 --- a/packages/docs/it/programmable/mcp-server.md +++ b/packages/docs/it/programmable/mcp-server.md @@ -32,7 +32,7 @@ Or run from source without installing: "mcpServers": { "open-pencil": { "command": "bun", - "args": ["/path/to/open-pencil/packages/mcp/src/index.ts"] + "args": ["/path/to/open-pencil/packages/mcp/src/stdio.ts"] } } } @@ -42,7 +42,7 @@ Or run from source without installing: "mcpServers": { "open-pencil": { "command": "npx", - "args": ["tsx", "/path/to/open-pencil/packages/mcp/src/index.ts"] + "args": ["tsx", "/path/to/open-pencil/packages/mcp/src/stdio.ts"] } } } @@ -57,7 +57,7 @@ For browser extensions, scripts, CI, or any HTTP client: openpencil-mcp-http ``` -Or from source: `bun packages/mcp/src/http.ts` / `npx tsx packages/mcp/src/http.ts` +Or from source: `bun packages/mcp/src/index.ts` / `npx tsx packages/mcp/src/index.ts` Security defaults (HTTP transport): @@ -67,7 +67,7 @@ Security defaults (HTTP transport): - CORS is disabled by default; set `OPENPENCIL_MCP_CORS_ORIGIN` to allow one origin - Optional auth token: `OPENPENCIL_MCP_AUTH_TOKEN` (client sends `Authorization: Bearer ` or `x-mcp-token`) -Server starts on port 3100 (override with `PORT` env var). Endpoints: +Server starts on port 7600 (override with `PORT` env var). Endpoints: - `GET /health` — server status - `POST /mcp` — MCP Streamable HTTP (SSE). Sessions via `mcp-session-id` header. diff --git a/packages/docs/pl/programmable/mcp-server.md b/packages/docs/pl/programmable/mcp-server.md index e296a6447..d6d4fad71 100644 --- a/packages/docs/pl/programmable/mcp-server.md +++ b/packages/docs/pl/programmable/mcp-server.md @@ -32,7 +32,7 @@ Or run from source without installing: "mcpServers": { "open-pencil": { "command": "bun", - "args": ["/path/to/open-pencil/packages/mcp/src/index.ts"] + "args": ["/path/to/open-pencil/packages/mcp/src/stdio.ts"] } } } @@ -42,7 +42,7 @@ Or run from source without installing: "mcpServers": { "open-pencil": { "command": "npx", - "args": ["tsx", "/path/to/open-pencil/packages/mcp/src/index.ts"] + "args": ["tsx", "/path/to/open-pencil/packages/mcp/src/stdio.ts"] } } } @@ -57,7 +57,7 @@ For browser extensions, scripts, CI, or any HTTP client: openpencil-mcp-http ``` -Or from source: `bun packages/mcp/src/http.ts` / `npx tsx packages/mcp/src/http.ts` +Or from source: `bun packages/mcp/src/index.ts` / `npx tsx packages/mcp/src/index.ts` Security defaults (HTTP transport): @@ -67,7 +67,7 @@ Security defaults (HTTP transport): - CORS is disabled by default; set `OPENPENCIL_MCP_CORS_ORIGIN` to allow one origin - Optional auth token: `OPENPENCIL_MCP_AUTH_TOKEN` (client sends `Authorization: Bearer ` or `x-mcp-token`) -Server starts on port 3100 (override with `PORT` env var). Endpoints: +Server starts on port 7600 (override with `PORT` env var). Endpoints: - `GET /health` — server status - `POST /mcp` — MCP Streamable HTTP (SSE). Sessions via `mcp-session-id` header. diff --git a/packages/docs/programmable/mcp-server.md b/packages/docs/programmable/mcp-server.md index e296a6447..cbe70bc50 100644 --- a/packages/docs/programmable/mcp-server.md +++ b/packages/docs/programmable/mcp-server.md @@ -1,8 +1,8 @@ # MCP Server -OpenPencil includes an MCP (Model Context Protocol) server that lets AI coding tools — Claude Code, Cursor, Windsurf, etc. — read and modify `.fig` files headlessly. +OpenPencil includes an MCP (Model Context Protocol) server that lets AI coding tools — Claude Code, Cursor, Windsurf, etc. — read and modify designs through the running app. -Two transports: **stdio** for MCP clients, **HTTP** for everything else. +Two transports: **stdio** for MCP clients, **HTTP** for browser extensions and scripts. ## Install @@ -24,6 +24,8 @@ Add to your MCP config (e.g. `~/.claude/settings.json` or `.cursor/mcp.json`): } ``` +The stdio server connects to the running OpenPencil app via WebSocket (port 7601). Make sure the app is open with a document loaded. + Or run from source without installing: ::: code-group @@ -32,7 +34,7 @@ Or run from source without installing: "mcpServers": { "open-pencil": { "command": "bun", - "args": ["/path/to/open-pencil/packages/mcp/src/index.ts"] + "args": ["/path/to/open-pencil/packages/mcp/src/stdio.ts"] } } } @@ -42,7 +44,7 @@ Or run from source without installing: "mcpServers": { "open-pencil": { "command": "npx", - "args": ["tsx", "/path/to/open-pencil/packages/mcp/src/index.ts"] + "args": ["tsx", "/path/to/open-pencil/packages/mcp/src/stdio.ts"] } } } @@ -57,7 +59,7 @@ For browser extensions, scripts, CI, or any HTTP client: openpencil-mcp-http ``` -Or from source: `bun packages/mcp/src/http.ts` / `npx tsx packages/mcp/src/http.ts` +Or from source: `bun packages/mcp/src/index.ts` / `npx tsx packages/mcp/src/index.ts` Security defaults (HTTP transport): @@ -67,7 +69,7 @@ Security defaults (HTTP transport): - CORS is disabled by default; set `OPENPENCIL_MCP_CORS_ORIGIN` to allow one origin - Optional auth token: `OPENPENCIL_MCP_AUTH_TOKEN` (client sends `Authorization: Bearer ` or `x-mcp-token`) -Server starts on port 3100 (override with `PORT` env var). Endpoints: +Server starts on port 7600 (override with `PORT` env var). Endpoints: - `GET /health` — server status - `POST /mcp` — MCP Streamable HTTP (SSE). Sessions via `mcp-session-id` header. diff --git a/packages/docs/ru/programmable/mcp-server.md b/packages/docs/ru/programmable/mcp-server.md index e296a6447..d6d4fad71 100644 --- a/packages/docs/ru/programmable/mcp-server.md +++ b/packages/docs/ru/programmable/mcp-server.md @@ -32,7 +32,7 @@ Or run from source without installing: "mcpServers": { "open-pencil": { "command": "bun", - "args": ["/path/to/open-pencil/packages/mcp/src/index.ts"] + "args": ["/path/to/open-pencil/packages/mcp/src/stdio.ts"] } } } @@ -42,7 +42,7 @@ Or run from source without installing: "mcpServers": { "open-pencil": { "command": "npx", - "args": ["tsx", "/path/to/open-pencil/packages/mcp/src/index.ts"] + "args": ["tsx", "/path/to/open-pencil/packages/mcp/src/stdio.ts"] } } } @@ -57,7 +57,7 @@ For browser extensions, scripts, CI, or any HTTP client: openpencil-mcp-http ``` -Or from source: `bun packages/mcp/src/http.ts` / `npx tsx packages/mcp/src/http.ts` +Or from source: `bun packages/mcp/src/index.ts` / `npx tsx packages/mcp/src/index.ts` Security defaults (HTTP transport): @@ -67,7 +67,7 @@ Security defaults (HTTP transport): - CORS is disabled by default; set `OPENPENCIL_MCP_CORS_ORIGIN` to allow one origin - Optional auth token: `OPENPENCIL_MCP_AUTH_TOKEN` (client sends `Authorization: Bearer ` or `x-mcp-token`) -Server starts on port 3100 (override with `PORT` env var). Endpoints: +Server starts on port 7600 (override with `PORT` env var). Endpoints: - `GET /health` — server status - `POST /mcp` — MCP Streamable HTTP (SSE). Sessions via `mcp-session-id` header. diff --git a/packages/mcp/package.json b/packages/mcp/package.json index e1b793abb..53d7a7a92 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -5,7 +5,8 @@ "type": "module", "main": "./dist/server.js", "bin": { - "openpencil-mcp": "./dist/index.js" + "openpencil-mcp": "./dist/stdio.js", + "openpencil-mcp-http": "./dist/index.js" }, "files": [ "dist" diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index a01b36f3a..32b3530a0 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -17,7 +17,7 @@ const { app, httpPort } = startServer({ serve({ fetch: app.fetch, port: httpPort, hostname: host }) -console.log(`OpenPencil MCP server`) -console.log(` HTTP: http://${host}:${httpPort}`) -console.log(` WS: ws://${host}:${wsPort}`) -console.log(` MCP: http://${host}:${httpPort}/mcp`) +process.stderr.write(`OpenPencil MCP server\n`) +process.stderr.write(` HTTP: http://${host}:${httpPort}\n`) +process.stderr.write(` WS: ws://${host}:${wsPort}\n`) +process.stderr.write(` MCP: http://${host}:${httpPort}/mcp\n`) diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index e158f3b75..c1a392d38 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -19,10 +19,12 @@ import { import type { ParamDef, ParamType } from '@open-pencil/core' const require = createRequire(import.meta.url) -const MCP_VERSION: string = (require('../package.json') as { version: string }).version +export const MCP_VERSION: string = (require('../package.json') as { version: string }).version -type MCPContent = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string } -type MCPResult = { content: MCPContent[]; isError?: boolean } +export type MCPContent = + | { type: 'text'; text: string } + | { type: 'image'; data: string; mimeType: string } +export type MCPResult = { content: MCPContent[]; isError?: boolean } const RPC_TIMEOUT = 30_000 @@ -32,15 +34,90 @@ interface PendingRequest { timer: ReturnType } -function ok(data: unknown): MCPResult { +export function ok(data: unknown): MCPResult { return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] } } -function fail(e: unknown): MCPResult { +export function fail(e: unknown): MCPResult { const msg = e instanceof Error ? e.message : String(e) return { content: [{ type: 'text', text: JSON.stringify({ error: msg }) }], isError: true } } +export type RpcSender = (body: Record) => Promise + +export interface RegisterToolsOptions { + enableEval: boolean + sendRpc: RpcSender +} + +export function registerTools(mcpServer: McpServer, options: RegisterToolsOptions) { + const { enableEval, sendRpc } = options + const register = mcpServer.registerTool.bind(mcpServer) as (...a: unknown[]) => void + + for (const def of ALL_TOOLS) { + if (!enableEval && def.name === 'eval') continue + const shape: Record = {} + for (const [key, param] of Object.entries(def.params)) { + shape[key] = paramToZod(param) + } + register( + def.name, + { description: def.description, inputSchema: z.object(shape) }, + async (args: Record) => { + try { + const result = await sendRpc({ command: 'tool', args: { name: def.name, args } }) + const res = result as { ok?: boolean; result?: unknown; error?: string } + if (res.ok === false) return fail(new Error(res.error)) + const r = res.result as Record | undefined + if (r && 'base64' in r && 'mimeType' in r) { + return { + content: [ + { + type: 'image' as const, + data: r.base64 as string, + mimeType: r.mimeType as string + } + ] + } + } + return ok(r) + } catch (e) { + return fail(e) + } + } + ) + } + + register( + 'save_file', + { + description: + 'Save the current document to disk. Uses the existing file path if available, otherwise prompts for a location.', + inputSchema: z.object({}) + }, + async () => { + try { + const result = await sendRpc({ command: 'save_file' }) + const res = result as { ok?: boolean; error?: string } + if (res.ok === false) return fail(new Error(res.error)) + return ok({ saved: true }) + } catch (e) { + return fail(e) + } + } + ) + + register( + 'get_codegen_prompt', + { + description: + 'Get design-to-code generation guidelines. Call before generating frontend code.', + inputSchema: z.object({}) + }, + async () => ok({ prompt: CODEGEN_PROMPT }) + ) +} + export function paramToZod(param: ParamDef): z.ZodType { const typeMap: Record z.ZodType> = { string: () => @@ -274,70 +351,7 @@ export function startServer(options: ServerOptions = {}) { function createMCPSession(id: string): MCPTransport { const mcpServer = new McpServer({ name: 'open-pencil', version: MCP_VERSION }) - const register = mcpServer.registerTool.bind(mcpServer) as (...a: unknown[]) => void - - for (const def of ALL_TOOLS) { - if (!enableEval && def.name === 'eval') continue - const shape: Record = {} - for (const [key, param] of Object.entries(def.params)) { - shape[key] = paramToZod(param) - } - register( - def.name, - { description: def.description, inputSchema: z.object(shape) }, - async (args: Record) => { - try { - const result = await sendToBrowser({ command: 'tool', args: { name: def.name, args } }) - const res = result as { ok?: boolean; result?: unknown; error?: string } - if (res.ok === false) return fail(new Error(res.error)) - const r = res.result as Record | undefined - if (r && 'base64' in r && 'mimeType' in r) { - return { - content: [ - { - type: 'image' as const, - data: r.base64 as string, - mimeType: r.mimeType as string - } - ] - } - } - return ok(r) - } catch (e) { - return fail(e) - } - } - ) - } - - register( - 'save_file', - { - description: - 'Save the current document to disk. Uses the existing file path if available, otherwise prompts for a location.', - inputSchema: z.object({}) - }, - async () => { - try { - const result = await sendToBrowser({ command: 'save_file' }) - const res = result as { ok?: boolean; error?: string } - if (res.ok === false) return fail(new Error(res.error)) - return ok({ saved: true }) - } catch (e) { - return fail(e) - } - } - ) - - register( - 'get_codegen_prompt', - { - description: - 'Get design-to-code generation guidelines. Call before generating frontend code.', - inputSchema: z.object({}) - }, - async () => ok({ prompt: CODEGEN_PROMPT }) - ) + registerTools(mcpServer, { enableEval, sendRpc: sendToBrowser }) const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => id diff --git a/packages/mcp/src/stdio.ts b/packages/mcp/src/stdio.ts new file mode 100644 index 000000000..563a9bcc8 --- /dev/null +++ b/packages/mcp/src/stdio.ts @@ -0,0 +1,99 @@ +#!/usr/bin/env node +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { WebSocket } from 'ws' + +import { MCP_VERSION, registerTools } from './server.js' + +const wsPort = parseInt(process.env.WS_PORT ?? '7601', 10) +const wsHost = process.env.HOST ?? '127.0.0.1' +const enableEval = process.env.OPENPENCIL_MCP_EVAL === '1' + +const wsUrl = `ws://${wsHost}:${wsPort}` +let ws: WebSocket | null = null +let registered = false + +const pending = new Map< + string, + { + resolve: (v: unknown) => void + reject: (e: Error) => void + timer: ReturnType + } +>() + +function connect() { + ws = new WebSocket(wsUrl) + + ws.on('open', () => { + process.stderr.write(`Connected to OpenPencil app at ${wsUrl}\n`) + }) + + ws.on('message', (raw) => { + try { + const msg = JSON.parse(String(raw)) as { + type: string + id?: string + token?: string + result?: unknown + error?: string + ok?: boolean + } + if (msg.type === 'register' && msg.token) { + registered = true + return + } + if (msg.type === 'response' && msg.id) { + const req = pending.get(msg.id) + if (!req) return + pending.delete(msg.id) + clearTimeout(req.timer) + if (msg.ok === false) req.reject(new Error(msg.error ?? 'RPC failed')) + else { + const { type: _, id: __, ...payload } = msg + req.resolve(payload) + } + } + } catch { + process.stderr.write('Malformed WS message\n') + } + }) + + ws.on('close', () => { + registered = false + for (const [id, req] of pending) { + clearTimeout(req.timer) + req.reject(new Error('WebSocket closed')) + pending.delete(id) + } + setTimeout(connect, 2000) + }) + + ws.on('error', () => { + ws?.close() + }) +} + +function sendRpc(body: Record): Promise { + return new Promise((resolve, reject) => { + if (!ws || ws.readyState !== WebSocket.OPEN || !registered) { + reject(new Error('OpenPencil app is not connected. Start the app and open a document.')) + return + } + const id = crypto.randomUUID() + const timer = setTimeout(() => { + pending.delete(id) + reject(new Error('RPC timeout (30s)')) + }, 30_000) + pending.set(id, { resolve, reject, timer }) + ws.send(JSON.stringify({ type: 'request', id, ...body })) + }) +} + +connect() + +const mcpServer = new McpServer({ name: 'open-pencil', version: MCP_VERSION }) +registerTools(mcpServer, { enableEval, sendRpc }) + +const transport = new StdioServerTransport() +void mcpServer.connect(transport) diff --git a/src/automation/spawn-mcp.ts b/src/automation/spawn-mcp.ts index 0ebf3fb33..659974fc9 100644 --- a/src/automation/spawn-mcp.ts +++ b/src/automation/spawn-mcp.ts @@ -65,7 +65,7 @@ export async function spawnMCPIfNeeded(): Promise runtimeAutomationAuthToken = authToken const { Command } = await import('@tauri-apps/plugin-shell') - const command = Command.create('openpencil-mcp', [], { + const command = Command.create('openpencil-mcp-http', [], { env: { OPENPENCIL_MCP_AUTH_TOKEN: authToken, OPENPENCIL_MCP_CORS_ORIGIN: window.location.origin @@ -95,6 +95,6 @@ export async function spawnMCPIfNeeded(): Promise await child.kill() throw new Error( - 'Failed to start MCP server. Is openpencil-mcp installed? Run: npm i -g @open-pencil/mcp' + 'Failed to start MCP server. Is openpencil-mcp-http installed? Run: npm i -g @open-pencil/mcp' ) } diff --git a/tests/engine/mcp-stdio.test.ts b/tests/engine/mcp-stdio.test.ts new file mode 100644 index 000000000..9b3624518 --- /dev/null +++ b/tests/engine/mcp-stdio.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, test, beforeEach, afterEach } from 'bun:test' + +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' +import { WebSocketServer, type WebSocket } from 'ws' + +import { + ALL_TOOLS, + FigmaAPI, + SceneGraph, + computeAllLayouts, + executeRpcCommand +} from '@open-pencil/core' + +import type { AddressInfo } from 'node:net' + +function createMockApp() { + const graph = new SceneGraph() + const wss = new WebSocketServer({ port: 0, host: '127.0.0.1' }) + let clientWs: WebSocket | null = null + + wss.on('connection', (ws) => { + clientWs = ws + ws.send(JSON.stringify({ type: 'register', token: 'mock-token' })) + + ws.on('message', async (raw) => { + const msg = JSON.parse(String(raw)) as { + type: string + id: string + command: string + args?: { name?: string; args?: Record } + } + if (msg.type !== 'request') return + + try { + let result: unknown + if (msg.command === 'tool' && msg.args?.name) { + const def = ALL_TOOLS.find((t) => t.name === msg.args!.name) + if (!def) throw new Error(`Unknown tool: ${msg.args.name}`) + const api = new FigmaAPI(graph) + api.currentPage = api.wrapNode(graph.getPages()[0].id) + result = await def.execute(api, msg.args.args ?? {}) + if (def.mutates) computeAllLayouts(graph) + } else if (msg.command === 'save_file') { + result = { ok: true } + } else { + result = executeRpcCommand(graph, msg.command, msg.args ?? {}) + } + + ws.send(JSON.stringify({ type: 'response', id: msg.id, ok: true, result })) + } catch (e) { + ws.send( + JSON.stringify({ + type: 'response', + id: msg.id, + ok: false, + error: e instanceof Error ? e.message : String(e) + }) + ) + } + }) + }) + + const port = new Promise((resolve) => { + wss.on('listening', () => resolve((wss.address() as AddressInfo).port)) + }) + + return { + graph, + wss, + port, + close: () => { + clientWs?.close() + wss.close() + } + } +} + +async function createStdioClient(wsPort: number) { + const transport = new StdioClientTransport({ + command: 'bun', + args: ['packages/mcp/src/stdio.ts'], + env: { + ...process.env, + WS_PORT: String(wsPort), + PATH: process.env.PATH ?? '' + }, + stderr: 'pipe' + }) + + const client = new Client({ name: 'test-stdio-client', version: '0.0.0' }) + + await new Promise((resolve) => { + const stderr = transport.stderr + if (stderr && 'on' in stderr) { + ;(stderr as NodeJS.ReadableStream).on('data', (chunk: Buffer) => { + if (chunk.toString().includes('Connected to OpenPencil app')) { + resolve() + } + }) + } + void client.connect(transport).then(() => { + setTimeout(resolve, 1000) + }) + }) + + return { client, transport } +} + +describe('MCP stdio transport', () => { + let app: ReturnType + let client: Client + let transport: StdioClientTransport + + beforeEach(async () => { + app = createMockApp() + const wsPort = await app.port + const ctx = await createStdioClient(wsPort) + client = ctx.client + transport = ctx.transport + }) + + afterEach(async () => { + await client.close() + app.close() + }) + + test('lists tools over stdio', async () => { + const { tools } = await client.listTools() + const names = tools.map((t) => t.name) + expect(names).toContain('create_shape') + expect(names).toContain('get_page_tree') + expect(names).toContain('save_file') + expect(names).toContain('get_codegen_prompt') + expect(tools.length).toBeGreaterThan(30) + }) + + test('create_shape via stdio creates a node', async () => { + const result = await client.callTool({ + name: 'create_shape', + arguments: { type: 'FRAME', x: 10, y: 20, width: 200, height: 100, name: 'StdioFrame' } + }) + expect(result.isError).not.toBe(true) + const data = JSON.parse( + (result.content as { type: string; text: string }[]).find((c) => c.type === 'text')!.text + ) as { id: string; name: string; type: string } + expect(data.type).toBe('FRAME') + expect(data.name).toBe('StdioFrame') + + const node = app.graph.getNode(data.id) + expect(node).toBeDefined() + expect(node!.width).toBe(200) + }) + + test('save_file via stdio succeeds', async () => { + const result = await client.callTool({ name: 'save_file', arguments: {} }) + expect(result.isError).not.toBe(true) + const data = JSON.parse( + (result.content as { type: string; text: string }[]).find((c) => c.type === 'text')!.text + ) as { saved: boolean } + expect(data.saved).toBe(true) + }) + + test('get_codegen_prompt via stdio returns prompt', async () => { + const result = await client.callTool({ name: 'get_codegen_prompt', arguments: {} }) + expect(result.isError).not.toBe(true) + const data = JSON.parse( + (result.content as { type: string; text: string }[]).find((c) => c.type === 'text')!.text + ) as { prompt: string } + expect(data.prompt.length).toBeGreaterThan(100) + }) + + test('delete_node via stdio removes a node', async () => { + const create = await client.callTool({ + name: 'create_shape', + arguments: { type: 'RECTANGLE', x: 0, y: 0, width: 50, height: 50 } + }) + const { id } = JSON.parse( + (create.content as { type: string; text: string }[]).find((c) => c.type === 'text')!.text + ) as { id: string } + + expect(app.graph.getNode(id)).toBeDefined() + + await client.callTool({ name: 'delete_node', arguments: { id } }) + + expect(app.graph.getNode(id)).toBeUndefined() + }) + + test('stderr does not contain JSON-RPC', async () => { + const stderrChunks: string[] = [] + const stderr = transport.stderr + if (stderr && 'on' in stderr) { + ;(stderr as NodeJS.ReadableStream).on('data', (chunk: Buffer) => { + stderrChunks.push(chunk.toString()) + }) + } + + await client.callTool({ + name: 'create_shape', + arguments: { type: 'FRAME', x: 0, y: 0, width: 100, height: 100 } + }) + + const allStderr = stderrChunks.join('') + expect(allStderr).not.toContain('"jsonrpc"') + expect(allStderr).not.toContain('"method"') + }) +})