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:
parent
082fc2c88e
commit
cd4e1a2271
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
|
|||
|
|
@ -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
99
packages/mcp/src/stdio.ts
Normal 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)
|
||||
|
|
@ -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'
|
||||
)
|
||||
}
|
||||
|
|
|
|||
207
tests/engine/mcp-stdio.test.ts
Normal file
207
tests/engine/mcp-stdio.test.ts
Normal 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"')
|
||||
})
|
||||
})
|
||||
Loading…
Reference in a new issue