GitHub Trending · kgoedecke/doop · 2026-08-25

Doop MCP 工具面:设计画布的 Agent API

拆解 server/mcp.ts 如何把工具调用绑定到 canvas、frame、截图与反馈闭环。

01

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。

02

上下文绑定

每个 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。

03

可见性工具

截图和 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。

04

传输边界

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。