Centina MCP server
Agents as first-class editors: any MCP client (Claude Code, Claude Desktop, custom agents) gets live read/write access to the document open in the browser. Because the canvas is real DOM, reads report measured truth (getBoundingClientRect, getComputedStyle), and writes go through the same command layer as human edits — atomic, validated, and undoable.
Quick start
Start the editor, then register the MCP command with your client (Claude Code shown):
centina serveclaude mcp add centina -- centina mcp
The full setup — absolute paths, JSON client config, LAN sharing, firewall notes — is in the setup and self-hosting playbook.
The browser tab connects to the bridge automatically within ~2.5 s. Open several tabs and the newest one wins — older tabs go quiet. Then just talk to the agent: "what's on my canvas?", "build me a pricing section", "make this dark mode".
How it works
MCP client ⇄ stdio ⇄ centina mcp ⇄ WebSocket ⇄ bridge (centina serve) ⇄ open browser tab
The serving process hosts the bridge, so an editor tab is reachable by agents whenever its server runs — centina mcp is a stateless client the MCP client launches. Any number of agent sessions can share one tab concurrently — the bridge fans their requests in; tabs still follow newest-tab-wins. If the server runs but no tab is connected, every tool fails fast with No editor tab is open at http://localhost:<port>; if the server itself is down, with Centina at http://localhost:<port> isn't running.
Instances are identified by their app URL. Canvases autosave in the browser's IndexedDB, which is per-origin — localhost:3000 and localhost:4500 are different libraries. Node refs therefore carry their origin (localhost:3000#ab12), and every tool routes each call to the instance its ids name; bare ids target the default instance. Tools that can run without any id (ping, get_canvas_info, get_selection, get_tree_summary, create_box, write_html, finish_working_on_nodes) take an optional instance param (localhost:3000) for the same routing. One call targets one instance — mixing origins in a single call is an error. The bridge always listens at the web port + 701 (see ports and firewalls).
Concepts
- Everything is a box. Container nodes are boxes (what other tools call frames); the other node types are text and image. Tools say "node" when any type works and "box" only for container concepts.
- Node ids address everything. Get them from any read tool, or from the human: Copy for agent (right-click any node, ⇧⌘C, or the button at the bottom of the inspector) puts a self-describing reference on their clipboard —
[Centina localhost:3000#ab12 "Hero" · box · 960×540 — use centina MCP tools with this id]— so a paste into any agent chat carries the id, the instance it lives in, and how to resolve it, no per-project setup needed. Every tool accepts the qualified token verbatim and routes to that instance. The context menu's Copy ID copies the bare id(s) for prompts that already have context. Id inputs accept the display form with a leading#. - Every write is one undo step. A whole
write_htmlimport, a 12-nodeupdate_stylesbatch — each tool call reverts with a single ⌘Z. Validation is atomic: if any entry in a batch is invalid, nothing applies and the error names every offending path. - Measured truth.
layout.width/heightvalues are stale forfit/fillsizing — the browser decides those. ReadworldRect(from the live DOM) when you need actual geometry; it'snullfor hidden nodes. - Style writes speak the model schema, not CSS.
update_stylestakesfills,radius,layout.width: {mode}, … — not CSS property names. TherawCssgroup is the escape hatch for arbitrary CSS (it applies last, so rendering stays faithful). It merges per declaration:{prop: "value"}sets one key (validated by the rendering browser'sCSS.supports; custom--*properties exempt),{prop: null}removes it,nullclears the whole map, untouched keys stay.disabledCsstakes the same shape for declarations kept but toggled off; setting a key in either map removes it from the other. Read both back viaget_node_info'sstylefield — the same maps the panel's "Other styles" section edits. - Presence. Nodes an agent touches show a shimmering "Crafting…" overlay — ephemeral, never in the document or history. Its lifetime is the agent session, not write recency: every tool call (reads and screenshots included) keeps it alive, so a half-built design never reads as finished while the agent thinks between writes. It clears on
finish_working_on_nodes, instantly when the last agent session disconnects (the hub pushes agent head-counts to the tab), or via a 120 s idle backstop for agents that stay connected but silently move on. The human can also end it: the editor's Home button, pressed while the shimmer is live, confirms and then ends every agent session — the hub fails in-flight calls with "The human ended this agent session from the editor." and closes agent sockets (code 4002). A session ended this way reconnects on its next request; treat the error as "stop this task", not "the bridge is broken". - Pages are no boundary. Ids resolve across every page of the document: reads mix pages freely (nodes not on the human's page carry a
page: {id, name, active: false}field), and writes work on any page — the human's canvas stays wherever they are, and the folios panel marks pages being crafted with a shimmer + pulsing dot (the panel-side face of the canvas shimmer). Constraints that keep this sane: one write call edits one page (ids that span pages are an atomic error; each call is one entry in that page's own undo history), the id-less creators (create_box,write_html,get_tree_summary) take apageparam (id or unambiguous name), andselect_nodes {reveal: true}is the only call that switches the human's page — background-page geometry is still measured truth (a hidden, unpainted stage mounts those pages in the live tab), and background-page screenshots render through the headless fallback automatically. - The human is watching. Every tool call paints in the live tab immediately, so an agent session is a performance by default: new canvas-root content lands in the human's current view when
x/yare omitted (sliding right past any existing roots so it never covers their work), big builds should go shell-first then section-by-section (write_htmlwithtargetId— each call one undo step), andselect_nodes {reveal: true}pans the camera to finished work. The server ships these habits as MCPinstructionsat initialize, so any connecting agent gets the workflow without per-project setup.
Tool reference
Parameter names are strict: a call with a param the tool doesn't declare is a tool error naming the bad key and listing the valid ones. (A typo'd parentId for targetId, silently dropped, once sprayed nine page sections across the human's canvas as top-level roots — never again.)
Orient & read
| Tool | Arguments | Returns |
|---|---|---|
ping | — | canvas name, current page, page count, node count — the health check |
get_canvas_info | — | canvas overview: pages plus the current page's top-level nodes with measured sizes; call first to orient (get_book_info and get_document_info remain as deprecated aliases) |
get_tree_summary | nodeId?, maxDepth?, page? | compact indented text, one line per node (id, name, kind, size); the cheapest structural read — deep levels truncate with an explicit … +N descendants; page lists a whole non-current page |
get_selection | — | the human's current selection with full details; use when they say "this" |
get_node_info | ids | per node: type, name, layout, flex/grid config, children, content, the measured worldRect, and style.rawCss/style.disabledCss when the node carries raw-CSS declarations |
get_computed_styles | ids, properties? | exact getComputedStyle values (curated layout/paint/typography set by default) |
get_compass_info | nodeId | Compass's own box-redline + typography readout for a subtree — measured padding/gaps, material facts, per-text font readouts/cap-height/x-height/characters-per-line — computed fresh regardless of whether the human has the I/T lenses toggled on. Also guides: each page guide with signed guide − edge distances (world px) to the inspected root's start/center/end — 0 = flush. Active page only. |
get_guides | page? | the page's alignment guides {id, axis, position}, sorted axis-then-position: "v" pins a world X (vertical line), "h" a world Y — compare against worldRect to align or read layout rhythm |
Edit
All edit tools are batch, atomic, and exactly one undo step each.
| Tool | Arguments | Notes | |||||||
|---|---|---|---|---|---|---|---|---|---|
create_box | name?, parentId?, index?, layoutMode?, layout?, style?, flex?, grid?, page? | new container div; default parent is the canvas root; canvas roots with layout.x/y omitted land in the human's current view (background page: clear of existing content); returns the node with its measured rect | |||||||
update_styles | nodes: [{id, style?, layout?, flex?, grid?, textStyle?, clipsContent?}] | model-schema styling; colors as #rrggbb[aa] or {r,g,b,a}; layout.position: "absolute" opts a child out of its flex/grid parent's flow; `layout.mode: "flex" \ | "grid" switches a box's layout mode in place (to-grid seeds default tracks; "absolute" stays editor-only — it needs measured child pinning); layout.constraints: {h?, v?} sets per-axis resize anchors on absolutely-positioned children ({mode: left\ | right\ | both\ | center\ | scale, inset?\ | offset?\ | pct?}, null` clears an axis; omit the value to anchor at the current spot — see the constraints guide; x/y on an anchored axis convert to its canonical field, and percent dims require a Scale axis) |
move_nodes | moves: [{id, x?, y?, parentId?, index?, before?, after?}] | reposition (absolute contexts), reparent (ids preserved), or order-precise placement via a sibling id | |||||||
set_text_content | texts: [{id, text}] | text nodes only | |||||||
rename_nodes | renames: [{id, name}] | ||||||||
duplicate_nodes | ids | deep clone; returns newIds plus the full old→new idMap so clones' internals stay addressable | |||||||
delete_nodes | ids | node + descendants | |||||||
set_visibility | ids, visible | idempotent | |||||||
set_locked | ids, locked | idempotent | |||||||
add_guides | guides: [{axis, position}], page? | add alignment guides (world px); the human's box drags/resizes snap to them immediately (unless they've hidden guides with ⇧G — hidden guides don't render or snap in that tab, but all guide tools still work); returns the created guides with minted ids | |||||||
move_guides | `moves: [{id, position?}\ | {id, delta?}]` | exactly one of position/delta per move; one call edits one page (guide ids resolve across pages) | ||||||
delete_guides | ids | atomic — any unknown id applies nothing | |||||||
select_nodes | ids, reveal? | hands focus back to the human ("I changed these"); not an undo step. reveal: true also pans/zooms the camera to fit the selection (no-op when already fully visible), switching to the nodes' page first when needed — for presenting finished work, not every edit. Nodes on another page require reveal: true (without it the call errors instead of selecting where the human can't see) |
Import & export — the web-standard loop
| Tool | Arguments | Notes |
|---|---|---|
write_html | html, mode?, targetId?, index?, x?, y?, page? | the main way to create designs: inline-styled HTML becomes fully editable nodes — divs/sections → boxes with genuine flex/grid config, text-only elements → text nodes, <img> → image, <svg> → svg node (sanitized verbatim markup, monochrome paints normalized to currentColor). Unmapped CSS is preserved as rawCss (rendering stays faithful) and reported in warnings. mode: "insert-children" (default) appends into targetId or the canvas root (x/y optional — default is the human's current view); "replace" swaps targetId's children. One undo step for the whole import. |
get_component | nodeId, framework? | the subtree as standalone code, riding the exact CSS mapping the editor renders with. framework: "html" (default) is a plain markup fragment with inline style="" attributes; "react" a function component with camelCase style objects; "solid" a function component with dash-case style objects (Solid's dialect — camelCase keys would silently not apply). Canvas position is stripped from the root so the output flows in a host layout. Returns {framework, componentName, code}. |
embed_images | ids | the durability offramp: every http(s) image in the subtrees — image fills and image nodes — is downloaded by the server (no CORS limits) and inlined as a data: URI, so the design survives the source site removing the asset, works offline, and copies between documents intact. One fetch per distinct URL; one undo step for the whole embed; failures (404, non-image content-type, >8MB) leave their srcs untouched and are itemized in the reply. Returns per-URL {mimeType, bytes, occurrences} plus alreadyEmbedded/notFetchable counts and documentBytesAdded — never the data URIs themselves. An explicit step for designs worth keeping, not a routine post-import pass (~4/3 of the image bytes lands in the document JSON per occurrence). |
Generate HTML, write_html it, refine with update_styles/move_nodes, then hand back get_component when the human wants code — the default html round-trips: get_component output is valid write_html input. Imported designs reference images by their original URLs; run embed_images only when the human wants the document to outlive the source site.
Look
| Tool | Arguments | Notes |
|---|---|---|
get_screenshot | nodeId, scale? (1 or 2) | chrome-free PNG of one node — selection, guides, and the Crafting… shimmer are hidden; the camera pans the node into view and restores after. Two capture paths: see below. |
Live-tab path (exact pixels, fast). Used when the editor tab lives in a Chrome started with remote debugging on the instance's debug port — the web port + 6333, so 9333 for the default port 3000:
open -a "Google Chrome" --args --remote-debugging-port=9333
Chrome 136+ blocks DevTools on the default profile, so a normally-launched browser never qualifies — that's what the fallback is for.
Headless fallback (any browser, ~2–4 s slower). When the live tab is unreachable over the debug port — or the node lives on a page the human isn't viewing (the live tab can't photograph the hidden measurement stage) — the server pulls the canvas through the bridge, re-renders it in a throwaway headless Chrome served by the same Centina process with that page active, and clips the same screenshot there. Same engine, same bundle, same fonts — and the human's session is untouched: a capture tab never dials the bridge (newest-tab-wins is not disturbed) and never reads or writes the autosave. The fallback needs headless Chrome available on the serving machine; the editor tab merely needs to exist in any browser.
Presence
| Tool | Arguments | Notes |
|---|---|---|
finish_working_on_nodes | ids? | clears the "Crafting…" shimmer (all nodes when omitted); call after finishing a batch of edits |
Troubleshooting
- "Centina at http://localhost:… isn't running" — the named instance's server is down. Start it:
centina serve(add--portif the reference names a non-default port). - "No editor tab is open at http://localhost:…" — the server runs but no tab is attached to its bridge. Open the URL (the page only connects on
localhost/127.0.0.1), or you opened a newer tab that took over and later closed it: reload the tab you want. - "ids name N different Centina instances" — one call routes to one editor tab; split the call per instance.
- "the … port is already in use" at
centina servestartup — another Centina (or another app) owns the web or bridge port; stop it or pick another base port (centina serve --port 4500). - Screenshots black or wrong region — the node was offscreen and the pan failed to settle; re-run. File it if it reproduces.
- Style writes rejected —
update_stylesspeaks the model schema (fills,radius,layout.width: {mode}…), not CSS property names. Errors list every offending path; nothing was applied. TherawCssgroup is the escape hatch for arbitrary CSS — but its values are validated by the rendering browser (CSS.supports), so a declaration Chrome can't parse is rejected rather than silently rendering nothing (custom--*props exempt).