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:
parent
e79fb5ac5d
commit
ff78d8d57b
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue