@open-pencil/mcp 0.4.0 → 0.7.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,4 +1,5 @@
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
5
  import { ALL_TOOLS, FigmaAPI, parseFigFile, computeAllLayouts, SceneGraph } from '@open-pencil/core';
@@ -29,8 +30,12 @@ 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
+ export function createServer(version, options = {}) {
33
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);
34
39
  let graph = null;
35
40
  let currentPageId = null;
36
41
  function makeFigma() {
@@ -41,6 +46,16 @@ export function createServer(version) {
41
46
  api.currentPage = api.wrapNode(currentPageId);
42
47
  return api;
43
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
+ }
44
59
  function registerTool(def) {
45
60
  const shape = {};
46
61
  for (const [key, param] of Object.entries(def.params)) {
@@ -63,7 +78,8 @@ export function createServer(version) {
63
78
  inputSchema: z.object({ path: z.string().describe('Absolute path to a .fig file') })
64
79
  }, async ({ path: filePath }) => {
65
80
  try {
66
- const buf = await readFile(filePath);
81
+ const path = resolveAndCheckPath(filePath);
82
+ const buf = await readFile(path);
67
83
  graph = await parseFigFile(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
68
84
  computeAllLayouts(graph);
69
85
  const pages = graph.getPages();
@@ -82,9 +98,10 @@ export function createServer(version) {
82
98
  if (!graph)
83
99
  throw new Error('No document loaded');
84
100
  const { exportFigFile } = await import('@open-pencil/core');
101
+ const path = resolveAndCheckPath(filePath);
85
102
  const data = await exportFigFile(graph);
86
- await writeFile(filePath, new Uint8Array(data));
87
- return ok({ saved: filePath, bytes: data.byteLength });
103
+ await writeFile(path, new Uint8Array(data));
104
+ return ok({ saved: path, bytes: data.byteLength });
88
105
  }
89
106
  catch (e) {
90
107
  return fail(e);
@@ -105,6 +122,8 @@ export function createServer(version) {
105
122
  }
106
123
  });
107
124
  for (const tool of ALL_TOOLS) {
125
+ if (!enableEval && tool.name === 'eval')
126
+ continue;
108
127
  registerTool(tool);
109
128
  }
110
129
  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.7.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": "workspace:*",
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,4 +1,5 @@
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'
@@ -8,6 +9,10 @@ import { ALL_TOOLS, FigmaAPI, parseFigFile, computeAllLayouts, SceneGraph } from
8
9
  import type { ToolDef, ParamDef, ParamType } from '@open-pencil/core'
9
10
 
10
11
  type McpResult = { content: { type: 'text'; text: string }[]; isError?: boolean }
12
+ export interface CreateServerOptions {
13
+ enableEval?: boolean
14
+ fileRoot?: string | null
15
+ }
11
16
 
12
17
  function ok(data: unknown): McpResult {
13
18
  return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }
@@ -39,8 +44,12 @@ function paramToZod(param: ParamDef): z.ZodTypeAny {
39
44
  return param.required ? schema : schema.optional()
40
45
  }
41
46
 
42
- export function createServer(version: string): McpServer {
47
+ export function createServer(version: string, options: CreateServerOptions = {}): McpServer {
43
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)
44
53
 
45
54
  let graph: SceneGraph | null = null
46
55
  let currentPageId: string | null = null
@@ -52,6 +61,16 @@ export function createServer(version: string): McpServer {
52
61
  return api
53
62
  }
54
63
 
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
70
+ }
71
+ throw new Error(`Path "${filePath}" is outside allowed root "${fileRoot}"`)
72
+ }
73
+
55
74
  function registerTool(def: ToolDef) {
56
75
  const shape: Record<string, z.ZodTypeAny> = {}
57
76
  for (const [key, param] of Object.entries(def.params)) {
@@ -78,7 +97,8 @@ export function createServer(version: string): McpServer {
78
97
  },
79
98
  async ({ path: filePath }: { path: string }) => {
80
99
  try {
81
- const buf = await readFile(filePath)
100
+ const path = resolveAndCheckPath(filePath)
101
+ const buf = await readFile(path)
82
102
  graph = await parseFigFile(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength))
83
103
  computeAllLayouts(graph)
84
104
  const pages = graph.getPages()
@@ -100,9 +120,10 @@ export function createServer(version: string): McpServer {
100
120
  try {
101
121
  if (!graph) throw new Error('No document loaded')
102
122
  const { exportFigFile } = await import('@open-pencil/core')
123
+ const path = resolveAndCheckPath(filePath)
103
124
  const data = await exportFigFile(graph)
104
- await writeFile(filePath, new Uint8Array(data))
105
- return ok({ saved: filePath, bytes: data.byteLength })
125
+ await writeFile(path, new Uint8Array(data))
126
+ return ok({ saved: path, bytes: data.byteLength })
106
127
  } catch (e) {
107
128
  return fail(e)
108
129
  }
@@ -128,6 +149,7 @@ export function createServer(version: string): McpServer {
128
149
  )
129
150
 
130
151
  for (const tool of ALL_TOOLS) {
152
+ if (!enableEval && tool.name === 'eval') continue
131
153
  registerTool(tool)
132
154
  }
133
155