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:
Danila Poyarkov 2026-03-15 10:25:48 +03:00 committed by Anton A S
parent 5641554ab7
commit 1092e6b9f5
8 changed files with 423 additions and 931 deletions

View file

@ -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",
},
},
},

View file

@ -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"
}
}

View file

@ -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}`)

View file

@ -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`)

View file

@ -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 }
}

View file

@ -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' })

View file

@ -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')
}
}
}

View file

@ -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)
})
})