@design-twin/dtwin 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +959 -2
  3. package/figma-plugin/code.js +4207 -0
  4. package/figma-plugin/manifest.json +28 -0
  5. package/figma-plugin/ui.html +686 -0
  6. package/layer/cross-check.js +2746 -0
  7. package/layer/drift-lint.js +1526 -0
  8. package/layer/get-component.js +553 -0
  9. package/layer/map-bootstrap.js +1404 -0
  10. package/layer/map-validate.js +397 -0
  11. package/package.json +53 -4
  12. package/src/abort-reason.ts +12 -0
  13. package/src/asset-compare.ts +175 -0
  14. package/src/asset-owners.ts +280 -0
  15. package/src/atomic-write.ts +79 -0
  16. package/src/bridge-holder.ts +294 -0
  17. package/src/cli-commands.ts +427 -0
  18. package/src/client-presence.ts +146 -0
  19. package/src/clock.ts +45 -0
  20. package/src/commands.ts +561 -0
  21. package/src/daemon-auth.ts +174 -0
  22. package/src/daemon.ts +788 -0
  23. package/src/delivery.ts +85 -0
  24. package/src/design-system-layout.ts +232 -0
  25. package/src/doc-types.ts +1054 -0
  26. package/src/doctor.ts +602 -0
  27. package/src/errmsg.ts +14 -0
  28. package/src/export-reader.ts +409 -0
  29. package/src/export-verdict.ts +258 -0
  30. package/src/figma-mcp.ts +942 -0
  31. package/src/figma-pull.ts +1265 -0
  32. package/src/file-lock.ts +268 -0
  33. package/src/file-memo.ts +62 -0
  34. package/src/hash.ts +6 -0
  35. package/src/hex-color.ts +39 -0
  36. package/src/human-ms.ts +7 -0
  37. package/src/init.ts +424 -0
  38. package/src/instance-overrides.ts +7 -0
  39. package/src/json-file.ts +79 -0
  40. package/src/json-size.ts +108 -0
  41. package/src/json-util.ts +51 -0
  42. package/src/library-layout.ts +193 -0
  43. package/src/node-id.ts +69 -0
  44. package/src/pages-layout.ts +483 -0
  45. package/src/pending-requests.ts +187 -0
  46. package/src/plugin-frames.ts +32 -0
  47. package/src/png.ts +196 -0
  48. package/src/ports.ts +25 -0
  49. package/src/project-layout.ts +189 -0
  50. package/src/quick-keys.ts +68 -0
  51. package/src/read-json.ts +67 -0
  52. package/src/read-opts.ts +125 -0
  53. package/src/read-plan.ts +105 -0
  54. package/src/read-run.ts +119 -0
  55. package/src/route.ts +243 -0
  56. package/src/seed-components.ts +229 -0
  57. package/src/served-session.ts +347 -0
  58. package/src/server-core.ts +1093 -0
  59. package/src/settings.ts +156 -0
  60. package/src/source-stamp.ts +16 -0
  61. package/src/staleness.ts +63 -0
  62. package/src/stdio.ts +156 -0
  63. package/src/svg-normalize.ts +205 -0
  64. package/src/timeouts.ts +76 -0
  65. package/src/timings.ts +118 -0
  66. package/src/token-store.ts +261 -0
  67. package/src/variables-merge.ts +337 -0
  68. package/src/verbs.ts +140 -0
  69. package/src/write-out.ts +1255 -0
