@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 CHANGED
@@ -1,14 +1,13 @@
1
1
  {
2
2
  "name": "@open-pencil/mcp",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
- "main": "./src/server.ts",
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.0",
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
- }