feat(mcp): add stdio transport for Claude Code / Cursor

- Add packages/mcp/src/stdio.ts — proper stdio MCP server that
  connects to the running app via WebSocket
- openpencil-mcp bin now points to stdio entry (was HTTP)
- openpencil-mcp-http bin added for the HTTP server
- Extract registerTools() from server.ts to share between transports
- Move index.ts banner output to stderr (prevents stdout corruption)
- Update Tauri shell spawn to use openpencil-mcp-http
- Update all docs (EN + 6 translations): correct binary names, source
  paths, port number

Fixes #194
This commit is contained in:
Danila Poyarkov 2026-04-13 10:11:23 +03:00
parent 082fc2c88e
commit cd4e1a2271
15 changed files with 435 additions and 107 deletions

View file

@ -2,9 +2,14 @@
## Unreleased
### Features
- Add stdio transport for MCP server — `openpencil-mcp` now works as a proper stdio MCP server for Claude Code, Cursor, etc. HTTP server available as `openpencil-mcp-http`.
### Fixes
- Fix color picker dragging flooding the undo stack — fill/stroke/effect color and opacity drags now collapse into a single undo entry per interaction via debounced batching in `PropertyListRoot`
- Fix .fig import crash on alias variables without a GUID
## 0.11.6 — 2026-04-08

View file

@ -25,7 +25,7 @@
{ "name": "claude-agent-acp", "cmd": "claude-agent-acp", "args": true },
{ "name": "codex-acp", "cmd": "codex-acp", "args": true },
{ "name": "gemini", "cmd": "gemini", "args": true },
{ "name": "openpencil-mcp", "cmd": "openpencil-mcp", "args": true }
{ "name": "openpencil-mcp-http", "cmd": "openpencil-mcp-http", "args": true }
]
},
"shell:allow-stdin-write",

View file

@ -32,7 +32,7 @@ Or run from source without installing:
"mcpServers": {
"open-pencil": {
"command": "bun",
"args": ["/path/to/open-pencil/packages/mcp/src/index.ts"]
"args": ["/path/to/open-pencil/packages/mcp/src/stdio.ts"]
}
}
}
@ -42,7 +42,7 @@ Or run from source without installing:
"mcpServers": {
"open-pencil": {
"command": "npx",
"args": ["tsx", "/path/to/open-pencil/packages/mcp/src/index.ts"]
"args": ["tsx", "/path/to/open-pencil/packages/mcp/src/stdio.ts"]
}
}
}
@ -57,7 +57,7 @@ For browser extensions, scripts, CI, or any HTTP client:
openpencil-mcp-http
```
Or from source: `bun packages/mcp/src/http.ts` / `npx tsx packages/mcp/src/http.ts`
Or from source: `bun packages/mcp/src/index.ts` / `npx tsx packages/mcp/src/index.ts`
Security defaults (HTTP transport):
@ -67,7 +67,7 @@ Security defaults (HTTP transport):
- CORS is disabled by default; set `OPENPENCIL_MCP_CORS_ORIGIN` to allow one origin
- Optional auth token: `OPENPENCIL_MCP_AUTH_TOKEN` (client sends `Authorization: Bearer <token>` or `x-mcp-token`)
Server starts on port 3100 (override with `PORT` env var). Endpoints:
Server starts on port 7600 (override with `PORT` env var). Endpoints:
- `GET /health` — server status
- `POST /mcp` — MCP Streamable HTTP (SSE). Sessions via `mcp-session-id` header.

View file

@ -32,7 +32,7 @@ Or run from source without installing:
"mcpServers": {
"open-pencil": {
"command": "bun",
"args": ["/path/to/open-pencil/packages/mcp/src/index.ts"]
"args": ["/path/to/open-pencil/packages/mcp/src/stdio.ts"]
}
}
}
@ -42,7 +42,7 @@ Or run from source without installing:
"mcpServers": {
"open-pencil": {
"command": "npx",
"args": ["tsx", "/path/to/open-pencil/packages/mcp/src/index.ts"]
"args": ["tsx", "/path/to/open-pencil/packages/mcp/src/stdio.ts"]
}
}
}
@ -57,7 +57,7 @@ For browser extensions, scripts, CI, or any HTTP client:
openpencil-mcp-http
```
Or from source: `bun packages/mcp/src/http.ts` / `npx tsx packages/mcp/src/http.ts`
Or from source: `bun packages/mcp/src/index.ts` / `npx tsx packages/mcp/src/index.ts`
Security defaults (HTTP transport):
@ -67,7 +67,7 @@ Security defaults (HTTP transport):
- CORS is disabled by default; set `OPENPENCIL_MCP_CORS_ORIGIN` to allow one origin
- Optional auth token: `OPENPENCIL_MCP_AUTH_TOKEN` (client sends `Authorization: Bearer <token>` or `x-mcp-token`)
Server starts on port 3100 (override with `PORT` env var). Endpoints:
Server starts on port 7600 (override with `PORT` env var). Endpoints:
- `GET /health` — server status
- `POST /mcp` — MCP Streamable HTTP (SSE). Sessions via `mcp-session-id` header.

