@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.
- package/LICENSE +21 -0
- package/README.md +959 -2
- package/figma-plugin/code.js +4207 -0
- package/figma-plugin/manifest.json +28 -0
- package/figma-plugin/ui.html +686 -0
- package/layer/cross-check.js +2746 -0
- package/layer/drift-lint.js +1526 -0
- package/layer/get-component.js +553 -0
- package/layer/map-bootstrap.js +1404 -0
- package/layer/map-validate.js +397 -0
- package/package.json +53 -4
- package/src/abort-reason.ts +12 -0
- package/src/asset-compare.ts +175 -0
- package/src/asset-owners.ts +280 -0
- package/src/atomic-write.ts +79 -0
- package/src/bridge-holder.ts +294 -0
- package/src/cli-commands.ts +427 -0
- package/src/client-presence.ts +146 -0
- package/src/clock.ts +45 -0
- package/src/commands.ts +561 -0
- package/src/daemon-auth.ts +174 -0
- package/src/daemon.ts +788 -0
- package/src/delivery.ts +85 -0
- package/src/design-system-layout.ts +232 -0
- package/src/doc-types.ts +1054 -0
- package/src/doctor.ts +602 -0
- package/src/errmsg.ts +14 -0
- package/src/export-reader.ts +409 -0
- package/src/export-verdict.ts +258 -0
- package/src/figma-mcp.ts +942 -0
- package/src/figma-pull.ts +1265 -0
- package/src/file-lock.ts +268 -0
- package/src/file-memo.ts +62 -0
- package/src/hash.ts +6 -0
- package/src/hex-color.ts +39 -0
- package/src/human-ms.ts +7 -0
- package/src/init.ts +424 -0
- package/src/instance-overrides.ts +7 -0
- package/src/json-file.ts +79 -0
- package/src/json-size.ts +108 -0
- package/src/json-util.ts +51 -0
- package/src/library-layout.ts +193 -0
- package/src/node-id.ts +69 -0
- package/src/pages-layout.ts +483 -0
- package/src/pending-requests.ts +187 -0
- package/src/plugin-frames.ts +32 -0
- package/src/png.ts +196 -0
- package/src/ports.ts +25 -0
- package/src/project-layout.ts +189 -0
- package/src/quick-keys.ts +68 -0
- package/src/read-json.ts +67 -0
- package/src/read-opts.ts +125 -0
- package/src/read-plan.ts +105 -0
- package/src/read-run.ts +119 -0
- package/src/route.ts +243 -0
- package/src/seed-components.ts +229 -0
- package/src/served-session.ts +347 -0
- package/src/server-core.ts +1093 -0
- package/src/settings.ts +156 -0
- package/src/source-stamp.ts +16 -0
- package/src/staleness.ts +63 -0
- package/src/stdio.ts +156 -0
- package/src/svg-normalize.ts +205 -0
- package/src/timeouts.ts +76 -0
- package/src/timings.ts +118 -0
- package/src/token-store.ts +261 -0
- package/src/variables-merge.ts +337 -0
- package/src/verbs.ts +140 -0
- package/src/write-out.ts +1255 -0
package/src/figma-mcp.ts
ADDED
|
@@ -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();
|