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": [ "allow": [
{ "name": "claude-code-acp", "cmd": "claude-code-acp", "args": true }, { "name": "claude-code-acp", "cmd": "claude-code-acp", "args": true },
{ "name": "codex-acp", "cmd": "codex-acp", "args": true }, { "name": "codex-acp", "cmd": "codex-acp", "args": true },
{ "name": "gemini", "cmd": "gemini", "args": true }, { "name": "gemini", "cmd": "gemini", "args": true }
{ "name": "bun", "cmd": "bun", "args": true }
] ]
}, },
"shell:allow-stdin-write", "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 } 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> = { const typeMap: Record<ParamType, () => z.ZodTypeAny> = {
string: () => string: () =>
param.enum param.enum

View file

@ -27,14 +27,10 @@ export class ACPChatTransport implements ChatTransport<UIMessage> {
private session: ACPSession | null = null private session: ACPSession | null = null
private agentDef: ACPAgentDef private agentDef: ACPAgentDef
private cwd: string 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.agentDef = options.agentDef
this.cwd = options.cwd ?? '.' this.cwd = options.cwd ?? '.'
this.mcpCommand = options.mcpCommand
this.mcpArgs = options.mcpArgs ?? []
} }
async sendMessages({ async sendMessages({
@ -203,13 +199,16 @@ export class ACPChatTransport implements ChatTransport<UIMessage> {
clientCapabilities: {} clientCapabilities: {}
}) })
const mcpServers = this.mcpCommand
? [{ name: 'open-pencil', command: this.mcpCommand, args: this.mcpArgs, env: [] }]
: []
const sessionResult = await connection.newSession({ const sessionResult = await connection.newSession({
cwd: this.cwd, cwd: this.cwd,
mcpServers mcpServers: [
{
type: 'http' as const,
name: 'open-pencil',
url: 'http://127.0.0.1:7600/mcp',
headers: []
}
]
}) })
const session: ACPSession = { const session: ACPSession = {

View file

@ -16,6 +16,8 @@ import { Hono } from 'hono'
import { cors } from 'hono/cors' import { cors } from 'hono/cors'
import { WebSocketServer, type WebSocket } from 'ws' 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 // 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 // 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. // 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) void startServer(app)
console.log(`[automation] HTTP http://127.0.0.1:${AUTOMATION_HTTP_PORT}`) 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] 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 { function isBunRuntime(): boolean {

View file

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

View file

@ -211,18 +211,7 @@ async function createACPTransport() {
const { ACPChatTransport } = await import('@/ai/acp-transport') const { ACPChatTransport } = await import('@/ai/acp-transport')
const { homeDir } = await import('@tauri-apps/api/path') const { homeDir } = await import('@tauri-apps/api/path')
await acpTransportInstance?.destroy() await acpTransportInstance?.destroy()
const transport = new ACPChatTransport({ agentDef, cwd: await homeDir() })
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
})
acpTransportInstance = transport acpTransportInstance = transport
return transport return transport
} }

View file

@ -15,9 +15,6 @@ import { automationPlugin } from './src/automation/vite-plugin'
const host = process.env.TAURI_DEV_HOST const host = process.env.TAURI_DEV_HOST
export default defineConfig(async () => ({ export default defineConfig(async () => ({
define: {
'import.meta.env.VITE_PROJECT_ROOT': JSON.stringify(__dirname)
},
resolve: { resolve: {
alias: { alias: {
'@': resolve(__dirname, 'src'), '@': resolve(__dirname, 'src'),