diff --git a/bun.lock b/bun.lock index ce1a9de44..fb9c470f8 100644 --- a/bun.lock +++ b/bun.lock @@ -51,6 +51,7 @@ "@hono/node-server": "^1.19.11", "@iconify-json/lucide": "^1.2.94", "@modelcontextprotocol/sdk": "^1.27.1", + "@open-pencil/mcp": "workspace:*", "@playwright/test": "^1.58.2", "@tauri-apps/cli": "^2", "@types/culori": "^4.0.1", @@ -135,7 +136,7 @@ "@open-pencil/core": "workspace:*", "canvaskit-wasm": "^0.40.0", "hono": "^4.11.4", - "zod": "^3.25.0", + "zod": "^4.3.6", }, "devDependencies": { "@types/node": "^22.0.0", @@ -2429,14 +2430,8 @@ "@multiformats/dns/@libp2p/interface": ["@libp2p/interface@3.1.0", "", { "dependencies": { "@multiformats/dns": "^1.0.6", "@multiformats/multiaddr": "^13.0.1", "main-event": "^1.0.1", "multiformats": "^13.4.0", "progress-events": "^1.0.1", "uint8arraylist": "^2.4.8" } }, "sha512-RE7/XyvC47fQBe1cHxhMvepYKa5bFCUyFrrpj8PuM0E7JtzxU7F+Du5j4VXbg2yLDcToe0+j8mB7jvwE2AThYw=="], - "@open-pencil/mcp/@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], - "@open-pencil/mcp/@types/node": ["@types/node@22.19.13", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-akNQMv0wW5uyRpD2v2IEyRSZiR+BeGuoB6L310EgGObO44HSMNT8z1xzio28V8qOrgYaopIDNA18YgdXd+qTiw=="], - "@open-pencil/mcp/hono": ["hono@4.12.3", "", {}, "sha512-SFsVSjp8sj5UumXOOFlkZOG6XS9SJDKw0TbwFeV+AJ8xlST8kxK5Z/5EYa111UY8732lK2S/xB653ceuaoGwpg=="], - - "@open-pencil/mcp/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@rollup/plugin-node-resolve/@rollup/pluginutils": ["@rollup/pluginutils@5.3.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-walker": "^2.0.2", "picomatch": "^4.0.2" }, "peerDependencies": { "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" }, "optionalPeers": ["rollup"] }, "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q=="], "@rollup/plugin-replace/magic-string": ["magic-string@0.25.9", "", { "dependencies": { "sourcemap-codec": "^1.4.8" } }, "sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ=="], diff --git a/package.json b/package.json index c0c171563..bad0df53b 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "vite": "^8.0.0", "vite-plugin-pwa": "^1.2.0", "workbox-window": "^7.4.0", - "ws": "^8.19.0" + "ws": "^8.19.0", + "@open-pencil/mcp": "workspace:*" } } diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 698811a93..88d376329 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -31,7 +31,7 @@ "@open-pencil/core": "workspace:*", "canvaskit-wasm": "^0.40.0", "hono": "^4.11.4", - "zod": "^3.25.0" + "zod": "^4.3.6" }, "devDependencies": { "@types/node": "^22.0.0" diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index d8cad091c..2e0580d48 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -34,8 +34,8 @@ function fail(e: unknown): McpResult { return { content: [{ type: 'text', text: JSON.stringify({ error: msg }) }], isError: true } } -export function paramToZod(param: ParamDef): z.ZodTypeAny { - const typeMap: Record z.ZodTypeAny> = { +export function paramToZod(param: ParamDef): z.ZodType { + const typeMap: Record z.ZodType> = { string: () => param.enum ? z.enum(param.enum as [string, ...string[]]).describe(param.description) @@ -94,7 +94,7 @@ export function createServer(version: string, options: CreateServerOptions = {}) } function registerTool(def: ToolDef) { - const shape: Record = {} + const shape: Record = {} for (const [key, param] of Object.entries(def.params)) { shape[key] = paramToZod(param) } diff --git a/src/automation/bridge.ts b/src/automation/bridge.ts index 4ce373351..d257ed2c3 100644 --- a/src/automation/bridge.ts +++ b/src/automation/bridge.ts @@ -5,22 +5,14 @@ * ws WebSocket server on :7601 for the browser page. * * Flow: CLI → HTTP POST /rpc → Hono → WebSocket → browser → execute → response - * - * Security model: same-machine trust. Both HTTP and WebSocket bind to 127.0.0.1. - * The browser generates a random bearer token and registers it via WebSocket. - * GET /health exposes the token so the CLI can discover it — this is intentional: - * any local process can access the bridge, and the token only prevents accidental - * cross-session collisions when multiple instances are running. */ +import { serve } from '@hono/node-server' import { Hono } from 'hono' import { cors } from 'hono/cors' import { WebSocketServer, type WebSocket } from 'ws' -import type { ZodTypeAny } from 'zod' +import type { ViteDevServer } from 'vite' -// Can't import from @open-pencil/core here — this file is bundled by esbuild -// as part of the Vite config, and workspace packages are externalized then -// loaded by Node's ESM resolver which can't handle .ts source imports. const AUTOMATION_HTTP_PORT = 7600 const AUTOMATION_WS_PORT = 7601 const RPC_TIMEOUT = 30_000 @@ -31,9 +23,7 @@ interface PendingRequest { timer: ReturnType } -type ViteServer = { ssrLoadModule: (id: string) => Promise> } - -export function startAutomationBridge(server: ViteServer) { +export function startAutomationBridge(server: ViteDevServer) { const pending = new Map() let browserWs: WebSocket | null = null let authToken: string | null = null @@ -119,12 +109,9 @@ export function startAutomationBridge(server: ViteServer) { async function jsxToTree(jsx: string): Promise { const core = await server.ssrLoadModule('@open-pencil/core') - const buildComponent = core.buildComponent as (jsx: string) => () => unknown - const resolveToTree = core.resolveToTree as (el: unknown) => unknown - const createElement = core.createElement as (type: unknown, props: unknown) => unknown - const Component = buildComponent(jsx) - const element = createElement(Component, null) - return resolveToTree(element) + const Component = (core.buildComponent as (jsx: string) => () => unknown)(jsx) + const element = (core.createElement as (type: unknown, props: unknown) => unknown)(Component, null) + return (core.resolveToTree as (el: unknown) => unknown)(element) } async function preprocessRpc(body: Record): Promise> { @@ -178,7 +165,7 @@ export function startAutomationBridge(server: ViteServer) { } }) - // MCP Streamable HTTP endpoint — proxies tool calls through WebSocket to the live editor + // MCP Streamable HTTP — proxies tool calls through WebSocket to the live editor type McpTransport = { handleRequest: (r: Request) => Promise } const mcpSessions = new Map() @@ -186,15 +173,15 @@ export function startAutomationBridge(server: ViteServer) { const cached = sessionId ? mcpSessions.get(sessionId) : undefined if (cached) return cached + const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js') const { WebStandardStreamableHTTPServerTransport } = await import('@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js') - const { McpServer } = await import('@modelcontextprotocol/sdk/server/mcp.js') const { z } = await import('zod') const core = await server.ssrLoadModule('@open-pencil/core') as { - ALL_TOOLS: Array<{ name: string; description: string; params: Record; mutates?: boolean }> + ALL_TOOLS: Array<{ name: string; description: string; params: Record }> } - const mcp = await server.ssrLoadModule('../../packages/mcp/src/server.ts') as { - paramToZod: (p: unknown) => ZodTypeAny + const mcp = await server.ssrLoadModule('@open-pencil/mcp') as { + paramToZod: (p: unknown) => ReturnType } const id = sessionId ?? crypto.randomUUID() @@ -202,7 +189,7 @@ export function startAutomationBridge(server: ViteServer) { const register = mcpServer.registerTool.bind(mcpServer) as (...a: unknown[]) => void for (const def of core.ALL_TOOLS) { - const shape: Record = {} + const shape: Record> = {} for (const [key, param] of Object.entries(def.params)) { shape[key] = mcp.paramToZod(param) } @@ -239,26 +226,9 @@ export function startAutomationBridge(server: ViteServer) { return transport.handleRequest(c.req.raw) }) - void startServer(app) + serve({ fetch: app.fetch, port: AUTOMATION_HTTP_PORT, hostname: '127.0.0.1' }) console.log(`[automation] HTTP http://127.0.0.1:${AUTOMATION_HTTP_PORT}`) console.log(`[automation] WS ws://127.0.0.1:${AUTOMATION_WS_PORT}`) console.log(`[automation] MCP http://127.0.0.1:${AUTOMATION_HTTP_PORT}/mcp`) } - -function isBunRuntime(): boolean { - return 'Bun' in globalThis -} - -async function startServer(app: Hono) { - if (isBunRuntime()) { - ;(globalThis as unknown as { Bun: { serve: (opts: object) => void } }).Bun.serve({ - fetch: app.fetch, - port: AUTOMATION_HTTP_PORT, - hostname: '127.0.0.1' - }) - } else { - const { serve } = await import('@hono/node-server') - serve({ fetch: app.fetch, port: AUTOMATION_HTTP_PORT, hostname: '127.0.0.1' }) - } -}