View file

@ -32,7 +32,7 @@ Or run from source without installing:
"mcpServers": {
"open-pencil": {
"command": "bun",
"args": ["/path/to/open-pencil/packages/mcp/src/index.ts"]
"args": ["/path/to/open-pencil/packages/mcp/src/stdio.ts"]
}
}
}
@ -42,7 +42,7 @@ Or run from source without installing:
"mcpServers": {
"open-pencil": {
"command": "npx",
"args": ["tsx", "/path/to/open-pencil/packages/mcp/src/index.ts"]
"args": ["tsx", "/path/to/open-pencil/packages/mcp/src/stdio.ts"]
}
}
}
@ -57,7 +57,7 @@ For browser extensions, scripts, CI, or any HTTP client:
openpencil-mcp-http
```
Or from source: `bun packages/mcp/src/http.ts` / `npx tsx packages/mcp/src/http.ts`
Or from source: `bun packages/mcp/src/index.ts` / `npx tsx packages/mcp/src/index.ts`
Security defaults (HTTP transport):
@ -67,7 +67,7 @@ Security defaults (HTTP transport):
- CORS is disabled by default; set `OPENPENCIL_MCP_CORS_ORIGIN` to allow one origin
- Optional auth token: `OPENPENCIL_MCP_AUTH_TOKEN` (client sends `Authorization: Bearer <token>` or `x-mcp-token`)
Server starts on port 3100 (override with `PORT` env var). Endpoints:
Server starts on port 7600 (override with `PORT` env var). Endpoints:
- `GET /health` — server status
- `POST /mcp` — MCP Streamable HTTP (SSE). Sessions via `mcp-session-id` header.

View file

@ -32,7 +32,7 @@ Or run from source without installing:
"mcpServers": {
"open-pencil": {
"command": "bun",
"args": ["/path/to/open-pencil/packages/mcp/src/index.ts"]
"args": ["/path/to/open-pencil/packages/mcp/src/stdio.ts"]
}
}
}
@ -42,7 +42,7 @@ Or run from source without installing:
"mcpServers": {
"open-pencil": {
"command": "npx",
"args": ["tsx", "/path/to/open-pencil/packages/mcp/src/index.ts"]
"args": ["tsx", "/path/to/open-pencil/packages/mcp/src/stdio.ts"]
}
}
}
@ -57,7 +57,7 @@ For browser extensions, scripts, CI, or any HTTP client:
openpencil-mcp-http
```
Or from source: `bun packages/mcp/src/http.ts` / `npx tsx packages/mcp/src/http.ts`
Or from source: `bun packages/mcp/src/index.ts` / `npx tsx packages/mcp/src/index.ts`
Security defaults (HTTP transport):
@ -67,7 +67,7 @@ Security defaults (HTTP transport):
- CORS is disabled by default; set `OPENPENCIL_MCP_CORS_ORIGIN` to allow one origin
- Optional auth token: `OPENPENCIL_MCP_AUTH_TOKEN` (client sends `Authorization: Bearer <token>` or `x-mcp-token`)
Server starts on port 3100 (override with `PORT` env var). Endpoints:
Server starts on port 7600 (override with `PORT` env var). Endpoints:
- `GET /health` — server status
- `POST /mcp` — MCP Streamable HTTP (SSE). Sessions via `mcp-session-id` header.

