@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 +7 -7
- package/src/index.ts +18 -7
- package/src/server.ts +275 -101
- package/dist/http.js +0 -81
- package/dist/index.js +0 -8
- package/dist/server.js +0 -130
- package/src/http.ts +0 -97
package/package.json
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-pencil/mcp",
|
|
3
|
-
"version": "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": "
|
|
32
|
-
"canvaskit-wasm": "^0.40.0",
|
|
30
|
+
"@open-pencil/core": "^0.11.0",
|
|
33
31
|
"hono": "^4.11.4",
|
|
34
|
-
"zod": "^3.
|
|
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 {
|
|
2
|
+
import { serve } from '@hono/node-server'
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { startServer } from './server.js'
|
|
5
5
|
|
|
6
|
-
|
|
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
|
|
9
|
-
|
|
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
|
-
|
|
12
|
-
|
|
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 {
|
|
2
|
-
import {
|
|
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 {
|
|
11
|
+
import {
|
|
12
|
+
ALL_TOOLS,
|
|
13
|
+
CODEGEN_PROMPT,
|
|
14
|
+
buildComponent,
|
|
15
|
+
createElement,
|
|
16
|
+
resolveToTree
|
|
17
|
+
} from '@open-pencil/core'
|
|
8
18
|
|
|
9
|
-
import type {
|
|
19
|
+
import type { ParamDef, ParamType } from '@open-pencil/core'
|
|
10
20
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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):
|
|
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):
|
|
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.
|
|
27
|
-
const typeMap: Record<ParamType, () => z.
|
|
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
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
75
|
-
const
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
{
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
-
|
|
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}`)
|