Bridge MCP server to live editor via automation WebSocket

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
This commit is contained in:
Danila Poyarkov 2026-03-14 21:18:00 +03:00 committed by Anton A S
parent e79fb5ac5d
commit ff78d8d57b
7 changed files with 77 additions and 28 deletions

View file

@ -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",

View file

@ -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<ParamType, () => z.ZodTypeAny> = {
string: () =>
param.enum

View file

@ -27,14 +27,10 @@ export class ACPChatTransport implements ChatTransport<UIMessage> {
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<UIMessage> {
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 = {

View file

@ -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<Response> }
const mcpSessions = new Map<string, McpTransport>()
async function getOrCreateMcpSession(sessionId?: string): Promise<McpTransport> {
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<string, unknown>; 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<string, ZodTypeAny> = {}
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<string, unknown>) => {
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<string, unknown> | 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 {

View file

@ -12,6 +12,7 @@ type ToolPart = Extract<UIMessagePart, { toolCallId: string }>
function toolDisplayName(part: ToolPart): string {
return getToolName(part)
.replace(/^mcp__[^_]+__/, '')
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase())
}

View file

@ -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
}

View file

@ -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'),