From ff78d8d57ba1c624d06e8c8b9f1a07623f0e3329 Mon Sep 17 00:00:00 2001 From: Danila Poyarkov Date: Sat, 14 Mar 2026 21:18:00 +0300 Subject: [PATCH] Bridge MCP server to live editor via automation WebSocket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ACP agents now get an MCP server at http://127.0.0.1:7600/mcp that proxies tool calls through the automation WebSocket to the browser, where they execute against the live editor store. No separate subprocess, no stale npm package — tools operate on the open canvas. - Add /mcp endpoint to automation bridge (Streamable HTTP transport) - Each MCP tool call → sendToBrowser({command:'tool'}) → browser executes - Strip mcp__open-pencil__ prefix from tool names in chat UI - Export paramToZod from MCP package for bridge reuse --- desktop/capabilities/default.json | 3 +- packages/mcp/src/server.ts | 2 +- src/ai/acp-transport.ts | 19 ++++----- src/automation/bridge.ts | 64 +++++++++++++++++++++++++++++ src/components/chat/ChatMessage.vue | 1 + src/composables/use-chat.ts | 13 +----- vite.config.ts | 3 -- 7 files changed, 77 insertions(+), 28 deletions(-) diff --git a/desktop/capabilities/default.json b/desktop/capabilities/default.json index 43f9a654f..996d19f91 100644 --- a/desktop/capabilities/default.json +++ b/desktop/capabilities/default.json @@ -24,8 +24,7 @@ "allow": [ { "name": "claude-code-acp", "cmd": "claude-code-acp", "args": true }, { "name": "codex-acp", "cmd": "codex-acp", "args": true }, - { "name": "gemini", "cmd": "gemini", "args": true }, - { "name": "bun", "cmd": "bun", "args": true } + { "name": "gemini", "cmd": "gemini", "args": true } ] }, "shell:allow-stdin-write", diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 125aca47a..d8cad091c 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -34,7 +34,7 @@ function fail(e: unknown): McpResult { return { content: [{ type: 'text', text: JSON.stringify({ error: msg }) }], isError: true } } -function paramToZod(param: ParamDef): z.ZodTypeAny { +export function paramToZod(param: ParamDef): z.ZodTypeAny { const typeMap: Record z.ZodTypeAny> = { string: () => param.enum diff --git a/src/ai/acp-transport.ts b/src/ai/acp-transport.ts index ed20cf238..d18a1bda6 100644 --- a/src/ai/acp-transport.ts +++ b/src/ai/acp-transport.ts @@ -27,14 +27,10 @@ export class ACPChatTransport implements ChatTransport { private session: ACPSession | null = null private agentDef: ACPAgentDef private cwd: string - private mcpCommand?: string - private mcpArgs: string[] - constructor(options: { agentDef: ACPAgentDef; cwd?: string; mcpCommand?: string; mcpArgs?: string[] }) { + constructor(options: { agentDef: ACPAgentDef; cwd?: string }) { this.agentDef = options.agentDef this.cwd = options.cwd ?? '.' - this.mcpCommand = options.mcpCommand - this.mcpArgs = options.mcpArgs ?? [] } async sendMessages({ @@ -203,13 +199,16 @@ export class ACPChatTransport implements ChatTransport { clientCapabilities: {} }) - const mcpServers = this.mcpCommand - ? [{ name: 'open-pencil', command: this.mcpCommand, args: this.mcpArgs, env: [] }] - : [] - const sessionResult = await connection.newSession({ cwd: this.cwd, - mcpServers + mcpServers: [ + { + type: 'http' as const, + name: 'open-pencil', + url: 'http://127.0.0.1:7600/mcp', + headers: [] + } + ] }) const session: ACPSession = { diff --git a/src/automation/bridge.ts b/src/automation/bridge.ts index 99badbe52..da0015ec1 100644 --- a/src/automation/bridge.ts +++ b/src/automation/bridge.ts @@ -16,6 +16,8 @@ import { Hono } from 'hono' import { cors } from 'hono/cors' import { WebSocketServer, type WebSocket } from 'ws' +import type { ZodTypeAny } from 'zod' + // 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. @@ -176,10 +178,72 @@ export function startAutomationBridge(server: ViteServer) { } }) + // MCP Streamable HTTP endpoint — proxies tool calls through WebSocket to the live editor + type McpTransport = { handleRequest: (r: Request) => Promise } + const mcpSessions = new Map() + + async function getOrCreateMcpSession(sessionId?: string): Promise { + const cached = sessionId ? mcpSessions.get(sessionId) : undefined + if (cached) return cached + + 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 }> + } + const mcp = await server.ssrLoadModule('@open-pencil/mcp') as { + paramToZod: (p: unknown) => ZodTypeAny + } + + const id = sessionId ?? crypto.randomUUID() + const mcpServer = new McpServer({ name: 'open-pencil', version: '0.0.0' }) + const register = mcpServer.registerTool.bind(mcpServer) as (...a: unknown[]) => void + + for (const def of core.ALL_TOOLS) { + const shape: Record = {} + for (const [key, param] of Object.entries(def.params)) { + shape[key] = mcp.paramToZod(param) + } + register(def.name, { description: def.description, inputSchema: z.object(shape) }, + async (args: Record) => { + try { + const result = await sendToBrowser({ command: 'tool', args: { name: def.name, args } }) + const res = result as { ok?: boolean; result?: unknown; error?: string } + if (res.ok === false) { + return { content: [{ type: 'text' as const, text: JSON.stringify({ error: res.error }) }], isError: true } + } + const r = res.result as Record | undefined + if (r && 'base64' in r && 'mimeType' in r) { + return { content: [{ type: 'image' as const, data: r.base64 as string, mimeType: r.mimeType as string }] } + } + return { content: [{ type: 'text' as const, text: JSON.stringify(r, null, 2) }] } + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) + return { content: [{ type: 'text' as const, text: JSON.stringify({ error: msg }) }], isError: true } + } + } + ) + } + + const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => id }) + await mcpServer.connect(transport) + mcpSessions.set(id, transport) + return transport + } + + app.all('/mcp', async (c) => { + const sessionId = c.req.header('mcp-session-id') ?? undefined + const transport = await getOrCreateMcpSession(sessionId) + return transport.handleRequest(c.req.raw) + }) + void startServer(app) 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 { diff --git a/src/components/chat/ChatMessage.vue b/src/components/chat/ChatMessage.vue index b387aad08..4dd541b8f 100644 --- a/src/components/chat/ChatMessage.vue +++ b/src/components/chat/ChatMessage.vue @@ -12,6 +12,7 @@ type ToolPart = Extract function toolDisplayName(part: ToolPart): string { return getToolName(part) + .replace(/^mcp__[^_]+__/, '') .replace(/_/g, ' ') .replace(/\b\w/g, (c) => c.toUpperCase()) } diff --git a/src/composables/use-chat.ts b/src/composables/use-chat.ts index 104165a5e..09d9c5db3 100644 --- a/src/composables/use-chat.ts +++ b/src/composables/use-chat.ts @@ -211,18 +211,7 @@ async function createACPTransport() { const { ACPChatTransport } = await import('@/ai/acp-transport') const { homeDir } = await import('@tauri-apps/api/path') await acpTransportInstance?.destroy() - - const mcpCommand = import.meta.env.DEV ? 'bun' : 'npx' - const mcpArgs = import.meta.env.DEV - ? [import.meta.env.VITE_PROJECT_ROOT + '/packages/mcp/src/index.ts'] - : ['-y', '@open-pencil/mcp'] - - const transport = new ACPChatTransport({ - agentDef, - cwd: await homeDir(), - mcpCommand, - mcpArgs - }) + const transport = new ACPChatTransport({ agentDef, cwd: await homeDir() }) acpTransportInstance = transport return transport } diff --git a/vite.config.ts b/vite.config.ts index 48132f6e3..002397a42 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -15,9 +15,6 @@ import { automationPlugin } from './src/automation/vite-plugin' const host = process.env.TAURI_DEV_HOST export default defineConfig(async () => ({ - define: { - 'import.meta.env.VITE_PROJECT_ROOT': JSON.stringify(__dirname) - }, resolve: { alias: { '@': resolve(__dirname, 'src'),