🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@open-pencil/mcp

Package Overview
Dependencies
Maintainers
1
Versions
24
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@open-pencil/mcp - npm Package Compare versions

Comparing version
0.11.6
to
0.11.7
+86
dist/stdio.js
#!/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`);
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 };
}
{
"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",