@open-pencil/mcp 0.4.0 → 0.8.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/dist/http.js CHANGED
@@ -1,45 +1,81 @@
1
1
  #!/usr/bin/env node
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { readFile } from 'node:fs/promises';
4
+ import { resolve } from 'node:path';
4
5
  import { Hono } from 'hono';
5
6
  import { cors } from 'hono/cors';
6
7
  import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
7
8
  import { createServer } from './server.js';
8
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());
9
15
  const sessions = new Map();
10
16
  async function getOrCreateSession(sessionId) {
11
17
  if (sessionId && sessions.has(sessionId)) {
12
18
  return sessions.get(sessionId);
13
19
  }
14
20
  const id = sessionId ?? randomUUID();
15
- const server = createServer(pkg.version);
21
+ const server = createServer(pkg.version, {
22
+ enableEval: false,
23
+ fileRoot
24
+ });
16
25
  const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => id });
17
26
  await server.connect(transport);
18
27
  sessions.set(id, { server, transport });
19
28
  return { server, transport };
20
29
  }
21
30
  const app = new Hono();
22
- app.use('*', cors({
23
- origin: '*',
24
- allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
25
- allowHeaders: ['Content-Type', 'mcp-session-id', 'Last-Event-ID', 'mcp-protocol-version'],
26
- exposeHeaders: ['mcp-session-id', 'mcp-protocol-version']
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
27
52
  }));
28
- app.get('/health', (c) => c.json({ status: 'ok', version: pkg.version, tools: 29 }));
29
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
+ }
30
63
  const sessionId = c.req.header('mcp-session-id') ?? undefined;
31
64
  const { transport } = await getOrCreateSession(sessionId);
32
65
  return transport.handleRequest(c.req.raw);
33
66
  });
34
- const port = parseInt(process.env.PORT ?? '3100', 10);
35
67
  const isBun = typeof globalThis.Bun !== 'undefined';
36
68
  if (isBun) {
37
- Bun.serve({ fetch: app.fetch, port });
69
+ Bun.serve({ fetch: app.fetch, port, hostname: host });
38
70
  }
39
71
  else {
40
72
  const { serve } = await import('@hono/node-server');
41
- serve({ fetch: app.fetch, port });
73
+ serve({ fetch: app.fetch, port, hostname: host });
42
74
  }
43
75
  console.log(`OpenPencil MCP server v${pkg.version}`);
44
- console.log(` Health: http://localhost:${port}/health`);
45
- console.log(` MCP: http://localhost:${port}/mcp`);
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 CHANGED
File without changes
package/dist/server.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { readFile, writeFile } from 'node:fs/promises';
2
+ import { isAbsolute, relative, resolve } from 'node:path';
2
3
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
4
  import { z } from 'zod';
4
- import { ALL_TOOLS, FigmaAPI, parseFigFile, computeAllLayouts, SceneGraph } from '@open-pencil/core';
5
+ import { ALL_TOOLS, FigmaAPI, parseFigFile, computeAllLayouts, SceneGraph, renderNodesToImage, SkiaRenderer } from '@open-pencil/core';
5
6
  function ok(data) {
6
7
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
7
8
  }
@@ -29,8 +30,22 @@ function paramToZod(param) {
29
30
  const schema = typeMap[param.type]();
30
31
  return param.required ? schema : schema.optional();
31
32
  }
