figma-prototype-mcp 0.30.2 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/dist/server/index.js +289 -116
- package/package.json +1 -1
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")*
|
|
@@ -166,9 +172,14 @@ To bypass the preset system (e.g. for `MOVE_IN`/`PUSH`/`SLIDE_*` directional tra
|
|
|
166
172
|
| `create_reactions` | **Write**: batch create prototype reactions. Each connection's `action` picks between Navigate To (action.type=navigate, targetFrameId), Scroll To (scroll, targetNodeId), Open Overlay (overlay, targetFrameId), Close Overlay (close, no destination), Back (back, no destination), Open URL (url, url, openInNewTab?), Swap Overlay (swap_overlay, targetFrameId), and Change To (change_to, targetVariantId — switch a component instance to a sibling variant). Triggers: string shortcuts `ON_CLICK` (default) / `ON_HOVER` / `ON_PRESS` / `AFTER_TIMEOUT` (with top-level `afterTimeoutSeconds`); object form additionally supports `{type:"ON_DRAG"}`, `{type:"MOUSE_UP"\|"MOUSE_DOWN", delay?}`, `{type:"MOUSE_ENTER"\|"MOUSE_LEAVE", delay?, deprecatedVersion?}`, `{type:"ON_KEY_DOWN", device, keyCodes}`, `{type:"ON_MEDIA_HIT", mediaHitTime}`, `{type:"ON_MEDIA_END"}`, and a self-contained `{type:"AFTER_TIMEOUT", timeout}`. Transitions: string shortcuts `INSTANT` / `DISSOLVE` / `SMART_ANIMATE`, simple object form (DISSOLVE/SMART_ANIMATE/SCROLL_ANIMATE + duration + easing), and directional form (`MOVE_IN`/`MOVE_OUT`/`PUSH`/`SLIDE_IN`/`SLIDE_OUT` × `direction` LEFT/RIGHT/TOP/BOTTOM × optional `matchLayers`). NODE actions (navigate / scroll / overlay / swap_overlay) also accept optional `resetScrollPosition?: boolean` — `false` to keep the destination frame's previous scroll position, `true` to reset to top. Omit to use Figma's runtime default. Each succeeds or fails independently; scroll targets without a scrollable ancestor return a `warning`. A `conditional` action wraps an IF/ELSE: `{ type: "conditional", condition, then: [action, ...], else?: [action, ...] }` where `condition` is a single comparison `{ variable, operator: "==" \| "!=" \| "<" \| "<=" \| ">" \| ">=", value }` or a one-level compound `{ all: [comparison, ...] }` (AND) / `{ any: [comparison, ...] }` (OR) over ≥2 comparisons. The `variable` is the name of a local Figma variable (BOOLEAN/FLOAT/STRING); plugin resolves to id. Nested conditionals are rejected. Branches use any of the 7 non-conditional action types. Variable mutations: `set_variable` action assigns a literal (`{ type: "set_variable", variable, value }`; value is boolean/number/string matching the variable's resolvedType; valid both at top-level and inside conditional then/else); `toggle_variable` action flips a BOOLEAN variable (`{ type: "toggle_variable", variable }`; top-level only — desugars to CONDITIONAL+2 SET_VARIABLE; nested-rejected to preserve the no-nesting rule). Both reference local Figma variables by name. `list_reactions` round-trips toggle_variable via pattern detection on the stored CONDITIONAL. COLOR variables accept hex string values (`"#RRGGBB"` or `"#RRGGBBAA"` — case insensitive); the plugin validates format and parses to Figma's RGB(A) shape internally. `list_reactions` echoes COLOR `value` back as a hex string. Conditional comparison against COLOR variables is rejected (use BOOLEAN/FLOAT/STRING for conditions). |
|
|
167
173
|
| `list_reactions` | Inspect existing reactions on a node |
|
|
168
174
|
| `get_prototype_flow` | **Read** the whole prototype interaction graph of a page in one call: frames (with `isStartFrame`) + every wired interaction (`frameId`, `sourceNodeId`, `trigger`, decoded `action` — same shape as `list_reactions`). Page-scoped (optional `pageId`); `limit` caps results. Use to see what is already wired before adding more. |
|
|
175
|
+
| `export_interactions` | Export the wired interactions of designated **completed screens** as a canonical, framework-agnostic **JSON spec** for developer handoff. Input `{ screens: string[] (frame node IDs), pageId? }`. Each interaction is a typed action (navigate / scrollTo / openOverlay / swapOverlay / closeOverlay / back / openUrl / setVariable / toggleVariable / changeVariant / conditional); unmappable actions are flagged in `unsupported[]`, unknown screen IDs in `missingScreens[]`. Read-only — developers (or Claude) derive framework code from the JSON. |
|
|
169
176
|
| `clear_reactions` | Remove reactions from one or more nodes |
|
|
170
177
|
| `set_frame_scroll` | **Write**: configure scroll-related properties on one or more FRAME nodes. Each entry accepts optional `direction` (`NONE` / `HORIZONTAL` / `VERTICAL` / `BOTH`) and/or optional `fixedChildren` (number of top-most children to fix when scrolling — Figma's sticky-header model fixes the first N children in z-order; layer panel order matters). At least one of `direction` or `fixedChildren` must be provided per entry. Each frame succeeds or fails independently; response includes `applied` array naming which fields were set. |
|
|
171
178
|
|
|
179
|
+
### Developer handoff: export interactions as JSON
|
|
180
|
+
|
|
181
|
+
`export_interactions` turns the prototype interactions you wired into a **language-neutral JSON spec** — a faithful map of "what each control does" (trigger → actions) for the screens you designate as done. It is intentionally framework-agnostic: it describes the behavior (navigate, set variable, conditional, …) using Figma's own vocabulary, and a developer (or Claude, on request) generates React/Vue/state-machine code from it. It does NOT emit framework code or visual UI — pair it with Figma Dev Mode / Code Connect for the UI.
|
|
182
|
+
|
|
172
183
|
## Troubleshooting
|
|
173
184
|
|
|
174
185
|
| Symptom | Cause / fix |
|
|
@@ -180,6 +191,7 @@ To bypass the preset system (e.g. for `MOVE_IN`/`PUSH`/`SLIDE_*` directional tra
|
|
|
180
191
|
| 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
192
|
| `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
193
|
| Cryptic crash on startup (syntax / module errors) | Check your Node version — this needs **Node ≥ 18** (`node -v`). |
|
|
194
|
+
| 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
195
|
|
|
184
196
|
## Known limitations
|
|
185
197
|
|
package/dist/server/index.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
// src/server/
|
|
4
|
-
import
|
|
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";
|
|
@@ -146,9 +148,9 @@ var PluginSession = class {
|
|
|
146
148
|
// src/server/plugin-ws.ts
|
|
147
149
|
import { WebSocketServer } from "ws";
|
|
148
150
|
var PLUGIN_PATH = "/ws";
|
|
149
|
-
function attachPluginWebSocket(
|
|
151
|
+
function attachPluginWebSocket(httpServer, session) {
|
|
150
152
|
const wss = new WebSocketServer({ noServer: true });
|
|
151
|
-
|
|
153
|
+
httpServer.on("upgrade", (req, socket, head) => {
|
|
152
154
|
if (req.url === PLUGIN_PATH) {
|
|
153
155
|
wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req));
|
|
154
156
|
} else {
|
|
@@ -156,7 +158,7 @@ function attachPluginWebSocket(httpServer2, session2) {
|
|
|
156
158
|
}
|
|
157
159
|
});
|
|
158
160
|
wss.on("connection", (ws) => {
|
|
159
|
-
|
|
161
|
+
session.setActive(ws);
|
|
160
162
|
ws.on("message", (raw) => {
|
|
161
163
|
let msg;
|
|
162
164
|
try {
|
|
@@ -165,15 +167,61 @@ function attachPluginWebSocket(httpServer2, session2) {
|
|
|
165
167
|
return;
|
|
166
168
|
}
|
|
167
169
|
if (typeof msg === "object" && msg !== null && msg.type === "response") {
|
|
168
|
-
|
|
170
|
+
session.handleResponse(msg);
|
|
169
171
|
}
|
|
170
172
|
});
|
|
171
|
-
ws.on("close", () =>
|
|
172
|
-
ws.on("error", () =>
|
|
173
|
+
ws.on("close", () => session.clearActive(ws));
|
|
174
|
+
ws.on("error", () => session.clearActive(ws));
|
|
173
175
|
});
|
|
174
176
|
return wss;
|
|
175
177
|
}
|
|
176
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
|
+
|
|
177
225
|
// src/server/tools.ts
|
|
178
226
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
179
227
|
import {
|
|
@@ -217,8 +265,12 @@ var GetCanvasOverviewInput = z.object({
|
|
|
217
265
|
});
|
|
218
266
|
var GetPrototypeFlowInput = z.object({
|
|
219
267
|
pageId: z.string().optional(),
|
|
220
|
-
limit: z.number().int().positive().max(
|
|
268
|
+
limit: z.number().int().positive().max(5e3).default(500)
|
|
221
269
|
});
|
|
270
|
+
var ExportInteractionsInput = z.object({
|
|
271
|
+
screens: z.array(z.string()).min(1),
|
|
272
|
+
pageId: z.string().optional()
|
|
273
|
+
}).strict();
|
|
222
274
|
var FindNodesInput = z.object({
|
|
223
275
|
query: z.string().min(1),
|
|
224
276
|
nodeTypes: z.array(z.string()).optional(),
|
|
@@ -834,49 +886,99 @@ function compileProtoConditional(input) {
|
|
|
834
886
|
return { connections, replaceExisting: input.replaceExisting };
|
|
835
887
|
}
|
|
836
888
|
|
|
837
|
-
// src/server/
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
889
|
+
// src/server/interaction-spec.ts
|
|
890
|
+
function mapCondition(c) {
|
|
891
|
+
if (c && typeof c === "object") {
|
|
892
|
+
if (Array.isArray(c.all)) return { all: c.all.map(mapCondition) };
|
|
893
|
+
if (Array.isArray(c.any)) return { any: c.any.map(mapCondition) };
|
|
894
|
+
if ("variable" in c && "operator" in c) {
|
|
895
|
+
return { variable: c.variable, operator: c.operator, value: c.value };
|
|
896
|
+
}
|
|
897
|
+
if ("raw" in c) return { raw: c.raw };
|
|
844
898
|
}
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
input,
|
|
852
|
-
result
|
|
853
|
-
};
|
|
854
|
-
this.buffer.push(entry);
|
|
855
|
-
if (this.buffer.length > this.capacity) this.buffer.shift();
|
|
856
|
-
return entry;
|
|
899
|
+
return { raw: c };
|
|
900
|
+
}
|
|
901
|
+
function mapAction(a, source, unsupported) {
|
|
902
|
+
if (!a || typeof a !== "object") {
|
|
903
|
+
unsupported.push({ source, reason: "non-object action", raw: a });
|
|
904
|
+
return null;
|
|
857
905
|
}
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
906
|
+
switch (a.type) {
|
|
907
|
+
case "BACK":
|
|
908
|
+
return { type: "back" };
|
|
909
|
+
case "CLOSE":
|
|
910
|
+
return { type: "closeOverlay" };
|
|
911
|
+
case "URL":
|
|
912
|
+
return { type: "openUrl", url: a.url, openInNewTab: a.openInNewTab };
|
|
913
|
+
case "set_variable":
|
|
914
|
+
return { type: "setVariable", variable: a.variable, value: a.value };
|
|
915
|
+
case "toggle_variable":
|
|
916
|
+
return { type: "toggleVariable", variable: a.variable };
|
|
917
|
+
case "NODE": {
|
|
918
|
+
const to = { id: a.destinationId ?? null, name: a.destinationName ?? null };
|
|
919
|
+
switch (a.navigation) {
|
|
920
|
+
case "NAVIGATE":
|
|
921
|
+
return { type: "navigate", to, transition: a.transition };
|
|
922
|
+
case "SCROLL_TO":
|
|
923
|
+
return { type: "scrollTo", to, transition: a.transition };
|
|
924
|
+
case "OVERLAY":
|
|
925
|
+
return { type: "openOverlay", to, transition: a.transition };
|
|
926
|
+
case "SWAP":
|
|
927
|
+
return { type: "swapOverlay", to, transition: a.transition };
|
|
928
|
+
case "CHANGE_TO":
|
|
929
|
+
return { type: "changeVariant", to };
|
|
930
|
+
default:
|
|
931
|
+
unsupported.push({ source, reason: `unknown navigation: ${String(a.navigation)}`, raw: a });
|
|
932
|
+
return null;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
case "CONDITIONAL": {
|
|
936
|
+
if (a.condition === void 0 || a.then === void 0) {
|
|
937
|
+
unsupported.push({ source, reason: "non-standard conditional", raw: a });
|
|
938
|
+
return null;
|
|
939
|
+
}
|
|
940
|
+
const then = (Array.isArray(a.then) ? a.then : []).map((x) => mapAction(x, source, unsupported)).filter((x) => x !== null);
|
|
941
|
+
const elseActions = a.else !== void 0 ? (Array.isArray(a.else) ? a.else : []).map((x) => mapAction(x, source, unsupported)).filter((x) => x !== null) : void 0;
|
|
942
|
+
return { type: "conditional", if: mapCondition(a.condition), then, else: elseActions };
|
|
943
|
+
}
|
|
944
|
+
default:
|
|
945
|
+
unsupported.push({ source, reason: `unknown action type: ${String(a.type)}`, raw: a });
|
|
946
|
+
return null;
|
|
866
947
|
}
|
|
867
|
-
|
|
868
|
-
|
|
948
|
+
}
|
|
949
|
+
function buildInteractionSpec(flow, screens) {
|
|
950
|
+
const frameById = new Map((flow.frames ?? []).map((f) => [f.id, f]));
|
|
951
|
+
const byFrame = /* @__PURE__ */ new Map();
|
|
952
|
+
for (const it of flow.interactions ?? []) {
|
|
953
|
+
if (it.frameId == null) continue;
|
|
954
|
+
const arr = byFrame.get(it.frameId) ?? [];
|
|
955
|
+
arr.push(it);
|
|
956
|
+
byFrame.set(it.frameId, arr);
|
|
869
957
|
}
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
958
|
+
const unsupported = [];
|
|
959
|
+
const screensOut = [];
|
|
960
|
+
const missingScreens = [];
|
|
961
|
+
for (const id of screens) {
|
|
962
|
+
const frame = frameById.get(id);
|
|
963
|
+
if (!frame) {
|
|
964
|
+
missingScreens.push(id);
|
|
965
|
+
continue;
|
|
966
|
+
}
|
|
967
|
+
const interactions = (byFrame.get(id) ?? []).map((it) => {
|
|
968
|
+
const source = { id: it.sourceNodeId, name: it.sourceNodeName ?? null };
|
|
969
|
+
const mapped = mapAction(it.action, source, unsupported);
|
|
970
|
+
return { source, trigger: it.trigger, actions: mapped ? [mapped] : [] };
|
|
971
|
+
});
|
|
972
|
+
screensOut.push({ id, name: frame.name ?? null, interactions });
|
|
874
973
|
}
|
|
875
|
-
const r = raw;
|
|
876
974
|
return {
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
975
|
+
schemaVersion: "1.0",
|
|
976
|
+
page: flow.page ?? { id: "", name: "" },
|
|
977
|
+
screens: screensOut,
|
|
978
|
+
requestedScreens: screens,
|
|
979
|
+
missingScreens,
|
|
980
|
+
unsupported,
|
|
981
|
+
truncated: Boolean(flow.truncated)
|
|
880
982
|
};
|
|
881
983
|
}
|
|
882
984
|
|
|
@@ -886,7 +988,7 @@ async function recordedHandler(store, tool, parsedInput, send) {
|
|
|
886
988
|
store.record(tool, parsedInput, summarizeResult(result));
|
|
887
989
|
return result;
|
|
888
990
|
}
|
|
889
|
-
function makeTools(
|
|
991
|
+
function makeTools(historyStore) {
|
|
890
992
|
return [
|
|
891
993
|
{
|
|
892
994
|
name: "get_canvas_overview",
|
|
@@ -900,6 +1002,17 @@ function makeTools(historyStore2) {
|
|
|
900
1002
|
schema: GetPrototypeFlowInput,
|
|
901
1003
|
command: "GET_PROTOTYPE_FLOW"
|
|
902
1004
|
},
|
|
1005
|
+
{
|
|
1006
|
+
name: "export_interactions",
|
|
1007
|
+
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. Export the wired prototype interactions of the given completed screens as a canonical, framework-agnostic JSON spec for developer handoff. Input `{ screens: string[] (frame node IDs), pageId? }`. Returns `{ schemaVersion, page, screens:[{id,name,interactions:[{source,trigger,actions}]}], requestedScreens, missingScreens, unsupported, truncated }`. Each action is a typed entry (navigate / scrollTo / openOverlay / swapOverlay / closeOverlay / back / openUrl / setVariable / toggleVariable / changeVariant / conditional). This is a READ/handoff tool \u2014 developers (or you) derive framework code (React, etc.) from the JSON; it does NOT generate framework or UI code.",
|
|
1008
|
+
schema: ExportInteractionsInput,
|
|
1009
|
+
handler: async (input, session) => {
|
|
1010
|
+
const { screens, pageId } = input;
|
|
1011
|
+
const params = pageId ? { pageId, limit: 5e3 } : { limit: 5e3 };
|
|
1012
|
+
const flow = await session.sendCommand("GET_PROTOTYPE_FLOW", params);
|
|
1013
|
+
return buildInteractionSpec(flow, screens);
|
|
1014
|
+
}
|
|
1015
|
+
},
|
|
903
1016
|
{
|
|
904
1017
|
name: "find_nodes",
|
|
905
1018
|
description: "Search nodes on the current page (or document) by name substring, with optional type filter.",
|
|
@@ -940,13 +1053,13 @@ function makeTools(historyStore2) {
|
|
|
940
1053
|
name: "proto_wire",
|
|
941
1054
|
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.",
|
|
942
1055
|
schema: ProtoWireInput,
|
|
943
|
-
handler: async (input,
|
|
1056
|
+
handler: async (input, session) => {
|
|
944
1057
|
const parsedInput = input;
|
|
945
1058
|
return recordedHandler(
|
|
946
|
-
|
|
1059
|
+
historyStore,
|
|
947
1060
|
"proto_wire",
|
|
948
1061
|
parsedInput,
|
|
949
|
-
() =>
|
|
1062
|
+
() => session.sendCommand("CREATE_REACTIONS", compileProtoWire(parsedInput))
|
|
950
1063
|
);
|
|
951
1064
|
}
|
|
952
1065
|
},
|
|
@@ -954,13 +1067,13 @@ function makeTools(historyStore2) {
|
|
|
954
1067
|
name: "proto_change_to",
|
|
955
1068
|
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.",
|
|
956
1069
|
schema: ProtoChangeToInput,
|
|
957
|
-
handler: async (input,
|
|
1070
|
+
handler: async (input, session) => {
|
|
958
1071
|
const parsedInput = input;
|
|
959
1072
|
return recordedHandler(
|
|
960
|
-
|
|
1073
|
+
historyStore,
|
|
961
1074
|
"proto_change_to",
|
|
962
1075
|
parsedInput,
|
|
963
|
-
() =>
|
|
1076
|
+
() => session.sendCommand("CREATE_REACTIONS", compileProtoChangeTo(parsedInput))
|
|
964
1077
|
);
|
|
965
1078
|
}
|
|
966
1079
|
},
|
|
@@ -968,13 +1081,13 @@ function makeTools(historyStore2) {
|
|
|
968
1081
|
name: "proto_overlay",
|
|
969
1082
|
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.`,
|
|
970
1083
|
schema: ProtoOverlayInput,
|
|
971
|
-
handler: async (input,
|
|
1084
|
+
handler: async (input, session) => {
|
|
972
1085
|
const parsedInput = input;
|
|
973
1086
|
return recordedHandler(
|
|
974
|
-
|
|
1087
|
+
historyStore,
|
|
975
1088
|
"proto_overlay",
|
|
976
1089
|
parsedInput,
|
|
977
|
-
() =>
|
|
1090
|
+
() => session.sendCommand("CREATE_REACTIONS", compileProtoOverlay(parsedInput))
|
|
978
1091
|
);
|
|
979
1092
|
}
|
|
980
1093
|
},
|
|
@@ -982,13 +1095,13 @@ function makeTools(historyStore2) {
|
|
|
982
1095
|
name: "proto_scroll",
|
|
983
1096
|
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.",
|
|
984
1097
|
schema: ProtoScrollInput,
|
|
985
|
-
handler: async (input,
|
|
1098
|
+
handler: async (input, session) => {
|
|
986
1099
|
const parsedInput = input;
|
|
987
1100
|
return recordedHandler(
|
|
988
|
-
|
|
1101
|
+
historyStore,
|
|
989
1102
|
"proto_scroll",
|
|
990
1103
|
parsedInput,
|
|
991
|
-
() =>
|
|
1104
|
+
() => session.sendCommand("CREATE_REACTIONS", compileProtoScroll(parsedInput))
|
|
992
1105
|
);
|
|
993
1106
|
}
|
|
994
1107
|
},
|
|
@@ -996,13 +1109,13 @@ function makeTools(historyStore2) {
|
|
|
996
1109
|
name: "proto_back",
|
|
997
1110
|
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.",
|
|
998
1111
|
schema: ProtoBackInput,
|
|
999
|
-
handler: async (input,
|
|
1112
|
+
handler: async (input, session) => {
|
|
1000
1113
|
const parsedInput = input;
|
|
1001
1114
|
return recordedHandler(
|
|
1002
|
-
|
|
1115
|
+
historyStore,
|
|
1003
1116
|
"proto_back",
|
|
1004
1117
|
parsedInput,
|
|
1005
|
-
() =>
|
|
1118
|
+
() => session.sendCommand("CREATE_REACTIONS", compileProtoBack(parsedInput))
|
|
1006
1119
|
);
|
|
1007
1120
|
}
|
|
1008
1121
|
},
|
|
@@ -1010,13 +1123,13 @@ function makeTools(historyStore2) {
|
|
|
1010
1123
|
name: "proto_url",
|
|
1011
1124
|
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.",
|
|
1012
1125
|
schema: ProtoUrlInput,
|
|
1013
|
-
handler: async (input,
|
|
1126
|
+
handler: async (input, session) => {
|
|
1014
1127
|
const parsedInput = input;
|
|
1015
1128
|
return recordedHandler(
|
|
1016
|
-
|
|
1129
|
+
historyStore,
|
|
1017
1130
|
"proto_url",
|
|
1018
1131
|
parsedInput,
|
|
1019
|
-
() =>
|
|
1132
|
+
() => session.sendCommand("CREATE_REACTIONS", compileProtoUrl(parsedInput))
|
|
1020
1133
|
);
|
|
1021
1134
|
}
|
|
1022
1135
|
},
|
|
@@ -1024,13 +1137,13 @@ function makeTools(historyStore2) {
|
|
|
1024
1137
|
name: "proto_set_variable",
|
|
1025
1138
|
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.',
|
|
1026
1139
|
schema: ProtoSetVariableInput,
|
|
1027
|
-
handler: async (input,
|
|
1140
|
+
handler: async (input, session) => {
|
|
1028
1141
|
const parsedInput = input;
|
|
1029
1142
|
return recordedHandler(
|
|
1030
|
-
|
|
1143
|
+
historyStore,
|
|
1031
1144
|
"proto_set_variable",
|
|
1032
1145
|
parsedInput,
|
|
1033
|
-
() =>
|
|
1146
|
+
() => session.sendCommand("CREATE_REACTIONS", compileProtoSetVariable(parsedInput))
|
|
1034
1147
|
);
|
|
1035
1148
|
}
|
|
1036
1149
|
},
|
|
@@ -1038,13 +1151,13 @@ function makeTools(historyStore2) {
|
|
|
1038
1151
|
name: "proto_toggle_variable",
|
|
1039
1152
|
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).",
|
|
1040
1153
|
schema: ProtoToggleVariableInput,
|
|
1041
|
-
handler: async (input,
|
|
1154
|
+
handler: async (input, session) => {
|
|
1042
1155
|
const parsedInput = input;
|
|
1043
1156
|
return recordedHandler(
|
|
1044
|
-
|
|
1157
|
+
historyStore,
|
|
1045
1158
|
"proto_toggle_variable",
|
|
1046
1159
|
parsedInput,
|
|
1047
|
-
() =>
|
|
1160
|
+
() => session.sendCommand("CREATE_REACTIONS", compileProtoToggleVariable(parsedInput))
|
|
1048
1161
|
);
|
|
1049
1162
|
}
|
|
1050
1163
|
},
|
|
@@ -1052,13 +1165,13 @@ function makeTools(historyStore2) {
|
|
|
1052
1165
|
name: "proto_conditional",
|
|
1053
1166
|
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.",
|
|
1054
1167
|
schema: ProtoConditionalInput,
|
|
1055
|
-
handler: async (input,
|
|
1168
|
+
handler: async (input, session) => {
|
|
1056
1169
|
const parsedInput = input;
|
|
1057
1170
|
return recordedHandler(
|
|
1058
|
-
|
|
1171
|
+
historyStore,
|
|
1059
1172
|
"proto_conditional",
|
|
1060
1173
|
parsedInput,
|
|
1061
|
-
() =>
|
|
1174
|
+
() => session.sendCommand("CREATE_REACTIONS", compileProtoConditional(parsedInput))
|
|
1062
1175
|
);
|
|
1063
1176
|
}
|
|
1064
1177
|
},
|
|
@@ -1068,13 +1181,13 @@ function makeTools(historyStore2) {
|
|
|
1068
1181
|
schema: ProtoGetLastHistoryInput,
|
|
1069
1182
|
handler: async (input) => {
|
|
1070
1183
|
const { count } = input;
|
|
1071
|
-
return { entries:
|
|
1184
|
+
return { entries: historyStore.getLast(count) };
|
|
1072
1185
|
}
|
|
1073
1186
|
}
|
|
1074
1187
|
];
|
|
1075
1188
|
}
|
|
1076
|
-
function registerToolHandlers(mcp,
|
|
1077
|
-
const TOOLS = makeTools(
|
|
1189
|
+
function registerToolHandlers(mcp, session, historyStore) {
|
|
1190
|
+
const TOOLS = makeTools(historyStore);
|
|
1078
1191
|
mcp.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
1079
1192
|
tools: TOOLS.map((t) => ({
|
|
1080
1193
|
name: t.name,
|
|
@@ -1092,19 +1205,19 @@ function registerToolHandlers(mcp, session2, historyStore2) {
|
|
|
1092
1205
|
return { isError: true, content: [{ type: "text", text: `Invalid input: ${parsed.error.message}` }] };
|
|
1093
1206
|
}
|
|
1094
1207
|
try {
|
|
1095
|
-
const result = tool.handler !== void 0 ? await tool.handler(parsed.data,
|
|
1208
|
+
const result = tool.handler !== void 0 ? await tool.handler(parsed.data, session) : await session.sendCommand(tool.command, parsed.data);
|
|
1096
1209
|
return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
|
|
1097
1210
|
} catch (err) {
|
|
1098
1211
|
return { isError: true, content: [{ type: "text", text: err.message }] };
|
|
1099
1212
|
}
|
|
1100
1213
|
});
|
|
1101
1214
|
}
|
|
1102
|
-
function createMcpServer(
|
|
1215
|
+
function createMcpServer(session, historyStore, version) {
|
|
1103
1216
|
const server = new Server(
|
|
1104
1217
|
{ name: "figma-prototype-mcp", version },
|
|
1105
1218
|
{ capabilities: { tools: {} } }
|
|
1106
1219
|
);
|
|
1107
|
-
registerToolHandlers(server,
|
|
1220
|
+
registerToolHandlers(server, session, historyStore);
|
|
1108
1221
|
return server;
|
|
1109
1222
|
}
|
|
1110
1223
|
|
|
@@ -1148,47 +1261,107 @@ var SseSession = class {
|
|
|
1148
1261
|
}
|
|
1149
1262
|
};
|
|
1150
1263
|
|
|
1151
|
-
// src/server/
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
})
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1264
|
+
// src/server/run.ts
|
|
1265
|
+
function parseArgs(argv) {
|
|
1266
|
+
return { mode: argv.includes("--stdio") ? "stdio" : "sse" };
|
|
1267
|
+
}
|
|
1268
|
+
function createDeps() {
|
|
1269
|
+
const pkg = JSON.parse(
|
|
1270
|
+
readFileSync(new URL("../../package.json", import.meta.url), "utf8")
|
|
1271
|
+
);
|
|
1272
|
+
return {
|
|
1273
|
+
session: new PluginSession(),
|
|
1274
|
+
historyStore: new HistoryStore(),
|
|
1275
|
+
version: pkg.version
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
function listenWithWs(httpServer, port, session) {
|
|
1279
|
+
attachPluginWebSocket(httpServer, session);
|
|
1280
|
+
return new Promise((resolve) => {
|
|
1281
|
+
httpServer.on("error", (err) => {
|
|
1282
|
+
if (err.code === "EADDRINUSE") {
|
|
1283
|
+
console.error(
|
|
1284
|
+
`[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).`
|
|
1285
|
+
);
|
|
1286
|
+
} else {
|
|
1287
|
+
console.error("[server] http server error:", err);
|
|
1288
|
+
}
|
|
1289
|
+
process.exit(1);
|
|
1290
|
+
});
|
|
1291
|
+
httpServer.listen(port, () => {
|
|
1292
|
+
resolve();
|
|
1293
|
+
});
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
function logStartup(port, mode2) {
|
|
1297
|
+
if (mode2 === "sse") {
|
|
1298
|
+
console.error(`[server] listening on http://localhost:${port}`);
|
|
1299
|
+
console.error(`[server] MCP SSE endpoint: GET /sse`);
|
|
1300
|
+
} else {
|
|
1301
|
+
console.error(`[server] stdio MCP mode (MCP over stdio; stdout is the JSON-RPC channel)`);
|
|
1177
1302
|
}
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
var httpServer = app.listen(PORT, () => {
|
|
1181
|
-
console.log(`[server] listening on http://localhost:${PORT}`);
|
|
1182
|
-
console.log(`[server] MCP SSE endpoint: GET /sse`);
|
|
1183
|
-
console.log(`[server] Plugin WebSocket: ws://localhost:${PORT}/ws`);
|
|
1184
|
-
console.log(
|
|
1303
|
+
console.error(`[server] Plugin WebSocket: ws://localhost:${port}/ws`);
|
|
1304
|
+
console.error(
|
|
1185
1305
|
`[server] Figma plugin manifest: ${fileURLToPath(new URL("../figma-plugin/manifest.json", import.meta.url))}`
|
|
1186
1306
|
);
|
|
1187
|
-
}
|
|
1188
|
-
|
|
1307
|
+
}
|
|
1308
|
+
async function runSse(deps2, port = Number(process.env.PORT ?? 3e3)) {
|
|
1309
|
+
const sse = new SseSession();
|
|
1310
|
+
const app = express();
|
|
1311
|
+
app.get("/sse", async (_req, res) => {
|
|
1312
|
+
const server = createMcpServer(deps2.session, deps2.historyStore, deps2.version);
|
|
1313
|
+
const transport = new SSEServerTransport("/messages", res);
|
|
1314
|
+
res.on("close", () => sse.clear(transport));
|
|
1315
|
+
await server.connect(transport);
|
|
1316
|
+
const evicted = sse.activate(server, transport);
|
|
1317
|
+
if (evicted) {
|
|
1318
|
+
console.error(
|
|
1319
|
+
"[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)."
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1322
|
+
});
|
|
1323
|
+
app.post("/messages", express.json(), async (req, res) => {
|
|
1324
|
+
const t = sse.get(String(req.query.sessionId ?? ""));
|
|
1325
|
+
if (!t) {
|
|
1326
|
+
res.status(400).send("unknown session");
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
await t.handlePostMessage(req, res, req.body);
|
|
1330
|
+
});
|
|
1331
|
+
const httpServer = http.createServer(app);
|
|
1332
|
+
await listenWithWs(httpServer, port, deps2.session);
|
|
1333
|
+
logStartup(port, "sse");
|
|
1334
|
+
return httpServer;
|
|
1335
|
+
}
|
|
1336
|
+
async function runStdio(deps2, port = Number(process.env.PORT ?? 3e3), transport = new StdioServerTransport()) {
|
|
1337
|
+
const httpServer = http.createServer();
|
|
1338
|
+
await listenWithWs(httpServer, port, deps2.session);
|
|
1339
|
+
const mcpServer = createMcpServer(deps2.session, deps2.historyStore, deps2.version);
|
|
1340
|
+
mcpServer.onclose = () => {
|
|
1341
|
+
try {
|
|
1342
|
+
httpServer.close();
|
|
1343
|
+
} catch {
|
|
1344
|
+
}
|
|
1345
|
+
};
|
|
1346
|
+
await mcpServer.connect(transport).catch((err) => {
|
|
1347
|
+
httpServer.close();
|
|
1348
|
+
throw err;
|
|
1349
|
+
});
|
|
1350
|
+
logStartup(port, "stdio");
|
|
1351
|
+
return { httpServer, mcpServer };
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// src/server/index.ts
|
|
1189
1355
|
process.on("unhandledRejection", (err) => {
|
|
1190
1356
|
console.error("[server] unhandledRejection:", err);
|
|
1191
1357
|
});
|
|
1192
1358
|
process.on("uncaughtException", (err) => {
|
|
1193
1359
|
console.error("[server] uncaughtException:", err);
|
|
1194
1360
|
});
|
|
1361
|
+
var { mode } = parseArgs(process.argv.slice(2));
|
|
1362
|
+
var deps = createDeps();
|
|
1363
|
+
if (mode === "stdio") {
|
|
1364
|
+
void runStdio(deps);
|
|
1365
|
+
} else {
|
|
1366
|
+
void runSse(deps);
|
|
1367
|
+
}
|