Doop MCP 工具面:设计画布的 Agent API
拆解 server/mcp.ts 如何把工具调用绑定到 canvas、frame、截图与反馈闭环。
MCP 先给规则,再给工具
指令是工具协议的一部分
Doop 把 MCP instructions 当作产品协议,而不是单纯提示词。server/mcp.ts:16 要求 agent 先调用 guide,server/mcp.ts:20 让 agent 保持进度可见,server/mcp.ts:22 限定只能编辑 Doop 创建的 frame,server/mcp.ts:24 强制截图检查,server/mcp.ts:27 则把用户反馈纳入闭环。这些约束让后面的 create/update 工具有了可评审的行为边界。
server/mcp.ts:16MCP instructions 把 agent 工作流写成协议16const INSTRUCTIONS = `Doop is a shared multiplayer design canvas: humans and AI agents design together in real time. Canvases contain frames — artboards that render complete HTML documents live for everyone viewing.
17
18You MUST call get_guide({ topic: "doop-instructions" }) once before using other Doop tools. Call it again if a long conversation may have compressed or dropped the guide text.
19
20- Context first: call get_canvas before adding or editing frames.
21- Identity: pick an agent_name and reuse the SAME name on every call — your presence and edits are attributed live.
22- Narrate: call set_status with a one-line summary when you start a task and whenever your focus shifts — people watching the canvas see it live next to your name.
23- Creating: create_frame, then stream the design with append_frame_html in ~300–500 character chunks (start=true on the first, done=true on the last). Viewers watch you work.
24- Review: after every create or significant edit you MUST call get_frame_screenshot and fix what looks wrong before moving on.
25- Small edits: edit_frame_html (exact find/replace — the change morphs into the rendered frame in place). Full redesigns: set_frame_html or a new stream. Rename/move/resize: update_frame.
26- Images: real imagery makes designs. search_images finds stock photos (you SEE thumbnails and pick), search_icons finds 200k+ UI icons as hotlinkable SVGs, search_logos finds real company logos by brand name or domain, upload_asset stores your own file (remote file → source_url; local file → local_file=true, returns a curl command) and returns a permanent URL. Never inline images as data: URIs.
27- Feedback: humans reply to your tasks; their notes arrive inside your tool results as HUMAN FEEDBACK blocks — address them before continuing.
28- Guidelines: canvases can carry named style guides (brand rules, style recipes). get_canvas lists them with one-line summaries — read the relevant ones with get_guidelines BEFORE designing and follow them.
29- Memory: canvases can also carry pinned style references — exemplar designs humans marked as "more like this". get_canvas lists them; read the relevant one with get_reference and match its look. When your human gives you design feedback in conversation and you address it, record it with save_decision so the canvas remembers their taste.`
buildMcpServer 传入 canvasId 和 frameId server/mcp.ts:153。 canvasFor 会校验当前 canvas 是否存在 server/mcp.ts:158。 frameFor 会限定 frame 归属 server/mcp.ts:162。 McpServer 在同一个闭包里注册工具 server/mcp.ts:175。
上下文绑定
每个 MCP 请求都落到具体画布
工具注册前先定义 server/mcp.ts:158 的 canvasFor 和 server/mcp.ts:162 的 frameFor,这意味着后续工具不需要从文本里反推当前画布。server/mcp.ts:177 开始的 guide 工具能返回 canvas 级上下文,server/mcp.ts:438 的 set_status 写入任务日志,server/mcp.ts:486 的 create_frame 再通过 store 产生真实 frame。这个闭包式设计把 MCP session 和应用状态绑定得很紧。
server/mcp.ts:153MCP server 构造时捕获当前上下文153export function buildMcpServer(owner?: string, ownerId?: string): McpServer {
154 const actorFrom = (agent_name?: string) => actions.resolveActor({ name: agent_name, kind: 'agent', owner })
155 /* Canvas access for agents mirrors the web UI: the OAuth user's id runs
156 through the same canAccessCanvas gate as browser sessions. Every tool
157 that takes a canvas or frame id resolves it through these. */
158 const canvasFor = (canvasId: string) => {
159 const c = store.getCanvas(canvasId)
160 return c && canAccessCanvas(ownerId, c) ? c : undefined
161 }
162 const frameFor = (frameId: string) => {
163 const f = store.getFrame(frameId)
164 if (!f) return undefined
165 return canvasFor(f.canvasId) ? f : undefined
166 }
167 const noCanvas = (id: string) => err(`no canvas with id ${id} accessible to this account`)
168 const noFrame = (id: string) => err(`no frame with id ${id} accessible to this account`)
169 /* Reads count as arrival: presence (and with it every "your agent is
170 connected" confirmation in the UI) must appear on an agent's FIRST
171 canvas-scoped call, not only once it mutates something. */
172 const arrive = (canvasId: string, agent_name?: string) => {
173 if (agent_name) actions.heartbeatAgent(canvasId, actorFrom(agent_name))
174 }
175 const server = new McpServer({ name: 'doop-canvas', version: '0.1.0' }, { instructions: INSTRUCTIONS })
setAgentStatus 接收 MCP 进度 server/actions.ts:205。 状态事件进入任务 log server/actions.ts:221。 任务状态会广播到浏览器 server/actions.ts:233。 新 frame 通过 store 创建 server/store.ts:187。 frame 内容最后进入持久化队列 server/db/persist.ts:199。
可见性工具
截图和 HTML 增量修改配套出现
Doop 没有只提供“写 HTML”的单向工具。server/mcp.ts:868 的 screenshot 能让 agent 看到 iframe 渲染结果,server/mcp.ts:907 的 append_frame_html 才允许追加内容,server/mcp.ts:922 会读取当前 frame,server/mcp.ts:932 再保存更新。这样的工具配对让模型可以先观察、再修改、再请用户反馈。
server/mcp.ts:868截图观察与 HTML 增量修改工具868 server.registerTool(
869 'get_frame_screenshot',
870 {
871 description:
872 'Render a frame and return a PNG screenshot of it — this is how you SEE your design. Always review your work with this after creating or updating a frame, then fix what looks wrong (spacing, overflow, contrast, alignment) and check again. Iterate until it actually looks good, not just until the HTML seems right.',
873 inputSchema: {
874 frame_id: z.string(),
875 scale: z
876 .union([z.literal(1), z.literal(2)])
877 .optional()
878 .describe('Device scale factor: 1 (default) or 2 for a retina-resolution image'),
879 agent_name: agentName.optional(),
880 },
881 },
882 async ({ frame_id, scale, agent_name }) => {
883 const f = frameFor(frame_id)
884 if (!f) return noFrame(frame_id)
885 arrive(f.canvasId, agent_name)
886 try {
887 const png = await renderFrame(f, scale ?? 1)
888 return withFeedback(
889 {
890 content: [
891 { type: 'image' as const, data: png.toString('base64'), mimeType: 'image/png' },
892 {
893 type: 'text' as const,
894 text: `Screenshot of “${f.name}” (${f.width}×${f.height}@${scale ?? 1}x, html ${f.html.length} bytes)`,
895 },
896 ],
897 },
898 f.canvasId,
899 agent_name ? actorFrom(agent_name) : undefined,
900 )
901 } catch (e) {
902 return err(`screenshot failed: ${e instanceof Error ? e.message : String(e)}`)
903 }
904 },
905 )
906
907 server.registerTool(
908 'append_frame_html',
909 {
910 description:
911 'Stream a design into a frame chunk by chunk, so viewers watch it build up live — prefer this over set_frame_html when creating or reworking a whole design. Send the HTML in order, in chunks of roughly 200–600 characters (e.g. head/styles first, then section by section). Set start=true on the FIRST chunk (replaces any existing content and shows a live "designing…" badge) and done=true on the LAST chunk. Partial HTML renders progressively, so structure chunks to end at reasonable boundaries when you can.',
912 inputSchema: {
913 frame_id: z.string(),
914 html_chunk: z.string().describe('The next piece of HTML, appended to what has been sent so far.'),
915 start: z.boolean().optional().describe('true on the first chunk — clears the frame and starts the live stream'),
916 done: z.boolean().optional().describe('true on the final chunk — ends the live stream'),
917 agent_name: agentName,
918 },
919 },
920 async ({ frame_id, html_chunk, start, done, agent_name }) => {
921 if (!frameFor(frame_id)) return noFrame(frame_id)
922 const frame = actions.appendFrameHtml(frame_id, html_chunk, actorFrom(agent_name), { start, done })
923 if (!frame) return noFrame(frame_id)
924 const result = done
925 ? textWithNudge(
926 { ok: true, streaming: false, htmlBytes: frame.html.length },
927 `Stream complete. ${REVIEW_NUDGE}`,
928 )
929 : text({ ok: true, streaming: true, htmlBytes: frame.html.length })
930 /* nudge only on the first chunk — mid-stream results should stay lean */
931 const nudged = start ? withGuidelinesNudge(result, frame.canvasId, actorFrom(agent_name)) : result
932 return withStatusNudge(
933 withFeedback(nudged, frame.canvasId, actorFrom(agent_name)),
934 frame.canvasId,
935 actorFrom(agent_name),
936 )
937 },
iframe 会向父页面报告交互状态 src/components/FrameView.tsx:126。 元素检查提供 selector 上下文 src/components/FrameView.tsx:147。 元素评论能回到 agent 队列 server/actions.ts:397。 agent 可以领取元素评论 server/actions.ts:430。 处理完成后评论可关闭 server/actions.ts:479。
传输边界
OAuth 和 SSE/HTTP 共用处理入口
传输层集中在 server/mcp.ts:1014。它先用 authInfo 提供 owner,server/mcp.ts:1025 再检查 canvasId,随后由 transport 处理实际 MCP 请求;server/mcp.ts:1042 与 server/mcp.ts:1050 分别覆盖 HTTP 和 session cleanup。这个入口值得继续加审计日志,因为 MCP 工具已经能影响页面内容。
API 层同样有 auth middleware server/index.ts:440。 requireCanvas 是 REST 入口的权限栅栏 server/index.ts:456。 requireFrame 校验 frame 归属 server/index.ts:469。 MCP 未授权用例已经覆盖 tests/access.test.ts:126。 未授权请求应返回 401 tests/access.test.ts:130。