@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 +48 -12
- package/dist/index.js +0 -0
- package/dist/server.js +52 -5
- package/package.json +3 -3
- package/src/http.ts +53 -14
- package/src/server.ts +71 -10
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
|
45
|
-
console.log(` MCP: http
|
|
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
|
-
|
|
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
|
|
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(
|
|
87
|
-
return ok({ saved:
|
|
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.
|
|
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.
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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) =>
|
|
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
|
|
58
|
-
console.log(` MCP: http
|
|
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 {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
105
|
-
return ok({ saved:
|
|
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
|
|