@@ -0,0 +1,942 @@
1
+ #!/usr/bin/env -S bun --no-install --no-env-file
2
+ // figma-mcp — WRITE plane (also exposes read tools).
3
+ // Exposes an MCP server to Claude Code over STDIO, and internally hosts the localhost WebSocket
4
+ // bridge the Figma plugin connects to. Use for code -> design (interactive authoring). For bulk
5
+ // reads prefer the dtwin CLI (files on disk).
6
+ //
7
+ // Run via .mcp.json: { "mcpServers": { "designtwin": { "type":"stdio",
8
+ // "command":"<abs path to bun>", "args":["--no-install","--no-env-file","<abs path>/bridge/src/figma-mcp.ts"] } } }
9
+ // — Bun runs the TypeScript source directly, for an npm install and a repo checkout alike (the package
10
+ // ships src/; there is no build). `dtwin init --mcp` writes exactly that entry, naming the bun that ran
11
+ // it and the figma-mcp.ts beside it; the two flags are the shebang's (figma-pull.ts says why).
12
+ //
13
+ // ESM TypeScript like the rest of bridge/src/ (the MCP SDK is ESM-only). The bridge core and the other
14
+ // bridge modules are imported directly, typed by their own source.
15
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
17
+ import { z } from "zod";
18
+ import fs from "node:fs";
19
+
20
+ // The bridge core. `request`'s `timeoutMs` (3rd) is server-core's default-120s budget: exports on a
21
+ // real design-system file routinely run longer than that — the dtwin CLI scales its own default
22
+ // for the same reason — so the export tools below pass an explicit, larger budget rather than
23
+ // inheriting a default tuned for small commands like ping/getSelection. `target` (4th) selects WHICH
24
+ // connected Figma file the command goes to — a connId, a fileKey, or part of the file name. Omitted
25
+ // with one file connected, required with several (the bridge refuses rather than guessing; see
26
+ // server-core's resolveClient).
27
+ import { createBridge } from "./server-core.ts";
28
+ import { createRoute } from "./route.ts";
29
+ import type { RouteRequest, Upstream } from "./route.ts";
30
+ import { TIMEOUTS, NAMED_CLIENT_WAIT_MS } from "./timeouts.ts";
31
+ import { errMsg } from "./errmsg.ts";
32
+ import { runRead, tickMessage } from "./read-run.ts";
33
+ import type { ReadRun } from "./read-run.ts";
34
+ import type { ConnectionInfo, ProgressTick } from "./server-core.ts";
35
+ import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
36
+ import type { ServerNotification, ServerRequest } from "@modelcontextprotocol/sdk/types.js";
37
+ // The command/reply contract shared with the plugin (commands.ts): every route request below is
38
+ // typed per command from it, so a tool cannot read a reply field the plugin never sends.
39
+ import { WRITE_OP_NAMES, writeOpPreview, type Cmd, type Commands, type ExportReply, type WriteOp } from "./commands.ts";
40
+ // TIMEOUTS is the per-command budget table shared with the dtwin CLI. Every EXPORT tool needs a
41
+ // budget well above server-core's 120s default (tuned for ping/getSelection, not for a walk of every
42
+ // node on a page) — a whole-file walk is the slow case, but a single big frame is the same shape of
43
+ // work, and inheriting the small default would make figma_export_url die at 120s on exactly the
44
+ // screen you most wanted. An export's tier is the read plan's (read-plan.ts, below), so this front-end
45
+ // cannot restate (or omit) a tier the CLI has.
46
+ // What an export tool SENDS — command, arguments, budget — is the read plan's (read-plan.ts), the same one
47
+ // the CLI sends; each tool's declared read options come from the scopes in the same registry rows.
48
+ import { planRead, readOptsFor } from "./read-plan.ts";
49
+ import type { PlanOf, ReadCmd, ReadPlan, ReadRefusal, ReadScopeKind } from "./read-plan.ts";
50
+ import { ifDefined } from "./json-util.ts";
51
+ import type { ReadOptNameFor } from "./read-opts.ts";
52
+ import { parseNodeId, toNodeId } from "./node-id.ts";
53
+ import { HEX_COLOR_RE, HEX_COLOR_HINT } from "./hex-color.ts";
54
+
55
+ // WHO holds the bridge. Port 8787 has one owner at a time, and this server does not insist on being it: with
56
+ // `dtwin serve` running, or a second Claude Code session open, it routes through that process's daemon socket; when
57
+ // it opens the bridge itself, it publishes it there so the NEXT session (or a `dtwin pull`) shares it instead of
58
+ // fighting for the port — and takes the bridge over when its owner goes. All of that is the bridge holder's
59
+ // "session" lifetime (bridge-holder.ts); this file only tells it when the client has left or a signal says stop.
60
+ import { bridgeHolder, daemonSide } from "./bridge-holder.ts";
61
+ import type { Holder } from "./bridge-holder.ts";
62
+ import { readSettings } from "./settings.ts";
63
+ import * as daemon from "./daemon.ts";
64
+ import { exitFlushed, installQueuedConsole } from "./stdio.ts";
65
+ import { clientPresence, processIO } from "./client-presence.ts";
66
+ // The holder, made on first use from the settings this process was started with (startServer reads them first and
67
+ // exits on a bad port; a suite that loads this module in process has set its environment before the first call).
68
+ let holder: Holder | null = null;
69
+ function session(): Holder {
70
+ if (holder) return holder;
71
+ const r = readSettings();
72
+ if (!r.ok) throw new Error(r.problem);
73
+ holder = bridgeHolder("session", { settings: r.settings, createBridge, daemon: daemonSide, log: wlog });
74
+ return holder;
75
+ }
76
+ // Every tool reaches the plugin through ONE route (route.ts), over whichever side holds the bridge — this
77
+ // process or a daemon (holder above, re-resolved per call). The route owns the rules, the same ones the CLI
78
+ // gets: a call with no `client`, or a bare c<N> connId, is resolved against what is connected now; a NAMED
79
+ // client (file name / fileKey) waits up to NAMED_CLIENT_WAIT_MS for that file — every open plugin window
80
+ // redials on its own clock (timeouts.ts PLUGIN_REDIAL_MS), so right after this process opened the bridge only
81
+ // one of two open files may have landed yet (seen live) — and then resolveClient reports whatever IS connected.
82
+ // Only an export hears progress ticks (withProgress relays them as notifications/progress, MCP spec 2025-06-18,
83
+ // basic/utilities/progress; through a daemon they come as progress frames, which also re-arm the socket's outer
84
+ // guard). No stall check: a long-lived server's connection is warm. Every reply is checked against commands.ts
85
+ // on arrival, and says WHICH connected file answered (null from a daemon too old to relay it) — what the export
86
+ // tools stamp as `sourceFile` and whoami describes. `signal` is the tool call's own: the SDK aborts
87
+ // `extra.signal` on notifications/cancelled, and stdin closing aborts every in-flight call (callSignal below);
88
+ // the plugin then gets a cancel frame for the request, whichever side holds the bridge.
89
+ const route = createRoute(() => session().upstream(), { connectWaitMs: 0, oneShot: false, namedWaitMs: NAMED_CLIENT_WAIT_MS });
90
+ /** A request's reply alone. */
91
+ const ask = async <C extends Cmd>(cmd: C, args: Commands[C]["args"], o: RouteRequest): Promise<Commands[C]["reply"]> => (await route.request(cmd, args, o)).reply;
92
+ // The socket half of whoami for `connId` — the connection a request was answered by, not the first live one.
93
+ // Through a daemon `via` names the shared bridge; only a daemon too old to send its connection gets the 3-field
94
+ // stub, its missing fields reported as undefined, never guessed.
95
+ async function connectionInfo(connId: string | null): Promise<ConnectionInfo | (ConnectionInfo & { via: string }) | { via: string; port: number | undefined; pluginConnected: boolean | undefined } | null> {
96
+ const { connection, owner } = await route.connectionInfo(connId);
97
+ if (owner.kind === "own") return connection;
98
+ const via = "shared bridge (pid " + (owner.pid ?? "?") + ")";
99
+ if (connection) return { via, ...connection };
100
+ return { via, port: owner.port ?? undefined, pluginConnected: owner.pluginConnected ?? undefined };
101
+ }
102
+
103
+ // ---- result helpers ----
104
+ function textResult(obj: unknown): { content: [{ type: "text"; text: string }] } {
105
+ return { content: [{ type: "text" as const, text: typeof obj === "string" ? obj : JSON.stringify(obj, null, 2) }] };
106
+ }
107
+ // Takes the thrown value, not a pre-stringified message: the coercion lives once in server-core
108
+ // (`errMsg`) for the whole Bun side.
109
+ function errorResult(e: unknown) {
110
+ return { content: [{ type: "text" as const, text: errMsg(e) }], isError: true as const };
111
+ }
112
+
113
+ // Every tool handler needs the same "turn a throw into an isError result" wrapper — a tool that
114
+ // forgets it throws through the MCP transport instead. Applying it at registration makes that
115
+ // impossible to forget and collapses each handler to its one expression.
116
+ const guarded = <A extends unknown[], R>(fn: (...a: A) => Promise<R>) => async (...a: A) => {
117
+ try { return await fn(...a); } catch (e) { return errorResult(e); }
118
+ };
119
+
120
+ // Read results carry asset payloads (base64 PNG / SVG strings) that would dump megabytes of noise
121
+ // straight into the agent's context. Strip the payloads to a lightweight manifest — the figma-pull
122
+ // CLI is the path that actually writes asset bytes to disk.
123
+ /** An asset row with its bytes stripped: identity only. */
124
+ interface LightAsset { id: string; name: string; format: string; kind?: string }
125
+ function stripAssets(r: ExportReply): object {
126
+ if (!("assets" in r)) return r; // a catalog-only pull carries no assets at all
127
+ const light: Omit<typeof r, "assets"> & { assets: LightAsset[]; assetsNote?: string } =
128
+ { ...r, assets: r.assets.map((a) => ({ id: a.id, name: a.name, format: a.format, ...(a.kind ? { kind: a.kind } : {}) })) };
129
+ if (r.assets.length) light.assetsNote = "Asset bytes omitted from context — re-run with writeToDisk:true to write them to <outDir>/assets/.";
130
+ return light;
131
+ }
132
+
133
+ // writeToDisk is the answer to the two things the in-context path cannot do: asset BYTES (stripAssets
134
+ // throws them away by design) and payloads past the MCP result cap (25k tokens by default, so a real
135
+ // page export is truncated). Writing the export and returning the compact index instead is also the
136
+ // cheaper path in context by a wide margin — the agent then Reads/Greps the files at whatever
137
+ // granularity it actually needs. It is opt-in, not the default, because a small selection export is
138
+ // genuinely more useful inline than as a file path — but only while it FITS. An inline result past the
139
+ // client's cap is not "more useful", it is cut off mid-JSON (a real design-system export measured
140
+ // 589 KB ≈ 147k tokens against a 25k cap). So an oversized inline result goes to disk on its own and
141
+ // says so; an explicit writeToDisk:false is refused with the size instead, never silently truncated.
142
+ //
143
+ // This matters more than it looks: the CLI cannot be used as the disk path WHILE the MCP server is
144
+ // running, because both call createBridge() and bind port 8787 — the second to start hits EADDRINUSE
145
+ // and exits (server-core.ts). Before this, an MCP-only session had no way to get assets at all.
146
+ import { writeAny, assertInsideCwd, inlineLimitChars } from "./write-out.ts";
147
+ import { decideDelivery, describeWrite } from "./delivery.ts";
148
+ import { prettyJsonWithin } from "./json-size.ts";
149
+ import { findMapFile } from "./project-layout.ts";
150
+ import { exportRoot, readComponentCatalog } from "./export-reader.ts";
151
+ import type { Stamped } from "./write-out.ts";
152
+ import type { CatalogComponent, ComponentDetailFile } from "./doc-types.ts";
153
+
154
+ // stderr, never stdout: stdout IS the MCP stdio transport, and a stray line there corrupts the
155
+ // protocol stream.
156
+ const wlog = (m: string) => console.error("[figma-mcp] " + m);
157
+
158
+ // MCP progress (spec 2025-06-18, basic/utilities/progress): "When a party wants to receive progress
159
+ // updates for a request, it includes a progressToken in the request metadata", and the receiver MAY
160
+ // then send notifications/progress carrying that token, where "The progress value MUST increase with
161
+ // each notification". The plugin's relayed progress ticks become those notifications — progress is a
162
+ // running tick count (the plugin's own counters can reset per page), the message says where the walk
163
+ // is. No token, no listener: nothing is sent to a client that did not ask.
164
+ //
165
+ // Ordering: the bridge hands a tick to the callback synchronously from the socket's message handler
166
+ // (the WebSocket's when this process holds the bridge; the daemon socket's reader, frame by frame and
167
+ // always ahead of the reply frame, when a daemon does), so a tick and the reply that lands in the same event-loop turn would otherwise race — the tool
168
+ // result could reach stdout before the last notification, which the client then drops (it does not
169
+ // know the token any more). The SDK's own progressExample.js `await`s each sendNotification; a callback cannot,
170
+ // so `withProgress` collects every send and awaits them all before the tool result is returned (the
171
+ // stdio transport's send() resolves once the frame is written, so the order on the wire is
172
+ // notifications first, result last). A send that fails (the client went away) is swallowed: the
173
+ // export itself carries on and its result is still the answer.
174
+ type ToolExtra = RequestHandlerExtra<ServerRequest, ServerNotification>;
175
+ type OnTick = (t: ProgressTick) => void;
176
+ async function withProgress<R>(extra: ToolExtra | undefined, run: (onTick: OnTick | undefined) => Promise<R>): Promise<R> {
177
+ const progressToken = extra?._meta?.progressToken;
178
+ if (!extra || progressToken === undefined) return run(undefined);
179
+ let progress = 0;
180
+ // Spec (basic/utilities/progress): "Progress notifications MUST stop after completion" — and Claude Code
181
+ // has torn a stdio server down over a notification whose token was no longer in flight. So a
182
+ // tick that lands once run() has settled (a late frame from the plugin or the daemon), or after the call
183
+ // was cancelled, is dropped here; no notification ever carries a `total`, so none reads as a final one.
184
+ let settled = false;
185
+ const pending: Promise<void>[] = [];
186
+ const onTick: OnTick = (t) => {
187
+ if (settled || extra.signal.aborted || presence.gone.aborted) return;
188
+ pending.push(extra.sendNotification({ method: "notifications/progress", params: { progressToken, progress: ++progress, message: tickMessage(t) } })
189
+ .catch(() => { /* the client went away; the export itself carries on */ }));
190
+ };
191
+ try {
192
+ return await run(onTick);
193
+ } finally {
194
+ settled = true;
195
+ // Every entry already has its rejection handled, so this never throws and never masks run()'s
196
+ // own error; it only holds the result until the last notification is on the wire.
197
+ await Promise.all(pending);
198
+ }
199
+ }
200
+
201
+ // Whether the MCP client is still there (client-presence.ts): stdin ending or closing, a write to stdout finding it
202
+ // gone, or a signal — whichever comes first — aborts `presence.gone`, which every tool call's bridge requests carry
203
+ // beside the SDK's own per-call signal; what the process does then is the bridge holder's decision (clientLeft).
204
+ const presence = clientPresence(processIO(), () => session());
205
+ const callSignal = (extra: ToolExtra | undefined): AbortSignal => (extra ? AbortSignal.any([extra.signal, presence.gone]) : presence.gone);
206
+
207
+ /** The two writeShape arguments every export tool reads — what Zod parses them to, not a restatement. */
208
+ type WriteArgs = z.output<z.ZodObject<typeof writeShape>>;
209
+
210
+ // One export, run (read-run.ts): the read plan sent to the file the call names, its ticks relayed as progress
211
+ // notifications, cancelled with the call, timed, and stamped with WHICH connected file answered — the same
212
+ // `sourceFile` the CLI writes (figma-pull.ts), on written and inline results alike.
213
+ function pull<C extends ReadCmd>(extra: ToolExtra | undefined, plan: PlanOf<C>, client: string | undefined, progress = true): Promise<ReadRun<C>> {
214
+ const run = (onTick: OnTick | undefined): Promise<ReadRun<C>> => runRead(route, plan, { target: client, onProgress: onTick, signal: callSignal(extra) });
215
+ return progress ? withProgress(extra, run) : run(undefined);
216
+ }
217
+
218
+ // The most recent write THIS server made (per process — gone on restart). figma_status reports
219
+ // it beside lastScreenExport (which is read from disk), so an agent can see what an implicit spill touched.
220
+ // `prevKept`: how many .prev copies an implicit full/design-system spill kept (0 when none differed).
221
+ interface LastWrite { tool: string; at: string; implicit: boolean; file: string; prev?: string; prevKept?: number }
222
+ let lastWrite: LastWrite | null = null;
223
+
224
+ // An outDir the caller NAMED (or a writeToDisk:true, which writes to the default) is
225
+ // checked BEFORE the export, so a refused path costs no plugin walk — the plugin is never asked.
226
+ // exportResult still checks again after it (the one case left: FIGMA_EXPORT_DIR alone plus an implicit
227
+ // spill — pre-checking that would refuse every inline call for a user whose env names a directory
228
+ // outside the project). writeToDisk:false writes nothing, so its outDir is not checked.
229
+ function preflightOut(a: WriteArgs): void {
230
+ // `outDir: ""` is not a named outDir: resolveOutDir treats it as absent too (the env/default dir).
231
+ if (a.writeToDisk === true || (a.writeToDisk !== false && !!a.outDir)) assertInsideCwd(a.outDir);
232
+ }
233
+
234
+ // One export, delivered: the pure step (delivery.ts) decides inline / written / refused and words the note, from the
235
+ // change report write-out returns for an implicit spill; this does the measuring, the writing and the bookkeeping.
236
+ function exportResult(tool: string, a: WriteArgs, run: ReadRun<ReadCmd>, opts?: { scale?: number | undefined }) {
237
+ const r: Stamped<ExportReply> = run.reply;
238
+ // The clock rides along inline too (top level); `write` is 0 — nothing was written. The text only when it fits: a
239
+ // real export is tens of megabytes, so the size is measured without serialising it.
240
+ const d = decideDelivery(a.writeToDisk, (limit) => prettyJsonWithin({ ...stripAssets(r), durationMs: run.durationMs() }, limit), inlineLimitChars());
241
+ if (d.kind === "refuse") throw new Error(d.message);
242
+ if (d.kind === "inline") return textResult(d.text);
243
+ // Validate BEFORE writing anything: a rejected outDir must not leave a half-written export behind.
244
+ // guarded() turns the throw into an isError result naming the offending path.
245
+ const dir = assertInsideCwd(a.outDir);
246
+ // An IMPLICIT spill must not silently replace what is on disk: it keeps `.prev` copies (keepPrev), and the change
247
+ // report says which — kept, replaced, and the earlier ones still there. An explicit write keeps nothing.
248
+ const { changes, ...written } = run.write((stamped) => writeAny(a.outDir, stamped, wlog, { scale: opts && opts.scale, keepPrev: d.implicit }));
249
+ const wrote = written.wrote;
250
+ const said = describeWrite(d, "screen" in wrote ? { kind: "screen", dir, screen: wrote.screen, changes }
251
+ : "reference" in wrote ? { kind: "screenshot", dir, reference: wrote.reference, changes }
252
+ : { kind: "export", dir, changes });
253
+ lastWrite = { tool, at: new Date().toISOString(), implicit: d.implicit, file: said.file, ...ifDefined("prev", said.prev), ...ifDefined("prevKept", said.prevKept) };
254
+ // A screenshot's node size and render scale (from the plugin; absent from an older one) beside the
255
+ // PNG's own pixel size (writeScreenshot reads it from the bytes).
256
+ const shot = "reference" in r ? { ...ifDefined("w", r.w), ...ifDefined("h", r.h), ...ifDefined("scale", r.scale) } : {};
257
+ return textResult({ ...shot, ...written, durationMs: run.durationMs(), note: said.note });
258
+ }
259
+
260
+ // Shared by every export tool, so the pair cannot end up declared on some and not others — the exact
261
+ // failure mode the readOptsShape comment below records for skipAssets (Zod strips undeclared keys, so
262
+ // an option a tool forgets to declare is silently dropped before the handler sees it).
263
+ // WHICH connected Figma file a tool talks to. Declared on every tool that reaches the plugin, for the
264
+ // same reason writeShape is shared: a tool that forgets to declare it has the argument silently
265
+ // STRIPPED by Zod before the handler runs, so `client` would be accepted and ignored — routing the
266
+ // call to whatever file the bridge picked, with no error to notice.
267
+ // An array parameter that also accepts what some clients actually send for one — a JSON-encoded array
268
+ // string ('["1:2"]'; Claude Code has stringified array/object args on their way to tools/call)
269
+ // or a bare string where a one-element array was meant — including one that starts with "[" but does not
270
+ // parse as a JSON array (a page named "[WIP] Screens"). Anything else (a number, an object) reaches z.array
271
+ // unchanged and fails its validation. The
272
+ // advertised JSON Schema is still the inner `type: "array"` (zod's pipe emits its input side's target —
273
+ // mcp-smoke checks it through listTools), so a well-behaved client sees no change.
274
+ function tolerantStringArray() {
275
+ return z.preprocess((v: unknown): unknown => {
276
+ if (typeof v !== "string") return v;
277
+ const t = v.trim();
278
+ if (!t.startsWith("[")) return [v];
279
+ // a page literally named "[WIP] Screens" is not JSON (or not an array): it is still one bare name
280
+ try { const p: unknown = JSON.parse(t); return Array.isArray(p) ? p : [v]; } catch { return [v]; }
281
+ }, z.array(z.string()));
282
+ }
283
+
284
+ const clientShape = {
285
+ client: z.string().optional().describe("WHICH connected Figma file to talk to — a connId (from figma_list_clients), a fileKey, or part of the file's name. The bridge accepts one connection per open Figma file, so a design file and the library it draws on can both be connected at once. Omit when only one file is connected. With several connected, omitting this is an ERROR listing the choices rather than a guess — an export from the wrong file is indistinguishable from a correct one."),
286
+ };
287
+
288
+ const writeShape = {
289
+ writeToDisk: z.boolean().optional().describe("Write the export to disk and return a compact index (counts + file paths) instead of the node payloads. REQUIRED to get asset bytes — they are never returned inline — and the right choice for anything beyond one small frame. Omitted: a result that fits the client's output cap comes back inline, a larger one is written to disk automatically (the note says so). false: never write — a result too large to return is an error."),
290
+ outDir: z.string().optional().describe("Directory for writeToDisk, relative to the directory this MCP server was started in (i.e. your project). Default: FIGMA_EXPORT_DIR or 'design/export'."),
291
+ };
292
+
293
+ // ---- read options, per export tool (opt-in; each costs extra Plugin-API work) ----
294
+ // DERIVED from bridge/src/read-opts.ts — the ONE registry the CLI flag table and the plugin's own run reads
295
+ // come from too — rather than a fifth hand-written list of the same names and help text. A restated
296
+ // literal is how skipAssets went missing on two of the three export tools: the MCP SDK validates args
297
+ // with z.object(inputSchema) and hands the callback `parseResult.data`, and Zod's default object mode
298
+ // STRIPS unknown keys — so an option a tool's schema does not declare is silently dropped before the
299
+ // handler ever sees it. Each tool declares exactly the options its read scope takes (the rows' `scopes`),
300
+ // so what reaches the handler is what the read plan forwards.
301
+ function readOptsShapeFor<K extends ReadScopeKind>(kind: K): Record<ReadOptNameFor<K>, z.ZodOptional<z.ZodBoolean>> {
302
+ return Object.fromEntries(readOptsFor(kind).map((o) => [o.name, z.boolean().optional().describe(o.describe)])) as Record<ReadOptNameFor<K>, z.ZodOptional<z.ZodBoolean>>;
303
+ }
304
+
305
+ // The read plan, or a refusal: unreachable while each tool declares only its scope's options (the SDK strips
306
+ // the rest first), and an error naming them if a schema ever declares more than its scope takes.
307
+ function planned<P extends ReadPlan>(p: P | ReadRefusal): P {
308
+ if ("refused" in p) throw new Error(`${p.refused.join(", ")} ${p.refused.length > 1 ? "are read options" : "is a read option"} that a ${p.scope} export does not read`);
309
+ return p;
310
+ }
311
+
312
+ const READ_ONLY = { readOnlyHint: true } as const;
313
+
314
+ // figma_status reports the verdict on the export on disk (export-verdict.ts — the one `dtwin doctor` formats) so an
315
+ // agent can see how stale, and how mixed, its LAST EXPORT is without shelling out. That is distinct from bridge
316
+ // connectivity, so nothing exported is not an error, just `snapshot: null`.
317
+ import { readExportVerdict, statusSnapshot } from "./export-verdict.ts";
318
+
319
+ // The version the server reports in its MCP `initialize` answer: the package's own, never a literal that
320
+ // drifts from it.
321
+ import pkg from "../package.json" with { type: "json" };
322
+ const server = new McpServer({ name: "designtwin", version: pkg.version });
323
+
324
+ server.registerTool(
325
+ "figma_status",
326
+ {
327
+ description: "Check which Figma files are connected to the bridge, and which page each has open. Several files can be connected at once (one per open Figma file running the plugin) — the `clients` array lists them, and each row's connId is what you pass as `client` to every other tool. Also reports the export on disk, if any, as `snapshot`: the newest stamp's exportedAt/ageMs/sourceFile, `stale` (older than `staleAfterMs`), `sources` (the screens of each Figma file, and the design system, each with its own age) and `problems` (files there but unusable) — so an agent can tell whether it's about to read a stale or mixed snapshot — plus `lastScreenExport` (the newest screen in pages/index.json) and `lastWrite` (the last file this server wrote, and whether that was an implicit spill).",
328
+ inputSchema: { ...clientShape },
329
+ annotations: READ_ONLY,
330
+ },
331
+ guarded(async (a, extra) => {
332
+ // One verdict on the export (export-verdict.ts) answers both the snapshot and the newest screen row (disk);
333
+ // lastWrite is this server's own last write (session).
334
+ const verdict = readExportVerdict(exportRoot());
335
+ const snapshot = statusSnapshot(verdict);
336
+ const ledger = { lastScreenExport: verdict.lastScreen, lastWrite };
337
+ if (!(a && a.client)) {
338
+ const clients = await route.listClients();
339
+ if (!clients.length) return textResult({ connected: false, hint: "Open the Figma file and run the plugin.", snapshot, ...ledger });
340
+ // With several connected, an unaddressed ping would be REFUSED — so report the roster instead of
341
+ // failing. "Which files can I talk to?" is exactly what this tool is for.
342
+ if (clients.length > 1) {
343
+ return textResult({
344
+ connected: true,
345
+ clients,
346
+ snapshot,
347
+ ...ledger,
348
+ note: "Several Figma files are connected. Pass `client` (connId, fileKey, or part of the file name) to every tool call, or it will be refused rather than guess which file you meant.",
349
+ });
350
+ }
351
+ }
352
+ // The ping comes BEFORE the roster is listed: the request wrapper above is where a named `client` is waited
353
+ // for (NAMED_CLIENT_WAIT_MS, the same rule as `--client <name>`), and live (2026-09-25, §5.2c) right
354
+ // after a bridge restart — every window mid-redial, nothing connected yet — the zero-clients early
355
+ // return above answered `connected: false` in 10 ms for a file that landed two seconds later. With a
356
+ // `client` the early returns are skipped, so the wait applies; listing after the ping also means the
357
+ // roster shows the window the ping reached, identified. A bare connId never waits (resolveClient's
358
+ // own refusal, as for every other tool).
359
+ const pong = await ask("ping", {}, { timeoutMs: TIMEOUTS.command, target: a && a.client, signal: callSignal(extra) });
360
+ return textResult({ connected: true, clients: await route.listClients(), ...pong, snapshot, ...ledger });
361
+ })
362
+ );
363
+
364
+ // The discovery step for multi-file work: which files can I talk to, and what do I call them? Reads
365
+ // the bridge's OWN registry and never touches the plugin, so it answers instantly even while every
366
+ // connected file is mid-export — the same "cheap index you consult before committing" role
367
+ // figma_list_pages plays for a file's contents.
368
+ server.registerTool(
369
+ "figma_list_clients",
370
+ {
371
+ description:
372
+ "WHICH Figma files are currently connected to the bridge — the discovery step before addressing " +
373
+ "one. Each row gives a connId (the routing key), the file name, its fileKey when available, the " +
374
+ "open page, and how long it has been connected. Pass any of connId / fileKey / part of the file " +
375
+ "name as `client` on the other tools. The bridge accepts one connection per open Figma file, so a " +
376
+ "design file and the library it draws on can be driven in the same session without either " +
377
+ "displacing the other. Costs nothing — it reads the bridge's own registry and never touches the " +
378
+ "plugin, so it answers even while every connected file is busy with a long export.",
379
+ annotations: READ_ONLY,
380
+ },
381
+ guarded(async () => {
382
+ const clients = await route.listClients();
383
+ return textResult({
384
+ clients,
385
+ count: clients.length,
386
+ // The EMPTY case has to explain itself: nothing connected is the normal state before the plugin
387
+ // is opened, not a broken bridge.
388
+ ...(clients.length ? {} : {
389
+ note: "Nothing connected. Open a file in Figma and run \"Design Twin\" (Plugins → Development) — it auto-connects and announces itself. You can open it in several files at once; each becomes a separate row here.",
390
+ }),
391
+ ...(clients.some((c) => !c.identified) ? {
392
+ identifiedNote: "A row with identified:false is connected but has not announced itself yet (or is an older plugin build that does not) — address it by connId.",
393
+ } : {}),
394
+ });
395
+ })
396
+ );
397
+
398
+ // Identity of ONE connection. Distinct from figma_list_clients (which files are there?) — this asks a
399
+ // specific plugin instance who it is, which is how a runtime teardown is detected: the socket survives
400
+ // and the connId is unchanged, but the instanceId is new.
401
+ server.registerTool(
402
+ "figma_whoami",
403
+ {
404
+ description:
405
+ "Identity and liveness of ONE connected plugin: a per-run instanceId, the file/page name, whether " +
406
+ "figma.fileKey is available, and how long its socket has been up. Use figma_list_clients to see " +
407
+ "every connected file; use this to interrogate one of them. A CHANGED instanceId across two calls " +
408
+ "addressing the same file means Figma tore down and re-ran the plugin runtime (the connection " +
409
+ "survived, the runtime did not) — worth knowing if a long-idle file starts behaving oddly. Costs " +
410
+ "nothing (no page load, no node walk, no assets).",
411
+ inputSchema: { ...clientShape },
412
+ annotations: READ_ONLY,
413
+ },
414
+ // `connection` describes the socket that ANSWERED, not whichever file connected first.
415
+ guarded(async (a, extra) => {
416
+ const sent = await route.request("whoami", {}, { target: a && a.client, signal: callSignal(extra) });
417
+ return textResult({ plugin: sent.reply, connection: await connectionInfo(sent.client ? sent.client.connId : null) });
418
+ })
419
+ );
420
+
421
+ server.registerTool(
422
+ "figma_get_selection",
423
+ {
424
+ description: "List the currently selected nodes (id, name, type) in a connected Figma file.",
425
+ inputSchema: { ...clientShape },
426
+ annotations: READ_ONLY,
427
+ },
428
+ guarded(async (a, extra) => textResult(await ask("getSelection", {}, { timeoutMs: TIMEOUTS.command, target: a && a.client, signal: callSignal(extra) })))
429
+ );
430
+
431
+ // The cheap map. This is the tool an agent should reach for FIRST: every other read here
432
+ // deep-serializes, so without it the only way to learn what a file holds was to export all of it —
433
+ // measured 12+ minutes and tens of MB on a real design system, straight into the context window.
434
+ server.registerTool(
435
+ "figma_list_pages",
436
+ {
437
+ description:
438
+ "CHEAP structural index of the open file: pages, and (depth 2) their top-level frames with " +
439
+ "id/name/type/size. No recursion, no assets, no node properties. Call this FIRST to decide WHICH " +
440
+ "page or frame to export, then pass those ids to figma_export_full({page}) or figma_export_url. " +
441
+ "depth 1 is near-free (page names only, loads nothing); depth 2 loads every page, which Figma " +
442
+ "warns can be slow on large files — still far cheaper than an export.",
443
+ inputSchema: {
444
+ ...clientShape,
445
+ depth: z.union([z.literal(1), z.literal(2)]).optional().describe("1 = page names only (near-free). 2 = pages + their top-level frames (default)."),
446
+ },
447
+ annotations: READ_ONLY,
448
+ },
449
+ // Pass `depth` through unnormalised: listPages owns the default (and the 1-vs-2 clamp), so a third
450
+ // tier there doesn't need a matching edit here.
451
+ guarded(async (a, extra) => textResult(await ask("listPages", ifDefined("depth", a && a.depth), { timeoutMs: TIMEOUTS.list, target: a && a.client, signal: callSignal(extra) })))
452
+ );
453
+
454
+ // The library-scoped discovery call, and the other half of "look before you pull": figma_list_pages
455
+ // answers WHERE things are in this file, this one answers WHICH design libraries the file draws on.
456
+ // Same cheap tier (TIMEOUTS.list), no arguments, and the result is deliberately COMPACT — a handful
457
+ // of rows — because a discovery call that costs real context defeats its own purpose.
458
+ server.registerTool(
459
+ "figma_list_libraries",
460
+ {
461
+ description:
462
+ "The SLOWEST discovery read (a few seconds to ~15 s: it walks every instance and makes one Figma call per enabled library variable collection; about 2 s when no libraries are enabled): which design libraries this file draws on — the local file's own published " +
463
+ "assets plus every ENABLED team library — with each one's variable collections and how many of " +
464
+ "its components this file uses. Not part of the default discovery order (figma_status -> " +
465
+ "figma_list_pages -> figma_list_children -> figma_export_url): call it to learn which library owns a " +
466
+ "token, or before a CLI `dtwin pull --as-library`. Limits worth knowing: component " +
467
+ "counts are USAGE-derived (Figma exposes no API to enumerate a library's full contents), and " +
468
+ "libraries can only be enabled from the Figma UI — never via API — so results reflect whatever " +
469
+ "was enabled at call time. An EMPTY result is a normal outcome (free plan, or no library " +
470
+ "enabled), not a failure; check `warnings`. Requires a plugin built with the 'teamlibrary' " +
471
+ "permission — re-import the plugin in Figma if this returns nothing.",
472
+ inputSchema: { ...clientShape },
473
+ annotations: READ_ONLY,
474
+ },
475
+ // Compacted here rather than passed through: the plugin's shape is already small, but an agent
476
+ // reads this tool's result in full, so collapse each library's collections to name+count and keep
477
+ // `warnings` (the field that explains an empty list) intact. Same spirit as stripAssets.
478
+ guarded(async (a, extra) => {
479
+ const r = await ask("listLibraries", {}, { timeoutMs: TIMEOUTS.list, target: a && a.client, signal: callSignal(extra) });
480
+ const libraries = r.libraries.map((l) => ({
481
+ key: l.key,
482
+ name: l.name,
483
+ kind: l.kind,
484
+ componentCount: l.componentCount,
485
+ variableCollections: l.variableCollections.map((c) => ({ key: c.key, name: c.name, variableCount: c.variableCount })),
486
+ ...(l.note ? { note: l.note } : {}),
487
+ }));
488
+ const warnings = r.warnings;
489
+ return textResult({
490
+ libraries,
491
+ warnings,
492
+ ...(libraries.length
493
+ ? {}
494
+ : {
495
+ note:
496
+ "No libraries reported — a NORMAL outcome, not necessarily an error. Check, in order: " +
497
+ "the plugin in Figma predates the 'teamlibrary' permission (re-import it); no team " +
498
+ "library is enabled for this file (enable it in the Figma UI — no API can); free plan " +
499
+ "(library variable collections are not exposed to plugins).",
500
+ }),
501
+ countsNote: "componentCount is USAGE-derived — components of that library used in THIS file. Figma exposes no API to enumerate a library's full contents.",
502
+ });
503
+ })
504
+ );
505
+
506
+ // The node-scoped twin: listPages depth 2 stops at a page's top-level frames, and peeking INSIDE one
507
+ // otherwise meant a full recursive export of it.
508
+ server.registerTool(
509
+ "figma_list_children",
510
+ {
511
+ description:
512
+ "CHEAP listing of ONE node's DIRECT children (id/name/type/size/childCount/hasChildren) — no recursion, no " +
513
+ "assets. Rows that share name + size also carry a `title` (first visible text), with one warning per group; when their titles match too, `distinctTexts` (up to 3 texts that row shows and its twins do not; a very large listing skips it with a warning). Use it to drill into a frame that figma_list_pages surfaced before committing to a full " +
514
+ "export of it. Accepts a bare node id or a figma.com URL containing ?node-id=.",
515
+ inputSchema: {
516
+ ...clientShape,
517
+ nodeId: z.string().describe("A node id like '123:456' / '123-456', or a figma.com design URL containing ?node-id=..."),
518
+ },
519
+ annotations: READ_ONLY,
520
+ },
521
+ guarded(async (a, extra) => {
522
+ const nodeId = toNodeId(a.nodeId);
523
+ if (!nodeId) return errorResult("Provide a node id (e.g. 123:456) or a Figma URL containing ?node-id=... — see figma_list_pages.");
524
+ return textResult(await ask("listChildren", { nodeId }, { timeoutMs: TIMEOUTS.list, target: a && a.client, signal: callSignal(extra) }));
525
+ })
526
+ );
527
+
528
+ server.registerTool(
529
+ "figma_export_full",
530
+ {
531
+ description:
532
+ "Export the design system (component + token catalog — always spans the WHOLE file) plus frame " +
533
+ "trees. Frames default to the CURRENT page; pass page:[ids] to export named page(s), or " +
534
+ "allPages:true to walk every page. Large — call figma_list_pages first to pick a target, and " +
535
+ "pass writeToDisk:true for anything beyond a quick look: it writes the export to your project " +
536
+ "and returns a compact index, which is the only way to get asset bytes and avoids the result " +
537
+ "cap truncating the tree.",
538
+ inputSchema: {
539
+ ...clientShape,
540
+ allPages: z.boolean().optional().describe("Export frame trees from every page. Can be very large and slow (measured >15 min on a 25-page file) — prefer `page` with ids from figma_list_pages, or the dtwin CLI."),
541
+ page: tolerantStringArray().optional().describe("Export these page(s) by id (preferred) or exact name, instead of the current page. Ids come from figma_list_pages. Cannot be combined with allPages — passing both is refused rather than silently resolved. An unknown or ambiguous name fails with the available pages listed."),
542
+ ...readOptsShapeFor("pages"),
543
+ ...writeShape,
544
+ },
545
+ annotations: READ_ONLY,
546
+ },
547
+ // The budget follows the SCOPE, exactly as the dtwin CLI's does (one read plan for both): exportAll (15 min) only for
548
+ // the whole-file walk, export (5 min) for the current page or a bounded page:[ids] pull. Handing
549
+ // every scope the whole-file budget made the bounded pull — the one this tool's own description
550
+ // tells you to prefer — wait 15 minutes to report a hang that a 5-minute limit would have caught,
551
+ // and quietly undid the point of having tiers in the shared TIMEOUTS table at all.
552
+ // An empty `page` is the current page (the plan sends no selector for it). The allPages+page conflict is
553
+ // refused by collectFull itself (one guard, every caller) — no copy of the rule here. This path always
554
+ // needs a connected plugin anyway, so there is nothing to answer faster.
555
+ guarded(async (a, extra) => {
556
+ preflightOut(a);
557
+ const plan = planned(planRead({ kind: "pages", allPages: !!a.allPages, pages: a.page ?? [] }, a));
558
+ return exportResult("figma_export_full", a, await pull(extra, plan, a && a.client));
559
+ })
560
+ );
561
+
562
+ server.registerTool(
563
+ "figma_export_design_system",
564
+ {
565
+ description:
566
+ "Export ONLY the design system — variables, styles, local + library components, hygiene report — " +
567
+ "with no page/frame walk and therefore no assets (assets are exported per-node during that walk). " +
568
+ "The cheap sibling of figma_export_full for callers who just want tokens/styles/components. One " +
569
+ "tradeoff: on a file that CONSUMES a library this returns that file's OWN tokens, styles and components " +
570
+ "plus only the library variables something in the file references (flagged remote:true) — never the " +
571
+ "library's catalog; for that, run `dtwin pull --as-library \"<name>\"` (CLI) inside the library file. " +
572
+ "A design-system export takes ~15–40 s on a real file and reports progress only to clients that send a " +
573
+ "progress token. Pass writeToDisk:true for the design-system/ split. " +
574
+ "Each catalogued component/variant carries its own fills/strokes/effects/cornerRadius/opacity/blendMode " +
575
+ "(componentsLocal[].visuals) — components DEFINED in this file only, not ones consumed from a published " +
576
+ "library (pull dtwin --as-library on the source library file for those). Pass variantVisuals:true " +
577
+ "to also attach each COMPONENT_SET's variants' REAL layout/fills/radius/tokens (componentsLocal[].variants) " +
578
+ "— the master-component source of truth, not the set wrapper's own selection-chrome visuals. It is the " +
579
+ "ONE read option this tool accepts (the others need a node/page walk this tool skips); one extra node " +
580
+ "walk per variant, so it is slower on a large design system.",
581
+ inputSchema: { ...clientShape, ...readOptsShapeFor("designSystem"), ...writeShape },
582
+ annotations: READ_ONLY,
583
+ },
584
+ // buildDesignSystem() walks every page's component catalog (loadAllPages + findAllWithCriteria), so
585
+ // this is EXPORT-tier work despite taking no scope arguments — TIMEOUTS.list would undersell it.
586
+ guarded(async (a, extra) => {
587
+ preflightOut(a);
588
+ return exportResult("figma_export_design_system", a, await pull(extra, planned(planRead({ kind: "designSystem" }, a)), a && a.client));
589
+ })
590
+ );
591
+
592
+ server.registerTool(
593
+ "figma_export_selection",
594
+ {
595
+ description: "Export the current selection as compacted node JSON + variables + assets.",
596
+ inputSchema: { ...clientShape, ...readOptsShapeFor("selection"), ...writeShape },
597
+ annotations: READ_ONLY,
598
+ },
599
+ guarded(async (a, extra) => {
600
+ preflightOut(a);
601
+ return exportResult("figma_export_selection", a, await pull(extra, planned(planRead({ kind: "selection" }, a)), a && a.client));
602
+ })
603
+ );
604
+
605
+ server.registerTool(
606
+ "figma_export_url",
607
+ {
608
+ description:
609
+ "Given a Figma design URL (containing ?node-id=...) or a bare node id, select that node in the " +
610
+ "open file and export it as compacted node JSON + variables + an asset manifest. This is the " +
611
+ "'paste a link and ask about it' path. Requires the file the link points to be OPEN in the Figma " +
612
+ "desktop app with the plugin running (the bridge reads the live file — it cannot fetch a link cold).",
613
+ inputSchema: {
614
+ ...clientShape,
615
+ url: z.string().describe("A figma.com design/file URL with ?node-id=..., or a bare node id like '123:456' / '123-456'."),
616
+ ...readOptsShapeFor("node"),
617
+ ...writeShape,
618
+ },
619
+ annotations: READ_ONLY,
620
+ },
621
+ guarded(async (a, extra) => {
622
+ const nodeId = parseNodeId(a.url);
623
+ if (!nodeId) return errorResult("Couldn't find a node id in: " + a.url + " — paste a link that contains ?node-id=..., or the node id directly (e.g. 123:456).");
624
+ preflightOut(a);
625
+ return exportResult("figma_export_url", a, await pull(extra, planned(planRead({ kind: "node", nodeId }, a)), a && a.client));
626
+ })
627
+ );
628
+
629
+ server.registerTool(
630
+ "figma_screenshot",
631
+ {
632
+ description:
633
+ "On-demand PNG of ONE node — the visual-validation counterpart to figma_export_* (which already " +
634
+ "attach a whole-frame reference PNG to every exported root). Use this to check a SPECIFIC " +
635
+ "component/instance buried in a dense screen against the code you generated for it, without " +
636
+ "re-exporting or walking the whole frame. Mirrors Figma's own get_screenshot tool (single-node " +
637
+ "scope, called on demand after get_metadata/figma_list_children, as a validation step) rather " +
638
+ "than pre-rendering every node up front. Pass writeToDisk:true to get the actual PNG file — " +
639
+ "REQUIRED, since asset bytes are never returned inline.",
640
+ inputSchema: {
641
+ ...clientShape,
642
+ nodeId: z.string().describe("A node id like '123:456' / '123-456', or a figma.com design URL containing ?node-id=..."),
643
+ scale: z.number().positive().optional().describe("Override the default render scale (auto, capped at 2048px on the longest side)."),
644
+ ...readOptsShapeFor("screenshot"), // none: a screenshot renders a PNG and walks nothing
645
+ ...writeShape,
646
+ },
647
+ annotations: READ_ONLY,
648
+ },
649
+ guarded(async (a, extra) => {
650
+ const nodeId = toNodeId(a.nodeId);
651
+ if (!nodeId) return errorResult("Provide a node id (e.g. 123:456) or a Figma URL containing ?node-id=... — see figma_list_pages.");
652
+ preflightOut(a);
653
+ return exportResult("figma_screenshot", a, await pull(extra, planned(planRead({ kind: "screenshot", nodeId, scale: a.scale })), a && a.client, false), { scale: a.scale });
654
+ })
655
+ );
656
+
657
+ // What a figma_write batch WOULD do, without doing it. creates = additive; overwrites = replaces a
658
+ // property on a node the user already has (the irreversible half).
659
+ // The op schema is the wire contract (commands.ts WriteOp) spelled in Zod: `op` is constrained to the
660
+ // four ops the plugin implements — rejected at the boundary rather than round-tripped — and the fields
661
+ // each op reads are typed. `.passthrough()` keeps a stray extra field flowing (the plugin ignores it).
662
+ // A fill/color is a hex colour: "red" or "#12" is refused here rather than painted as a wrong colour.
663
+ const hexColor = z.string().regex(HEX_COLOR_RE, `must be ${HEX_COLOR_HINT}`);
664
+ // createFrame/createText `fill` also takes "" (the plugin reads it as "no fill"); one regex alternative, so the
665
+ // published JSON schema keeps a single `pattern`. setFill's `color` stays strict: it has no "none".
666
+ const hexColorOrEmpty = z.string().regex(new RegExp(`^(?:${HEX_COLOR_RE.source.slice(1, -1)}|)$`), `must be ${HEX_COLOR_HINT}, or "" for no fill`);
667
+ const writeOpShape = z.object({
668
+ op: z.enum(WRITE_OP_NAMES),
669
+ parentId: z.string().optional(),
670
+ name: z.string().optional(),
671
+ width: z.number().optional(),
672
+ height: z.number().optional(),
673
+ layoutMode: z.enum(["HORIZONTAL", "VERTICAL"]).optional(),
674
+ itemSpacing: z.number().optional(),
675
+ padding: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional(),
676
+ fill: hexColorOrEmpty.optional(),
677
+ text: z.string().optional(),
678
+ fontSize: z.number().optional(),
679
+ nodeId: z.string().optional(),
680
+ color: hexColor.optional(),
681
+ }).passthrough();
682
+
683
+ // Each step is commands.ts writeOpPreview — the op's WRITE_OPS row, which the plugin's wire check (writeOpError) reads too.
684
+ function previewWrites(ops: WriteOp[]) {
685
+ const steps = ops.map(writeOpPreview);
686
+ return {
687
+ dryRun: true,
688
+ applied: false,
689
+ creates: steps.filter((s) => s.effect === "creates").length,
690
+ overwrites: steps.filter((s) => s.effect === "overwrites").length,
691
+ invalid: steps.filter((s) => "invalid" in s).length,
692
+ steps,
693
+ note: "Nothing was changed. Overwrites cannot be undone from here (only Cmd-Z in Figma). Re-call without dryRun to apply.",
694
+ };
695
+ }
696
+
697
+ server.registerTool(
698
+ "figma_write",
699
+ {
700
+ description:
701
+ "Apply a batch of safe write operations to the open Figma file. Each op: " +
702
+ "{op:'createFrame', name?, width?, height?, layoutMode?('HORIZONTAL'|'VERTICAL'), itemSpacing?, padding?[t,r,b,l], fill?(#hex), parentId?} | " +
703
+ "{op:'createText', text, fontSize?, fill?, parentId?} | " +
704
+ "{op:'setFill', nodeId, color(#hex)} | {op:'setText', nodeId, text}. " +
705
+ "Ops are applied in order and are NOT transactional: if one fails, the earlier ones stay applied " +
706
+ "and their node ids are reported alongside the error so you can continue or clean up. " +
707
+ "There is no undo from here (only the designer's own Cmd-Z) — so for anything that OVERWRITES " +
708
+ "(setFill/setText), call once with dryRun:true, show the user the preview, and only then apply.",
709
+ inputSchema: {
710
+ ...clientShape,
711
+ // Constrain `op` to the four ops the plugin actually implements — reject unknown ops at the
712
+ // boundary rather than round-tripping them to the plugin. Other fields stay open (.passthrough).
713
+ ops: z.array(writeOpShape)
714
+ // Writes are not transactional and cannot be undone from here, so a runaway batch is bounded:
715
+ // 50 ops is several screens' worth of frames/text, and a larger job is simply several calls.
716
+ .max(50, "at most 50 ops per call — split a larger job into several calls (writes cannot be undone from here)")
717
+ .describe("Ordered list of write operations (each must have an `op` field). At most 50 per call."),
718
+ dryRun: z.boolean().optional().describe("true = validate the batch and return a preview of what each op would create/overwrite, WITHOUT touching the Figma file (needs no plugin connection)."),
719
+ },
720
+ // destructiveHint MUST stay true. Per the MCP schema it asserts, when false, that "the tool
721
+ // performs only additive updates" — but setFill/setText OVERWRITE properties on nodes the user
722
+ // already has, and (as the description says) nothing here is transactional or rollback-able.
723
+ // Clients use these hints to decide whether to put a human in the loop, so declaring `false`
724
+ // suppressed the confirmation prompt on the one tool that irreversibly mutates a design file.
725
+ // idempotentHint is likewise false: re-running a batch creates a SECOND set of frames/text.
726
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
727
+ },
728
+ guarded(async (a) => {
729
+ // The preview is built HERE, not plugin-side: it must work before a connection exists, and the
730
+ // annotations above are only a hint — not every MCP client turns destructiveHint into a prompt.
731
+ if (a.dryRun) return textResult(previewWrites(a.ops || []));
732
+ // No cancel signal here (unlike the reads): a write cannot be un-applied, and aborting the wait would
733
+ // only lose the `applied` ids below.
734
+ const r = await ask("write", { ops: a.ops || [] }, { target: a && a.client });
735
+ // Partial failure is reported as an error result, but MUST still carry `applied` — those nodes
736
+ // exist in the document and the agent needs their ids to continue or undo.
737
+ if (!r.ok) {
738
+ const applied = r.applied;
739
+ return errorResult(
740
+ `Write failed at op ${r.failedAt}${r.failedOp ? " (" + r.failedOp + ")" : ""}: ${r.error}\n` +
741
+ `${applied.length} earlier op(s) were applied and NOT rolled back:\n` +
742
+ JSON.stringify(applied, null, 2)
743
+ );
744
+ }
745
+ return textResult(r);
746
+ })
747
+ );
748
+
749
+ // ---------------------------------------------------------------- design-to-code layer (no Figma)
750
+ // These two read the EXPORT ON DISK, not the live file, so they work with no plugin connected and
751
+ // cost nothing. The design-to-code layer is otherwise reachable only by a human typing `bun design-to-code/...`
752
+ // — without these the agent has the figma_* tools and no way in, and the map and the codegen skill drift
753
+ // into two different formats. Registering them here is what makes that layer exist for its actual
754
+ // consumer.
755
+ // Loaded LAZILY, on call, and never at module scope, from one of two places. In a repo checkout it is
756
+ // ../../design-to-code/<mod>.ts, a SIBLING of this package root (bridge/) — which npm's `files` cannot reach. So
757
+ // the npm package carries the layer as bundles instead: `prepack` (bridge/pack.ts) copies the committed,
758
+ // self-contained claude-plugin/scripts/<mod>.js into ../layer/. Neither there (a checkout without design-to-code/,
759
+ // a package packed without its prepack): importing eagerly would take the whole server down with a
760
+ // module-not-found, killing the other tools over two that merely cannot work — so the server always starts and only
761
+ // these two report, precisely, what is missing.
762
+ // The slice of each layer module this server calls, declared HERE (over doc-types.ts shapes) rather
763
+ // than as `typeof import("../../design-to-code/x.ts")`: even a type-only reference pulls that file and
764
+ // its imports into the bridge program, and the published package (src/ is TypeScript, the layer only bundles) would name files it
765
+ // does not ship (see doc-types.ts). So bridge/src has no dependency of any kind on the layer — only the
766
+ // dynamic import below, which fails soft. Mirrors design-to-code/get-component.ts GetComponentResult and
767
+ // design-to-code/drift-lint.ts runDriftLint(); keep them in step (test/mcp-smoke.test.ts checks the real modules still satisfy these at compile time).
768
+ export interface GetComponentModule {
769
+ getComponent(catalogFile: string, handle: string):
770
+ | { found: false }
771
+ | { found: true; component: CatalogComponent; detail: ComponentDetailFile | null; detailPath?: string };
772
+ }
773
+ /** design-to-code/drift-lint.ts runDriftLint(): the one run the CLI prints and design_drift_lint returns. */
774
+ export interface DriftLintModule {
775
+ runDriftLint(input: { mapFile: string; catalogFile: string; extraCatalogFiles?: readonly string[]; screenFiles?: readonly string[]; maxAgeMs?: number }):
776
+ | { status: "unusable" | "map-invalid"; message: string }
777
+ | { status: "linted"; errors: unknown[]; warnings: unknown[]; freshness: unknown; summary: unknown; catalogs: ReadonlyArray<{ file: string }>; coverage: { coverage: object; warnings: unknown[] } | null };
778
+ }
779
+ async function loadLayer<T>(mod: string, tool: string): Promise<T> {
780
+ // The source in a checkout first (what the repo's tests run), else the package's bundle. Whether either is there
781
+ // is asked of the file system, before the import: the import's own failure cannot say it — a missing
782
+ // dependency OF the layer fails the same way, and the error's code and url fields differ between
783
+ // runtimes (Bun reports neither the way Node did). Any error from an import of a file that IS there
784
+ // is passed on as it is.
785
+ const url = [new URL(`../../design-to-code/${mod}.ts`, import.meta.url), new URL(`../layer/${mod}.js`, import.meta.url)].find((u) => fs.existsSync(u));
786
+ if (!url) {
787
+ throw new Error(
788
+ `${tool} needs the design-to-code layer, which is not present in this install: neither a repo checkout's ` +
789
+ `design-to-code/${mod}.ts nor the npm package's layer/${mod}.js. Reinstall @design-twin/dtwin, or run this MCP ` +
790
+ `server from a repo checkout (bun bridge/src/figma-mcp.ts). Everything else on this server works either way.`
791
+ );
792
+ }
793
+ return (await import(url.href)) as T;
794
+ }
795
+ const getComponent = async (catalogFile: string, handle: string) =>
796
+ (await loadLayer<GetComponentModule>("get-component", "design_get_component")).getComponent(
797
+ catalogFile,
798
+ handle
799
+ );
800
+ const driftLintLayer = () => loadLayer<DriftLintModule>("drift-lint", "design_drift_lint");
801
+
802
+ // The export's component catalog is export-reader.ts readComponentCatalog's: the `files.componentsLocal` pointer
803
+ // (inside the export only), else an older export's inline components[] in design-system.json — the catalog `dtwin
804
+ // seed` reads too. No exportDir: export-reader's one precedence (FIGMA_EXPORT_DIR, else this project's export). The
805
+ // server adds only its own boundary: the export and the catalog file are held inside its start directory (a symlink
806
+ // out included), so these read tools never read outside the project.
807
+ function componentCatalogFile(exportDir?: string): string {
808
+ const root = assertInsideCwd(exportRoot(exportDir), "exportDir");
809
+ const catalog = readComponentCatalog(root);
810
+ if ("error" in catalog) throw new Error(catalog.missing ? `${catalog.error}; from this server, figma_export_design_system with writeToDisk:true — or pass exportDir` : catalog.error);
811
+ return assertInsideCwd(catalog.file, "the component catalog");
812
+ }
813
+
814
+ const exportDirShape = {
815
+ exportDir: z
816
+ .string()
817
+ .optional()
818
+ .describe(
819
+ "Directory holding the design export, relative to the directory this server was started in. Default: FIGMA_EXPORT_DIR, else this project's export (design/export, or design/ on the older flat layout)."
820
+ ),
821
+ };
822
+
823
+ server.registerTool(
824
+ "design_get_component",
825
+ {
826
+ description:
827
+ "Read ONE component out of the design export on disk, with its real variant node trees. Handle is the component's stable publish `key` (preferred — survives renames), its node id, or its exact name (an ambiguous name is an ERROR listing the keys, never a guess). Use this when building a screen and you need one component's actual variant visuals: the catalog is deliberately slim, so every COMPONENT_SET's heavy per-variant node trees live in their own file and this is the tool that follows that pointer. Needs NO Figma connection and costs nothing — it reads files an earlier export already wrote.",
828
+ inputSchema: {
829
+ handle: z.string().describe("Component publish key, node id, or exact name. Keys come from design_drift_lint or the components.local.json catalog."),
830
+ ...exportDirShape,
831
+ },
832
+ annotations: READ_ONLY,
833
+ },
834
+ guarded(async (a) => {
835
+ const res = await getComponent(componentCatalogFile(a.exportDir), a.handle);
836
+ if (!res.found) return errorResult(`No component matches '${a.handle}' in the export. Try its publish key or id.`);
837
+ // A catalog entry with no exported node tree is a real, non-error outcome (the export ran without
838
+ // variantVisuals) — say so rather than returning an empty-looking success.
839
+ if (!res.detail) {
840
+ return textResult({
841
+ component: res.component,
842
+ detail: null,
843
+ note: "No node trees were exported for this component — re-run the export with variantVisuals:true to get its variant visuals.",
844
+ });
845
+ }
846
+ return textResult({ component: res.component, detail: res.detail });
847
+ })
848
+ );
849
+
850
+ server.registerTool(
851
+ "design_drift_lint",
852
+ {
853
+ description:
854
+ "Check a codeconnect.local.json map against the design export for DRIFT, and report snapshot staleness. Joins on the stable publish `key`, so it catches the case Figma's own Code Connect ships silently (its node-id join misses renamed/republished components — issue #337): orphaned map entries, unmapped components, stale or uncovered props, kind/enum mismatches. Also warns when the export on disk is older than --max-age. Run it before building a screen (so you find out the map is wrong BEFORE generating code against it) and in CI/pre-commit. Needs NO Figma connection.",
855
+ inputSchema: {
856
+ map: z.string().optional().describe("Path to the map, relative to the server's start directory. Default: design/codeconnect.local.json (or a root codeconnect.local.json an older project still has)."),
857
+ maxAgeHours: z.number().optional().describe("Warn if the export snapshot is older than this many hours. Omit for the built-in default."),
858
+ catalogs: tolerantStringArray().optional().describe("More component catalog file(s) the map may point into, relative to the server's start directory — the CLI's --catalog (e.g. a library's components.json kept outside the export). components.library.json and every pulled library are read without it."),
859
+ screens: tolerantStringArray().optional().describe("Screen export JSON file(s) (e.g. design/export/pages/<Page>/<Screen>__<id>.json), relative to the server's start directory — the CLI's --screen. Adds `screenCoverage`: how many of the component sets placed on THOSE screens your map covers (by key), with the same warnings the CLI prints. The number that matters before building a screen."),
860
+ ...exportDirShape,
861
+ },
862
+ annotations: READ_ONLY,
863
+ },
864
+ guarded(async (a) => {
865
+ // Every path is held inside the server's start directory; reading, checking and linting them is the CLI's
866
+ // own run (design-to-code/drift-lint.ts runDriftLint), so the two cannot disagree on what a file means.
867
+ // The project layout's map (design/codeconnect.local.json), or the root copy an older project still has.
868
+ const mapFile = assertInsideCwd(a.map || findMapFile(process.cwd()).rel, "map");
869
+ const catalogFile = componentCatalogFile(a.exportDir);
870
+ const run = (await driftLintLayer()).runDriftLint({
871
+ mapFile,
872
+ catalogFile,
873
+ extraCatalogFiles: (a.catalogs || []).map((f) => assertInsideCwd(f, "catalogs")),
874
+ screenFiles: (a.screens || []).map((f) => assertInsideCwd(f, "screens")),
875
+ ...(a.maxAgeHours ? { maxAgeMs: a.maxAgeHours * 3600000 } : {}),
876
+ });
877
+ if (run.status !== "linted") return errorResult(run.message);
878
+ // Drift is a FINDING, not a tool failure — return it as a normal result so the agent reads the
879
+ // errors instead of an isError blob it may discard. `ok` is the thing to branch on.
880
+ return textResult({
881
+ ok: run.errors.length === 0,
882
+ catalogsRead: run.catalogs.map((c) => c.file),
883
+ errors: run.errors,
884
+ warnings: run.warnings,
885
+ freshness: run.freshness,
886
+ summary: run.summary,
887
+ ...(run.coverage ? { screenCoverage: { ...run.coverage.coverage, warnings: run.coverage.warnings } } : {}),
888
+ });
889
+ })
890
+ );
891
+
892
+ // The server, started: stdio guards, signal handlers, the stdio transport, then the bridge. NOT run at import — only
893
+ // when this file is the process entry (`bun figma-mcp.ts`, the .mcp.json entry) or `dtwin mcp` calls runServer() once
894
+ // it has loaded this module — so a suite can load it and drive `server` in process.
895
+ async function startServer() {
896
+ // This process IS the user's MCP session: a stray rejection is logged and the session stays up; an
897
+ // uncaught exception is logged and exits 1 (daemon.ts installCrashHandlers says why). Before that exit waits on
898
+ // stderr, the cleanup lets go of what this process holds — the daemon socket it serves (unlinked) and the bridge's
899
+ // port — synchronously, so a Claude Code that restarts the server at once finds 8787 free.
900
+ // The settings first, as the bridge core once did at its load: a port no plugin can reach leaves nothing to serve.
901
+ const settings = readSettings();
902
+ if (!settings.ok) {
903
+ console.error(settings.problem);
904
+ exitFlushed(1);
905
+ return;
906
+ }
907
+ daemon.installCrashHandlers(wlog, () => { session().release(); });
908
+ // Before anything can write to stdout (client-presence.ts says why its stdout guard cannot wait for the client to go):
909
+ // the stdio guards, the signal handlers, and the exit once the holder has let go.
910
+ presence.start();
911
+ // console.* through the streams' queue (Bun drops a direct console write that meets a full pipe) — and ALL of it to
912
+ // stderr: stdout carries the JSON-RPC the transport writes and nothing else.
913
+ installQueuedConsole({ logTo: "stderr" });
914
+ // stdio is the transport Claude Code speaks; the WebSocket to the plugin is internal.
915
+ const transport = new StdioServerTransport();
916
+ await server.connect(transport);
917
+ presence.watchStdin();
918
+ // Resolve the bridge up front, so a port problem is reported at startup — but only REPORTED: the
919
+ // stdio session stays up, every tool call retries the resolution, and a held port comes back as that
920
+ // call's error (the port may be free by then). Exiting here killed the whole MCP server.
921
+ let h: Upstream;
922
+ try {
923
+ h = await session().upstream();
924
+ } catch (e) {
925
+ console.error("[figma-mcp] MCP server up (stdio), but no bridge yet: " + errMsg(e) + " Tool calls will retry.");
926
+ return;
927
+ }
928
+ console.error("[figma-mcp] MCP server up (stdio). " + (h.kind === "own"
929
+ ? "Bridge listening on " + h.address + "."
930
+ : "Sharing the bridge already running at " + h.address + "."));
931
+ }
932
+
933
+ /** Start the server as the process's MCP session: a startup failure is fatal (logged, exit 1). */
934
+ export function runServer(): void {
935
+ startServer().catch((e) => {
936
+ console.error("[figma-mcp] fatal:", errMsg(e));
937
+ exitFlushed(1);
938
+ });
939
+ }
940
+ export { server };
941
+
942
+ if (import.meta.main) runServer();