@open-pencil/mcp
Advanced tools
| #!/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 mcpRoot = process.env.OPENPENCIL_MCP_ROOT?.trim() || process.cwd(); | ||
| const wsUrl = `ws://${wsHost}:${wsPort}`; | ||
| let ws = null; | ||
| let registered = false; | ||
| const pending = new Map(); | ||
| function connect() { | ||
| ws = new WebSocket(wsUrl); | ||
| ws.on('open', () => { | ||
| process.stderr.write(`Connected to OpenPencil app at ${wsUrl}\n`); | ||
| }); | ||
| ws.on('message', (raw) => { | ||
| try { | ||
| let text; | ||
| if (Buffer.isBuffer(raw)) | ||
| text = raw.toString('utf8'); | ||
| else if (Array.isArray(raw)) | ||
| text = Buffer.concat(raw).toString('utf8'); | ||
| else | ||
| text = Buffer.from(raw).toString('utf8'); | ||
| const msg = JSON.parse(text); | ||
| 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) { | ||
| return new Promise((resolve, reject) => { | ||
| if (!ws || ws.readyState !== WebSocket.OPEN || !registered) { | ||
| reject(new Error('OpenPencil app is not connected. ' + | ||
| 'STOP and tell the user: "The OpenPencil desktop app is not running or no document is open. ' + | ||
| 'Please start the app and open a document, then try again." ' + | ||
| 'Do NOT attempt to start the app yourself or retry automatically.')); | ||
| 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, mcpRoot, sendRpc }); | ||
| const transport = new StdioServerTransport(); | ||
| void mcpServer.connect(transport); |
+5
-4
@@ -11,2 +11,3 @@ #!/usr/bin/env node | ||
| enableEval: process.env.OPENPENCIL_MCP_EVAL === '1', | ||
| mcpRoot: process.env.OPENPENCIL_MCP_ROOT?.trim() || process.cwd(), | ||
| authToken: process.env.OPENPENCIL_MCP_AUTH_TOKEN?.trim() || null, | ||
@@ -16,5 +17,5 @@ corsOrigin: process.env.OPENPENCIL_MCP_CORS_ORIGIN?.trim() || null | ||
| 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`); |
+147
-58
| import { randomUUID } from 'node:crypto'; | ||
| import { writeFile, mkdir } from 'node:fs/promises'; | ||
| import { createRequire } from 'node:module'; | ||
| import { resolve, dirname, sep as osSep } from 'node:path'; | ||
| import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; | ||
@@ -11,11 +13,136 @@ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'; | ||
| const require = createRequire(import.meta.url); | ||
| const MCP_VERSION = require('../package.json').version; | ||
| export const MCP_VERSION = require('../package.json').version; | ||
| const RPC_TIMEOUT = 30_000; | ||
| function ok(data) { | ||
| export function ok(data) { | ||
| return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }; | ||
| } | ||
| function fail(e) { | ||
| export function fail(e) { | ||
| const msg = e instanceof Error ? e.message : String(e); | ||
| return { content: [{ type: 'text', text: JSON.stringify({ error: msg }) }], isError: true }; | ||
| } | ||
| function resolveSafePath(filePath, root) { | ||
| const resolved = resolve(filePath); | ||
| const sep = root.endsWith('/') || root.endsWith('\\') ? '' : osSep; | ||
| if (!resolved.startsWith(root + sep) && resolved !== root) { | ||
| throw new Error(`Path is outside the allowed root: ${root}`); | ||
| } | ||
| return resolved; | ||
| } | ||
| export function registerTools(mcpServer, options) { | ||
| const { enableEval, sendRpc } = options; | ||
| const resolvedRoot = options.mcpRoot ? resolve(options.mcpRoot) : null; | ||
| const register = mcpServer.registerTool.bind(mcpServer); | ||
| async function writeToolOutput(toolName, r, filePath, root) { | ||
| const resolved = resolveSafePath(filePath, root); | ||
| await mkdir(dirname(resolved), { recursive: true }); | ||
| if (toolName === 'export_svg' && typeof r.svg === 'string') { | ||
| await writeFile(resolved, r.svg, 'utf8'); | ||
| return ok({ written: resolved, byteLength: Buffer.byteLength(r.svg, 'utf8') }); | ||
| } | ||
| if (toolName === 'export_image' && typeof r.base64 === 'string') { | ||
| await writeFile(resolved, Buffer.from(r.base64, 'base64')); | ||
| return ok({ written: resolved, byteLength: r.byteLength ?? null }); | ||
| } | ||
| if (toolName === 'get_jsx' && typeof r.jsx === 'string') { | ||
| await writeFile(resolved, r.jsx, 'utf8'); | ||
| return ok({ written: resolved, byteLength: Buffer.byteLength(r.jsx, 'utf8') }); | ||
| } | ||
| return null; | ||
| } | ||
| for (const def of ALL_TOOLS) { | ||
| if (!enableEval && def.name === 'eval') | ||
| continue; | ||
| const shape = {}; | ||
| 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) => { | ||
| try { | ||
| const result = await sendRpc({ command: 'tool', args: { name: def.name, args } }); | ||
| const res = result; | ||
| if (res.ok === false) | ||
| return fail(new Error(res.error)); | ||
| const r = res.result; | ||
| const filePath = typeof args.path === 'string' ? args.path : null; | ||
| if (r && filePath && resolvedRoot) { | ||
| const written = await writeToolOutput(def.name, r, filePath, resolvedRoot); | ||
| if (written) | ||
| return written; | ||
| } | ||
| if (r && 'base64' in r && 'mimeType' in r) { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'image', | ||
| data: r.base64, | ||
| mimeType: r.mimeType | ||
| } | ||
| ] | ||
| }; | ||
| } | ||
| 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; | ||
| if (res.ok === false) | ||
| return fail(new Error(res.error)); | ||
| return ok({ saved: true }); | ||
| } | ||
| catch (e) { | ||
| return fail(e); | ||
| } | ||
| }); | ||
| if (resolvedRoot) { | ||
| register('open_file', { | ||
| description: `Open a .fig or .pen file from disk into a new tab. Path must be inside ${resolvedRoot}.`, | ||
| inputSchema: z.object({ | ||
| path: z.string().describe('Absolute path to the design file') | ||
| }) | ||
| }, async (args) => { | ||
| try { | ||
| const safe = resolveSafePath(args.path, resolvedRoot); | ||
| const result = await sendRpc({ command: 'open_file', args: { path: safe } }); | ||
| const res = result; | ||
| if (res.ok === false) | ||
| return fail(new Error(res.error)); | ||
| return ok({ opened: true }); | ||
| } | ||
| catch (e) { | ||
| return fail(e); | ||
| } | ||
| }); | ||
| register('new_document', { | ||
| description: `Create a new empty document. Optionally set a save path inside ${resolvedRoot}.`, | ||
| inputSchema: z.object({ | ||
| path: z.string().describe('Optional absolute path for the new file').optional() | ||
| }) | ||
| }, async (args) => { | ||
| try { | ||
| const safePath = args.path ? resolveSafePath(args.path, resolvedRoot) : undefined; | ||
| const result = await sendRpc({ command: 'new_document', args: { path: safePath } }); | ||
| const res = result; | ||
| if (res.ok === false) | ||
| return fail(new Error(res.error)); | ||
| return ok({ created: 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) { | ||
@@ -27,3 +154,3 @@ const typeMap = { | ||
| number: () => { | ||
| let s = z.number(); | ||
| let s = z.coerce.number(); | ||
| if (param.min !== undefined) | ||
@@ -46,2 +173,3 @@ s = s.min(param.min); | ||
| const enableEval = options.enableEval ?? false; | ||
| const mcpRoot = options.mcpRoot ?? null; | ||
| const authToken = options.authToken ?? null; | ||
@@ -87,2 +215,3 @@ const corsOrigin = options.corsOrigin ?? null; | ||
| browserRegistered = true; | ||
| notifyToolsChanged(); | ||
| return; | ||
@@ -128,2 +257,3 @@ } | ||
| rejectAllPending('Browser disconnected'); | ||
| notifyToolsChanged(); | ||
| } | ||
@@ -203,2 +333,7 @@ }); | ||
| const mcpSessions = new Map(); | ||
| function notifyToolsChanged() { | ||
| for (const session of mcpSessions.values()) { | ||
| session.server.sendToolListChanged().catch(() => undefined); | ||
| } | ||
| } | ||
| const MAX_MCP_SESSIONS = 10; | ||
@@ -216,54 +351,3 @@ const MCP_SESSION_TTL_MS = 15 * 60_000; | ||
| const mcpServer = new McpServer({ name: 'open-pencil', version: MCP_VERSION }); | ||
| const register = mcpServer.registerTool.bind(mcpServer); | ||
| for (const def of ALL_TOOLS) { | ||
| if (!enableEval && def.name === 'eval') | ||
| continue; | ||
| const shape = {}; | ||
| 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) => { | ||
| try { | ||
| const result = await sendToBrowser({ command: 'tool', args: { name: def.name, args } }); | ||
| const res = result; | ||
| if (res.ok === false) | ||
| return fail(new Error(res.error)); | ||
| const r = res.result; | ||
| if (r && 'base64' in r && 'mimeType' in r) { | ||
| return { | ||
| content: [ | ||
| { | ||
| type: 'image', | ||
| data: r.base64, | ||
| mimeType: r.mimeType | ||
| } | ||
| ] | ||
| }; | ||
| } | ||
| 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; | ||
| 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, mcpRoot, sendRpc: sendToBrowser }); | ||
| const transport = new WebStandardStreamableHTTPServerTransport({ | ||
@@ -273,3 +357,3 @@ sessionIdGenerator: () => id | ||
| void mcpServer.connect(transport); | ||
| mcpSessions.set(id, { transport, lastSeen: Date.now() }); | ||
| mcpSessions.set(id, { transport, server: mcpServer, lastSeen: Date.now() }); | ||
| return transport; | ||
@@ -307,3 +391,8 @@ } | ||
| }); | ||
| return { app, wss, httpPort }; | ||
| function close() { | ||
| rejectAllPending('Server shutting down'); | ||
| mcpSessions.clear(); | ||
| wss.close(); | ||
| } | ||
| return { app, wss, httpPort, close }; | ||
| } |
+4
-3
| { | ||
| "name": "@open-pencil/mcp", | ||
| "version": "0.11.6", | ||
| "version": "0.11.7", | ||
| "license": "MIT", | ||
@@ -8,3 +8,4 @@ "type": "module", | ||
| "bin": { | ||
| "openpencil-mcp": "./dist/index.js" | ||
| "openpencil-mcp": "./dist/stdio.js", | ||
| "openpencil-mcp-http": "./dist/index.js" | ||
| }, | ||
@@ -22,3 +23,3 @@ "files": [ | ||
| "@modelcontextprotocol/sdk": "^1.25.2", | ||
| "@open-pencil/core": "^0.11.5", | ||
| "@open-pencil/core": "^0.11.7", | ||
| "hono": "^4.11.4", | ||
@@ -25,0 +26,0 @@ "zod": "^4.3.6", |
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
20533
54.11%4
33.33%494
55.35%14
75%Updated