import { env } from './env.ts' import { createHash } from 'node:crypto' import { eq } from 'drizzle-orm' import { db } from './db/index.ts' import * as authSchema from './db/auth-schema.ts' import { canvasPages, framePageId, MAX_PAGE_NAME, samePages } from '../shared/pages.ts' import { normalizePageBackground } from '../shared/pageBackground.ts' import { prototypeReport, prototypeUrl, setPrototypeStart } from './prototypeTools.ts' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' import type { Request, Response } from 'express' import { fromNodeHeaders } from 'better-auth/node' import { z } from 'zod' import { PageLimitError, store } from './store.ts' import { fontsForCanvas } from './fonts.ts' import { describeLibrary, libraryBrief } from '../shared/fonts.ts' import { MAX_TOKEN_DESCRIPTION, TOKEN_TYPES, describeTokens, tokensBrief, tokensCss, tokensMarkdown, tokensTailwind, } from '../shared/designTokens.ts' import { webFontFamilies, webFontsBrief } from '../shared/webFonts.ts' import * as actions from './actions.ts' import { canvasRole } from './access.ts' import { roleAllows, type CanvasRole } from '../shared/canvasRole.ts' import { isAgentKeySecret, verifyAgentKey } from './agentKeys.ts' import * as workspaces from './workspaces.ts' import { auth, getUserName, isBanned } from './auth.ts' import { capture, captureThrottled } from './analytics.ts' import { renderFrame } from './screenshot.ts' import { MAX_PDF_FRAMES, PDF_UNAVAILABLE_MESSAGE, pdfFileName, pdfRendererAvailable, renderFramesPdf, selectPdfFrames, takePdfExportSlot, } from './pdfExport.ts' import { primeImage } from './previews.ts' import { DOOP_GUIDE, GUIDE_TOPICS } from './guide.ts' import { describeInspiration, INSPIRATION_NO_MATCH_NOTE, INSPIRATION_UNAVAILABLE_NOTE, INSPIRATION_USAGE_NOTE, inspirationSearchEnabled, searchInspiration, } from './inspiration.ts' import { ESCAPED_HTML_NOTE, looksEscapedHtml } from './escapedHtml.ts' import { describeSyncFlow, getSyncFlow } from './ingest.ts' import * as assets from './assets.ts' import * as imageSearch from './imageSearch.ts' import * as imageGen from './imageGen.ts' import * as backgrounds from './backgrounds.ts' import { viewWebsite } from './website.ts' import { createImportedWebpageFrame } from './webpageImport.ts' import { normalizeImportUrl } from './importer.ts' import { extensions, type McpToolHelpers } from './extensions.ts' import { websiteAccessErrorMessage } from './websiteAccess.ts' import { mentionedRole } from '../shared/agents.ts' import * as allowance from './allowance.ts' import { previewOf, previewToolMeta, registerFramePreview, type CanvasPreview } from './chatgptApp.ts' import * as persist from './db/persist.ts' import type { FrameApproval } from '../shared/types.ts' import { recordAgentFeedback } from './agentFeedback.ts' import { AGENT_IDEA_CATEGORIES, AGENT_ISSUE_CATEGORIES, MAX_AGENT_FEEDBACK_CHARS, type AgentFeedbackCategory, type AgentFeedbackKind, } from '../shared/agentFeedback.ts' const 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. You 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. - Context first: call get_canvas before adding or editing frames. - Implementation handoff: get_canvas and get_frame expose approvalStatus (draft, ready_for_dev, needs_review) and human approval attribution. Use get_canvas with approval_status="ready_for_dev" to find approved screens. Re-read get_frame before implementing; only ready_for_dev is current sign-off. HTML or size edits invalidate approval. Approval does not automatically start an implementation task; follow the human's implementation request. - Element links: /c/?frame=&element= identifies one element: the one carrying data-doop-id="" in that frame's HTML. Read get_canvas and get_frame and find that attribute before applying the feedback. If no element carries the id any more, ask for an updated reference. - Keep data-doop-* attributes on every element you rewrite: data-doop-id is how comments and links point at an element, data-doop-on / data-doop-action carry prototype wiring, data-doop-locked a layer lock. Drop one only together with its element. - Identity: pick an agent_name and reuse the SAME name on every call — your presence and edits are attributed live. - 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. - Creating: create_frame, then stream the design with append_frame_html one complete section at a time (~1–4 KB chunks; start=true on the first, done=true on the last). Each chunk renders the moment it arrives — viewers watch you work. - Review: after every create or significant edit you MUST call get_frame_screenshot and fix what looks wrong before moving on. - 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. - Clickable prototypes: wire screens with data-doop-on / data-doop-action attributes in frame HTML (guide → Prototypes), set the flow start with set_prototype_start, and check the wiring with get_prototype. - History: every replace and stream files the design it overwrote. list_frame_versions shows a frame's earlier states; restore_frame_version brings one back (HTML and size) when a human asks for the previous version or your rework turned out worse. - 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 — call it once per brand BEFORE writing any logo wall, integration row, press bar or testimonial, and never ship a placeholder tile, "LOGO" text or an invented wordmark in its place, list_backgrounds shows a page of curated hero/section/bento backgrounds (glows, grainy meshes, aurora, painterly scenes) as thumbnails — browse it when a section wants atmosphere rather than defaulting to a flat CSS gradient, judge by eye whether one fits the frame, and draw your own when none does, upload_asset stores your own file (remote file → source_url; local file → local_file=true, returns a curl command) and returns a permanent URL. generate_image makes a new image from a prompt (a full-bleed hero background in the frame's exact palette, illustration, a product render, brand-specific hero art that stock cannot supply) and stores it as a permanent asset — reach for it when search_images cannot deliver the exact visual, or when the human asks for a generated image; it costs the human money or quota, so one considered prompt beats five drafts. Never inline images as data: URIs. - Websites: when a request names an existing site or URL — a redesign of it, or "like acme.com" — call import_webpage FIRST so an editable HTML snapshot lands on the canvas. Leave that source frame unchanged and design in a separate frame. view_website is only for read-only inspection when the page should not be added. If Doop cannot capture the site, do not retry with view_website because it uses the same capture path. Use your own browser or web tool and work only from content you actually observe; if that is unavailable, ask the user for screenshots or an HTML export rather than inventing content. ${extensionGuideLines()}- Feedback: humans reply to your tasks; their notes arrive inside your tool results as HUMAN FEEDBACK blocks (read-only tools only announce them: claim those with get_feedback) — address them before continuing. - Comments: call get_comments to read comments and replies on a canvas — pinned to an element inside a frame, or to a point on the canvas (pageId, x, y) — optionally filtered by frame; reply_to_comment answers a thread and resolve_comment closes it. Reading does not claim feedback or comments. A reply that @mentions a resident role is metered like a comment left in the browser. - 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. - Design tokens: canvases can carry typed design tokens (colour, spacing, radius, type scale — CSS custom properties), each with a value per theme (light, dark, …) where it differs. get_canvas lists them; use one by writing var(--name) in CSS, never the literal, so a token change restyles every frame; switch a frame's theme with data-doop-theme="dark" on its . create_tokens adds them when a brief names a palette or scale; set_tokens changes, renames or deletes; set_token_themes declares the themes; get_tokens exports them as JSON, CSS or a Tailwind @theme block. - 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. - Problems with Doop itself: when a Doop tool errors, misbehaves or lacks something you need, or the guide leaves you guessing, call report_issue with what you tried and what happened, then carry on. Ideas for making Doop better for agents, or something that worked well, go to share_feedback. Both reach the Doop team, not the humans on the canvas.` function extensionGuideLines(): string { const lines = extensions.flatMap((extension) => extension.mcpGuideLines ?? []) return lines.length ? lines.join('\n') + '\n' : '' } function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') } function text(data: unknown) { return { content: [{ type: 'text' as const, text: typeof data === 'string' ? data : JSON.stringify(data, null, 2) }] } } /** Result payload plus a workflow nudge the agent reads at its decision point. */ function textWithNudge(data: unknown, nudge: string) { return { content: [ { type: 'text' as const, text: JSON.stringify(data, null, 2) }, { type: 'text' as const, text: nudge }, ], } } const REVIEW_NUDGE = 'You have not seen this design yet. Call get_frame_screenshot on it now, judge it against the review checkpoints (fit, spacing, hierarchy, contrast, alignment, realism), and fix any issues before moving on.' import type { Actor, Frame } from '../shared/types.ts' /** Reaches agents whose session predates set_status (or who skipped the guide). */ type ToolContent = { content: { type: 'text' | 'image'; [k: string]: unknown }[] } function appendStatusNudge(result: T, canvasId: string, actor?: Actor): T { if (!actor) return result if (actions.hasAnnouncedTask(canvasId, actor)) return result result.content.push({ type: 'text' as const, text: 'You have not announced what you are working on. Call set_status with a one-line, present-tense summary (e.g. "Designing a pricing page, dark editorial style") — it shows live next to your name and builds your task history in the panel. Update it when your focus shifts; clear it with "" when done.', }) return result } /** Tells an agent its markup arrived escaped — actions.ts already decoded it. */ function withEscapeNote( result: T, sent: string, ): T { if (looksEscapedHtml(sent)) result.content.push({ type: 'text' as const, text: ESCAPED_HTML_NOTE }) return result } /** A PDF returned inline as base64; beyond this, export fewer frames. */ const MAX_MCP_PDF_BYTES = 15_000_000 function err(message: string) { return { content: [{ type: 'text' as const, text: `Error: ${message}` }], isError: true } } /** Reaches agents that start designing without having read the canvas's style guides. */ function withGuidelinesNudge( result: T, canvasId: string, actor?: Actor, ): T { if (!actor) return result const docs = store.getGuidelines(canvasId) if (docs.length === 0 || actions.hasSeenGuidelines(canvasId, actor.name)) return result result.content.push({ type: 'text' as const, text: `This canvas has style guides you have not read: ${docs.map((d) => d.name).join(', ')}. Call get_guidelines({ canvas_id, name }) for each relevant one NOW and make your design follow them.`, }) return result } /** Append any undelivered human feedback for this agent to a tool result. * MCP is pull-based, so this is the channel through which humans steer agents. */ function appendFeedback(result: T, canvasId: string, actor?: Actor): T { if (!actor) return result const pending = actions.takeFeedbackFor(canvasId, actor.name) if (pending.length === 0) return result const tasks = actions.getTasks(canvasId) const lines = pending.map((f) => { const task = tasks.find((t) => t.id === f.taskId) const mine = task && task.agentName === actor.name const about = task ? ` (about ${mine ? 'your' : `${task.agentName}’s`} work: “${task.status}”)` : '' return `- ${f.from}${about}: ${f.text}` }) result.content.push({ type: 'text' as const, text: `HUMAN FEEDBACK — open request(s) on this canvas, now assigned to YOU:\n${lines.join('\n')}\nAddress this NOW, before continuing your plan: locate the frame in question (get_canvas / get_frame), make the change, and review with get_frame_screenshot. If it concerns another agent's frame, edit it anyway — a human request overrides the don't-touch-others'-frames etiquette. Update your set_status to say what you're picking up.`, }) return result } /** The read-only tools' share of the feedback channel: says open human * feedback is waiting without claiming it, so a read never writes. Claiming * stays with get_feedback and the tools that change the canvas. */ function appendFeedbackNotice(result: T, canvasId: string, actor?: Actor): T { if (!actor) return result const open = actions.openFeedbackFor(canvasId, actor.name).length if (open === 0) return result result.content.push({ type: 'text' as const, text: `HUMAN FEEDBACK — ${open} open request${open === 1 ? '' : 's'} on this canvas. Call get_feedback({ canvas_id, agent_name }) NOW to claim and read ${open === 1 ? 'it' : 'them'}, and address ${open === 1 ? 'it' : 'them'} before continuing your plan.`, }) return result } /** Attaches the data behind the ChatGPT preview card (server/chatgptApp.ts): * the frames a tool touched, as `structuredContent`. Clients without a * component host ignore it; the model keeps reading `content` as before. */ function withPreview( result: T, canvasId: string, frames: Frame[], ): T & { structuredContent: CanvasPreview } { const canvas = store.getCanvas(canvasId) return { ...result, structuredContent: previewOf({ id: canvasId, name: canvas?.name ?? '' }, frames) } } /* Per-connecting-user rate limits, each overridable by environment variable for self-hosted instances that want a looser cap (see .env.example). The defaults are the conservative values that suit the hosted service. */ const uploadHits = new Map() const UPLOADS_PER_MIN = env.MCP_UPLOADS_PER_MIN /* photo search burns the shared Pexels quota (200 req/hour on the free tier) */ const searchHits = new Map() const SEARCHES_PER_MIN = env.MCP_SEARCHES_PER_MIN /* generation spends the payer's subscription quota or money — keep a burst of retries from draining it */ const generateHits = new Map() const GENERATIONS_PER_MIN = env.MCP_GENERATIONS_PER_MIN /* importing writes a potentially large HTML frame, so keep it at the same conservative per-user rate as the browser UI's import endpoint */ const importHits = new Map() const IMPORTS_PER_MIN = env.MCP_IMPORTS_PER_MIN const agentName = z .string() .describe( 'Your display name, shown live to everyone on the canvas (e.g. "Claude", "Codex"). Reuse the SAME name on every call so your work is attributed consistently.', ) function frameSummary(f: { pageId?: string id: string name: string x: number y: number width: number height: number updatedAt: number updatedBy: string html: string demo?: boolean hidden?: boolean locked?: boolean approval?: FrameApproval | null }) { return { id: f.id, pageId: f.pageId, name: f.name, approvalStatus: f.approval?.status ?? 'draft', approval: f.approval ?? null, ...(f.demo ? { demo: true } : {}), ...(f.hidden ? { hidden: true } : {}), ...(f.locked ? { locked: true } : {}), x: f.x, y: f.y, width: f.width, height: f.height, updatedAt: new Date(f.updatedAt).toISOString(), updatedBy: f.updatedBy, htmlBytes: f.html.length, /* public render of the CURRENT design — downloadable/hotlinkable (append &download, or .jpg?quality=90 for JPEG) */ image_url: `${env.BETTER_AUTH_URL}/i/${f.id}.png?scale=2`, } } /** owner is the connecting user's display name (for attribution); ownerId is * their user id — canvases the agent creates or lists are scoped to it, the * same isolation the web UI gets. clientName names the connecting client * when this server did not see its initialize (handleMcpRequest builds one * server per stateless request). */ export function buildMcpServer( owner?: string, ownerId?: string, clientName?: () => Promise, ): McpServer { const actorFrom = (agent_name?: string) => actions.resolveActor({ name: agent_name, kind: 'agent', owner }) /* Canvas access for agents mirrors the web UI: the OAuth user's id runs through the same canvasRole gate as browser sessions, so an agent connected by someone holding a view or comment link reads but cannot write. Every tool that takes a canvas or frame id resolves it through these, naming the role it needs: 'view' to read, 'comment' for comment threads, 'edit' for everything that changes the canvas. */ const roleOn = (canvasId: string) => { const c = store.getCanvas(canvasId) return c ? canvasRole(ownerId, c) : null } const canvasFor = (canvasId: string, need: CanvasRole) => roleAllows(roleOn(canvasId), need) ? store.getCanvas(canvasId) : undefined const frameFor = (frameId: string, need: CanvasRole) => { const f = store.getFrame(frameId) return f && canvasFor(f.canvasId, need) ? f : undefined } /* a canvas the account can read but not change says so, instead of claiming the canvas is not there */ const readOnlyNote = (canvasId: string | undefined) => { const role = canvasId ? roleOn(canvasId) : null return role ? ` with the access this task needs — this account can only ${role} on that canvas` : '' } const noCanvas = (id: string) => err(`no canvas with id ${id} accessible to this account${readOnlyNote(id)}`) const noFrame = (id: string) => err(`no frame with id ${id} accessible to this account${readOnlyNote(store.getFrame(id)?.canvasId)}`) /* Feedback is claimed, and set_status nudged, only for agents that may act on it: claiming assigns a human's request to this agent, which a read-only agent could never carry out. */ const withFeedback = (result: T, canvasId: string, actor?: Actor): T => roleAllows(roleOn(canvasId), 'edit') ? appendFeedback(result, canvasId, actor) : result const withFeedbackNotice = (result: T, canvasId: string, actor?: Actor): T => roleAllows(roleOn(canvasId), 'edit') ? appendFeedbackNotice(result, canvasId, actor) : result const withStatusNudge = (result: T, canvasId: string, actor?: Actor): T => roleAllows(roleOn(canvasId), 'edit') ? appendStatusNudge(result, canvasId, actor) : result const noComment = (id: string) => err(`no comment with id ${id} on this canvas`) /* export_frame's PDF: the file travels inside the tool result, not as a public URL, so it stays behind the same canvas gate as the frames */ const exportPdf = async (first: Frame, more: string[]) => { const canvas = canvasFor(first.canvasId, 'view') if (!canvas) return noFrame(first.id) const selection = selectPdfFrames(canvas, { frameIds: [first.id, ...more] }) if (!selection.ok) return err(selection.error) if (!pdfRendererAvailable()) return err(PDF_UNAVAILABLE_MESSAGE) if (!takePdfExportSlot(ownerId ?? `mcp:${owner ?? 'anonymous'}`)) return err('PDF export rate limit — wait a minute before exporting again') try { const pdf = await renderFramesPdf(selection.frames) if (pdf.length > MAX_MCP_PDF_BYTES) return err('The PDF is too large to return — export fewer frames at once') const fileName = pdfFileName(canvas, selection.frames) return { content: [ { type: 'resource' as const, resource: { uri: `doop://canvas/${canvas.id}/${encodeURIComponent(fileName)}`, mimeType: 'application/pdf', blob: pdf.toString('base64'), }, }, { type: 'text' as const, text: JSON.stringify({ file_name: fileName, bytes: pdf.length, pages: selection.frames.length, note: 'The PDF is the embedded application/pdf resource (base64). Save it to disk as the file name above; each page is one frame at its own size, text stays selectable.', }), }, ], } } catch (e) { return err(`PDF export failed: ${e instanceof Error ? e.message : String(e)}`) } } /* Reads count as arrival: presence (and with it every "your agent is connected" confirmation in the UI) must appear on an agent's FIRST canvas-scoped call, not only once it mutates something. */ const arrive = (canvasId: string, agent_name?: string) => { if (agent_name) actions.heartbeatAgent(canvasId, actorFrom(agent_name)) } const server = new McpServer({ name: 'doop-canvas', version: '0.1.0' }, { instructions: INSTRUCTIONS }) registerFramePreview(server) server.registerTool( 'get_guide', { title: 'Read the Doop agent guide', description: 'Read the Doop agent guide: mandatory review checkpoints, the streaming workflow, frame sizing, design-quality doctrine, and multiplayer etiquette. Call with topic "doop-instructions" ONCE before using other Doop tools; call again if a long conversation may have compressed earlier context.', annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, inputSchema: { topic: z.enum(GUIDE_TOPICS).describe('Guide topic to load'), }, }, async () => text(DOOP_GUIDE), ) server.registerTool( 'search_inspiration', { title: 'Search design inspiration', description: 'Search One Page Love\'s hand-curated gallery of real, well-designed live websites and SEE thumbnails of each, with their tags (style, colour, fonts, genre, sections). Call it FIRST when writing a design brief — it is the required inspiration step, especially for landing pages. Query the genre plus the look you want in the words the catalog is tagged with ("saas landing page, dark minimal", "finance, editorial", "portfolio, typographic", "pricing, illustrative"), not the product noun alone. The catalog is landing pages and one-page sites, not app screens: for a dashboard or app UI, query the product\'s register ("saas, minimal") and take the type, colour and density from it. Study the thumbnails, pick the ONE exemplar that fits the brief best and follow it — do not blend several — and name it in the brief. Do not embed these screenshots in a frame.', annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, inputSchema: { query: z .string() .describe('Genre + look, e.g. "saas landing page, dark minimal", "restaurant, retro", "finance, editorial"'), count: z.number().min(1).max(6).optional().describe('Exemplars to return, default 4'), canvas_id: z.string().optional().describe('The canvas you are designing on (lets human feedback reach you)'), agent_name: agentName, }, }, async ({ query, count, canvas_id, agent_name }) => { if (!inspirationSearchEnabled()) return text({ ok: true, results: [], note: INSPIRATION_UNAVAILABLE_NOTE }) const now = Date.now() const limitKey = ownerId ?? agent_name const hits = (searchHits.get(limitKey) ?? []).filter((t) => now - t < 60_000) if (hits.length >= SEARCHES_PER_MIN) return err('search rate limit — wait a minute') hits.push(now) searchHits.set(limitKey, hits) try { const results = await searchInspiration(query, count ?? 4) if (results.length === 0) return text({ ok: true, results: [], note: `No inspiration for "${query}" — ${INSPIRATION_NO_MATCH_NOTE}.` }) const thumbs = await Promise.all(results.map((r) => imageSearch.fetchThumb(r.thumb_url))) type ResultBlock = { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string } const content: ResultBlock[] = [ { type: 'text' as const, text: `${results.length} exemplar(s) for "${query}" — study each thumbnail with its style facts:`, }, ] results.forEach((r, i) => { const thumb = thumbs[i] if (thumb) content.push({ type: 'image' as const, data: thumb.data, mimeType: thumb.mime }) content.push({ type: 'text' as const, text: describeInspiration(r, i) }) }) content.push({ type: 'text' as const, text: INSPIRATION_USAGE_NOTE }) const result = { content } return canvas_id ? withFeedbackNotice(result, canvas_id, actorFrom(agent_name)) : result } catch (e) { return err(e instanceof Error ? e.message : 'inspiration search failed') } }, ) server.registerTool( 'list_canvases', { title: 'List canvases', description: "List the connected user's design canvases with their ids, names and frame counts — personal ones, ones shared with them, and every canvas in their workspaces (workspace_id / workspace_name set).", annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, inputSchema: {}, }, /* '' matches no ownerId: a session without a user sees nothing */ async () => text( workspaces.canvasesFor(ownerId ?? '').map((m) => ({ ...m, ...(m.workspaceId ? { workspace_id: m.workspaceId, workspace_name: workspaces.getWorkspace(m.workspaceId)?.name } : {}), /* count what get_canvas will actually return — demo frames are hidden from agents */ frameCount: store.getCanvas(m.id)?.frames.filter((f) => !f.demo).length ?? m.frameCount, guidelinesCount: store.getGuidelines(m.id).length, })), ), ) /* ChatGPT shows this when several Doop accounts are connected — the `openai/profile` flag marks it as the one that tells them apart. */ server.registerTool( 'get_profile', { title: 'Get the connected account', description: 'Return the Doop account this connection is signed in as (stable id and display name). Read-only; use it to confirm whose canvases you are working on.', annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, _meta: { 'openai/profile': true }, inputSchema: {}, outputSchema: { id: z.string(), name: z.string() }, }, async () => { if (!ownerId) return err('this connection is not signed in to a Doop account') const profile = { id: ownerId, name: owner ?? '' } return { content: [{ type: 'text' as const, text: JSON.stringify(profile) }], structuredContent: profile } }, ) server.registerTool( 'create_canvas', { title: 'Create a canvas', description: 'Create a new design canvas. Returns the canvas id, which is part of the shareable URL (/c/). Pass workspace_id (from list_canvases) to create it inside a shared workspace so every member can open it.', annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, inputSchema: { name: z.string().describe('Canvas name'), workspace_id: z.string().optional().describe('Create inside this shared workspace'), agent_name: agentName.optional(), }, }, async ({ name, workspace_id }) => { if (workspace_id) { const ws = workspaces.getWorkspace(workspace_id) if (!ws || !workspaces.isWorkspaceMember(ws.id, ownerId)) return err(`no workspace with id ${workspace_id}`) if (!workspaces.isActive(ws)) return err(`workspace "${ws.name}" needs a Team plan before canvases can be added`) } /* owned by the connecting user — an ownerless canvas would be invisible on every dashboard (and was once visible on all of them) */ const canvas = store.createCanvas(name, ownerId, workspace_id) return text({ id: canvas.id, name: canvas.name, url: `/c/${canvas.id}` }) }, ) server.registerTool( 'get_canvas', { title: 'Get a canvas and its frames', _meta: previewToolMeta('Reading the canvas…', 'Canvas loaded'), description: "Get a canvas: its name, its pages (id, name, prototype start frame) and its frames with pageId, position, size, approvalStatus and human approval attribution (not the HTML — use get_frame for that). Pass page_id to see one page's frames only; filter approval_status to ready_for_dev to find screens approved for implementation. Use this to see the current layout before adding or editing frames. Pass your agent_name so the result can tell you when human feedback is waiting (claim it with get_feedback).", annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, inputSchema: { canvas_id: z.string(), agent_name: agentName.optional(), page_id: z.string().optional().describe("Only this page's frames (a page id from a previous get_canvas)"), approval_status: z.enum(['draft', 'ready_for_dev', 'needs_review']).optional(), }, }, async ({ canvas_id, agent_name, page_id, approval_status }) => { const c = canvasFor(canvas_id, 'view') if (!c) return noCanvas(canvas_id) if (page_id && !canvasPages(c).some((page) => page.id === page_id)) return err(`No page ${page_id} on this canvas. Its pages are listed by get_canvas without page_id.`) arrive(canvas_id, agent_name) /* demo frames (the Doop welcome show, seeded examples) are product content, not user work — hidden so agents never mistake them for the canvas's established style */ const shown = c.frames.filter( (f) => !f.demo && (!page_id || framePageId(c, f) === page_id) && (!approval_status || (f.approval?.status ?? 'draft') === approval_status), ) const docs = store.getGuidelines(canvas_id) const refs = store.getReferences(canvas_id) const library = fontsForCanvas(c) const tokens = store.getTokens(canvas_id) const tokenThemes = store.getTokenThemes(canvas_id) /* design-synced canvases carry a flow map: real user navigation between the synced screens — context a redesign must respect */ const flow = describeSyncFlow(await getSyncFlow(c), c.frames) const notes = [ ...(flow.length ? [ 'This canvas is synced from a live app and `flow` shows how its screens connect — including how often real users take each path. Do not bury or weaken elements that carry heavy navigation.', ] : []), ...(docs.length ? [ 'This canvas has style guides. Call get_guidelines for each relevant one BEFORE designing — every frame must follow them.', ] : []), ...(library.length ? [libraryBrief(library)] : []), webFontsBrief(), ...(tokens.length ? [tokensBrief(tokens, tokenThemes)] : []), ...(refs.length ? [ 'This canvas has pinned style references — exemplar designs humans marked as "more like this". Call get_reference on the relevant one and match its look (palette, type, spacing) in what you design.', ] : []), ] return withPreview( withFeedbackNotice( text({ id: c.id, name: c.name, pages: canvasPages(c), frames: shown.map(frameSummary), guidelines: docs.map((d) => ({ name: d.name, title: actions.guidelineTitle(d), summary: actions.guidelineSummary(d), bytes: d.markdown.length, })), references: refs.map((r) => ({ id: r.id, title: r.title, size: `${Math.round(r.width)}x${Math.round(r.height)}`, htmlBytes: r.html.length, pinnedBy: r.pinnedBy, })), ...(library.length ? { fonts: describeLibrary(library) } : {}), webFonts: webFontFamilies(), ...(tokens.length ? { tokens: describeTokens(tokens) } : {}), ...(tokenThemes.length ? { tokenThemes } : {}), ...(flow.length ? { flow } : {}), ...(notes.length ? { note: notes.join(' ') } : {}), }), canvas_id, agent_name ? actorFrom(agent_name) : undefined, ), canvas_id, shown, ) }, ) server.registerTool( 'list_guidelines', { title: 'List style guides', description: "List a canvas's style guides (named markdown guidelines — brand rules, style recipes) with one-line summaries. Fetch the full text of the relevant ones with get_guidelines before designing.", annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, inputSchema: { canvas_id: z.string(), agent_name: agentName.optional() }, }, async ({ canvas_id, agent_name }) => { if (!canvasFor(canvas_id, 'view')) return noCanvas(canvas_id) if (agent_name) actions.markGuidelinesSeen(canvas_id, actorFrom(agent_name).name) const docs = store.getGuidelines(canvas_id) if (docs.length === 0) return text({ guidelines: [], note: 'No style guides on this canvas yet. Set one with set_guidelines when a human hands you brand or style rules.', }) return withFeedbackNotice( text({ guidelines: docs.map((d) => ({ name: d.name, title: actions.guidelineTitle(d), summary: actions.guidelineSummary(d), bytes: d.markdown.length, updatedAt: new Date(d.updatedAt).toISOString(), updatedBy: d.updatedBy, })), }), canvas_id, agent_name ? actorFrom(agent_name) : undefined, ) }, ) server.registerTool( 'get_guidelines', { title: 'Read a style guide', description: "Read one of the canvas's style guides in full: the style rules (palettes, fonts, layout recipes, asset URLs) every frame must follow. If get_canvas listed style guides, read the relevant ones with this BEFORE creating or restyling frames.", annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, inputSchema: { canvas_id: z.string(), name: z.string().describe('Doc name from get_canvas / list_guidelines, e.g. "feature-image"'), agent_name: agentName.optional(), }, }, async ({ canvas_id, name, agent_name }) => { if (!canvasFor(canvas_id, 'view')) return noCanvas(canvas_id) const doc = store.getGuidelines(canvas_id).find((d) => d.name === name.trim().toLowerCase()) if (!doc) { const names = store.getGuidelines(canvas_id).map((d) => d.name) return err( names.length ? `no style guide named “${name}” — this canvas has: ${names.join(', ')}` : `no style guides on this canvas yet`, ) } if (agent_name) actions.markGuidelinesSeen(canvas_id, actorFrom(agent_name).name) return withFeedbackNotice(text(doc.markdown), canvas_id, agent_name ? actorFrom(agent_name) : undefined) }, ) server.registerTool( 'set_guidelines', { title: 'Create or replace a style guide', description: "Create, replace or delete a named style guide on a canvas (markdown, max 24,000 chars; empty string deletes). Write rules other designers and agents can execute directly: palette hexes, font s, ready-to-paste