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:

ts
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#

text
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, unlock

Bridge (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.