figma-prototype-mcp 0.30.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.
@@ -0,0 +1,1155 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/server/index.ts
4
+ import express from "express";
5
+ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
6
+ import { readFileSync } from "fs";
7
+ import { fileURLToPath } from "url";
8
+
9
+ // src/server/sessions.ts
10
+ import { randomUUID } from "crypto";
11
+ var PluginSession = class {
12
+ active = null;
13
+ pending = /* @__PURE__ */ new Map();
14
+ waiters = /* @__PURE__ */ new Set();
15
+ commandTimeoutMs;
16
+ connectWaitMs;
17
+ constructor(opts = {}) {
18
+ this.commandTimeoutMs = opts.commandTimeoutMs ?? 3e4;
19
+ this.connectWaitMs = opts.connectWaitMs ?? 3e3;
20
+ }
21
+ isConnected() {
22
+ return this.active !== null && this.active.readyState === 1;
23
+ }
24
+ setActive(ws) {
25
+ if (this.active && this.active !== ws) {
26
+ try {
27
+ this.active.send(JSON.stringify({ type: "system", message: "Replaced by newer connection" }));
28
+ } catch {
29
+ }
30
+ try {
31
+ this.active.close();
32
+ } catch {
33
+ }
34
+ this.failAllPending(new Error("Plugin connection replaced by newer connection"));
35
+ }
36
+ this.active = ws;
37
+ try {
38
+ ws.send(JSON.stringify({ type: "ready" }));
39
+ } catch {
40
+ }
41
+ this.notifyWaiters();
42
+ }
43
+ clearActive(ws) {
44
+ if (this.active === ws) {
45
+ this.active = null;
46
+ this.failAllPending(new Error("Plugin disconnected"));
47
+ }
48
+ }
49
+ handleResponse(msg) {
50
+ const p = this.pending.get(msg.id);
51
+ if (!p) return;
52
+ this.pending.delete(msg.id);
53
+ clearTimeout(p.timer);
54
+ if (msg.status === "ok") p.resolve(msg.result);
55
+ else p.reject(new Error(msg.error?.message ?? "plugin error"));
56
+ }
57
+ async sendCommand(command, params) {
58
+ if (!this.isConnected()) {
59
+ await this.waitForConnection(this.connectWaitMs);
60
+ if (!this.isConnected()) {
61
+ throw new Error("\uD53C\uADF8\uB9C8 \uD50C\uB7EC\uADF8\uC778 \uC5F0\uACB0\uC744 \uD655\uC778\uD574\uC8FC\uC138\uC694");
62
+ }
63
+ }
64
+ const id = randomUUID();
65
+ return new Promise((resolve, reject) => {
66
+ const timer = setTimeout(() => {
67
+ this.pending.delete(id);
68
+ reject(new Error(`Command ${command} timed out after ${this.commandTimeoutMs}ms`));
69
+ }, this.commandTimeoutMs);
70
+ this.pending.set(id, { resolve, reject, timer });
71
+ try {
72
+ this.active.send(JSON.stringify({ type: "command", id, command, params }));
73
+ } catch (e) {
74
+ clearTimeout(timer);
75
+ this.pending.delete(id);
76
+ reject(e instanceof Error ? e : new Error(String(e)));
77
+ }
78
+ });
79
+ }
80
+ waitForConnection(timeoutMs) {
81
+ if (this.isConnected()) return Promise.resolve();
82
+ return new Promise((resolve) => {
83
+ const timer = setTimeout(() => {
84
+ this.waiters.delete(waiter);
85
+ resolve();
86
+ }, timeoutMs);
87
+ const waiter = () => {
88
+ clearTimeout(timer);
89
+ this.waiters.delete(waiter);
90
+ resolve();
91
+ };
92
+ this.waiters.add(waiter);
93
+ });
94
+ }
95
+ notifyWaiters() {
96
+ for (const w of [...this.waiters]) w();
97
+ }
98
+ failAllPending(err) {
99
+ for (const [id, p] of this.pending) {
100
+ clearTimeout(p.timer);
101
+ p.reject(err);
102
+ this.pending.delete(id);
103
+ }
104
+ }
105
+ };
106
+
107
+ // src/server/plugin-ws.ts
108
+ import { WebSocketServer } from "ws";
109
+ var PLUGIN_PATH = "/ws";
110
+ function attachPluginWebSocket(httpServer2, session2) {
111
+ const wss = new WebSocketServer({ noServer: true });
112
+ httpServer2.on("upgrade", (req, socket, head) => {
113
+ if (req.url === PLUGIN_PATH) {
114
+ wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
115
+ } else {
116
+ socket.destroy();
117
+ }
118
+ });
119
+ wss.on("connection", (ws) => {
120
+ session2.setActive(ws);
121
+ ws.on("message", (raw) => {
122
+ let msg;
123
+ try {
124
+ msg = JSON.parse(raw.toString());
125
+ } catch {
126
+ return;
127
+ }
128
+ if (typeof msg === "object" && msg !== null && msg.type === "response") {
129
+ session2.handleResponse(msg);
130
+ }
131
+ });
132
+ ws.on("close", () => session2.clearActive(ws));
133
+ ws.on("error", () => session2.clearActive(ws));
134
+ });
135
+ return wss;
136
+ }
137
+
138
+ // src/server/tools.ts
139
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
140
+ import {
141
+ CallToolRequestSchema,
142
+ ListToolsRequestSchema
143
+ } from "@modelcontextprotocol/sdk/types.js";
144
+ import { zodToJsonSchema } from "zod-to-json-schema";
145
+
146
+ // src/mcp-server/tools.ts
147
+ import { z } from "zod";
148
+
149
+ // src/shared/wire-vocabulary.ts
150
+ var TRIGGER_SHORTCUTS = ["ON_CLICK", "ON_HOVER", "ON_PRESS", "AFTER_TIMEOUT"];
151
+ var TRIGGER_NOPARAM_TYPES = ["ON_CLICK", "ON_HOVER", "ON_PRESS", "ON_DRAG", "ON_MEDIA_END"];
152
+ var MOUSE_CLICK_TYPES = ["MOUSE_UP", "MOUSE_DOWN"];
153
+ var MOUSE_HOVER_TYPES = ["MOUSE_ENTER", "MOUSE_LEAVE"];
154
+ var KEYBOARD_DEVICES = ["KEYBOARD", "XBOX_ONE", "PS4", "SWITCH_PRO", "UNKNOWN_CONTROLLER"];
155
+ var TRANSITION_SHORTCUTS = ["INSTANT", "DISSOLVE", "SMART_ANIMATE"];
156
+ var SIMPLE_TRANSITION_TYPES = ["DISSOLVE", "SMART_ANIMATE", "SCROLL_ANIMATE"];
157
+ var DIRECTIONAL_TRANSITION_TYPES = ["MOVE_IN", "MOVE_OUT", "PUSH", "SLIDE_IN", "SLIDE_OUT"];
158
+ var NAMED_EASINGS = [
159
+ "LINEAR",
160
+ "EASE_IN",
161
+ "EASE_OUT",
162
+ "EASE_IN_AND_OUT",
163
+ "EASE_IN_BACK",
164
+ "EASE_OUT_BACK",
165
+ "EASE_IN_AND_OUT_BACK",
166
+ "GENTLE",
167
+ "QUICK",
168
+ "BOUNCY",
169
+ "SLOW"
170
+ ];
171
+ var DIRECTIONS = ["LEFT", "RIGHT", "TOP", "BOTTOM"];
172
+ var COMPARISON_OPERATORS = ["==", "!=", "<", "<=", ">", ">="];
173
+ var OVERFLOW_DIRECTIONS = ["NONE", "HORIZONTAL", "VERTICAL", "BOTH"];
174
+
175
+ // src/mcp-server/tools.ts
176
+ var GetCanvasOverviewInput = z.object({
177
+ pageId: z.string().optional()
178
+ });
179
+ var GetPrototypeFlowInput = z.object({
180
+ pageId: z.string().optional(),
181
+ limit: z.number().int().positive().max(2e3).default(500)
182
+ });
183
+ var FindNodesInput = z.object({
184
+ query: z.string().min(1),
185
+ nodeTypes: z.array(z.string()).optional(),
186
+ scope: z.enum(["page", "document"]).default("page"),
187
+ limit: z.number().int().positive().max(500).default(50)
188
+ });
189
+ var ListVariablesInput = z.object({
190
+ resolvedType: z.enum(["BOOLEAN", "FLOAT", "STRING", "COLOR"]).optional().describe("Filter to one variable type (BOOLEAN, FLOAT, STRING, or COLOR)."),
191
+ includeRemote: z.boolean().default(true).describe(
192
+ "Enumerate library (remote) variables in addition to local ones. Can be slow on files with large connected libraries; set false to list local only."
193
+ ),
194
+ nameQuery: z.string().optional().describe("Case-insensitive substring filter on the variable name.")
195
+ }).strict();
196
+ var TriggerEnum = z.enum(TRIGGER_SHORTCUTS);
197
+ var KeyboardDeviceEnum = z.enum(KEYBOARD_DEVICES);
198
+ var TriggerObjectNoParam = z.object({
199
+ type: z.enum(TRIGGER_NOPARAM_TYPES)
200
+ });
201
+ var TriggerObjectAfterTimeout = z.object({
202
+ type: z.literal("AFTER_TIMEOUT"),
203
+ timeout: z.number().positive().max(60)
204
+ });
205
+ var TriggerObjectMouseClick = z.object({
206
+ type: z.enum(MOUSE_CLICK_TYPES),
207
+ delay: z.number().nonnegative().max(60).optional()
208
+ });
209
+ var TriggerObjectMouseHover = z.object({
210
+ type: z.enum(MOUSE_HOVER_TYPES),
211
+ delay: z.number().nonnegative().max(60).optional()
212
+ });
213
+ var TriggerObjectKeyDown = z.object({
214
+ type: z.literal("ON_KEY_DOWN"),
215
+ device: KeyboardDeviceEnum,
216
+ keyCodes: z.array(z.number().int().nonnegative()).min(1)
217
+ });
218
+ var TriggerObjectMediaHit = z.object({
219
+ type: z.literal("ON_MEDIA_HIT"),
220
+ mediaHitTime: z.number().nonnegative()
221
+ });
222
+ var TriggerInput = z.union([
223
+ TriggerEnum,
224
+ TriggerObjectNoParam,
225
+ TriggerObjectAfterTimeout,
226
+ TriggerObjectMouseClick,
227
+ TriggerObjectMouseHover,
228
+ TriggerObjectKeyDown,
229
+ TriggerObjectMediaHit
230
+ ]).describe(
231
+ "When the interaction fires. Default ON_CLICK. Natural-language cues (KO/EN): ON_CLICK=\uD074\uB9AD/\uD0ED/\uB204\uB974\uBA74/click,tap; ON_HOVER=\uD638\uBC84/\uB9C8\uC6B0\uC2A4 \uC62C\uB9AC\uBA74/'~\uD558\uB294 \uB3D9\uC548'/while hovering (round-trip: auto-reverts when cursor leaves); ON_PRESS=\uAFB9/\uAE38\uAC8C \uB204\uB974\uBA74/\uB204\uB974\uACE0 \uC788\uC73C\uBA74/long-press (round-trip: reverts on release); ON_DRAG=\uB4DC\uB798\uADF8/\uC2A4\uC640\uC774\uD504/\uB04C\uBA74/\uBC00\uBA74/swipe; MOUSE_ENTER=\uB9C8\uC6B0\uC2A4 \uB4E4\uC5B4\uC624\uBA74/'\uD55C\uBC88 \uD638\uBC84\uD558\uBA74 \uC720\uC9C0'/permanent hover (one-way, stays \u2014 distinct from ON_HOVER); MOUSE_LEAVE=\uB9C8\uC6B0\uC2A4 \uB098\uAC00\uBA74/\uCEE4\uC11C \uBE60\uC9C0\uBA74; MOUSE_DOWN=\uB204\uB974\uB294 \uC21C\uAC04; MOUSE_UP=\uB5BC\uBA74; AFTER_TIMEOUT=N\uCD08 \uD6C4/\uC7A0\uC2DC \uD6C4/\uC790\uB3D9\uC73C\uB85C (requires timeout in seconds; \uC7A0\uAE50\u22480.5, \uC7A0\uC2DC\u22481, \uBA87 \uCD08\u22483); ON_KEY_DOWN=\uC5D4\uD130/\uB2E8\uCD95\uD0A4/Cmd+K (requires device + keyCodes, e.g. Cmd+K=[91,75]); ON_MEDIA_END=\uC601\uC0C1 \uB05D\uB098\uBA74; ON_MEDIA_HIT=\uC601\uC0C1 N\uCD08 \uC2DC\uC810 (requires mediaHitTime in seconds). Decision rules: '\uB204\uB974\uBA74/press' alone \u2192 ON_CLICK, with '\uAFB9/\uAE38\uAC8C/hold' \u2192 ON_PRESS; '\uD638\uBC84/hover' alone \u2192 ON_HOVER, with '\uC720\uC9C0/\uACC4\uC18D/stays' \u2192 MOUSE_ENTER. Full vocabulary: docs/dictionaries/trigger-dictionary."
232
+ );
233
+ var TransitionEnum = z.enum(TRANSITION_SHORTCUTS);
234
+ var NamedEasingEnum = z.enum(NAMED_EASINGS);
235
+ var CustomCubicBezierEasing = z.object({
236
+ type: z.literal("CUSTOM_CUBIC_BEZIER"),
237
+ x1: z.number().min(0).max(1),
238
+ y1: z.number(),
239
+ x2: z.number().min(0).max(1),
240
+ y2: z.number()
241
+ });
242
+ var CustomSpringEasing = z.object({
243
+ type: z.literal("CUSTOM_SPRING"),
244
+ mass: z.number().positive(),
245
+ stiffness: z.number().positive(),
246
+ damping: z.number().positive()
247
+ });
248
+ var EasingInputUnion = z.union([NamedEasingEnum, CustomCubicBezierEasing, CustomSpringEasing]);
249
+ var SimpleTransitionObject = z.object({
250
+ type: z.enum(SIMPLE_TRANSITION_TYPES),
251
+ duration: z.number().positive().max(10).optional(),
252
+ easing: EasingInputUnion.optional()
253
+ });
254
+ var DirectionEnum = z.enum(DIRECTIONS);
255
+ var DirectionalTransitionObject = z.object({
256
+ type: z.enum(DIRECTIONAL_TRANSITION_TYPES),
257
+ direction: DirectionEnum,
258
+ matchLayers: z.boolean().optional(),
259
+ duration: z.number().positive().max(10).optional(),
260
+ easing: EasingInputUnion.optional()
261
+ });
262
+ var TransitionInput = z.union([TransitionEnum, SimpleTransitionObject, DirectionalTransitionObject]);
263
+ var NavigateActionInput = z.object({
264
+ type: z.literal("navigate"),
265
+ targetFrameId: z.string().min(1),
266
+ resetScrollPosition: z.boolean().optional()
267
+ });
268
+ var ScrollActionInput = z.object({
269
+ type: z.literal("scroll"),
270
+ targetNodeId: z.string().min(1),
271
+ resetScrollPosition: z.boolean().optional()
272
+ });
273
+ var OverlayActionInput = z.object({
274
+ type: z.literal("overlay"),
275
+ targetFrameId: z.string().min(1),
276
+ resetScrollPosition: z.boolean().optional()
277
+ });
278
+ var CloseActionInput = z.object({
279
+ type: z.literal("close")
280
+ });
281
+ var BackActionInput = z.object({
282
+ type: z.literal("back")
283
+ });
284
+ var UrlActionInput = z.object({
285
+ type: z.literal("url"),
286
+ url: z.string().min(1),
287
+ openInNewTab: z.boolean().optional()
288
+ });
289
+ var SwapOverlayActionInput = z.object({
290
+ type: z.literal("swap_overlay"),
291
+ targetFrameId: z.string().min(1),
292
+ resetScrollPosition: z.boolean().optional()
293
+ });
294
+ var ComparisonOperator = z.enum(COMPARISON_OPERATORS);
295
+ var ConditionComparison = z.object({
296
+ variable: z.string().min(1),
297
+ collection: z.string().min(1).optional(),
298
+ operator: ComparisonOperator,
299
+ value: z.union([z.boolean(), z.number(), z.string()])
300
+ });
301
+ var ConditionInput = z.union([
302
+ ConditionComparison,
303
+ z.object({ all: z.array(ConditionComparison).min(2) }).strict(),
304
+ z.object({ any: z.array(ConditionComparison).min(2) }).strict()
305
+ ]);
306
+ var SetVariableActionInput = z.object({
307
+ type: z.literal("set_variable"),
308
+ variable: z.string().min(1),
309
+ collection: z.string().min(1).optional(),
310
+ value: z.union([z.boolean(), z.number(), z.string()])
311
+ });
312
+ var ToggleVariableActionInput = z.object({
313
+ type: z.literal("toggle_variable"),
314
+ variable: z.string().min(1),
315
+ collection: z.string().min(1).optional()
316
+ });
317
+ var ChangeToActionInput = z.object({
318
+ type: z.literal("change_to"),
319
+ targetVariantId: z.string().min(1)
320
+ });
321
+ var NonConditionalActionInput = z.discriminatedUnion("type", [
322
+ NavigateActionInput,
323
+ ScrollActionInput,
324
+ OverlayActionInput,
325
+ CloseActionInput,
326
+ BackActionInput,
327
+ UrlActionInput,
328
+ SwapOverlayActionInput,
329
+ SetVariableActionInput,
330
+ ChangeToActionInput
331
+ ]);
332
+ var ConditionalActionInput = z.object({
333
+ type: z.literal("conditional"),
334
+ condition: ConditionInput,
335
+ then: z.array(NonConditionalActionInput).min(1),
336
+ else: z.array(NonConditionalActionInput).min(1).optional()
337
+ });
338
+ var ActionInput = z.discriminatedUnion("type", [
339
+ NavigateActionInput,
340
+ ScrollActionInput,
341
+ OverlayActionInput,
342
+ CloseActionInput,
343
+ BackActionInput,
344
+ UrlActionInput,
345
+ SwapOverlayActionInput,
346
+ ConditionalActionInput,
347
+ SetVariableActionInput,
348
+ ToggleVariableActionInput,
349
+ ChangeToActionInput
350
+ ]);
351
+ var ConnectionInput = z.object({
352
+ sourceNodeId: z.string().min(1),
353
+ trigger: TriggerInput.default("ON_CLICK"),
354
+ afterTimeoutSeconds: z.number().positive().optional(),
355
+ transition: TransitionInput.default("INSTANT"),
356
+ degradeTo: z.enum(["DISSOLVE", "INSTANT"]).optional(),
357
+ action: ActionInput
358
+ }).refine(
359
+ (v) => v.trigger !== "AFTER_TIMEOUT" || typeof v.afterTimeoutSeconds === "number",
360
+ { message: 'afterTimeoutSeconds is required when trigger is the string "AFTER_TIMEOUT" (object form uses { type: "AFTER_TIMEOUT", timeout })' }
361
+ );
362
+ var CreateReactionsInput = z.object({
363
+ connections: z.array(ConnectionInput).min(1),
364
+ replaceExisting: z.boolean().default(false)
365
+ });
366
+ var ListReactionsInput = z.object({
367
+ nodeId: z.string().min(1)
368
+ });
369
+ var ClearReactionsInput = z.object({
370
+ nodeIds: z.array(z.string().min(1)).min(1),
371
+ indices: z.array(z.number().int().nonnegative()).optional()
372
+ }).refine(
373
+ (v) => !v.indices || v.nodeIds.length === 1,
374
+ { message: "indices may only be specified when nodeIds has exactly 1 entry" }
375
+ );
376
+ var OverflowDirectionEnum = z.enum(OVERFLOW_DIRECTIONS);
377
+ var SetFrameScrollEntry = z.object({
378
+ frameId: z.string().min(1),
379
+ direction: OverflowDirectionEnum.optional(),
380
+ fixedChildren: z.number().int().min(0).optional()
381
+ }).refine(
382
+ (v) => v.direction !== void 0 || v.fixedChildren !== void 0,
383
+ { message: "Each entry must include at least one of `direction` or `fixedChildren`" }
384
+ );
385
+ var SetFrameScrollInput = z.object({
386
+ frames: z.array(SetFrameScrollEntry).min(1)
387
+ });
388
+
389
+ // src/mcp-server/protoTools.ts
390
+ import { z as z2 } from "zod";
391
+
392
+ // src/shared/motionPresets.ts
393
+ var PRESET_NAMES = [
394
+ "M3_EMPHASIZED",
395
+ "M3_EMPHASIZED_DECELERATE",
396
+ "M3_EMPHASIZED_ACCELERATE",
397
+ "M3_STANDARD",
398
+ "M3_STANDARD_DECELERATE",
399
+ "M3_STANDARD_ACCELERATE",
400
+ "HIG_DEFAULT",
401
+ "HIG_SMOOTH",
402
+ "HIG_SNAPPY",
403
+ "HIG_BOUNCY"
404
+ ];
405
+ var PRESETS = {
406
+ M3_EMPHASIZED: {
407
+ type: "SMART_ANIMATE",
408
+ duration: 0.5,
409
+ easing: { type: "CUSTOM_CUBIC_BEZIER", x1: 0.2, y1: 0, x2: 0, y2: 1 }
410
+ },
411
+ M3_EMPHASIZED_DECELERATE: {
412
+ type: "SMART_ANIMATE",
413
+ duration: 0.4,
414
+ easing: { type: "CUSTOM_CUBIC_BEZIER", x1: 0.05, y1: 0.7, x2: 0.1, y2: 1 }
415
+ },
416
+ M3_EMPHASIZED_ACCELERATE: {
417
+ type: "SMART_ANIMATE",
418
+ duration: 0.2,
419
+ easing: { type: "CUSTOM_CUBIC_BEZIER", x1: 0.3, y1: 0, x2: 0.8, y2: 0.15 }
420
+ },
421
+ M3_STANDARD: {
422
+ type: "SMART_ANIMATE",
423
+ duration: 0.3,
424
+ easing: { type: "CUSTOM_CUBIC_BEZIER", x1: 0.2, y1: 0, x2: 0, y2: 1 }
425
+ },
426
+ M3_STANDARD_DECELERATE: {
427
+ type: "SMART_ANIMATE",
428
+ duration: 0.25,
429
+ easing: { type: "CUSTOM_CUBIC_BEZIER", x1: 0, y1: 0, x2: 0, y2: 1 }
430
+ },
431
+ M3_STANDARD_ACCELERATE: {
432
+ type: "SMART_ANIMATE",
433
+ duration: 0.2,
434
+ easing: { type: "CUSTOM_CUBIC_BEZIER", x1: 0.3, y1: 0, x2: 1, y2: 1 }
435
+ },
436
+ HIG_DEFAULT: { type: "SMART_ANIMATE", easing: "GENTLE" },
437
+ HIG_SMOOTH: { type: "SMART_ANIMATE", easing: "SLOW" },
438
+ HIG_SNAPPY: { type: "SMART_ANIMATE", easing: "QUICK" },
439
+ HIG_BOUNCY: { type: "SMART_ANIMATE", easing: "BOUNCY" }
440
+ };
441
+ function isPresetName(s) {
442
+ return PRESET_NAMES.includes(s);
443
+ }
444
+ function resolveMotion(m) {
445
+ if (m === void 0) return PRESETS["M3_EMPHASIZED"];
446
+ if (typeof m === "string" && isPresetName(m)) return PRESETS[m];
447
+ return m;
448
+ }
449
+
450
+ // src/mcp-server/protoTools.ts
451
+ var PresetNameEnum = z2.enum(PRESET_NAMES);
452
+ var MotionInputSchema = z2.union([PresetNameEnum, TransitionInput]).describe(
453
+ "How it animates: a preset name OR a full TransitionInput. Natural-language \u2192 preset (KO/EN): \uBD80\uB4DC\uB7FD\uAC8C/\uC790\uC5F0\uC2A4\uB7FD\uAC8C/smooth \u2192 M3_STANDARD; \uAC15\uC870/\uBB35\uC9C1/emphasized \u2192 M3_EMPHASIZED (default); \uD280\uB294/\uD1B5\uD1B5/\uC2A4\uD504\uB9C1/bouncy \u2192 HIG_BOUNCY; \uBE60\uB974\uAC8C/\uC2A4\uB0C5/snappy \u2192 HIG_SNAPPY; \uB290\uB9AC\uAC8C/\uC5EC\uC720/slow \u2192 HIG_SMOOTH; iOS/\uC560\uD50C \u2192 HIG_DEFAULT; Material/\uC548\uB4DC\uB85C\uC774\uB4DC \u2192 M3_*. All 10 presets are SMART_ANIMATE (morph). A directional feel (\uC606\uC73C\uB85C/\uC2AC\uB77C\uC774\uB4DC/\uB2E4\uC74C\uC73C\uB85C/\uB118\uAE30\uB4EF/push,slide) or a fade (\uC11C\uC11C\uD788/\uD750\uB824\uC9C0\uBA70/fade) is NOT a preset \u2014 pass a TransitionInput instead: {type:'PUSH'|'SLIDE_IN'|'SLIDE_OUT', direction} or {type:'DISSOLVE'}. Duration cues (for a custom TransitionInput, not presets): \uBE60\uB974\uAC8C\u22480.1\u20130.15s, \uBCF4\uD1B5\u22480.15s, \uBD80\uB4DC\uB7FD\uAC8C\u22480.25s, \uB290\uB9AC\uAC8C\u22480.4s. Spatial cues map to a directional TransitionInput, not a preset: \uBC00\uACE0 \uB4E4\uC5B4\uC624\uB294/\uB4E4\uC5B4\uC640 \u2192 {type:'MOVE_IN', direction}; \uBC00\uC5B4\uB0B4\uBA70/\uB098\uAC00\uBA70/\uB0B4\uBCF4\uB0B4\uBA70 \u2192 {type:'MOVE_OUT', direction}; \uC62C\uB77C\uC624\uB294/\uC62C\uB77C\uC640 \u2192 {type:'MOVE_IN', direction:'BOTTOM'}; \uB0B4\uB824\uC624\uB294 \u2192 {type:'MOVE_IN', direction:'TOP'}. (A DISSOLVE cannot carry matching layers \u2014 Figma runtime rejects matchLayers on DISSOLVE. For a fade that also morphs shared layers, use a directional transition with matchLayers, e.g. {type:'PUSH', direction, matchLayers:true}.) Full vocabulary: docs/dictionaries/."
454
+ );
455
+ var DEGRADE_TO_FIELD = z2.enum(["DISSOLVE", "INSTANT"]).optional().describe(
456
+ "Fallback transition used ONLY when a SMART_ANIMATE motion (the default, and every M3/HIG preset) connects two frames that share no matching layer names \u2014 there is nothing to morph, so it degrades. DISSOLVE (default) keeps a soft fade; INSTANT cuts immediately. Ignored for non-SMART_ANIMATE motion."
457
+ );
458
+ var ProtoWireEntry = z2.object({
459
+ from: z2.string().min(1).describe('Source node ID (e.g. "1404:1947"), NOT a frame name \u2014 resolve names via find_nodes/get_canvas_overview first.'),
460
+ to: z2.string().min(1).describe('Destination frame node ID (e.g. "1404:1950"), NOT a frame name.'),
461
+ trigger: TriggerInput.optional(),
462
+ motion: MotionInputSchema.optional(),
463
+ resetScrollPosition: z2.boolean().optional(),
464
+ degradeTo: DEGRADE_TO_FIELD
465
+ });
466
+ var ProtoWireInput = z2.object({
467
+ wires: z2.array(ProtoWireEntry).min(1),
468
+ replaceExisting: z2.boolean().default(false)
469
+ });
470
+ var ProtoChangeToEntry = z2.object({
471
+ from: z2.string().min(1).describe("Source node ID \u2014 a component instance (or a node inside one), NOT a name. Resolve via find_nodes."),
472
+ to: z2.string().min(1).describe("Target variant node ID \u2014 a COMPONENT inside the same component set, NOT a name. Resolve via find_nodes."),
473
+ trigger: TriggerInput.optional(),
474
+ motion: MotionInputSchema.optional()
475
+ });
476
+ var ProtoChangeToInput = z2.object({
477
+ changes: z2.array(ProtoChangeToEntry).min(1),
478
+ replaceExisting: z2.boolean().default(false)
479
+ });
480
+ var ProtoOverlayOpenEntry = z2.object({
481
+ mode: z2.literal("open"),
482
+ from: z2.string().min(1),
483
+ overlay: z2.string().min(1),
484
+ trigger: TriggerInput.optional(),
485
+ motion: MotionInputSchema.optional()
486
+ }).strict();
487
+ var ProtoOverlaySwapEntry = z2.object({
488
+ mode: z2.literal("swap"),
489
+ from: z2.string().min(1),
490
+ overlay: z2.string().min(1),
491
+ trigger: TriggerInput.optional(),
492
+ motion: MotionInputSchema.optional()
493
+ }).strict();
494
+ var ProtoOverlayCloseEntry = z2.object({
495
+ mode: z2.literal("close"),
496
+ from: z2.string().min(1),
497
+ trigger: TriggerInput.optional(),
498
+ motion: MotionInputSchema.optional()
499
+ }).strict();
500
+ var ProtoOverlayEntry = z2.discriminatedUnion("mode", [
501
+ ProtoOverlayOpenEntry,
502
+ ProtoOverlaySwapEntry,
503
+ ProtoOverlayCloseEntry
504
+ ]);
505
+ var ProtoOverlayInput = z2.object({
506
+ overlays: z2.array(ProtoOverlayEntry).min(1),
507
+ replaceExisting: z2.boolean().default(false)
508
+ });
509
+ var ProtoScrollEntry = z2.object({
510
+ from: z2.string().min(1),
511
+ to: z2.string().min(1),
512
+ trigger: TriggerInput.optional(),
513
+ motion: MotionInputSchema.optional(),
514
+ resetScrollPosition: z2.boolean().optional()
515
+ });
516
+ var ProtoScrollInput = z2.object({
517
+ scrolls: z2.array(ProtoScrollEntry).min(1),
518
+ replaceExisting: z2.boolean().default(false)
519
+ });
520
+ var ProtoBackEntry = z2.object({
521
+ from: z2.string().min(1),
522
+ trigger: TriggerInput.optional(),
523
+ motion: MotionInputSchema.optional()
524
+ });
525
+ var ProtoBackInput = z2.object({
526
+ backs: z2.array(ProtoBackEntry).min(1),
527
+ replaceExisting: z2.boolean().default(false)
528
+ });
529
+ var ProtoUrlEntry = z2.object({
530
+ from: z2.string().min(1),
531
+ url: z2.string().min(1),
532
+ openInNewTab: z2.boolean().optional(),
533
+ trigger: TriggerInput.optional()
534
+ }).strict();
535
+ var ProtoUrlInput = z2.object({
536
+ urls: z2.array(ProtoUrlEntry).min(1),
537
+ replaceExisting: z2.boolean().default(false)
538
+ });
539
+ var COLLECTION_FIELD = z2.string().min(1).optional().describe(
540
+ "Only needed when the same variable name exists in multiple collections. Use list_variables to find the collection name and pass it here. Omitting it on a collision returns an error."
541
+ );
542
+ var ProtoSetVariableEntry = z2.object({
543
+ from: z2.string().min(1),
544
+ variable: z2.string().min(1),
545
+ collection: COLLECTION_FIELD,
546
+ value: z2.union([z2.boolean(), z2.number(), z2.string()]),
547
+ trigger: TriggerInput.optional()
548
+ }).strict();
549
+ var ProtoSetVariableInput = z2.object({
550
+ sets: z2.array(ProtoSetVariableEntry).min(1),
551
+ replaceExisting: z2.boolean().default(false)
552
+ });
553
+ var ProtoToggleVariableEntry = z2.object({
554
+ from: z2.string().min(1),
555
+ variable: z2.string().min(1),
556
+ collection: COLLECTION_FIELD,
557
+ trigger: TriggerInput.optional()
558
+ }).strict();
559
+ var ProtoToggleVariableInput = z2.object({
560
+ toggles: z2.array(ProtoToggleVariableEntry).min(1),
561
+ replaceExisting: z2.boolean().default(false)
562
+ });
563
+ var ComparisonOperator2 = z2.enum(COMPARISON_OPERATORS);
564
+ var ProtoConditionIf = z2.object({
565
+ variable: z2.string().min(1),
566
+ collection: COLLECTION_FIELD,
567
+ operator: ComparisonOperator2.default("=="),
568
+ value: z2.union([z2.boolean(), z2.number(), z2.string()])
569
+ }).strict();
570
+ var ProtoConditionExpr = z2.union([
571
+ ProtoConditionIf,
572
+ z2.object({ all: z2.array(ProtoConditionIf).min(2) }).strict(),
573
+ z2.object({ any: z2.array(ProtoConditionIf).min(2) }).strict()
574
+ ]);
575
+ var BranchNavigate = z2.object({
576
+ navigate: z2.string().min(1),
577
+ resetScrollPosition: z2.boolean().optional()
578
+ }).strict();
579
+ var BranchScroll = z2.object({
580
+ scroll: z2.string().min(1),
581
+ resetScrollPosition: z2.boolean().optional()
582
+ }).strict();
583
+ var BranchOverlay = z2.object({
584
+ overlay: z2.string().min(1)
585
+ }).strict();
586
+ var BranchSwap = z2.object({
587
+ swap: z2.string().min(1)
588
+ }).strict();
589
+ var BranchClose = z2.object({
590
+ close: z2.literal(true)
591
+ }).strict();
592
+ var BranchBack = z2.object({
593
+ back: z2.literal(true)
594
+ }).strict();
595
+ var BranchUrl = z2.object({
596
+ url: z2.string().min(1),
597
+ openInNewTab: z2.boolean().optional()
598
+ }).strict();
599
+ var BranchSet = z2.object({
600
+ set: z2.object({
601
+ variable: z2.string().min(1),
602
+ collection: COLLECTION_FIELD,
603
+ value: z2.union([z2.boolean(), z2.number(), z2.string()])
604
+ }).strict()
605
+ }).strict();
606
+ var BranchAction = z2.union([
607
+ BranchNavigate,
608
+ BranchScroll,
609
+ BranchOverlay,
610
+ BranchSwap,
611
+ BranchClose,
612
+ BranchBack,
613
+ BranchUrl,
614
+ BranchSet
615
+ ]);
616
+ var ProtoConditionalEntry = z2.object({
617
+ from: z2.string().min(1),
618
+ trigger: TriggerInput.optional(),
619
+ motion: MotionInputSchema.optional(),
620
+ degradeTo: DEGRADE_TO_FIELD,
621
+ if: ProtoConditionExpr,
622
+ then: BranchAction,
623
+ else: BranchAction.optional()
624
+ }).strict();
625
+ var ProtoConditionalInput = z2.object({
626
+ conditions: z2.array(ProtoConditionalEntry).min(1),
627
+ replaceExisting: z2.boolean().default(false)
628
+ });
629
+ var ProtoGetLastHistoryInput = z2.object({
630
+ count: z2.number().int().min(1).max(10).default(1)
631
+ });
632
+ var DEFAULT_TRIGGER = "ON_CLICK";
633
+ function buildConnection(from, triggerArg, motionArg, action, transformTransition) {
634
+ const baseTransition = resolveMotion(motionArg);
635
+ return {
636
+ sourceNodeId: from,
637
+ trigger: triggerArg ?? DEFAULT_TRIGGER,
638
+ transition: transformTransition ? transformTransition(baseTransition) : baseTransition,
639
+ action
640
+ };
641
+ }
642
+ function rewriteForOverlay(t) {
643
+ if (t === "SMART_ANIMATE") return "DISSOLVE";
644
+ if (typeof t !== "string" && t.type === "SMART_ANIMATE") {
645
+ return { ...t, type: "DISSOLVE" };
646
+ }
647
+ return t;
648
+ }
649
+ function compileProtoWire(input) {
650
+ const connections = input.wires.map((w) => {
651
+ const action = w.resetScrollPosition === void 0 ? { type: "navigate", targetFrameId: w.to } : { type: "navigate", targetFrameId: w.to, resetScrollPosition: w.resetScrollPosition };
652
+ return {
653
+ ...buildConnection(w.from, w.trigger, w.motion, action),
654
+ ...w.degradeTo !== void 0 && { degradeTo: w.degradeTo }
655
+ };
656
+ });
657
+ return { connections, replaceExisting: input.replaceExisting };
658
+ }
659
+ function compileProtoChangeTo(input) {
660
+ const connections = input.changes.map(
661
+ (w) => buildConnection(w.from, w.trigger, w.motion, { type: "change_to", targetVariantId: w.to })
662
+ );
663
+ return { connections, replaceExisting: input.replaceExisting };
664
+ }
665
+ function compileProtoOverlay(input) {
666
+ const connections = input.overlays.map((o) => {
667
+ let action;
668
+ if (o.mode === "open") {
669
+ action = { type: "overlay", targetFrameId: o.overlay };
670
+ } else if (o.mode === "swap") {
671
+ action = { type: "swap_overlay", targetFrameId: o.overlay };
672
+ } else {
673
+ action = { type: "close" };
674
+ }
675
+ return buildConnection(o.from, o.trigger, o.motion, action, rewriteForOverlay);
676
+ });
677
+ return { connections, replaceExisting: input.replaceExisting };
678
+ }
679
+ function compileProtoScroll(input) {
680
+ const connections = input.scrolls.map((s) => {
681
+ const action = s.resetScrollPosition === void 0 ? { type: "scroll", targetNodeId: s.to } : { type: "scroll", targetNodeId: s.to, resetScrollPosition: s.resetScrollPosition };
682
+ return buildConnection(s.from, s.trigger, s.motion, action);
683
+ });
684
+ return { connections, replaceExisting: input.replaceExisting };
685
+ }
686
+ function compileProtoBack(input) {
687
+ const connections = input.backs.map((b) => {
688
+ const action = { type: "back" };
689
+ return buildConnection(b.from, b.trigger, b.motion, action);
690
+ });
691
+ return { connections, replaceExisting: input.replaceExisting };
692
+ }
693
+ function compileProtoUrl(input) {
694
+ const connections = input.urls.map((u) => {
695
+ const action = {
696
+ type: "url",
697
+ url: u.url,
698
+ openInNewTab: u.openInNewTab ?? false
699
+ };
700
+ return {
701
+ sourceNodeId: u.from,
702
+ trigger: u.trigger ?? DEFAULT_TRIGGER,
703
+ action
704
+ };
705
+ });
706
+ return { connections, replaceExisting: input.replaceExisting };
707
+ }
708
+ function compileProtoSetVariable(input) {
709
+ const connections = input.sets.map((s) => {
710
+ const action = {
711
+ type: "set_variable",
712
+ variable: s.variable,
713
+ ...s.collection !== void 0 && { collection: s.collection },
714
+ value: s.value
715
+ };
716
+ return {
717
+ sourceNodeId: s.from,
718
+ trigger: s.trigger ?? DEFAULT_TRIGGER,
719
+ action
720
+ };
721
+ });
722
+ return { connections, replaceExisting: input.replaceExisting };
723
+ }
724
+ function compileProtoToggleVariable(input) {
725
+ const connections = input.toggles.map((t) => {
726
+ const action = {
727
+ type: "toggle_variable",
728
+ variable: t.variable,
729
+ ...t.collection !== void 0 && { collection: t.collection }
730
+ };
731
+ return {
732
+ sourceNodeId: t.from,
733
+ trigger: t.trigger ?? DEFAULT_TRIGGER,
734
+ action
735
+ };
736
+ });
737
+ return { connections, replaceExisting: input.replaceExisting };
738
+ }
739
+ function compileBranchAction(b) {
740
+ if ("navigate" in b) {
741
+ return b.resetScrollPosition === void 0 ? { type: "navigate", targetFrameId: b.navigate } : { type: "navigate", targetFrameId: b.navigate, resetScrollPosition: b.resetScrollPosition };
742
+ }
743
+ if ("scroll" in b) {
744
+ return b.resetScrollPosition === void 0 ? { type: "scroll", targetNodeId: b.scroll } : { type: "scroll", targetNodeId: b.scroll, resetScrollPosition: b.resetScrollPosition };
745
+ }
746
+ if ("overlay" in b) return { type: "overlay", targetFrameId: b.overlay };
747
+ if ("swap" in b) return { type: "swap_overlay", targetFrameId: b.swap };
748
+ if ("close" in b) return { type: "close" };
749
+ if ("back" in b) return { type: "back" };
750
+ if ("url" in b) return { type: "url", url: b.url, openInNewTab: b.openInNewTab ?? false };
751
+ if ("set" in b) return {
752
+ type: "set_variable",
753
+ variable: b.set.variable,
754
+ ...b.set.collection !== void 0 && { collection: b.set.collection },
755
+ value: b.set.value
756
+ };
757
+ throw new Error("unreachable: zod parse guarantees BranchAction coverage");
758
+ }
759
+ function branchUsesOverlayOrSwap(b) {
760
+ return "overlay" in b || "swap" in b;
761
+ }
762
+ function compileLeaf(leaf) {
763
+ return {
764
+ variable: leaf.variable,
765
+ ...leaf.collection !== void 0 && { collection: leaf.collection },
766
+ operator: leaf.operator,
767
+ // zod already applied default "=="
768
+ value: leaf.value
769
+ };
770
+ }
771
+ function compileConditionExpr(cond) {
772
+ if ("all" in cond) return { all: cond.all.map(compileLeaf) };
773
+ if ("any" in cond) return { any: cond.any.map(compileLeaf) };
774
+ return compileLeaf(cond);
775
+ }
776
+ function compileProtoConditional(input) {
777
+ const connections = input.conditions.map((c) => {
778
+ const baseTransition = resolveMotion(c.motion);
779
+ const needsOverlayRewrite = branchUsesOverlayOrSwap(c.then) || c.else !== void 0 && branchUsesOverlayOrSwap(c.else);
780
+ const transition = needsOverlayRewrite ? rewriteForOverlay(baseTransition) : baseTransition;
781
+ const action = {
782
+ type: "conditional",
783
+ condition: compileConditionExpr(c.if),
784
+ then: [compileBranchAction(c.then)],
785
+ ...c.else !== void 0 && { else: [compileBranchAction(c.else)] }
786
+ };
787
+ return {
788
+ sourceNodeId: c.from,
789
+ trigger: c.trigger ?? DEFAULT_TRIGGER,
790
+ transition,
791
+ ...c.degradeTo !== void 0 && { degradeTo: c.degradeTo },
792
+ action
793
+ };
794
+ });
795
+ return { connections, replaceExisting: input.replaceExisting };
796
+ }
797
+
798
+ // src/server/history.ts
799
+ import { randomUUID as randomUUID2 } from "crypto";
800
+ var HistoryStore = class {
801
+ buffer = [];
802
+ capacity;
803
+ constructor(capacity = 10) {
804
+ this.capacity = capacity;
805
+ }
806
+ record(tool, input, result) {
807
+ if (result.successCount === 0) return null;
808
+ const entry = {
809
+ historyId: randomUUID2(),
810
+ timestamp: Date.now(),
811
+ tool,
812
+ input,
813
+ result
814
+ };
815
+ this.buffer.push(entry);
816
+ if (this.buffer.length > this.capacity) this.buffer.shift();
817
+ return entry;
818
+ }
819
+ /**
820
+ * Return up to `count` most-recent entries in oldest-to-newest order
821
+ * (so `arr.at(-1)` is the most recent). Empty array if count < 1.
822
+ * Clamped to `buffer.length` when count exceeds it.
823
+ */
824
+ getLast(count = 1) {
825
+ if (count < 1) return [];
826
+ return this.buffer.slice(-Math.min(count, this.buffer.length));
827
+ }
828
+ size() {
829
+ return this.buffer.length;
830
+ }
831
+ };
832
+ function summarizeResult(raw) {
833
+ if (typeof raw !== "object" || raw === null) {
834
+ return { successCount: 0, errorCount: 0, warningCount: 0 };
835
+ }
836
+ const r = raw;
837
+ return {
838
+ successCount: typeof r.successCount === "number" ? r.successCount : 0,
839
+ errorCount: typeof r.errorCount === "number" ? r.errorCount : 0,
840
+ warningCount: typeof r.warningCount === "number" ? r.warningCount : 0
841
+ };
842
+ }
843
+
844
+ // src/server/tools.ts
845
+ async function recordedHandler(store, tool, parsedInput, send) {
846
+ const result = await send();
847
+ store.record(tool, parsedInput, summarizeResult(result));
848
+ return result;
849
+ }
850
+ function makeTools(historyStore2) {
851
+ return [
852
+ {
853
+ name: "get_canvas_overview",
854
+ description: "Return the current Figma page, its top-level frames, and currently selected nodes. Use as the first call in any scenario to understand context.",
855
+ schema: GetCanvasOverviewInput,
856
+ command: "GET_CANVAS_OVERVIEW"
857
+ },
858
+ {
859
+ name: "get_prototype_flow",
860
+ description: "Return the whole prototype interaction graph of a page in ONE call: its frames (each with `isStartFrame`) and every wired interaction \u2014 `{ frameId, frameName, sourceNodeId, sourceNodeName, trigger, action }`. `action` is decoded exactly as `list_reactions` returns it (navigate / scroll / overlay / swap / close / back / url / change_to / set_variable / toggle_variable / conditional incl. all/any compound). Use this to see what is ALREADY wired before adding more (avoid duplicates, check what a screen connects to); for a single node use list_reactions. Page-scoped \u2014 optional `pageId` (defaults to current page); `limit` caps interactions (default 500) and sets `truncated`.",
861
+ schema: GetPrototypeFlowInput,
862
+ command: "GET_PROTOTYPE_FLOW"
863
+ },
864
+ {
865
+ name: "find_nodes",
866
+ description: "Search nodes on the current page (or document) by name substring, with optional type filter.",
867
+ schema: FindNodesInput,
868
+ command: "FIND_NODES"
869
+ },
870
+ {
871
+ name: "list_variables",
872
+ description: "List Figma variables usable by name in set/toggle/conditional tools. Returns `local` variables (in this file) and `library` variables (from connected libraries) \u2014 library variables are usable directly in set/toggle/conditional and are auto-imported on first use. Call this BEFORE proto_set_variable / proto_toggle_variable / proto_conditional instead of guessing a variable name. `remoteEnumerated:false` means library enumeration was unavailable (local list still valid).",
873
+ schema: ListVariablesInput,
874
+ command: "LIST_VARIABLES"
875
+ },
876
+ {
877
+ name: "create_reactions",
878
+ description: "Create prototype reactions in batch. Each connection's action picks between Navigate To (action.type=navigate, targetFrameId) and Scroll To (action.type=scroll, targetNodeId). Each connection succeeds or fails independently. Low-level escape hatch \u2014 proto_wire/overlay/scroll cover the common cases with named motion presets.",
879
+ schema: CreateReactionsInput,
880
+ command: "CREATE_REACTIONS"
881
+ },
882
+ {
883
+ name: "list_reactions",
884
+ description: "List existing prototype reactions on a single node.",
885
+ schema: ListReactionsInput,
886
+ command: "LIST_REACTIONS"
887
+ },
888
+ {
889
+ name: "clear_reactions",
890
+ description: "Remove reactions from one or more nodes. If `indices` is given, exactly one nodeId is allowed.",
891
+ schema: ClearReactionsInput,
892
+ command: "CLEAR_REACTIONS"
893
+ },
894
+ {
895
+ name: "set_frame_scroll",
896
+ description: "Configure a frame's scroll behavior (overflowDirection) for prototype mode. Accepts a batch of { frameId, direction }; each succeeds or fails independently. Direction values: NONE (no scrolling), HORIZONTAL, VERTICAL, BOTH.",
897
+ schema: SetFrameScrollInput,
898
+ command: "SET_FRAME_SCROLL"
899
+ },
900
+ {
901
+ name: "proto_wire",
902
+ description: "\uC774 \uB3C4\uAD6C\uB294 \uC624\uC9C1 \uD53C\uADF8\uB9C8\uC758 \uD504\uB85C\uD1A0\uD0C0\uC785 \uC778\uD130\uB799\uC158/\uC560\uB2C8\uBA54\uC774\uC158 \uC0DD\uC131\xB7\uC218\uC815 \uBAA9\uC801\uC73C\uB85C\uB9CC \uC0AC\uC6A9\uD569\uB2C8\uB2E4. Wire one or more source nodes to destination frames with Navigate To. `from`/`to` are node IDs (e.g. \"1404:1947\"), NOT frame names \u2014 resolve names to IDs with find_nodes or get_canvas_overview first. Use when the WHOLE screen changes to the destination. For a modal/popup/dialog/toast/sheet that appears ON TOP of the current screen ('\uB5A0/\uD31D\uC5C5/\uBAA8\uB2EC'), use proto_overlay (open) instead. Accepts a `motion` preset name (e.g. \"M3_EMPHASIZED\") or a full TransitionInput. Defaults: trigger=ON_CLICK, motion=M3_EMPHASIZED (a SMART_ANIMATE preset). SMART_ANIMATE only morphs layers shared by name between the two frames; when they share none it auto-degrades to the connection's `degradeTo` (DISSOLVE by default). For a spatial 'slides/pushes in' feel between distinct screens, pass a directional TransitionInput (PUSH/MOVE_IN/MOVE_OUT) as `motion`. Compiles to create_reactions internally.",
903
+ schema: ProtoWireInput,
904
+ handler: async (input, session2) => {
905
+ const parsedInput = input;
906
+ return recordedHandler(
907
+ historyStore2,
908
+ "proto_wire",
909
+ parsedInput,
910
+ () => session2.sendCommand("CREATE_REACTIONS", compileProtoWire(parsedInput))
911
+ );
912
+ }
913
+ },
914
+ {
915
+ name: "proto_change_to",
916
+ description: "\uC774 \uB3C4\uAD6C\uB294 \uC624\uC9C1 \uD53C\uADF8\uB9C8\uC758 \uD504\uB85C\uD1A0\uD0C0\uC785 \uC778\uD130\uB799\uC158/\uC560\uB2C8\uBA54\uC774\uC158 \uC0DD\uC131\xB7\uC218\uC815 \uBAA9\uC801\uC73C\uB85C\uB9CC \uC0AC\uC6A9\uD569\uB2C8\uB2E4. Switch a component INSTANCE to a sibling VARIANT on interaction (Figma's 'Change to'). This is a ONE-SHOT switch to a SPECIFIC target variant (\u2192 selected, \u2192 highlight, \u2192 on), NOT an alternating flip \u2014 for tabs, segmented controls, and 'set this to its <state> state' changes driven by variants of one component. KO cues: '\uC120\uD0DD \uC0C1\uD0DC\uB85C', 'highlight \uC0C1\uD0DC\uB85C \uBC14\uAFD4', 'variant \uBC14\uAFD4', '~\uC0C1\uD0DC\uB85C \uBC14\uAFD4'. `from` = a component instance node ID (or a node inside one); `to` = the target variant node ID (a COMPONENT inside the same component set, and NOT the instance's current variant) \u2014 both are node IDs, NOT names; resolve names via find_nodes first. Boundaries: a whole-screen change \u2192 proto_wire; a data value \u2192 proto_set_variable; an on/off that flips BACK on every tap ('\uCF1C\uACE0 \uB044\uAE30', a repeating toggle) must be driven by a BOOLEAN variable \u2192 use proto_toggle_variable (a single change_to only goes one direction, it cannot alternate). Defaults: trigger=ON_CLICK, motion=M3_EMPHASIZED (SMART_ANIMATE morph between variants). Compiles to create_reactions internally.",
917
+ schema: ProtoChangeToInput,
918
+ handler: async (input, session2) => {
919
+ const parsedInput = input;
920
+ return recordedHandler(
921
+ historyStore2,
922
+ "proto_change_to",
923
+ parsedInput,
924
+ () => session2.sendCommand("CREATE_REACTIONS", compileProtoChangeTo(parsedInput))
925
+ );
926
+ }
927
+ },
928
+ {
929
+ name: "proto_overlay",
930
+ description: `\uC774 \uB3C4\uAD6C\uB294 \uC624\uC9C1 \uD53C\uADF8\uB9C8\uC758 \uD504\uB85C\uD1A0\uD0C0\uC785 \uC778\uD130\uB799\uC158/\uC560\uB2C8\uBA54\uC774\uC158 \uC0DD\uC131\xB7\uC218\uC815 \uBAA9\uC801\uC73C\uB85C\uB9CC \uC0AC\uC6A9\uD569\uB2C8\uB2E4. Create overlay reactions in batch. Each entry has mode = "open" | "swap" | "close". open/swap require an \`overlay\` frameId; close has none. 'open' = content floating above the current screen (modal/popup/dialog/toast/bottom-sheet); for a full screen change use proto_wire. 'close' = dismiss an open overlay, revealing the screen underneath it. If the user says 'go back / \uB3CC\uC544\uAC00 / \uB4A4\uB85C' while on an overlay, that is AMBIGUOUS between close (reveal the underlying screen) and proto_back (history pop) \u2014 ask the user which they mean rather than guessing. Defaults: trigger=ON_CLICK, motion=M3_EMPHASIZED. Compiles to create_reactions internally. Note: Figma's runtime rejects SMART_ANIMATE on overlay/swap/close navigation, so any SMART_ANIMATE-based motion (including all M3/HIG presets) is silently rewritten to DISSOLVE while preserving duration + easing.`,
931
+ schema: ProtoOverlayInput,
932
+ handler: async (input, session2) => {
933
+ const parsedInput = input;
934
+ return recordedHandler(
935
+ historyStore2,
936
+ "proto_overlay",
937
+ parsedInput,
938
+ () => session2.sendCommand("CREATE_REACTIONS", compileProtoOverlay(parsedInput))
939
+ );
940
+ }
941
+ },
942
+ {
943
+ name: "proto_scroll",
944
+ description: "\uC774 \uB3C4\uAD6C\uB294 \uC624\uC9C1 \uD53C\uADF8\uB9C8\uC758 \uD504\uB85C\uD1A0\uD0C0\uC785 \uC778\uD130\uB799\uC158/\uC560\uB2C8\uBA54\uC774\uC158 \uC0DD\uC131\xB7\uC218\uC815 \uBAA9\uC801\uC73C\uB85C\uB9CC \uC0AC\uC6A9\uD569\uB2C8\uB2E4. Wire source nodes to scroll targets \u2014 Figma's SCROLL_TO action: clicking the source jumps the prototype view to a target NODE inside the same scrollable frame (the target frame must have overflowDirection set, e.g. via set_frame_scroll). NOT for the general 'scroll feel' between pages ('\uC2A4\uD06C\uB864 \uB290\uB08C\uC73C\uB85C \uD654\uBA74\uC774 \uBD80\uB4DC\uB7FD\uAC8C \uB118\uC5B4\uAC00\uAC8C') \u2014 for that effect, use a directional transition (PUSH or SLIDE_*) via proto_wire instead. Defaults: trigger=ON_CLICK, motion=M3_EMPHASIZED. Compiles to create_reactions internally.",
945
+ schema: ProtoScrollInput,
946
+ handler: async (input, session2) => {
947
+ const parsedInput = input;
948
+ return recordedHandler(
949
+ historyStore2,
950
+ "proto_scroll",
951
+ parsedInput,
952
+ () => session2.sendCommand("CREATE_REACTIONS", compileProtoScroll(parsedInput))
953
+ );
954
+ }
955
+ },
956
+ {
957
+ name: "proto_back",
958
+ description: "\uC774 \uB3C4\uAD6C\uB294 \uC624\uC9C1 \uD53C\uADF8\uB9C8\uC758 \uD504\uB85C\uD1A0\uD0C0\uC785 \uC778\uD130\uB799\uC158/\uC560\uB2C8\uBA54\uC774\uC158 \uC0DD\uC131\xB7\uC218\uC815 \uBAA9\uC801\uC73C\uB85C\uB9CC \uC0AC\uC6A9\uD569\uB2C8\uB2E4. Wire source nodes to the Back navigation action (pops the prototype history stack \u2014 no destination). Use for 'go back / \uB4A4\uB85C' = return to whatever screen the user came from (dynamic, no fixed destination). To navigate to a SPECIFIC previous frame, use proto_wire instead. Choosing the source node: for an abstract request ('\uB4A4\uB85C\uAC00\uAE30 \uB2EC\uC544\uC918/add back to each screen') FIRST look for a visible back affordance in each frame \u2014 a small top-left icon, or a node whose name contains back/arrow/chevron/prev, or a '<'/'\u2039' glyph \u2014 and wire THAT with ON_CLICK. Only use a frame-level ON_DRAG swipe-back when the request names a gesture ('\uC2A4\uC640\uC774\uD504/\uBC00\uC5B4\uC11C \uB4A4\uB85C'). If the intent is abstract AND no back-affordance node exists, ASK the user ('\uBC31\uBC84\uD2BC\uC774 \uC548 \uBCF4\uC774\uB294\uB370 \uC2A4\uC640\uC774\uD504 \uC81C\uC2A4\uCC98\uB85C \uD560\uAE4C\uC694?') rather than silently wiring a swipe \u2014 do not create a node (this tool only wires). \u26A0\uFE0F If the source is on an OVERLAY (popup/modal/dialog/sheet shown on top of another screen), 'go back / \uB3CC\uC544\uAC00 / \uB4A4\uB85C' is AMBIGUOUS \u2014 it may mean dismiss the overlay to reveal the screen underneath (= proto_overlay close) or pop the navigation history (= Back, which on an overlay often lands on an unexpected earlier frame). Ask the user which they mean before wiring. Defaults: trigger=ON_CLICK, motion=M3_EMPHASIZED. Compiles to create_reactions internally.",
959
+ schema: ProtoBackInput,
960
+ handler: async (input, session2) => {
961
+ const parsedInput = input;
962
+ return recordedHandler(
963
+ historyStore2,
964
+ "proto_back",
965
+ parsedInput,
966
+ () => session2.sendCommand("CREATE_REACTIONS", compileProtoBack(parsedInput))
967
+ );
968
+ }
969
+ },
970
+ {
971
+ name: "proto_url",
972
+ description: "\uC774 \uB3C4\uAD6C\uB294 \uC624\uC9C1 \uD53C\uADF8\uB9C8\uC758 \uD504\uB85C\uD1A0\uD0C0\uC785 \uC778\uD130\uB799\uC158/\uC560\uB2C8\uBA54\uC774\uC158 \uC0DD\uC131\xB7\uC218\uC815 \uBAA9\uC801\uC73C\uB85C\uB9CC \uC0AC\uC6A9\uD569\uB2C8\uB2E4. Wire source nodes to the Open URL action. Input `{ urls: [{ from, url, openInNewTab? }] }`. Defaults: trigger=ON_CLICK, openInNewTab=false. No `motion` field \u2014 URL is a terminal event and the underlying reaction's transition defaults to INSTANT. Compiles to create_reactions internally.",
973
+ schema: ProtoUrlInput,
974
+ handler: async (input, session2) => {
975
+ const parsedInput = input;
976
+ return recordedHandler(
977
+ historyStore2,
978
+ "proto_url",
979
+ parsedInput,
980
+ () => session2.sendCommand("CREATE_REACTIONS", compileProtoUrl(parsedInput))
981
+ );
982
+ }
983
+ },
984
+ {
985
+ name: "proto_set_variable",
986
+ description: '\uC774 \uB3C4\uAD6C\uB294 \uC624\uC9C1 \uD53C\uADF8\uB9C8\uC758 \uD504\uB85C\uD1A0\uD0C0\uC785 \uC778\uD130\uB799\uC158/\uC560\uB2C8\uBA54\uC774\uC158 \uC0DD\uC131\xB7\uC218\uC815 \uBAA9\uC801\uC73C\uB85C\uB9CC \uC0AC\uC6A9\uD569\uB2C8\uB2E4. Wire source nodes to the Set Variable action \u2014 clicking the source assigns a literal value to a Figma variable (resolved by NAME \u2014 local or library/remote; library variables are auto-imported on use). Input `{ sets: [{ from, variable, value }] }`. `value` is boolean / number / string and must match the variable\'s resolvedType; for COLOR variables, pass `value` as a hex string ("#RRGGBB" or "#RRGGBBAA"). To flip a BOOLEAN without naming the target value (\'\uD1A0\uAE00/\uCF1C\uACE0 \uB044\uAE30\'), use proto_toggle_variable \u2014 this tool assigns a SPECIFIC value. Defaults: trigger=ON_CLICK. No `motion` field \u2014 variable changes are instant (transition defaults to INSTANT). Compiles to create_reactions internally.',
987
+ schema: ProtoSetVariableInput,
988
+ handler: async (input, session2) => {
989
+ const parsedInput = input;
990
+ return recordedHandler(
991
+ historyStore2,
992
+ "proto_set_variable",
993
+ parsedInput,
994
+ () => session2.sendCommand("CREATE_REACTIONS", compileProtoSetVariable(parsedInput))
995
+ );
996
+ }
997
+ },
998
+ {
999
+ name: "proto_toggle_variable",
1000
+ description: "\uC774 \uB3C4\uAD6C\uB294 \uC624\uC9C1 \uD53C\uADF8\uB9C8\uC758 \uD504\uB85C\uD1A0\uD0C0\uC785 \uC778\uD130\uB799\uC158/\uC560\uB2C8\uBA54\uC774\uC158 \uC0DD\uC131\xB7\uC218\uC815 \uBAA9\uC801\uC73C\uB85C\uB9CC \uC0AC\uC6A9\uD569\uB2C8\uB2E4. Wire source nodes to the Toggle Variable action \u2014 clicking the source flips a BOOLEAN variable (resolved by NAME \u2014 local or library/remote, auto-imported on use). Input `{ toggles: [{ from, variable }] }`. The variable's resolvedType MUST be BOOLEAN (plugin rejects otherwise). Use to flip/switch a boolean ('\uD1A0\uAE00', '\uCF1C\uACE0 \uB044\uAE30') with no named target value; to assign a specific value (true/false/number/string/color) use proto_set_variable instead. This is the right tool for a REPEATING on/off that flips back on every tap. If the on/off is a VISUAL component built from variants and NOT backed by a boolean variable, a one-directional switch to a specific state is proto_change_to instead; toggle_variable requires a BOOLEAN variable to flip. Defaults: trigger=ON_CLICK. No `motion` field \u2014 variable changes are instant. Compiles to create_reactions internally (desugars to CONDITIONAL + 2 SET_VARIABLE under the hood; list_reactions round-trips to toggle_variable shape).",
1001
+ schema: ProtoToggleVariableInput,
1002
+ handler: async (input, session2) => {
1003
+ const parsedInput = input;
1004
+ return recordedHandler(
1005
+ historyStore2,
1006
+ "proto_toggle_variable",
1007
+ parsedInput,
1008
+ () => session2.sendCommand("CREATE_REACTIONS", compileProtoToggleVariable(parsedInput))
1009
+ );
1010
+ }
1011
+ },
1012
+ {
1013
+ name: "proto_conditional",
1014
+ description: "\uC774 \uB3C4\uAD6C\uB294 \uC624\uC9C1 \uD53C\uADF8\uB9C8\uC758 \uD504\uB85C\uD1A0\uD0C0\uC785 \uC778\uD130\uB799\uC158/\uC560\uB2C8\uBA54\uC774\uC158 \uC0DD\uC131\xB7\uC218\uC815 \uBAA9\uC801\uC73C\uB85C\uB9CC \uC0AC\uC6A9\uD569\uB2C8\uB2E4. Wire a conditional reaction (if/then/else) on a source node based on a variable comparison. Use for '~\uBA74 ~\uD558\uACE0 \uC544\uB2C8\uBA74 ~' / '\uC870\uAC74\uC5D0 \uB530\uB77C' branching interactions. The variable is referenced by NAME; the plugin resolves it at runtime \u2014 local variables match directly, library/remote variables are auto-imported on use. Use list_variables to find exact names. Input `{ conditions: [{ from, if, then, else? }] }`. `if` is a single comparison `{ variable, operator?, value }`, OR a one-level compound: `{ all: [<comparison>, \u2026] }` (AND \u2014 \uBAA8\uB450 \uCC38\uC77C \uB54C; cues: '\uADF8\uB9AC\uACE0 / \uC774\uACE0 / \uB458 \uB2E4 / \uBAA8\uB450') or `{ any: [<comparison>, \u2026] }` (OR \u2014 \uD558\uB098\uB77C\uB3C4 \uCC38\uC77C \uB54C; cues: '\uB610\uB294 / \uAC70\uB098 / \uD558\uB098\uB77C\uB3C4'). Each array needs \u22652 comparisons; `all` and `any` cannot be mixed or nested (one level only) \u2014 for multi-way branching use separate reactions (Figma has no else-if). `if.operator` defaults to \"==\" if omitted (most common case); other operators: !=, <, <=, >, >=. `then` / `else` each take exactly ONE branch action (single sugar entry). Branch sugar keys: `navigate` / `scroll` / `overlay` / `swap` / `close` / `back` / `url` / `set`. `toggle_variable` is not available inside conditional (toggle itself desugars to CONDITIONAL \u2014 nesting is meaningless). For multi-action branches, use low-level `create_reactions` (escape hatch). Overlay/swap branches: if either branch is `{ overlay }` or `{ swap }`, SMART_ANIMATE auto-rewrites to DISSOLVE (Figma's overlay transition constraint); the motion intent (duration/easing) is preserved. Variable type must match `if.value` (BOOLEAN/FLOAT/STRING); COLOR variables are NOT comparable. `trigger` / `motion` apply at the conditional level (shared across branches); branch sugars do NOT accept them.",
1015
+ schema: ProtoConditionalInput,
1016
+ handler: async (input, session2) => {
1017
+ const parsedInput = input;
1018
+ return recordedHandler(
1019
+ historyStore2,
1020
+ "proto_conditional",
1021
+ parsedInput,
1022
+ () => session2.sendCommand("CREATE_REACTIONS", compileProtoConditional(parsedInput))
1023
+ );
1024
+ }
1025
+ },
1026
+ {
1027
+ name: "proto_get_last_history",
1028
+ description: '\uC774 \uB3C4\uAD6C\uB294 \uC624\uC9C1 \uD53C\uADF8\uB9C8\uC758 \uD504\uB85C\uD1A0\uD0C0\uC785 \uC778\uD130\uB799\uC158/\uC560\uB2C8\uBA54\uC774\uC158 \uC0DD\uC131\xB7\uC218\uC815 \uBAA9\uC801\uC73C\uB85C\uB9CC \uC0AC\uC6A9\uD569\uB2C8\uB2E4. Returns the most-recent successful proto_* tool calls as an array of HistoryEntry objects, newest-last. Use when the user references "the last thing I made" / "\uBC29\uAE08 \uB9CC\uB4E0 \uAC70" to recover the source/target IDs and motion preset, then re-call the corresponding proto_* with replaceExisting=true to apply a modification.',
1029
+ schema: ProtoGetLastHistoryInput,
1030
+ handler: async (input) => {
1031
+ const { count } = input;
1032
+ return { entries: historyStore2.getLast(count) };
1033
+ }
1034
+ }
1035
+ ];
1036
+ }
1037
+ function registerToolHandlers(mcp, session2, historyStore2) {
1038
+ const TOOLS = makeTools(historyStore2);
1039
+ mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
1040
+ tools: TOOLS.map((t) => ({
1041
+ name: t.name,
1042
+ description: t.description,
1043
+ inputSchema: zodToJsonSchema(t.schema)
1044
+ }))
1045
+ }));
1046
+ mcp.setRequestHandler(CallToolRequestSchema, async (req) => {
1047
+ const tool = TOOLS.find((t) => t.name === req.params.name);
1048
+ if (!tool) {
1049
+ return { isError: true, content: [{ type: "text", text: `Unknown tool: ${req.params.name}` }] };
1050
+ }
1051
+ const parsed = tool.schema.safeParse(req.params.arguments ?? {});
1052
+ if (!parsed.success) {
1053
+ return { isError: true, content: [{ type: "text", text: `Invalid input: ${parsed.error.message}` }] };
1054
+ }
1055
+ try {
1056
+ const result = tool.handler !== void 0 ? await tool.handler(parsed.data, session2) : await session2.sendCommand(tool.command, parsed.data);
1057
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1058
+ } catch (err) {
1059
+ return { isError: true, content: [{ type: "text", text: err.message }] };
1060
+ }
1061
+ });
1062
+ }
1063
+ function createMcpServer(session2, historyStore2, version) {
1064
+ const server = new Server(
1065
+ { name: "figma-prototype-mcp", version },
1066
+ { capabilities: { tools: {} } }
1067
+ );
1068
+ registerToolHandlers(server, session2, historyStore2);
1069
+ return server;
1070
+ }
1071
+
1072
+ // src/server/sse-session.ts
1073
+ var SseSession = class {
1074
+ active = null;
1075
+ /**
1076
+ * Adopt `transport` as the active connection; close + discard any prior one.
1077
+ * Returns `true` iff a *different* prior connection was evicted — the caller
1078
+ * logs this so a silent takeover (a second MCP client displacing the first) is
1079
+ * diagnosable. A well-behaved MCP client fast-fails its next call after eviction
1080
+ * (the displaced POST gets HTTP 400 "unknown session"; verified 2026-06-12). A
1081
+ * stdio↔SSE bridge such as supergateway may NOT propagate that failure to its
1082
+ * stdio client, which then hangs to its own timeout — so keep ONE MCP client per
1083
+ * server. Newest-wins is deliberate: it lets a fresh reconnect replace a dead
1084
+ * prior stream (the zombie-SSE case) without manual cleanup.
1085
+ */
1086
+ activate(server, transport) {
1087
+ const evicted = this.active !== null && this.active.transport !== transport;
1088
+ if (evicted) {
1089
+ try {
1090
+ void this.active.transport.close();
1091
+ } catch {
1092
+ }
1093
+ }
1094
+ this.active = { server, transport };
1095
+ return evicted;
1096
+ }
1097
+ /** The active transport iff its id matches `sessionId` (POST routing); else null. */
1098
+ get(sessionId) {
1099
+ return this.active && this.active.transport.sessionId === sessionId ? this.active.transport : null;
1100
+ }
1101
+ /** Clear iff `transport` is still the active one (called on stream close). */
1102
+ clear(transport) {
1103
+ if (this.active && this.active.transport === transport) {
1104
+ this.active = null;
1105
+ }
1106
+ }
1107
+ isActive() {
1108
+ return this.active !== null;
1109
+ }
1110
+ };
1111
+
1112
+ // src/server/index.ts
1113
+ var pkg = JSON.parse(
1114
+ readFileSync(new URL("../../package.json", import.meta.url), "utf8")
1115
+ );
1116
+ var PORT = Number(process.env.PORT ?? 3e3);
1117
+ var session = new PluginSession();
1118
+ var historyStore = new HistoryStore();
1119
+ var sse = new SseSession();
1120
+ var app = express();
1121
+ app.get("/sse", async (_req, res) => {
1122
+ const server = createMcpServer(session, historyStore, pkg.version);
1123
+ const transport = new SSEServerTransport("/messages", res);
1124
+ res.on("close", () => sse.clear(transport));
1125
+ await server.connect(transport);
1126
+ const evicted = sse.activate(server, transport);
1127
+ if (evicted) {
1128
+ console.warn(
1129
+ "[server] a second MCP client connected \u2014 evicted the prior SSE connection (newest-wins). The displaced client's next call fails fast with HTTP 400 and it should reconnect; keep a single MCP client per server (a supergateway bridge may hang instead of surfacing the eviction)."
1130
+ );
1131
+ }
1132
+ });
1133
+ app.post("/messages", express.json(), async (req, res) => {
1134
+ const t = sse.get(String(req.query.sessionId ?? ""));
1135
+ if (!t) {
1136
+ res.status(400).send("unknown session");
1137
+ return;
1138
+ }
1139
+ await t.handlePostMessage(req, res, req.body);
1140
+ });
1141
+ var httpServer = app.listen(PORT, () => {
1142
+ console.log(`[server] listening on http://localhost:${PORT}`);
1143
+ console.log(`[server] MCP SSE endpoint: GET /sse`);
1144
+ console.log(`[server] Plugin WebSocket: ws://localhost:${PORT}/ws`);
1145
+ console.log(
1146
+ `[server] Figma plugin manifest: ${fileURLToPath(new URL("../figma-plugin/manifest.json", import.meta.url))}`
1147
+ );
1148
+ });
1149
+ attachPluginWebSocket(httpServer, session);
1150
+ process.on("unhandledRejection", (err) => {
1151
+ console.error("[server] unhandledRejection:", err);
1152
+ });
1153
+ process.on("uncaughtException", (err) => {
1154
+ console.error("[server] uncaughtException:", err);
1155
+ });