View file

@ -32,7 +32,7 @@ Or run from source without installing:
"mcpServers": {
"open-pencil": {
"command": "bun",
"args": ["/path/to/open-pencil/packages/mcp/src/index.ts"]
"args": ["/path/to/open-pencil/packages/mcp/src/stdio.ts"]
}
}
}
@ -42,7 +42,7 @@ Or run from source without installing:
"mcpServers": {
"open-pencil": {
"command": "npx",
"args": ["tsx", "/path/to/open-pencil/packages/mcp/src/index.ts"]
"args": ["tsx", "/path/to/open-pencil/packages/mcp/src/stdio.ts"]
}
}
}
@ -57,7 +57,7 @@ For browser extensions, scripts, CI, or any HTTP client:
openpencil-mcp-http
```
Or from source: `bun packages/mcp/src/http.ts` / `npx tsx packages/mcp/src/http.ts`
Or from source: `bun packages/mcp/src/index.ts` / `npx tsx packages/mcp/src/index.ts`
Security defaults (HTTP transport):
@ -67,7 +67,7 @@ Security defaults (HTTP transport):
- CORS is disabled by default; set `OPENPENCIL_MCP_CORS_ORIGIN` to allow one origin
- Optional auth token: `OPENPENCIL_MCP_AUTH_TOKEN` (client sends `Authorization: Bearer <token>` or `x-mcp-token`)
Server starts on port 3100 (override with `PORT` env var). Endpoints:
Server starts on port 7600 (override with `PORT` env var). Endpoints:
- `GET /health` — server status
- `POST /mcp` — MCP Streamable HTTP (SSE). Sessions via `mcp-session-id` header.

View file

@ -1,8 +1,8 @@
# MCP Server
OpenPencil includes an MCP (Model Context Protocol) server that lets AI coding tools — Claude Code, Cursor, Windsurf, etc. — read and modify `.fig` files headlessly.
OpenPencil includes an MCP (Model Context Protocol) server that lets AI coding tools — Claude Code, Cursor, Windsurf, etc. — read and modify designs through the running app.
Two transports: **stdio** for MCP clients, **HTTP** for everything else.
Two transports: **stdio** for MCP clients, **HTTP** for browser extensions and scripts.
## Install
@ -24,6 +24,8 @@ Add to your MCP config (e.g. `~/.claude/settings.json` or `.cursor/mcp.json`):
}
```
The stdio server connects to the running OpenPencil app via WebSocket (port 7601). Make sure the app is open with a document loaded.
Or run from source without installing:
::: code-group
@ -32,7 +34,7 @@ Or run from source without installing:
"mcpServers": {
"open-pencil": {
"command": "bun",
"args": ["/path/to/open-pencil/packages/mcp/src/index.ts"]
"args": ["/path/to/open-pencil/packages/mcp/src/stdio.ts"]
}
}
}
@ -42,7 +44,7 @@ Or run from source without installing:
"mcpServers": {
"open-pencil": {
"command": "npx",
"args": ["tsx", "/path/to/open-pencil/packages/mcp/src/index.ts"]
"args": ["tsx", "/path/to/open-pencil/packages/mcp/src/stdio.ts"]
}
}
}
@ -57,7 +59,7 @@ For browser extensions, scripts, CI, or any HTTP client:
openpencil-mcp-http
```
Or from source: `bun packages/mcp/src/http.ts` / `npx tsx packages/mcp/src/http.ts`
Or from source: `bun packages/mcp/src/index.ts` / `npx tsx packages/mcp/src/index.ts`
Security defaults (HTTP transport):
@ -67,7 +69,7 @@ Security defaults (HTTP transport):
- CORS is disabled by default; set `OPENPENCIL_MCP_CORS_ORIGIN` to allow one origin
- Optional auth token: `OPENPENCIL_MCP_AUTH_TOKEN` (client sends `Authorization: Bearer <token>` or `x-mcp-token`)
Server starts on port 3100 (override with `PORT` env var). Endpoints:
Server starts on port 7600 (override with `PORT` env var). Endpoints:
- `GET /health` — server status
- `POST /mcp` — MCP Streamable HTTP (SSE). Sessions via `mcp-session-id` header.

View file

@ -32,7 +32,7 @@ Or run from source without installing:
"mcpServers": {
"open-pencil": {
"command": "bun",
"args": ["/path/to/open-pencil/packages/mcp/src/index.ts"]
"args": ["/path/to/open-pencil/packages/mcp/src/stdio.ts"]
}
}
}
@ -42,7 +42,7 @@ Or run from source without installing:
"mcpServers": {
"open-pencil": {
"command": "npx",
"args": ["tsx", "/path/to/open-pencil/packages/mcp/src/index.ts"]
"args": ["tsx", "/path/to/open-pencil/packages/mcp/src/stdio.ts"]
}
}
}
@ -57,7 +57,7 @@ For browser extensions, scripts, CI, or any HTTP client:
openpencil-mcp-http
```
Or from source: `bun packages/mcp/src/http.ts` / `npx tsx packages/mcp/src/http.ts`
Or from source: `bun packages/mcp/src/index.ts` / `npx tsx packages/mcp/src/index.ts`
Security defaults (HTTP transport):
@ -67,7 +67,7 @@ Security defaults (HTTP transport):
- CORS is disabled by default; set `OPENPENCIL_MCP_CORS_ORIGIN` to allow one origin
- Optional auth token: `OPENPENCIL_MCP_AUTH_TOKEN` (client sends `Authorization: Bearer <token>` or `x-mcp-token`)
Server starts on port 3100 (override with `PORT` env var). Endpoints:
Server starts on port 7600 (override with `PORT` env var). Endpoints:
- `GET /health` — server status
- `POST /mcp` — MCP Streamable HTTP (SSE). Sessions via `mcp-session-id` header.

