@open-pencil/mcp 0.11.1 → 0.11.2
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/index.js +18 -0
- package/dist/server.js +257 -0
- package/package.json +2 -10
package/dist/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { serve } from '@hono/node-server';
|
|
3
|
+
import { startServer } from './server.js';
|
|
4
|
+
const port = parseInt(process.env.PORT ?? '7600', 10);
|
|
5
|
+
const wsPort = parseInt(process.env.WS_PORT ?? '7601', 10);
|
|
6
|
+
const host = process.env.HOST ?? '127.0.0.1';
|
|
7
|
+
const { app, httpPort } = startServer({
|
|
8
|
+
httpPort: port,
|
|
9
|
+
wsPort,
|
|
10
|
+
enableEval: process.env.OPENPENCIL_MCP_EVAL === '1',
|
|
11
|
+
authToken: process.env.OPENPENCIL_MCP_AUTH_TOKEN?.trim() || null,
|
|
12
|
+
corsOrigin: process.env.OPENPENCIL_MCP_CORS_ORIGIN?.trim() || null
|
|
13
|
+
});
|
|
14
|
+
serve({ fetch: app.fetch, port: httpPort, hostname: host });
|
|
15
|
+
console.log(`OpenPencil MCP server`);
|
|
16
|
+
console.log(` HTTP: http://${host}:${httpPort}`);
|
|
17
|
+
console.log(` WS: ws://${host}:${wsPort}`);
|
|
18
|
+
console.log(` MCP: http://${host}:${httpPort}/mcp`);
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
+
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
|
|
5
|
+
import { Hono } from 'hono';
|
|
6
|
+
import { cors } from 'hono/cors';
|
|
7
|
+
import { WebSocketServer } from 'ws';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { ALL_TOOLS, CODEGEN_PROMPT, buildComponent, createElement, resolveToTree } from '@open-pencil/core';
|
|
10
|
+
const require = createRequire(import.meta.url);
|
|
11
|
+
const MCP_VERSION = require('../package.json').version;
|
|
12
|
+
const RPC_TIMEOUT = 30_000;
|
|
13
|
+
function ok(data) {
|
|
14
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
15
|
+
}
|
|
16
|
+
function fail(e) {
|
|
17
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
18
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: msg }) }], isError: true };
|
|
19
|
+
}
|
|
20
|
+
export function paramToZod(param) {
|
|
21
|
+
const typeMap = {
|
|
22
|
+
string: () => param.enum
|
|
23
|
+
? z.enum(param.enum).describe(param.description)
|
|
24
|
+
: z.string().describe(param.description),
|
|
25
|
+
number: () => {
|
|
26
|
+
let s = z.number();
|
|
27
|
+
if (param.min !== undefined)
|
|
28
|
+
s = s.min(param.min);
|
|
29
|
+
if (param.max !== undefined)
|
|
30
|
+
s = s.max(param.max);
|
|
31
|
+
return s.describe(param.description);
|
|
32
|
+
},
|
|
33
|
+
boolean: () => z.boolean().describe(param.description),
|
|
34
|
+
color: () => z.string().describe(param.description),
|
|
35
|
+
'string[]': () => z.array(z.string()).min(1).describe(param.description)
|
|
36
|
+
};
|
|
37
|
+
const schema = typeMap[param.type]();
|
|
38
|
+
return param.required ? schema : schema.optional();
|
|
39
|
+
}
|
|
40
|
+
export function startServer(options = {}) {
|
|
41
|
+
const httpPort = options.httpPort ?? 7600;
|
|
42
|
+
const wsPort = options.wsPort ?? 7601;
|
|
43
|
+
const enableEval = options.enableEval ?? false;
|
|
44
|
+
const authToken = options.authToken ?? null;
|
|
45
|
+
const corsOrigin = options.corsOrigin ?? null;
|
|
46
|
+
const pending = new Map();
|
|
47
|
+
let browserWs = null;
|
|
48
|
+
let browserToken = null;
|
|
49
|
+
// --- WebSocket: browser connects here ---
|
|
50
|
+
function sendToBrowser(body) {
|
|
51
|
+
return new Promise((resolve, reject) => {
|
|
52
|
+
if (!browserWs || browserWs.readyState !== browserWs.OPEN) {
|
|
53
|
+
reject(new Error('OpenPencil app is not connected'));
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
const id = randomUUID();
|
|
57
|
+
const timer = setTimeout(() => {
|
|
58
|
+
pending.delete(id);
|
|
59
|
+
reject(new Error('RPC timeout (30s)'));
|
|
60
|
+
}, RPC_TIMEOUT);
|
|
61
|
+
pending.set(id, { resolve, reject, timer });
|
|
62
|
+
browserWs.send(JSON.stringify({ type: 'request', id, ...body }));
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
function handleBrowserMessage(data) {
|
|
66
|
+
try {
|
|
67
|
+
const msg = JSON.parse(data);
|
|
68
|
+
if (msg.type === 'register' && msg.token) {
|
|
69
|
+
browserToken = msg.token;
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (msg.type === 'response' && msg.id) {
|
|
73
|
+
const req = pending.get(msg.id);
|
|
74
|
+
if (!req)
|
|
75
|
+
return;
|
|
76
|
+
pending.delete(msg.id);
|
|
77
|
+
clearTimeout(req.timer);
|
|
78
|
+
if (msg.ok === false)
|
|
79
|
+
req.reject(new Error(msg.error ?? 'RPC failed'));
|
|
80
|
+
else {
|
|
81
|
+
const { type: _, id: __, ...payload } = msg;
|
|
82
|
+
req.resolve(payload);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
console.warn('Malformed automation message:', e);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function rejectAllPending(reason) {
|
|
91
|
+
for (const [id, req] of pending) {
|
|
92
|
+
clearTimeout(req.timer);
|
|
93
|
+
req.reject(new Error(reason));
|
|
94
|
+
pending.delete(id);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const wss = new WebSocketServer({ port: wsPort, host: '127.0.0.1' });
|
|
98
|
+
wss.on('connection', (ws) => {
|
|
99
|
+
if (browserWs && browserWs.readyState === WebSocket.OPEN)
|
|
100
|
+
browserWs.close();
|
|
101
|
+
rejectAllPending('Browser reconnected');
|
|
102
|
+
browserWs = ws;
|
|
103
|
+
browserToken = null;
|
|
104
|
+
ws.on('message', (raw) => {
|
|
105
|
+
handleBrowserMessage(typeof raw === 'string' ? raw : Buffer.from(raw).toString('utf-8'));
|
|
106
|
+
});
|
|
107
|
+
ws.on('close', () => {
|
|
108
|
+
if (browserWs === ws) {
|
|
109
|
+
browserWs = null;
|
|
110
|
+
browserToken = null;
|
|
111
|
+
rejectAllPending('Browser disconnected');
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
// --- JSX preprocessing ---
|
|
116
|
+
function preprocessRpc(body) {
|
|
117
|
+
if (body.command !== 'tool')
|
|
118
|
+
return body;
|
|
119
|
+
const args = body.args;
|
|
120
|
+
if (args?.name !== 'render' || !args.args?.jsx)
|
|
121
|
+
return body;
|
|
122
|
+
try {
|
|
123
|
+
const Component = buildComponent(args.args.jsx);
|
|
124
|
+
const element = createElement(Component, null);
|
|
125
|
+
const tree = resolveToTree(element);
|
|
126
|
+
return {
|
|
127
|
+
...body,
|
|
128
|
+
args: { ...args, args: { ...args.args, jsx: undefined, tree } }
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
console.warn('JSX preprocessing failed, passing raw:', e instanceof Error ? e.message : e);
|
|
133
|
+
return body;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
// --- HTTP server ---
|
|
137
|
+
const app = new Hono();
|
|
138
|
+
if (corsOrigin) {
|
|
139
|
+
app.use('*', cors({
|
|
140
|
+
origin: corsOrigin,
|
|
141
|
+
allowMethods: ['GET', 'POST', 'DELETE', 'OPTIONS'],
|
|
142
|
+
allowHeaders: [
|
|
143
|
+
'Content-Type',
|
|
144
|
+
'Authorization',
|
|
145
|
+
'x-mcp-token',
|
|
146
|
+
'mcp-session-id',
|
|
147
|
+
'Last-Event-ID',
|
|
148
|
+
'mcp-protocol-version'
|
|
149
|
+
],
|
|
150
|
+
exposeHeaders: ['mcp-session-id', 'mcp-protocol-version']
|
|
151
|
+
}));
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
app.use('*', cors());
|
|
155
|
+
}
|
|
156
|
+
app.get('/health', (c) => c.json({
|
|
157
|
+
status: browserWs ? 'ok' : 'no_app',
|
|
158
|
+
...(browserWs && browserToken ? { token: browserToken } : {})
|
|
159
|
+
}));
|
|
160
|
+
app.use('/rpc', async (c, next) => {
|
|
161
|
+
if (!browserWs || !browserToken) {
|
|
162
|
+
return c.json({ error: 'OpenPencil app is not connected. Is a document open?' }, 503);
|
|
163
|
+
}
|
|
164
|
+
const auth = c.req.header('authorization');
|
|
165
|
+
const provided = auth?.startsWith('Bearer ') ? auth.slice(7) : null;
|
|
166
|
+
if (provided !== browserToken) {
|
|
167
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
168
|
+
}
|
|
169
|
+
return next();
|
|
170
|
+
});
|
|
171
|
+
app.post('/rpc', async (c) => {
|
|
172
|
+
let body = await c.req.json().catch(() => null);
|
|
173
|
+
if (!body || typeof body !== 'object') {
|
|
174
|
+
return c.json({ error: 'Invalid request body' }, 400);
|
|
175
|
+
}
|
|
176
|
+
try {
|
|
177
|
+
body = preprocessRpc(body);
|
|
178
|
+
const result = await sendToBrowser(body);
|
|
179
|
+
return c.json(result);
|
|
180
|
+
}
|
|
181
|
+
catch (e) {
|
|
182
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
183
|
+
return c.json({ ok: false, error: msg }, 502);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
const mcpSessions = new Map();
|
|
187
|
+
const MAX_MCP_SESSIONS = 10;
|
|
188
|
+
function createMCPSession(id) {
|
|
189
|
+
const mcpServer = new McpServer({ name: 'open-pencil', version: MCP_VERSION });
|
|
190
|
+
const register = mcpServer.registerTool.bind(mcpServer);
|
|
191
|
+
for (const def of ALL_TOOLS) {
|
|
192
|
+
if (!enableEval && def.name === 'eval')
|
|
193
|
+
continue;
|
|
194
|
+
const shape = {};
|
|
195
|
+
for (const [key, param] of Object.entries(def.params)) {
|
|
196
|
+
shape[key] = paramToZod(param);
|
|
197
|
+
}
|
|
198
|
+
register(def.name, { description: def.description, inputSchema: z.object(shape) }, async (args) => {
|
|
199
|
+
try {
|
|
200
|
+
const result = await sendToBrowser({ command: 'tool', args: { name: def.name, args } });
|
|
201
|
+
const res = result;
|
|
202
|
+
if (res.ok === false)
|
|
203
|
+
return fail(new Error(res.error));
|
|
204
|
+
const r = res.result;
|
|
205
|
+
if (r && 'base64' in r && 'mimeType' in r) {
|
|
206
|
+
return {
|
|
207
|
+
content: [
|
|
208
|
+
{
|
|
209
|
+
type: 'image',
|
|
210
|
+
data: r.base64,
|
|
211
|
+
mimeType: r.mimeType
|
|
212
|
+
}
|
|
213
|
+
]
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
return ok(r);
|
|
217
|
+
}
|
|
218
|
+
catch (e) {
|
|
219
|
+
return fail(e);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
register('get_codegen_prompt', {
|
|
224
|
+
description: 'Get design-to-code generation guidelines. Call before generating frontend code.',
|
|
225
|
+
inputSchema: z.object({})
|
|
226
|
+
}, async () => ok({ prompt: CODEGEN_PROMPT }));
|
|
227
|
+
const transport = new WebStandardStreamableHTTPServerTransport({
|
|
228
|
+
sessionIdGenerator: () => id
|
|
229
|
+
});
|
|
230
|
+
void mcpServer.connect(transport);
|
|
231
|
+
mcpSessions.set(id, transport);
|
|
232
|
+
return transport;
|
|
233
|
+
}
|
|
234
|
+
app.all('/mcp', async (c) => {
|
|
235
|
+
if (authToken) {
|
|
236
|
+
const auth = c.req.header('authorization');
|
|
237
|
+
const token = auth?.startsWith('Bearer ')
|
|
238
|
+
? auth.slice('Bearer '.length)
|
|
239
|
+
: c.req.header('x-mcp-token');
|
|
240
|
+
if (token !== authToken) {
|
|
241
|
+
return c.json({ error: 'Unauthorized' }, 401);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const sessionId = c.req.header('mcp-session-id') ?? undefined;
|
|
245
|
+
const existing = sessionId ? mcpSessions.get(sessionId) : undefined;
|
|
246
|
+
if (!existing && mcpSessions.size >= MAX_MCP_SESSIONS) {
|
|
247
|
+
return c.json({ error: 'Too many active MCP sessions' }, { status: 503, headers: { 'Retry-After': '5' } });
|
|
248
|
+
}
|
|
249
|
+
const transport = existing ?? createMCPSession(sessionId ?? randomUUID());
|
|
250
|
+
const response = await transport.handleRequest(c.req.raw);
|
|
251
|
+
if (c.req.method === 'DELETE' && sessionId) {
|
|
252
|
+
mcpSessions.delete(sessionId);
|
|
253
|
+
}
|
|
254
|
+
return response;
|
|
255
|
+
});
|
|
256
|
+
return { app, wss, httpPort };
|
|
257
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-pencil/mcp",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.2",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/server.js",
|
|
@@ -10,10 +10,6 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"dist"
|
|
12
12
|
],
|
|
13
|
-
"scripts": {
|
|
14
|
-
"build": "bunx tsgo && bunx fix-esm-import-path dist",
|
|
15
|
-
"prepublishOnly": "bun run build"
|
|
16
|
-
},
|
|
17
13
|
"repository": {
|
|
18
14
|
"type": "git",
|
|
19
15
|
"url": "git+https://github.com/open-pencil/open-pencil.git",
|
|
@@ -26,13 +22,9 @@
|
|
|
26
22
|
"dependencies": {
|
|
27
23
|
"@hono/node-server": "^1.19.9",
|
|
28
24
|
"@modelcontextprotocol/sdk": "^1.25.2",
|
|
29
|
-
"@open-pencil/core": "^0.11.
|
|
25
|
+
"@open-pencil/core": "^0.11.2",
|
|
30
26
|
"hono": "^4.11.4",
|
|
31
27
|
"zod": "^4.3.6",
|
|
32
28
|
"ws": "^8.19.0"
|
|
33
|
-
},
|
|
34
|
-
"devDependencies": {
|
|
35
|
-
"@types/node": "^22.0.0",
|
|
36
|
-
"@types/ws": "^8.18.1"
|
|
37
29
|
}
|
|
38
30
|
}
|