# MCP server — let an AI build designs
The `crafted-design` **MCP server** (the bin's `mcp` subcommand) is a
[Model Context Protocol](https://modelcontextprotocol.io)
server that exposes the editor's component registry and document model as
tools. An AI client (Claude Code, Claude Desktop, any MCP client) can use it to
**author and edit editor documents** — producing the same `EditorDocument`
JSON the editor loads, the `` renders, and you ship.
It builds on the headless API ([`SDK_GUIDE`](./SDK_GUIDE.md) →
`@crafted-design/editor/headless`): the agent works against an in-progress
document with no browser and no editor running.
## Install
The server ships as a `bin` on the package. Its only extra dependency is the
MCP SDK, an **optional peer** (the editor itself doesn't need it):
```bash
npm install @crafted-design/editor @modelcontextprotocol/sdk
```
For the `render_image` tool (and the exact, in-browser `check_contrast`), also
install Playwright + a browser — both are optional; the rest of the server
works without them:
```bash
npm i -D playwright && npx playwright install chromium
```
## Connect a client
**Claude Code:**
```bash
claude mcp add crafted-design -- npx -y @crafted-design/editor mcp
```
**Claude Desktop** (`claude_desktop_config.json` → `mcpServers`):
```jsonc
{
"mcpServers": {
"crafted-design": {
"command": "npx",
"args": ["-y", "@crafted-design/editor", "mcp"]
}
}
}
```
(Both run the `crafted-design` bin's `mcp` subcommand over stdio. If the MCP
SDK isn't installed, it prints an install hint and exits.)
## Other MCP clients
Nothing here is Claude-specific — this is a standard **stdio** MCP server, so
any MCP client registers it with the *same command and args*:
```
command: npx args: ["-y", "@crafted-design/editor", "mcp"]
```
(Before the package is published, point at the built bin instead:
`command: node`, `args: ["/dist-lib/cli.js", "mcp"]`.) Only the config file and
its key differ per client:
| Client | Where | Key |
|---|---|---|
| **VS Code** (Copilot agent) | `.vscode/mcp.json` | `servers` |
| **Cursor** | `.cursor/mcp.json` (or `~/.cursor/mcp.json`) | `mcpServers` |
| **Windsurf** | `~/.codeium/windsurf/mcp_config.json` | `mcpServers` |
| **Cline / Roo** (VS Code) | extension "MCP Servers" settings | `mcpServers` |
| **Continue.dev** | `~/.continue/config.yaml` | `mcpServers:` |
| **Zed** | `settings.json` | `context_servers` |
| **Gemini CLI** | `~/.gemini/settings.json` | `mcpServers` |
Most use the JSON shape:
```jsonc
{
"mcpServers": {
"crafted-design": {
"command": "npx",
"args": ["-y", "@crafted-design/editor", "mcp"]
}
}
}
```
**Codex** (OpenAI CLI) uses **TOML**, with a snake_case key — `~/.codex/config.toml`:
```toml
[mcp_servers.crafted-design]
command = "npx"
args = ["-y", "@crafted-design/editor", "mcp"]
```
(or `codex mcp add crafted-design -- npx -y @crafted-design/editor mcp`).
**Custom agents / frameworks** connect programmatically — no config file. Pass
the same stdio command to an MCP client SDK or an agent framework's MCP
adapter:
```python
# OpenAI Agents SDK
from agents.mcp import MCPServerStdio
server = MCPServerStdio(params={
"command": "npx",
"args": ["-y", "@crafted-design/editor", "mcp"],
})
```
```ts
// Raw MCP TypeScript SDK
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
const transport = new StdioClientTransport({
command: 'npx',
args: ['-y', '@crafted-design/editor', 'mcp'],
})
```
The same works with LangChain (`langchain-mcp-adapters`), LlamaIndex,
Pydantic-AI, and others.
> **Transport:** the server speaks **stdio** (every desktop / CLI client
> supports it). A client that needs HTTP/SSE can't connect directly — open an
> issue if you need a streamable-HTTP transport.
### Declaring template variables
To let the agent insert merge tokens (`{{ contact.name }}`), set the
`CRAFTED_DESIGN_TEMPLATE_VARIABLES` env var to a JSON array of
`{ key, label?, group?, sample? }` — the same shape the editor's
`EditorTemplateVariablesProvider` takes (see the
[Integration Guide](./INTEGRATION_GUIDE.md#template-variables-190)). The agent
discovers them via `list_template_variables`; `get_capabilities` nudges it to
use tokens. Invalid JSON is ignored (the server starts with no variables).
```jsonc
{
"mcpServers": {
"crafted-design": {
"command": "npx",
"args": ["-y", "@crafted-design/editor", "mcp"],
"env": {
"CRAFTED_DESIGN_TEMPLATE_VARIABLES": "[{\"key\":\"contact.name\",\"label\":\"Full name\",\"group\":\"Contact\",\"sample\":\"Jane Doe\"}]"
}
}
}
}
```
## The workflow
Call `get_capabilities` first — it returns this in-band. The shape:
1. **Discover** — `list_canonicals` (every component, container vs leaf vs
multi-canvas) and `describe_canonical` (full props **JSON Schema**,
defaults, slots, panels).
2. **Start** — `create_document` (root is a Box canvas), or `apply_template` /
`load_document`.
3. **Build** — `add_node` returns the new node's id; address later edits by it.
- Pattern A containers (box, stack, section): pass `parentId`.
- Pattern B (card, tabs, table): pass `parentId` **and** `slot` (see
`describe_canonical` → `canvasSlots`).
4. **Refine** — `update_node_props`, `update_node_style`, `move_node`,
`remove_node`.
5. **See it** — `render_image` (a PNG you can look at), `outline_document`
(cheap text tree), or `render_html` (structure-faithful HTML).
6. **Check colors** — `theme_palette` (the theme's pairs) + `check_contrast`
(per text node, worst-first) so you don't ship illegible text.
7. **Finish** — `validate_document`, then `get_document` for the
`EditorDocument` JSON.
Every mutating tool returns the validation status + a fresh outline, so the
model stays oriented. Bad input (unknown canonical, schema violation, missing
node) comes back as a recoverable tool error, not a crash.
## Tool catalog
| Tool | What it does |
|---|---|
| `get_capabilities` | The workflow + tool order (read first). |
| `list_canonicals` | All components: id · category · container/leaf/slots. |
| `describe_canonical` | One component: props JSON Schema, defaults, slots, panels. |
| `list_adapters` / `list_themes` / `list_templates` | Registered design systems / themes / templates. |
| `list_template_variables` | Host-declared merge variables; insert any as a `{{ key }}` token in a text prop. |
| `create_document` | Fresh document (adapter / theme / colorMode / root). |
| `apply_template` | Load a registered starter template. |
| `add_node` | Add a canonical under a parent (or a Pattern B `slot`); returns its id. |
| `update_node_props` | Merge a props patch (schema-checked). |
| `update_node_style` | Merge Tailwind classes per style slot. |
| `move_node` | Reparent (slot/index); cycle-safe. |
| `remove_node` | Delete a node + subtree (ROOT / slot containers protected). |
| `set_adapter` / `set_theme` | Set the document's design system / canvas theme. |
| `outline_document` | Compact id · canonical tree. |
| `render_html` | Static structural HTML preview. |
| `render_image` | A PNG screenshot of the design (needs Playwright). |
| `theme_palette` | The theme's token colors + WCAG ratios for key pairs. |
| `check_contrast` | Per-text-node contrast + grade, worst-first. |
| `validate_document` | Structural + semantic issues. |
| `get_document` | The full `EditorDocument` JSON. |
| `load_document` / `reset_document` | Replace from JSON / start over. |
Resources: `craft://document.json` (the live envelope) and
`craft://preview.html` (its HTML preview).
## A worked example
> **Prompt:** "Build a pricing hero — a headline, a subheading, and a card with
> a plan name and a Subscribe button."
A capable agent runs roughly:
```
create_document { adapterId: "shadcn" }
add_node { parentId: "ROOT", canonical: "heading",
nodeProps: { content: "Simple, honest pricing" },
classes: { root: "text-4xl font-bold" } } → heading-1
add_node { parentId: "ROOT", canonical: "text",
nodeProps: { content: "One plan. Everything included." } } → text-1
add_node { parentId: "ROOT", canonical: "card" } → card-1
add_node { parentId: "card-1", slot: "header", canonical: "heading",
nodeProps: { content: "Pro", level: "3" } } → heading-2
add_node { parentId: "card-1", slot: "footer", canonical: "button",
nodeProps: { label: "Subscribe" } } → button-1
render_image # SEE it
check_contrast # is the text legible?
get_document # → EditorDocument JSON
```
The resulting JSON drops straight into the editor or the renderer:
```tsx
import { DocumentRenderer } from '@crafted-design/editor/renderer'
```
## Seeing colors & contrast
Structure tools (`outline_document`, `render_html`) tell the agent *what* it
built, not *how it looks*. Three tools close that gap:
- **`render_image`** → a PNG, rendered by a persistent headless page that
mounts the real `` through the document's design system
(the same output a host ships). The multimodal client sees it inline.
Requires Playwright (optional); without it the tool returns a hint.
- **`theme_palette`** → the theme's token colors with WCAG ratios for the key
pairs (body / muted / card text, primary / secondary / accent buttons). No
browser needed.
- **`check_contrast`** → every text node's foreground/background + ratio +
grade, worst-first. Exact (in-browser computed styles) when Playwright is
installed; a deterministic token-based report otherwise — which flags nodes
using literal/arbitrary colors as `indeterminate` (verify those with
`render_image`).
The loop: **build → `render_image` (look) → `check_contrast` (measure) → fix
the failing nodes → look again.**
> Fonts: offline renders may substitute glyphs, but color, contrast, spacing,
> and layout — what these tools are for — are faithful.
### Verifying the Playwright tools
`render_image` needs three things present: the MCP SDK, Playwright, and the
render harness (`dist-lib/harness/`, produced by the build). Quick checklist:
```bash
npm i -D @modelcontextprotocol/sdk playwright
npx playwright install chromium # the browser render_image drives
npm run build:dist # builds dist-lib/cli.js + mcp.js AND dist-lib/harness/
```
(For a published install via `npx -y @crafted-design/editor mcp`,
the harness already ships in the package — only the SDK + Playwright steps
apply.)
Then drive the built server over stdio and confirm a real PNG comes back —
from the package root so the SDK resolves:
```js
// smoke.mjs — node smoke.mjs
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'
const t = new StdioClientTransport({ command: 'node', args: ['dist-lib/cli.js', 'mcp'] })
const c = new Client({ name: 'smoke', version: '1' })
await c.connect(t)
await c.callTool({ name: 'create_document', arguments: { adapterId: 'shadcn' } })
await c.callTool({
name: 'add_node',
arguments: { parentId: 'ROOT', canonical: 'heading', nodeProps: { content: 'Hello' } },
})
const img = await c.callTool({ name: 'render_image', arguments: {} })
console.log(img.content[0].type, img.content[0].mimeType, 'b64 bytes:', img.content[0].data?.length)
console.log((await c.callTool({ name: 'check_contrast', arguments: {} })).content[0].text.split('\n')[0])
await c.close()
```
Expected: `image image/png b64 bytes: ~10000` and a `check_contrast` line that
begins **`exact (rendered)`** (proving the in-browser audit engaged). If you
instead see a "render_image needs Playwright" message, Playwright isn't
installed; a harness error means the build didn't include `dist-lib/harness/`
(use `npm run build:dist`, not a partial build).
**Troubleshooting**
- *Headless Linux / CI:* chromium may need system libraries —
`npx playwright install-deps chromium` (requires root). The server launches
with `--no-sandbox` already.
- *First call is slow (~1–2s):* the browser launches lazily on the first
`render_image`, then is reused for the server's lifetime — later renders are
fast.
- *Renders are hermetic:* the page is network-blocked except the loopback
harness, so no external fetches; web fonts may be substituted (colors and
layout are unaffected).
## What it is and isn't
- **`render_image` is structure + style faithful, not a design mockup** — it's
exactly what `` produces. `render_html` is the lighter,
no-browser structural view (real DOM + classes, no resolved colors).
- **Stateless across sessions.** The server holds one in-progress document per
process; persisting it is the host's job (`get_document` → your storage /
`StorageAdapter`).
- **Adapter-independent build.** Documents are canonical-id based; the agent
can target any registered adapter via `set_adapter`. `render_html` always
previews through the dependency-free HTML adapter for reliability.