pireelMCP Bridge
§ · Nested detail page

MCP 与浏览器桥 深度解析

这一页拆解 Pireel 如何把外部 agent 的 JSON-RPC 请求路由到真实浏览器 tab,并用截图和状态快照形成闭环。

§ 00 · IMPORT CHAIN

导入链与契约

在 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 专属。

packages/studio-engine/src/mcp.ts · L85-107TypeScript
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}
§ 01 · CORE IMPL

核心实现,逐段拆

▸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 是视觉验证工具。

packages/studio-engine/src/mcp.ts · L154-184TypeScript
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 错误。

packages/studio-ui/src/use-agent-bridge.ts · L31-84TypeScript
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      };
§ 02 · KEY FLOW

关键流程

tools/list -> buildMcpTools agent tools/call -> server direct tool OR brief composite OR bridge call bridge call -> live browser runTool queue mutation -> get_state / capture_frame verifies result

在 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 保活。

§ 03 · EDGE CASES

边界 / 失败模式

▸失败时要让调用方知道该怎么修

在 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 变更。
Sub-page · 2026-07-23Source · pireel/pireel↑ 返回主页面