@open-pencil/mcp 0.7.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,12 +1,11 @@
1
1
  {
2
2
  "name": "@open-pencil/mcp",
3
- "version": "0.7.0",
3
+ "version": "0.11.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./src/server.ts",
7
7
  "bin": {
8
- "openpencil-mcp": "./dist/index.js",
9
- "openpencil-mcp-http": "./dist/http.js"
8
+ "openpencil-mcp": "./dist/index.js"
10
9
  },
11
10
  "files": [
12
11
  "src",
@@ -28,12 +27,13 @@
28
27
  "dependencies": {
29
28
  "@hono/node-server": "^1.19.9",
30
29
  "@modelcontextprotocol/sdk": "^1.25.2",
31
- "@open-pencil/core": "workspace:*",
32
- "canvaskit-wasm": "^0.40.0",
30
+ "@open-pencil/core": "^0.11.0",
33
31
  "hono": "^4.11.4",
34
- "zod": "^3.25.0"
32
+ "zod": "^4.3.6",
33
+ "ws": "^8.19.0"
35
34
  },
36
35
  "devDependencies": {
37
- "@types/node": "^22.0.0"
36
+ "@types/node": "^22.0.0",
37
+ "@types/ws": "^8.18.1"
38
38
  }
39
39
  }
package/src/index.ts CHANGED
@@ -1,12 +1,23 @@
1
1
  #!/usr/bin/env node
2
- import { readFile } from 'node:fs/promises'
2
+ import { serve } from '@hono/node-server'
3
3
 
4
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
4
+ import { startServer } from './server.js'
5
5
 
6
- import { createServer } from './server.js'
6
+ const port = parseInt(process.env.PORT ?? '7600', 10)
7
+ const wsPort = parseInt(process.env.WS_PORT ?? '7601', 10)
8
+ const host = process.env.HOST ?? '127.0.0.1'
7
9
 
8
- const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf-8'))
9
- const server = createServer(pkg.version)
10
+ const { app, httpPort } = startServer({
11
+ httpPort: port,
12
+ wsPort,
13
+ enableEval: process.env.OPENPENCIL_MCP_EVAL === '1',
14
+ authToken: process.env.OPENPENCIL_MCP_AUTH_TOKEN?.trim() || null,
15
+ corsOrigin: process.env.OPENPENCIL_MCP_CORS_ORIGIN?.trim() || null
16
+ })
10
17
 
11
- const transport = new StdioServerTransport()
12
- await server.connect(transport)
18
+ serve({ fetch: app.fetch, port: httpPort, hostname: host })
19
+
20
+ console.log(`OpenPencil MCP server`)
21
+ console.log(` HTTP: http://${host}:${httpPort}`)
22
+ console.log(` WS: ws://${host}:${wsPort}`)
23
+ console.log(` MCP: http://${host}:${httpPort}/mcp`)
package/src/server.ts CHANGED
@@ -1,30 +1,48 @@
1
- import { readFile, writeFile } from 'node:fs/promises'
2
- import { isAbsolute, relative, resolve } from 'node:path'
1
+ import { randomUUID } from 'node:crypto'
2
+ import { createRequire } from 'node:module'
3
3
 
4
4
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
5
+ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'
6
+ import { Hono } from 'hono'
7
+ import { cors } from 'hono/cors'
8
+ import { WebSocketServer, type WebSocket } from 'ws'
5
9
  import { z } from 'zod'
6
10
 
7
- import { ALL_TOOLS, FigmaAPI, parseFigFile, computeAllLayouts, SceneGraph } from '@open-pencil/core'
11
+ import {
12
+ ALL_TOOLS,
13
+ CODEGEN_PROMPT,
14
+ buildComponent,
15
+ createElement,
16
+ resolveToTree
17
+ } from '@open-pencil/core'
8
18
 
9
- import type { ToolDef, ParamDef, ParamType } from '@open-pencil/core'
19
+ import type { ParamDef, ParamType } from '@open-pencil/core'
10
20
 
11
- type McpResult = { content: { type: 'text'; text: string }[]; isError?: boolean }
12
- export interface CreateServerOptions {
13
- enableEval?: boolean
14
- fileRoot?: string | null
21
+ const require = createRequire(import.meta.url)
22
+ const MCP_VERSION: string = (require('../package.json') as { version: string }).version
23
+
24
+ type MCPContent = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }
25
+ type MCPResult = { content: MCPContent[]; isError?: boolean }
26
+
27
+ const RPC_TIMEOUT = 30_000
28
+
29
+ interface PendingRequest {
30
+ resolve: (value: unknown) => void
31
+ reject: (error: Error) => void
32
+ timer: ReturnType<typeof setTimeout>
15
33
  }
16
34
 
17
- function ok(data: unknown): McpResult {
35
+ function ok(data: unknown): MCPResult {
18
36
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }
19
37
  }
20
38
 
21
- function fail(e: unknown): McpResult {
39
+ function fail(e: unknown): MCPResult {
22
40
  const msg = e instanceof Error ? e.message : String(e)
23
41
  return { content: [{ type: 'text', text: JSON.stringify({ error: msg }) }], isError: true }
24
42
  }
25
43
 
26
- function paramToZod(param: ParamDef): z.ZodTypeAny {
27
- const typeMap: Record<ParamType, () => z.ZodTypeAny> = {
44
+ export function paramToZod(param: ParamDef): z.ZodType {
45
+ const typeMap: Record<ParamType, () => z.ZodType> = {
28
46
  string: () =>
29
47
  param.enum
30
48
  ? z.enum(param.enum as [string, ...string[]]).describe(param.description)
@@ -44,114 +62,270 @@ function paramToZod(param: ParamDef): z.ZodTypeAny {
44
62
  return param.required ? schema : schema.optional()
45
63
  }
46
64
 
47
- export function createServer(version: string, options: CreateServerOptions = {}): McpServer {
48
- const server = new McpServer({ name: 'open-pencil', version })
49
- const enableEval = options.enableEval ?? true
50
- const fileRoot = options.fileRoot === null || options.fileRoot === undefined
51
- ? null
52
- : resolve(options.fileRoot)
53
-
54
- let graph: SceneGraph | null = null
55
- let currentPageId: string | null = null
56
-
57
- function makeFigma(): FigmaAPI {
58
- if (!graph) throw new Error('No document loaded. Use open_file or new_document first.')
59
- const api = new FigmaAPI(graph)
60
- if (currentPageId) api.currentPage = api.wrapNode(currentPageId)
61
- return api
65
+ export interface ServerOptions {
66
+ httpPort?: number
67
+ wsPort?: number
68
+ enableEval?: boolean
69
+ authToken?: string | null
70
+ corsOrigin?: string | null
71
+ }
72
+
73
+ export function startServer(options: ServerOptions = {}) {
74
+ const httpPort = options.httpPort ?? 7600
75
+ const wsPort = options.wsPort ?? 7601
76
+ const enableEval = options.enableEval ?? false
77
+ const authToken = options.authToken ?? null
78
+ const corsOrigin = options.corsOrigin ?? null
79
+
80
+ const pending = new Map<string, PendingRequest>()
81
+ let browserWs: WebSocket | null = null
82
+ let browserToken: string | null = null
83
+
84
+ // --- WebSocket: browser connects here ---
85
+
86
+ function sendToBrowser(body: Record<string, unknown>): Promise<unknown> {
87
+ return new Promise((resolve, reject) => {
88
+ if (!browserWs || browserWs.readyState !== browserWs.OPEN) {
89
+ reject(new Error('OpenPencil app is not connected'))
90
+ return
91
+ }
92
+ const id = randomUUID()
93
+ const timer = setTimeout(() => {
94
+ pending.delete(id)
95
+ reject(new Error('RPC timeout (30s)'))
96
+ }, RPC_TIMEOUT)
97
+ pending.set(id, { resolve, reject, timer })
98
+ browserWs.send(JSON.stringify({ type: 'request', id, ...body }))
99
+ })
62
100
  }
63
101
 
64
- function resolveAndCheckPath(filePath: string): string {
65
- const resolved = resolve(filePath)
66
- if (!fileRoot) return resolved
67
- const rel = relative(fileRoot, resolved)
68
- if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) {
69
- return resolved
102
+ function handleBrowserMessage(data: string) {
103
+ try {
104
+ const msg = JSON.parse(data) as {
105
+ type: string
106
+ id?: string
107
+ token?: string
108
+ result?: unknown
109
+ error?: string
110
+ ok?: boolean
111
+ }
112
+ if (msg.type === 'register' && msg.token) {
113
+ browserToken = msg.token
114
+ return
115
+ }
116
+ if (msg.type === 'response' && msg.id) {
117
+ const req = pending.get(msg.id)
118
+ if (!req) return
119
+ pending.delete(msg.id)
120
+ clearTimeout(req.timer)
121
+ if (msg.ok === false) req.reject(new Error(msg.error ?? 'RPC failed'))
122
+ else {
123
+ const { type: _, id: __, ...payload } = msg
124
+ req.resolve(payload)
125
+ }
126
+ }
127
+ } catch (e) {
128
+ console.warn('Malformed automation message:', e)
70
129
  }
71
- throw new Error(`Path "${filePath}" is outside allowed root "${fileRoot}"`)
72
130
  }
73
131
 
74
- function registerTool(def: ToolDef) {
75
- const shape: Record<string, z.ZodTypeAny> = {}
76
- for (const [key, param] of Object.entries(def.params)) {
77
- shape[key] = paramToZod(param)
132
+ function rejectAllPending(reason: string) {
133
+ for (const [id, req] of pending) {
134
+ clearTimeout(req.timer)
135
+ req.reject(new Error(reason))
136
+ pending.delete(id)
78
137
  }
138
+ }
139
+
140
+ const wss = new WebSocketServer({ port: wsPort, host: '127.0.0.1' })
141
+
142
+ wss.on('connection', (ws) => {
143
+ if (browserWs && browserWs.readyState === WebSocket.OPEN) browserWs.close()
144
+ rejectAllPending('Browser reconnected')
145
+ browserWs = ws
146
+ browserToken = null
147
+
148
+ ws.on('message', (raw) => {
149
+ handleBrowserMessage(
150
+ typeof raw === 'string' ? raw : Buffer.from(raw as Buffer).toString('utf-8')
151
+ )
152
+ })
79
153
 
80
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic schema from ToolDef params
81
- server.registerTool(def.name, { description: def.description, inputSchema: z.object(shape) } as any, async (args: any) => {
82
- try {
83
- const result = await def.execute(makeFigma(), args as Record<string, unknown>)
84
- return ok(result)
85
- } catch (e) {
86
- return fail(e)
154
+ ws.on('close', () => {
155
+ if (browserWs === ws) {
156
+ browserWs = null
157
+ browserToken = null
158
+ rejectAllPending('Browser disconnected')
87
159
  }
88
160
  })
89
- }
161
+ })
90
162
 
91
- const register = server.registerTool.bind(server) as (...args: unknown[]) => void
92
- register(
93
- 'open_file',
94
- {
95
- description: 'Open a .fig file for editing. Must be called before using other tools.',
96
- inputSchema: z.object({ path: z.string().describe('Absolute path to a .fig file') })
97
- },
98
- async ({ path: filePath }: { path: string }) => {
99
- try {
100
- const path = resolveAndCheckPath(filePath)
101
- const buf = await readFile(path)
102
- graph = await parseFigFile(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength))
103
- computeAllLayouts(graph)
104
- const pages = graph.getPages()
105
- currentPageId = pages[0]?.id ?? null
106
- return ok({ pages: pages.map((p) => ({ id: p.id, name: p.name })), currentPage: pages[0]?.name })
107
- } catch (e) {
108
- return fail(e)
163
+ // --- JSX preprocessing ---
164
+
165
+ function preprocessRpc(body: Record<string, unknown>): Record<string, unknown> {
166
+ if (body.command !== 'tool') return body
167
+ const args = body.args as { name?: string; args?: Record<string, unknown> } | undefined
168
+ if (args?.name !== 'render' || !args.args?.jsx) return body
169
+ try {
170
+ const Component = buildComponent(args.args.jsx as string)
171
+ const element = createElement(Component, null)
172
+ const tree = resolveToTree(element)
173
+ return {
174
+ ...body,
175
+ args: { ...args, args: { ...args.args, jsx: undefined, tree } }
109
176
  }
177
+ } catch (e) {
178
+ console.warn('JSX preprocessing failed, passing raw:', e instanceof Error ? e.message : e)
179
+ return body
110
180
  }
181
+ }
182
+
183
+ // --- HTTP server ---
184
+
185
+ const app = new Hono()
186
+
187
+ if (corsOrigin) {
188
+ app.use(
189
+ '*',
190
+ cors({
191
+ origin: corsOrigin,
192
+ allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
193
+ allowHeaders: [
194
+ 'Content-Type',
195
+ 'Authorization',
196
+ 'x-mcp-token',
197
+ 'mcp-session-id',
198
+ 'Last-Event-ID',
199
+ 'mcp-protocol-version'
200
+ ],
201
+ exposeHeaders: ['mcp-session-id', 'mcp-protocol-version']
202
+ })
203
+ )
204
+ } else {
205
+ app.use('*', cors())
206
+ }
207
+
208
+ app.get('/health', (c) =>
209
+ c.json({
210
+ status: browserWs ? 'ok' : 'no_app',
211
+ ...(browserWs && browserToken ? { token: browserToken } : {})
212
+ })
111
213
  )
112
214
 
113
- register(
114
- 'save_file',
115
- {
116
- description: 'Save the current document to a .fig file.',
117
- inputSchema: z.object({ path: z.string().describe('Absolute path to save the .fig file') })
118
- },
119
- async ({ path: filePath }: { path: string }) => {
120
- try {
121
- if (!graph) throw new Error('No document loaded')
122
- const { exportFigFile } = await import('@open-pencil/core')
123
- const path = resolveAndCheckPath(filePath)
124
- const data = await exportFigFile(graph)
125
- await writeFile(path, new Uint8Array(data))
126
- return ok({ saved: path, bytes: data.byteLength })
127
- } catch (e) {
128
- return fail(e)
129
- }
215
+ app.use('/rpc', async (c, next) => {
216
+ if (!browserWs || !browserToken) {
217
+ return c.json({ error: 'OpenPencil app is not connected. Is a document open?' }, 503)
130
218
  }
131
- )
219
+ const auth = c.req.header('authorization')
220
+ const provided = auth?.startsWith('Bearer ') ? auth.slice(7) : null
221
+ if (provided !== browserToken) {
222
+ return c.json({ error: 'Unauthorized' }, 401)
223
+ }
224
+ return next()
225
+ })
132
226
 
133
- register(
134
- 'new_document',
135
- {
136
- description: 'Create a new empty document with a blank page.',
137
- inputSchema: z.object({})
138
- },
139
- async () => {
140
- try {
141
- graph = new SceneGraph()
142
- const pages = graph.getPages()
143
- currentPageId = pages[0]?.id ?? null
144
- return ok({ page: pages[0]?.name, id: currentPageId })
145
- } catch (e) {
146
- return fail(e)
227
+ app.post('/rpc', async (c) => {
228
+ let body = await c.req.json().catch(() => null)
229
+ if (!body || typeof body !== 'object') {
230
+ return c.json({ error: 'Invalid request body' }, 400)
231
+ }
232
+ try {
233
+ body = preprocessRpc(body as Record<string, unknown>)
234
+ const result = await sendToBrowser(body as Record<string, unknown>)
235
+ return c.json(result)
236
+ } catch (e) {
237
+ const msg = e instanceof Error ? e.message : String(e)
238
+ return c.json({ ok: false, error: msg }, 502)
239
+ }
240
+ })
241
+
242
+ // --- MCP Streamable HTTP ---
243
+
244
+ type MCPTransport = { handleRequest: (r: Request) => Promise<Response> }
245
+ const mcpSessions = new Map<string, MCPTransport>()
246
+ const MAX_MCP_SESSIONS = 10
247
+
248
+ function createMCPSession(id: string): MCPTransport {
249
+ const mcpServer = new McpServer({ name: 'open-pencil', version: MCP_VERSION })
250
+ const register = mcpServer.registerTool.bind(mcpServer) as (...a: unknown[]) => void
251
+
252
+ for (const def of ALL_TOOLS) {
253
+ if (!enableEval && def.name === 'eval') continue
254
+ const shape: Record<string, z.ZodType> = {}
255
+ for (const [key, param] of Object.entries(def.params)) {
256
+ shape[key] = paramToZod(param)
147
257
  }
258
+ register(
259
+ def.name,
260
+ { description: def.description, inputSchema: z.object(shape) },
261
+ async (args: Record<string, unknown>) => {
262
+ try {
263
+ const result = await sendToBrowser({ command: 'tool', args: { name: def.name, args } })
264
+ const res = result as { ok?: boolean; result?: unknown; error?: string }
265
+ if (res.ok === false) return fail(new Error(res.error))
266
+ const r = res.result as Record<string, unknown> | undefined
267
+ if (r && 'base64' in r && 'mimeType' in r) {
268
+ return {
269
+ content: [
270
+ {
271
+ type: 'image' as const,
272
+ data: r.base64 as string,
273
+ mimeType: r.mimeType as string
274
+ }
275
+ ]
276
+ }
277
+ }
278
+ return ok(r)
279
+ } catch (e) {
280
+ return fail(e)
281
+ }
282
+ }
283
+ )
148
284
  }
149
- )
150
285
 
151
- for (const tool of ALL_TOOLS) {
152
- if (!enableEval && tool.name === 'eval') continue
153
- registerTool(tool)
286
+ register(
287
+ 'get_codegen_prompt',
288
+ {
289
+ description:
290
+ 'Get design-to-code generation guidelines. Call before generating frontend code.',
291
+ inputSchema: z.object({})
292
+ },
293
+ async () => ok({ prompt: CODEGEN_PROMPT })
294
+ )
295
+
296
+ const transport = new WebStandardStreamableHTTPServerTransport({
297
+ sessionIdGenerator: () => id
298
+ })
299
+ void mcpServer.connect(transport)
300
+ mcpSessions.set(id, transport)
301
+ return transport
154
302
  }
155
303
 
156
- return server
304
+ app.all('/mcp', async (c) => {
305
+ if (authToken) {
306
+ const auth = c.req.header('authorization')
307
+ const token = auth?.startsWith('Bearer ')
308
+ ? auth.slice('Bearer '.length)
309
+ : c.req.header('x-mcp-token')
310
+ if (token !== authToken) {
311
+ return c.json({ error: 'Unauthorized' }, 401)
312
+ }
313
+ }
314
+ const sessionId = c.req.header('mcp-session-id') ?? undefined
315
+ const existing = sessionId ? mcpSessions.get(sessionId) : undefined
316
+ if (!existing && mcpSessions.size >= MAX_MCP_SESSIONS) {
317
+ return c.json(
318
+ { error: 'Too many active MCP sessions' },
319
+ { status: 503, headers: { 'Retry-After': '5' } }
320
+ )
321
+ }
322
+ const transport = existing ?? createMCPSession(sessionId ?? randomUUID())
323
+ const response = await transport.handleRequest(c.req.raw)
324
+ if (c.req.method === 'DELETE' && sessionId) {
325
+ mcpSessions.delete(sessionId)
326
+ }
327
+ return response
328
+ })
329
+
330
+ return { app, wss, httpPort }
157
331
  }
package/dist/http.js DELETED
@@ -1,81 +0,0 @@
1
- #!/usr/bin/env node
2
- import { randomUUID } from 'node:crypto';
3
- import { readFile } from 'node:fs/promises';
4
- import { resolve } from 'node:path';
5
- import { Hono } from 'hono';
6
- import { cors } from 'hono/cors';
7
- import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
8
- import { createServer } from './server.js';
9
- const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf-8'));
10
- const port = parseInt(process.env.PORT ?? '3100', 10);
11
- const host = process.env.HOST ?? '127.0.0.1';
12
- const authToken = process.env.OPENPENCIL_MCP_AUTH_TOKEN?.trim() || null;
13
- const corsOrigin = process.env.OPENPENCIL_MCP_CORS_ORIGIN?.trim() || null;
14
- const fileRoot = resolve(process.env.OPENPENCIL_MCP_ROOT ?? process.cwd());
15
- const sessions = new Map();
16
- async function getOrCreateSession(sessionId) {
17
- if (sessionId && sessions.has(sessionId)) {
18
- return sessions.get(sessionId);
19
- }
20
- const id = sessionId ?? randomUUID();
21
- const server = createServer(pkg.version, {
22
- enableEval: false,
23
- fileRoot
24
- });
25
- const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => id });
26
- await server.connect(transport);
27
- sessions.set(id, { server, transport });
28
- return { server, transport };
29
- }
30
- const app = new Hono();
31
- if (corsOrigin) {
32
- app.use('*', cors({
33
- origin: corsOrigin,
34
- allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
35
- allowHeaders: [
36
- 'Content-Type',
37
- 'Authorization',
38
- 'x-mcp-token',
39
- 'mcp-session-id',
40
- 'Last-Event-ID',
41
- 'mcp-protocol-version'
42
- ],
43
- exposeHeaders: ['mcp-session-id', 'mcp-protocol-version']
44
- }));
45
- }
46
- app.get('/health', (c) => c.json({
47
- status: 'ok',
48
- version: pkg.version,
49
- authRequired: Boolean(authToken),
50
- evalEnabled: false,
51
- fileRoot
52
- }));
53
- app.all('/mcp', async (c) => {
54
- if (authToken) {
55
- const authHeader = c.req.header('authorization');
56
- const token = authHeader?.startsWith('Bearer ')
57
- ? authHeader.slice('Bearer '.length)
58
- : c.req.header('x-mcp-token');
59
- if (token !== authToken) {
60
- return c.json({ error: 'Unauthorized' }, 401);
61
- }
62
- }
63
- const sessionId = c.req.header('mcp-session-id') ?? undefined;
64
- const { transport } = await getOrCreateSession(sessionId);
65
- return transport.handleRequest(c.req.raw);
66
- });
67
- const isBun = typeof globalThis.Bun !== 'undefined';
68
- if (isBun) {
69
- Bun.serve({ fetch: app.fetch, port, hostname: host });
70
- }
71
- else {
72
- const { serve } = await import('@hono/node-server');
73
- serve({ fetch: app.fetch, port, hostname: host });
74
- }
75
- console.log(`OpenPencil MCP server v${pkg.version}`);
76
- console.log(` Health: http://${host}:${port}/health`);
77
- console.log(` MCP: http://${host}:${port}/mcp`);
78
- console.log(` Auth: ${authToken ? 'required (OPENPENCIL_MCP_AUTH_TOKEN)' : 'disabled'}`);
79
- console.log(` CORS: ${corsOrigin ?? 'disabled'}`);
80
- console.log(` Eval: disabled`);
81
- console.log(` Root: ${fileRoot}`);
package/dist/index.js DELETED
@@ -1,8 +0,0 @@
1
- #!/usr/bin/env node
2
- import { readFile } from 'node:fs/promises';
3
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
- import { createServer } from './server.js';
5
- const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf-8'));
6
- const server = createServer(pkg.version);
7
- const transport = new StdioServerTransport();
8
- await server.connect(transport);
package/dist/server.js DELETED
@@ -1,130 +0,0 @@
1
- import { readFile, writeFile } from 'node:fs/promises';
2
- import { isAbsolute, relative, resolve } from 'node:path';
3
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
- import { z } from 'zod';
5
- import { ALL_TOOLS, FigmaAPI, parseFigFile, computeAllLayouts, SceneGraph } from '@open-pencil/core';
6
- function ok(data) {
7
- return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
8
- }
9
- function fail(e) {
10
- const msg = e instanceof Error ? e.message : String(e);
11
- return { content: [{ type: 'text', text: JSON.stringify({ error: msg }) }], isError: true };
12
- }
13
- function paramToZod(param) {
14
- const typeMap = {
15
- string: () => param.enum
16
- ? z.enum(param.enum).describe(param.description)
17
- : z.string().describe(param.description),
18
- number: () => {
19
- let s = z.number();
20
- if (param.min !== undefined)
21
- s = s.min(param.min);
22
- if (param.max !== undefined)
23
- s = s.max(param.max);
24
- return s.describe(param.description);
25
- },
26
- boolean: () => z.boolean().describe(param.description),
27
- color: () => z.string().describe(param.description),
28
- 'string[]': () => z.array(z.string()).min(1).describe(param.description)
29
- };
30
- const schema = typeMap[param.type]();
31
- return param.required ? schema : schema.optional();
32
- }
33
- export function createServer(version, options = {}) {
34
- const server = new McpServer({ name: 'open-pencil', version });
35
- const enableEval = options.enableEval ?? true;
36
- const fileRoot = options.fileRoot === null || options.fileRoot === undefined
37
- ? null
38
- : resolve(options.fileRoot);
39
- let graph = null;
40
- let currentPageId = null;
41
- function makeFigma() {
42
- if (!graph)
43
- throw new Error('No document loaded. Use open_file or new_document first.');
44
- const api = new FigmaAPI(graph);
45
- if (currentPageId)
46
- api.currentPage = api.wrapNode(currentPageId);
47
- return api;
48
- }
49
- function resolveAndCheckPath(filePath) {
50
- const resolved = resolve(filePath);
51
- if (!fileRoot)
52
- return resolved;
53
- const rel = relative(fileRoot, resolved);
54
- if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) {
55
- return resolved;
56
- }
57
- throw new Error(`Path "${filePath}" is outside allowed root "${fileRoot}"`);
58
- }
59
- function registerTool(def) {
60
- const shape = {};
61
- for (const [key, param] of Object.entries(def.params)) {
62
- shape[key] = paramToZod(param);
63
- }
64
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic schema from ToolDef params
65
- server.registerTool(def.name, { description: def.description, inputSchema: z.object(shape) }, async (args) => {
66
- try {
67
- const result = await def.execute(makeFigma(), args);
68
- return ok(result);
69
- }
70
- catch (e) {
71
- return fail(e);
72
- }
73
- });
74
- }
75
- const register = server.registerTool.bind(server);
76
- register('open_file', {
77
- description: 'Open a .fig file for editing. Must be called before using other tools.',
78
- inputSchema: z.object({ path: z.string().describe('Absolute path to a .fig file') })
79
- }, async ({ path: filePath }) => {
80
- try {
81
- const path = resolveAndCheckPath(filePath);
82
- const buf = await readFile(path);
83
- graph = await parseFigFile(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
84
- computeAllLayouts(graph);
85
- const pages = graph.getPages();
86
- currentPageId = pages[0]?.id ?? null;
87
- return ok({ pages: pages.map((p) => ({ id: p.id, name: p.name })), currentPage: pages[0]?.name });
88
- }
89
- catch (e) {
90
- return fail(e);
91
- }
92
- });
93
- register('save_file', {
94
- description: 'Save the current document to a .fig file.',
95
- inputSchema: z.object({ path: z.string().describe('Absolute path to save the .fig file') })
96
- }, async ({ path: filePath }) => {
97
- try {
98
- if (!graph)
99
- throw new Error('No document loaded');
100
- const { exportFigFile } = await import('@open-pencil/core');
101
- const path = resolveAndCheckPath(filePath);
102
- const data = await exportFigFile(graph);
103
- await writeFile(path, new Uint8Array(data));
104
- return ok({ saved: path, bytes: data.byteLength });
105
- }
106
- catch (e) {
107
- return fail(e);
108
- }
109
- });
110
- register('new_document', {
111
- description: 'Create a new empty document with a blank page.',
112
- inputSchema: z.object({})
113
- }, async () => {
114
- try {
115
- graph = new SceneGraph();
116
- const pages = graph.getPages();
117
- currentPageId = pages[0]?.id ?? null;
118
- return ok({ page: pages[0]?.name, id: currentPageId });
119
- }
120
- catch (e) {
121
- return fail(e);
122
- }
123
- });
124
- for (const tool of ALL_TOOLS) {
125
- if (!enableEval && tool.name === 'eval')
126
- continue;
127
- registerTool(tool);
128
- }
129
- return server;
130
- }
package/src/http.ts DELETED
@@ -1,97 +0,0 @@
1
- #!/usr/bin/env node
2
- import { randomUUID } from 'node:crypto'
3
- import { readFile } from 'node:fs/promises'
4
- import { resolve } from 'node:path'
5
-
6
- import { Hono } from 'hono'
7
- import { cors } from 'hono/cors'
8
- import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js'
9
-
10
- import { createServer } from './server.js'
11
-
12
- const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf-8'))
13
- const port = parseInt(process.env.PORT ?? '3100', 10)
14
- const host = process.env.HOST ?? '127.0.0.1'
15
- const authToken = process.env.OPENPENCIL_MCP_AUTH_TOKEN?.trim() || null
16
- const corsOrigin = process.env.OPENPENCIL_MCP_CORS_ORIGIN?.trim() || null
17
- const fileRoot = resolve(process.env.OPENPENCIL_MCP_ROOT ?? process.cwd())
18
-
19
- const sessions = new Map<string, { server: ReturnType<typeof createServer>; transport: WebStandardStreamableHTTPServerTransport }>()
20
-
21
- async function getOrCreateSession(sessionId?: string) {
22
- if (sessionId && sessions.has(sessionId)) {
23
- return sessions.get(sessionId)!
24
- }
25
-
26
- const id = sessionId ?? randomUUID()
27
- const server = createServer(pkg.version, {
28
- enableEval: false,
29
- fileRoot
30
- })
31
- const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => id })
32
- await server.connect(transport)
33
- sessions.set(id, { server, transport })
34
- return { server, transport }
35
- }
36
-
37
- const app = new Hono()
38
-
39
- if (corsOrigin) {
40
- app.use('*', cors({
41
- origin: corsOrigin,
42
- allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
43
- allowHeaders: [
44
- 'Content-Type',
45
- 'Authorization',
46
- 'x-mcp-token',
47
- 'mcp-session-id',
48
- 'Last-Event-ID',
49
- 'mcp-protocol-version'
50
- ],
51
- exposeHeaders: ['mcp-session-id', 'mcp-protocol-version']
52
- }))
53
- }
54
-
55
- app.get('/health', (c) =>
56
- c.json({
57
- status: 'ok',
58
- version: pkg.version,
59
- authRequired: Boolean(authToken),
60
- evalEnabled: false,
61
- fileRoot
62
- })
63
- )
64
-
65
- app.all('/mcp', async (c) => {
66
- if (authToken) {
67
- const authHeader = c.req.header('authorization')
68
- const token =
69
- authHeader?.startsWith('Bearer ')
70
- ? authHeader.slice('Bearer '.length)
71
- : c.req.header('x-mcp-token')
72
- if (token !== authToken) {
73
- return c.json({ error: 'Unauthorized' }, 401)
74
- }
75
- }
76
-
77
- const sessionId = c.req.header('mcp-session-id') ?? undefined
78
- const { transport } = await getOrCreateSession(sessionId)
79
- return transport.handleRequest(c.req.raw)
80
- })
81
-
82
- const isBun = typeof globalThis.Bun !== 'undefined'
83
-
84
- if (isBun) {
85
- Bun.serve({ fetch: app.fetch, port, hostname: host })
86
- } else {
87
- const { serve } = await import('@hono/node-server')
88
- serve({ fetch: app.fetch, port, hostname: host })
89
- }
90
-
91
- console.log(`OpenPencil MCP server v${pkg.version}`)
92
- console.log(` Health: http://${host}:${port}/health`)
93
- console.log(` MCP: http://${host}:${port}/mcp`)
94
- console.log(` Auth: ${authToken ? 'required (OPENPENCIL_MCP_AUTH_TOKEN)' : 'disabled'}`)
95
- console.log(` CORS: ${corsOrigin ?? 'disabled'}`)
96
- console.log(` Eval: disabled`)
97
- console.log(` Root: ${fileRoot}`)