9. The `ud` CLI and the MCP server
Details of every command and tool are in CLI-001.
Esta página está em inglês; a tradução está a caminho.
Details of every command and tool are in CLI-001. This section is about how they are built.
9.1 One tool table#
src/mcp/tools.ts is the single table of Figma tools and native tools:
interface ToolDef {
name: string; // "get_metadata" — Figma's name; "ud_render" for native tools
cliAliases: string[]; // ["get-metadata"]
inputSchema: JsonSchema; // Figma's schema, plus optional extras
description: string; // Figma's text adapted, plus UI Designer notes
licence: "read" | "free" | "paid";
since: string;
unsupported?: { hint: string }; // CMD-6: exit 6, not listed by ud mcp
lmTool?: boolean; // registered with vscode.lm (AI-12)
handler(ctx: ToolContext, args: unknown): Promise<ToolResult>; // MCP content array
}From this table are generated: the ud <tool> argument parser (flags from the schema’s properties, --json
for the whole object), ud help <tool> and ud schema <tool>, the MCP tools/list, the
contributes.languageModelTools entries in package.json (§15.3), and the tool reference of the docs site.
Native verbs (ud tree, ud inspect, ud render, …) are thin wrappers over the same handlers or over
registry commands.
Name resolution of ud <word>: a native verb first, then a Figma tool or alias, then a registry command id
(they contain a dot, so they cannot collide with tool names). ud run <id> is unambiguous.
9.2 The Figma tool schema snapshot#
test/fixtures/figma-mcp-tools.json is the tools/list of Figma’s MCP server, refreshed by
scripts/refresh-figma-tools.mjs (connects to the server with the maintainer’s credentials and writes the file
sorted and pretty-printed). test/mcp/figma-schema.test.ts (CMD-8) fails when, for any tool not marked
unsupported:
- the tool is missing from our table;
- a property of Figma’s schema is missing, renamed, or of another
type/enum; - a property required by Figma is optional for us, or the reverse.
Extra optional properties on our side pass. A Figma tool absent from our table and not marked unsupported
also fails, so a new Figma tool is a conscious decision.
9.3 The MCP server#
ud mcp is a stdio JSON-RPC server (MCP 2025-06-18) implemented in src/mcp/server.ts (initialize,
tools/list, tools/call, resources/list, resources/read, prompts/list, prompts/get, progress and
cancellation notifications). It owns no document: each call resolves fileKey, then goes through the bridge or
headless exactly as the CLI does. Resources: the design-system digest (ud system), the Plugin API coverage,
the skills (skill://ui-designer/…). Prompt: create_design_system_rules (CMD-7). Errors are tool results with
isError: true and the exit-code name in the text (CMD-30).
Warm state (ADR-9). Because ud mcp is a long-running process that answers many calls against the same
file, it keeps three caches that a one-shot ud cannot: the folded DocumentState of each headless file,
keyed by the file’s path, size and mtime (re-read and re-folded only when either changed, and re-checked under
the lock before every mutation); one warm QuickJS runtime per file, reset between scripts, so use_figma pays
module instantiation once; and the derived caches of §4.5. Layout is lazy: a read tool lays out the
subtree it asks for (get_metadata --nodeId) and its hug ancestors, never the whole document. It also hosts the
asset server of §9.6 for the lifetime of the process.
9.4 Bridge or headless#
ud <anything> ──► resolve fileKey ──► bridge registry ──► live editor answers handled: true?
│ yes: send the request; the host runs it
│ no / none: headless
▼
HeadlessDocument: lock, read, run, write, unlockBridge (as in the sibling products): each VS Code window running the extension writes
{pid, socket, token, startedAt, protocol} into a registry folder only the user can read (mode 0700, entries
0600): UD_BRIDGE_DIR, else $XDG_RUNTIME_DIR/ui-designer, else <tmp>/ui-designer-<uid>. The socket is a
Unix socket there, or a named pipe on Windows. The wire is one JSON object per line each way; every request
carries the token. Requests:
| Request | Does |
|---|---|
ping |
is this file open here? (canonical real path, case-folded on Windows and macOS) |
tool |
run a tool of the table in the host: {name, args, actor, proposal?} — the CLI sends the whole call, so a read is one round trip (CMD-31) |
run / apply |
registry command or transaction |
selection / select |
read or set the editor’s selection (CMD-26) |
watch |
stream committed events as JSON lines (CMD-37) |
A tool runs on the live document, including unsaved edits. A mutation on a document that had no unsaved changes is saved after it, so the file on disk keeps up; a dirty document stays dirty. The status bar shows the agent (UI-7); its Stop button makes the host refuse further requests from that actor for the rest of the run.
Headless (src/headless): the file is locked with .<name>.design.lock (exclusive create; wait up to 8 s;
a lock older than 30 s or of a dead process is taken over), read with the same tolerant reader, edited through
the same registry, and written atomically (temporary file renamed over the original). Events are appended to
the WAL; consolidation follows FMT-001’s rule. Assets are written into <name>.design.assets/ before the
events that reference them.
ud where prints which path a command would take, the file, the actor (CMD-29) and the licence view.
9.5 fileKey and nodeId resolution#
fileKey (CMD-2), in order: a path (absolute, or relative to the current directory, then to the workspace
root); a ud://<path>.design?node-id=… link; a document id doc-… (looked up among open editors through the
bridge, then among .design files under the current directory); omitted → the only design open in the
editors, else the only .design file in the current folder; else exit 2 with the candidates. A figma.com URL
is refused with a hint to ud import figma.
nodeId (CMD-3): nd-<hex> or nd:<hex> (normalised to -); the node-id query parameter of a ud://
link; a name path from the page (Home/Header/Logo), with \/ escaping a slash in a name. Composite ids of
instance sublayers are accepted in the same two spellings.
9.6 Asset server#
get_screenshot, download_assets and upload_assets (CMD-4, CMD-5) keep Figma’s shape: a rendered image or
an asset slot is handed to the agent as a URL, so Figma’s skills work unchanged. Nothing listens on the network
permanently; the URL is served by a loopback asset server (src/mcp/assets-server.ts) that exists only while
a process that can answer it exists:
| Who serves | When | Lifetime |
|---|---|---|
| The extension host | the call came through the bridge | while the window is open |
ud mcp |
headless, inside the MCP server process | while ud mcp runs |
| nobody | a one-shot headless ud get_screenshot or ud download_assets from a terminal |
the tool writes the file and returns path with url: null; upload_assets headless needs --paths and returns no POST URLs |
The server is Node’s http on 127.0.0.1 with an ephemeral port, started on the first URL handed out and
stopped when its process ends. Each URL is http://127.0.0.1:<port>/a/<token> with a 128-bit random token
(crypto.getRandomValues), single use (the entry is deleted after the first successful response; a
download_assets call issues one URL per asset), and a 10-minute lifetime after which it answers 404. For
upload_assets the token is a PUT slot: the body is written to a temporary file, hashed, checked for size and
type, and only then moved into the assets folder and referenced by asset.add through the registry.
Hardening (also listed in §13): bind 127.0.0.1 only, never 0.0.0.0 or ::; the Host header must be
exactly 127.0.0.1:<port>, else 400 (DNS rebinding); any request with an Origin header is refused with 403,
so a browser page cannot fetch or post to it even on the same machine; bodies are capped at 10 MB and the
connection is closed at the cap; only GET (and PUT on upload slots) is accepted; responses carry
Cache-Control: no-store and X-Content-Type-Options: nosniff; a failed token is answered after a constant
delay and the server stops after 20 failures in a minute until the next URL is issued. The port is never
written anywhere but into the URLs themselves.