@open-pencil/mcp 0.3.2 → 0.3.4
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 +45 -0
- package/dist/index.js +8 -0
- package/dist/server.js +111 -0
- package/package.json +11 -5
- package/src/server.ts +8 -6
package/dist/http.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { Hono } from 'hono';
|
|
5
|
+
import { cors } from 'hono/cors';
|
|
6
|
+
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
|
|
7
|
+
import { createServer } from './server.js';
|
|
8
|
+
const pkg = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf-8'));
|
|
9
|
+
const sessions = new Map();
|
|
10
|
+
async function getOrCreateSession(sessionId) {
|
|
11
|
+
if (sessionId && sessions.has(sessionId)) {
|
|
12
|
+
return sessions.get(sessionId);
|
|
13
|
+
}
|
|
14
|
+
const id = sessionId ?? randomUUID();
|
|
15
|
+
const server = createServer(pkg.version);
|
|
16
|
+
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => id });
|
|
17
|
+
await server.connect(transport);
|
|
18
|
+
sessions.set(id, { server, transport });
|
|
19
|
+
return { server, transport };
|
|
20
|
+
}
|
|
21
|
+
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']
|
|
27
|
+
}));
|
|
28
|
+
app.get('/health', (c) => c.json({ status: 'ok', version: pkg.version, tools: 29 }));
|
|
29
|
+
app.all('/mcp', async (c) => {
|
|
30
|
+
const sessionId = c.req.header('mcp-session-id') ?? undefined;
|
|
31
|
+
const { transport } = await getOrCreateSession(sessionId);
|
|
32
|
+
return transport.handleRequest(c.req.raw);
|
|
33
|
+
});
|
|
34
|
+
const port = parseInt(process.env.PORT ?? '3100', 10);
|
|
35
|
+
const isBun = typeof globalThis.Bun !== 'undefined';
|
|
36
|
+
if (isBun) {
|
|
37
|
+
Bun.serve({ fetch: app.fetch, port });
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
const { serve } = await import('@hono/node-server');
|
|
41
|
+
serve({ fetch: app.fetch, port });
|
|
42
|
+
}
|
|
43
|
+
console.log(`OpenPencil MCP server v${pkg.version}`);
|
|
44
|
+
console.log(` Health: http://localhost:${port}/health`);
|
|
45
|
+
console.log(` MCP: http://localhost:${port}/mcp`);
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
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
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { ALL_TOOLS, FigmaAPI, parseFigFile, computeAllLayouts, SceneGraph } from '@open-pencil/core';
|
|
5
|
+
function ok(data) {
|
|
6
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
7
|
+
}
|
|
8
|
+
function fail(e) {
|
|
9
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
10
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: msg }) }], isError: true };
|
|
11
|
+
}
|
|
12
|
+
function paramToZod(param) {
|
|
13
|
+
const typeMap = {
|
|
14
|
+
string: () => param.enum
|
|
15
|
+
? z.enum(param.enum).describe(param.description)
|
|
16
|
+
: z.string().describe(param.description),
|
|
17
|
+
number: () => {
|
|
18
|
+
let s = z.number();
|
|
19
|
+
if (param.min !== undefined)
|
|
20
|
+
s = s.min(param.min);
|
|
21
|
+
if (param.max !== undefined)
|
|
22
|
+
s = s.max(param.max);
|
|
23
|
+
return s.describe(param.description);
|
|
24
|
+
},
|
|
25
|
+
boolean: () => z.boolean().describe(param.description),
|
|
26
|
+
color: () => z.string().describe(param.description),
|
|
27
|
+
'string[]': () => z.array(z.string()).min(1).describe(param.description)
|
|
28
|
+
};
|
|
29
|
+
const schema = typeMap[param.type]();
|
|
30
|
+
return param.required ? schema : schema.optional();
|
|
31
|
+
}
|
|
32
|
+
export function createServer(version) {
|
|
33
|
+
const server = new McpServer({ name: 'open-pencil', version });
|
|
34
|
+
let graph = null;
|
|
35
|
+
let currentPageId = null;
|
|
36
|
+
function makeFigma() {
|
|
37
|
+
if (!graph)
|
|
38
|
+
throw new Error('No document loaded. Use open_file or new_document first.');
|
|
39
|
+
const api = new FigmaAPI(graph);
|
|
40
|
+
if (currentPageId)
|
|
41
|
+
api.currentPage = api.wrapNode(currentPageId);
|
|
42
|
+
return api;
|
|
43
|
+
}
|
|
44
|
+
function registerTool(def) {
|
|
45
|
+
const shape = {};
|
|
46
|
+
for (const [key, param] of Object.entries(def.params)) {
|
|
47
|
+
shape[key] = paramToZod(param);
|
|
48
|
+
}
|
|
49
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic schema from ToolDef params
|
|
50
|
+
server.registerTool(def.name, { description: def.description, inputSchema: z.object(shape) }, async (args) => {
|
|
51
|
+
try {
|
|
52
|
+
const result = await def.execute(makeFigma(), args);
|
|
53
|
+
return ok(result);
|
|
54
|
+
}
|
|
55
|
+
catch (e) {
|
|
56
|
+
return fail(e);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
const register = server.registerTool.bind(server);
|
|
61
|
+
register('open_file', {
|
|
62
|
+
description: 'Open a .fig file for editing. Must be called before using other tools.',
|
|
63
|
+
inputSchema: z.object({ path: z.string().describe('Absolute path to a .fig file') })
|
|
64
|
+
}, async ({ path: filePath }) => {
|
|
65
|
+
try {
|
|
66
|
+
const buf = await readFile(filePath);
|
|
67
|
+
graph = await parseFigFile(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
|
|
68
|
+
computeAllLayouts(graph);
|
|
69
|
+
const pages = graph.getPages();
|
|
70
|
+
currentPageId = pages[0]?.id ?? null;
|
|
71
|
+
return ok({ pages: pages.map((p) => ({ id: p.id, name: p.name })), currentPage: pages[0]?.name });
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
return fail(e);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
register('save_file', {
|
|
78
|
+
description: 'Save the current document to a .fig file.',
|
|
79
|
+
inputSchema: z.object({ path: z.string().describe('Absolute path to save the .fig file') })
|
|
80
|
+
}, async ({ path: filePath }) => {
|
|
81
|
+
try {
|
|
82
|
+
if (!graph)
|
|
83
|
+
throw new Error('No document loaded');
|
|
84
|
+
const { exportFigFile } = await import('@open-pencil/core');
|
|
85
|
+
const data = await exportFigFile(graph);
|
|
86
|
+
await writeFile(filePath, new Uint8Array(data));
|
|
87
|
+
return ok({ saved: filePath, bytes: data.byteLength });
|
|
88
|
+
}
|
|
89
|
+
catch (e) {
|
|
90
|
+
return fail(e);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
register('new_document', {
|
|
94
|
+
description: 'Create a new empty document with a blank page.',
|
|
95
|
+
inputSchema: z.object({})
|
|
96
|
+
}, async () => {
|
|
97
|
+
try {
|
|
98
|
+
graph = new SceneGraph();
|
|
99
|
+
const pages = graph.getPages();
|
|
100
|
+
currentPageId = pages[0]?.id ?? null;
|
|
101
|
+
return ok({ page: pages[0]?.name, id: currentPageId });
|
|
102
|
+
}
|
|
103
|
+
catch (e) {
|
|
104
|
+
return fail(e);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
for (const tool of ALL_TOOLS) {
|
|
108
|
+
registerTool(tool);
|
|
109
|
+
}
|
|
110
|
+
return server;
|
|
111
|
+
}
|
package/package.json
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-pencil/mcp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.4",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/server.ts",
|
|
7
7
|
"bin": {
|
|
8
|
-
"openpencil-mcp": "./
|
|
9
|
-
"openpencil-mcp-http": "./
|
|
8
|
+
"openpencil-mcp": "./dist/index.js",
|
|
9
|
+
"openpencil-mcp-http": "./dist/http.js"
|
|
10
10
|
},
|
|
11
11
|
"files": [
|
|
12
|
-
"src"
|
|
12
|
+
"src",
|
|
13
|
+
"dist"
|
|
13
14
|
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "bunx tsgo",
|
|
17
|
+
"prepublishOnly": "bunx tsgo"
|
|
18
|
+
},
|
|
14
19
|
"repository": {
|
|
15
20
|
"type": "git",
|
|
16
21
|
"url": "git+https://github.com/open-pencil/open-pencil.git",
|
|
@@ -22,8 +27,9 @@
|
|
|
22
27
|
},
|
|
23
28
|
"dependencies": {
|
|
24
29
|
"@modelcontextprotocol/sdk": "^1.25.2",
|
|
25
|
-
"@open-pencil/core": "
|
|
30
|
+
"@open-pencil/core": "0.3.2",
|
|
26
31
|
"canvaskit-wasm": "^0.40.0",
|
|
32
|
+
"@hono/node-server": "^1.19.9",
|
|
27
33
|
"hono": "^4.11.4",
|
|
28
34
|
"zod": "^3.25.0"
|
|
29
35
|
},
|
package/src/server.ts
CHANGED
|
@@ -58,7 +58,8 @@ export function createServer(version: string): McpServer {
|
|
|
58
58
|
shape[key] = paramToZod(param)
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- dynamic schema from ToolDef params
|
|
62
|
+
server.registerTool(def.name, { description: def.description, inputSchema: z.object(shape) } as any, async (args: any) => {
|
|
62
63
|
try {
|
|
63
64
|
const result = await def.execute(makeFigma(), args as Record<string, unknown>)
|
|
64
65
|
return ok(result)
|
|
@@ -68,13 +69,14 @@ export function createServer(version: string): McpServer {
|
|
|
68
69
|
})
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
server.registerTool(
|
|
72
|
+
const register = server.registerTool.bind(server) as (...args: unknown[]) => void
|
|
73
|
+
register(
|
|
72
74
|
'open_file',
|
|
73
75
|
{
|
|
74
76
|
description: 'Open a .fig file for editing. Must be called before using other tools.',
|
|
75
77
|
inputSchema: z.object({ path: z.string().describe('Absolute path to a .fig file') })
|
|
76
78
|
},
|
|
77
|
-
async ({ path: filePath }) => {
|
|
79
|
+
async ({ path: filePath }: { path: string }) => {
|
|
78
80
|
try {
|
|
79
81
|
const buf = await readFile(filePath)
|
|
80
82
|
graph = await parseFigFile(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength))
|
|
@@ -88,13 +90,13 @@ export function createServer(version: string): McpServer {
|
|
|
88
90
|
}
|
|
89
91
|
)
|
|
90
92
|
|
|
91
|
-
|
|
93
|
+
register(
|
|
92
94
|
'save_file',
|
|
93
95
|
{
|
|
94
96
|
description: 'Save the current document to a .fig file.',
|
|
95
97
|
inputSchema: z.object({ path: z.string().describe('Absolute path to save the .fig file') })
|
|
96
98
|
},
|
|
97
|
-
async ({ path: filePath }) => {
|
|
99
|
+
async ({ path: filePath }: { path: string }) => {
|
|
98
100
|
try {
|
|
99
101
|
if (!graph) throw new Error('No document loaded')
|
|
100
102
|
const { exportFigFile } = await import('@open-pencil/core')
|
|
@@ -107,7 +109,7 @@ export function createServer(version: string): McpServer {
|
|
|
107
109
|
}
|
|
108
110
|
)
|
|
109
111
|
|
|
110
|
-
|
|
112
|
+
register(
|
|
111
113
|
'new_document',
|
|
112
114
|
{
|
|
113
115
|
description: 'Create a new empty document with a blank page.',
|