Unify MCP server: always proxy to live editor via WebSocket
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)
This commit is contained in:
parent
5641554ab7
commit
1092e6b9f5
4
bun.lock
4
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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string, { server: ReturnType<typeof createServer>; 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}`)
|
||||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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<typeof setTimeout>
|
||||
}
|
||||
|
||||
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<string, PendingRequest>()
|
||||
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<string, z.ZodType> = {}
|
||||
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<string, unknown>): Promise<unknown> {
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
if (body.command !== 'tool') return body
|
||||
const args = body.args as { name?: string; args?: Record<string, unknown> } | 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<string, unknown>)
|
||||
const result = await sendToBrowser(body as Record<string, unknown>)
|
||||
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<Response> }
|
||||
const mcpSessions = new Map<string, McpTransport>()
|
||||
|
||||
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<string, z.ZodType> = {}
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown> | 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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof setTimeout>
|
||||
}
|
||||
|
||||
export function startAutomationBridge(server: ViteDevServer) {
|
||||
const pending = new Map<string, PendingRequest>()
|
||||
let browserWs: WebSocket | null = null
|
||||
let authToken: string | null = null
|
||||
|
||||
function sendToBrowser(body: Record<string, unknown>): Promise<unknown> {
|
||||
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<unknown> {
|
||||
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<string, unknown>): Promise<Record<string, unknown>> {
|
||||
if (body.command !== 'tool') return body
|
||||
const args = body.args as { name?: string; args?: Record<string, unknown> } | 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<string, unknown>)
|
||||
const result = await sendToBrowser(body as Record<string, unknown>)
|
||||
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<Response> }
|
||||
const mcpSessions = new Map<string, McpTransport>()
|
||||
|
||||
async function getOrCreateMcpSession(sessionId?: string): Promise<McpTransport> {
|
||||
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<string, unknown> }>
|
||||
}
|
||||
const mcp = await server.ssrLoadModule('@open-pencil/mcp') as {
|
||||
paramToZod: (p: unknown) => ReturnType<typeof z.string>
|
||||
}
|
||||
|
||||
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<string, ReturnType<typeof z.string>> = {}
|
||||
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<string, unknown>) => {
|
||||
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<string, unknown> | 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' })
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof createServer>[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<MockBrowser> {
|
||||
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<string, unknown> } | 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<void>
|
||||
|
||||
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: '<Frame w={200} h={100} bg="#FF0000"><Text size={14}>Hello</Text></Frame>' }
|
||||
})
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Reference in a new issue