MCP 与浏览器桥 深度解析
这一页拆解 Pireel 如何把外部 agent 的 JSON-RPC 请求路由到真实浏览器 tab,并用截图和状态快照形成闭环。
导入链与契约
在 packages/studio-engine/src/mcp.ts:1,MCP server core 声明自身无 I/O。 在 packages/studio-engine/src/mcp.ts:9,工具表复用内部 STUDIO_TOOLS。 在 packages/studio-engine/src/mcp.ts:51,McpDeps 注入所有边界依赖。 在 packages/studio-engine/src/mcp.ts:85,服务端直答工具集合先分流。 在 packages/studio-engine/src/mcp.ts:92,额外 bridge tools 是 MCP 专属。
85/** Tools answered directly on the server (body only on server / pure catalog / direct cloud-state ops): no bridge. */ 86export const MCP_SERVER_TOOL_IDS = new Set(['read_editing_guide', 'read_frame', 'list_frames', 'get_icons', 'import_media', 'create_browser_handoff', 'create_project', 'list_projects', 'switch_project', 'rename_project']); 87 88/** MCP-only bridge tools (not in STUDIO_TOOLS, invisible to internal chat): 89 * get_state=state snapshot; apply_block/submit_plan=the validate-and-place surface for BYO generation output; 90 * capture_frame=visual verification (returns a captured frame as image content so the agent can "see" its own edits). 91 * compose_block_brief/plan_brief are "bridge-fetch context + server-assemble" composite tools, dispatched separately. */ 92export const MCP_BRIDGE_EXTRA_TOOL_IDS = new Set(['get_state', 'apply_block', 'submit_plan', 'capture_frame', 'visual_brief', 'submit_visual']); 93 94/** Brief composite tools → bridge context-operation names (implemented browser-side in runExternalTool). */ 95export const MCP_BRIEF_TOOLS: Record<string, string> = { 96 compose_block_brief: 'compose_context', 97 plan_brief: 'plan_context', 98}; 99 100/** Bridge timeout for slow tools (generation/analysis in the browser, minutes-scale); instant ops get 60s. */ 101const CARD_TIMEOUT_MS = 600_000; 102const BADGE_TIMEOUT_MS = 60_000; 103 104export function bridgeTimeoutMs(toolId: string): number { 105 if (toolId === 'visual_brief') return CARD_TIMEOUT_MS; // runs the free geometry pass inside (minutes-scale); don't cap it at extra's 60s 106 return STUDIO_TOOL_MAP[toolId]?.kind === 'card' ? CARD_TIMEOUT_MS : BADGE_TIMEOUT_MS; 107}
核心实现,逐段拆
▸MCP Bridge 的第一层约束
在 packages/studio-engine/src/mcp.ts:118,buildMcpTools 产出完整工具列表。 在 packages/studio-engine/src/mcp.ts:143,get_state 描述要求拿当前 composition。 在 packages/studio-engine/src/mcp.ts:156,compose_block_brief 取上下文给外部模型。 在 packages/studio-engine/src/mcp.ts:170,apply_block 验证并放置外部生成结果。 在 packages/studio-engine/src/mcp.ts:187,plan_brief 生成 narration plan 契约。 在 packages/studio-engine/src/mcp.ts:265,capture_frame 是视觉验证工具。
154 /* ---------- BYO-brain generation surface: brief → you generate → apply validates and places (no Pireel credits burned) ---------- */ 155 { 156 name: 'compose_block_brief', 157 description: 158 "Get the FULL generation contract {system, prompt} for one overlay block, assembled from the live composition (theme tokens, frame design language, box size, on-screen beats, neighbor roster). YOU then generate the response with your own model, following the contract exactly (one short note, then ```html fence, then ```js timeline fence), and submit the raw text via apply_block. Targets: `blockId` of a pending placeholder (its design spec becomes the instruction — omit `instruction`); `blockId` of an existing block + `instruction` = rewrite; no blockId + `instruction` = new element at `atSec` (defaults to playhead). This is the default way to create/edit block content — it does NOT charge Pireel credits.", 159 inputSchema: { 160 type: 'object', 161 additionalProperties: false, 162 properties: { 163 blockId: { type: 'string', description: 'Target block id (placeholder to fill, or existing block to rewrite). Omit for a new element.' }, 164 atSec: { type: 'number', description: 'New element only: timeline start seconds (defaults to playhead).' }, 165 instruction: { type: 'string', description: 'What to build/change. Required unless targeting a placeholder.' }, 166 }, 167 }, 168 }, 169 { 170 name: 'apply_block', 171 description: 172 "Validate and place a block you generated from compose_block_brief. Pass the SAME blockId/atSec you gave the brief, and `raw` = your full generated text (note + ```html + ```js fences; fences are parsed out). The block is linted (scoped CSS, no scripts, deterministic animation); on lint failure you get the issues back — fix ONLY those and re-apply. Placeholder blockId → fills it; existing blockId → overwrites its content; neither → inserts a new element (optional durationSec, default 3s; optional label).", 173 inputSchema: { 174 type: 'object', 175 additionalProperties: false, 176 properties: { 177 blockId: { type: 'string' }, 178 atSec: { type: 'number' }, 179 durationSec: { type: 'number', description: 'New element only: seconds on screen (default 3).' }, 180 label: { type: 'string', description: 'New element only: short timeline label.' }, 181 raw: { type: 'string', description: 'Your full generated text: note, then ```html fence, then ```js fence.' }, 182 }, 183 required: ['raw'], 184 },
▸MCP Bridge 的第二层约束
在 packages/studio-engine/src/mcp.ts:275,browser handoff 必须用可观察浏览器打开。 在 packages/studio-engine/src/mcp.ts:346,协议版本固定。 在 packages/studio-engine/src/mcp.ts:393,initialize 返回 capabilities 与 instructions。 在 packages/studio-engine/src/mcp.ts:407,tools/call 是主要 dispatch。 在 packages/studio-engine/src/mcp.ts:436,brief 工具先桥取上下文再组装 prompt。 在 packages/studio-engine/src/mcp.ts:448,未知工具直接 JSON-RPC 错误。
31 useEffect(() => { 32 let ws: WebSocket | null = null; 33 let alive = true; 34 let retries = 0; 35 let everConnected = false; 36 let queue: Promise<void> = Promise.resolve(); 37 38 const connect = () => { 39 if (!alive) return; 40 const url = new URL('/api/studio/bridge', location.href); 41 url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; 42 let sock: WebSocket; 43 try { 44 sock = new WebSocket(url); 45 } catch { 46 return; // environment unsupported, give up 47 } 48 ws = sock; 49 sock.onopen = () => { 50 everConnected = true; 51 retries = 0; 52 }; 53 sock.onmessage = (ev) => { 54 if (typeof ev.data !== 'string' || ev.data === 'pong') return; 55 let m: { id?: string; tool?: string; input?: Record<string, unknown> }; 56 try { 57 m = JSON.parse(ev.data) as typeof m; 58 } catch { 59 return; 60 } 61 if (!m.id || !m.tool) return; 62 const { id, tool, input } = m as { id: string; tool: string; input?: Record<string, unknown> }; 63 queue = queue.then(async () => { 64 let out: StudioToolResult & { state?: string }; 65 try { 66 out = tool === 'get_state' ? { ok: true, state: optsRef.current.getState() } : await optsRef.current.runTool(tool, input ?? {}); 67 } catch (e) { 68 out = { ok: false, error: e instanceof Error ? e.message : String(e) }; 69 } 70 try { 71 sock.send(JSON.stringify({ id, ...out })); 72 } catch { 73 /* socket already dead, DO side will time out / fail */ 74 } 75 optsRef.current.onExternalCall?.(tool, out); 76 }); 77 }; 78 sock.onclose = (ev) => { 79 if (ws === sock) ws = null; 80 if (!alive) return; 81 if (ev.code === 4000) return; // kicked by a new tab: this page exits the bridge, don't fight for it 82 if (!everConnected && retries >= 6) return; // never connected (not logged in, etc.): don't spin 83 setTimeout(connect, Math.min(30_000, 1_000 * 2 ** retries++)); 84 };
关键流程
在 packages/studio-ui/src/use-agent-bridge.ts:31,bridge effect 内建立 websocket。 在 packages/studio-ui/src/use-agent-bridge.ts:36,queue 串行外部调用。 在 packages/studio-ui/src/use-agent-bridge.ts:66,get_state 在浏览器侧特判。 在 packages/studio-ui/src/use-agent-bridge.ts:75,外部调用完成后回调 UI。 在 packages/studio-ui/src/use-agent-bridge.ts:81,新 tab 踢旧 tab。 在 packages/studio-ui/src/use-agent-bridge.ts:88,ping 保活。
边界 / 失败模式
▸失败时要让调用方知道该怎么修
在 packages/studio-engine/src/mcp.test.ts:30,工具列表有测试。 在 packages/studio-engine/src/mcp.test.ts:80,服务端直答不走 bridge 有测试。 在 packages/studio-engine/src/mcp.test.ts:110,get_state 文本快照有测试。 在 packages/studio-engine/src/mcp.test.ts:117,bridge 失败设置 isError。 在 packages/studio-engine/src/mcp.test.ts:126,BYO 契约有测试。 在 packages/studio-engine/src/mcp.test.ts:163,capture_frame image content 有测试。
- studio tab 未打开时,bridge 返回 isError,调用方不能假装成功。
- handoff URL 是一次性登录票据,页面明确禁止把它当用户链接。
- 外部调用串行执行,牺牲并发换来可预测的 Composition 变更。