View file

@ -5,7 +5,8 @@
"type": "module",
"main": "./dist/server.js",
"bin": {
"openpencil-mcp": "./dist/index.js"
"openpencil-mcp": "./dist/stdio.js",
"openpencil-mcp-http": "./dist/index.js"
},
"files": [
"dist"

View file

@ -17,7 +17,7 @@ const { app, httpPort } = startServer({
serve({ fetch: app.fetch, port: httpPort, hostname: host })
console.log(`OpenPencil MCP server`)
console.log(` HTTP: http://${host}:${httpPort}`)
console.log(` WS: ws://${host}:${wsPort}`)
console.log(` MCP: http://${host}:${httpPort}/mcp`)
process.stderr.write(`OpenPencil MCP server\n`)
process.stderr.write(` HTTP: http://${host}:${httpPort}\n`)
process.stderr.write(` WS: ws://${host}:${wsPort}\n`)
process.stderr.write(` MCP: http://${host}:${httpPort}/mcp\n`)

View file

@ -19,10 +19,12 @@ import {
import type { ParamDef, ParamType } from '@open-pencil/core'
const require = createRequire(import.meta.url)
const MCP_VERSION: string = (require('../package.json') as { version: string }).version
export const MCP_VERSION: string = (require('../package.json') as { version: string }).version
type MCPContent = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }
type MCPResult = { content: MCPContent[]; isError?: boolean }
export type MCPContent =
| { type: 'text'; text: string }
| { type: 'image'; data: string; mimeType: string }
export type MCPResult = { content: MCPContent[]; isError?: boolean }
const RPC_TIMEOUT = 30_000
@ -32,15 +34,90 @@ interface PendingRequest {
timer: ReturnType<typeof setTimeout>
}
function ok(data: unknown): MCPResult {
export function ok(data: unknown): MCPResult {
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }
}
function fail(e: unknown): MCPResult {
export function fail(e: unknown): MCPResult {
const msg = e instanceof Error ? e.message : String(e)
return { content: [{ type: 'text', text: JSON.stringify({ error: msg }) }], isError: true }
}
export type RpcSender = (body: Record<string, unknown>) => Promise<unknown>
export interface RegisterToolsOptions {
enableEval: boolean
sendRpc: RpcSender
}
export function registerTools(mcpServer: McpServer, options: RegisterToolsOptions) {
const { enableEval, sendRpc } = options
const register = mcpServer.registerTool.bind(mcpServer) as (...a: unknown[]) => void
for (const def of ALL_TOOLS) {
if (!enableEval && def.name === 'eval') continue
const shape: Record<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 sendRpc({ command: 'tool', args: { name: def.name, args } })
const res = result as { ok?: boolean; result?: unknown; error?: string }
if (res.ok === false) return fail(new Error(res.error))
const r = res.result as Record<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(
'save_file',
{
description:
'Save the current document to disk. Uses the existing file path if available, otherwise prompts for a location.',
inputSchema: z.object({})
},
async () => {
try {
const result = await sendRpc({ command: 'save_file' })
const res = result as { ok?: boolean; error?: string }
if (res.ok === false) return fail(new Error(res.error))
return ok({ saved: true })
} catch (e) {
return fail(e)
}
}
)
register(
'get_codegen_prompt',
{
description:
'Get design-to-code generation guidelines. Call before generating frontend code.',
inputSchema: z.object({})
},
async () => ok({ prompt: CODEGEN_PROMPT })
)
}
export function paramToZod(param: ParamDef): z.ZodType {
const typeMap: Record<ParamType, () => z.ZodType> = {
string: () =>
@ -274,70 +351,7 @@ export function startServer(options: ServerOptions = {}) {
function createMCPSession(id: string): MCPTransport {
const mcpServer = new McpServer({ name: 'open-pencil', version: MCP_VERSION })
const register = mcpServer.registerTool.bind(mcpServer) as (...a: unknown[]) => void
for (const def of ALL_TOOLS) {
if (!enableEval && def.name === 'eval') continue
const shape: Record<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(
'save_file',
{
description:
'Save the current document to disk. Uses the existing file path if available, otherwise prompts for a location.',
inputSchema: z.object({})
},
async () => {
try {
const result = await sendToBrowser({ command: 'save_file' })
const res = result as { ok?: boolean; error?: string }
if (res.ok === false) return fail(new Error(res.error))
return ok({ saved: true })
} catch (e) {
return fail(e)
}
}
)
register(
'get_codegen_prompt',
{
description:
'Get design-to-code generation guidelines. Call before generating frontend code.',
inputSchema: z.object({})
},
async () => ok({ prompt: CODEGEN_PROMPT })
)
registerTools(mcpServer, { enableEval, sendRpc: sendToBrowser })
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: () => id

99
packages/mcp/src/stdio.ts Normal file
View file

@ -0,0 +1,99 @@
#!/usr/bin/env node
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
import { WebSocket } from 'ws'
import { MCP_VERSION, registerTools } from './server.js'
const wsPort = parseInt(process.env.WS_PORT ?? '7601', 10)
const wsHost = process.env.HOST ?? '127.0.0.1'
const enableEval = process.env.OPENPENCIL_MCP_EVAL === '1'
const wsUrl = `ws://${wsHost}:${wsPort}`
let ws: WebSocket | null = null
let registered = false
const pending = new Map<
string,
{
resolve: (v: unknown) => void
reject: (e: Error) => void
timer: ReturnType<typeof setTimeout>
}
>()
function connect() {
ws = new WebSocket(wsUrl)
ws.on('open', () => {
process.stderr.write(`Connected to OpenPencil app at ${wsUrl}\n`)
})
ws.on('message', (raw) => {
try {
const msg = JSON.parse(String(raw)) as {
type: string
id?: string
token?: string
result?: unknown
error?: string
ok?: boolean
}
if (msg.type === 'register' && msg.token) {
registered = true
return
}
if (msg.type === 'response' && msg.id) {
const req = pending.get(msg.id)
if (!req) return
pending.delete(msg.id)
clearTimeout(req.timer)
if (msg.ok === false) req.reject(new Error(msg.error ?? 'RPC failed'))
else {
const { type: _, id: __, ...payload } = msg
req.resolve(payload)
}
}
} catch {
process.stderr.write('Malformed WS message\n')
}
})
ws.on('close', () => {
registered = false
for (const [id, req] of pending) {
clearTimeout(req.timer)
req.reject(new Error('WebSocket closed'))
pending.delete(id)
}
setTimeout(connect, 2000)
})
ws.on('error', () => {
ws?.close()
})
}
function sendRpc(body: Record<string, unknown>): Promise<unknown> {
return new Promise((resolve, reject) => {
if (!ws || ws.readyState !== WebSocket.OPEN || !registered) {
reject(new Error('OpenPencil app is not connected. Start the app and open a document.'))
return
}
const id = crypto.randomUUID()
const timer = setTimeout(() => {
pending.delete(id)
reject(new Error('RPC timeout (30s)'))
}, 30_000)
pending.set(id, { resolve, reject, timer })
ws.send(JSON.stringify({ type: 'request', id, ...body }))
})
}
connect()
const mcpServer = new McpServer({ name: 'open-pencil', version: MCP_VERSION })
registerTools(mcpServer, { enableEval, sendRpc })
const transport = new StdioServerTransport()
void mcpServer.connect(transport)

View file

@ -65,7 +65,7 @@ export async function spawnMCPIfNeeded(): Promise<AutomationServerHandle | null>
runtimeAutomationAuthToken = authToken
const { Command } = await import('@tauri-apps/plugin-shell')
const command = Command.create('openpencil-mcp', [], {
const command = Command.create('openpencil-mcp-http', [], {
env: {
OPENPENCIL_MCP_AUTH_TOKEN: authToken,
OPENPENCIL_MCP_CORS_ORIGIN: window.location.origin
@ -95,6 +95,6 @@ export async function spawnMCPIfNeeded(): Promise<AutomationServerHandle | null>
await child.kill()
throw new Error(
'Failed to start MCP server. Is openpencil-mcp installed? Run: npm i -g @open-pencil/mcp'
'Failed to start MCP server. Is openpencil-mcp-http installed? Run: npm i -g @open-pencil/mcp'
)
}

View file

@ -0,0 +1,207 @@
import { describe, expect, test, beforeEach, afterEach } from 'bun:test'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
import { WebSocketServer, type WebSocket } from 'ws'
import {
ALL_TOOLS,
FigmaAPI,
SceneGraph,
computeAllLayouts,
executeRpcCommand
} from '@open-pencil/core'
import type { AddressInfo } from 'node:net'
function createMockApp() {
const graph = new SceneGraph()
const wss = new WebSocketServer({ port: 0, host: '127.0.0.1' })
let clientWs: WebSocket | null = null
wss.on('connection', (ws) => {
clientWs = ws
ws.send(JSON.stringify({ type: 'register', token: 'mock-token' }))
ws.on('message', async (raw) => {
const msg = JSON.parse(String(raw)) as {
type: string
id: string
command: string
args?: { name?: string; args?: Record<string, unknown> }
}
if (msg.type !== 'request') return
try {
let result: unknown
if (msg.command === 'tool' && msg.args?.name) {
const def = ALL_TOOLS.find((t) => t.name === msg.args!.name)
if (!def) throw new Error(`Unknown tool: ${msg.args.name}`)
const api = new FigmaAPI(graph)
api.currentPage = api.wrapNode(graph.getPages()[0].id)
result = await def.execute(api, msg.args.args ?? {})
if (def.mutates) computeAllLayouts(graph)
} else if (msg.command === 'save_file') {
result = { ok: true }
} else {
result = executeRpcCommand(graph, msg.command, msg.args ?? {})
}
ws.send(JSON.stringify({ type: 'response', id: msg.id, ok: true, result }))
} catch (e) {
ws.send(
JSON.stringify({
type: 'response',
id: msg.id,
ok: false,
error: e instanceof Error ? e.message : String(e)
})
)
}
})
})
const port = new Promise<number>((resolve) => {
wss.on('listening', () => resolve((wss.address() as AddressInfo).port))
})
return {
graph,
wss,
port,
close: () => {
clientWs?.close()
wss.close()
}
}
}
async function createStdioClient(wsPort: number) {
const transport = new StdioClientTransport({
command: 'bun',
args: ['packages/mcp/src/stdio.ts'],
env: {
...process.env,
WS_PORT: String(wsPort),
PATH: process.env.PATH ?? ''
},
stderr: 'pipe'
})
const client = new Client({ name: 'test-stdio-client', version: '0.0.0' })
await new Promise<void>((resolve) => {
const stderr = transport.stderr
if (stderr && 'on' in stderr) {
;(stderr as NodeJS.ReadableStream).on('data', (chunk: Buffer) => {
if (chunk.toString().includes('Connected to OpenPencil app')) {
resolve()
}
})
}
void client.connect(transport).then(() => {
setTimeout(resolve, 1000)
})
})
return { client, transport }
}
describe('MCP stdio transport', () => {
let app: ReturnType<typeof createMockApp>
let client: Client
let transport: StdioClientTransport
beforeEach(async () => {
app = createMockApp()
const wsPort = await app.port
const ctx = await createStdioClient(wsPort)
client = ctx.client
transport = ctx.transport
})
afterEach(async () => {
await client.close()
app.close()
})
test('lists tools over stdio', async () => {
const { tools } = await client.listTools()
const names = tools.map((t) => t.name)
expect(names).toContain('create_shape')
expect(names).toContain('get_page_tree')
expect(names).toContain('save_file')
expect(names).toContain('get_codegen_prompt')
expect(tools.length).toBeGreaterThan(30)
})
test('create_shape via stdio creates a node', async () => {
const result = await client.callTool({
name: 'create_shape',
arguments: { type: 'FRAME', x: 10, y: 20, width: 200, height: 100, name: 'StdioFrame' }
})
expect(result.isError).not.toBe(true)
const data = JSON.parse(
(result.content as { type: string; text: string }[]).find((c) => c.type === 'text')!.text
) as { id: string; name: string; type: string }
expect(data.type).toBe('FRAME')
expect(data.name).toBe('StdioFrame')
const node = app.graph.getNode(data.id)
expect(node).toBeDefined()
expect(node!.width).toBe(200)
})
test('save_file via stdio succeeds', async () => {
const result = await client.callTool({ name: 'save_file', arguments: {} })
expect(result.isError).not.toBe(true)
const data = JSON.parse(
(result.content as { type: string; text: string }[]).find((c) => c.type === 'text')!.text
) as { saved: boolean }
expect(data.saved).toBe(true)
})
test('get_codegen_prompt via stdio returns prompt', async () => {
const result = await client.callTool({ name: 'get_codegen_prompt', arguments: {} })
expect(result.isError).not.toBe(true)
const data = JSON.parse(
(result.content as { type: string; text: string }[]).find((c) => c.type === 'text')!.text
) as { prompt: string }
expect(data.prompt.length).toBeGreaterThan(100)
})
test('delete_node via stdio removes a node', async () => {
const create = await client.callTool({
name: 'create_shape',
arguments: { type: 'RECTANGLE', x: 0, y: 0, width: 50, height: 50 }
})
const { id } = JSON.parse(
(create.content as { type: string; text: string }[]).find((c) => c.type === 'text')!.text
) as { id: string }
expect(app.graph.getNode(id)).toBeDefined()
await client.callTool({ name: 'delete_node', arguments: { id } })
expect(app.graph.getNode(id)).toBeUndefined()
})
test('stderr does not contain JSON-RPC', async () => {
const stderrChunks: string[] = []
const stderr = transport.stderr
if (stderr && 'on' in stderr) {
;(stderr as NodeJS.ReadableStream).on('data', (chunk: Buffer) => {
stderrChunks.push(chunk.toString())
})
}
await client.callTool({
name: 'create_shape',
arguments: { type: 'FRAME', x: 0, y: 0, width: 100, height: 100 }
})
const allStderr = stderrChunks.join('')
expect(allStderr).not.toContain('"jsonrpc"')
expect(allStderr).not.toContain('"method"')
})
})