From 1092e6b9f554161985b6ce8207e892cb9b0cbe49 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sun, 15 Mar 2026 10:25:48 +0300 Subject: [PATCH] Unify MCP server: always proxy to live editor via WebSocket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove headless SceneGraph mode — the MCP server now always proxies tool calls to the browser via WebSocket. One server, one architecture: HTTP :7600 — /health, /rpc (CLI), /mcp (MCP Streamable HTTP) WS :7601 — browser connects, executes tool calls against live editor - Delete http.ts (merged into server.ts) - Delete headless SceneGraph, open_file, save_file, new_document - Delete canvaskit-wasm dependency (no headless rendering) - Add ws dependency for WebSocket server - Rewrite tests with mock browser over real HTTP+WebSocket - Simplify bridge.ts to 6 lines (just imports and starts the server) --- bun.lock | 4 +- packages/mcp/package.json | 10 +- packages/mcp/src/http.ts | 98 ------ packages/mcp/src/index.ts | 25 +- packages/mcp/src/server.ts | 398 +++++++++++++--------- src/automation/bridge.ts | 234 +------------ src/automation/vite-plugin.ts | 6 +- tests/engine/mcp-server.test.ts | 579 +++++++++----------------------- 8 files changed, 423 insertions(+), 931 deletions(-) delete mode 100644 packages/mcp/src/http.ts diff --git a/bun.lock b/bun.lock index fb9c470f8..5963fa395 100644 --- a/bun.lock +++ b/bun.lock @@ -128,18 +128,18 @@ "version": "0.8.0", "bin": { "openpencil-mcp": "./dist/index.js", - "openpencil-mcp-http": "./dist/http.js", }, "dependencies": { "@hono/node-server": "^1.19.9", "@modelcontextprotocol/sdk": "^1.25.2", "@open-pencil/core": "workspace:*", - "canvaskit-wasm": "^0.40.0", "hono": "^4.11.4", + "ws": "^8.19.0", "zod": "^4.3.6", }, "devDependencies": { "@types/node": "^22.0.0", + "@types/ws": "^8.18.1", }, }, }, diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 88d376329..13c554593 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -5,8 +5,7 @@ "type": "module", "main": "./src/server.ts", "bin": { - "openpencil-mcp": "./dist/index.js", - "openpencil-mcp-http": "./dist/http.js" + "openpencil-mcp": "./dist/index.js" }, "files": [ "src", @@ -29,11 +28,12 @@ "@hono/node-server": "^1.19.9", "@modelcontextprotocol/sdk": "^1.25.2", "@open-pencil/core": "workspace:*", - "canvaskit-wasm": "^0.40.0", "hono": "^4.11.4", - "zod": "^4.3.6" + "zod": "^4.3.6", + "ws": "^8.19.0" }, "devDependencies": { - "@types/node": "^22.0.0" + "@types/node": "^22.0.0", + "@types/ws": "^8.18.1" } } diff --git a/packages/mcp/src/http.ts b/packages/mcp/src/http.ts deleted file mode 100644 index 61998164b..000000000 --- a/packages/mcp/src/http.ts +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env node -import { randomUUID } from 'node:crypto' -import { readFile } from 'node:fs/promises' -import { resolve } from 'node:path' - -import { Hono } from 'hono' -import { cors } from 'hono/cors' -import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' - -import { createServer } from './server.js' - -const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf-8')) -const port = parseInt(process.env.PORT ?? '3100', 10) -const host = process.env.HOST ?? '127.0.0.1' -const authToken = process.env.OPENPENCIL_MCP_AUTH_TOKEN?.trim() || null -const corsOrigin = process.env.OPENPENCIL_MCP_CORS_ORIGIN?.trim() || null -const fileRoot = resolve(process.env.OPENPENCIL_MCP_ROOT ?? process.cwd()) - -const sessions = new Map; transport: WebStandardStreamableHTTPServerTransport }>() - -async function getOrCreateSession(sessionId?: string) { - if (sessionId && sessions.has(sessionId)) { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- guarded by has() above - return sessions.get(sessionId)! - } - - const id = sessionId ?? randomUUID() - const server = createServer(pkg.version, { - enableEval: false, - fileRoot - }) - const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => id }) - await server.connect(transport) - sessions.set(id, { server, transport }) - return { server, transport } -} - -const app = new Hono() - -if (corsOrigin) { - app.use('*', cors({ - origin: corsOrigin, - allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'], - allowHeaders: [ - 'Content-Type', - 'Authorization', - 'x-mcp-token', - 'mcp-session-id', - 'Last-Event-ID', - 'mcp-protocol-version' - ], - exposeHeaders: ['mcp-session-id', 'mcp-protocol-version'] - })) -} - -app.get('/health', (c) => - c.json({ - status: 'ok', - version: pkg.version, - authRequired: Boolean(authToken), - evalEnabled: false, - fileRoot - }) -) - -app.all('/mcp', async (c) => { - if (authToken) { - const authHeader = c.req.header('authorization') - const token = - authHeader?.startsWith('Bearer ') - ? authHeader.slice('Bearer '.length) - : c.req.header('x-mcp-token') - if (token !== authToken) { - return c.json({ error: 'Unauthorized' }, 401) - } - } - - const sessionId = c.req.header('mcp-session-id') ?? undefined - const { transport } = await getOrCreateSession(sessionId) - return transport.handleRequest(c.req.raw) -}) - -const isBun = 'Bun' in globalThis - -if (isBun) { - Bun.serve({ fetch: app.fetch, port, hostname: host }) -} else { - const { serve } = await import('@hono/node-server') - serve({ fetch: app.fetch, port, hostname: host }) -} - -console.log(`OpenPencil MCP server v${pkg.version}`) -console.log(` Health: http://${host}:${port}/health`) -console.log(` MCP: http://${host}:${port}/mcp`) -console.log(` Auth: ${authToken ? 'required (OPENPENCIL_MCP_AUTH_TOKEN)' : 'disabled'}`) -console.log(` CORS: ${corsOrigin ?? 'disabled'}`) -console.log(` Eval: disabled`) -console.log(` Root: ${fileRoot}`) diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 2577ef95c..a01b36f3a 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,12 +1,23 @@ #!/usr/bin/env node -import { readFile } from 'node:fs/promises' +import { serve } from '@hono/node-server' -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' +import { startServer } from './server.js' -import { createServer } from './server.js' +const port = parseInt(process.env.PORT ?? '7600', 10) +const wsPort = parseInt(process.env.WS_PORT ?? '7601', 10) +const host = process.env.HOST ?? '127.0.0.1' -const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf-8')) -const server = createServer(pkg.version) +const { app, httpPort } = startServer({ + httpPort: port, + wsPort, + enableEval: process.env.OPENPENCIL_MCP_EVAL === '1', + authToken: process.env.OPENPENCIL_MCP_AUTH_TOKEN?.trim() || null, + corsOrigin: process.env.OPENPENCIL_MCP_CORS_ORIGIN?.trim() || null +}) -const transport = new StdioServerTransport() -await server.connect(transport) +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`) diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 2e0580d48..33cccd783 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -1,28 +1,32 @@ -import { readFile, writeFile } from 'node:fs/promises' -import { isAbsolute, relative, resolve } from 'node:path' +import { randomUUID } from 'node:crypto' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js' +import { Hono } from 'hono' +import { cors } from 'hono/cors' +import { WebSocketServer, type WebSocket } from 'ws' import { z } from 'zod' import { ALL_TOOLS, CODEGEN_PROMPT, - FigmaAPI, - parseFigFile, - computeAllLayouts, - SceneGraph, - headlessRenderNodes + buildComponent, + createElement, + resolveToTree } from '@open-pencil/core' import { exportImage } from '@open-pencil/core/tools' -import type { ToolDef, ParamDef, ParamType, ExportFormat } from '@open-pencil/core' +import type { ParamDef, ParamType } from '@open-pencil/core' type McpContent = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string } type McpResult = { content: McpContent[]; isError?: boolean } -export interface CreateServerOptions { - enableEval?: boolean - fileRoot?: string | null - makeFigma?: () => FigmaAPI + +const RPC_TIMEOUT = 30_000 + +interface PendingRequest { + resolve: (value: unknown) => void + reject: (error: Error) => void + timer: ReturnType } function ok(data: unknown): McpResult { @@ -55,172 +59,240 @@ export function paramToZod(param: ParamDef): z.ZodType { return param.required ? schema : schema.optional() } -export function createServer(version: string, options: CreateServerOptions = {}): McpServer { - const server = new McpServer({ name: 'open-pencil', version }) - const enableEval = options.enableEval ?? true - const fileRoot = options.fileRoot === null || options.fileRoot === undefined - ? null - : resolve(options.fileRoot) +export interface ServerOptions { + httpPort?: number + wsPort?: number + enableEval?: boolean + authToken?: string | null + corsOrigin?: string | null +} - const externalMakeFigma = options.makeFigma ?? null +export function startServer(options: ServerOptions = {}) { + const httpPort = options.httpPort ?? 7600 + const wsPort = options.wsPort ?? 7601 + const enableEval = options.enableEval ?? false + const authToken = options.authToken ?? null + const corsOrigin = options.corsOrigin ?? null - let graph: SceneGraph | null = null - let currentPageId: string | null = null + const pending = new Map() + let browserWs: WebSocket | null = null + let browserToken: string | null = null - function makeFigma(): FigmaAPI { - if (externalMakeFigma) return externalMakeFigma() - if (!graph) throw new Error('No document loaded. Use open_file or new_document first.') - const g = graph - const api = new FigmaAPI(g) - if (currentPageId) api.currentPage = api.wrapNode(currentPageId) - api.exportImage = async (nodeIds, opts) => { - const pageId = currentPageId ?? g.getPages()[0].id - return headlessRenderNodes(g, pageId, nodeIds, { - scale: opts.scale ?? 1, - format: (opts.format ?? 'PNG') as ExportFormat - }) - } - return api - } + // --- WebSocket: browser connects here --- - function resolveAndCheckPath(filePath: string): string { - const resolved = resolve(filePath) - if (!fileRoot) return resolved - const rel = relative(fileRoot, resolved) - if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) { - return resolved - } - throw new Error(`Path "${filePath}" is outside allowed root "${fileRoot}"`) - } - - function registerTool(def: ToolDef) { - const shape: Record = {} - for (const [key, param] of Object.entries(def.params)) { - shape[key] = paramToZod(param) - } - - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic schema from ToolDef params - server.registerTool(def.name, { description: def.description, inputSchema: z.object(shape) } as any, async (args: any) => { - try { - const result = await def.execute(makeFigma(), args) - if (result && typeof result === 'object' && 'base64' in result && 'mimeType' in result) { - return { - content: [{ type: 'image' as const, data: result.base64 as string, mimeType: result.mimeType as string }] - } - } - return ok(result) - } catch (e) { - return fail(e) + function sendToBrowser(body: Record): Promise { + return new Promise((resolve, reject) => { + if (!browserWs || browserWs.readyState !== browserWs.OPEN) { + reject(new Error('OpenPencil app is not connected')) + return } + const id = randomUUID() + const timer = setTimeout(() => { + pending.delete(id) + reject(new Error('RPC timeout (30s)')) + }, RPC_TIMEOUT) + pending.set(id, { resolve, reject, timer }) + browserWs.send(JSON.stringify({ type: 'request', id, ...body })) }) } - const register = server.registerTool.bind(server) as (...args: unknown[]) => void - - if (!externalMakeFigma) { - register( - 'open_file', - { - description: 'Open a .fig file for editing. Must be called before using other tools.', - inputSchema: z.object({ path: z.string().describe('Absolute path to a .fig file') }) - }, - async ({ path: filePath }: { path: string }) => { - try { - const path = resolveAndCheckPath(filePath) - const buf = await readFile(path) - graph = await parseFigFile(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength)) - computeAllLayouts(graph) - const pages = graph.getPages() - currentPageId = pages[0]?.id ?? null - return ok({ pages: pages.map((p) => ({ id: p.id, name: p.name })), currentPage: pages[0]?.name }) - } catch (e) { - return fail(e) + function handleBrowserMessage(data: string) { + try { + const msg = JSON.parse(data) as { + type: string + id?: string + token?: string + result?: unknown + error?: string + ok?: boolean } - } - ) - - register( - 'save_file', - { - description: 'Save the current document to a .fig file.', - inputSchema: z.object({ path: z.string().describe('Absolute path to save the .fig file') }) - }, - async ({ path: filePath }: { path: string }) => { - try { - if (!graph) throw new Error('No document loaded') - const { exportFigFile } = await import('@open-pencil/core') - const path = resolveAndCheckPath(filePath) - const data = await exportFigFile(graph) - await writeFile(path, new Uint8Array(data)) - return ok({ saved: path, bytes: data.byteLength }) - } catch (e) { - return fail(e) + if (msg.type === 'register' && msg.token) { + browserToken = msg.token + return } - } - ) - - register( - 'new_document', - { - description: 'Create a new empty document with a blank page.', - inputSchema: z.object({}) - }, - async () => { - try { - graph = new SceneGraph() - const pages = graph.getPages() - currentPageId = pages[0]?.id ?? null - return ok({ page: pages[0]?.name, id: currentPageId }) - } catch (e) { - return fail(e) + 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 (e) { + console.warn('Malformed automation message:', e) } - ) - } // end if (!externalMakeFigma) - register( - 'export_image_file', - { - description: 'Export nodes as a PNG/JPG/WEBP image file saved to disk. Returns the file path and size.', - inputSchema: z.object({ - path: z.string().describe('Absolute path to save the image file (e.g. /tmp/design.png)'), - ids: z.array(z.string()).min(1).optional().describe('Node IDs to export. Omit to export all top-level nodes on the current page.'), - format: z.enum(['PNG', 'JPG', 'WEBP']).optional().describe('Image format (default: PNG)'), - scale: z.number().min(0.1).max(4).optional().describe('Export scale multiplier (default: 2)') - }) - }, - async ({ path: filePath, ids, format, scale }: { path: string; ids?: string[]; format?: string; scale?: number }) => { - try { - const outPath = resolveAndCheckPath(filePath) - const result = await exportImage.execute(makeFigma(), { - ids, - format: format ?? 'PNG', - scale: scale ?? 2 - }) - if (result && 'error' in result) throw new Error(result.error as string) - const { base64 } = result as { base64: string } - const data = Buffer.from(base64, 'base64') - await writeFile(outPath, data) - return ok({ saved: outPath, bytes: data.length, format: (format ?? 'PNG').toUpperCase(), scale: scale ?? 2 }) - } catch (e) { - return fail(e) - } - } - ) - - for (const tool of ALL_TOOLS) { - if (!enableEval && tool.name === 'eval') continue - registerTool(tool) } - register( - 'get_codegen_prompt', - { - description: 'Get design-to-code generation guidelines. Call before generating frontend code.', - inputSchema: z.object({}) - }, - async () => ok({ prompt: CODEGEN_PROMPT }) + function rejectAllPending(reason: string) { + for (const [id, req] of pending) { + clearTimeout(req.timer) + req.reject(new Error(reason)) + pending.delete(id) + } + } + + const wss = new WebSocketServer({ port: wsPort, host: '127.0.0.1' }) + + wss.on('connection', (ws) => { + if (browserWs?.readyState === browserWs?.OPEN) browserWs?.close() + rejectAllPending('Browser reconnected') + browserWs = ws + browserToken = null + + ws.on('message', (raw) => { + handleBrowserMessage( + typeof raw === 'string' ? raw : Buffer.from(raw as Buffer).toString('utf-8') + ) + }) + + ws.on('close', () => { + if (browserWs === ws) { + browserWs = null + browserToken = null + rejectAllPending('Browser disconnected') + } + }) + }) + + // --- JSX preprocessing --- + + function preprocessRpc(body: Record): Record { + if (body.command !== 'tool') return body + const args = body.args as { name?: string; args?: Record } | undefined + if (args?.name !== 'render' || !args.args?.jsx) return body + const Component = buildComponent(args.args.jsx as string) + const element = createElement(Component, null) + const tree = resolveToTree(element) + return { + ...body, + args: { ...args, args: { ...args.args, jsx: undefined, tree } } + } + } + + // --- HTTP server --- + + const app = new Hono() + + if (corsOrigin) { + app.use('*', cors({ + origin: corsOrigin, + allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'], + allowHeaders: [ + 'Content-Type', 'Authorization', 'x-mcp-token', + 'mcp-session-id', 'Last-Event-ID', 'mcp-protocol-version' + ], + exposeHeaders: ['mcp-session-id', 'mcp-protocol-version'] + })) + } else { + app.use('*', cors()) + } + + app.get('/health', (c) => + c.json({ + status: browserWs ? 'ok' : 'no_app', + ...(browserWs && browserToken ? { token: browserToken } : {}) + }) ) - return server + app.use('/rpc', async (c, next) => { + if (!browserWs || !browserToken) { + return c.json({ error: 'OpenPencil app is not connected. Is a document open?' }, 503) + } + const auth = c.req.header('authorization') + const provided = auth?.startsWith('Bearer ') ? auth.slice(7) : null + if (provided !== browserToken) { + return c.json({ error: 'Unauthorized' }, 401) + } + return next() + }) + + app.post('/rpc', async (c) => { + let body = await c.req.json().catch(() => null) + if (!body || typeof body !== 'object') { + return c.json({ error: 'Invalid request body' }, 400) + } + try { + body = preprocessRpc(body as Record) + const result = await sendToBrowser(body as Record) + return c.json(result) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + return c.json({ ok: false, error: msg }, 502) + } + }) + + // --- MCP Streamable HTTP --- + + type McpTransport = { handleRequest: (r: Request) => Promise } + const mcpSessions = new Map() + + function createMcpSession(id: string): McpTransport { + const mcpServer = new McpServer({ name: 'open-pencil', version: '0.0.0' }) + 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( + 'get_codegen_prompt', + { + description: 'Get design-to-code generation guidelines. Call before generating frontend code.', + inputSchema: z.object({}) + }, + async () => ok({ prompt: CODEGEN_PROMPT }) + ) + + const transport = new WebStandardStreamableHTTPServerTransport({ + sessionIdGenerator: () => id + }) + void mcpServer.connect(transport) + mcpSessions.set(id, transport) + return transport + } + + app.all('/mcp', async (c) => { + if (authToken) { + const auth = c.req.header('authorization') + const token = auth?.startsWith('Bearer ') ? auth.slice('Bearer '.length) : c.req.header('x-mcp-token') + if (token !== authToken) { + return c.json({ error: 'Unauthorized' }, 401) + } + } + const sessionId = c.req.header('mcp-session-id') ?? undefined + const transport = + (sessionId && mcpSessions.get(sessionId)) ?? + createMcpSession(sessionId ?? randomUUID()) + return transport.handleRequest(c.req.raw) + }) + + return { app, wss, httpPort } } diff --git a/src/automation/bridge.ts b/src/automation/bridge.ts index d257ed2c3..4c8fcc863 100644 --- a/src/automation/bridge.ts +++ b/src/automation/bridge.ts @@ -1,234 +1,6 @@ -/** - * Automation bridge — runs in the Vite process (Node.js or Bun). - * - * Hono HTTP server on :7600 for CLI/MCP clients. - * ws WebSocket server on :7601 for the browser page. - * - * Flow: CLI → HTTP POST /rpc → Hono → WebSocket → browser → execute → response - */ import { serve } from '@hono/node-server' -import { Hono } from 'hono' -import { cors } from 'hono/cors' -import { WebSocketServer, type WebSocket } from 'ws' +import { startServer } from '@open-pencil/mcp' -import type { ViteDevServer } from 'vite' +const { app, httpPort } = startServer() -const AUTOMATION_HTTP_PORT = 7600 -const AUTOMATION_WS_PORT = 7601 -const RPC_TIMEOUT = 30_000 - -interface PendingRequest { - resolve: (value: unknown) => void - reject: (error: Error) => void - timer: ReturnType -} - -export function startAutomationBridge(server: ViteDevServer) { - const pending = new Map() - let browserWs: WebSocket | null = null - let authToken: string | null = null - - function sendToBrowser(body: Record): Promise { - return new Promise((resolve, reject) => { - if (!browserWs || browserWs.readyState !== browserWs.OPEN) { - reject(new Error('OpenPencil app is not connected')) - return - } - const id = crypto.randomUUID() - const timer = setTimeout(() => { - pending.delete(id) - reject(new Error('RPC timeout (30s)')) - }, RPC_TIMEOUT) - pending.set(id, { resolve, reject, timer }) - browserWs.send(JSON.stringify({ type: 'request', id, ...body })) - }) - } - - function handleBrowserMessage(data: string) { - try { - const msg = JSON.parse(data) as { - type: string - id?: string - token?: string - result?: unknown - error?: string - ok?: boolean - } - if (msg.type === 'register' && msg.token) { - authToken = msg.token - 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 (e) { - console.warn('Malformed automation message:', e) - } - } - - function rejectAllPending(reason: string) { - for (const [id, req] of pending) { - clearTimeout(req.timer) - req.reject(new Error(reason)) - pending.delete(id) - } - } - - const wss = new WebSocketServer({ port: AUTOMATION_WS_PORT, host: '127.0.0.1' }) - - wss.on('connection', (ws) => { - if (browserWs && browserWs.readyState === browserWs.OPEN) { - browserWs.close() - } - rejectAllPending('Browser reconnected') - browserWs = ws - authToken = null - - ws.on('message', (raw) => { - handleBrowserMessage( - typeof raw === 'string' ? raw : Buffer.from(raw as Buffer).toString('utf-8') - ) - }) - - ws.on('close', () => { - if (browserWs === ws) { - browserWs = null - authToken = null - rejectAllPending('Browser disconnected') - } - }) - }) - - async function jsxToTree(jsx: string): Promise { - const core = await server.ssrLoadModule('@open-pencil/core') - const Component = (core.buildComponent as (jsx: string) => () => unknown)(jsx) - const element = (core.createElement as (type: unknown, props: unknown) => unknown)(Component, null) - return (core.resolveToTree as (el: unknown) => unknown)(element) - } - - async function preprocessRpc(body: Record): Promise> { - if (body.command !== 'tool') return body - const args = body.args as { name?: string; args?: Record } | undefined - if (args?.name !== 'render' || !args.args?.jsx) return body - const tree = await jsxToTree(args.args.jsx as string) - return { - ...body, - args: { - ...args, - args: { ...args.args, jsx: undefined, tree } - } - } - } - - const app = new Hono() - app.use('*', cors()) - - app.get('/health', (c) => { - return c.json({ - status: browserWs ? 'ok' : 'no_app', - ...(browserWs && authToken ? { token: authToken } : {}) - }) - }) - - app.use('/rpc', async (c, next) => { - if (!browserWs || !authToken) { - return c.json({ error: 'OpenPencil app is not connected. Is a document open?' }, 503) - } - const auth = c.req.header('authorization') - const provided = auth?.startsWith('Bearer ') ? auth.slice(7) : null - if (provided !== authToken) { - return c.json({ error: 'Unauthorized' }, 401) - } - return next() - }) - - app.post('/rpc', async (c) => { - let body = await c.req.json().catch(() => null) - if (!body || typeof body !== 'object') { - return c.json({ error: 'Invalid request body' }, 400) - } - try { - body = await preprocessRpc(body as Record) - const result = await sendToBrowser(body as Record) - return c.json(result) - } catch (e) { - const msg = e instanceof Error ? e.message : String(e) - return c.json({ ok: false, error: msg }, 502) - } - }) - - // MCP Streamable HTTP — proxies tool calls through WebSocket to the live editor - type McpTransport = { handleRequest: (r: Request) => Promise } - const mcpSessions = new Map() - - async function getOrCreateMcpSession(sessionId?: string): Promise { - const cached = sessionId ? mcpSessions.get(sessionId) : undefined - if (cached) return cached - - const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js') - const { WebStandardStreamableHTTPServerTransport } = - await import('@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js') - const { z } = await import('zod') - const core = await server.ssrLoadModule('@open-pencil/core') as { - ALL_TOOLS: Array<{ name: string; description: string; params: Record }> - } - const mcp = await server.ssrLoadModule('@open-pencil/mcp') as { - paramToZod: (p: unknown) => ReturnType - } - - const id = sessionId ?? crypto.randomUUID() - const mcpServer = new McpServer({ name: 'open-pencil', version: '0.0.0' }) - const register = mcpServer.registerTool.bind(mcpServer) as (...a: unknown[]) => void - - for (const def of core.ALL_TOOLS) { - const shape: Record> = {} - for (const [key, param] of Object.entries(def.params)) { - shape[key] = mcp.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 { content: [{ type: 'text' as const, text: JSON.stringify({ error: res.error }) }], isError: true } - } - 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 { content: [{ type: 'text' as const, text: JSON.stringify(r, null, 2) }] } - } catch (e) { - const msg = e instanceof Error ? e.message : String(e) - return { content: [{ type: 'text' as const, text: JSON.stringify({ error: msg }) }], isError: true } - } - } - ) - } - - const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => id }) - await mcpServer.connect(transport) - mcpSessions.set(id, transport) - return transport - } - - app.all('/mcp', async (c) => { - const sessionId = c.req.header('mcp-session-id') ?? undefined - const transport = await getOrCreateMcpSession(sessionId) - return transport.handleRequest(c.req.raw) - }) - - serve({ fetch: app.fetch, port: AUTOMATION_HTTP_PORT, hostname: '127.0.0.1' }) - - console.log(`[automation] HTTP http://127.0.0.1:${AUTOMATION_HTTP_PORT}`) - console.log(`[automation] WS ws://127.0.0.1:${AUTOMATION_WS_PORT}`) - console.log(`[automation] MCP http://127.0.0.1:${AUTOMATION_HTTP_PORT}/mcp`) -} +serve({ fetch: app.fetch, port: httpPort, hostname: '127.0.0.1' }) diff --git a/src/automation/vite-plugin.ts b/src/automation/vite-plugin.ts index d07d9f244..1c01eaa10 100644 --- a/src/automation/vite-plugin.ts +++ b/src/automation/vite-plugin.ts @@ -3,10 +3,8 @@ import type { Plugin } from 'vite' export function automationPlugin(): Plugin { return { name: 'open-pencil-automation', - configureServer(server) { - void import('./bridge').then(({ startAutomationBridge }) => { - startAutomationBridge(server) - }) + configureServer() { + void import('./bridge') } } } diff --git a/tests/engine/mcp-server.test.ts b/tests/engine/mcp-server.test.ts index 85ca68204..b0eaeb9fa 100644 --- a/tests/engine/mcp-server.test.ts +++ b/tests/engine/mcp-server.test.ts @@ -1,47 +1,125 @@ import { describe, expect, test, beforeEach, afterEach } from 'bun:test' -import { join } from 'node:path' -import { mkdtemp, rm, unlink, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import WebSocket from 'ws' -import { createServer } from '../../packages/mcp/src/server' -import { SceneGraph, exportFigFile } from '@open-pencil/core' +import { startServer } from '../../packages/mcp/src/server' +import { + ALL_TOOLS, + FigmaAPI, + SceneGraph, + computeAllLayouts, + executeRpcCommand +} from '@open-pencil/core' +import { serve } from '@hono/node-server' -function parseResult(result: { content: { type: string; text: string }[] }): unknown { - return JSON.parse(result.content[0].text) +let httpPort = 17600 +let wsPort = 17601 + +function nextPorts() { + httpPort += 2 + wsPort += 2 + return { httpPort, wsPort } } -async function createLinkedClient(options?: Parameters[1]) { - const server = createServer('0.0.0-test', options) - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() - await server.connect(serverTransport) +interface MockBrowser { + ws: WebSocket + graph: SceneGraph + close: () => void +} + +function connectMockBrowser(port: number, graph: SceneGraph): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}`) + const token = 'test-token-' + Date.now() + + ws.on('open', () => { + ws.send(JSON.stringify({ type: 'register', token })) + + ws.on('message', async (raw) => { + const msg = JSON.parse(raw.toString()) as { + type: string + id: string + command: string + args?: unknown + } + if (msg.type !== 'request') return + + try { + const command = msg.command + const args = msg.args as { name?: string; args?: Record } | undefined + + let result: unknown + if (command === 'tool' && args?.name) { + const def = ALL_TOOLS.find((t) => t.name === args.name) + if (!def) throw new Error(`Unknown tool: ${args.name}`) + const api = new FigmaAPI(graph) + api.currentPage = api.wrapNode(graph.getPages()[0].id) + result = await def.execute(api, args.args ?? {}) + if (def.mutates) computeAllLayouts(graph) + } else { + result = executeRpcCommand(graph, command, 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) + })) + } + }) + + resolve({ ws, graph, close: () => ws.close() }) + }) + + ws.on('error', reject) + }) +} + +async function createTestClient(ports: { httpPort: number; wsPort: number }) { + const { app, wss } = startServer(ports) + const httpServer = serve({ fetch: app.fetch, port: ports.httpPort, hostname: '127.0.0.1' }) + + const graph = new SceneGraph() + const browser = await connectMockBrowser(ports.wsPort, graph) + const client = new Client({ name: 'test-client', version: '0.0.0' }) - await client.connect(clientTransport) + const transport = new StreamableHTTPClientTransport( + new URL(`http://127.0.0.1:${ports.httpPort}/mcp`) + ) + await client.connect(transport) + return { client, + graph, close: async () => { await client.close() - await server.close() + browser.close() + wss.close() + httpServer.close() } } } +function parseResult(result: { content: { type: string; text?: string }[] }): unknown { + const textContent = result.content.find((c) => c.type === 'text') + return textContent?.text ? JSON.parse(textContent.text) : null +} + describe('MCP server', () => { let client: Client + let graph: SceneGraph let cleanup: () => Promise beforeEach(async () => { - const server = createServer('0.0.0-test') - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() - await server.connect(serverTransport) - client = new Client({ name: 'test-client', version: '0.0.0' }) - await client.connect(clientTransport) - cleanup = async () => { - await client.close() - await server.close() - } + const ctx = await createTestClient(nextPorts()) + client = ctx.client + graph = ctx.graph + cleanup = ctx.close }) afterEach(async () => { @@ -51,13 +129,12 @@ describe('MCP server', () => { test('lists all registered tools', async () => { const { tools } = await client.listTools() const names = tools.map((t) => t.name) - expect(names).toContain('new_document') - expect(names).toContain('open_file') - expect(names).toContain('save_file') expect(names).toContain('create_shape') expect(names).toContain('set_fill') expect(names).toContain('get_page_tree') - expect(tools.length).toBeGreaterThan(70) + expect(names).toContain('render') + expect(names).toContain('get_codegen_prompt') + expect(tools.length).toBeGreaterThan(30) }) test('tools have descriptions and input schemas', async () => { @@ -68,422 +145,82 @@ describe('MCP server', () => { } }) - test('new_document creates an empty document', async () => { - const result = await client.callTool({ name: 'new_document', arguments: {} }) - const data = parseResult(result) as { page: string; id: string } - expect(data.page).toBe('Page 1') - expect(data.id).toBeTruthy() - }) - - test('tools fail without a loaded document', async () => { - const result = await client.callTool({ name: 'get_page_tree', arguments: {} }) - expect(result.isError).toBe(true) - const data = parseResult(result) as { error: string } - expect(data.error).toContain('No document loaded') - }) - - test('create_shape after new_document', async () => { - await client.callTool({ name: 'new_document', arguments: {} }) + test('create_shape creates a node on the live canvas', async () => { const result = await client.callTool({ name: 'create_shape', arguments: { type: 'FRAME', x: 0, y: 0, width: 200, height: 100, name: 'Test' } }) + expect(result.isError).not.toBe(true) const data = parseResult(result) as { id: string; name: string; type: string } - expect(data.name).toBe('Test') expect(data.type).toBe('FRAME') - expect(data.id).toBeTruthy() + expect(data.name).toBe('Test') + + const node = graph.getNode(data.id) + expect(node).toBeDefined() + expect(node?.name).toBe('Test') }) - test('set_fill validates color string', async () => { - await client.callTool({ name: 'new_document', arguments: {} }) - const shape = parseResult( - await client.callTool({ - name: 'create_shape', - arguments: { type: 'RECTANGLE', x: 0, y: 0, width: 50, height: 50 } - }) - ) as { id: string } + test('set_fill validates and applies color', async () => { + const create = await client.callTool({ + name: 'create_shape', + arguments: { type: 'RECTANGLE', x: 0, y: 0, width: 50, height: 50 } + }) + const { id } = parseResult(create) as { id: string } - await client.callTool({ + const fill = await client.callTool({ name: 'set_fill', - arguments: { id: shape.id, color: '#00ff00' } + arguments: { id, color: '#00ff00' } }) - - const node = parseResult( - await client.callTool({ name: 'get_node', arguments: { id: shape.id } }) - ) as { fills: { color: { r: number; g: number; b: number } }[] } - expect(node.fills[0].color.g).toBeCloseTo(1) + expect(fill.isError).not.toBe(true) }) - test('delete_node removes created node', async () => { - await client.callTool({ name: 'new_document', arguments: {} }) - const shape = parseResult( - await client.callTool({ - name: 'create_shape', - arguments: { type: 'RECTANGLE', x: 0, y: 0, width: 50, height: 50 } - }) - ) as { id: string } - - await client.callTool({ name: 'delete_node', arguments: { id: shape.id } }) - - const result = await client.callTool({ name: 'get_node', arguments: { id: shape.id } }) - const data = parseResult(result) as { error?: string } - expect(data.error).toContain('not found') - }) - - test('get_node on nonexistent ID returns error in response', async () => { - await client.callTool({ name: 'new_document', arguments: {} }) - const result = await client.callTool({ name: 'get_node', arguments: { id: 'bogus' } }) - const data = parseResult(result) as { error: string } - expect(data.error).toContain('not found') - }) - - test('open_file loads a .fig file', async () => { - const fixturePath = join(import.meta.dir, '..', 'fixtures', 'gold-preview.fig') - const result = await client.callTool({ name: 'open_file', arguments: { path: fixturePath } }) - const data = parseResult(result) as { pages: { name: string }[]; currentPage: string } - expect(data.pages.length).toBeGreaterThan(0) - expect(data.currentPage).toBeTruthy() - }) - - test('open_file with invalid path returns error', async () => { - const result = await client.callTool({ - name: 'open_file', - arguments: { path: '/nonexistent/file.fig' } - }) - expect(result.isError).toBe(true) - }) - - test('save_file roundtrips a document', async () => { - const tmpPath = join(import.meta.dir, '..', `_mcp_test_${Date.now()}.fig`) - try { - await client.callTool({ name: 'new_document', arguments: {} }) - await client.callTool({ - name: 'create_shape', - arguments: { type: 'FRAME', x: 0, y: 0, width: 300, height: 200, name: 'Saved' } - }) - - const saveResult = await client.callTool({ - name: 'save_file', - arguments: { path: tmpPath } - }) - const saved = parseResult(saveResult) as { saved: string; bytes: number } - expect(saved.bytes).toBeGreaterThan(0) - - await client.callTool({ name: 'open_file', arguments: { path: tmpPath } }) - const tree = parseResult( - await client.callTool({ name: 'get_page_tree', arguments: {} }) - ) as { children: { name: string }[] } - expect(tree.children.some((c) => c.name === 'Saved')).toBe(true) - } finally { - await unlink(tmpPath).catch(() => {}) - } - }) - - test('save_file without document returns error', async () => { - const result = await client.callTool({ - name: 'save_file', - arguments: { path: '/tmp/_mcp_no_doc.fig' } - }) - expect(result.isError).toBe(true) - }) - - test('full workflow: new → create → query → delete', async () => { - await client.callTool({ name: 'new_document', arguments: {} }) - - const frame = parseResult( - await client.callTool({ - name: 'create_shape', - arguments: { type: 'FRAME', x: 10, y: 20, width: 400, height: 300, name: 'Container' } - }) - ) as { id: string } - - const child = parseResult( - await client.callTool({ - name: 'create_shape', - arguments: { - type: 'TEXT', - x: 0, - y: 0, - width: 200, - height: 30, - name: 'Label', - parent_id: frame.id - } - }) - ) as { id: string } - - await client.callTool({ - name: 'set_fill', - arguments: { id: frame.id, color: '#336699' } - }) - - const tree = parseResult( - await client.callTool({ name: 'get_page_tree', arguments: {} }) - ) as { children: { id: string; name: string; children?: { name: string }[] }[] } - - const container = tree.children.find((c) => c.name === 'Container') - expect(container).toBeDefined() - expect(container!.children?.some((c) => c.name === 'Label')).toBe(true) - - await client.callTool({ name: 'delete_node', arguments: { id: child.id } }) - - const tree2 = parseResult( - await client.callTool({ name: 'get_page_tree', arguments: {} }) - ) as { children: { id: string; children?: unknown[] }[] } - const container2 = tree2.children.find((c) => c.id === frame.id) - expect(container2!.children ?? []).toHaveLength(0) - }) - - test('find_nodes filters by type', async () => { - await client.callTool({ name: 'new_document', arguments: {} }) + test('get_page_tree returns page structure', async () => { await client.callTool({ name: 'create_shape', arguments: { type: 'FRAME', x: 0, y: 0, width: 100, height: 100, name: 'F1' } }) - await client.callTool({ - name: 'create_shape', - arguments: { type: 'RECTANGLE', x: 0, y: 0, width: 50, height: 50, name: 'R1' } - }) - await client.callTool({ - name: 'create_shape', - arguments: { type: 'FRAME', x: 0, y: 0, width: 100, height: 100, name: 'F2' } - }) + const result = await client.callTool({ name: 'get_page_tree', arguments: {} }) + expect(result.isError).not.toBe(true) + const data = parseResult(result) as { children: { name: string }[] } + expect(data.children.some((c) => c.name === 'F1')).toBe(true) + }) + test('delete_node 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 } = parseResult(create) as { id: string } + + await client.callTool({ name: 'delete_node', arguments: { id } }) + + const get = await client.callTool({ name: 'get_node', arguments: { id } }) + const data = parseResult(get) as { error?: string } + expect(data.error).toContain('not found') + }) + + test('find_nodes filters by type', async () => { + await client.callTool({ + name: 'create_shape', + arguments: { type: 'FRAME', x: 0, y: 0, width: 100, height: 100 } + }) + await client.callTool({ + name: 'create_shape', + arguments: { type: 'RECTANGLE', x: 0, y: 0, width: 50, height: 50 } + }) + await client.callTool({ + name: 'create_shape', + arguments: { type: 'FRAME', x: 0, y: 0, width: 100, height: 100 } + }) const result = await client.callTool({ name: 'find_nodes', arguments: { type: 'FRAME' } }) const data = parseResult(result) as { count: number } expect(data.count).toBe(2) }) - test('create_shape rejects invalid type enum', async () => { - await client.callTool({ name: 'new_document', arguments: {} }) - - const result = await client.callTool({ - name: 'create_shape', - arguments: { type: 'INVALID_TYPE', x: 0, y: 0, width: 100, height: 100 } - }) - const r = result as { content: { text: string }[]; isError?: boolean } - const text = r.content[0].text - expect(r.isError === true || text.toLowerCase().includes('invalid')).toBe(true) - }) - - test('create_shape rejects missing required param', async () => { - await client.callTool({ name: 'new_document', arguments: {} }) - - const result = await client.callTool({ - name: 'create_shape', - arguments: { x: 0, y: 0, width: 100, height: 100 } - }) - const r = result as { content: { text: string }[]; isError?: boolean } - const text = r.content[0].text - expect(r.isError === true || text.toLowerCase().includes('required')).toBe(true) - }) - - test('createServer option enableEval=false removes eval tool', async () => { - const custom = await createLinkedClient({ enableEval: false }) - try { - const { tools } = await custom.client.listTools() - const names = tools.map((t) => t.name) - expect(names).not.toContain('eval') - expect(names).toContain('create_shape') - } finally { - await custom.close() - } - }) - - test('export_image_file produces a valid PNG after new_document + render', async () => { - const tmpPath = join(import.meta.dir, '..', `_mcp_test_export_${Date.now()}.png`) - try { - await client.callTool({ name: 'new_document', arguments: {} }) - await client.callTool({ - name: 'render', - arguments: { jsx: 'Hello' } - }) - const result = await client.callTool({ - name: 'export_image_file', - arguments: { path: tmpPath, format: 'PNG', scale: 1 } - }) - expect(result.isError).not.toBe(true) - const data = parseResult(result) as { saved: string; bytes: number; format: string } - expect(data.saved).toBe(tmpPath) - expect(data.bytes).toBeGreaterThan(100) - expect(data.format).toBe('PNG') - } finally { - await unlink(tmpPath).catch(() => {}) - } - }) - - test('export_image_file without ids exports all page children', async () => { - const tmpPath = join(import.meta.dir, '..', `_mcp_test_export_all_${Date.now()}.png`) - try { - await client.callTool({ name: 'new_document', arguments: {} }) - await client.callTool({ - name: 'create_shape', - arguments: { type: 'FRAME', x: 0, y: 0, width: 100, height: 100 } - }) - const result = await client.callTool({ - name: 'export_image_file', - arguments: { path: tmpPath } - }) - expect(result.isError).not.toBe(true) - const data = parseResult(result) as { bytes: number } - expect(data.bytes).toBeGreaterThan(0) - } finally { - await unlink(tmpPath).catch(() => {}) - } - }) - - test('export_image_file without a loaded document returns an error', async () => { - const result = await client.callTool({ - name: 'export_image_file', - arguments: { path: '/tmp/_no_doc.png' } - }) - expect(result.isError).toBe(true) - }) - - test('export_image_file with out-of-root path returns an error', async () => { - const rootDir = await mkdtemp(join(tmpdir(), 'openpencil-mcp-export-root-')) - const custom = await createLinkedClient({ fileRoot: rootDir }) - try { - await custom.client.callTool({ name: 'new_document', arguments: {} }) - await custom.client.callTool({ - name: 'create_shape', - arguments: { type: 'RECTANGLE', x: 0, y: 0, width: 50, height: 50 } - }) - const result = await custom.client.callTool({ - name: 'export_image_file', - arguments: { path: '/tmp/_outside_root.png' } - }) - expect(result.isError).toBe(true) - const data = parseResult(result) as { error: string } - expect(data.error).toContain('outside allowed root') - } finally { - await custom.close() - await rm(rootDir, { recursive: true, force: true }) - } - }) - - test('export_image_file JPG and WEBP formats produce non-empty output', async () => { - const jpgPath = join(import.meta.dir, '..', `_mcp_test_${Date.now()}.jpg`) - const webpPath = join(import.meta.dir, '..', `_mcp_test_${Date.now()}.webp`) - try { - await client.callTool({ name: 'new_document', arguments: {} }) - await client.callTool({ - name: 'create_shape', - arguments: { type: 'RECTANGLE', x: 0, y: 0, width: 80, height: 80 } - }) - - const jpgResult = await client.callTool({ - name: 'export_image_file', - arguments: { path: jpgPath, format: 'JPG', scale: 1 } - }) - expect(jpgResult.isError).not.toBe(true) - const jpgData = parseResult(jpgResult) as { bytes: number; format: string } - expect(jpgData.bytes).toBeGreaterThan(0) - expect(jpgData.format).toBe('JPG') - - const webpResult = await client.callTool({ - name: 'export_image_file', - arguments: { path: webpPath, format: 'WEBP', scale: 1 } - }) - expect(webpResult.isError).not.toBe(true) - const webpData = parseResult(webpResult) as { bytes: number; format: string } - expect(webpData.bytes).toBeGreaterThan(0) - expect(webpData.format).toBe('WEBP') - } finally { - await unlink(jpgPath).catch(() => {}) - await unlink(webpPath).catch(() => {}) - } - }) - - test('export_image_file is listed in tools', async () => { - const { tools } = await client.listTools() - const names = tools.map((t) => t.name) - expect(names).toContain('export_image_file') - }) - - test('createServer option fileRoot restricts open_file and save_file paths', async () => { - const rootDir = await mkdtemp(join(tmpdir(), 'openpencil-mcp-root-')) - const insidePath = join(rootDir, 'inside.fig') - const outsidePath = join(tmpdir(), `outside-${Date.now()}.fig`) - - const graph = new SceneGraph() - const bytes = await exportFigFile(graph) - await writeFile(insidePath, new Uint8Array(bytes)) - - const custom = await createLinkedClient({ fileRoot: rootDir }) - try { - const openInside = await custom.client.callTool({ - name: 'open_file', - arguments: { path: insidePath } - }) - expect(openInside.isError).not.toBe(true) - - const saveOutside = await custom.client.callTool({ - name: 'save_file', - arguments: { path: outsidePath } - }) - expect(saveOutside.isError).toBe(true) - const saveOutsideErr = parseResult(saveOutside) as { error: string } - expect(saveOutsideErr.error).toContain('outside allowed root') - - const saveInside = await custom.client.callTool({ - name: 'save_file', - arguments: { path: insidePath } - }) - expect(saveInside.isError).not.toBe(true) - } finally { - await custom.close() - await unlink(outsidePath).catch(() => {}) - await rm(rootDir, { recursive: true, force: true }) - } - }) - - test('createServer with makeFigma skips file lifecycle tools', async () => { - const graph = new SceneGraph() - const { FigmaAPI } = await import('@open-pencil/core') - const custom = await createLinkedClient({ - makeFigma: () => new FigmaAPI(graph) - }) - try { - const { tools } = await custom.client.listTools() - const names = tools.map((t) => t.name) - expect(names).not.toContain('open_file') - expect(names).not.toContain('save_file') - expect(names).not.toContain('new_document') - expect(names).toContain('create_shape') - expect(names).toContain('render') - expect(names).toContain('get_page_tree') - } finally { - await custom.close() - } - }) - - test('createServer with makeFigma can call tools without loading a document', async () => { - const graph = new SceneGraph() - const { FigmaAPI } = await import('@open-pencil/core') - const custom = await createLinkedClient({ - makeFigma: () => { - const api = new FigmaAPI(graph) - api.currentPage = api.wrapNode(graph.getPages()[0].id) - return api - } - }) - try { - const result = await custom.client.callTool({ - name: 'create_shape', - arguments: { type: 'RECTANGLE', x: 0, y: 0, width: 100, height: 50 } - }) - expect(result.isError).not.toBe(true) - const data = parseResult(result) as { id: string; type: string } - expect(data.type).toBe('RECTANGLE') - expect(data.id).toBeTruthy() - - const tree = await custom.client.callTool({ name: 'get_page_tree', arguments: {} }) - expect(tree.isError).not.toBe(true) - const treeData = parseResult(tree) as { children: { id: string }[] } - expect(treeData.children.some((c) => c.id === data.id)).toBe(true) - } finally { - await custom.close() - } + test('get_codegen_prompt returns prompt text', async () => { + const result = await client.callTool({ name: 'get_codegen_prompt', arguments: {} }) + expect(result.isError).not.toBe(true) + const data = parseResult(result) as { prompt: string } + expect(data.prompt.length).toBeGreaterThan(100) }) })