@open-pencil/mcp 0.11.0 → 0.11.1
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 +3 -4
- package/src/index.ts +0 -23
- package/src/server.ts +0 -331
package/package.json
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-pencil/mcp",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "./
|
|
6
|
+
"main": "./dist/server.js",
|
|
7
7
|
"bin": {
|
|
8
8
|
"openpencil-mcp": "./dist/index.js"
|
|
9
9
|
},
|
|
10
10
|
"files": [
|
|
11
|
-
"src",
|
|
12
11
|
"dist"
|
|
13
12
|
],
|
|
14
13
|
"scripts": {
|
|
@@ -27,7 +26,7 @@
|
|
|
27
26
|
"dependencies": {
|
|
28
27
|
"@hono/node-server": "^1.19.9",
|
|
29
28
|
"@modelcontextprotocol/sdk": "^1.25.2",
|
|
30
|
-
"@open-pencil/core": "^0.11.
|
|
29
|
+
"@open-pencil/core": "^0.11.1",
|
|
31
30
|
"hono": "^4.11.4",
|
|
32
31
|
"zod": "^4.3.6",
|
|
33
32
|
"ws": "^8.19.0"
|
package/src/index.ts
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { serve } from '@hono/node-server'
|
|
3
|
-
|
|
4
|
-
import { startServer } from './server.js'
|
|
5
|
-
|
|
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'
|
|
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
|
-
})
|
|
17
|
-
|
|
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
DELETED
|
@@ -1,331 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from 'node:crypto'
|
|
2
|
-
import { createRequire } from 'node:module'
|
|
3
|
-
|
|
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'
|
|
9
|
-
import { z } from 'zod'
|
|
10
|
-
|
|
11
|
-
import {
|
|
12
|
-
ALL_TOOLS,
|
|
13
|
-
CODEGEN_PROMPT,
|
|
14
|
-
buildComponent,
|
|
15
|
-
createElement,
|
|
16
|
-
resolveToTree
|
|
17
|
-
} from '@open-pencil/core'
|
|
18
|
-
|
|
19
|
-
import type { ParamDef, ParamType } from '@open-pencil/core'
|
|
20
|
-
|
|
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>
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function ok(data: unknown): MCPResult {
|
|
36
|
-
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] }
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
function fail(e: unknown): MCPResult {
|
|
40
|
-
const msg = e instanceof Error ? e.message : String(e)
|
|
41
|
-
return { content: [{ type: 'text', text: JSON.stringify({ error: msg }) }], isError: true }
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function paramToZod(param: ParamDef): z.ZodType {
|
|
45
|
-
const typeMap: Record<ParamType, () => z.ZodType> = {
|
|
46
|
-
string: () =>
|
|
47
|
-
param.enum
|
|
48
|
-
? z.enum(param.enum as [string, ...string[]]).describe(param.description)
|
|
49
|
-
: z.string().describe(param.description),
|
|
50
|
-
number: () => {
|
|
51
|
-
let s = z.number()
|
|
52
|
-
if (param.min !== undefined) s = s.min(param.min)
|
|
53
|
-
if (param.max !== undefined) s = s.max(param.max)
|
|
54
|
-
return s.describe(param.description)
|
|
55
|
-
},
|
|
56
|
-
boolean: () => z.boolean().describe(param.description),
|
|
57
|
-
color: () => z.string().describe(param.description),
|
|
58
|
-
'string[]': () => z.array(z.string()).min(1).describe(param.description)
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const schema = typeMap[param.type]()
|
|
62
|
-
return param.required ? schema : schema.optional()
|
|
63
|
-
}
|
|
64
|
-
|
|
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
|
-
})
|
|
100
|
-
}
|
|
101
|
-
|
|
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)
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
|
|
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)
|
|
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
|
-
})
|
|
153
|
-
|
|
154
|
-
ws.on('close', () => {
|
|
155
|
-
if (browserWs === ws) {
|
|
156
|
-
browserWs = null
|
|
157
|
-
browserToken = null
|
|
158
|
-
rejectAllPending('Browser disconnected')
|
|
159
|
-
}
|
|
160
|
-
})
|
|
161
|
-
})
|
|
162
|
-
|
|
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 } }
|
|
176
|
-
}
|
|
177
|
-
} catch (e) {
|
|
178
|
-
console.warn('JSX preprocessing failed, passing raw:', e instanceof Error ? e.message : e)
|
|
179
|
-
return body
|
|
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
|
-
})
|
|
213
|
-
)
|
|
214
|
-
|
|
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)
|
|
218
|
-
}
|
|
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
|
-
})
|
|
226
|
-
|
|
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)
|
|
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
|
-
)
|
|
284
|
-
}
|
|
285
|
-
|
|
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
|
|
302
|
-
}
|
|
303
|
-
|
|
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 }
|
|
331
|
-
}
|