figma-relai 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/dist/index.js ADDED
@@ -0,0 +1,3526 @@
1
+ #!/usr/bin/env node
2
+ var __defProp = Object.defineProperty;
3
+ var __export = (target, all) => {
4
+ for (var name in all)
5
+ __defProp(target, name, { get: all[name], enumerable: true });
6
+ };
7
+
8
+ // src/index.ts
9
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
10
+
11
+ // src/server.ts
12
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13
+ function createServer() {
14
+ return new McpServer(
15
+ {
16
+ name: "Relai",
17
+ version: "0.1.0"
18
+ },
19
+ {
20
+ instructions: `
21
+ You control Figma through ~30 consolidated tools. Pairing with the Figma plugin is automatic \u2014 just call any tool; join_room is only needed if an error reports multiple plugins.
22
+
23
+ \u{1F4D6} UNDERSTAND (start here):
24
+ get_document_overview \u2014 structure, page/component/style/variable counts
25
+ get_selection_context \u2014 full context for what the designer selected
26
+ get_node_details \u2014 deep single-node inspection (CSS, token bindings)
27
+ search_nodes \u2014 find nodes by name/type \xB7 get_node_data \u2014 raw data/tree/css
28
+ get_design_tokens \u2014 variable collections, modes, styles
29
+ screenshot \u2014 see the canvas (use this to verify your work visually)
30
+
31
+ \u{1F50D} DIAGNOSE:
32
+ analyze_design \u2014 aspect: color/layout/components/accessibility, or
33
+ "overall" for a weighted 0-100 health score across all four
34
+ diff_nodes \u2014 compare two nodes, or checkpoint save/compare to audit
35
+ what changed on a node over an editing session
36
+
37
+ \u270F\uFE0F EDIT:
38
+ create_node \u2014 any node type (rectangle/frame/text/svg/image/\u2026)
39
+ set_properties \u2014 one call for geometry, fills, strokes, effects, text,
40
+ auto-layout, constraints, style/variable bindings, on one or many nodes
41
+ set_text \u2014 single, bulk, or character-range text edits
42
+ edit_structure \u2014 group/reparent/reorder/clone/flatten/boolean/delete
43
+ navigate \u2014 focus/select/viewport/switch_page \xB7 manage_pages
44
+
45
+ \u{1F9F1} DESIGN SYSTEM:
46
+ manage_components \u2014 create/variants/instantiate/overrides/props/detach
47
+ manage_variables \u2014 collections, modes, tokens, bind/unbind
48
+ manage_styles \u2014 paint/text/effect/grid styles
49
+ import_from_library \u2014 bring in library components/styles/variables
50
+
51
+ \u{1F4E6} ASSETS: export_asset (PNG/JPG/SVG/PDF) \xB7 add_image (URL or fill)
52
+ \u{1F4DD} ANNOTATIONS: annotate (Dev Mode annotations)
53
+ \u{1F4AC} COMMENTS: manage_comments \u2014 read/apply/reply to file comments
54
+ (needs FIGMA_TOKEN env; the tool explains setup when missing)
55
+
56
+ \u2705 VERIFY: verify_changes \xB7 validate_design_rules
57
+
58
+ \u26A1 ADVANCED:
59
+ batch_execute \u2014 run many plugin-level commands in one round-trip
60
+ execute_figma \u2014 run JavaScript against the Figma Plugin API directly
61
+ (the escape hatch when no tool fits; small incremental scripts,
62
+ screenshot between steps; the designer can disable it)
63
+
64
+ OPERATING PRINCIPLES:
65
+ - Inspect context BEFORE changing things; don't assume selection state
66
+ - After visual edits, verify: screenshot or verify_changes
67
+ - Colors are RGBA in 0-1 range (Figma format), not 0-255
68
+ - layoutSizing FILL requires the node's PARENT to have auto-layout;
69
+ set layoutMode on a frame before padding/alignment/spacing
70
+ - Responses include recommended_next tools \u2014 follow them
71
+ - Errors name the node, its type, and the fix \u2014 read them, don't retry blindly
72
+ `
73
+ }
74
+ );
75
+ }
76
+
77
+ // src/connection.ts
78
+ import WebSocket from "ws";
79
+ import { v4 as uuidv4 } from "uuid";
80
+
81
+ // src/logger.ts
82
+ var logger = {
83
+ info: (message) => process.stderr.write(`[INFO] ${message}
84
+ `),
85
+ debug: (message) => process.stderr.write(`[DEBUG] ${message}
86
+ `),
87
+ warn: (message) => process.stderr.write(`[WARN] ${message}
88
+ `),
89
+ error: (message) => process.stderr.write(`[ERROR] ${message}
90
+ `)
91
+ };
92
+
93
+ // src/request-tracker.ts
94
+ var RequestTracker = class {
95
+ pending = /* @__PURE__ */ new Map();
96
+ // Register a new pending request with timeout
97
+ add(id, resolve, reject, timeoutMs = 3e4) {
98
+ const timeout = setTimeout(() => {
99
+ if (this.pending.has(id)) {
100
+ this.pending.delete(id);
101
+ logger.error(`Request ${id} timed out after ${timeoutMs / 1e3}s`);
102
+ reject(new Error("Request to Figma timed out"));
103
+ }
104
+ }, timeoutMs);
105
+ this.pending.set(id, {
106
+ resolve,
107
+ reject,
108
+ timeout,
109
+ lastActivity: Date.now()
110
+ });
111
+ }
112
+ // Resolve a request with result
113
+ resolve(id, result) {
114
+ const request = this.pending.get(id);
115
+ if (!request) return false;
116
+ clearTimeout(request.timeout);
117
+ request.resolve(result);
118
+ this.pending.delete(id);
119
+ return true;
120
+ }
121
+ // Reject a request with error
122
+ reject(id, error) {
123
+ const request = this.pending.get(id);
124
+ if (!request) return false;
125
+ clearTimeout(request.timeout);
126
+ request.reject(error);
127
+ this.pending.delete(id);
128
+ return true;
129
+ }
130
+ // Reset timeout on progress update to prevent timeout during long operations
131
+ resetTimeout(id, extendedTimeoutMs = 6e4) {
132
+ const request = this.pending.get(id);
133
+ if (!request) return;
134
+ request.lastActivity = Date.now();
135
+ clearTimeout(request.timeout);
136
+ request.timeout = setTimeout(() => {
137
+ if (this.pending.has(id)) {
138
+ this.pending.delete(id);
139
+ logger.error(`Request ${id} timed out after extended inactivity`);
140
+ request.reject(new Error("Request to Figma timed out"));
141
+ }
142
+ }, extendedTimeoutMs);
143
+ }
144
+ // Check if a request is pending
145
+ has(id) {
146
+ return this.pending.has(id);
147
+ }
148
+ // Reject all pending requests (e.g., on connection close)
149
+ rejectAll(reason) {
150
+ for (const [id, request] of this.pending.entries()) {
151
+ clearTimeout(request.timeout);
152
+ request.reject(new Error(reason));
153
+ }
154
+ this.pending.clear();
155
+ }
156
+ get size() {
157
+ return this.pending.size;
158
+ }
159
+ };
160
+
161
+ // src/connection.ts
162
+ function formatFigmaError(e) {
163
+ if (typeof e === "string") return e;
164
+ const { message, command, nodeId, nodeType } = e ?? {};
165
+ const prefix = command ? `[${command}] ` : "";
166
+ const suffix = nodeId ? ` (node ${nodeId}${nodeType ? `, type ${nodeType}` : ""})` : "";
167
+ return `${prefix}${message ?? JSON.stringify(e)}${suffix}`;
168
+ }
169
+ function routeResponse(response, tracker) {
170
+ if (!response?.id || !tracker.has(response.id)) return;
171
+ if (response.error !== void 0) {
172
+ tracker.reject(response.id, new Error(formatFigmaError(response.error)));
173
+ } else if ("result" in response) {
174
+ tracker.resolve(response.id, response.result);
175
+ }
176
+ }
177
+ var FigmaConnection = class {
178
+ ws = null;
179
+ currentRoom = null;
180
+ tracker = new RequestTracker();
181
+ wsUrl;
182
+ reconnectTimer = null;
183
+ shouldReconnect = false;
184
+ connectPromise = null;
185
+ commandHandler = null;
186
+ options;
187
+ relayVerified = false;
188
+ // null = unknown (no presence message yet); false = room has no plugin
189
+ pluginPresent = null;
190
+ // Designer activity piggybacked on plugin responses (selection changes etc.)
191
+ eventQueue = [];
192
+ constructor(serverUrl2 = "localhost", port2 = 9055, options = {}) {
193
+ const protocol = serverUrl2 === "localhost" ? "ws" : "wss";
194
+ this.wsUrl = serverUrl2 === "localhost" ? `${protocol}://${serverUrl2}:${port2}` : `${protocol}://${serverUrl2}`;
195
+ this.options = options;
196
+ }
197
+ // Connect to WebSocket relay server
198
+ connect() {
199
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
200
+ logger.info("Already connected to relay");
201
+ return Promise.resolve();
202
+ }
203
+ if (this.connectPromise) {
204
+ return this.connectPromise;
205
+ }
206
+ logger.info(`Connecting to relay at ${this.wsUrl}...`);
207
+ this.connectPromise = new Promise((resolve, reject) => {
208
+ const ws = new WebSocket(this.wsUrl);
209
+ this.ws = ws;
210
+ const cleanup = () => {
211
+ this.connectPromise = null;
212
+ };
213
+ ws.on("open", () => {
214
+ logger.info("Connected to relay server");
215
+ this.shouldReconnect = true;
216
+ cleanup();
217
+ resolve();
218
+ this.relayVerified = false;
219
+ try {
220
+ ws.send(JSON.stringify({ type: "hello" }));
221
+ } catch {
222
+ }
223
+ setTimeout(() => {
224
+ if (!this.relayVerified && this.ws === ws) {
225
+ logger.warn(
226
+ "Connected endpoint did not identify as a figma-relai relay \u2014 is another app using the port?"
227
+ );
228
+ }
229
+ }, 1500);
230
+ if (this.currentRoom) {
231
+ const room = this.currentRoom;
232
+ this.sendCommand("join", { room }).then(
233
+ () => logger.info(`Rejoined room: ${room}`),
234
+ (err) => logger.warn(
235
+ `Failed to rejoin room ${room}: ${err instanceof Error ? err.message : String(err)}`
236
+ )
237
+ );
238
+ }
239
+ });
240
+ ws.on("message", (data) => {
241
+ try {
242
+ const json = JSON.parse(data.toString());
243
+ if (json.type === "hello" && json.server === "figma-relai") {
244
+ this.relayVerified = true;
245
+ logger.debug(`Relay verified (version ${json.version})`);
246
+ return;
247
+ }
248
+ if (json.type === "presence") {
249
+ if (json.room === this.currentRoom && Array.isArray(json.peers)) {
250
+ this.pluginPresent = json.peers.some(
251
+ (p) => p.role === "plugin"
252
+ );
253
+ }
254
+ return;
255
+ }
256
+ if (json.type === "list_rooms_result" && json.id) {
257
+ this.tracker.resolve(json.id, json.rooms ?? []);
258
+ return;
259
+ }
260
+ if (json.type === "broadcast" && json.message?.command) {
261
+ this.commandHandler?.(json.message.command, json.message.params);
262
+ return;
263
+ }
264
+ if (json.type === "progress_update") {
265
+ const progressData = json.message?.data;
266
+ const requestId = json.id || "";
267
+ if (requestId && this.tracker.has(requestId)) {
268
+ this.tracker.resetTimeout(requestId);
269
+ logger.info(
270
+ `Progress: ${progressData.commandType} ${progressData.progress}% - ${progressData.message}`
271
+ );
272
+ }
273
+ return;
274
+ }
275
+ if (Array.isArray(json.message?.events)) {
276
+ this.eventQueue.push(...json.message.events);
277
+ if (this.eventQueue.length > 50) {
278
+ this.eventQueue = this.eventQueue.slice(-50);
279
+ }
280
+ }
281
+ routeResponse(json.message, this.tracker);
282
+ } catch (error) {
283
+ logger.error(
284
+ `Error parsing message: ${error instanceof Error ? error.message : String(error)}`
285
+ );
286
+ }
287
+ });
288
+ ws.on("error", (error) => {
289
+ logger.error(`WebSocket error: ${error.message}`);
290
+ cleanup();
291
+ reject(error);
292
+ });
293
+ ws.on("close", () => {
294
+ logger.info("Disconnected from relay");
295
+ this.ws = null;
296
+ this.tracker.rejectAll("Connection closed");
297
+ cleanup();
298
+ if (this.shouldReconnect) {
299
+ this.reconnectTimer = setTimeout(async () => {
300
+ try {
301
+ await this.options.beforeReconnect?.();
302
+ } catch {
303
+ }
304
+ this.connect().catch(() => {
305
+ });
306
+ }, 2e3);
307
+ }
308
+ });
309
+ });
310
+ return this.connectPromise;
311
+ }
312
+ // Disconnect and stop reconnection
313
+ disconnect() {
314
+ this.shouldReconnect = false;
315
+ if (this.reconnectTimer) {
316
+ clearTimeout(this.reconnectTimer);
317
+ this.reconnectTimer = null;
318
+ }
319
+ if (this.ws) {
320
+ this.ws.close();
321
+ this.ws = null;
322
+ }
323
+ }
324
+ // Join a room for communication
325
+ async joinRoom(roomName) {
326
+ await this.sendCommand("join", { room: roomName });
327
+ this.currentRoom = roomName;
328
+ this.pluginPresent = null;
329
+ this.options.onRoomChanged?.(roomName);
330
+ logger.info(`Joined room: ${roomName}`);
331
+ }
332
+ // Ask the relay which rooms exist and who is in them
333
+ listRooms() {
334
+ return new Promise((resolve, reject) => {
335
+ const id = uuidv4();
336
+ this.tracker.add(id, resolve, reject, 5e3);
337
+ this.ws.send(JSON.stringify({ type: "list_rooms", id }));
338
+ });
339
+ }
340
+ // Auto-pair with the plugin so join_room is only needed for disambiguation:
341
+ // prefer the persisted room if its plugin is present, otherwise join the
342
+ // single plugin room, otherwise explain exactly what to do.
343
+ async ensureRoom() {
344
+ if (this.currentRoom) return;
345
+ const rooms = await this.listRooms();
346
+ const withPlugin = rooms.filter((r) => r.hasPlugin);
347
+ const saved = this.options.initialRoom;
348
+ if (saved && withPlugin.some((r) => r.room === saved)) {
349
+ return this.joinRoom(saved);
350
+ }
351
+ if (withPlugin.length === 1) {
352
+ return this.joinRoom(withPlugin[0].room);
353
+ }
354
+ if (withPlugin.length === 0) {
355
+ throw new Error(
356
+ "No Figma plugin is connected. Open the Relai plugin in Figma (it connects automatically), then try again."
357
+ );
358
+ }
359
+ throw new Error(
360
+ `Multiple Figma plugins are connected (rooms: ${withPlugin.map((r) => r.room).join(", ")}). Call join_room with the room shown in the plugin you want to control.`
361
+ );
362
+ }
363
+ // Send a command to Figma plugin via the relay
364
+ async sendCommand(command, params = {}, timeoutMs = 3e4) {
365
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
366
+ try {
367
+ await this.connect();
368
+ } catch {
369
+ throw new Error(
370
+ "Not connected to the relay. The MCP server hosts it automatically \u2014 if this persists, another app may be occupying the port."
371
+ );
372
+ }
373
+ }
374
+ if (command !== "join") {
375
+ await this.ensureRoom();
376
+ if (this.pluginPresent === false) {
377
+ throw new Error(
378
+ "The Figma plugin is not open. Open the Relai plugin in Figma \u2014 it will reconnect to the same room automatically."
379
+ );
380
+ }
381
+ }
382
+ const result = await new Promise((resolve, reject) => {
383
+ const id = uuidv4();
384
+ const request = {
385
+ id,
386
+ type: command === "join" ? "join" : "message",
387
+ room: command === "join" ? params.room : this.currentRoom,
388
+ ...command === "join" ? { role: "agent", meta: { client: "figma-relai-mcp" } } : {},
389
+ message: {
390
+ id,
391
+ command,
392
+ params: {
393
+ ...params,
394
+ commandId: id
395
+ }
396
+ }
397
+ };
398
+ this.tracker.add(id, resolve, reject, timeoutMs);
399
+ logger.info(`Sending command: ${command}`);
400
+ logger.debug(`Request: ${JSON.stringify(request)}`);
401
+ this.ws.send(JSON.stringify(request));
402
+ });
403
+ if (this.eventQueue.length > 0 && result !== null && typeof result === "object" && !Array.isArray(result)) {
404
+ return { ...result, designer_events: this.consumeEvents() };
405
+ }
406
+ return result;
407
+ }
408
+ // Drain buffered designer-activity events
409
+ consumeEvents() {
410
+ const drained = this.eventQueue;
411
+ this.eventQueue = [];
412
+ return drained;
413
+ }
414
+ // Set handler for incoming commands from other room members (e.g. plugin UI)
415
+ setCommandHandler(handler) {
416
+ this.commandHandler = handler;
417
+ }
418
+ get isConnected() {
419
+ return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
420
+ }
421
+ get room() {
422
+ return this.currentRoom;
423
+ }
424
+ };
425
+
426
+ // src/tools/index.ts
427
+ import { z as z21 } from "zod";
428
+
429
+ // src/tools/core/create.ts
430
+ var create_exports = {};
431
+ __export(create_exports, {
432
+ register: () => register
433
+ });
434
+ import { z as z2 } from "zod";
435
+
436
+ // ../shared/src/utils/color.ts
437
+ function rgbaToHex(color) {
438
+ const r = Math.round(color.r * 255);
439
+ const g = Math.round(color.g * 255);
440
+ const b = Math.round(color.b * 255);
441
+ const hex = `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
442
+ if (color.a !== void 0 && color.a < 1) {
443
+ const a = Math.round(color.a * 255);
444
+ return hex + a.toString(16).padStart(2, "0");
445
+ }
446
+ return hex;
447
+ }
448
+
449
+ // ../shared/src/utils/schemas.ts
450
+ import { z } from "zod";
451
+ var colorSchema = z.object({
452
+ r: z.number().min(0).max(1).describe("Red channel (0-1)"),
453
+ g: z.number().min(0).max(1).describe("Green channel (0-1)"),
454
+ b: z.number().min(0).max(1).describe("Blue channel (0-1)"),
455
+ a: z.number().min(0).max(1).optional().describe("Alpha channel (0-1)")
456
+ });
457
+ var dimensionSchema = z.number().positive();
458
+ var layoutModeSchema = z.enum(["NONE", "HORIZONTAL", "VERTICAL"]);
459
+ var layoutWrapSchema = z.enum(["NO_WRAP", "WRAP"]);
460
+ var primaryAxisAlignSchema = z.enum([
461
+ "MIN",
462
+ "MAX",
463
+ "CENTER",
464
+ "SPACE_BETWEEN"
465
+ ]);
466
+ var counterAxisAlignSchema = z.enum([
467
+ "MIN",
468
+ "MAX",
469
+ "CENTER",
470
+ "BASELINE"
471
+ ]);
472
+ var sizingModeSchema = z.enum(["FIXED", "HUG", "FILL"]);
473
+ var exportFormatSchema = z.enum(["PNG", "JPG", "SVG", "PDF"]);
474
+ var blendModeSchema = z.enum([
475
+ "PASS_THROUGH",
476
+ "NORMAL",
477
+ "DARKEN",
478
+ "MULTIPLY",
479
+ "LINEAR_BURN",
480
+ "COLOR_BURN",
481
+ "LIGHTEN",
482
+ "SCREEN",
483
+ "LINEAR_DODGE",
484
+ "COLOR_DODGE",
485
+ "OVERLAY",
486
+ "SOFT_LIGHT",
487
+ "HARD_LIGHT",
488
+ "DIFFERENCE",
489
+ "EXCLUSION",
490
+ "HUE",
491
+ "SATURATION",
492
+ "COLOR",
493
+ "LUMINOSITY"
494
+ ]);
495
+ var gradientTypeSchema = z.enum([
496
+ "GRADIENT_LINEAR",
497
+ "GRADIENT_RADIAL",
498
+ "GRADIENT_ANGULAR",
499
+ "GRADIENT_DIAMOND"
500
+ ]);
501
+ var gradientStopSchema = z.object({
502
+ position: z.number().min(0).max(1).describe("Stop position (0-1)"),
503
+ color: colorSchema
504
+ });
505
+ var booleanOperationSchema = z.enum([
506
+ "UNION",
507
+ "SUBTRACT",
508
+ "INTERSECT",
509
+ "EXCLUDE"
510
+ ]);
511
+ var constraintTypeSchema = z.enum([
512
+ "MIN",
513
+ "CENTER",
514
+ "MAX",
515
+ "STRETCH",
516
+ "SCALE"
517
+ ]);
518
+ var reorderDirectionSchema = z.enum([
519
+ "FRONT",
520
+ "BACK",
521
+ "FORWARD",
522
+ "BACKWARD"
523
+ ]);
524
+
525
+ // ../shared/src/relay-core.ts
526
+ var RelayCore = class {
527
+ rooms = /* @__PURE__ */ new Map();
528
+ lastActivity = /* @__PURE__ */ new Map();
529
+ staleTimer = null;
530
+ version;
531
+ log;
532
+ constructor(options = {}) {
533
+ this.version = options.version ?? "unknown";
534
+ this.log = options.log ?? (() => {
535
+ });
536
+ }
537
+ handleOpen(ws) {
538
+ this.lastActivity.set(ws, Date.now());
539
+ this.send(ws, { type: "system", message: "Please join a room to start" });
540
+ }
541
+ handleMessage(ws, raw) {
542
+ this.lastActivity.set(ws, Date.now());
543
+ let data;
544
+ try {
545
+ data = JSON.parse(raw);
546
+ } catch {
547
+ this.send(ws, { type: "error", message: "Invalid JSON" });
548
+ return;
549
+ }
550
+ switch (data.type) {
551
+ case "hello":
552
+ this.send(ws, { type: "hello", server: "figma-relai", version: this.version });
553
+ return;
554
+ case "join": {
555
+ const room = data.room;
556
+ if (!room || typeof room !== "string") {
557
+ this.send(ws, { type: "error", message: "Room name required" });
558
+ return;
559
+ }
560
+ const role = data.role === "plugin" || data.role === "agent" ? data.role : "unknown";
561
+ const peers = this.roomPeers(room);
562
+ peers.set(ws, { role, meta: data.meta });
563
+ this.log(`Client joined "${room}" as ${role} (${peers.size} clients)`);
564
+ this.send(ws, { type: "system", message: `Joined room: ${room}`, room });
565
+ this.send(ws, {
566
+ type: "system",
567
+ message: { id: data.id, result: `Connected to room: ${room}` },
568
+ room
569
+ });
570
+ this.broadcastPresence(room);
571
+ return;
572
+ }
573
+ case "list_rooms": {
574
+ const rooms = [];
575
+ for (const [room, peers] of this.rooms) {
576
+ if (peers.size === 0) continue;
577
+ rooms.push({
578
+ room,
579
+ hasPlugin: [...peers.values()].some((p) => p.role === "plugin"),
580
+ agentCount: [...peers.values()].filter((p) => p.role === "agent").length
581
+ });
582
+ }
583
+ this.send(ws, { type: "list_rooms_result", id: data.id, rooms });
584
+ return;
585
+ }
586
+ case "message": {
587
+ const room = data.room;
588
+ if (!room || typeof room !== "string") {
589
+ this.send(ws, { type: "error", message: "Room name required" });
590
+ return;
591
+ }
592
+ if (!this.rooms.get(room)?.has(ws)) {
593
+ this.send(ws, { type: "error", message: "Must join room first" });
594
+ return;
595
+ }
596
+ const payload = JSON.stringify({
597
+ type: "broadcast",
598
+ message: data.message,
599
+ sender: "peer",
600
+ room
601
+ });
602
+ const count = this.broadcast(ws, room, payload);
603
+ if (count === 0) this.log(`No peers in "${room}" to receive message`);
604
+ return;
605
+ }
606
+ case "progress_update": {
607
+ const room = data.room;
608
+ if (room && typeof room === "string" && this.rooms.get(room)?.has(ws)) {
609
+ this.broadcast(ws, room, raw);
610
+ }
611
+ return;
612
+ }
613
+ }
614
+ }
615
+ handleClose(ws) {
616
+ this.lastActivity.delete(ws);
617
+ const affected = [];
618
+ for (const [room, peers] of this.rooms) {
619
+ if (peers.delete(ws)) affected.push(room);
620
+ if (peers.size === 0) this.rooms.delete(room);
621
+ }
622
+ for (const room of affected) this.broadcastPresence(room);
623
+ }
624
+ // Periodically close connections with no activity (heartbeat)
625
+ startStaleCleanup(staleTimeoutMs = 12e4, checkIntervalMs = 3e4) {
626
+ this.staleTimer = setInterval(() => {
627
+ const now = Date.now();
628
+ for (const [ws, lastTime] of this.lastActivity) {
629
+ if (now - lastTime > staleTimeoutMs) {
630
+ this.log("Closing stale connection");
631
+ try {
632
+ ws.close();
633
+ } catch {
634
+ }
635
+ this.handleClose(ws);
636
+ }
637
+ }
638
+ }, checkIntervalMs);
639
+ }
640
+ stop() {
641
+ if (this.staleTimer) {
642
+ clearInterval(this.staleTimer);
643
+ this.staleTimer = null;
644
+ }
645
+ }
646
+ roomPeers(room) {
647
+ let peers = this.rooms.get(room);
648
+ if (!peers) {
649
+ peers = /* @__PURE__ */ new Map();
650
+ this.rooms.set(room, peers);
651
+ }
652
+ return peers;
653
+ }
654
+ // Presence goes to every member (including the newcomer) so both the plugin
655
+ // UI and the MCP server always know who is in the room
656
+ broadcastPresence(room) {
657
+ const peers = this.rooms.get(room);
658
+ if (!peers) return;
659
+ const payload = JSON.stringify({
660
+ type: "presence",
661
+ room,
662
+ peers: [...peers.values()].map((p) => ({ role: p.role, meta: p.meta }))
663
+ });
664
+ for (const ws of peers.keys()) {
665
+ try {
666
+ ws.send(payload);
667
+ } catch {
668
+ }
669
+ }
670
+ }
671
+ broadcast(sender, room, payload) {
672
+ const peers = this.rooms.get(room);
673
+ if (!peers) return 0;
674
+ let count = 0;
675
+ for (const ws of peers.keys()) {
676
+ if (ws === sender) continue;
677
+ try {
678
+ ws.send(payload);
679
+ count++;
680
+ } catch {
681
+ }
682
+ }
683
+ return count;
684
+ }
685
+ send(ws, data) {
686
+ try {
687
+ ws.send(JSON.stringify(data));
688
+ } catch {
689
+ }
690
+ }
691
+ };
692
+
693
+ // src/tools/core/helpers.ts
694
+ function textResult(text) {
695
+ return { content: [{ type: "text", text }] };
696
+ }
697
+ function jsonResult(value) {
698
+ return textResult(typeof value === "string" ? value : JSON.stringify(value, null, 2));
699
+ }
700
+ function errorResult(error) {
701
+ return textResult(`Error: ${error instanceof Error ? error.message : String(error)}`);
702
+ }
703
+
704
+ // src/tools/core/create.ts
705
+ var NODE_KINDS = [
706
+ "rectangle",
707
+ "frame",
708
+ "text",
709
+ "ellipse",
710
+ "polygon",
711
+ "star",
712
+ "line",
713
+ "section",
714
+ "slice",
715
+ "svg",
716
+ "image",
717
+ "connector",
718
+ "table",
719
+ "sticky",
720
+ "code_block"
721
+ ];
722
+ var KIND_COMMAND = {
723
+ rectangle: "create_rectangle",
724
+ frame: "create_frame",
725
+ text: "create_text",
726
+ ellipse: "create_ellipse",
727
+ polygon: "create_polygon",
728
+ star: "create_star",
729
+ line: "create_line",
730
+ section: "create_section",
731
+ slice: "create_slice",
732
+ svg: "create_node_from_svg",
733
+ image: "create_image_from_url",
734
+ connector: "create_connector",
735
+ table: "create_table",
736
+ sticky: "create_sticky",
737
+ code_block: "create_code_block"
738
+ };
739
+ function register(server, sendCommand) {
740
+ server.tool(
741
+ "create_node",
742
+ "Create a node in Figma: rectangle, frame, text, ellipse, polygon, star, line, section, slice, svg (from markup), image (from URL), or FigJam connector/table/sticky/code_block. Returns the new node's id \u2014 style it further with set_properties. For component instances use manage_components.",
743
+ {
744
+ type: z2.enum(NODE_KINDS).describe("What to create"),
745
+ x: z2.number().optional().describe("X position (default 0)"),
746
+ y: z2.number().optional().describe("Y position (default 0)"),
747
+ width: dimensionSchema.optional(),
748
+ height: dimensionSchema.optional(),
749
+ name: z2.string().optional(),
750
+ parentId: z2.string().optional().describe("Parent node id (default: current page)"),
751
+ text: z2.string().optional().describe("Text content (text/sticky)"),
752
+ fontSize: z2.number().positive().optional().describe("text only"),
753
+ fontWeight: z2.number().optional().describe("text only (e.g. 400, 700)"),
754
+ fontColor: colorSchema.optional().describe("text only"),
755
+ fillColor: colorSchema.optional().describe("frame/rectangle fill"),
756
+ pointCount: z2.number().int().min(3).optional().describe("polygon/star points"),
757
+ innerRadius: z2.number().min(0).max(1).optional().describe("star inner radius ratio"),
758
+ length: z2.number().positive().optional().describe("line length"),
759
+ rotation: z2.number().optional().describe("line rotation in degrees"),
760
+ strokeWeight: z2.number().min(0).optional().describe("line stroke weight"),
761
+ svg: z2.string().optional().describe("SVG markup (type=svg)"),
762
+ url: z2.string().optional().describe("Image URL (type=image)"),
763
+ scaleMode: z2.enum(["FILL", "FIT", "CROP", "TILE"]).optional().describe("image only"),
764
+ rows: z2.number().int().positive().optional().describe("table only"),
765
+ cols: z2.number().int().positive().optional().describe("table only"),
766
+ startNodeId: z2.string().optional().describe("connector only"),
767
+ endNodeId: z2.string().optional().describe("connector only"),
768
+ lineType: z2.enum(["ELBOWED", "STRAIGHT"]).optional().describe("connector only")
769
+ },
770
+ async ({ type, ...params }) => {
771
+ try {
772
+ const clean = Object.fromEntries(
773
+ Object.entries(params).filter(([, v]) => v !== void 0)
774
+ );
775
+ const result = await sendCommand(KIND_COMMAND[type], clean);
776
+ return jsonResult(result);
777
+ } catch (error) {
778
+ return errorResult(error);
779
+ }
780
+ }
781
+ );
782
+ }
783
+
784
+ // src/tools/core/properties.ts
785
+ var properties_exports = {};
786
+ __export(properties_exports, {
787
+ mapPropertiesToCommands: () => mapPropertiesToCommands,
788
+ propertiesSchema: () => propertiesSchema,
789
+ register: () => register2
790
+ });
791
+ import { z as z3 } from "zod";
792
+ var effectSchema = z3.object({
793
+ type: z3.enum(["DROP_SHADOW", "INNER_SHADOW", "LAYER_BLUR", "BACKGROUND_BLUR"]),
794
+ color: colorSchema.optional().describe("Shadow color (shadows only)"),
795
+ offsetX: z3.number().optional(),
796
+ offsetY: z3.number().optional(),
797
+ radius: z3.number().min(0).optional().describe("Blur radius"),
798
+ spread: z3.number().optional()
799
+ });
800
+ var propertiesSchema = z3.object({
801
+ // Geometry
802
+ x: z3.number().optional(),
803
+ y: z3.number().optional(),
804
+ width: dimensionSchema.optional(),
805
+ height: dimensionSchema.optional(),
806
+ rotation: z3.number().optional().describe("Degrees"),
807
+ // Identity / visibility
808
+ name: z3.string().optional(),
809
+ visible: z3.boolean().optional(),
810
+ locked: z3.boolean().optional(),
811
+ opacity: z3.number().min(0).max(1).optional(),
812
+ blendMode: blendModeSchema.optional(),
813
+ // Paint
814
+ fillColor: colorSchema.optional(),
815
+ strokeColor: colorSchema.optional(),
816
+ strokeWeight: z3.number().min(0).optional(),
817
+ cornerRadius: z3.number().min(0).optional(),
818
+ corners: z3.array(z3.boolean()).length(4).optional().describe("With cornerRadius: apply per corner [TL, TR, BR, BL]"),
819
+ gradient: z3.object({ gradientType: gradientTypeSchema, stops: z3.array(gradientStopSchema).min(2) }).optional(),
820
+ // Effects (replaces existing effects list entry of same type)
821
+ effects: z3.array(effectSchema).optional(),
822
+ removeEffects: z3.boolean().optional(),
823
+ // Text
824
+ text: z3.string().optional().describe("Text content (TEXT nodes)"),
825
+ // Auto-layout (frame itself)
826
+ layoutMode: layoutModeSchema.optional(),
827
+ layoutWrap: layoutWrapSchema.optional().describe("WRAP requires layoutMode HORIZONTAL"),
828
+ paddingTop: z3.number().min(0).optional(),
829
+ paddingRight: z3.number().min(0).optional(),
830
+ paddingBottom: z3.number().min(0).optional(),
831
+ paddingLeft: z3.number().min(0).optional(),
832
+ primaryAxisAlignItems: primaryAxisAlignSchema.optional(),
833
+ counterAxisAlignItems: counterAxisAlignSchema.optional(),
834
+ itemSpacing: z3.number().optional(),
835
+ counterAxisSpacing: z3.number().optional(),
836
+ // Auto-layout (as a child; FILL requires the PARENT to have auto-layout)
837
+ layoutSizingHorizontal: sizingModeSchema.optional(),
838
+ layoutSizingVertical: sizingModeSchema.optional(),
839
+ // Constraints & sizing limits
840
+ constraintsHorizontal: constraintTypeSchema.optional(),
841
+ constraintsVertical: constraintTypeSchema.optional(),
842
+ minWidth: z3.number().min(0).optional(),
843
+ maxWidth: z3.number().min(0).optional(),
844
+ minHeight: z3.number().min(0).optional(),
845
+ maxHeight: z3.number().min(0).optional(),
846
+ aspectRatioLocked: z3.boolean().optional(),
847
+ clipsContent: z3.boolean().optional(),
848
+ // Design-system bindings
849
+ styleId: z3.string().optional().describe("Apply a paint/text/effect/grid style by id"),
850
+ styleType: z3.enum(["PAINT", "TEXT", "EFFECT", "GRID"]).optional(),
851
+ boundVariables: z3.record(z3.string()).optional().describe('Bind variables: {"fills": "VariableID:...", "width": "..."}')
852
+ }).strict();
853
+ function defined(obj) {
854
+ return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== void 0));
855
+ }
856
+ function mapPropertiesToCommands(nodeId, p) {
857
+ const calls = [];
858
+ const add = (command, params) => calls.push({ command, params: { nodeId, ...defined(params) } });
859
+ if (p.name !== void 0) add("rename_node", { name: p.name });
860
+ if (p.visible !== void 0) add("set_visible", { visible: p.visible });
861
+ if (p.locked !== void 0) add("set_locked", { locked: p.locked });
862
+ if (p.x !== void 0 || p.y !== void 0) add("move_node", { x: p.x, y: p.y });
863
+ if (p.width !== void 0 || p.height !== void 0)
864
+ add("resize_node", { width: p.width, height: p.height });
865
+ if (p.rotation !== void 0) add("set_rotation", { rotation: p.rotation });
866
+ if (p.layoutMode !== void 0)
867
+ add("set_layout_mode", { layoutMode: p.layoutMode, layoutWrap: p.layoutWrap });
868
+ if (p.paddingTop !== void 0 || p.paddingRight !== void 0 || p.paddingBottom !== void 0 || p.paddingLeft !== void 0)
869
+ add("set_padding", {
870
+ paddingTop: p.paddingTop,
871
+ paddingRight: p.paddingRight,
872
+ paddingBottom: p.paddingBottom,
873
+ paddingLeft: p.paddingLeft
874
+ });
875
+ if (p.primaryAxisAlignItems !== void 0 || p.counterAxisAlignItems !== void 0)
876
+ add("set_axis_align", {
877
+ primaryAxisAlignItems: p.primaryAxisAlignItems,
878
+ counterAxisAlignItems: p.counterAxisAlignItems
879
+ });
880
+ if (p.itemSpacing !== void 0 || p.counterAxisSpacing !== void 0)
881
+ add("set_item_spacing", {
882
+ itemSpacing: p.itemSpacing,
883
+ counterAxisSpacing: p.counterAxisSpacing
884
+ });
885
+ if (p.layoutSizingHorizontal !== void 0 || p.layoutSizingVertical !== void 0)
886
+ add("set_layout_sizing", {
887
+ layoutSizingHorizontal: p.layoutSizingHorizontal,
888
+ layoutSizingVertical: p.layoutSizingVertical
889
+ });
890
+ if (p.fillColor !== void 0) add("set_fill_color", { color: p.fillColor });
891
+ if (p.strokeColor !== void 0 || p.strokeWeight !== void 0)
892
+ add("set_stroke_color", {
893
+ color: p.strokeColor ?? { r: 0, g: 0, b: 0, a: 1 },
894
+ weight: p.strokeWeight
895
+ });
896
+ if (p.gradient !== void 0)
897
+ add("set_gradient_fill", { gradientType: p.gradient.gradientType, stops: p.gradient.stops });
898
+ if (p.cornerRadius !== void 0)
899
+ add("set_corner_radius", { radius: p.cornerRadius, corners: p.corners });
900
+ if (p.opacity !== void 0) add("set_opacity", { opacity: p.opacity });
901
+ if (p.blendMode !== void 0) add("set_blend_mode", { blendMode: p.blendMode });
902
+ if (p.removeEffects) add("remove_effects", {});
903
+ for (const effect of p.effects ?? []) {
904
+ const common = {
905
+ color: effect.color,
906
+ offsetX: effect.offsetX,
907
+ offsetY: effect.offsetY,
908
+ radius: effect.radius,
909
+ spread: effect.spread
910
+ };
911
+ if (effect.type === "DROP_SHADOW") add("add_drop_shadow", common);
912
+ else if (effect.type === "INNER_SHADOW") add("add_inner_shadow", common);
913
+ else if (effect.type === "LAYER_BLUR") add("add_blur", { radius: effect.radius });
914
+ else add("add_background_blur", { radius: effect.radius });
915
+ }
916
+ if (p.text !== void 0) add("set_text_content", { text: p.text });
917
+ if (p.constraintsHorizontal !== void 0 || p.constraintsVertical !== void 0)
918
+ add("set_constraints", {
919
+ horizontal: p.constraintsHorizontal,
920
+ vertical: p.constraintsVertical
921
+ });
922
+ if (p.minWidth !== void 0 || p.maxWidth !== void 0 || p.minHeight !== void 0 || p.maxHeight !== void 0)
923
+ add("set_min_max_size", {
924
+ minWidth: p.minWidth,
925
+ maxWidth: p.maxWidth,
926
+ minHeight: p.minHeight,
927
+ maxHeight: p.maxHeight
928
+ });
929
+ if (p.aspectRatioLocked !== void 0) add("set_aspect_ratio", { lock: p.aspectRatioLocked });
930
+ if (p.clipsContent !== void 0) add("set_clips_content", { clipsContent: p.clipsContent });
931
+ if (p.styleId !== void 0)
932
+ add("apply_style", { styleId: p.styleId, styleType: p.styleType });
933
+ for (const [property, variableId] of Object.entries(p.boundVariables ?? {})) {
934
+ add("bind_variable", { property, variableId });
935
+ }
936
+ return calls;
937
+ }
938
+ function register2(server, sendCommand) {
939
+ server.tool(
940
+ "set_properties",
941
+ "Set any visual/layout/text properties on one or more nodes in a single call: geometry (x/y/width/height/rotation), fills/strokes/gradients/cornerRadius/opacity/effects, text content, auto-layout (layoutMode, padding, alignment, spacing, layoutSizing \u2014 FILL requires the parent to have auto-layout), constraints, style application, and variable bindings. Groups map to focused operations with per-operation success/error reporting.",
942
+ {
943
+ nodeIds: z3.array(z3.string()).min(1).describe("Node IDs to update"),
944
+ properties: propertiesSchema.describe("Properties to apply to every listed node")
945
+ },
946
+ { idempotentHint: true },
947
+ async ({ nodeIds, properties }) => {
948
+ try {
949
+ const commands = nodeIds.flatMap(
950
+ (nodeId) => mapPropertiesToCommands(nodeId, properties)
951
+ );
952
+ if (commands.length === 0) return textResult("No properties given \u2014 nothing to do.");
953
+ if (commands.length === 1) {
954
+ const result = await sendCommand(commands[0].command, commands[0].params);
955
+ return jsonResult(result);
956
+ }
957
+ const results = await sendCommand(
958
+ "batch_execute",
959
+ { commands },
960
+ Math.max(3e4, commands.length * 5e3)
961
+ );
962
+ return jsonResult(results);
963
+ } catch (error) {
964
+ return errorResult(error);
965
+ }
966
+ }
967
+ );
968
+ }
969
+
970
+ // src/tools/core/structure.ts
971
+ var structure_exports = {};
972
+ __export(structure_exports, {
973
+ register: () => register3
974
+ });
975
+ import { z as z4 } from "zod";
976
+ function register3(server, sendCommand) {
977
+ server.tool(
978
+ "edit_structure",
979
+ "Structural operations on nodes: group/ungroup, reparent (move into another parent), reorder (z-order), clone, flatten to vector, boolean operations (union/subtract/intersect/exclude), delete. group/boolean/delete take nodeIds; the rest take nodeId.",
980
+ {
981
+ operation: z4.enum([
982
+ "group",
983
+ "ungroup",
984
+ "reparent",
985
+ "reorder",
986
+ "clone",
987
+ "flatten",
988
+ "boolean",
989
+ "delete"
990
+ ]),
991
+ nodeId: z4.string().optional().describe("Target node (ungroup/reparent/reorder/clone/flatten)"),
992
+ nodeIds: z4.array(z4.string()).optional().describe("Target nodes (group/boolean/delete)"),
993
+ parentId: z4.string().optional().describe("reparent: new parent"),
994
+ index: z4.number().int().min(0).optional().describe("reparent: insertion index"),
995
+ direction: reorderDirectionSchema.optional().describe("reorder direction"),
996
+ booleanOperation: booleanOperationSchema.optional().describe("boolean: which operation"),
997
+ x: z4.number().optional().describe("clone: position of the copy"),
998
+ y: z4.number().optional()
999
+ },
1000
+ async ({ operation, nodeId, nodeIds, parentId, index, direction, booleanOperation, x, y }) => {
1001
+ try {
1002
+ const need = (value, what) => {
1003
+ if (value === void 0) throw new Error(`"${operation}" requires ${what}`);
1004
+ };
1005
+ let result;
1006
+ switch (operation) {
1007
+ case "group":
1008
+ need(nodeIds, "nodeIds");
1009
+ result = await sendCommand("group_nodes", { nodeIds });
1010
+ break;
1011
+ case "ungroup":
1012
+ need(nodeId, "nodeId");
1013
+ result = await sendCommand("ungroup_nodes", { nodeId });
1014
+ break;
1015
+ case "reparent":
1016
+ need(nodeId, "nodeId");
1017
+ need(parentId, "parentId");
1018
+ result = await sendCommand("reparent_node", { nodeId, parentId, index });
1019
+ break;
1020
+ case "reorder":
1021
+ need(nodeId, "nodeId");
1022
+ need(direction, "direction");
1023
+ result = await sendCommand("reorder_node", { nodeId, direction });
1024
+ break;
1025
+ case "clone":
1026
+ need(nodeId, "nodeId");
1027
+ result = await sendCommand("clone_node", { nodeId, x, y });
1028
+ break;
1029
+ case "flatten":
1030
+ need(nodeId, "nodeId");
1031
+ result = await sendCommand("flatten_node", { nodeId });
1032
+ break;
1033
+ case "boolean":
1034
+ need(nodeIds, "nodeIds");
1035
+ need(booleanOperation, "booleanOperation");
1036
+ result = await sendCommand("boolean_operation", {
1037
+ nodeIds,
1038
+ operation: booleanOperation
1039
+ });
1040
+ break;
1041
+ case "delete":
1042
+ if (nodeIds) result = await sendCommand("delete_multiple_nodes", { nodeIds });
1043
+ else {
1044
+ need(nodeId, "nodeId or nodeIds");
1045
+ result = await sendCommand("delete_node", { nodeId });
1046
+ }
1047
+ break;
1048
+ }
1049
+ return jsonResult(result);
1050
+ } catch (error) {
1051
+ return errorResult(error);
1052
+ }
1053
+ }
1054
+ );
1055
+ }
1056
+
1057
+ // src/tools/core/text.ts
1058
+ var text_exports = {};
1059
+ __export(text_exports, {
1060
+ register: () => register4
1061
+ });
1062
+ import { z as z5 } from "zod";
1063
+ function register4(server, sendCommand) {
1064
+ server.tool(
1065
+ "set_text",
1066
+ "Edit text nodes. Modes: single (nodeId + text), bulk (items \u2014 efficient for many nodes, e.g. translations), or character-range styling (nodeId + range with fontSize/fontWeight/letterSpacing/lineHeight). Fonts load automatically with fallbacks. To find text nodes first, use search_nodes or get_selection_context.",
1067
+ {
1068
+ nodeId: z5.string().optional().describe("Target text node (single / range mode)"),
1069
+ text: z5.string().optional().describe("New content (single mode)"),
1070
+ items: z5.array(z5.object({ nodeId: z5.string(), text: z5.string() })).optional().describe("Bulk replacements"),
1071
+ range: z5.object({
1072
+ start: z5.number().int().min(0),
1073
+ end: z5.number().int().positive(),
1074
+ fontSize: z5.number().positive().optional(),
1075
+ fontWeight: z5.number().optional(),
1076
+ letterSpacing: z5.number().optional(),
1077
+ lineHeight: z5.number().optional()
1078
+ }).refine((r) => r.end > r.start, { message: "end must be greater than start" }).optional().describe("Style a character range [start, end)")
1079
+ },
1080
+ async ({ nodeId, text, items, range }) => {
1081
+ try {
1082
+ if (items?.length) {
1083
+ const result = await sendCommand(
1084
+ "set_multiple_text_contents",
1085
+ { text: items },
1086
+ Math.max(3e4, items.length * 2e3)
1087
+ );
1088
+ return jsonResult(result);
1089
+ }
1090
+ if (nodeId && range) {
1091
+ const result = await sendCommand("set_text_style_range", { nodeId, ...range });
1092
+ return jsonResult(result);
1093
+ }
1094
+ if (nodeId && text !== void 0) {
1095
+ const result = await sendCommand("set_text_content", { nodeId, text });
1096
+ return jsonResult(result);
1097
+ }
1098
+ return textResult(
1099
+ "Provide either items (bulk), nodeId + text (single), or nodeId + range (styling)."
1100
+ );
1101
+ } catch (error) {
1102
+ return errorResult(error);
1103
+ }
1104
+ }
1105
+ );
1106
+ }
1107
+
1108
+ // src/tools/core/components.ts
1109
+ var components_exports = {};
1110
+ __export(components_exports, {
1111
+ register: () => register5
1112
+ });
1113
+ import { z as z6 } from "zod";
1114
+ function register5(server, sendCommand) {
1115
+ server.tool(
1116
+ "manage_components",
1117
+ "Component workflow: list local components, create a component from a node, create_set (combine components as variants), instantiate (place an instance by componentKey \u2014 imports from the team library if needed), get_props / set_props (component properties on an instance), get_overrides / set_overrides (copy overrides from a source instance to targets), detach.",
1118
+ {
1119
+ action: z6.enum([
1120
+ "list",
1121
+ "create",
1122
+ "create_set",
1123
+ "instantiate",
1124
+ "get_props",
1125
+ "set_props",
1126
+ "get_overrides",
1127
+ "set_overrides",
1128
+ "detach"
1129
+ ]),
1130
+ nodeId: z6.string().optional().describe("Target node (create/get_props/set_props/detach)"),
1131
+ componentIds: z6.array(z6.string()).optional().describe("create_set: components to combine"),
1132
+ componentKey: z6.string().optional().describe("instantiate: component key (or node id)"),
1133
+ x: z6.number().optional().describe("instantiate: position"),
1134
+ y: z6.number().optional(),
1135
+ properties: z6.record(z6.union([z6.string(), z6.boolean()])).optional().describe("set_props: property name \u2192 value (variant/text/boolean/swap)"),
1136
+ sourceInstanceId: z6.string().optional().describe("set_overrides: copy from this instance"),
1137
+ targetNodeIds: z6.array(z6.string()).optional().describe("set_overrides: apply to these"),
1138
+ instanceNodeId: z6.string().optional().describe("get_overrides: instance to inspect")
1139
+ },
1140
+ async (args2) => {
1141
+ try {
1142
+ let result;
1143
+ switch (args2.action) {
1144
+ case "list":
1145
+ result = await sendCommand("get_local_components", {}, 6e4);
1146
+ break;
1147
+ case "create":
1148
+ result = await sendCommand("create_component_from_node", { nodeId: args2.nodeId });
1149
+ break;
1150
+ case "create_set":
1151
+ result = await sendCommand("create_component_set", { componentIds: args2.componentIds });
1152
+ break;
1153
+ case "instantiate":
1154
+ result = await sendCommand("create_component_instance", {
1155
+ componentKey: args2.componentKey,
1156
+ x: args2.x,
1157
+ y: args2.y
1158
+ });
1159
+ break;
1160
+ case "get_props":
1161
+ result = await sendCommand("get_component_properties", { nodeId: args2.nodeId });
1162
+ break;
1163
+ case "set_props":
1164
+ result = await sendCommand("set_component_properties", {
1165
+ nodeId: args2.nodeId,
1166
+ properties: args2.properties
1167
+ });
1168
+ break;
1169
+ case "get_overrides":
1170
+ result = await sendCommand("get_instance_overrides", {
1171
+ instanceNodeId: args2.instanceNodeId ?? args2.nodeId
1172
+ });
1173
+ break;
1174
+ case "set_overrides":
1175
+ result = await sendCommand("set_instance_overrides", {
1176
+ sourceInstanceId: args2.sourceInstanceId,
1177
+ targetNodeIds: args2.targetNodeIds
1178
+ });
1179
+ break;
1180
+ case "detach":
1181
+ result = await sendCommand("detach_instance", { nodeId: args2.nodeId });
1182
+ break;
1183
+ }
1184
+ return jsonResult(result);
1185
+ } catch (error) {
1186
+ return errorResult(error);
1187
+ }
1188
+ }
1189
+ );
1190
+ }
1191
+
1192
+ // src/tools/core/variables.ts
1193
+ var variables_exports = {};
1194
+ __export(variables_exports, {
1195
+ register: () => register6
1196
+ });
1197
+ import { z as z7 } from "zod";
1198
+ var ACTIONS = {
1199
+ list_collections: ["get_variable_collections", []],
1200
+ list: ["get_variables", ["collectionId"]],
1201
+ create_collection: ["create_variable_collection", ["name", "modes"]],
1202
+ update_collection: ["update_variable_collection", ["collectionId", "name", "hiddenFromPublishing"]],
1203
+ delete_collection: ["delete_variable_collection", ["collectionId"]],
1204
+ create: ["create_variable", ["collectionId", "name", "resolvedType", "value"]],
1205
+ update: ["update_variable", ["variableId", "modeId", "value", "name", "description", "hiddenFromPublishing"]],
1206
+ delete: ["delete_variable", ["variableId"]],
1207
+ add_mode: ["add_mode", ["collectionId", "name"]],
1208
+ remove_mode: ["remove_mode", ["collectionId", "modeId"]],
1209
+ rename_mode: ["rename_mode", ["collectionId", "modeId", "name"]],
1210
+ set_scopes: ["set_variable_scopes", ["variableId", "scopes"]],
1211
+ set_code_syntax: ["set_variable_code_syntax", ["variableId", "platform", "value"]],
1212
+ remove_code_syntax: ["remove_variable_code_syntax", ["variableId", "platform"]],
1213
+ create_alias: ["create_variable_alias", ["variableId", "targetVariableId", "modeId"]],
1214
+ bind: ["bind_variable", ["nodeId", "variableId", "property"]],
1215
+ unbind: ["unbind_variable", ["nodeId", "property"]],
1216
+ set_node_mode: ["set_node_variable_mode", ["nodeId", "collectionId", "modeId"]],
1217
+ get_node_modes: ["get_resolved_variable_modes", ["nodeId"]]
1218
+ };
1219
+ function register6(server, sendCommand) {
1220
+ server.tool(
1221
+ "manage_variables",
1222
+ "Design-token variables: list_collections / list (variables in a collection) / create_collection / update_collection / delete_collection / create / update / delete / add_mode / remove_mode / rename_mode / set_scopes / set_code_syntax / remove_code_syntax / create_alias / bind (variable\u2192node property) / unbind / set_node_mode / get_node_modes. Pass only the fields the action needs.",
1223
+ {
1224
+ action: z7.enum(Object.keys(ACTIONS)),
1225
+ collectionId: z7.string().optional(),
1226
+ variableId: z7.string().optional(),
1227
+ targetVariableId: z7.string().optional().describe("create_alias: variable to alias to"),
1228
+ nodeId: z7.string().optional().describe("bind/unbind/set_node_mode/get_node_modes"),
1229
+ name: z7.string().optional(),
1230
+ description: z7.string().optional(),
1231
+ modes: z7.array(z7.string()).optional().describe("create_collection: mode names"),
1232
+ modeId: z7.string().optional(),
1233
+ resolvedType: z7.enum(["COLOR", "FLOAT", "STRING", "BOOLEAN"]).optional(),
1234
+ value: z7.unknown().optional().describe("Variable value (color object, number, string, bool)"),
1235
+ property: z7.string().optional().describe('bind/unbind: node property (e.g. "fills", "width")'),
1236
+ scopes: z7.array(z7.string()).optional(),
1237
+ platform: z7.enum(["WEB", "ANDROID", "iOS"]).optional(),
1238
+ hiddenFromPublishing: z7.boolean().optional()
1239
+ },
1240
+ async (args2) => {
1241
+ try {
1242
+ const [command, fields] = ACTIONS[args2.action];
1243
+ const params = Object.fromEntries(
1244
+ fields.map((f) => [f, args2[f]]).filter(([, v]) => v !== void 0)
1245
+ );
1246
+ const result = await sendCommand(command, params);
1247
+ return jsonResult(result);
1248
+ } catch (error) {
1249
+ return errorResult(error);
1250
+ }
1251
+ }
1252
+ );
1253
+ }
1254
+
1255
+ // src/tools/core/styles.ts
1256
+ var styles_exports = {};
1257
+ __export(styles_exports, {
1258
+ register: () => register7
1259
+ });
1260
+ import { z as z8 } from "zod";
1261
+ function register7(server, sendCommand) {
1262
+ server.tool(
1263
+ "manage_styles",
1264
+ "Shared styles: list all local styles; create_paint / create_text / create_effect / create_grid; update (name/properties); delete; apply (style to a node); reorder. To bind variables instead of styles, use manage_variables.",
1265
+ {
1266
+ action: z8.enum([
1267
+ "list",
1268
+ "create_paint",
1269
+ "create_text",
1270
+ "create_effect",
1271
+ "create_grid",
1272
+ "update",
1273
+ "delete",
1274
+ "apply",
1275
+ "reorder"
1276
+ ]),
1277
+ name: z8.string().optional().describe("Style name (create_*/update)"),
1278
+ styleId: z8.string().optional().describe("Target style (update/delete/apply/reorder)"),
1279
+ styleType: z8.enum(["PAINT", "TEXT", "EFFECT", "GRID"]).optional().describe("apply/reorder"),
1280
+ nodeId: z8.string().optional().describe("apply: target node"),
1281
+ color: colorSchema.optional().describe("create_paint/create_effect"),
1282
+ gradientType: gradientTypeSchema.optional(),
1283
+ gradientStops: z8.array(gradientStopSchema).optional(),
1284
+ fontFamily: z8.string().optional().describe("create_text"),
1285
+ fontSize: z8.number().positive().optional(),
1286
+ fontWeight: z8.number().optional(),
1287
+ lineHeight: z8.number().optional(),
1288
+ letterSpacing: z8.number().optional(),
1289
+ effectType: z8.enum(["DROP_SHADOW", "INNER_SHADOW", "LAYER_BLUR", "BACKGROUND_BLUR"]).optional().describe("create_effect"),
1290
+ offsetX: z8.number().optional(),
1291
+ offsetY: z8.number().optional(),
1292
+ radius: z8.number().min(0).optional(),
1293
+ spread: z8.number().optional(),
1294
+ grids: z8.array(z8.record(z8.unknown())).optional().describe("create_grid: layout grid objects"),
1295
+ properties: z8.record(z8.unknown()).optional().describe("update: properties to change"),
1296
+ afterStyleId: z8.string().optional().describe("reorder: place after this style")
1297
+ },
1298
+ async (args2) => {
1299
+ try {
1300
+ let result;
1301
+ switch (args2.action) {
1302
+ case "list":
1303
+ result = await sendCommand("get_styles", {});
1304
+ break;
1305
+ case "create_paint":
1306
+ result = await sendCommand("create_paint_style", {
1307
+ name: args2.name,
1308
+ color: args2.color,
1309
+ gradientType: args2.gradientType,
1310
+ gradientStops: args2.gradientStops
1311
+ });
1312
+ break;
1313
+ case "create_text":
1314
+ result = await sendCommand("create_text_style", {
1315
+ name: args2.name,
1316
+ fontFamily: args2.fontFamily,
1317
+ fontSize: args2.fontSize,
1318
+ fontWeight: args2.fontWeight,
1319
+ lineHeight: args2.lineHeight,
1320
+ letterSpacing: args2.letterSpacing
1321
+ });
1322
+ break;
1323
+ case "create_effect":
1324
+ result = await sendCommand("create_effect_style", {
1325
+ name: args2.name,
1326
+ effectType: args2.effectType,
1327
+ color: args2.color,
1328
+ offsetX: args2.offsetX,
1329
+ offsetY: args2.offsetY,
1330
+ radius: args2.radius,
1331
+ spread: args2.spread
1332
+ });
1333
+ break;
1334
+ case "create_grid":
1335
+ result = await sendCommand("create_grid_style", { name: args2.name, grids: args2.grids });
1336
+ break;
1337
+ case "update":
1338
+ result = await sendCommand("update_style", {
1339
+ styleId: args2.styleId,
1340
+ name: args2.name,
1341
+ properties: args2.properties
1342
+ });
1343
+ break;
1344
+ case "delete":
1345
+ result = await sendCommand("delete_style", { styleId: args2.styleId });
1346
+ break;
1347
+ case "apply":
1348
+ result = await sendCommand("apply_style", {
1349
+ nodeId: args2.nodeId,
1350
+ styleId: args2.styleId,
1351
+ styleType: args2.styleType
1352
+ });
1353
+ break;
1354
+ case "reorder":
1355
+ result = await sendCommand("reorder_style", {
1356
+ styleId: args2.styleId,
1357
+ afterStyleId: args2.afterStyleId,
1358
+ styleType: args2.styleType
1359
+ });
1360
+ break;
1361
+ }
1362
+ return jsonResult(result);
1363
+ } catch (error) {
1364
+ return errorResult(error);
1365
+ }
1366
+ }
1367
+ );
1368
+ }
1369
+
1370
+ // src/tools/core/pages.ts
1371
+ var pages_exports = {};
1372
+ __export(pages_exports, {
1373
+ register: () => register8
1374
+ });
1375
+ import { z as z9 } from "zod";
1376
+ function register8(server, sendCommand) {
1377
+ server.tool(
1378
+ "manage_pages",
1379
+ "Page operations: list all pages, create/rename/delete a page, or set a page's background color. To make a page current, use navigate with switch_page.",
1380
+ {
1381
+ action: z9.enum(["list", "create", "rename", "delete", "set_background"]),
1382
+ pageId: z9.string().optional().describe("Target page (rename/delete/set_background)"),
1383
+ name: z9.string().optional().describe("Page name (create/rename)"),
1384
+ color: colorSchema.optional().describe("Background color (set_background)")
1385
+ },
1386
+ async ({ action, pageId, name, color }) => {
1387
+ try {
1388
+ let result;
1389
+ switch (action) {
1390
+ case "list":
1391
+ result = await sendCommand("get_pages", {});
1392
+ break;
1393
+ case "create":
1394
+ result = await sendCommand("create_page", { name });
1395
+ break;
1396
+ case "rename":
1397
+ result = await sendCommand("rename_page", { pageId, name });
1398
+ break;
1399
+ case "delete":
1400
+ result = await sendCommand("delete_page", { pageId });
1401
+ break;
1402
+ case "set_background":
1403
+ result = await sendCommand("set_page_background", { pageId, color });
1404
+ break;
1405
+ }
1406
+ return jsonResult(result);
1407
+ } catch (error) {
1408
+ return errorResult(error);
1409
+ }
1410
+ }
1411
+ );
1412
+ }
1413
+
1414
+ // src/tools/core/navigate.ts
1415
+ var navigate_exports = {};
1416
+ __export(navigate_exports, {
1417
+ register: () => register9
1418
+ });
1419
+ import { z as z10 } from "zod";
1420
+ function register9(server, sendCommand) {
1421
+ server.tool(
1422
+ "navigate",
1423
+ "Control the designer's view: focus (scroll+zoom to a node), select (set the selection), get_viewport / set_viewport (x/y/zoom), or switch_page.",
1424
+ {
1425
+ action: z10.enum(["focus", "select", "get_viewport", "set_viewport", "switch_page"]),
1426
+ nodeId: z10.string().optional().describe("focus target"),
1427
+ nodeIds: z10.array(z10.string()).optional().describe("select targets"),
1428
+ x: z10.number().optional().describe("set_viewport center x"),
1429
+ y: z10.number().optional(),
1430
+ zoom: z10.number().positive().optional(),
1431
+ pageId: z10.string().optional().describe("switch_page target")
1432
+ },
1433
+ async ({ action, nodeId, nodeIds, x, y, zoom, pageId }) => {
1434
+ try {
1435
+ let result;
1436
+ switch (action) {
1437
+ case "focus":
1438
+ result = await sendCommand("set_focus", { nodeId });
1439
+ break;
1440
+ case "select":
1441
+ result = await sendCommand("set_selections", { nodeIds });
1442
+ break;
1443
+ case "get_viewport":
1444
+ result = await sendCommand("get_viewport", {});
1445
+ break;
1446
+ case "set_viewport":
1447
+ result = await sendCommand("set_viewport", { x, y, zoom });
1448
+ break;
1449
+ case "switch_page":
1450
+ result = await sendCommand("switch_page", { pageId });
1451
+ break;
1452
+ }
1453
+ return jsonResult(result);
1454
+ } catch (error) {
1455
+ return errorResult(error);
1456
+ }
1457
+ }
1458
+ );
1459
+ }
1460
+
1461
+ // src/tools/core/assets.ts
1462
+ var assets_exports = {};
1463
+ __export(assets_exports, {
1464
+ register: () => register10
1465
+ });
1466
+ import { z as z11 } from "zod";
1467
+ function register10(server, sendCommand) {
1468
+ server.tool(
1469
+ "export_asset",
1470
+ "Export a node as PNG/JPG/SVG/PDF. Returns base64 data for saving to disk. For a quick visual check of your work, prefer the screenshot tool (returns a viewable image).",
1471
+ {
1472
+ nodeId: z11.string().describe("Node to export"),
1473
+ format: exportFormatSchema.optional().describe("Default PNG"),
1474
+ scale: z11.number().positive().optional().describe("Export scale (default 1)")
1475
+ },
1476
+ { readOnlyHint: true },
1477
+ async ({ nodeId, format, scale }) => {
1478
+ try {
1479
+ const result = await sendCommand("export_node_as_image", { nodeId, format, scale }, 6e4);
1480
+ return jsonResult(result);
1481
+ } catch (error) {
1482
+ return errorResult(error);
1483
+ }
1484
+ }
1485
+ );
1486
+ server.tool(
1487
+ "add_image",
1488
+ "Place an image: from a URL (creates a new image node) or as a fill on an existing node (base64 imageData). scaleMode controls how the image fits.",
1489
+ {
1490
+ url: z11.string().optional().describe("Image URL \u2014 creates a new node"),
1491
+ nodeId: z11.string().optional().describe("Existing node \u2014 sets its fill from imageData"),
1492
+ imageData: z11.string().optional().describe("Base64 image data (with nodeId)"),
1493
+ x: z11.number().optional(),
1494
+ y: z11.number().optional(),
1495
+ width: z11.number().positive().optional(),
1496
+ height: z11.number().positive().optional(),
1497
+ name: z11.string().optional(),
1498
+ scaleMode: z11.enum(["FILL", "FIT", "CROP", "TILE"]).optional()
1499
+ },
1500
+ async ({ url, nodeId, imageData, x, y, width, height, name, scaleMode }) => {
1501
+ try {
1502
+ if (url) {
1503
+ const result = await sendCommand(
1504
+ "create_image_from_url",
1505
+ { url, x, y, width, height, name, scaleMode },
1506
+ 6e4
1507
+ );
1508
+ return jsonResult(result);
1509
+ }
1510
+ if (nodeId && imageData) {
1511
+ const result = await sendCommand("set_image_fill", { nodeId, imageData, scaleMode }, 6e4);
1512
+ return jsonResult(result);
1513
+ }
1514
+ return textResult("Provide either url (new image node) or nodeId + imageData (image fill).");
1515
+ } catch (error) {
1516
+ return errorResult(error);
1517
+ }
1518
+ }
1519
+ );
1520
+ }
1521
+
1522
+ // src/tools/core/library.ts
1523
+ var library_exports = {};
1524
+ __export(library_exports, {
1525
+ register: () => register11
1526
+ });
1527
+ import { z as z12 } from "zod";
1528
+ function register11(server, sendCommand) {
1529
+ server.tool(
1530
+ "import_from_library",
1531
+ "Import a component, style, or variable from an enabled team library by its key. Component keys come from the design system or get_local_components; imported components can then be instantiated with manage_components.",
1532
+ {
1533
+ kind: z12.enum(["component", "style", "variable"]),
1534
+ key: z12.string().describe("Library key")
1535
+ },
1536
+ async ({ kind, key }) => {
1537
+ try {
1538
+ const command = kind === "component" ? "import_component_by_key" : kind === "style" ? "import_style_by_key" : "import_variable_by_key";
1539
+ const result = await sendCommand(command, { key }, 6e4);
1540
+ return jsonResult(result);
1541
+ } catch (error) {
1542
+ return errorResult(error);
1543
+ }
1544
+ }
1545
+ );
1546
+ }
1547
+
1548
+ // src/tools/core/annotate.ts
1549
+ var annotate_exports = {};
1550
+ __export(annotate_exports, {
1551
+ register: () => register12
1552
+ });
1553
+ import { z as z13 } from "zod";
1554
+ function register12(server, sendCommand) {
1555
+ server.tool(
1556
+ "annotate",
1557
+ "Read or write Dev Mode annotations. get: read annotations on a node. set: add/update one annotation (labelMarkdown). set_multiple: batch annotations.",
1558
+ {
1559
+ action: z13.enum(["get", "set", "set_multiple"]),
1560
+ nodeId: z13.string().optional().describe("Target node (get/set)"),
1561
+ labelMarkdown: z13.string().optional().describe("Annotation text (set)"),
1562
+ categoryId: z13.string().optional(),
1563
+ annotations: z13.array(
1564
+ z13.object({
1565
+ nodeId: z13.string(),
1566
+ labelMarkdown: z13.string(),
1567
+ categoryId: z13.string().optional()
1568
+ })
1569
+ ).optional().describe("Batch annotations (set_multiple)")
1570
+ },
1571
+ async ({ action, nodeId, labelMarkdown, categoryId, annotations }) => {
1572
+ try {
1573
+ if (action === "get") {
1574
+ const result2 = await sendCommand("get_annotations", { nodeId });
1575
+ return jsonResult(result2);
1576
+ }
1577
+ if (action === "set") {
1578
+ if (!nodeId || labelMarkdown === void 0)
1579
+ return textResult("set requires nodeId and labelMarkdown.");
1580
+ const result2 = await sendCommand("set_annotation", { nodeId, labelMarkdown, categoryId });
1581
+ return jsonResult(result2);
1582
+ }
1583
+ if (!annotations?.length) return textResult("set_multiple requires annotations.");
1584
+ const result = await sendCommand("set_multiple_annotations", { annotations });
1585
+ return jsonResult(result);
1586
+ } catch (error) {
1587
+ return errorResult(error);
1588
+ }
1589
+ }
1590
+ );
1591
+ }
1592
+
1593
+ // src/tools/core/read.ts
1594
+ var read_exports = {};
1595
+ __export(read_exports, {
1596
+ register: () => register13
1597
+ });
1598
+ import { z as z14 } from "zod";
1599
+ function register13(server, sendCommand) {
1600
+ server.tool(
1601
+ "get_node_data",
1602
+ "Raw node data at a chosen detail level: summary (shallow), tree (sparse structure for orientation), full (deeper serialization), css (CSS representation), variables (bound variable ids). For human-oriented summaries prefer get_node_details / get_selection_context.",
1603
+ {
1604
+ nodeIds: z14.array(z14.string()).min(1).describe("One or more node ids"),
1605
+ detail: z14.enum(["summary", "tree", "full", "css", "variables"]).optional().describe("Default summary"),
1606
+ depth: z14.number().int().min(0).optional().describe("Override serialization depth"),
1607
+ maxNodes: z14.number().int().positive().optional().describe("Override node budget")
1608
+ },
1609
+ { readOnlyHint: true },
1610
+ async ({ nodeIds, detail = "summary", depth, maxNodes }) => {
1611
+ try {
1612
+ if (detail === "css" || detail === "variables") {
1613
+ const command = detail === "css" ? "get_css" : "get_bound_variables";
1614
+ const results = [];
1615
+ for (const nodeId of nodeIds) {
1616
+ results.push({ nodeId, data: await sendCommand(command, { nodeId }) });
1617
+ }
1618
+ return jsonResult(nodeIds.length === 1 ? results[0].data : results);
1619
+ }
1620
+ if (detail === "tree") {
1621
+ const results = [];
1622
+ for (const nodeId of nodeIds) {
1623
+ results.push(await sendCommand("get_node_tree", { nodeId, depth, maxNodes }));
1624
+ }
1625
+ return jsonResult(nodeIds.length === 1 ? results[0] : results);
1626
+ }
1627
+ if (nodeIds.length > 1) {
1628
+ const result2 = await sendCommand("get_nodes_info", { nodeIds });
1629
+ return jsonResult(result2);
1630
+ }
1631
+ const result = await sendCommand("get_node_info", {
1632
+ nodeId: nodeIds[0],
1633
+ depth: depth ?? (detail === "full" ? 4 : 1),
1634
+ maxNodes
1635
+ });
1636
+ return jsonResult(result);
1637
+ } catch (error) {
1638
+ return errorResult(error);
1639
+ }
1640
+ }
1641
+ );
1642
+ }
1643
+
1644
+ // src/tools/core/execute.ts
1645
+ var execute_exports = {};
1646
+ __export(execute_exports, {
1647
+ register: () => register14
1648
+ });
1649
+ import { z as z15 } from "zod";
1650
+ function register14(server, sendCommand) {
1651
+ server.tool(
1652
+ "execute_figma",
1653
+ "Run JavaScript against the Figma Plugin API inside the plugin sandbox \u2014 the escape hatch for anything the other tools don't cover. The code runs in an async function with `figma` in scope; its return value is serialized back (node objects become summaries). console.log output is captured into `logs`. Work incrementally: small scripts, verify with screenshot between steps. Remember dynamic-page rules (use figma.getNodeByIdAsync, await figma.loadFontAsync before text edits). The designer can disable this tool via the plugin's 'Allow code execution' toggle.",
1654
+ {
1655
+ code: z15.string().describe("JavaScript source. May use await. Return a JSON-serializable value."),
1656
+ description: z15.string().optional().describe("One line shown in the plugin's activity feed describing what this does")
1657
+ },
1658
+ async ({ code, description }) => {
1659
+ try {
1660
+ const result = await sendCommand("execute_code", { code, description }, 6e4);
1661
+ return jsonResult(result);
1662
+ } catch (error) {
1663
+ return errorResult(error);
1664
+ }
1665
+ }
1666
+ );
1667
+ }
1668
+
1669
+ // src/tools/core/comments.ts
1670
+ var comments_exports = {};
1671
+ __export(comments_exports, {
1672
+ parseFileKey: () => parseFileKey,
1673
+ register: () => register15
1674
+ });
1675
+ import { z as z16 } from "zod";
1676
+ var API = "https://api.figma.com/v1";
1677
+ function parseFileKey(input) {
1678
+ const url = input.match(/figma\.com\/(?:file|design)\/([A-Za-z0-9]+)/);
1679
+ if (url) return url[1];
1680
+ if (/^[A-Za-z0-9]{15,}$/.test(input)) return input;
1681
+ return null;
1682
+ }
1683
+ function compactComment(c) {
1684
+ return {
1685
+ id: c.id,
1686
+ message: c.message,
1687
+ author: c.user?.handle,
1688
+ created_at: c.created_at,
1689
+ ...c.resolved_at ? { resolved: true } : {},
1690
+ ...c.parent_id ? { replyTo: c.parent_id } : {},
1691
+ ...c.client_meta?.node_id ? { nodeId: c.client_meta.node_id } : {}
1692
+ };
1693
+ }
1694
+ function register15(server, sendCommand) {
1695
+ server.tool(
1696
+ "manage_comments",
1697
+ "Read and write comments on the Figma file: list (with node anchors \u2014 great for 'apply the feedback in the comments'), add (optionally pinned to a node), reply, delete. Requires a FIGMA_TOKEN env var (personal access token with comment scopes, generated at figma.com Settings \u2192 Security); the canvas tools work without it. The file is auto-detected from the open plugin when possible \u2014 otherwise pass fileUrl.",
1698
+ {
1699
+ action: z16.enum(["list", "add", "reply", "delete"]),
1700
+ fileUrl: z16.string().optional().describe("Figma file URL or key (auto-detected when omitted)"),
1701
+ message: z16.string().optional().describe("Comment text (add/reply)"),
1702
+ commentId: z16.string().optional().describe("Target comment (reply/delete)"),
1703
+ nodeId: z16.string().optional().describe("add: pin the comment to this node"),
1704
+ x: z16.number().optional().describe("add: canvas position (with y, when not pinning to a node)"),
1705
+ y: z16.number().optional()
1706
+ },
1707
+ async ({ action, fileUrl, message, commentId, nodeId, x, y }) => {
1708
+ const token = process.env.FIGMA_TOKEN;
1709
+ if (!token) {
1710
+ return textResult(
1711
+ 'Comments need a Figma personal access token. Generate one at figma.com \u2192 Settings \u2192 Security \u2192 Personal access tokens (enable comment scopes), then add it to the MCP config: "env": { "FIGMA_TOKEN": "figd_..." } and restart. Everything else works without it.'
1712
+ );
1713
+ }
1714
+ try {
1715
+ let fileKey = fileUrl ? parseFileKey(fileUrl) : null;
1716
+ if (fileUrl && !fileKey) {
1717
+ return textResult(`Could not extract a file key from "${fileUrl}" \u2014 pass a figma.com/design/... URL.`);
1718
+ }
1719
+ if (!fileKey) {
1720
+ const info = await sendCommand("get_file_info", {});
1721
+ fileKey = info?.fileKey ?? null;
1722
+ }
1723
+ if (!fileKey) {
1724
+ return textResult(
1725
+ "Figma doesn't expose this file's key to the plugin. Pass fileUrl with the file's figma.com URL (copy it from the browser or Share dialog)."
1726
+ );
1727
+ }
1728
+ const request = async (method, path, body) => {
1729
+ const res = await fetch(`${API}${path}`, {
1730
+ method,
1731
+ headers: {
1732
+ "X-Figma-Token": token,
1733
+ ...body ? { "Content-Type": "application/json" } : {}
1734
+ },
1735
+ ...body ? { body: JSON.stringify(body) } : {}
1736
+ });
1737
+ const json = await res.json().catch(() => ({}));
1738
+ if (!res.ok) {
1739
+ throw new Error(
1740
+ `Figma API ${res.status}: ${json.err ?? json.message ?? "request failed"}` + (res.status === 403 ? " \u2014 check the token's comment scopes and file access" : "")
1741
+ );
1742
+ }
1743
+ return json;
1744
+ };
1745
+ switch (action) {
1746
+ case "list": {
1747
+ const data = await request("GET", `/files/${fileKey}/comments`);
1748
+ const comments = (data.comments ?? []).slice(0, 100).map(compactComment);
1749
+ return jsonResult({ count: comments.length, comments });
1750
+ }
1751
+ case "add": {
1752
+ if (!message) return textResult("add requires message.");
1753
+ const client_meta = nodeId ? { node_id: nodeId, node_offset: { x: x ?? 0, y: y ?? 0 } } : x !== void 0 && y !== void 0 ? { x, y } : void 0;
1754
+ const data = await request("POST", `/files/${fileKey}/comments`, {
1755
+ message,
1756
+ ...client_meta ? { client_meta } : {}
1757
+ });
1758
+ return jsonResult(compactComment(data));
1759
+ }
1760
+ case "reply": {
1761
+ if (!message || !commentId) return textResult("reply requires commentId and message.");
1762
+ const data = await request("POST", `/files/${fileKey}/comments`, {
1763
+ message,
1764
+ comment_id: commentId
1765
+ });
1766
+ return jsonResult(compactComment(data));
1767
+ }
1768
+ case "delete": {
1769
+ if (!commentId) return textResult("delete requires commentId.");
1770
+ await request("DELETE", `/files/${fileKey}/comments/${commentId}`);
1771
+ return textResult(`Comment ${commentId} deleted.`);
1772
+ }
1773
+ }
1774
+ } catch (error) {
1775
+ return errorResult(error);
1776
+ }
1777
+ }
1778
+ );
1779
+ }
1780
+
1781
+ // src/tools/batch.ts
1782
+ var batch_exports = {};
1783
+ __export(batch_exports, {
1784
+ register: () => register16
1785
+ });
1786
+ import { z as z17 } from "zod";
1787
+ function textResult2(text) {
1788
+ return { content: [{ type: "text", text }] };
1789
+ }
1790
+ function register16(server, sendCommand) {
1791
+ server.tool(
1792
+ "batch_execute",
1793
+ "Execute multiple commands in a single round-trip. Use when performing 3+ similar operations (e.g., styling multiple nodes, creating many elements). Commands run sequentially.",
1794
+ {
1795
+ commands: z17.array(
1796
+ z17.object({
1797
+ command: z17.string().describe("Command name"),
1798
+ params: z17.record(z17.string(), z17.unknown()).optional().describe("Command parameters")
1799
+ })
1800
+ ).describe("Array of commands to execute sequentially")
1801
+ },
1802
+ async ({ commands }) => {
1803
+ try {
1804
+ const result = await sendCommand(
1805
+ "batch_execute",
1806
+ { commands },
1807
+ commands.length * 3e4
1808
+ // Scale timeout by command count
1809
+ );
1810
+ return textResult2(JSON.stringify(result));
1811
+ } catch (error) {
1812
+ return textResult2(`Error: ${error instanceof Error ? error.message : String(error)}`);
1813
+ }
1814
+ }
1815
+ );
1816
+ }
1817
+
1818
+ // src/tools/v2/context.ts
1819
+ var context_exports = {};
1820
+ __export(context_exports, {
1821
+ register: () => register17
1822
+ });
1823
+ import { z as z18 } from "zod";
1824
+
1825
+ // src/semantic/response.ts
1826
+ function standardResult(opts) {
1827
+ const response = {
1828
+ summary: opts.summary,
1829
+ data: opts.data
1830
+ };
1831
+ if (opts.warnings?.length) response.warnings = opts.warnings;
1832
+ if (opts.recommended_next?.length) response.recommended_next = opts.recommended_next;
1833
+ if (opts.partial_failures?.length) response.partial_failures = opts.partial_failures;
1834
+ return {
1835
+ content: [{ type: "text", text: JSON.stringify(response) }]
1836
+ };
1837
+ }
1838
+
1839
+ // src/semantic/errors.ts
1840
+ function errorResult2(category, message, recovery, extra) {
1841
+ const error = {
1842
+ category,
1843
+ message,
1844
+ recovery
1845
+ };
1846
+ if (extra?.preconditions) error.preconditions = extra.preconditions;
1847
+ if (extra?.partial_result !== void 0) error.partial_result = extra.partial_result;
1848
+ return {
1849
+ content: [{ type: "text", text: JSON.stringify({ error }, null, 2) }]
1850
+ };
1851
+ }
1852
+ function noSelectionError() {
1853
+ return errorResult2(
1854
+ "precondition_failed",
1855
+ "No nodes selected in Figma",
1856
+ {
1857
+ suggestion: "Select nodes in Figma, or use search_nodes to find nodes by name/type",
1858
+ tool: "search_nodes"
1859
+ },
1860
+ { preconditions: { required: "At least 1 node selected", current_state: "Selection is empty" } }
1861
+ );
1862
+ }
1863
+
1864
+ // src/semantic/normalize.ts
1865
+ function normalizeNode(raw) {
1866
+ if (!raw) return null;
1867
+ const summary = {
1868
+ id: raw.id,
1869
+ name: raw.name,
1870
+ type: raw.type,
1871
+ size: {
1872
+ width: raw.width ?? raw.absoluteBoundingBox?.width ?? 0,
1873
+ height: raw.height ?? raw.absoluteBoundingBox?.height ?? 0
1874
+ },
1875
+ position: {
1876
+ x: raw.x ?? raw.absoluteBoundingBox?.x ?? 0,
1877
+ y: raw.y ?? raw.absoluteBoundingBox?.y ?? 0
1878
+ }
1879
+ };
1880
+ const fill = extractFillColor(raw);
1881
+ if (fill) summary.fill = fill;
1882
+ const stroke = extractStrokeColor(raw);
1883
+ if (stroke) summary.stroke = stroke;
1884
+ if (raw.cornerRadius !== void 0) {
1885
+ summary.cornerRadius = raw.cornerRadius === "mixed" ? "mixed" : raw.cornerRadius;
1886
+ }
1887
+ if (raw.opacity !== void 0 && raw.opacity < 1) {
1888
+ summary.opacity = raw.opacity;
1889
+ }
1890
+ summary.layout = extractLayout(raw);
1891
+ if (raw.children?.length > 0) {
1892
+ summary.childSummary = summarizeChildren(raw.children);
1893
+ }
1894
+ summary.componentStatus = extractComponentStatus(raw);
1895
+ return summary;
1896
+ }
1897
+ function normalizeNodes(rawNodes) {
1898
+ return rawNodes.map(normalizeNode).filter((n) => n !== null);
1899
+ }
1900
+ function extractFillColor(node) {
1901
+ if (!node.fills?.length) return void 0;
1902
+ const solidFill = node.fills.find((f) => f.type === "SOLID" && f.visible !== false);
1903
+ if (!solidFill?.color) return void 0;
1904
+ if (typeof solidFill.color === "string" && solidFill.color.startsWith("#")) {
1905
+ return solidFill.color;
1906
+ }
1907
+ return rgbaToHex({
1908
+ r: solidFill.color.r,
1909
+ g: solidFill.color.g,
1910
+ b: solidFill.color.b,
1911
+ a: solidFill.opacity ?? solidFill.color.a ?? 1
1912
+ });
1913
+ }
1914
+ function extractStrokeColor(node) {
1915
+ if (!node.strokes?.length) return void 0;
1916
+ const stroke = node.strokes.find((s) => s.type === "SOLID" && s.visible !== false);
1917
+ if (!stroke?.color) return void 0;
1918
+ if (typeof stroke.color === "string" && stroke.color.startsWith("#")) {
1919
+ return stroke.color;
1920
+ }
1921
+ return rgbaToHex({
1922
+ r: stroke.color.r,
1923
+ g: stroke.color.g,
1924
+ b: stroke.color.b,
1925
+ a: stroke.opacity ?? stroke.color.a ?? 1
1926
+ });
1927
+ }
1928
+ function extractLayout(node) {
1929
+ if (!node.layoutMode || node.layoutMode === "NONE") return null;
1930
+ const pt = node.paddingTop ?? 0;
1931
+ const pr = node.paddingRight ?? 0;
1932
+ const pb = node.paddingBottom ?? 0;
1933
+ const pl = node.paddingLeft ?? 0;
1934
+ let padding;
1935
+ if (pt === pr && pr === pb && pb === pl) {
1936
+ padding = `${pt}px all`;
1937
+ } else if (pt === pb && pl === pr) {
1938
+ padding = `${pt}/${pl}`;
1939
+ } else {
1940
+ padding = `${pt}/${pr}/${pb}/${pl}`;
1941
+ }
1942
+ return {
1943
+ mode: node.layoutMode,
1944
+ padding,
1945
+ gap: node.itemSpacing ?? 0,
1946
+ sizing: {
1947
+ horizontal: node.primaryAxisSizingMode ?? node.layoutSizingHorizontal ?? "FIXED",
1948
+ vertical: node.counterAxisSizingMode ?? node.layoutSizingVertical ?? "FIXED"
1949
+ }
1950
+ };
1951
+ }
1952
+ function summarizeChildren(children) {
1953
+ const typeCounts = /* @__PURE__ */ new Map();
1954
+ for (const child of children) {
1955
+ const type = child.type || "UNKNOWN";
1956
+ typeCounts.set(type, (typeCounts.get(type) || 0) + 1);
1957
+ }
1958
+ const parts = Array.from(typeCounts.entries()).map(([type, count]) => `${count} ${type}`).join(", ");
1959
+ return `${children.length} children: ${parts}`;
1960
+ }
1961
+ function extractComponentStatus(node) {
1962
+ if (node.type === "COMPONENT") return "component";
1963
+ if (node.type === "COMPONENT_SET") return "component_set";
1964
+ if (node.type === "INSTANCE") {
1965
+ const mainName = node.mainComponent?.name || node.componentName;
1966
+ return mainName ? `instance of '${mainName}'` : "instance";
1967
+ }
1968
+ return null;
1969
+ }
1970
+ function calculateTokenCoverage(node, boundVariables) {
1971
+ let totalProps = 0;
1972
+ let boundProps = 0;
1973
+ if (node.fills?.length > 0) {
1974
+ totalProps++;
1975
+ if (boundVariables && hasBinding(boundVariables, "fills")) boundProps++;
1976
+ }
1977
+ if (node.strokes?.length > 0) {
1978
+ totalProps++;
1979
+ if (boundVariables && hasBinding(boundVariables, "strokes")) boundProps++;
1980
+ }
1981
+ for (const prop of ["itemSpacing", "paddingTop", "paddingRight", "paddingBottom", "paddingLeft"]) {
1982
+ if (node[prop] !== void 0 && node[prop] > 0) {
1983
+ totalProps++;
1984
+ if (boundVariables && hasBinding(boundVariables, prop)) boundProps++;
1985
+ }
1986
+ }
1987
+ if (node.cornerRadius !== void 0 && node.cornerRadius > 0) {
1988
+ totalProps++;
1989
+ if (boundVariables && hasBinding(boundVariables, "cornerRadius")) boundProps++;
1990
+ }
1991
+ if (totalProps === 0) return 1;
1992
+ return Math.round(boundProps / totalProps * 100) / 100;
1993
+ }
1994
+ function hasBinding(boundVars, prefix) {
1995
+ return Object.keys(boundVars).some((key) => key.startsWith(prefix) || key === prefix);
1996
+ }
1997
+
1998
+ // src/tools/v2/context.ts
1999
+ function register17(server, sendCommand) {
2000
+ server.tool(
2001
+ "get_document_overview",
2002
+ "Get the full document structure: pages, component/style/variable counts. Always call this first to understand the file. Follow with get_selection_context or search_nodes.",
2003
+ {},
2004
+ { readOnlyHint: true },
2005
+ async () => {
2006
+ try {
2007
+ const [docInfo, collections, styles] = await Promise.all([
2008
+ sendCommand("get_document_info"),
2009
+ sendCommand("get_variable_collections"),
2010
+ sendCommand("get_styles")
2011
+ ]);
2012
+ const componentCount = docInfo.componentCount ?? 0;
2013
+ const stylesByType = { paint: 0, text: 0, effect: 0, grid: 0 };
2014
+ if (Array.isArray(styles)) {
2015
+ for (const s of styles) {
2016
+ const t = (s.type || "").toLowerCase();
2017
+ if (t === "paint") stylesByType.paint++;
2018
+ else if (t === "text") stylesByType.text++;
2019
+ else if (t === "effect") stylesByType.effect++;
2020
+ else if (t === "grid") stylesByType.grid++;
2021
+ }
2022
+ }
2023
+ const data = {
2024
+ name: docInfo.name,
2025
+ currentPage: docInfo.currentPage,
2026
+ pages: docInfo.pages || [],
2027
+ counts: {
2028
+ components: componentCount,
2029
+ styles: Array.isArray(styles) ? styles.length : 0,
2030
+ variableCollections: Array.isArray(collections) ? collections.length : 0
2031
+ }
2032
+ };
2033
+ return standardResult({
2034
+ summary: `"${data.name}" \u2014 ${data.pages.length} pages, ${data.counts.components} components, ${data.counts.styles} styles, ${data.counts.variableCollections} variable collections`,
2035
+ data,
2036
+ recommended_next: [
2037
+ { tool: "get_selection_context", reason: "Inspect currently selected nodes" },
2038
+ { tool: "search_nodes", reason: "Find specific nodes by name or type" },
2039
+ { tool: "get_design_tokens", reason: "Explore the design token system" }
2040
+ ]
2041
+ });
2042
+ } catch (error) {
2043
+ return errorResult2(
2044
+ "connection_error",
2045
+ `Failed to get document info: ${error instanceof Error ? error.message : String(error)}`,
2046
+ { suggestion: "Ensure Figma plugin is connected. Call join_room first.", tool: "join_room" }
2047
+ );
2048
+ }
2049
+ }
2050
+ );
2051
+ server.tool(
2052
+ "get_selection_context",
2053
+ "Get full context for the current Figma selection: node properties, fills, layout, token bindings, and children summary. Use as the starting point for any inspection or modification workflow. Follow with analyze_* tools or update_node.",
2054
+ {},
2055
+ { readOnlyHint: true },
2056
+ async () => {
2057
+ try {
2058
+ const selection = await sendCommand("get_selection");
2059
+ if (!selection?.nodes?.length) {
2060
+ return noSelectionError();
2061
+ }
2062
+ const nodeIds = selection.nodes.map((n) => n.id);
2063
+ const [nodesInfo, ...bindings] = await Promise.all([
2064
+ sendCommand("get_nodes_info", { nodeIds, depth: 2 }),
2065
+ ...nodeIds.map(
2066
+ (id) => sendCommand("get_bound_variables", { nodeId: id }).catch(() => null)
2067
+ )
2068
+ ]);
2069
+ const nodes = (Array.isArray(nodesInfo) ? nodesInfo : []).map((raw, i) => {
2070
+ const summary = normalizeNode(raw);
2071
+ if (!summary) return null;
2072
+ const boundVars = bindings[i]?.boundVariables || null;
2073
+ summary.tokenCoverage = calculateTokenCoverage(raw, boundVars);
2074
+ if (boundVars) {
2075
+ summary.fillToken = extractTokenName(boundVars, "fills");
2076
+ summary.strokeToken = extractTokenName(boundVars, "strokes");
2077
+ }
2078
+ return summary;
2079
+ }).filter(Boolean);
2080
+ const data = {
2081
+ nodes,
2082
+ pageInfo: selection.currentPage || { id: "", name: "" }
2083
+ };
2084
+ const avgCoverage = nodes.length > 0 ? Math.round(nodes.reduce((sum, n) => sum + (n.tokenCoverage || 0), 0) / nodes.length * 100) : 0;
2085
+ const warnings = [];
2086
+ if (avgCoverage < 50) {
2087
+ warnings.push({
2088
+ category: "tokens",
2089
+ message: `Token coverage is ${avgCoverage}% \u2014 many properties are not bound to design variables`
2090
+ });
2091
+ }
2092
+ return standardResult({
2093
+ summary: `Selected ${nodes.length} node(s): ${nodes.map((n) => `'${n.name}' (${n.type})`).join(", ")}. Token coverage: ${avgCoverage}%`,
2094
+ data,
2095
+ warnings,
2096
+ recommended_next: [
2097
+ { tool: "analyze_color_usage", reason: "Audit color consistency and token coverage" },
2098
+ { tool: "update_node", reason: "Modify selected node properties" },
2099
+ { tool: "screenshot", reason: "Visually inspect the selection" }
2100
+ ]
2101
+ });
2102
+ } catch (error) {
2103
+ return errorResult2(
2104
+ "connection_error",
2105
+ `Failed to get selection: ${error instanceof Error ? error.message : String(error)}`,
2106
+ { suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
2107
+ );
2108
+ }
2109
+ }
2110
+ );
2111
+ server.tool(
2112
+ "get_node_details",
2113
+ "Get a single node's full properties, CSS, token bindings, and children. Use after search_nodes or get_selection_context to inspect a specific node. Follow with update_node or analyze_* tools.",
2114
+ {
2115
+ nodeId: z18.string().describe("The node ID to inspect")
2116
+ },
2117
+ { readOnlyHint: true },
2118
+ async ({ nodeId }) => {
2119
+ try {
2120
+ const [nodeInfo, boundVars, css] = await Promise.all([
2121
+ sendCommand("get_node_info", { nodeId, depth: 2 }),
2122
+ sendCommand("get_bound_variables", { nodeId }).catch(() => null),
2123
+ sendCommand("get_css", { nodeId }).catch(() => null)
2124
+ ]);
2125
+ if (!nodeInfo) {
2126
+ return errorResult2(
2127
+ "invalid_input",
2128
+ `Node '${nodeId}' not found`,
2129
+ { suggestion: "Use search_nodes to find the correct node ID", tool: "search_nodes" }
2130
+ );
2131
+ }
2132
+ const node = normalizeNode(nodeInfo);
2133
+ if (!node) {
2134
+ return errorResult2(
2135
+ "invalid_input",
2136
+ `Could not process node '${nodeId}'`,
2137
+ { suggestion: "Try get_node_info directly for raw data", tool: "get_node_info" }
2138
+ );
2139
+ }
2140
+ const bv = boundVars?.boundVariables || null;
2141
+ node.tokenCoverage = calculateTokenCoverage(nodeInfo, bv);
2142
+ if (bv) {
2143
+ node.fillToken = extractTokenName(bv, "fills");
2144
+ node.strokeToken = extractTokenName(bv, "strokes");
2145
+ }
2146
+ const children = nodeInfo.children ? normalizeNodes(nodeInfo.children) : void 0;
2147
+ const data = {
2148
+ node,
2149
+ css: css?.css,
2150
+ boundVariables: bv || void 0,
2151
+ children
2152
+ };
2153
+ return standardResult({
2154
+ summary: `'${node.name}' (${node.type}, ${node.size.width}\xD7${node.size.height}). Token coverage: ${Math.round((node.tokenCoverage || 0) * 100)}%`,
2155
+ data,
2156
+ recommended_next: [
2157
+ { tool: "update_node", reason: "Modify this node's properties", args: { nodeId } },
2158
+ { tool: "analyze_color_usage", reason: "Check color token usage", args: { nodeId } }
2159
+ ]
2160
+ });
2161
+ } catch (error) {
2162
+ return errorResult2(
2163
+ "connection_error",
2164
+ `Failed to get node details: ${error instanceof Error ? error.message : String(error)}`,
2165
+ { suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
2166
+ );
2167
+ }
2168
+ }
2169
+ );
2170
+ server.tool(
2171
+ "search_nodes",
2172
+ "Search for nodes by name and/or type within a scope. Returns matching node IDs, names, and types. Use to find specific elements before inspecting or modifying them. Follow with get_node_details or update_node.",
2173
+ {
2174
+ query: z18.string().optional().describe("Name substring to search for"),
2175
+ types: z18.array(z18.string()).optional().describe("Node types to filter: FRAME, TEXT, COMPONENT, INSTANCE, RECTANGLE, etc."),
2176
+ parentId: z18.string().optional().describe("Scope search to children of this node (default: current page)")
2177
+ },
2178
+ { readOnlyHint: true },
2179
+ async ({ query, types, parentId }) => {
2180
+ try {
2181
+ const result = await sendCommand("find_nodes", {
2182
+ name: query,
2183
+ types,
2184
+ parentId
2185
+ });
2186
+ const matches = (Array.isArray(result) ? result : []).map((n) => ({
2187
+ id: n.id,
2188
+ name: n.name,
2189
+ type: n.type,
2190
+ parentName: n.parent?.name
2191
+ }));
2192
+ const data = {
2193
+ matches,
2194
+ total: matches.length
2195
+ };
2196
+ return standardResult({
2197
+ summary: `Found ${matches.length} node(s)${query ? ` matching '${query}'` : ""}${types?.length ? ` of type ${types.join("/")}` : ""}`,
2198
+ data,
2199
+ recommended_next: matches.length > 0 ? [
2200
+ { tool: "get_node_details", reason: "Inspect a specific node", args: { nodeId: matches[0].id } },
2201
+ { tool: "navigate_to", reason: "Focus on a found node", args: { nodeId: matches[0].id } }
2202
+ ] : [
2203
+ { tool: "get_document_overview", reason: "Check document structure" }
2204
+ ]
2205
+ });
2206
+ } catch (error) {
2207
+ return errorResult2(
2208
+ "connection_error",
2209
+ `Search failed: ${error instanceof Error ? error.message : String(error)}`,
2210
+ { suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
2211
+ );
2212
+ }
2213
+ }
2214
+ );
2215
+ server.tool(
2216
+ "get_design_tokens",
2217
+ "Get an overview of the design token system: variable collections, modes, variable counts, and style counts. Use to understand the design system before binding tokens or creating new variables. Follow with manage_variables or bind_tokens.",
2218
+ {},
2219
+ { readOnlyHint: true },
2220
+ async () => {
2221
+ try {
2222
+ const [collections, styles] = await Promise.all([
2223
+ sendCommand("get_variable_collections"),
2224
+ sendCommand("get_styles")
2225
+ ]);
2226
+ const collectionSummaries = (Array.isArray(collections) ? collections : []).map((c) => ({
2227
+ id: c.id,
2228
+ name: c.name,
2229
+ modes: Array.isArray(c.modes) ? c.modes : [],
2230
+ variableCount: Array.isArray(c.variableIds) ? c.variableIds.length : 0
2231
+ }));
2232
+ const stylesByType = { paint: 0, text: 0, effect: 0, grid: 0 };
2233
+ if (Array.isArray(styles)) {
2234
+ for (const s of styles) {
2235
+ const t = (s.type || "").toLowerCase();
2236
+ if (t in stylesByType) stylesByType[t]++;
2237
+ }
2238
+ }
2239
+ const totalVars = collectionSummaries.reduce((sum, c) => sum + c.variableCount, 0);
2240
+ const data = {
2241
+ collections: collectionSummaries,
2242
+ styles: stylesByType
2243
+ };
2244
+ return standardResult({
2245
+ summary: `${collectionSummaries.length} collection(s) with ${totalVars} variables. Styles: ${stylesByType.paint} paint, ${stylesByType.text} text, ${stylesByType.effect} effect, ${stylesByType.grid} grid`,
2246
+ data,
2247
+ recommended_next: [
2248
+ { tool: "manage_variables", reason: "Create or update design tokens" },
2249
+ { tool: "analyze_color_usage", reason: "Check token coverage on selected nodes" }
2250
+ ]
2251
+ });
2252
+ } catch (error) {
2253
+ return errorResult2(
2254
+ "connection_error",
2255
+ `Failed to get design tokens: ${error instanceof Error ? error.message : String(error)}`,
2256
+ { suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
2257
+ );
2258
+ }
2259
+ }
2260
+ );
2261
+ server.tool(
2262
+ "screenshot",
2263
+ "Take a PNG screenshot of a node or the current view. Returns an image visible to the AI. Use to visually verify changes or inspect design details. Works for both context inspection and verification after modifications.",
2264
+ {
2265
+ nodeId: z18.string().optional().describe("Node ID to screenshot (default: current view)")
2266
+ },
2267
+ { readOnlyHint: true },
2268
+ async ({ nodeId }) => {
2269
+ try {
2270
+ const result = await sendCommand("get_screenshot", nodeId ? { nodeId } : {});
2271
+ if (result?.imageData) {
2272
+ return {
2273
+ content: [
2274
+ {
2275
+ type: "image",
2276
+ data: result.imageData,
2277
+ mimeType: "image/png"
2278
+ },
2279
+ {
2280
+ type: "text",
2281
+ text: JSON.stringify({
2282
+ summary: `Screenshot taken${nodeId ? ` of node ${nodeId}` : " of current view"}`,
2283
+ recommended_next: [
2284
+ { tool: "get_selection_context", reason: "Get structured data about the selection" },
2285
+ { tool: "update_node", reason: "Make changes based on what you see" }
2286
+ ]
2287
+ })
2288
+ }
2289
+ ]
2290
+ };
2291
+ }
2292
+ return {
2293
+ content: [{ type: "text", text: JSON.stringify(result) }]
2294
+ };
2295
+ } catch (error) {
2296
+ return errorResult2(
2297
+ "connection_error",
2298
+ `Screenshot failed: ${error instanceof Error ? error.message : String(error)}`,
2299
+ { suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
2300
+ );
2301
+ }
2302
+ }
2303
+ );
2304
+ }
2305
+ function extractTokenName(boundVars, prefix) {
2306
+ for (const [key, value] of Object.entries(boundVars)) {
2307
+ if (key.startsWith(prefix) || key === prefix) {
2308
+ if (value && typeof value === "object" && "name" in value) {
2309
+ return value.name;
2310
+ }
2311
+ if (typeof value === "string") return value;
2312
+ }
2313
+ }
2314
+ return null;
2315
+ }
2316
+
2317
+ // src/tools/v2/analysis.ts
2318
+ var analysis_exports = {};
2319
+ __export(analysis_exports, {
2320
+ collectColorIssues: () => collectColorIssues,
2321
+ register: () => register18
2322
+ });
2323
+ import { z as z19 } from "zod";
2324
+
2325
+ // src/semantic/health.ts
2326
+ var WEIGHTS = { color: 0.3, layout: 0.25, components: 0.2, accessibility: 0.25 };
2327
+ var clamp = (n) => Math.max(0, Math.min(100, Math.round(n)));
2328
+ function computeHealthScore(inputs) {
2329
+ const categories = [];
2330
+ if (inputs.color) {
2331
+ categories.push({
2332
+ category: "color",
2333
+ score: clamp(inputs.color.tokenCoverage * 100),
2334
+ weight: WEIGHTS.color,
2335
+ note: `${Math.round(inputs.color.tokenCoverage * 100)}% of color properties bound to tokens (${inputs.color.unboundCount} unbound)`
2336
+ });
2337
+ }
2338
+ if (inputs.layout) {
2339
+ categories.push({
2340
+ category: "layout",
2341
+ score: clamp(inputs.layout.autoLayoutCoverage * 100 - inputs.layout.issueCount * 3),
2342
+ weight: WEIGHTS.layout,
2343
+ note: `${Math.round(inputs.layout.autoLayoutCoverage * 100)}% auto-layout coverage, ${inputs.layout.issueCount} issue(s)`
2344
+ });
2345
+ }
2346
+ if (inputs.components) {
2347
+ const { totalInstances, detachedCount } = inputs.components;
2348
+ categories.push({
2349
+ category: "components",
2350
+ score: totalInstances > 0 ? clamp((1 - detachedCount / totalInstances) * 100) : 100,
2351
+ weight: WEIGHTS.components,
2352
+ note: totalInstances > 0 ? `${detachedCount}/${totalInstances} instances look detached` : "no instances in scope"
2353
+ });
2354
+ }
2355
+ if (inputs.accessibility) {
2356
+ categories.push({
2357
+ category: "accessibility",
2358
+ score: clamp(100 - inputs.accessibility.issueCount * 8),
2359
+ weight: WEIGHTS.accessibility,
2360
+ note: `${inputs.accessibility.issueCount} issue(s) found`
2361
+ });
2362
+ }
2363
+ const totalWeight = categories.reduce((s, c) => s + c.weight, 0);
2364
+ const score = totalWeight > 0 ? clamp(categories.reduce((s, c) => s + c.score * c.weight, 0) / totalWeight) : 0;
2365
+ const grade = score >= 90 ? "A" : score >= 75 ? "B" : score >= 55 ? "C" : "D";
2366
+ return { score, grade, categories };
2367
+ }
2368
+
2369
+ // src/tools/v2/analysis.ts
2370
+ var ASPECT_TOOL = {
2371
+ color: "analyze_color_usage",
2372
+ layout: "analyze_layout_quality",
2373
+ components: "analyze_component_health",
2374
+ accessibility: "analyze_accessibility"
2375
+ };
2376
+ function register18(server, sendCommand) {
2377
+ const aspectHandlers = /* @__PURE__ */ new Map();
2378
+ const interceptor = new Proxy(server, {
2379
+ get(target, prop, receiver) {
2380
+ if (prop !== "tool") return Reflect.get(target, prop, receiver);
2381
+ return (name, ...rest) => {
2382
+ if (name.startsWith("analyze_")) {
2383
+ aspectHandlers.set(name, rest[rest.length - 1]);
2384
+ return;
2385
+ }
2386
+ return target.tool.call(target, name, ...rest);
2387
+ };
2388
+ }
2389
+ });
2390
+ registerAnalysisTools(interceptor, sendCommand);
2391
+ server.tool(
2392
+ "analyze_design",
2393
+ "Audit the design from one aspect: color (token coverage, unbound fills/strokes), layout (auto-layout quality, spacing consistency), components (detached instances, component health), accessibility (WCAG contrast incl. large-text thresholds, touch targets, minimum text sizes), or overall (runs all four and returns a weighted 0-100 health score with per-category breakdown \u2014 good for audits and reports). Defaults to the current selection.",
2394
+ {
2395
+ aspect: z19.enum(["color", "layout", "components", "accessibility", "overall"]),
2396
+ nodeId: z19.string().optional().describe("Root node to analyze (default: current selection)")
2397
+ },
2398
+ { readOnlyHint: true },
2399
+ async ({ aspect, nodeId }) => {
2400
+ if (aspect === "overall") {
2401
+ return runOverallAudit(aspectHandlers, nodeId);
2402
+ }
2403
+ const handler = aspectHandlers.get(ASPECT_TOOL[aspect]);
2404
+ if (!handler) throw new Error(`Unknown aspect: ${aspect}`);
2405
+ return handler({ nodeId });
2406
+ }
2407
+ );
2408
+ }
2409
+ async function runOverallAudit(aspectHandlers, nodeId) {
2410
+ const parse = (result) => {
2411
+ try {
2412
+ const text = result.content?.[0]?.text ?? "";
2413
+ const parsed = JSON.parse(text);
2414
+ return parsed?.data ?? null;
2415
+ } catch {
2416
+ return null;
2417
+ }
2418
+ };
2419
+ const run = async (tool) => {
2420
+ const handler = aspectHandlers.get(tool);
2421
+ if (!handler) return null;
2422
+ try {
2423
+ return parse(await handler({ nodeId }));
2424
+ } catch {
2425
+ return null;
2426
+ }
2427
+ };
2428
+ const [color, layout, components, accessibility] = await Promise.all([
2429
+ run("analyze_color_usage"),
2430
+ run("analyze_layout_quality"),
2431
+ run("analyze_component_health"),
2432
+ run("analyze_accessibility")
2433
+ ]);
2434
+ const inputs = {
2435
+ color: color ? { tokenCoverage: color.tokenCoverage ?? 0, unboundCount: color.unboundCount ?? 0 } : null,
2436
+ layout: layout ? {
2437
+ autoLayoutCoverage: layout.autoLayoutCoverage ?? 0,
2438
+ issueCount: layout.issues?.length ?? 0
2439
+ } : null,
2440
+ components: components ? {
2441
+ totalInstances: components.totalInstances ?? 0,
2442
+ detachedCount: components.detachedCount ?? 0
2443
+ } : null,
2444
+ accessibility: accessibility ? { issueCount: accessibility.issueCount ?? 0 } : null
2445
+ };
2446
+ const health = computeHealthScore(inputs);
2447
+ const excluded = Object.keys(inputs).filter((k) => !inputs[k]);
2448
+ const worst = [...health.categories].sort((a, b) => a.score - b.score)[0];
2449
+ return standardResult({
2450
+ summary: `Design health: ${health.score}/100 (grade ${health.grade}). Weakest area: ${worst ? `${worst.category} at ${worst.score}` : "n/a"}.`,
2451
+ data: {
2452
+ ...health,
2453
+ ...excluded.length > 0 ? { excluded } : {},
2454
+ details: { color, layout, components, accessibility }
2455
+ },
2456
+ warnings: health.grade === "A" ? [] : health.categories.filter((c) => c.score < 75).map((c) => ({
2457
+ category: "general",
2458
+ message: `${c.category}: ${c.score}/100 \u2014 ${c.note}`
2459
+ })),
2460
+ recommended_next: worst ? [
2461
+ {
2462
+ tool: "analyze_design",
2463
+ reason: `Drill into the weakest area with aspect: "${worst.category}"`
2464
+ }
2465
+ ] : []
2466
+ });
2467
+ }
2468
+ function registerAnalysisTools(server, sendCommand) {
2469
+ server.tool(
2470
+ "analyze_color_usage",
2471
+ "Audit color consistency and design token coverage for a node tree. Reports which fill/stroke colors are not bound to variables. Use after get_selection_context to diagnose token gaps. Follow with bind_tokens to fix issues.",
2472
+ {
2473
+ nodeId: z19.string().optional().describe("Root node to analyze (default: current selection)")
2474
+ },
2475
+ { readOnlyHint: true },
2476
+ async ({ nodeId }) => {
2477
+ try {
2478
+ let targetIds;
2479
+ if (nodeId) {
2480
+ targetIds = [nodeId];
2481
+ } else {
2482
+ const selection = await sendCommand("get_selection");
2483
+ if (!selection?.nodes?.length) return noSelectionError();
2484
+ targetIds = selection.nodes.map((n) => n.id);
2485
+ }
2486
+ const unboundColors = [];
2487
+ const counters = { totalProps: 0, boundCount: 0 };
2488
+ for (const id of targetIds) {
2489
+ const [nodeInfo, boundVars] = await Promise.all([
2490
+ sendCommand("get_node_info", { nodeId: id, depth: 2, maxNodes: 200 }),
2491
+ sendCommand("get_bound_variables", { nodeId: id }).catch(() => null)
2492
+ ]);
2493
+ if (!nodeInfo) continue;
2494
+ const bv = boundVars?.boundVariables || {};
2495
+ collectColorIssues(nodeInfo, bv, unboundColors, counters);
2496
+ if (nodeInfo.children) {
2497
+ for (const child of nodeInfo.children) {
2498
+ const childBv = await sendCommand("get_bound_variables", { nodeId: child.id }).catch(() => null);
2499
+ const cbv = childBv?.boundVariables || {};
2500
+ collectColorIssues(child, cbv, unboundColors, counters);
2501
+ }
2502
+ }
2503
+ }
2504
+ const tokenCoverage = counters.totalProps > 0 ? Math.round(counters.boundCount / counters.totalProps * 100) / 100 : 1;
2505
+ const data = {
2506
+ totalProperties: counters.totalProps,
2507
+ boundCount: counters.boundCount,
2508
+ unboundCount: unboundColors.length,
2509
+ tokenCoverage,
2510
+ unboundColors
2511
+ };
2512
+ const warnings = unboundColors.length > 0 ? [{ category: "tokens", message: `${unboundColors.length} color(s) not bound to design tokens` }] : [];
2513
+ return standardResult({
2514
+ summary: `Color audit: ${unboundColors.length} unbound color(s) found. Token coverage: ${Math.round(data.tokenCoverage * 100)}%`,
2515
+ data,
2516
+ warnings,
2517
+ recommended_next: unboundColors.length > 0 ? [
2518
+ { tool: "bind_tokens", reason: "Bind unbound colors to design tokens" },
2519
+ { tool: "get_design_tokens", reason: "See available tokens for binding" }
2520
+ ] : [
2521
+ { tool: "analyze_layout_quality", reason: "Check layout quality next" }
2522
+ ]
2523
+ });
2524
+ } catch (error) {
2525
+ return errorResult2(
2526
+ "connection_error",
2527
+ `Analysis failed: ${error instanceof Error ? error.message : String(error)}`,
2528
+ { suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
2529
+ );
2530
+ }
2531
+ }
2532
+ );
2533
+ server.tool(
2534
+ "analyze_layout_quality",
2535
+ "Check layout quality: auto-layout usage, magic numbers in spacing/padding, and sizing consistency. Use after get_selection_context to find layout improvements. Follow with set_auto_layout to fix issues.",
2536
+ {
2537
+ nodeId: z19.string().optional().describe("Root node to analyze (default: current selection)")
2538
+ },
2539
+ { readOnlyHint: true },
2540
+ async ({ nodeId }) => {
2541
+ try {
2542
+ let targetId;
2543
+ if (nodeId) {
2544
+ targetId = nodeId;
2545
+ } else {
2546
+ const selection = await sendCommand("get_selection");
2547
+ if (!selection?.nodes?.length) return noSelectionError();
2548
+ targetId = selection.nodes[0].id;
2549
+ }
2550
+ const nodeInfo = await sendCommand("get_node_info", { nodeId: targetId, depth: 8, maxNodes: 500 });
2551
+ if (!nodeInfo) {
2552
+ return errorResult2("invalid_input", `Node '${targetId}' not found`, { suggestion: "Use search_nodes", tool: "search_nodes" });
2553
+ }
2554
+ const issues = [];
2555
+ let totalFrames = 0;
2556
+ let autoLayoutCount = 0;
2557
+ analyzeLayoutNode(nodeInfo, issues, { totalFrames: 0, autoLayoutCount: 0 });
2558
+ const frameNodes = collectFrames(nodeInfo);
2559
+ totalFrames = frameNodes.length;
2560
+ autoLayoutCount = frameNodes.filter((f) => f.layoutMode && f.layoutMode !== "NONE").length;
2561
+ const data = {
2562
+ totalFrames,
2563
+ autoLayoutCount,
2564
+ autoLayoutCoverage: totalFrames > 0 ? Math.round(autoLayoutCount / totalFrames * 100) / 100 : 1,
2565
+ issues
2566
+ };
2567
+ return standardResult({
2568
+ summary: `Layout audit: ${autoLayoutCount}/${totalFrames} frames use auto-layout (${Math.round(data.autoLayoutCoverage * 100)}%). ${issues.length} issue(s) found.`,
2569
+ data,
2570
+ warnings: issues.filter((i) => i.severity === "warning").map((i) => ({
2571
+ category: "layout",
2572
+ message: i.issue,
2573
+ nodeId: i.nodeId
2574
+ })),
2575
+ recommended_next: issues.length > 0 ? [{ tool: "set_auto_layout", reason: "Fix layout issues on specific frames" }] : [{ tool: "analyze_color_usage", reason: "Check color token coverage next" }]
2576
+ });
2577
+ } catch (error) {
2578
+ return errorResult2(
2579
+ "connection_error",
2580
+ `Analysis failed: ${error instanceof Error ? error.message : String(error)}`,
2581
+ { suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
2582
+ );
2583
+ }
2584
+ }
2585
+ );
2586
+ server.tool(
2587
+ "analyze_component_health",
2588
+ "Check component system health: detached instances, unused components, and override consistency. Use to audit design system adherence. Follow with create_component or update_instance to fix issues.",
2589
+ {
2590
+ nodeId: z19.string().optional().describe("Scope to analyze (default: current page)")
2591
+ },
2592
+ { readOnlyHint: true },
2593
+ async ({ nodeId }) => {
2594
+ try {
2595
+ const [instances, components] = await Promise.all([
2596
+ sendCommand("scan_nodes_by_types", {
2597
+ types: ["INSTANCE"],
2598
+ parentId: nodeId
2599
+ }, 12e4),
2600
+ sendCommand("get_local_components")
2601
+ ]);
2602
+ const instanceList = Array.isArray(instances) ? instances : [];
2603
+ const componentList = Array.isArray(components) ? components : [];
2604
+ const issues = [];
2605
+ let detachedCount = 0;
2606
+ for (const inst of instanceList) {
2607
+ if (!inst.mainComponent && !inst.componentId) {
2608
+ detachedCount++;
2609
+ issues.push({
2610
+ nodeId: inst.id,
2611
+ nodeName: inst.name,
2612
+ issue: "Instance appears detached from its main component"
2613
+ });
2614
+ }
2615
+ }
2616
+ const data = {
2617
+ totalComponents: componentList.length,
2618
+ totalInstances: instanceList.length,
2619
+ detachedCount,
2620
+ issues
2621
+ };
2622
+ return standardResult({
2623
+ summary: `Components: ${componentList.length} defined, ${instanceList.length} instances, ${detachedCount} potentially detached`,
2624
+ data,
2625
+ recommended_next: [
2626
+ { tool: "get_design_tokens", reason: "Review design system completeness" },
2627
+ { tool: "search_nodes", reason: "Find specific components by name" }
2628
+ ]
2629
+ });
2630
+ } catch (error) {
2631
+ return errorResult2(
2632
+ "connection_error",
2633
+ `Analysis failed: ${error instanceof Error ? error.message : String(error)}`,
2634
+ { suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
2635
+ );
2636
+ }
2637
+ }
2638
+ );
2639
+ server.tool(
2640
+ "analyze_accessibility",
2641
+ "Check accessibility: text contrast ratios against backgrounds, touch target sizes. Use to ensure designs meet WCAG guidelines. Follow with update_node to fix contrast or sizing issues.",
2642
+ {
2643
+ nodeId: z19.string().optional().describe("Root node to analyze (default: current selection)")
2644
+ },
2645
+ { readOnlyHint: true },
2646
+ async ({ nodeId }) => {
2647
+ try {
2648
+ let targetId;
2649
+ if (nodeId) {
2650
+ targetId = nodeId;
2651
+ } else {
2652
+ const selection = await sendCommand("get_selection");
2653
+ if (!selection?.nodes?.length) return noSelectionError();
2654
+ targetId = selection.nodes[0].id;
2655
+ }
2656
+ const nodeInfo = await sendCommand("get_node_info", { nodeId: targetId, depth: 8, maxNodes: 500 });
2657
+ if (!nodeInfo) {
2658
+ return errorResult2("invalid_input", `Node '${targetId}' not found`, { suggestion: "Use search_nodes", tool: "search_nodes" });
2659
+ }
2660
+ const issues = [];
2661
+ checkAccessibility(nodeInfo, null, issues);
2662
+ const data = {
2663
+ issueCount: issues.length,
2664
+ issues
2665
+ };
2666
+ return standardResult({
2667
+ summary: `Accessibility: ${issues.length} issue(s) found`,
2668
+ data,
2669
+ warnings: issues.map((i) => ({
2670
+ category: "accessibility",
2671
+ message: i.issue,
2672
+ nodeId: i.nodeId
2673
+ })),
2674
+ recommended_next: issues.length > 0 ? [{ tool: "update_node", reason: "Fix accessibility issues" }] : [{ tool: "validate_design_rules", reason: "Run full design validation" }]
2675
+ });
2676
+ } catch (error) {
2677
+ return errorResult2(
2678
+ "connection_error",
2679
+ `Analysis failed: ${error instanceof Error ? error.message : String(error)}`,
2680
+ { suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
2681
+ );
2682
+ }
2683
+ }
2684
+ );
2685
+ const CHECKPOINT_KEY = "relai.checkpoint";
2686
+ server.tool(
2687
+ "diff_nodes",
2688
+ `Compare two nodes and list their differences (fills, strokes, size, layout, etc.), or audit changes over time with checkpoints: checkpoint:"save" snapshots nodeIdA's key properties; checkpoint:"compare" later diffs the current state against that snapshot \u2014 useful before/after an editing session to show the designer exactly what changed.`,
2689
+ {
2690
+ nodeIdA: z19.string().describe("First node ID (or the checkpoint target)"),
2691
+ nodeIdB: z19.string().optional().describe("Second node ID (omit when using checkpoint)"),
2692
+ checkpoint: z19.enum(["save", "compare"]).optional().describe("save: snapshot nodeIdA now; compare: diff current vs saved snapshot")
2693
+ },
2694
+ { readOnlyHint: true },
2695
+ async ({ nodeIdA, nodeIdB, checkpoint }) => {
2696
+ try {
2697
+ if (checkpoint === "save") {
2698
+ const node = await sendCommand("get_node_info", { nodeId: nodeIdA, depth: 2 });
2699
+ if (!node) return errorResult2("invalid_input", `Node '${nodeIdA}' not found`, { suggestion: "Check node ID", tool: "search_nodes" });
2700
+ const summary = normalizeNode(node);
2701
+ await sendCommand("set_plugin_data", {
2702
+ nodeId: nodeIdA,
2703
+ key: CHECKPOINT_KEY,
2704
+ value: JSON.stringify({ ts: Date.now(), name: node.name, summary })
2705
+ });
2706
+ return standardResult({
2707
+ summary: `Checkpoint saved for '${node.name}' (${nodeIdA}). Run diff_nodes with checkpoint:"compare" after editing to see what changed.`,
2708
+ data: { nodeId: nodeIdA, saved: true },
2709
+ recommended_next: []
2710
+ });
2711
+ }
2712
+ if (checkpoint === "compare") {
2713
+ const [node, stored] = await Promise.all([
2714
+ sendCommand("get_node_info", { nodeId: nodeIdA, depth: 2 }),
2715
+ sendCommand("get_plugin_data", { nodeId: nodeIdA, key: CHECKPOINT_KEY })
2716
+ ]);
2717
+ if (!node) return errorResult2("invalid_input", `Node '${nodeIdA}' not found`, { suggestion: "Check node ID", tool: "search_nodes" });
2718
+ const raw = typeof stored === "string" ? stored : stored?.value;
2719
+ if (!raw) {
2720
+ return errorResult2("invalid_input", `No checkpoint saved on '${nodeIdA}'`, {
2721
+ suggestion: 'Save one first with checkpoint:"save"',
2722
+ tool: "diff_nodes"
2723
+ });
2724
+ }
2725
+ const saved = JSON.parse(raw);
2726
+ const before = saved.summary;
2727
+ const after = normalizeNode(node);
2728
+ const differences2 = [];
2729
+ if (before && after) {
2730
+ compareField(differences2, "type", before.type, after.type);
2731
+ compareField(differences2, "fill", before.fill, after.fill);
2732
+ compareField(differences2, "stroke", before.stroke, after.stroke);
2733
+ compareField(differences2, "cornerRadius", before.cornerRadius, after.cornerRadius);
2734
+ compareField(differences2, "opacity", before.opacity, after.opacity);
2735
+ compareField(differences2, "width", before.size?.width, after.size.width);
2736
+ compareField(differences2, "height", before.size?.height, after.size.height);
2737
+ compareField(differences2, "layout.mode", before.layout?.mode, after.layout?.mode);
2738
+ compareField(differences2, "layout.gap", before.layout?.gap, after.layout?.gap);
2739
+ compareField(differences2, "layout.padding", before.layout?.padding, after.layout?.padding);
2740
+ }
2741
+ const age = Math.round((Date.now() - (saved.ts ?? Date.now())) / 1e3);
2742
+ return standardResult({
2743
+ summary: differences2.length === 0 ? `'${node.name}' is unchanged since the checkpoint ${age}s ago` : `${differences2.length} propert${differences2.length === 1 ? "y" : "ies"} changed on '${node.name}' since the checkpoint ${age}s ago`,
2744
+ data: {
2745
+ nodeId: nodeIdA,
2746
+ checkpointTs: saved.ts,
2747
+ identical: differences2.length === 0,
2748
+ differences: differences2
2749
+ },
2750
+ recommended_next: []
2751
+ });
2752
+ }
2753
+ if (!nodeIdB) {
2754
+ return errorResult2("invalid_input", 'Provide nodeIdB, or use checkpoint:"save"/"compare" with nodeIdA alone', {
2755
+ suggestion: "Two-node diff needs both ids",
2756
+ tool: "diff_nodes"
2757
+ });
2758
+ }
2759
+ const [nodeA, nodeB] = await Promise.all([
2760
+ sendCommand("get_node_info", { nodeId: nodeIdA, depth: 2 }),
2761
+ sendCommand("get_node_info", { nodeId: nodeIdB, depth: 2 })
2762
+ ]);
2763
+ if (!nodeA) return errorResult2("invalid_input", `Node '${nodeIdA}' not found`, { suggestion: "Check node ID", tool: "search_nodes" });
2764
+ if (!nodeB) return errorResult2("invalid_input", `Node '${nodeIdB}' not found`, { suggestion: "Check node ID", tool: "search_nodes" });
2765
+ const summaryA = normalizeNode(nodeA);
2766
+ const summaryB = normalizeNode(nodeB);
2767
+ const differences = [];
2768
+ if (summaryA && summaryB) {
2769
+ compareField(differences, "type", summaryA.type, summaryB.type);
2770
+ compareField(differences, "fill", summaryA.fill, summaryB.fill);
2771
+ compareField(differences, "stroke", summaryA.stroke, summaryB.stroke);
2772
+ compareField(differences, "cornerRadius", summaryA.cornerRadius, summaryB.cornerRadius);
2773
+ compareField(differences, "opacity", summaryA.opacity, summaryB.opacity);
2774
+ compareField(differences, "width", summaryA.size.width, summaryB.size.width);
2775
+ compareField(differences, "height", summaryA.size.height, summaryB.size.height);
2776
+ compareField(differences, "layout.mode", summaryA.layout?.mode, summaryB.layout?.mode);
2777
+ compareField(differences, "layout.gap", summaryA.layout?.gap, summaryB.layout?.gap);
2778
+ compareField(differences, "layout.padding", summaryA.layout?.padding, summaryB.layout?.padding);
2779
+ }
2780
+ const data = {
2781
+ nodeA: { id: nodeIdA, name: nodeA.name },
2782
+ nodeB: { id: nodeIdB, name: nodeB.name },
2783
+ identical: differences.length === 0,
2784
+ differences
2785
+ };
2786
+ return standardResult({
2787
+ summary: differences.length === 0 ? `'${nodeA.name}' and '${nodeB.name}' are identical in key properties` : `${differences.length} difference(s) between '${nodeA.name}' and '${nodeB.name}'`,
2788
+ data,
2789
+ recommended_next: differences.length > 0 ? [{ tool: "update_node", reason: "Align properties between nodes" }] : []
2790
+ });
2791
+ } catch (error) {
2792
+ return errorResult2(
2793
+ "connection_error",
2794
+ `Diff failed: ${error instanceof Error ? error.message : String(error)}`,
2795
+ { suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
2796
+ );
2797
+ }
2798
+ }
2799
+ );
2800
+ }
2801
+ function collectColorIssues(node, boundVars, issues, counters) {
2802
+ if (node.fills?.length > 0) {
2803
+ const solidFills = node.fills.filter((f) => f.type === "SOLID" && f.visible !== false);
2804
+ if (solidFills.length > 0) {
2805
+ counters.totalProps++;
2806
+ const hasFillBinding = Object.keys(boundVars).some((k) => k.startsWith("fills"));
2807
+ if (hasFillBinding) {
2808
+ counters.boundCount++;
2809
+ } else {
2810
+ for (const fill of solidFills) {
2811
+ if (!fill.color) continue;
2812
+ const color = typeof fill.color === "string" ? fill.color : rgbaToHex({
2813
+ r: fill.color.r,
2814
+ g: fill.color.g,
2815
+ b: fill.color.b,
2816
+ a: fill.color.a ?? 1
2817
+ });
2818
+ issues.push({
2819
+ nodeId: node.id,
2820
+ nodeName: node.name,
2821
+ property: "fill",
2822
+ color
2823
+ });
2824
+ }
2825
+ }
2826
+ }
2827
+ }
2828
+ if (node.strokes?.length > 0) {
2829
+ const solidStrokes = node.strokes.filter((s) => s.type === "SOLID" && s.visible !== false);
2830
+ if (solidStrokes.length > 0) {
2831
+ counters.totalProps++;
2832
+ const hasStrokeBinding = Object.keys(boundVars).some((k) => k.startsWith("strokes"));
2833
+ if (hasStrokeBinding) {
2834
+ counters.boundCount++;
2835
+ } else {
2836
+ for (const stroke of solidStrokes) {
2837
+ if (!stroke.color) continue;
2838
+ const color = typeof stroke.color === "string" ? stroke.color : rgbaToHex({
2839
+ r: stroke.color.r,
2840
+ g: stroke.color.g,
2841
+ b: stroke.color.b,
2842
+ a: stroke.color.a ?? 1
2843
+ });
2844
+ issues.push({
2845
+ nodeId: node.id,
2846
+ nodeName: node.name,
2847
+ property: "stroke",
2848
+ color
2849
+ });
2850
+ }
2851
+ }
2852
+ }
2853
+ }
2854
+ }
2855
+ function collectFrames(node) {
2856
+ const frames = [];
2857
+ if (node.type === "FRAME" || node.type === "COMPONENT" || node.type === "COMPONENT_SET") {
2858
+ frames.push(node);
2859
+ }
2860
+ if (node.children) {
2861
+ for (const child of node.children) {
2862
+ frames.push(...collectFrames(child));
2863
+ }
2864
+ }
2865
+ return frames;
2866
+ }
2867
+ function analyzeLayoutNode(node, issues, _counters) {
2868
+ if (node.type === "FRAME" || node.type === "COMPONENT") {
2869
+ if (!node.layoutMode || node.layoutMode === "NONE") {
2870
+ if (node.children?.length >= 2) {
2871
+ issues.push({
2872
+ nodeId: node.id,
2873
+ nodeName: node.name,
2874
+ issue: `Frame '${node.name}' has ${node.children.length} children but no auto-layout`,
2875
+ severity: "warning",
2876
+ suggestion: "Add auto-layout with set_auto_layout for responsive behavior"
2877
+ });
2878
+ }
2879
+ }
2880
+ }
2881
+ if (node.children) {
2882
+ for (const child of node.children) {
2883
+ analyzeLayoutNode(child, issues, _counters);
2884
+ }
2885
+ }
2886
+ }
2887
+ function checkAccessibility(node, parentBg, issues) {
2888
+ if (node.type === "INSTANCE" || node.name?.toLowerCase().includes("button")) {
2889
+ const w = node.width ?? node.absoluteBoundingBox?.width ?? 0;
2890
+ const h = node.height ?? node.absoluteBoundingBox?.height ?? 0;
2891
+ if (w > 0 && w < 44 || h > 0 && h < 44) {
2892
+ issues.push({
2893
+ nodeId: node.id,
2894
+ nodeName: node.name,
2895
+ issue: `Touch target too small: ${Math.round(w)}\xD7${Math.round(h)}px (minimum 44\xD744px)`
2896
+ });
2897
+ }
2898
+ }
2899
+ if (node.type === "TEXT") {
2900
+ const fontSize = typeof node.fontSize === "number" ? node.fontSize : null;
2901
+ const fontWeight = typeof node.fontWeight === "number" ? node.fontWeight : 400;
2902
+ if (fontSize !== null && fontSize < 11) {
2903
+ issues.push({
2904
+ nodeId: node.id,
2905
+ nodeName: node.name,
2906
+ issue: `Text size ${fontSize}px is below the 11px readability floor`
2907
+ });
2908
+ }
2909
+ const textFill = node.fills?.find((f) => f.type === "SOLID" && f.visible !== false);
2910
+ if (textFill?.color && parentBg?.color) {
2911
+ if (parentBg.solid) {
2912
+ const alpha = (textFill.opacity ?? 1) * (typeof node.opacity === "number" ? node.opacity : 1) * (textFill.color.a ?? 1);
2913
+ const effective = blendOver(textFill.color, parentBg.color, alpha);
2914
+ const isLarge = fontSize !== null && (fontSize >= 24 || fontSize >= 18.66 && fontWeight >= 700);
2915
+ const required = isLarge ? 3 : 4.5;
2916
+ const ratio = calculateContrastRatio(effective, parentBg.color);
2917
+ if (ratio < required) {
2918
+ issues.push({
2919
+ nodeId: node.id,
2920
+ nodeName: node.name,
2921
+ issue: `Low contrast ratio: ${ratio.toFixed(1)}:1 (minimum ${required}:1 for ${isLarge ? "large" : "body"} text${alpha < 1 ? ", opacity included" : ""})`,
2922
+ contrastRatio: Math.round(ratio * 10) / 10,
2923
+ requiredRatio: required
2924
+ });
2925
+ }
2926
+ } else {
2927
+ issues.push({
2928
+ nodeId: node.id,
2929
+ nodeName: node.name,
2930
+ issue: `Text sits on a ${parentBg.kind} background \u2014 contrast cannot be verified automatically; check manually or add a solid scrim`
2931
+ });
2932
+ }
2933
+ }
2934
+ }
2935
+ let bg = parentBg;
2936
+ if (node.type !== "TEXT" && node.fills?.length > 0) {
2937
+ const visible = node.fills.filter((f) => f.visible !== false);
2938
+ const solid = visible.find((f) => f.type === "SOLID");
2939
+ const nonSolid = visible.find((f) => f.type !== "SOLID");
2940
+ if (nonSolid) {
2941
+ const type = String(nonSolid.type ?? "IMAGE");
2942
+ bg = {
2943
+ color: solid?.color ?? { r: 0.5, g: 0.5, b: 0.5 },
2944
+ solid: false,
2945
+ kind: type.startsWith("GRADIENT") ? "gradient" : type.toLowerCase()
2946
+ };
2947
+ } else if (solid?.color) {
2948
+ bg = { color: solid.color, solid: true, kind: "solid" };
2949
+ }
2950
+ }
2951
+ if (node.children) {
2952
+ for (const child of node.children) {
2953
+ checkAccessibility(child, bg, issues);
2954
+ }
2955
+ }
2956
+ }
2957
+ function blendOver(fg, bg, alpha) {
2958
+ const a = Math.max(0, Math.min(1, alpha));
2959
+ return {
2960
+ r: fg.r * a + bg.r * (1 - a),
2961
+ g: fg.g * a + bg.g * (1 - a),
2962
+ b: fg.b * a + bg.b * (1 - a)
2963
+ };
2964
+ }
2965
+ function calculateContrastRatio(fg, bg) {
2966
+ const fgL = relativeLuminance(fg.r ?? 0, fg.g ?? 0, fg.b ?? 0);
2967
+ const bgL = relativeLuminance(bg.r ?? 0, bg.g ?? 0, bg.b ?? 0);
2968
+ const lighter = Math.max(fgL, bgL);
2969
+ const darker = Math.min(fgL, bgL);
2970
+ return (lighter + 0.05) / (darker + 0.05);
2971
+ }
2972
+ function relativeLuminance(r, g, b) {
2973
+ const rL = r <= 0.03928 ? r / 12.92 : Math.pow((r + 0.055) / 1.055, 2.4);
2974
+ const gL = g <= 0.03928 ? g / 12.92 : Math.pow((g + 0.055) / 1.055, 2.4);
2975
+ const bL = b <= 0.03928 ? b / 12.92 : Math.pow((b + 0.055) / 1.055, 2.4);
2976
+ return 0.2126 * rL + 0.7152 * gL + 0.0722 * bL;
2977
+ }
2978
+ function compareField(diffs, field, a, b) {
2979
+ if (a !== b) {
2980
+ diffs.push({ field, nodeA: a, nodeB: b });
2981
+ }
2982
+ }
2983
+
2984
+ // src/tools/v2/verification.ts
2985
+ var verification_exports = {};
2986
+ __export(verification_exports, {
2987
+ register: () => register19
2988
+ });
2989
+ import { z as z20 } from "zod";
2990
+ function register19(server, sendCommand) {
2991
+ server.tool(
2992
+ "verify_changes",
2993
+ "Verify that a node's current properties match expected values. Use after update_node, set_auto_layout, or batch_execute to confirm changes applied correctly. Returns match/mismatch for each checked field.",
2994
+ {
2995
+ nodeId: z20.string().describe("Node ID to verify"),
2996
+ expected: z20.record(z20.string(), z20.unknown()).describe("Expected property values, e.g. { fill: '#3B82F6', cornerRadius: 8 }")
2997
+ },
2998
+ { readOnlyHint: true },
2999
+ async ({ nodeId, expected }) => {
3000
+ try {
3001
+ const nodeInfo = await sendCommand("get_node_info", { nodeId, depth: 2 });
3002
+ if (!nodeInfo) {
3003
+ return errorResult2(
3004
+ "invalid_input",
3005
+ `Node '${nodeId}' not found \u2014 it may have been deleted`,
3006
+ { suggestion: "Use search_nodes to find the node", tool: "search_nodes" }
3007
+ );
3008
+ }
3009
+ const normalized = normalizeNode(nodeInfo);
3010
+ if (!normalized) {
3011
+ return errorResult2(
3012
+ "invalid_input",
3013
+ `Could not normalize node '${nodeId}'`,
3014
+ { suggestion: "Try get_node_details for raw data", tool: "get_node_details", args: { nodeId } }
3015
+ );
3016
+ }
3017
+ const fields = [];
3018
+ for (const [key, expectedValue] of Object.entries(expected)) {
3019
+ const actual = getNestedValue(normalized, nodeInfo, key);
3020
+ const match = deepEqual(actual, expectedValue);
3021
+ fields.push({ field: key, expected: expectedValue, actual, match });
3022
+ }
3023
+ const allMatch = fields.every((f) => f.match);
3024
+ const data = {
3025
+ nodeId,
3026
+ allMatch,
3027
+ fields
3028
+ };
3029
+ return standardResult({
3030
+ summary: allMatch ? `All ${fields.length} checked properties match expected values` : `${fields.filter((f) => !f.match).length} of ${fields.length} properties don't match expected values`,
3031
+ data,
3032
+ recommended_next: allMatch ? [{ tool: "screenshot", reason: "Visually confirm the changes" }] : [{ tool: "update_node", reason: "Re-apply changes that didn't match", args: { nodeId } }]
3033
+ });
3034
+ } catch (error) {
3035
+ return errorResult2(
3036
+ "connection_error",
3037
+ `Verification failed: ${error instanceof Error ? error.message : String(error)}`,
3038
+ { suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
3039
+ );
3040
+ }
3041
+ }
3042
+ );
3043
+ server.tool(
3044
+ "validate_design_rules",
3045
+ "Run design quality rules on a node: token coverage, auto-layout usage, naming conventions, and accessibility basics. Use after modifications or during design audits. Returns pass/fail for each rule with fix suggestions.",
3046
+ {
3047
+ nodeId: z20.string().optional().describe("Root node to validate (default: current selection)")
3048
+ },
3049
+ { readOnlyHint: true },
3050
+ async ({ nodeId }) => {
3051
+ try {
3052
+ let targetId;
3053
+ if (nodeId) {
3054
+ targetId = nodeId;
3055
+ } else {
3056
+ const selection = await sendCommand("get_selection");
3057
+ if (!selection?.nodes?.length) return noSelectionError();
3058
+ targetId = selection.nodes[0].id;
3059
+ }
3060
+ const [nodeInfo, boundVars] = await Promise.all([
3061
+ sendCommand("get_node_info", { nodeId: targetId, depth: 2 }),
3062
+ sendCommand("get_bound_variables", { nodeId: targetId }).catch(() => null)
3063
+ ]);
3064
+ if (!nodeInfo) {
3065
+ return errorResult2("invalid_input", `Node '${targetId}' not found`, { suggestion: "Use search_nodes", tool: "search_nodes" });
3066
+ }
3067
+ const bv = boundVars?.boundVariables || {};
3068
+ const results = [];
3069
+ const coverage = calculateTokenCoverage(nodeInfo, bv);
3070
+ results.push({
3071
+ rule: "token_coverage",
3072
+ passed: coverage >= 0.8,
3073
+ severity: coverage >= 0.5 ? "warning" : "error",
3074
+ message: `Token coverage: ${Math.round(coverage * 100)}% (target: 80%+)`,
3075
+ nodeId: targetId,
3076
+ fix: coverage < 0.8 ? { tool: "bind_tokens", reason: "Bind unbound properties to design tokens", args: { nodeId: targetId } } : void 0
3077
+ });
3078
+ if (nodeInfo.type === "FRAME" || nodeInfo.type === "COMPONENT") {
3079
+ const hasAutoLayout = nodeInfo.layoutMode && nodeInfo.layoutMode !== "NONE";
3080
+ const hasChildren = nodeInfo.children?.length >= 2;
3081
+ results.push({
3082
+ rule: "auto_layout",
3083
+ passed: hasAutoLayout || !hasChildren,
3084
+ severity: "warning",
3085
+ message: hasAutoLayout ? `Auto-layout: ${nodeInfo.layoutMode}` : hasChildren ? `No auto-layout on frame with ${nodeInfo.children.length} children` : "No children requiring auto-layout",
3086
+ nodeId: targetId,
3087
+ fix: !hasAutoLayout && hasChildren ? { tool: "set_auto_layout", reason: "Add auto-layout for responsive behavior", args: { nodeId: targetId } } : void 0
3088
+ });
3089
+ }
3090
+ const hasDefaultName = /^(Frame|Rectangle|Ellipse|Group|Text|Vector|Line|Polygon|Star)\s*\d*$/.test(nodeInfo.name);
3091
+ results.push({
3092
+ rule: "naming_convention",
3093
+ passed: !hasDefaultName,
3094
+ severity: "info",
3095
+ message: hasDefaultName ? `'${nodeInfo.name}' uses a default Figma name \u2014 rename for clarity` : `Name '${nodeInfo.name}' looks intentional`,
3096
+ nodeId: targetId,
3097
+ fix: hasDefaultName ? { tool: "update_node", reason: "Rename with a semantic name", args: { nodeId: targetId } } : void 0
3098
+ });
3099
+ if (nodeInfo.type === "INSTANCE" || nodeInfo.name?.toLowerCase().includes("button")) {
3100
+ const w = nodeInfo.width ?? 0;
3101
+ const h = nodeInfo.height ?? 0;
3102
+ const adequate = w >= 44 && h >= 44;
3103
+ results.push({
3104
+ rule: "touch_target_size",
3105
+ passed: adequate,
3106
+ severity: "warning",
3107
+ message: adequate ? `Touch target: ${Math.round(w)}\xD7${Math.round(h)}px (OK)` : `Touch target: ${Math.round(w)}\xD7${Math.round(h)}px (minimum 44\xD744px)`,
3108
+ nodeId: targetId,
3109
+ fix: !adequate ? { tool: "update_node", reason: "Increase size to meet touch target minimum", args: { nodeId: targetId } } : void 0
3110
+ });
3111
+ }
3112
+ const passed = results.filter((r) => r.passed).length;
3113
+ const failed = results.filter((r) => !r.passed).length;
3114
+ const data = {
3115
+ rulesChecked: results.length,
3116
+ passed,
3117
+ failed,
3118
+ results
3119
+ };
3120
+ return standardResult({
3121
+ summary: `Design validation: ${passed}/${results.length} rules passed, ${failed} failed`,
3122
+ data,
3123
+ recommended_next: failed > 0 ? results.filter((r) => !r.passed && r.fix).map((r) => r.fix).slice(0, 3) : [{ tool: "screenshot", reason: "Visually confirm the design quality" }]
3124
+ });
3125
+ } catch (error) {
3126
+ return errorResult2(
3127
+ "connection_error",
3128
+ `Validation failed: ${error instanceof Error ? error.message : String(error)}`,
3129
+ { suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
3130
+ );
3131
+ }
3132
+ }
3133
+ );
3134
+ server.tool(
3135
+ "verify_visual",
3136
+ "The write\u2192see\u2192assert loop in one call: screenshots a node AND (optionally) checks expected property values. Returns the image plus structured pass/fail. Use after visual edits instead of separate screenshot + verify_changes calls.",
3137
+ {
3138
+ nodeId: z20.string().describe("Node to verify"),
3139
+ expected: z20.record(z20.string(), z20.unknown()).optional().describe("Expected property values, e.g. { cornerRadius: 8, width: 320 }")
3140
+ },
3141
+ { readOnlyHint: true },
3142
+ async ({ nodeId, expected }) => {
3143
+ try {
3144
+ const [shot, nodeInfo] = await Promise.all([
3145
+ sendCommand("get_screenshot", { nodeId }, 6e4),
3146
+ expected ? sendCommand("get_node_info", { nodeId, depth: 2 }) : Promise.resolve(null)
3147
+ ]);
3148
+ const content = [];
3149
+ if (shot?.imageData) {
3150
+ content.push({
3151
+ type: "image",
3152
+ data: shot.imageData,
3153
+ mimeType: shot.mimeType ?? "image/png"
3154
+ });
3155
+ }
3156
+ if (expected && nodeInfo) {
3157
+ const normalized = normalizeNode(nodeInfo) ?? {};
3158
+ const fields = Object.entries(expected).map(([key, expectedValue]) => {
3159
+ const actual = getNestedValue(normalized, nodeInfo, key);
3160
+ return { field: key, expected: expectedValue, actual, match: deepEqual(actual, expectedValue) };
3161
+ });
3162
+ const failing = fields.filter((f) => !f.match);
3163
+ content.push({
3164
+ type: "text",
3165
+ text: JSON.stringify(
3166
+ {
3167
+ summary: failing.length === 0 ? `All ${fields.length} checked properties match \u2014 confirm the screenshot looks right` : `${failing.length}/${fields.length} properties do NOT match`,
3168
+ fields
3169
+ },
3170
+ null,
3171
+ 2
3172
+ )
3173
+ });
3174
+ } else if (!shot?.imageData) {
3175
+ content.push({ type: "text", text: "Screenshot unavailable for this node." });
3176
+ }
3177
+ return { content };
3178
+ } catch (error) {
3179
+ return errorResult2(
3180
+ "connection_error",
3181
+ `Visual verification failed: ${error instanceof Error ? error.message : String(error)}`,
3182
+ { suggestion: "Ensure Figma plugin is connected", tool: "join_room" }
3183
+ );
3184
+ }
3185
+ }
3186
+ );
3187
+ }
3188
+ function getNestedValue(normalized, raw, key) {
3189
+ if (key in normalized) return normalized[key];
3190
+ if (key === "width") return normalized.size?.width;
3191
+ if (key === "height") return normalized.size?.height;
3192
+ if (key === "x") return normalized.position?.x;
3193
+ if (key === "y") return normalized.position?.y;
3194
+ return raw[key];
3195
+ }
3196
+ function deepEqual(a, b) {
3197
+ if (a === b) return true;
3198
+ if (a == null || b == null) return false;
3199
+ if (typeof a === "number" && typeof b === "number") {
3200
+ return Math.abs(a - b) < 0.01;
3201
+ }
3202
+ if (typeof a === "string" && typeof b === "string") {
3203
+ return a.toLowerCase() === b.toLowerCase();
3204
+ }
3205
+ return JSON.stringify(a) === JSON.stringify(b);
3206
+ }
3207
+
3208
+ // src/tools/index.ts
3209
+ var moduleCategories = [
3210
+ [context_exports, "context"],
3211
+ [analysis_exports, "analysis"],
3212
+ [verification_exports, "verification"],
3213
+ [read_exports, "read"],
3214
+ [create_exports, "create"],
3215
+ [properties_exports, "edit"],
3216
+ [structure_exports, "edit"],
3217
+ [text_exports, "edit"],
3218
+ [components_exports, "components"],
3219
+ [variables_exports, "design-system"],
3220
+ [styles_exports, "design-system"],
3221
+ [library_exports, "design-system"],
3222
+ [pages_exports, "document"],
3223
+ [navigate_exports, "document"],
3224
+ [assets_exports, "assets"],
3225
+ [annotate_exports, "annotations"],
3226
+ [comments_exports, "comments"],
3227
+ [batch_exports, "advanced"],
3228
+ [execute_exports, "advanced"]
3229
+ ];
3230
+ function registerRoomTool(server, joinRoom) {
3231
+ server.tool(
3232
+ "join_room",
3233
+ "Connect to a specific Figma plugin instance by room name. Usually unnecessary \u2014 pairing is automatic when one plugin is connected. Use only when an error reports multiple plugins/rooms; the room name is shown in each plugin's UI.",
3234
+ {
3235
+ room: z21.string().describe("Room name shown in the Figma plugin")
3236
+ },
3237
+ async ({ room }) => {
3238
+ try {
3239
+ if (!room) {
3240
+ return {
3241
+ content: [{ type: "text", text: "Please provide a room name." }]
3242
+ };
3243
+ }
3244
+ await joinRoom(room);
3245
+ return {
3246
+ content: [
3247
+ { type: "text", text: `Successfully joined room: ${room}` }
3248
+ ]
3249
+ };
3250
+ } catch (error) {
3251
+ return {
3252
+ content: [
3253
+ {
3254
+ type: "text",
3255
+ text: `Error joining room: ${error instanceof Error ? error.message : String(error)}`
3256
+ }
3257
+ ]
3258
+ };
3259
+ }
3260
+ }
3261
+ );
3262
+ }
3263
+ function registerEventsTool(server, consumeEvents, getSessionLog2) {
3264
+ server.tool(
3265
+ "get_events",
3266
+ "Activity since the last check. scope 'designer' (default): the designer's selection/node/page changes (also piggybacked as designer_events on command results). scope 'agent': this session's own command log \u2014 every plugin command sent, with success/duration \u2014 useful for summarizing what was changed. 'all': both.",
3267
+ {
3268
+ scope: z21.enum(["designer", "agent", "all"]).optional()
3269
+ },
3270
+ { readOnlyHint: true },
3271
+ async ({ scope = "designer" }) => {
3272
+ const parts = {};
3273
+ if (scope === "designer" || scope === "all") {
3274
+ parts.designer_events = consumeEvents();
3275
+ }
3276
+ if (scope === "agent" || scope === "all") {
3277
+ parts.session_log = getSessionLog2();
3278
+ }
3279
+ const empty = Object.values(parts).every((v) => Array.isArray(v) && v.length === 0);
3280
+ return {
3281
+ content: [
3282
+ {
3283
+ type: "text",
3284
+ text: empty ? "No activity recorded." : JSON.stringify(parts, null, 2)
3285
+ }
3286
+ ]
3287
+ };
3288
+ }
3289
+ );
3290
+ }
3291
+ function registerAllTools(server, sendCommand) {
3292
+ for (const [mod] of moduleCategories) {
3293
+ mod.register(server, sendCommand);
3294
+ }
3295
+ }
3296
+ function listToolCatalog() {
3297
+ const catalog = [
3298
+ { name: "join_room", category: "advanced" },
3299
+ { name: "get_events", category: "context" }
3300
+ ];
3301
+ for (const [mod, category] of moduleCategories) {
3302
+ const collector = {
3303
+ tool: (name) => {
3304
+ catalog.push({ name, category });
3305
+ }
3306
+ };
3307
+ mod.register(collector, async () => void 0);
3308
+ }
3309
+ return catalog;
3310
+ }
3311
+
3312
+ // src/embedded-relay.ts
3313
+ import { createServer as createServer2 } from "http";
3314
+ import { WebSocketServer } from "ws";
3315
+ function startEmbeddedRelay(port2, version) {
3316
+ return new Promise((resolve) => {
3317
+ const httpServer = createServer2();
3318
+ httpServer.once("error", (err) => {
3319
+ if (err.code === "EADDRINUSE") {
3320
+ resolve(null);
3321
+ } else {
3322
+ logger.warn(`Embedded relay failed to start: ${err.message}`);
3323
+ resolve(null);
3324
+ }
3325
+ });
3326
+ httpServer.listen(port2, "127.0.0.1", () => {
3327
+ const core = new RelayCore({
3328
+ version,
3329
+ log: (msg) => logger.debug(`[relay] ${msg}`)
3330
+ });
3331
+ const wss = new WebSocketServer({ server: httpServer });
3332
+ wss.on("connection", (ws) => {
3333
+ core.handleOpen(ws);
3334
+ ws.on("message", (data) => core.handleMessage(ws, data.toString()));
3335
+ ws.on("close", () => core.handleClose(ws));
3336
+ ws.on("error", () => {
3337
+ });
3338
+ });
3339
+ core.startStaleCleanup();
3340
+ logger.info(`Hosting embedded relay on 127.0.0.1:${port2}`);
3341
+ resolve({
3342
+ close() {
3343
+ core.stop();
3344
+ wss.close();
3345
+ httpServer.close();
3346
+ }
3347
+ });
3348
+ });
3349
+ });
3350
+ }
3351
+
3352
+ // src/state.ts
3353
+ import { mkdirSync, readFileSync, writeFileSync } from "fs";
3354
+ import { homedir } from "os";
3355
+ import { join } from "path";
3356
+ function stateDir() {
3357
+ return process.env.FIGMA_RELAI_STATE_DIR ?? join(homedir(), ".figma-relai");
3358
+ }
3359
+ function statePath() {
3360
+ return join(stateDir(), "state.json");
3361
+ }
3362
+ function loadState() {
3363
+ try {
3364
+ return JSON.parse(readFileSync(statePath(), "utf8"));
3365
+ } catch {
3366
+ return {};
3367
+ }
3368
+ }
3369
+ function saveState(patch) {
3370
+ try {
3371
+ mkdirSync(stateDir(), { recursive: true });
3372
+ const next = { ...loadState(), ...patch, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
3373
+ writeFileSync(statePath(), JSON.stringify(next, null, 2) + "\n");
3374
+ } catch {
3375
+ }
3376
+ }
3377
+
3378
+ // ../../docs/skills/figma-plugin-api.md
3379
+ var figma_plugin_api_default = '# Figma Plugin API Cheat Sheet \u2014 for `execute_figma`\n\nRules and patterns for writing code that runs in the plugin sandbox. Work in small incremental scripts and `screenshot` between steps.\n\n## Non-negotiable rules (dynamic-page mode)\n\n- **Node lookup is async**: `await figma.getNodeByIdAsync(id)` \u2014 `getNodeById` throws in this plugin.\n- **Load pages before traversal**: `await page.loadAsync()` before `page.findAll(...)` on a non-current page; `figma.currentPage` is always loaded.\n- **Load fonts before ANY text edit**: `await figma.loadFontAsync(textNode.fontName)` \u2014 if `fontName === figma.mixed`, load every range: `for (const f of textNode.getRangeAllFontNames(0, textNode.characters.length)) await figma.loadFontAsync(f)`.\n- **`figma.mixed` is a Symbol**: properties like `fontSize`, `cornerRadius`, `fills` return it when values differ across ranges/children. Check `=== figma.mixed` before using; never JSON-serialize it.\n\n## Common patterns\n\n```js\n// Selection\nconst sel = figma.currentPage.selection; // read\nfigma.currentPage.selection = [node]; // write\n\n// Create + place\nconst frame = figma.createFrame();\nframe.resize(320, 200); // width/height are read-only; use resize()\nparent.appendChild(frame); // default parent is currentPage\n\n// Fills/strokes are ARRAYS and must be reassigned wholesale\nnode.fills = [{ type: "SOLID", color: { r: 1, g: 0.5, b: 0 } }]; // rgb 0-1, no alpha key \u2014 use opacity\nconst fills = JSON.parse(JSON.stringify(node.fills)); // clone before mutating\nfills[0].color.r = 0; node.fills = fills;\n\n// Auto-layout: set layoutMode FIRST, then padding/spacing/align\nframe.layoutMode = "VERTICAL";\nframe.itemSpacing = 8; frame.paddingTop = 16;\nchild.layoutSizingHorizontal = "FILL"; // requires PARENT with auto-layout\n\n// Components\nconst comp = await figma.importComponentByKeyAsync(key); // team library\nconst inst = comp.createInstance();\ninst.setProperties({ Variant: "Primary" }); // throws on unknown property names\n\n// Variables\nconst collections = await figma.variables.getLocalVariableCollectionsAsync();\nconst v = figma.variables.createVariable("name", collection, "COLOR");\nv.setValueForMode(modeId, { r: 0, g: 0, b: 0 });\nnode.setBoundVariable("fills", v); // binding paint needs figma.variables.setBoundVariableForPaint on the paint object for fills in older APIs \u2014 prefer setBoundVariable where available\n\n// Export (returns Uint8Array)\nconst bytes = await node.exportAsync({ format: "PNG", constraint: { type: "SCALE", value: 2 } });\n```\n\n## Pitfalls that throw\n\n- `resize(w, h)` with `w <= 0 || h <= 0`; resizing nodes without `resize` (e.g. some text auto-resize modes \u2014 set `textAutoResize` first).\n- Setting layout-child props (`layoutSizing*`, `layoutAlign`) when the **parent** has `layoutMode: "NONE"`.\n- `layoutWrap = "WRAP"` on VERTICAL; `counterAxisAlignItems = "BASELINE"` on VERTICAL.\n- Editing `characters` without loading fonts \u2192 "unloaded font".\n- Per-corner radius (`topLeftRadius` \u2026) on nodes without RectangleCornerMixin (polygons, stars, lines).\n- Touching `node.removed === true` nodes; writing to `locked` nodes is allowed by API but surprises designers \u2014 check first.\n- `instance.children` mutations \u2014 detach first or edit the main component.\n\n## Return values\n\nReturn JSON-serializable data (the bridge summarizes nodes automatically and truncates >50k chars). Prefer returning `{ id, name, type }` summaries over whole nodes. `console.log` is captured and returned in `logs`.\n';
3380
+
3381
+ // ../../docs/skills/design-tokens-strategy.md
3382
+ var design_tokens_strategy_default = "# Design Tokens Strategy \u2014 4-Tier Token Architecture\n\n## Token Hierarchy\n\n### Tier 1 \u2014 Core (static)\nRaw values. Never referenced directly by components.\n- Naming: `color-static/{hue}/{shade}`, `spacing-static/{value}`\n- Examples: `color-static/slate/50`, `color-static/rose/500`\n- Single mode: Value\n- hiddenFromPublishing: true\n\n### Tier 1 \u2014 Core (dynamic)\nReferences static tokens. Handles Light/Dark inversion.\n- Naming: `color-dynamic/{hue}/{shade}`\n- Light mode: shade 50 \u2192 static/50, shade 900 \u2192 static/900\n- Dark mode: shade 50 \u2192 static/900, shade 900 \u2192 static/50 (inverted)\n- 2 modes: Light / Dark\n- hiddenFromPublishing: true\n\n### Tier 2 \u2014 Themes\nSemantic tokens. Define brand personality.\n- Naming: `color/{role}/{variant}`, `typography/{style}/{property}`, `border/{type}/{size}`\n- Color roles: background, content, border, icon\n- Color variants: surface-page, surface-default, surface-subtle, surface-knockout, disabled, utility/{status}/{emphasis|subtle}\n- Typography: headline-{lg|md|sm}, title-{lg|md}, body-{lg|md|sm}[-bold], label-{lg|md|sm}, code-{md|sm}[-bold]\n- Each typography style has: font-family, font-weight, font-size, letter-spacing, line-height\n- Modes: one per brand (e.g., Brand A, Brand B)\n- description: required \u2014 explain purpose and usage constraints\n- scopes: set appropriately (TEXT_FILL for content, STROKE_COLOR for border, FRAME_FILL for background, etc.)\n\n### Tier 3 \u2014 Components\nComponent-specific tokens. Reference Tier 2 or dynamic Tier 1.\n- Naming: `{component}/color/{role}/{state}`, `{component}/size/{variant}`\n- States: default, hover, pressed, disabled, selected, selected-hover, selected-pressed, selected-disabled\n- Sizes: sm, md, lg\n- Examples: `button/color/background/default`, `button/size/md`, `navigation/color/content/selected`\n- Modes: same as Tier 2 (one per brand)\n- Not every component needs Tier 3 \u2014 only when reuse or shared decision-making justifies it\n\n## Key Rules\n- Components MUST reference tokens only \u2014 never raw hex/px values\n- Tier 1 is internal plumbing \u2014 never expose to consumers\n- Adding a new brand = adding a mode to Tier 2 and Tier 3 collections\n- Light/Dark switching is handled entirely in Tier 1 dynamic \u2014 no brand-level light/dark logic needed\n\n## Workflow\n1. `get_design_tokens` \u2192 understand existing collections and modes\n2. `manage_variables` (list) with collectionId \u2192 inspect current tokens\n3. `manage_variables` (create_collection) \u2192 new collection with modes\n4. `manage_variables` (create) \u2192 define tokens with values per mode\n5. `manage_variables` (bind) \u2192 connect tokens to node properties\n6. `manage_variables` (set_scopes) \u2192 restrict UI picker visibility\n7. `manage_variables` (set_code_syntax) \u2192 set Dev Mode code references\n\n## Verification\n- `analyze_design` (aspect: color) \u2192 find unbound colors (token coverage)\n- `validate_design_rules` \u2192 check token_coverage rule\n- `get_node_data` (detail: variables) on key nodes \u2192 confirm bindings are correct\n";
3383
+
3384
+ // ../../docs/skills/component-architecture-strategy.md
3385
+ var component_architecture_strategy_default = "# Component Architecture \u2014 Component Conventions\n\n## Token-First Principle\nEvery visual property must reference a token. No raw values in components.\n- Colors \u2192 Tier 2 semantic tokens or Tier 3 component tokens\n- Sizes (height, spacing) \u2192 Tier 2 or Tier 3 size tokens\n- Typography \u2192 Tier 2 typography tokens\n- Border radius/width \u2192 Tier 2 border tokens\n\n## Tier 3 Token Pattern\nWhen a component needs brand-differentiated values:\n\n`{component}/color/{role}/{state}`\n- Roles: background, border, content\n- States: default, hover, pressed, disabled\n- Selected states: selected, selected-hover, selected-pressed, selected-disabled\n- Variants: primary, ai, critical, ghost (prefix before role)\n\nExample \u2014 Button tokens:\n- `button/color/background/default` \u2192 neutral button bg\n- `button/primary/color/background/hover` \u2192 primary variant hover\n- `button/size/sm` / `button/size/md` / `button/size/lg`\n\n## State Management\nStandard interactive states:\n- default \u2192 hover \u2192 pressed \u2192 disabled (always present)\n- selected \u2192 selected-hover \u2192 selected-pressed \u2192 selected-disabled (for toggleable elements)\n\n## Size Variants\nUse sm / md / lg consistently. Define as Tier 3 FLOAT tokens when brand-differentiated.\n- `{component}/size/sm`, `{component}/size/md`, `{component}/size/lg`\n\n## Component Structure\n1. Use auto-layout (VERTICAL or HORIZONTAL) for all containers\n2. Use HUG for content-driven sizes, FILL for responsive elements\n3. Use component properties for configurable aspects:\n - BOOLEAN: toggle visibility (icons, labels)\n - INSTANCE_SWAP: swap nested components\n - VARIANT: select between variant sets\n - TEXT: configurable text content\n\n## Workflow\n1. `get_selection_context` \u2192 understand current component structure\n2. `manage_components` (list) \u2192 check existing components\n3. Design the component with auto-layout\n4. Create variants for states using `manage_components` (create_set)\n5. `manage_variables` (bind) \u2192 attach Tier 2/3 tokens to all visual properties\n6. `manage_components` (set_props) \u2192 configure instance properties\n\n## Verification\n- `analyze_design` (aspect: components) \u2192 find detached instances, unused components\n- `verify_changes` \u2192 confirm properties match expected values\n- `get_node_data` (detail: variables) \u2192 ensure all fills/strokes are token-bound\n";
3386
+
3387
+ // ../../docs/skills/design-audit-strategy.md
3388
+ var design_audit_strategy_default = "# Design Audit Strategy\n\n## Phase 1: Overview\n1. `get_document_overview` \u2192 pages, component/style/variable counts\n2. `get_design_tokens` \u2192 token system structure (collections, modes, coverage)\n\n## Phase 2: Color & Token Audit\n1. `analyze_design` (aspect: color) \u2192 find colors not backed by variables\n2. `get_node_data` (detail: variables) on key nodes \u2192 verify correct tier references\n3. Check: components should reference Tier 2/3 tokens, never Tier 1 or raw values\n\n## Phase 3: Layout Quality\n1. `analyze_design` (aspect: layout) \u2192 detect missing auto-layout, inconsistent spacing\n2. Check: all containers should use auto-layout (not absolute positioning)\n3. Check: spacing should use token values, not arbitrary px\n\n## Phase 4: Component Health\n1. `analyze_design` (aspect: components) \u2192 detached instances, unused components\n2. `manage_components` (list) \u2192 review component inventory\n3. Check: all instances should be connected to their main component\n\n## Phase 5: Accessibility\n1. `analyze_design` (aspect: accessibility) \u2192 contrast ratios, touch target sizes\n2. Check: minimum contrast 4.5:1 for text, 3:1 for large text\n3. Check: touch targets \u2265 44\xD744px\n\n## Phase 6: Comprehensive Validation\n1. `validate_design_rules` \u2192 runs all checks in one pass:\n - token_coverage: % of nodes with variable bindings\n - auto_layout: % of frames using auto-layout\n - naming_convention: layer naming quality\n - touch_target_size: minimum size compliance\n\n## Reporting\n- `screenshot` before and after fixes\n- `annotate` (set) / `annotate` (set_multiple) to flag issues directly in the design\n- Summarize findings by severity (critical \u2192 warning \u2192 info)\n";
3389
+
3390
+ // ../../docs/skills/token-audit.md
3391
+ var token_audit_default = "# Token Audit \u2014 Token Hierarchy Compliance Check\n\n## Purpose\nVerify that a design correctly uses the 4-tier token hierarchy:\n- No raw color/size values (everything should be token-bound)\n- Correct tier references (components \u2192 Tier 2/3, never Tier 1 directly)\n- Consistent scope usage\n\n## Audit Process\n\n### Step 1: Scope the Audit\n- `get_selection_context` \u2192 identify the area to audit\n- Or `get_document_overview` \u2192 audit the full document\n\n### Step 2: Color Token Coverage\n- `analyze_design` (aspect: color) \u2192 find all colors and whether they're token-bound\n- Flag any raw hex values not backed by variables\n\n### Step 3: Variable Binding Check\n- `get_node_data` (detail: variables) on key nodes \u2192 check what's bound\n- For each binding, verify the variable comes from the correct tier:\n - \u2705 Component referencing Tier 2 (e.g., `color/content/default`)\n - \u2705 Component referencing Tier 3 (e.g., `button/color/background/default`)\n - \u274C Component referencing Tier 1 (e.g., `color-static/slate/500`)\n - \u274C Component referencing Tier 1 dynamic (e.g., `color-dynamic/slate/500`)\n\n### Step 4: Scope Validation\n- `manage_variables` (list) \u2192 check that scopes are set correctly:\n - Color content tokens \u2192 TEXT_FILL\n - Color background tokens \u2192 FRAME_FILL, SHAPE_FILL\n - Color border tokens \u2192 STROKE_COLOR\n - Size tokens \u2192 WIDTH_HEIGHT\n - Border radius \u2192 CORNER_RADIUS\n - Border width \u2192 STROKE_FLOAT\n\n### Step 5: Comprehensive Check\n- `validate_design_rules` \u2192 run all design rules at once\n\n## Output Format\nReport findings as:\n- **Coverage**: X% of nodes have token bindings\n- **Tier violations**: list of nodes referencing wrong tier\n- **Raw values**: list of unbound colors/sizes with suggested token mappings\n- **Scope issues**: variables with missing or incorrect scopes\n";
3392
+
3393
+ // ../../docs/skills/component-spec.md
3394
+ var component_spec_default = "# Component Specification Generator\n\n## Purpose\nGenerate a structured specification document for a component, covering its properties, variants, token bindings, and states.\n\n## Process\n\n### Step 1: Identify the Component\n- `get_selection_context` \u2192 read the selected component/instance\n- Or `search_nodes` by name to find it\n\n### Step 2: Extract Properties\n- `manage_components` (get_props) \u2192 list all configurable properties (VARIANT, BOOLEAN, INSTANCE_SWAP, TEXT)\n- `get_node_details` \u2192 full node structure with children summary\n\n### Step 3: Token Bindings\n- `get_node_data` (detail: variables) \u2192 list all token bindings on the component and its children\n- `manage_variables` (list) \u2192 resolve variable names, descriptions, scopes, codeSyntax\n- Map bindings to the 4-tier token structure\n\n### Step 4: Visual Reference\n- `screenshot` \u2192 capture the component in its default state\n- If variants exist, capture key states (default, hover, disabled, etc.)\n\n### Step 5: Generate Spec\n\nOutput format:\n\n#### {Component Name}\n**Description**: (from context or user input)\n\n**Properties**\n| Name | Type | Values | Default |\n|------|------|--------|---------|\n\n**Token Bindings**\n| Property | Token | Tier | Value (default mode) |\n|----------|-------|------|---------------------|\n\n**States**\n| State | Visual Changes |\n|-------|---------------|\n\n**Size Variants**\n| Size | Height | Token |\n|------|--------|-------|\n\n## Tips\n- For components from a library (remote: true), some properties may be read-only\n- Use `manage_components` (get_overrides) to see what's been customized on instances\n- Include codeSyntax values for developer handoff\n";
3395
+
3396
+ // src/prompts.ts
3397
+ var SKILLS = [
3398
+ [
3399
+ "figma-plugin-api",
3400
+ "Cheat sheet for writing execute_figma code: dynamic-page rules, font loading, auto-layout pitfalls, common patterns. Load before non-trivial execute_figma work.",
3401
+ figma_plugin_api_default
3402
+ ],
3403
+ [
3404
+ "design-tokens-strategy",
3405
+ "4-tier design-token architecture: naming, mode design, tier reference rules. Load before creating or restructuring variables.",
3406
+ design_tokens_strategy_default
3407
+ ],
3408
+ [
3409
+ "component-architecture-strategy",
3410
+ "Component conventions: token-first properties, state handling, size variants, variant naming. Load before building components.",
3411
+ component_architecture_strategy_default
3412
+ ],
3413
+ [
3414
+ "design-audit-strategy",
3415
+ "End-to-end design audit workflow: overview \u2192 tokens \u2192 per-aspect analysis \u2192 report.",
3416
+ design_audit_strategy_default
3417
+ ],
3418
+ [
3419
+ "token-audit",
3420
+ "Token-compliance check: find raw values, tier violations, scope issues, unbound properties.",
3421
+ token_audit_default
3422
+ ],
3423
+ [
3424
+ "component-spec",
3425
+ "Generate a structured component specification: properties, variants, token bindings, state matrix.",
3426
+ component_spec_default
3427
+ ]
3428
+ ];
3429
+ function registerPrompts(server) {
3430
+ for (const [name, description, text] of SKILLS) {
3431
+ server.prompt(name, description, () => ({
3432
+ messages: [
3433
+ {
3434
+ role: "user",
3435
+ content: { type: "text", text }
3436
+ }
3437
+ ]
3438
+ }));
3439
+ }
3440
+ }
3441
+
3442
+ // src/session-log.ts
3443
+ var MAX_ENTRIES = 200;
3444
+ var entries = [];
3445
+ function recordCommand(entry) {
3446
+ entries.push(entry);
3447
+ if (entries.length > MAX_ENTRIES) entries = entries.slice(-MAX_ENTRIES);
3448
+ }
3449
+ function getSessionLog() {
3450
+ return [...entries];
3451
+ }
3452
+
3453
+ // src/index.ts
3454
+ var VERSION = "0.1.0";
3455
+ var args = process.argv.slice(2);
3456
+ var serverArg = args.find((arg) => arg.startsWith("--server="));
3457
+ var serverUrl = serverArg ? serverArg.split("=")[1] : "localhost";
3458
+ var portArg = args.find((arg) => arg.startsWith("--port="));
3459
+ var port = portArg ? parseInt(portArg.split("=")[1]) : 9055;
3460
+ var roomArg = args.find((arg) => arg.startsWith("--room="));
3461
+ if (args.includes("--list-tools")) {
3462
+ console.log(JSON.stringify(listToolCatalog(), null, 2));
3463
+ process.exit(0);
3464
+ }
3465
+ async function main() {
3466
+ const server = createServer();
3467
+ let relay = null;
3468
+ if (serverUrl === "localhost") {
3469
+ relay = await startEmbeddedRelay(port, VERSION);
3470
+ if (!relay) {
3471
+ logger.info(`Port ${port} in use \u2014 connecting to the existing relay`);
3472
+ }
3473
+ }
3474
+ const initialRoom = roomArg?.split("=")[1] ?? process.env.FIGMA_RELAI_ROOM ?? loadState().room ?? null;
3475
+ const connection = new FigmaConnection(serverUrl, port, {
3476
+ initialRoom,
3477
+ onRoomChanged: (room) => saveState({ room }),
3478
+ beforeReconnect: async () => {
3479
+ if (serverUrl === "localhost" && !relay) {
3480
+ relay = await startEmbeddedRelay(port, VERSION);
3481
+ if (relay) logger.info("Took over relay hosting");
3482
+ }
3483
+ }
3484
+ });
3485
+ registerRoomTool(server, (room) => connection.joinRoom(room));
3486
+ registerEventsTool(server, () => connection.consumeEvents(), getSessionLog);
3487
+ registerPrompts(server);
3488
+ registerAllTools(server, async (command, params, timeoutMs) => {
3489
+ const t0 = Date.now();
3490
+ const nodeId = typeof params?.nodeId === "string" ? params.nodeId : void 0;
3491
+ try {
3492
+ const result = await connection.sendCommand(command, params, timeoutMs);
3493
+ recordCommand({ ts: t0, command, nodeId, ok: true, ms: Date.now() - t0 });
3494
+ return result;
3495
+ } catch (error) {
3496
+ recordCommand({
3497
+ ts: t0,
3498
+ command,
3499
+ nodeId,
3500
+ ok: false,
3501
+ ms: Date.now() - t0,
3502
+ error: error instanceof Error ? error.message.slice(0, 200) : String(error)
3503
+ });
3504
+ throw error;
3505
+ }
3506
+ });
3507
+ try {
3508
+ await connection.connect();
3509
+ logger.info("Connected to relay successfully");
3510
+ } catch (error) {
3511
+ logger.warn(
3512
+ `Could not connect initially: ${error instanceof Error ? error.message : String(error)}`
3513
+ );
3514
+ logger.warn("Will attempt to connect when the first command is sent");
3515
+ }
3516
+ const transport = new StdioServerTransport();
3517
+ await server.connect(transport);
3518
+ logger.info("Relai MCP server running on stdio");
3519
+ }
3520
+ main().catch((error) => {
3521
+ logger.error(
3522
+ `Fatal error: ${error instanceof Error ? error.message : String(error)}`
3523
+ );
3524
+ process.exit(1);
3525
+ });
3526
+ //# sourceMappingURL=index.js.map