figma-prototype-mcp 0.30.1 → 0.30.3

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/README.md CHANGED
@@ -27,6 +27,12 @@ npx figma-prototype-mcp
27
27
  { "mcpServers": { "figma-prototype": { "url": "http://localhost:3000/sse" } } }
28
28
  ```
29
29
 
30
+ **Claude Desktop** has no native SSE support, so point it at the server over stdio — it launches the server for you (no separate `npx figma-prototype-mcp` needed):
31
+ ```json
32
+ { "mcpServers": { "figma-prototype": { "command": "npx", "args": ["-y", "figma-prototype-mcp", "--stdio"] } } }
33
+ ```
34
+ In `--stdio` mode the client starts the server and talks to it over stdio; the server still hosts the Figma plugin WebSocket on `ws://localhost:3000/ws`. Don't also run a separate SSE server (`npx figma-prototype-mcp`) on the same port — pick one. (Claude Code can use either the SSE `url` above or this stdio command.)
35
+
30
36
  **4. Wire it by talking.** In a file with ≥2 frames, ask Claude:
31
37
  > "Home의 버튼을 누르면 Detail 화면으로 가게 해줘"
32
38
  > *(or "when the button on Home is clicked, navigate to Detail")*
@@ -180,6 +186,7 @@ To bypass the preset system (e.g. for `MOVE_IN`/`PUSH`/`SLIDE_*` directional tra
180
186
  | A tool call hangs, then the client falls back to another tool | A **second MCP client** connected and evicted the first (single-active, newest-wins). Keep one client per server; reconnect the one you want to use. A stdio↔SSE bridge (e.g. supergateway) may not surface the eviction — the server logs `a second MCP client connected — evicted the prior SSE connection`. |
181
187
  | `get_canvas_overview` shows `frames: []` but the page clearly has frames | `get_canvas_overview` lists only **top-level** frames, so frames nested inside a **Section** don't appear. `get_prototype_flow` lists frames recursively (Sections included) and is the better read for a populated page; pass `pageId` if you're not on the intended page. |
182
188
  | Cryptic crash on startup (syntax / module errors) | Check your Node version — this needs **Node ≥ 18** (`node -v`). |
189
+ | Client shows a zod `invalid_union` error mentioning `error.code` expected number, or `ECONNREFUSED ...:3000`, at startup | Your `:3000` server isn't running. For **Claude Desktop**, use the `--stdio` command config (it launches the server for you). For **Claude Code** over SSE, start `npx figma-prototype-mcp` first. (A stdio↔SSE bridge like supergateway reports a missing server as this malformed frame.) |
183
190
 
184
191
  ## Known limitations
185
192
 
@@ -1,13 +1,54 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/server/index.ts
4
- import express from "express";
5
- import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
3
+ // src/server/run.ts
4
+ import http from "http";
6
5
  import { readFileSync } from "fs";
7
6
  import { fileURLToPath } from "url";
7
+ import express from "express";
8
+ import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
9
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
10
 
9
11
  // src/server/sessions.ts
10
12
  import { randomUUID } from "crypto";
13
+
14
+ // src/server/messages.ts
15
+ var COMMUNITY_URL = "https://www.figma.com/community/plugin/1647184714488719280";
16
+ var PLUGIN_NOT_CONNECTED = [
17
+ `Figma plugin not connected. The MCP server is running, but no Figma plugin has connected yet.`,
18
+ `To connect: in Figma, open your file \u2192 Plugins \u2192 run "Prototype MCP".`,
19
+ `If you haven't installed it, get it from Figma Community:`,
20
+ COMMUNITY_URL,
21
+ `The plugin auto-connects to ws://localhost:3000/ws \u2014 once it shows "Connected", retry your request.`,
22
+ ``,
23
+ `Figma \uD50C\uB7EC\uADF8\uC778\uC774 \uC5F0\uACB0\uB418\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4. MCP \uC11C\uBC84\uB294 \uC2E4\uD589 \uC911\uC774\uC9C0\uB9CC \uD50C\uB7EC\uADF8\uC778\uC774 \uC544\uC9C1 \uC5F0\uACB0\uB418\uC9C0 \uC54A\uC558\uC5B4\uC694.`,
24
+ `\uC5F0\uACB0 \uBC29\uBC95: Figma\uC5D0\uC11C \uD30C\uC77C\uC744 \uC5F4\uACE0 \u2192 Plugins \u2192 "Prototype MCP" \uC2E4\uD589.`,
25
+ `\uC124\uCE58 \uC804\uC774\uB77C\uBA74 Figma Community\uC5D0\uC11C \uBC1B\uC73C\uC138\uC694:`,
26
+ COMMUNITY_URL,
27
+ `\uD50C\uB7EC\uADF8\uC778\uC740 ws://localhost:3000/ws\uC5D0 \uC790\uB3D9 \uC5F0\uACB0\uB429\uB2C8\uB2E4 \u2014 "Connected"\uAC00 \uB728\uBA74 \uB2E4\uC2DC \uC2DC\uB3C4\uD558\uC138\uC694.`
28
+ ].join("\n");
29
+ var PLUGIN_DISCONNECTED = [
30
+ `The Figma plugin disconnected before the command finished. This usually means the Figma tab/file or the plugin window was closed.`,
31
+ `Reopen "Prototype MCP" in Figma \u2014 it auto-reconnects \u2014 then retry.`,
32
+ ``,
33
+ `\uBA85\uB839\uC774 \uB05D\uB098\uAE30 \uC804\uC5D0 Figma \uD50C\uB7EC\uADF8\uC778 \uC5F0\uACB0\uC774 \uB04A\uACBC\uC2B5\uB2C8\uB2E4. \uBCF4\uD1B5 Figma \uD0ED/\uD30C\uC77C\uC774\uB098 \uD50C\uB7EC\uADF8\uC778 \uCC3D\uC744 \uB2EB\uC558\uC744 \uB54C \uBC1C\uC0DD\uD574\uC694.`,
34
+ `Figma\uC5D0\uC11C "Prototype MCP"\uB97C \uB2E4\uC2DC \uC2E4\uD589\uD558\uBA74 \uC790\uB3D9 \uC7AC\uC5F0\uACB0\uB429\uB2C8\uB2E4 \u2014 \uADF8 \uD6C4 \uB2E4\uC2DC \uC2DC\uB3C4\uD558\uC138\uC694.`
35
+ ].join("\n");
36
+ var PLUGIN_CONNECTION_REPLACED = [
37
+ `Your plugin connection was replaced by a newer one. Only one Figma plugin can be active at a time (newest wins) \u2014 this usually means the plugin connected from a second Figma tab or file.`,
38
+ `Use the most recently opened "Prototype MCP" plugin, then retry.`,
39
+ ``,
40
+ `\uD50C\uB7EC\uADF8\uC778 \uC5F0\uACB0\uC774 \uB354 \uC0C8\uB85C\uC6B4 \uC5F0\uACB0\uB85C \uAD50\uCCB4\uB418\uC5C8\uC2B5\uB2C8\uB2E4. \uD55C \uBC88\uC5D0 \uD558\uB098\uC758 Figma \uD50C\uB7EC\uADF8\uC778\uB9CC \uD65C\uC131\uD654\uB429\uB2C8\uB2E4(\uCD5C\uC2E0 \uC6B0\uC120) \u2014 \uBCF4\uD1B5 \uB450 \uBC88\uC9F8 Figma \uD0ED\uC774\uB098 \uD30C\uC77C\uC5D0\uC11C \uD50C\uB7EC\uADF8\uC778\uC774 \uC5F0\uACB0\uB410\uC744 \uB54C \uBC1C\uC0DD\uD574\uC694.`,
41
+ `\uAC00\uC7A5 \uCD5C\uADFC\uC5D0 \uC5F0 "Prototype MCP" \uD50C\uB7EC\uADF8\uC778\uC744 \uC0AC\uC6A9\uD55C \uB4A4 \uC7AC\uC2DC\uB3C4\uD558\uC138\uC694.`
42
+ ].join("\n");
43
+ var pluginCommandTimeout = (command, ms) => [
44
+ `The Figma plugin is connected but didn't respond within ${ms}ms (command: ${command}).`,
45
+ `Figma may be busy, or the plugin may be stuck. Try closing and relaunching "Prototype MCP" in Figma, then retry.`,
46
+ ``,
47
+ `Figma \uD50C\uB7EC\uADF8\uC778\uC774 \uC5F0\uACB0\uB3FC \uC788\uC9C0\uB9CC ${ms}ms \uC548\uC5D0 \uC751\uB2F5\uD558\uC9C0 \uC54A\uC558\uC2B5\uB2C8\uB2E4 (\uBA85\uB839: ${command}).`,
48
+ `Figma\uAC00 \uBC14\uC058\uAC70\uB098 \uD50C\uB7EC\uADF8\uC778\uC774 \uBA48\uCDC4\uC744 \uC218 \uC788\uC5B4\uC694. Figma\uC5D0\uC11C "Prototype MCP"\uB97C \uB2EB\uC558\uB2E4\uAC00 \uB2E4\uC2DC \uC2E4\uD589\uD55C \uB4A4 \uC7AC\uC2DC\uB3C4\uD558\uC138\uC694.`
49
+ ].join("\n");
50
+
51
+ // src/server/sessions.ts
11
52
  var PluginSession = class {
12
53
  active = null;
13
54
  pending = /* @__PURE__ */ new Map();
@@ -31,7 +72,7 @@ var PluginSession = class {
31
72
  this.active.close();
32
73
  } catch {
33
74
  }
34
- this.failAllPending(new Error("Plugin connection replaced by newer connection"));
75
+ this.failAllPending(new Error(PLUGIN_CONNECTION_REPLACED));
35
76
  }
36
77
  this.active = ws;
37
78
  try {
@@ -43,7 +84,7 @@ var PluginSession = class {
43
84
  clearActive(ws) {
44
85
  if (this.active === ws) {
45
86
  this.active = null;
46
- this.failAllPending(new Error("Plugin disconnected"));
87
+ this.failAllPending(new Error(PLUGIN_DISCONNECTED));
47
88
  }
48
89
  }
49
90
  handleResponse(msg) {
@@ -58,14 +99,14 @@ var PluginSession = class {
58
99
  if (!this.isConnected()) {
59
100
  await this.waitForConnection(this.connectWaitMs);
60
101
  if (!this.isConnected()) {
61
- throw new Error("\uD53C\uADF8\uB9C8 \uD50C\uB7EC\uADF8\uC778 \uC5F0\uACB0\uC744 \uD655\uC778\uD574\uC8FC\uC138\uC694");
102
+ throw new Error(PLUGIN_NOT_CONNECTED);
62
103
  }
63
104
  }
64
105
  const id = randomUUID();
65
106
  return new Promise((resolve, reject) => {
66
107
  const timer = setTimeout(() => {
67
108
  this.pending.delete(id);
68
- reject(new Error(`Command ${command} timed out after ${this.commandTimeoutMs}ms`));
109
+ reject(new Error(pluginCommandTimeout(command, this.commandTimeoutMs)));
69
110
  }, this.commandTimeoutMs);
70
111
  this.pending.set(id, { resolve, reject, timer });
71
112
  try {
@@ -107,9 +148,9 @@ var PluginSession = class {
107
148
  // src/server/plugin-ws.ts
108
149
  import { WebSocketServer } from "ws";
109
150
  var PLUGIN_PATH = "/ws";
110
- function attachPluginWebSocket(httpServer2, session2) {
151
+ function attachPluginWebSocket(httpServer, session) {
111
152
  const wss = new WebSocketServer({ noServer: true });
112
- httpServer2.on("upgrade", (req, socket, head) => {
153
+ httpServer.on("upgrade", (req, socket, head) => {
113
154
  if (req.url === PLUGIN_PATH) {
114
155
  wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
115
156
  } else {
@@ -117,7 +158,7 @@ function attachPluginWebSocket(httpServer2, session2) {
117
158
  }
118
159
  });
119
160
  wss.on("connection", (ws) => {
120
- session2.setActive(ws);
161
+ session.setActive(ws);
121
162
  ws.on("message", (raw) => {
122
163
  let msg;
123
164
  try {
@@ -126,15 +167,61 @@ function attachPluginWebSocket(httpServer2, session2) {
126
167
  return;
127
168
  }
128
169
  if (typeof msg === "object" && msg !== null && msg.type === "response") {
129
- session2.handleResponse(msg);
170
+ session.handleResponse(msg);
130
171
  }
131
172
  });
132
- ws.on("close", () => session2.clearActive(ws));
133
- ws.on("error", () => session2.clearActive(ws));
173
+ ws.on("close", () => session.clearActive(ws));
174
+ ws.on("error", () => session.clearActive(ws));
134
175
  });
135
176
  return wss;
136
177
  }
137
178
 
179
+ // src/server/history.ts
180
+ import { randomUUID as randomUUID2 } from "crypto";
181
+ var HistoryStore = class {
182
+ buffer = [];
183
+ capacity;
184
+ constructor(capacity = 10) {
185
+ this.capacity = capacity;
186
+ }
187
+ record(tool, input, result) {
188
+ if (result.successCount === 0) return null;
189
+ const entry = {
190
+ historyId: randomUUID2(),
191
+ timestamp: Date.now(),
192
+ tool,
193
+ input,
194
+ result
195
+ };
196
+ this.buffer.push(entry);
197
+ if (this.buffer.length > this.capacity) this.buffer.shift();
198
+ return entry;
199
+ }
200
+ /**
201
+ * Return up to `count` most-recent entries in oldest-to-newest order
202
+ * (so `arr.at(-1)` is the most recent). Empty array if count < 1.
203
+ * Clamped to `buffer.length` when count exceeds it.
204
+ */
205
+ getLast(count = 1) {
206
+ if (count < 1) return [];
207
+ return this.buffer.slice(-Math.min(count, this.buffer.length));
208
+ }
209
+ size() {
210
+ return this.buffer.length;
211
+ }
212
+ };
213
+ function summarizeResult(raw) {
214
+ if (typeof raw !== "object" || raw === null) {
215
+ return { successCount: 0, errorCount: 0, warningCount: 0 };
216
+ }
217
+ const r = raw;
218
+ return {
219
+ successCount: typeof r.successCount === "number" ? r.successCount : 0,
220
+ errorCount: typeof r.errorCount === "number" ? r.errorCount : 0,
221
+ warningCount: typeof r.warningCount === "number" ? r.warningCount : 0
222
+ };
223
+ }
224
+
138
225
  // src/server/tools.ts
139
226
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
140
227
  import {
@@ -795,59 +882,13 @@ function compileProtoConditional(input) {
795
882
  return { connections, replaceExisting: input.replaceExisting };
796
883
  }
797
884
 
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
885
  // src/server/tools.ts
845
886
  async function recordedHandler(store, tool, parsedInput, send) {
846
887
  const result = await send();
847
888
  store.record(tool, parsedInput, summarizeResult(result));
848
889
  return result;
849
890
  }
850
- function makeTools(historyStore2) {
891
+ function makeTools(historyStore) {
851
892
  return [
852
893
  {
853
894
  name: "get_canvas_overview",
@@ -901,13 +942,13 @@ function makeTools(historyStore2) {
901
942
  name: "proto_wire",
902
943
  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
944
  schema: ProtoWireInput,
904
- handler: async (input, session2) => {
945
+ handler: async (input, session) => {
905
946
  const parsedInput = input;
906
947
  return recordedHandler(
907
- historyStore2,
948
+ historyStore,
908
949
  "proto_wire",
909
950
  parsedInput,
910
- () => session2.sendCommand("CREATE_REACTIONS", compileProtoWire(parsedInput))
951
+ () => session.sendCommand("CREATE_REACTIONS", compileProtoWire(parsedInput))
911
952
  );
912
953
  }
913
954
  },
@@ -915,13 +956,13 @@ function makeTools(historyStore2) {
915
956
  name: "proto_change_to",
916
957
  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
958
  schema: ProtoChangeToInput,
918
- handler: async (input, session2) => {
959
+ handler: async (input, session) => {
919
960
  const parsedInput = input;
920
961
  return recordedHandler(
921
- historyStore2,
962
+ historyStore,
922
963
  "proto_change_to",
923
964
  parsedInput,
924
- () => session2.sendCommand("CREATE_REACTIONS", compileProtoChangeTo(parsedInput))
965
+ () => session.sendCommand("CREATE_REACTIONS", compileProtoChangeTo(parsedInput))
925
966
  );
926
967
  }
927
968
  },
@@ -929,13 +970,13 @@ function makeTools(historyStore2) {
929
970
  name: "proto_overlay",
930
971
  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
972
  schema: ProtoOverlayInput,
932
- handler: async (input, session2) => {
973
+ handler: async (input, session) => {
933
974
  const parsedInput = input;
934
975
  return recordedHandler(
935
- historyStore2,
976
+ historyStore,
936
977
  "proto_overlay",
937
978
  parsedInput,
938
- () => session2.sendCommand("CREATE_REACTIONS", compileProtoOverlay(parsedInput))
979
+ () => session.sendCommand("CREATE_REACTIONS", compileProtoOverlay(parsedInput))
939
980
  );
940
981
  }
941
982
  },
@@ -943,13 +984,13 @@ function makeTools(historyStore2) {
943
984
  name: "proto_scroll",
944
985
  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
986
  schema: ProtoScrollInput,
946
- handler: async (input, session2) => {
987
+ handler: async (input, session) => {
947
988
  const parsedInput = input;
948
989
  return recordedHandler(
949
- historyStore2,
990
+ historyStore,
950
991
  "proto_scroll",
951
992
  parsedInput,
952
- () => session2.sendCommand("CREATE_REACTIONS", compileProtoScroll(parsedInput))
993
+ () => session.sendCommand("CREATE_REACTIONS", compileProtoScroll(parsedInput))
953
994
  );
954
995
  }
955
996
  },
@@ -957,13 +998,13 @@ function makeTools(historyStore2) {
957
998
  name: "proto_back",
958
999
  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
1000
  schema: ProtoBackInput,
960
- handler: async (input, session2) => {
1001
+ handler: async (input, session) => {
961
1002
  const parsedInput = input;
962
1003
  return recordedHandler(
963
- historyStore2,
1004
+ historyStore,
964
1005
  "proto_back",
965
1006
  parsedInput,
966
- () => session2.sendCommand("CREATE_REACTIONS", compileProtoBack(parsedInput))
1007
+ () => session.sendCommand("CREATE_REACTIONS", compileProtoBack(parsedInput))
967
1008
  );
968
1009
  }
969
1010
  },
@@ -971,13 +1012,13 @@ function makeTools(historyStore2) {
971
1012
  name: "proto_url",
972
1013
  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
1014
  schema: ProtoUrlInput,
974
- handler: async (input, session2) => {
1015
+ handler: async (input, session) => {
975
1016
  const parsedInput = input;
976
1017
  return recordedHandler(
977
- historyStore2,
1018
+ historyStore,
978
1019
  "proto_url",
979
1020
  parsedInput,
980
- () => session2.sendCommand("CREATE_REACTIONS", compileProtoUrl(parsedInput))
1021
+ () => session.sendCommand("CREATE_REACTIONS", compileProtoUrl(parsedInput))
981
1022
  );
982
1023
  }
983
1024
  },
@@ -985,13 +1026,13 @@ function makeTools(historyStore2) {
985
1026
  name: "proto_set_variable",
986
1027
  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
1028
  schema: ProtoSetVariableInput,
988
- handler: async (input, session2) => {
1029
+ handler: async (input, session) => {
989
1030
  const parsedInput = input;
990
1031
  return recordedHandler(
991
- historyStore2,
1032
+ historyStore,
992
1033
  "proto_set_variable",
993
1034
  parsedInput,
994
- () => session2.sendCommand("CREATE_REACTIONS", compileProtoSetVariable(parsedInput))
1035
+ () => session.sendCommand("CREATE_REACTIONS", compileProtoSetVariable(parsedInput))
995
1036
  );
996
1037
  }
997
1038
  },
@@ -999,13 +1040,13 @@ function makeTools(historyStore2) {
999
1040
  name: "proto_toggle_variable",
1000
1041
  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
1042
  schema: ProtoToggleVariableInput,
1002
- handler: async (input, session2) => {
1043
+ handler: async (input, session) => {
1003
1044
  const parsedInput = input;
1004
1045
  return recordedHandler(
1005
- historyStore2,
1046
+ historyStore,
1006
1047
  "proto_toggle_variable",
1007
1048
  parsedInput,
1008
- () => session2.sendCommand("CREATE_REACTIONS", compileProtoToggleVariable(parsedInput))
1049
+ () => session.sendCommand("CREATE_REACTIONS", compileProtoToggleVariable(parsedInput))
1009
1050
  );
1010
1051
  }
1011
1052
  },
@@ -1013,13 +1054,13 @@ function makeTools(historyStore2) {
1013
1054
  name: "proto_conditional",
1014
1055
  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
1056
  schema: ProtoConditionalInput,
1016
- handler: async (input, session2) => {
1057
+ handler: async (input, session) => {
1017
1058
  const parsedInput = input;
1018
1059
  return recordedHandler(
1019
- historyStore2,
1060
+ historyStore,
1020
1061
  "proto_conditional",
1021
1062
  parsedInput,
1022
- () => session2.sendCommand("CREATE_REACTIONS", compileProtoConditional(parsedInput))
1063
+ () => session.sendCommand("CREATE_REACTIONS", compileProtoConditional(parsedInput))
1023
1064
  );
1024
1065
  }
1025
1066
  },
@@ -1029,13 +1070,13 @@ function makeTools(historyStore2) {
1029
1070
  schema: ProtoGetLastHistoryInput,
1030
1071
  handler: async (input) => {
1031
1072
  const { count } = input;
1032
- return { entries: historyStore2.getLast(count) };
1073
+ return { entries: historyStore.getLast(count) };
1033
1074
  }
1034
1075
  }
1035
1076
  ];
1036
1077
  }
1037
- function registerToolHandlers(mcp, session2, historyStore2) {
1038
- const TOOLS = makeTools(historyStore2);
1078
+ function registerToolHandlers(mcp, session, historyStore) {
1079
+ const TOOLS = makeTools(historyStore);
1039
1080
  mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
1040
1081
  tools: TOOLS.map((t) => ({
1041
1082
  name: t.name,
@@ -1053,19 +1094,19 @@ function registerToolHandlers(mcp, session2, historyStore2) {
1053
1094
  return { isError: true, content: [{ type: "text", text: `Invalid input: ${parsed.error.message}` }] };
1054
1095
  }
1055
1096
  try {
1056
- const result = tool.handler !== void 0 ? await tool.handler(parsed.data, session2) : await session2.sendCommand(tool.command, parsed.data);
1097
+ const result = tool.handler !== void 0 ? await tool.handler(parsed.data, session) : await session.sendCommand(tool.command, parsed.data);
1057
1098
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
1058
1099
  } catch (err) {
1059
1100
  return { isError: true, content: [{ type: "text", text: err.message }] };
1060
1101
  }
1061
1102
  });
1062
1103
  }
1063
- function createMcpServer(session2, historyStore2, version) {
1104
+ function createMcpServer(session, historyStore, version) {
1064
1105
  const server = new Server(
1065
1106
  { name: "figma-prototype-mcp", version },
1066
1107
  { capabilities: { tools: {} } }
1067
1108
  );
1068
- registerToolHandlers(server, session2, historyStore2);
1109
+ registerToolHandlers(server, session, historyStore);
1069
1110
  return server;
1070
1111
  }
1071
1112
 
@@ -1109,47 +1150,107 @@ var SseSession = class {
1109
1150
  }
1110
1151
  };
1111
1152
 
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;
1153
+ // src/server/run.ts
1154
+ function parseArgs(argv) {
1155
+ return { mode: argv.includes("--stdio") ? "stdio" : "sse" };
1156
+ }
1157
+ function createDeps() {
1158
+ const pkg = JSON.parse(
1159
+ readFileSync(new URL("../../package.json", import.meta.url), "utf8")
1160
+ );
1161
+ return {
1162
+ session: new PluginSession(),
1163
+ historyStore: new HistoryStore(),
1164
+ version: pkg.version
1165
+ };
1166
+ }
1167
+ function listenWithWs(httpServer, port, session) {
1168
+ attachPluginWebSocket(httpServer, session);
1169
+ return new Promise((resolve) => {
1170
+ httpServer.on("error", (err) => {
1171
+ if (err.code === "EADDRINUSE") {
1172
+ console.error(
1173
+ `[server] port ${port} is already in use \u2014 another figma-prototype-mcp server may be running. Stop it, or set PORT to a free port (and update the plugin manifest if you change it).`
1174
+ );
1175
+ } else {
1176
+ console.error("[server] http server error:", err);
1177
+ }
1178
+ process.exit(1);
1179
+ });
1180
+ httpServer.listen(port, () => {
1181
+ resolve();
1182
+ });
1183
+ });
1184
+ }
1185
+ function logStartup(port, mode2) {
1186
+ if (mode2 === "sse") {
1187
+ console.error(`[server] listening on http://localhost:${port}`);
1188
+ console.error(`[server] MCP SSE endpoint: GET /sse`);
1189
+ } else {
1190
+ console.error(`[server] stdio MCP mode (MCP over stdio; stdout is the JSON-RPC channel)`);
1138
1191
  }
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(
1192
+ console.error(`[server] Plugin WebSocket: ws://localhost:${port}/ws`);
1193
+ console.error(
1146
1194
  `[server] Figma plugin manifest: ${fileURLToPath(new URL("../figma-plugin/manifest.json", import.meta.url))}`
1147
1195
  );
1148
- });
1149
- attachPluginWebSocket(httpServer, session);
1196
+ }
1197
+ async function runSse(deps2, port = Number(process.env.PORT ?? 3e3)) {
1198
+ const sse = new SseSession();
1199
+ const app = express();
1200
+ app.get("/sse", async (_req, res) => {
1201
+ const server = createMcpServer(deps2.session, deps2.historyStore, deps2.version);
1202
+ const transport = new SSEServerTransport("/messages", res);
1203
+ res.on("close", () => sse.clear(transport));
1204
+ await server.connect(transport);
1205
+ const evicted = sse.activate(server, transport);
1206
+ if (evicted) {
1207
+ console.error(
1208
+ "[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)."
1209
+ );
1210
+ }
1211
+ });
1212
+ app.post("/messages", express.json(), async (req, res) => {
1213
+ const t = sse.get(String(req.query.sessionId ?? ""));
1214
+ if (!t) {
1215
+ res.status(400).send("unknown session");
1216
+ return;
1217
+ }
1218
+ await t.handlePostMessage(req, res, req.body);
1219
+ });
1220
+ const httpServer = http.createServer(app);
1221
+ await listenWithWs(httpServer, port, deps2.session);
1222
+ logStartup(port, "sse");
1223
+ return httpServer;
1224
+ }
1225
+ async function runStdio(deps2, port = Number(process.env.PORT ?? 3e3), transport = new StdioServerTransport()) {
1226
+ const httpServer = http.createServer();
1227
+ await listenWithWs(httpServer, port, deps2.session);
1228
+ const mcpServer = createMcpServer(deps2.session, deps2.historyStore, deps2.version);
1229
+ mcpServer.onclose = () => {
1230
+ try {
1231
+ httpServer.close();
1232
+ } catch {
1233
+ }
1234
+ };
1235
+ await mcpServer.connect(transport).catch((err) => {
1236
+ httpServer.close();
1237
+ throw err;
1238
+ });
1239
+ logStartup(port, "stdio");
1240
+ return { httpServer, mcpServer };
1241
+ }
1242
+
1243
+ // src/server/index.ts
1150
1244
  process.on("unhandledRejection", (err) => {
1151
1245
  console.error("[server] unhandledRejection:", err);
1152
1246
  });
1153
1247
  process.on("uncaughtException", (err) => {
1154
1248
  console.error("[server] uncaughtException:", err);
1155
1249
  });
1250
+ var { mode } = parseArgs(process.argv.slice(2));
1251
+ var deps = createDeps();
1252
+ if (mode === "stdio") {
1253
+ void runStdio(deps);
1254
+ } else {
1255
+ void runSse(deps);
1256
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "figma-prototype-mcp",
3
- "version": "0.30.1",
3
+ "version": "0.30.3",
4
4
  "description": "MCP server for creating Figma prototype interactions via natural language",
5
5
  "license": "MIT",
6
6
  "author": "smooeach",