figma-rust-mcp 4.1.0 → 5.0.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 CHANGED
@@ -18,7 +18,7 @@ Enables AI agents (Google Antigravity, Claude Code, Cursor, Windsurf, VS Code, Z
18
18
  - **Batch Asset Extractor (`figma_export_assets` / `figma_export_asset`)**: Extracts SVG icons and raster images directly to project folders (`outputPath`) with auto-generated TypeScript barrel exports (`index.ts`) — zero chat token bloating.
19
19
  - **Sandboxed JS Drawing Engine (`figma_write`)**: Powered by **Boa** (pure Rust ECMAScript engine) with built-in asset resolvers and 7 icon packs (*Ionicons, Lucide, Tabler, Bootstrap, Fluent, Phosphor*).
20
20
  - **Multi-Tab / Multi-Session Support**: Seamlessly connects to multiple open Figma files simultaneously.
21
- - **100% Localhost Privacy**: All communication stays strictly on `127.0.0.1:38451`.
21
+ - **100% Localhost Privacy**: All communication stays strictly on `127.0.0.1:41730`.
22
22
 
23
23
  ---
24
24
 
@@ -36,7 +36,7 @@ Enables AI agents (Google Antigravity, Claude Code, Cursor, Windsurf, VS Code, Z
36
36
  │ • Sandboxed JS Runtime (Boa ECMAScript Engine) │
37
37
  │ • Binary MessagePack & Progressive Subtree Chunk Receiver │
38
38
  └───────────────▲────────────────────────────────────────▲───────────────┘
39
- │ (ws://127.0.0.1:38451/ws) │ (http://127.0.0.1:38451/mcp)
39
+ │ (ws://127.0.0.1:41730/ws) │ (http://127.0.0.1:41730/mcp)
40
40
  │ (Dynamic Code Streaming & Hot-Reload) │ (JSON-RPC 2.0 / Streamable HTTP)
41
41
  ┌─────────┴─────────┐ ┌─────────┴─────────┐
42
42
  │ Figma Desktop │ │ AI Assistant(s) │
@@ -50,9 +50,10 @@ Enables AI agents (Google Antigravity, Claude Code, Cursor, Windsurf, VS Code, Z
50
50
 
51
51
  ### 🔌 How the Zero-Touch Connection Works:
52
52
  1. **Permanent Thin Loader**: You only import the Figma plugin **once** (`~/.figma-rust-mcp/plugin/manifest.json`).
53
- 2. **Dynamic Runtime Streaming**: Upon launch, the Thin Loader contacts `http://127.0.0.1:38451/plugin/code.js` to fetch and execute the latest runtime in memory.
54
- 3. **Live WebSocket Hot-Reload**: The plugin connects to `ws://127.0.0.1:38451/ws`. When you upgrade `figma-rust-mcp` or restart the daemon, the plugin automatically detects the new server version and hot-reloads seamlessly — **no need to re-import or restart the plugin in Figma**.
53
+ 2. **Dynamic Runtime Streaming**: Upon launch, the Thin Loader contacts `http://127.0.0.1:41730/plugin/code.js` to fetch and execute the latest runtime in memory.
54
+ 3. **Live WebSocket Hot-Reload**: The plugin connects to `ws://127.0.0.1:41730/ws`. When you upgrade `figma-rust-mcp` or restart the daemon, the plugin automatically detects the new server version and hot-reloads seamlessly — **no need to re-import or restart the plugin in Figma**.
55
55
  4. **Auto-Reconnect & Offline Buffer**: If Figma is opened before the Rust service starts, the plugin displays a waiting screen and connects automatically the instant the service is up.
56
+ 5. **Port Range & Auto-Switch**: The bridge uses ports `41730–41739`. On start it reuses a running server of the same version, skips any port held by another app or a different figma-rust-mcp version, and starts on the first free port. The plugin scans the same range, so it follows automatically. MCP clients configured by URL point at `41730`; the server prints a warning with the new port if it had to move.
56
57
 
57
58
  ---
58
59
 
@@ -148,13 +149,13 @@ Use the `/mcp` endpoint. It accepts `POST` JSON-RPC requests directly:
148
149
 
149
150
  ```toml
150
151
  [mcp_servers.figma-rust-mcp]
151
- url = "http://127.0.0.1:38451/mcp"
152
+ url = "http://127.0.0.1:41730/mcp"
152
153
  ```
153
154
 
154
155
  For Codex CLI, the equivalent command is:
155
156
 
156
157
  ```bash
157
- codex mcp add figma-rust-mcp --url http://127.0.0.1:38451/mcp
158
+ codex mcp add figma-rust-mcp --url http://127.0.0.1:41730/mcp
158
159
  ```
159
160
 
160
161
  #### Google Antigravity (SSE Transport)
@@ -163,7 +164,7 @@ Add to your `~/.gemini/config/mcp_config.json` or project `.agents/mcp_config.js
163
164
  {
164
165
  "mcpServers": {
165
166
  "figma-rust-mcp": {
166
- "serverUrl": "http://127.0.0.1:38451/sse"
167
+ "serverUrl": "http://127.0.0.1:41730/sse"
167
168
  }
168
169
  }
169
170
  }
@@ -176,7 +177,7 @@ These clients can use the Streamable HTTP `/mcp` endpoint:
176
177
  {
177
178
  "mcpServers": {
178
179
  "figma-rust-mcp": {
179
- "url": "http://127.0.0.1:38451/mcp"
180
+ "url": "http://127.0.0.1:41730/mcp"
180
181
  }
181
182
  }
182
183
  }
@@ -200,7 +201,7 @@ Alternatively, configure a stdio subprocess via NPX:
200
201
  Check the runtime bundle currently served to Figma Desktop:
201
202
 
202
203
  ```bash
203
- curl http://127.0.0.1:38451/plugin/version
204
+ curl http://127.0.0.1:41730/plugin/version
204
205
  ```
205
206
 
206
207
  The response includes `version` and `runtimeHash`. If the hash changes after an
@@ -4,7 +4,7 @@ import os from 'node:os';
4
4
  import { applyEdits, modify, parse } from 'jsonc-parser';
5
5
 
6
6
  const SERVER_NAME = 'figma-rust-mcp';
7
- const HTTP_URL = 'http://127.0.0.1:38451/mcp';
7
+ const HTTP_URL = 'http://127.0.0.1:41730/mcp';
8
8
 
9
9
  function existsAny(paths) {
10
10
  return paths.some(candidate => candidate && fs.existsSync(candidate));
@@ -78,7 +78,7 @@ function upsertCodex(filePath) {
78
78
 
79
79
  export function configureAgent(agent) {
80
80
  const entry = agent.id === 'antigravity'
81
- ? { serverUrl: 'http://127.0.0.1:38451/sse' }
81
+ ? { serverUrl: 'http://127.0.0.1:41730/sse' }
82
82
  : agent.id === 'claude' || agent.id === 'vscode'
83
83
  ? { type: 'http', url: HTTP_URL }
84
84
  : { url: HTTP_URL };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "figma-rust-mcp",
3
- "version": "4.1.0",
3
+ "version": "5.0.0",
4
4
  "description": "Claude Code to Figma — Bidirectional MCP bridge. AI draws UI on Figma canvas, reads designs back. Works with Claude, Cursor, VS Code, Windsurf, Antigravity. No API key needed.",
5
5
  "mcpName": "io.github.BuiHung1612/figma-rust-mcp",
6
6
  "type": "module",
@@ -5,21 +5,33 @@
5
5
  "main": "code.js",
6
6
  "ui": "ui.html",
7
7
  "documentAccess": "dynamic-page",
8
- "editorType": ["figma", "dev"],
8
+ "editorType": [
9
+ "figma",
10
+ "dev"
11
+ ],
9
12
  "networkAccess": {
10
13
  "allowedDomains": [
11
- "http://localhost:38451",
12
- "http://localhost:38452",
13
- "http://localhost:38453",
14
- "http://localhost:38454",
15
- "http://localhost:38455",
16
- "ws://localhost:38451",
17
- "ws://localhost:38452",
18
- "ws://localhost:38453",
19
- "ws://localhost:38454",
20
- "ws://localhost:38455"
14
+ "http://localhost:41730",
15
+ "http://localhost:41731",
16
+ "http://localhost:41732",
17
+ "http://localhost:41733",
18
+ "http://localhost:41734",
19
+ "http://localhost:41735",
20
+ "http://localhost:41736",
21
+ "http://localhost:41737",
22
+ "http://localhost:41738",
23
+ "http://localhost:41739",
24
+ "ws://localhost:41730",
25
+ "ws://localhost:41731",
26
+ "ws://localhost:41732",
27
+ "ws://localhost:41733",
28
+ "ws://localhost:41734",
29
+ "ws://localhost:41735",
30
+ "ws://localhost:41736",
31
+ "ws://localhost:41737",
32
+ "ws://localhost:41738",
33
+ "ws://localhost:41739"
21
34
  ],
22
- "reasoning": "Thin loader connecting dynamically to the local figma-rust-mcp Rust engine on ports 38451-38455."
35
+ "reasoning": "Thin loader connecting dynamically to the local figma-rust-mcp Rust engine on ports 41730-41739."
23
36
  }
24
37
  }
25
-
package/plugin/ui.html CHANGED
@@ -6,18 +6,18 @@
6
6
  <title>Figma Rust MCP Loader</title>
7
7
  <style>
8
8
  :root {
9
- --bg: #0d0e12;
10
- --surface-base: #12141a;
11
- --surface-card: #171922;
12
- --border-subtle: rgba(255, 255, 255, 0.08);
13
- --border-ghost: rgba(147, 129, 255, 0.35);
14
- --text-primary: #f4f4f7;
15
- --text-secondary: #9aa1b2;
16
- --text-muted: #52596d;
17
- --ghost-lavender: #9381ff;
18
- --ghost-mint: #34d399;
19
- --font-ui: -apple-system, BlinkMacSystemFont, 'Inter', 'SF Pro Text', sans-serif;
20
- --font-mono: 'JetBrains Mono', 'SF Mono', Menlo, monospace;
9
+ --bg: #fdf1dc;
10
+ --surface-base: #fffaf0;
11
+ --surface-card: #fffdf7;
12
+ --border-subtle: #1c1a17;
13
+ --border-ghost: #1c1a17;
14
+ --text-primary: #1c1a17;
15
+ --text-secondary: #4a453d;
16
+ --text-muted: #7d7567;
17
+ --ghost-lavender: #f4532c;
18
+ --ghost-mint: #2f8f3a;
19
+ --font-ui: ui-rounded, 'SF Pro Rounded', -apple-system, BlinkMacSystemFont, 'Inter', sans-serif;
20
+ --font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, monospace;
21
21
  }
22
22
  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
23
23
  html, body { width: 100%; height: 100%; overflow: hidden; background: var(--bg); color: var(--text-primary); font-family: var(--font-ui); font-size: 11px; }
@@ -58,7 +58,7 @@
58
58
  height: 14px;
59
59
  border-radius: 50%;
60
60
  background: var(--ghost-lavender);
61
- box-shadow: 0 0 16px var(--ghost-lavender);
61
+ border: 2px solid #1c1a17;
62
62
  }
63
63
 
64
64
  @keyframes pulse {
@@ -85,7 +85,8 @@
85
85
  gap: 6px;
86
86
  padding: 4px 10px;
87
87
  background: var(--surface-card);
88
- border: 1px solid var(--border-subtle);
88
+ border: 2px solid var(--border-subtle);
89
+ box-shadow: 2px 2px 0 #1c1a17;
89
90
  border-radius: 9999px;
90
91
  font-family: var(--font-mono);
91
92
  font-size: 10px;
@@ -94,7 +95,8 @@
94
95
  }
95
96
  .hint-box {
96
97
  background: var(--surface-base);
97
- border: 1px solid var(--border-subtle);
98
+ border: 2px solid var(--border-subtle);
99
+ box-shadow: 2px 2px 0 #1c1a17;
98
100
  border-radius: 8px;
99
101
  padding: 10px 12px;
100
102
  max-width: 280px;
@@ -108,7 +110,7 @@
108
110
  color: var(--ghost-lavender);
109
111
  display: block;
110
112
  margin-top: 4px;
111
- background: rgba(0,0,0,0.3);
113
+ background: rgba(28,26,23,0.06);
112
114
  padding: 3px 6px;
113
115
  border-radius: 4px;
114
116
  }
@@ -123,9 +125,9 @@
123
125
  <div class="radar-core"></div>
124
126
  </div>
125
127
  <div class="title">Connecting to figma-rust-mcp</div>
126
- <div id="status" class="status-text">Scanning local ports (38451-38455)...</div>
128
+ <div id="status" class="status-text">Scanning local ports (41730-41739)...</div>
127
129
  <div class="badge">
128
- <span>●</span> <span id="port-label">localhost:38451</span>
130
+ <span>●</span> <span id="port-label">localhost:41730</span>
129
131
  </div>
130
132
  <div class="hint-box">
131
133
  Ensure the local Rust server is running:
@@ -134,7 +136,8 @@
134
136
  </div>
135
137
 
136
138
  <script>
137
- const PORTS = [38451, 38452, 38453, 38454, 38455];
139
+ // Must match DEFAULT_PORT / PORT_RANGE in src/bridge/server.rs and manifest.json.
140
+ const PORTS = Array.from({ length: 10 }, (_, i) => 41730 + i);
138
141
  let attempts = 0;
139
142
 
140
143
  async function checkPort(port) {
@@ -148,8 +151,9 @@
148
151
  clearTimeout(timeoutId);
149
152
  if (res.ok) {
150
153
  const data = await res.json();
151
- return { ok: true, data };
154
+ if (data && data.name === "figma-rust-mcp") return { ok: true, data };
152
155
  }
156
+ return { ok: false, answered: true };
153
157
  } catch (_) {
154
158
  clearTimeout(timeoutId);
155
159
  }
@@ -174,7 +178,8 @@
174
178
  if (!uiRes.ok) throw new Error("Failed to load plugin UI: " + uiRes.status);
175
179
  const uiHtml = await uiRes.text();
176
180
 
177
- // 3. Inject full UI cleanly
181
+ // 3. Inject full UI cleanly; the runtime reads the port from here (globals survive document.open).
182
+ window.__FIGMA_MCP_PORT = port;
178
183
  document.open();
179
184
  document.write(uiHtml);
180
185
  document.close();
@@ -190,17 +195,20 @@
190
195
  const statusEl = document.getElementById("status");
191
196
  const portEl = document.getElementById("port-label");
192
197
 
193
- for (const port of PORTS) {
194
- if (portEl) portEl.textContent = `localhost:${port}`;
195
- const result = await checkPort(port);
196
- if (result.ok) {
197
- await bootstrapFromPort(port);
198
- return;
199
- }
198
+ if (portEl) portEl.textContent = `localhost:${PORTS[0]}-${PORTS[PORTS.length - 1]}`;
199
+ const results = await Promise.all(PORTS.map(checkPort));
200
+ const hit = results.findIndex(r => r.ok);
201
+ if (hit >= 0) {
202
+ if (portEl) portEl.textContent = `localhost:${PORTS[hit]}`;
203
+ await bootstrapFromPort(PORTS[hit]);
204
+ return;
200
205
  }
201
206
 
202
207
  if (statusEl) {
203
- statusEl.textContent = `Waiting for server (attempt #${attempts})...`;
208
+ const busy = PORTS.filter((_, i) => results[i].answered);
209
+ statusEl.textContent = busy.length
210
+ ? `Port ${busy.join(", ")} answered but is not a compatible figma-rust-mcp. Run: npx -y figma-rust-mcp@latest --install-service (attempt #${attempts})`
211
+ : `Waiting for server (attempt #${attempts})...`;
204
212
  }
205
213
  setTimeout(findAndConnect, 1000);
206
214
  }
@@ -53,6 +53,20 @@ test('compact tree and scan preserve mixed typography instead of using the first
53
53
  assert.equal(r.resolveTextStyle(node).fontWeight, undefined);
54
54
  });
55
55
 
56
+ test('scan_design expands instances by default and flags opaque ones as incomplete', async () => {
57
+ const text = { id: '1:3', name: 'Label', type: 'TEXT', characters: 'Hi', fontSize: 12, fontName: { family: 'Inter', style: 'Regular' }, visible: true };
58
+ const inst = { id: '1:2', name: 'Button', type: 'INSTANCE', visible: true, children: [text], getMainComponentAsync: async () => null };
59
+ const frame = { id: '1:1', name: 'Frame', type: 'FRAME', visible: true, children: [inst] };
60
+ const r = runtime({ root: { id: '0:0' }, currentPage: { id: '0:1' }, getNodeByIdAsync: async () => frame });
61
+ const full = plain(await r.handlers.scan_design({ id: '1:1' }));
62
+ assert.equal(full.totals.textNodes, 1);
63
+ assert.equal(full.complete, true);
64
+ const opaque = plain(await r.handlers.scan_design({ id: '1:1', expandInstances: false }));
65
+ assert.equal(opaque.totals.textNodes, 0);
66
+ assert.equal(opaque.totals.opaqueInstances, 1);
67
+ assert.equal(opaque.complete, false);
68
+ });
69
+
56
70
  test('equivalent CSS syntaxes preserve alpha in reads and writes', () => {
57
71
  const r = runtime();
58
72
  for (const color of ['rgba(255, 0, 0, .5)', 'rgb(100% 0% 0% / 50%)', 'hsl(0 100% 50% / .5)', 'hsla(0, 100%, 50%, .5)']) {
@@ -204,8 +218,8 @@ test('agent setup detects installed clients and preserves existing JSONC and TOM
204
218
  configureAgent(agent);
205
219
  const config = parseJsonc(readFileSync(agent.configPath, 'utf8')).mcpServers['figma-rust-mcp'];
206
220
  assert.ok(config);
207
- if (id === 'antigravity') assert.equal(config.serverUrl, 'http://127.0.0.1:38451/sse');
208
- else assert.equal(config.url, 'http://127.0.0.1:38451/mcp');
221
+ if (id === 'antigravity') assert.equal(config.serverUrl, 'http://127.0.0.1:41730/sse');
222
+ else assert.equal(config.url, 'http://127.0.0.1:41730/mcp');
209
223
  }
210
224
 
211
225
  const cursor = agents.find(agent => agent.id === 'cursor');
@@ -215,21 +229,21 @@ test('agent setup detects installed clients and preserves existing JSONC and TOM
215
229
  const cursorConfig = parseJsonc(cursorText);
216
230
  assert.match(cursorText, /keep this note/);
217
231
  assert.equal(cursorConfig.mcpServers.other.url, 'http://example.test');
218
- assert.equal(cursorConfig.mcpServers['figma-rust-mcp'].url, 'http://127.0.0.1:38451/mcp');
232
+ assert.equal(cursorConfig.mcpServers['figma-rust-mcp'].url, 'http://127.0.0.1:41730/mcp');
219
233
  assert.equal(configureAgent(cursor), false);
220
234
 
221
235
  const zed = agents.find(agent => agent.id === 'zed');
222
236
  writeFileSync(zed.configPath, '{\n "theme": "One Dark",\n}\n');
223
237
  configureAgent(zed);
224
238
  assert.equal(parseJsonc(readFileSync(zed.configPath, 'utf8')).theme, 'One Dark');
225
- assert.equal(parseJsonc(readFileSync(zed.configPath, 'utf8')).context_servers['figma-rust-mcp'].url, 'http://127.0.0.1:38451/mcp');
239
+ assert.equal(parseJsonc(readFileSync(zed.configPath, 'utf8')).context_servers['figma-rust-mcp'].url, 'http://127.0.0.1:41730/mcp');
226
240
 
227
241
  const codex = agents.find(agent => agent.id === 'codex');
228
242
  writeFileSync(codex.configPath, '[mcp_servers.other]\nurl = "http://other.test"\n');
229
243
  configureAgent(codex);
230
244
  const codexText = readFileSync(codex.configPath, 'utf8');
231
245
  assert.match(codexText, /\[mcp_servers\.other\]/);
232
- assert.match(codexText, /\[mcp_servers\.figma-rust-mcp\]\nurl = "http:\/\/127\.0\.0\.1:38451\/mcp"/);
246
+ assert.match(codexText, /\[mcp_servers\.figma-rust-mcp\]\nurl = "http:\/\/127\.0\.0\.1:41730\/mcp"/);
233
247
  assert.equal(configureAgent(codex), false);
234
248
  } finally {
235
249
  rmSync(home, { recursive: true, force: true });
@@ -525,10 +539,10 @@ test('old UI socket callbacks cannot clear or dispatch through a replacement soc
525
539
  close() {} send() {}
526
540
  }
527
541
  const r = vm.createContext({
528
- ws: null, wsConnected: false, sessionId: null, fileName: 'File', documentId: 'doc', BRIDGE: 'http://localhost:38451',
542
+ ws: null, wsConnected: false, sessionId: null, fileName: 'File', documentId: 'doc', BRIDGE: 'http://localhost:41730',
529
543
  WebSocket: Socket, setInterval: () => 1, clearInterval: () => {}, setTimeout: () => 1, clearTimeout: () => {},
530
544
  sendRuntimeCapabilities: () => {}, consecutiveErrors: 0, everConnected: false, retryTimer: null, polling: true,
531
- setStatus: () => {}, log: () => {}, currentPort: 38451, READ_OPS: [],
545
+ setStatus: () => {}, log: () => {}, currentPort: 41730, READ_OPS: [],
532
546
  startLongPoll: () => assert.fail('stale socket started poll'), dispatchToMain: req => dispatched.push(req),
533
547
  });
534
548
  vm.runInContext(source, r);