32
- export function createServer(version) {
33
+ let ckInstance = null;
34
+ async function getCanvasKit() {
35
+ if (ckInstance)
36
+ return ckInstance;
37
+ const CanvasKitInit = (await import('canvaskit-wasm/full')).default;
38
+ const ckPath = import.meta.resolve('canvaskit-wasm/full');
39
+ const binDir = new URL('.', ckPath).pathname;
40
+ ckInstance = await CanvasKitInit({ locateFile: (file) => binDir + file });
41
+ return ckInstance;
42
+ }
43
+ export function createServer(version, options = {}) {
33
44
  const server = new McpServer({ name: 'open-pencil', version });
45
+ const enableEval = options.enableEval ?? true;
46
+ const fileRoot = options.fileRoot === null || options.fileRoot === undefined
47
+ ? null
48
+ : resolve(options.fileRoot);
34
49
  let graph = null;
35
50
  let currentPageId = null;
36
51
  function makeFigma() {
@@ -39,8 +54,31 @@ export function createServer(version) {
39
54
  const api = new FigmaAPI(graph);
40
55
  if (currentPageId)
41
56
  api.currentPage = api.wrapNode(currentPageId);
57
+ api.exportImage = async (nodeIds, opts) => {
58
+ const ck = await getCanvasKit();
59
+ const surface = ck.MakeSurface(1, 1);
60
+ const renderer = new SkiaRenderer(ck, surface);
61
+ renderer.viewportWidth = 1;
62
+ renderer.viewportHeight = 1;
63
+ renderer.dpr = 1;
64
+ const pageId = currentPageId ?? graph.getPages()[0]?.id ?? graph.rootId;
65
+ return renderNodesToImage(ck, renderer, graph, pageId, nodeIds, {
66
+ scale: opts.scale ?? 1,
67
+ format: (opts.format ?? 'PNG')
68
+ });
69
+ };
42
70
  return api;
43
71
  }
72
+ function resolveAndCheckPath(filePath) {
73
+ const resolved = resolve(filePath);
74
+ if (!fileRoot)
75
+ return resolved;
76
+ const rel = relative(fileRoot, resolved);
77
+ if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) {
78
+ return resolved;
79
+ }
80
+ throw new Error(`Path "${filePath}" is outside allowed root "${fileRoot}"`);
81
+ }
44
82
  function registerTool(def) {
45
83
  const shape = {};
46
84
  for (const [key, param] of Object.entries(def.params)) {
@@ -50,6 +88,11 @@ export function createServer(version) {
50
88
  server.registerTool(def.name, { description: def.description, inputSchema: z.object(shape) }, async (args) => {
51
89
  try {
52
90
  const result = await def.execute(makeFigma(), args);
91
+ if (result && typeof result === 'object' && 'base64' in result && 'mimeType' in result) {
92
+ return {
93
+ content: [{ type: 'image', data: result.base64, mimeType: result.mimeType }]
94
+ };
95
+ }
53
96
  return ok(result);
54
97
  }
55
98
  catch (e) {
@@ -63,7 +106,8 @@ export function createServer(version) {
63
106
  inputSchema: z.object({ path: z.string().describe('Absolute path to a .fig file') })
64
107
  }, async ({ path: filePath }) => {
65
108
  try {
66
- const buf = await readFile(filePath);
109
+ const path = resolveAndCheckPath(filePath);
110
+ const buf = await readFile(path);
67
111
  graph = await parseFigFile(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
68
112
  computeAllLayouts(graph);
69
113
  const pages = graph.getPages();
@@ -82,9 +126,10 @@ export function createServer(version) {
82
126
  if (!graph)
83
127
  throw new Error('No document loaded');
84
128
  const { exportFigFile } = await import('@open-pencil/core');
129
+ const path = resolveAndCheckPath(filePath);
85
130
  const data = await exportFigFile(graph);
86
- await writeFile(filePath, new Uint8Array(data));
87
- return ok({ saved: filePath, bytes: data.byteLength });
131
+ await writeFile(path, new Uint8Array(data));
132
+ return ok({ saved: path, bytes: data.byteLength });
88
133
  }
89
134
  catch (e) {
90
135
  return fail(e);
@@ -105,6 +150,8 @@ export function createServer(version) {
105
150
  }
106
151
  });
107
152
  for (const tool of ALL_TOOLS) {
153
+ if (!enableEval && tool.name === 'eval')
154
+ continue;
108
155
  registerTool(tool);
109
156
  }
110
157
  return server;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-pencil/mcp",
3
- "version": "0.4.0",
3
+ "version": "0.8.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./src/server.ts",
@@ -26,10 +26,10 @@
26
26
  "provenance": true
27
27
  },
28
28
  "dependencies": {
29
+ "@hono/node-server": "^1.19.9",
29
30
  "@modelcontextprotocol/sdk": "^1.25.2",
30
- "@open-pencil/core": "^0.3.4",
31
+ "@open-pencil/core": "^0.8.0",
31
32
  "canvaskit-wasm": "^0.40.0",
32
- "@hono/node-server": "^1.19.9",
33
33
  "hono": "^4.11.4",
34
34
  "zod": "^3.25.0"
35
35
  },
package/src/http.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { randomUUID } from 'node:crypto'
3
3
  import { readFile } from 'node:fs/promises'
4
+ import { resolve } from 'node:path'
4
5
 
5
6
  import { Hono } from 'hono'
6
7
  import { cors } from 'hono/cors'
@@ -9,6 +10,11 @@ import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/
9
10
  import { createServer } from './server.js'
10
11
 
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())
12
18
 
13
19
  const sessions = new Map<string, { server: ReturnType<typeof createServer>; transport: WebStandardStreamableHTTPServerTransport }>()
14
20
 
@@ -18,7 +24,10 @@ async function getOrCreateSession(sessionId?: string) {
18
24
  }
19
25
 
20
26
  const id = sessionId ?? randomUUID()
21
- const server = createServer(pkg.version)
27
+ const server = createServer(pkg.version, {
28
+ enableEval: false,
29
+ fileRoot
30
+ })
22
31
  const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => id })
23
32
  await server.connect(transport)
24
33
  sessions.set(id, { server, transport })
@@ -27,32 +36,62 @@ async function getOrCreateSession(sessionId?: string) {
27
36
 
28
37
  const app = new Hono()
29
38
 
30
- app.use('*', cors({
31
- origin: '*',
32
- allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
33
- allowHeaders: ['Content-Type', 'mcp-session-id', 'Last-Event-ID', 'mcp-protocol-version'],
34
- exposeHeaders: ['mcp-session-id', 'mcp-protocol-version']
35
- }))
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
+ }
36
54
 
37
- app.get('/health', (c) => c.json({ status: 'ok', version: pkg.version, tools: 29 }))
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
+ )
38
64
 
39
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
+
40
77
  const sessionId = c.req.header('mcp-session-id') ?? undefined
41
78
  const { transport } = await getOrCreateSession(sessionId)
42
79
  return transport.handleRequest(c.req.raw)
43
80
  })
44
81
 
45
- const port = parseInt(process.env.PORT ?? '3100', 10)
46
-
47
82
  const isBun = typeof globalThis.Bun !== 'undefined'
48
83
 
49
84
  if (isBun) {
50
- Bun.serve({ fetch: app.fetch, port })
85
+ Bun.serve({ fetch: app.fetch, port, hostname: host })
51
86
  } else {
52
87
  const { serve } = await import('@hono/node-server')
53
- serve({ fetch: app.fetch, port })
88
+ serve({ fetch: app.fetch, port, hostname: host })
54
89
  }
55
90
 
56
91
  console.log(`OpenPencil MCP server v${pkg.version}`)
57
- console.log(` Health: http://localhost:${port}/health`)
58
- console.log(` MCP: http://localhost:${port}/mcp`)
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}`)
package/src/server.ts CHANGED
@@ -1,13 +1,28 @@
1
1
  import { readFile, writeFile } from 'node:fs/promises'
2
+ import { isAbsolute, relative, resolve } from 'node:path'
2
3
 
3
4
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
4
5
  import { z } from 'zod'
5
6
 
6
- import { ALL_TOOLS, FigmaAPI, parseFigFile, computeAllLayouts, SceneGraph } from '@open-pencil/core'
7
-
8
- import type { ToolDef, ParamDef, ParamType } from '@open-pencil/core'
9
-
10
- type McpResult = { content: { type: 'text'; text: string }[]; isError?: boolean }
7
+ import {
8
+ ALL_TOOLS,
9
+ FigmaAPI,
10
+ parseFigFile,
11
+ computeAllLayouts,
12
+ SceneGraph,
13
+ renderNodesToImage,
14
+ SkiaRenderer
15
+ } from '@open-pencil/core'
16
+
17
+ import type { ToolDef, ParamDef, ParamType, ExportFormat } from '@open-pencil/core'
18
+ import type { CanvasKit } from 'canvaskit-wasm'
19
+
20
+ type McpContent = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string }
21
+ type McpResult = { content: McpContent[]; isError?: boolean }
22
+ export interface CreateServerOptions {
23
+ enableEval?: boolean
24
+ fileRoot?: string | null
25
+ }
11
26
 
12
27
  function ok(data: unknown): McpResult {
13
28
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }
@@ -39,8 +54,23 @@ function paramToZod(param: ParamDef): z.ZodTypeAny {
39
54
  return param.required ? schema : schema.optional()
40
55
  }
41
56
 
42
- export function createServer(version: string): McpServer {
57
+ let ckInstance: CanvasKit | null = null
58
+
59
+ async function getCanvasKit(): Promise<CanvasKit> {
60
+ if (ckInstance) return ckInstance
61
+ const CanvasKitInit = (await import('canvaskit-wasm/full')).default
62
+ const ckPath = import.meta.resolve('canvaskit-wasm/full')
63
+ const binDir = new URL('.', ckPath).pathname
64
+ ckInstance = await CanvasKitInit({ locateFile: (file: string) => binDir + file })
65
+ return ckInstance
66
+ }
67
+
68
+ export function createServer(version: string, options: CreateServerOptions = {}): McpServer {
43
69
  const server = new McpServer({ name: 'open-pencil', version })
70
+ const enableEval = options.enableEval ?? true
71
+ const fileRoot = options.fileRoot === null || options.fileRoot === undefined
72
+ ? null
73
+ : resolve(options.fileRoot)
44
74
 
45
75
  let graph: SceneGraph | null = null
46
76
  let currentPageId: string | null = null
@@ -49,9 +79,32 @@ export function createServer(version: string): McpServer {
49
79
  if (!graph) throw new Error('No document loaded. Use open_file or new_document first.')
50
80
  const api = new FigmaAPI(graph)
51
81
  if (currentPageId) api.currentPage = api.wrapNode(currentPageId)
82
+ api.exportImage = async (nodeIds, opts) => {
83
+ const ck = await getCanvasKit()
84
+ const surface = ck.MakeSurface(1, 1)!
85
+ const renderer = new SkiaRenderer(ck, surface)
86
+ renderer.viewportWidth = 1
87
+ renderer.viewportHeight = 1
88
+ renderer.dpr = 1
89
+ const pageId = currentPageId ?? graph!.getPages()[0]?.id ?? graph!.rootId
90
+ return renderNodesToImage(ck, renderer, graph!, pageId, nodeIds, {
91
+ scale: opts.scale ?? 1,
92
+ format: (opts.format ?? 'PNG') as ExportFormat
93
+ })
94
+ }
52
95
  return api
53
96
  }
54
97
 
98
+ function resolveAndCheckPath(filePath: string): string {
99
+ const resolved = resolve(filePath)
100
+ if (!fileRoot) return resolved
101
+ const rel = relative(fileRoot, resolved)
102
+ if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) {
103
+ return resolved
104
+ }
105
+ throw new Error(`Path "${filePath}" is outside allowed root "${fileRoot}"`)
106
+ }
107
+
55
108
  function registerTool(def: ToolDef) {
56
109
  const shape: Record<string, z.ZodTypeAny> = {}
57
110
  for (const [key, param] of Object.entries(def.params)) {
@@ -61,7 +114,12 @@ export function createServer(version: string): McpServer {
61
114
  // eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic schema from ToolDef params
62
115
  server.registerTool(def.name, { description: def.description, inputSchema: z.object(shape) } as any, async (args: any) => {
63
116
  try {
64
- const result = await def.execute(makeFigma(), args as Record<string, unknown>)
117
+ const result = await def.execute(makeFigma(), args)
118
+ if (result && typeof result === 'object' && 'base64' in result && 'mimeType' in result) {
119
+ return {
120
+ content: [{ type: 'image' as const, data: result.base64 as string, mimeType: result.mimeType as string }]
121
+ }
122
+ }
65
123
  return ok(result)
66
124
  } catch (e) {
67
125
  return fail(e)
@@ -78,7 +136,8 @@ export function createServer(version: string): McpServer {
78
136
  },
79
137
  async ({ path: filePath }: { path: string }) => {
80
138
  try {
81
- const buf = await readFile(filePath)
139
+ const path = resolveAndCheckPath(filePath)
140
+ const buf = await readFile(path)
82
141
  graph = await parseFigFile(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength))
83
142
  computeAllLayouts(graph)
84
143
  const pages = graph.getPages()
@@ -100,9 +159,10 @@ export function createServer(version: string): McpServer {
100
159
  try {
101
160
  if (!graph) throw new Error('No document loaded')
102
161
  const { exportFigFile } = await import('@open-pencil/core')
162
+ const path = resolveAndCheckPath(filePath)
103
163
  const data = await exportFigFile(graph)
104
- await writeFile(filePath, new Uint8Array(data))
105
- return ok({ saved: filePath, bytes: data.byteLength })
164
+ await writeFile(path, new Uint8Array(data))
165
+ return ok({ saved: path, bytes: data.byteLength })
106
166
  } catch (e) {
107
167
  return fail(e)
108
168
  }
@@ -128,6 +188,7 @@ export function createServer(version: string): McpServer {
128
188
  )
129
189
 
130
190
  for (const tool of ALL_TOOLS) {
191
+ if (!enableEval && tool.name === 'eval') continue
131
192
  registerTool(tool)
132
193
  }
133
194