figma-rust-mcp 3.2.15 → 4.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
@@ -10,9 +10,9 @@ Enables AI agents (Google Antigravity, Claude Code, Cursor, Windsurf, VS Code, Z
10
10
 
11
11
  - **Lossless Rust Tree Compression**: Compact/minimal tree responses share identical style bundles when the complete JSON becomes smaller. Node IDs, hierarchy, text, geometry and state differences remain intact. Full detail bypasses Rust compression; no heuristic merging of screens or repeated items.
12
12
  - **Pure Rust Native Performance**: Starts in `< 1ms`, uses `~3MB RAM`, zero GC pauses.
13
- - **In-Memory Deep Indexing (`figma_index`)**: Queries layers, components, styles, and tokens in `< 1ms` without slow canvas roundtrips.
13
+ - **Scoped In-Memory Index (`figma_index`)**: Indexes page roots first and loads frame descendants on demand. Search reports the indexed scope and completeness.
14
14
  - **Binary IPC & Chunk Streaming**: Powered by **MessagePack** (`rmp-serde`) and progressive subtree chunking for instant transfers of massive design files.
15
- - **Incremental Diff Updates**: Sub-millisecond live document sync (`upsert_node`) keeps the server index fresh as you edit in Figma.
15
+ - **Revisioned Updates**: Property patches update affected nodes; revision gaps trigger a fresh snapshot.
16
16
  - **Instant Design-to-Code (`figma_to_code`)**: Compiles Figma frames into clean, semantic components (**React + Tailwind**, **React Native**, **Vue 3 + Tailwind**, **HTML**, **SwiftUI**).
17
17
  - **Design Token Exporter (`figma_get_tokens`)**: Exports variables and styles to **Tailwind config**, **CSS custom properties** (`:root` & dark mode), **TypeScript consts**, or **W3C DTCG Token Studio JSON**.
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.
@@ -217,7 +217,7 @@ Figma plugin only if the reported hash does not change.
217
217
  Checks live bridge connection status, connected Figma tabs/files, in-memory index health, queue length, and latency statistics.
218
218
 
219
219
  ### 2. `figma_inspect_node`
220
- Inspects a specific node by ID or name, returning CSS styles, flex layout rules, tokens, typography, and fills in a clean format (served in `< 1ms` via in-memory index or direct bridge).
220
+ Reads a specific node by ID or name through the canonical flat `read_nodes` response. Select fields explicitly to control the work performed.
221
221
 
222
222
  ### 3. `figma_to_code`
223
223
  Compiles any Figma node (Frame, Component, Section, or selection) directly into clean, production-ready component code.
@@ -291,7 +291,7 @@ Instant `< 1ms` in-memory queries against pre-indexed Figma file structures.
291
291
  - `"search_components"`: Find component sets and variants.
292
292
  - `"search_styles"`: Find paint, text, and effect styles.
293
293
  - `"search_variables"`: Find design token variables by name or collection.
294
- - `"refresh"`: Trigger full background re-indexing of the canvas.
294
+ - `"refresh"`: Refresh page roots, or expand the frame specified by `nodeId`.
295
295
  - `"subtree"`: Read/cache one `nodeId`, default depth 2 and 1000 nodes. Increase `depth` or `maxNodes` only for the section you need; check `meta` for truncation.
296
296
  - `"typography"`: Rust-generated text/font table for one `nodeId`, with mixed-style runs preserved. Default limit 200 rows. Null font fields mean unknown/mixed, not a guessed default.
297
297
 
@@ -301,8 +301,8 @@ For typography verification, call `figma_verify_ui` with a text `nodeId` and bro
301
301
 
302
302
  ### 9. `figma_read`
303
303
  Universal reader for advanced queries:
304
- - **Design-to-code**: `get_design_context`, `get_css`, `get_component_map`, `get_unmapped_components`.
305
- - **Inspection & Hierarchy**: `get_selection`, `get_design`, `get_page_nodes`, `get_node_detail`, `scan_design`, `search_nodes`.
304
+ - **Design-to-code**: `read_nodes`, `get_css`, `get_component_map`, `get_unmapped_components`.
305
+ - **Inspection & Hierarchy**: `read_nodes`, `get_selection`, `get_page_nodes`, `scan_design`, `search_nodes`.
306
306
  - **Design Systems**: `get_styles`, `get_variables`, `get_local_components`, `get_tokens`.
307
307
  - **Visuals & Canvas**: `screenshot`, `export_svg`, `export_image`, `get_viewport`.
308
308
 
@@ -366,23 +366,58 @@ Tools on one tab run sequentially, including every awaited operation in a
366
366
  (Node >=22) checks real WebSocket/HTTP transport with a simulated plugin canvas;
367
367
  it does not replace validation in Figma Desktop.
368
368
 
369
- ### Startup indexing
370
-
371
- Opening the bridge indexes top-level nodes on the active page and local tokens.
372
- It does not preload every page or scan the document-wide component catalogue.
373
- `figma_index` status exposes `components_indexed: false` until a full reindex;
374
- this means deferred, not an empty catalogue. Live component queries populate
375
- the Rust cache for subsequent reads while the index remains valid. Component reads/searches and rules
376
- that require the complete catalogue fall back to a live query.
377
-
378
- The plugin listens to active-page `nodechange` and global `stylechange` events,
379
- and rebuilds the lightweight index when the active page changes. It does not
380
- subscribe to document-wide node changes by preloading the whole file. Explicit
381
- reindexing still includes all local components; discovery loads pages one at a
382
- time and yields after at most 100 nodes or an 8ms traversal slice. Figma's native
383
- page load itself cannot be interrupted by the plugin. Concurrent identical
384
- index requests share one scan, and results from a previously active page are
385
- not published as the current index.
369
+ ### Startup indexing and v4 migration
370
+
371
+ Version 4 requires both server and plugin runtime protocol 3. Update both and
372
+ restart the plugin. Opening the bridge indexes only visible top-level nodes on
373
+ the active page, plus local styles and variables. Selection expands the selected
374
+ frame. Component catalogue discovery is deferred; request it with
375
+ `figma_index({ operation: "refresh", includeComponents: true })`.
376
+
377
+ Search covers indexed scopes only. Inspect `scope` and `complete` in search
378
+ responses; an empty result does not prove a layer is absent from the file.
379
+ Refresh a specific frame to expand its scope:
380
+
381
+ ```js
382
+ figma_index({ operation: "refresh", nodeId: "123:456", expandInstances: true })
383
+ figma_read({ operation: "read_nodes", nodeId: "123:456",
384
+ fields: ["geometry", "content", "text", "style", "layout", "tokens", "component"],
385
+ limit: 200, expandInstances: true })
386
+ // Continue using the returned cursor; do not repeat or change traversal options.
387
+ figma_read({ operation: "read_nodes", cursor: nextCursor, limit: 200 })
388
+ ```
389
+
390
+ `read_nodes` returns a flat envelope:
391
+ `{ schemaVersion: 4, pageId, revision, scope, nodes, nextCursor, complete, totalRead, budgetReached }`.
392
+ Identity and topology are always included. Default fields are geometry and
393
+ content; typography, paints, layout, variable/style references and component
394
+ metadata are opt-in. Token fields contain references, not resolved CSS values.
395
+ Page reads default to depth 0; frame reads default to full traversal (maximum
396
+ 256 levels). Limit is 1?500 nodes per response; traversal stops at 50,000 visited
397
+ nodes. `complete` describes the requested scope, including its depth and filters.
398
+ Restart from the root if the page or document revision changes between cursor
399
+ requests. Cursors are single-use and at most 16 traversals can remain open.
400
+
401
+ Instances are opaque by default: `opaque: true` and no descendant traversal.
402
+ Set `expandInstances: true` to read the actual instance children and overrides.
403
+ Hidden nodes require `includeHidden: true`. Expansion and visibility are separate
404
+ options; no master component is substituted for an instance.
405
+
406
+ Breaking output changes: public `get_design`, `get_design_context` and
407
+ `get_node_detail` aliases now return the same envelope as `read_nodes`.
408
+ `figma_inspect_node` and index `get_node`, `subtree`, `typography` also return
409
+ that envelope. Replace nested `children` consumers with `nodes` and parent/child
410
+ IDs. Replace index `maxNodes` with `limit` and cursor pagination. Internal
411
+ script helpers used for code generation retain their specialized formats.
412
+
413
+ Canvas traversal reads each child list once, yields after an 8ms slice, and
414
+ streams chunks of up to 500 nodes. Snapshots commit atomically; frame refreshes
415
+ preserve other indexed frames. Revisioned create/delete/property patches
416
+ invalidate affected cached reads, while unrelated nodes remain queryable.
417
+ Structural additions mark coverage incomplete until the scope is reread.
418
+ Revision gaps request resynchronization. Page switches rebuild the shallow
419
+ index without preloading every page. Exact-read caching evicts one least
420
+ recently used entry when its 64-entry capacity is reached.
386
421
 
387
422
  ## 💻 Development & Testing
388
423
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "figma-rust-mcp",
3
- "version": "3.2.15",
3
+ "version": "4.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",
@@ -21,6 +21,7 @@ const files = [
21
21
  "plugin-src/handlers-read-detail.js",
22
22
  "plugin-src/handlers-tokens.js",
23
23
  "plugin-src/handlers-write-ops.js",
24
+ "plugin-src/node-reader.js",
24
25
  "plugin-src/task-scope.js",
25
26
  "plugin-src/main.js",
26
27
  ];
@@ -64,6 +64,7 @@ test('multiple tabs, reconnect ownership and independent frame tasks across MCP
64
64
  state.runtime = vm.createContext({ handlers: {}, findNodeByIdAsync: async id => nodes.get(id), yieldToUI: async () => {} });
65
65
  vm.runInContext(readFileSync('plugin-src/task-scope.js', 'utf8'), state.runtime);
66
66
  state.runtime.handlers.get_design = async params => ({ id: params.id, type: 'FRAME', name: params.id, tab: sid });
67
+ state.runtime.handlers.read_nodes = async params => ({ schemaVersion: 4, pageId: 'page', revision: 0, scope: { id: params.id }, nodes: [{ id: params.id, type: 'FRAME', name: params.id, tab: sid }], nextCursor: null, complete: true });
67
68
  state.runtime.handlers.status = async () => ({ tab: sid });
68
69
  state.runtime.handlers.modify = async params => {
69
70
  actions.push(params.note);
@@ -92,11 +93,15 @@ test('multiple tabs, reconnect ownership and independent frame tasks across MCP
92
93
  });
93
94
  });
94
95
  await new Promise((resolve, reject) => { socket.addEventListener('open', resolve, { once: true }); socket.addEventListener('error', reject, { once: true }); });
95
- socket.send(JSON.stringify({ type: 'runtime-capabilities', runtimeVersion: 'test', protocolVersion: 2, documentId: 'same-document', operations: Object.keys(state.runtime.handlers) }));
96
+ socket.send(JSON.stringify({ type: 'runtime-capabilities', runtimeVersion: 'test', protocolVersion: 3, documentId: 'same-document', operations: Object.keys(state.runtime.handlers) }));
96
97
  return state;
97
98
  }
98
99
  const tabA = await plugin('tab-a'), tabB = await plugin('tab-b');
99
100
  const first = agent(), second = agent();
101
+ const scopedSearch = decode(ok(await rpc('figma_read', { operation: 'search_nodes', query: 'child', sessionId: 'tab-a' })));
102
+ assert.equal(scopedSearch.complete, false);
103
+ assert.deepEqual(scopedSearch.nodes, []);
104
+
100
105
  const ambiguous = await rpc('figma_read', { operation: 'get_design', nodeId: 'frame-a' });
101
106
  assert.equal(ambiguous.isError, true); assert.match(ambiguous.content[0].text, /Ambiguous/);
102
107
  const missing = await rpc('figma_read', { operation: 'get_design', sessionId: 'missing' });
@@ -131,9 +136,9 @@ test('multiple tabs, reconnect ownership and independent frame tasks across MCP
131
136
  const recovering = rpc('figma_read', { operation: 'get_design', nodeId: 'frame-a', sessionId: 'tab-a' });
132
137
  const ignored = await tabA.ignored.promise;
133
138
  await plugin('tab-a', tabA); oldSocket.close();
134
- assert.equal(decode(ok(await recovering)).tab, 'tab-a');
139
+ assert.equal(decode(ok(await recovering)).nodes[0].tab, 'tab-a');
135
140
  await delay(30);
136
- assert.equal(decode(ok(await rpc('figma_read', { operation: 'get_design', nodeId: 'frame-b', sessionId: 'tab-a' }))).id, 'frame-b');
141
+ assert.equal(decode(ok(await rpc('figma_read', { operation: 'get_design', nodeId: 'frame-b', sessionId: 'tab-a' }))).nodes[0].id, 'frame-b');
137
142
  // Cross-tab response spoofing cannot consume another tab's pending request.
138
143
  tabA.ignoreNext = true; tabA.ignored = deferred();
139
144
  const routed = rpc('figma_read', { operation: 'get_design', nodeId: 'frame-a', sessionId: 'tab-a' });
@@ -10,7 +10,7 @@ import { parse as parseJsonc } from 'jsonc-parser';
10
10
 
11
11
  function runtime(figma = {}) {
12
12
  const context = vm.createContext({ figma, handlers: {}, console, setTimeout, Map, Set, Uint8Array });
13
- for (const file of ['utils', 'token-helpers', 'handlers-read-detail', 'handlers-read', 'handlers-tokens', 'handlers-write-ops']) {
13
+ for (const file of ['utils', 'token-helpers', 'read-helpers', 'handlers-read-detail', 'handlers-read', 'handlers-tokens', 'handlers-write-ops', 'node-reader']) {
14
14
  vm.runInContext(readFileSync(`plugin-src/${file}.js`, 'utf8'), context, { filename: file });
15
15
  }
16
16
  return context;
@@ -281,6 +281,86 @@ test('startup index defers document-wide components and coalesces duplicate scan
281
281
  assert.equal(componentQueries, 1);
282
282
  });
283
283
 
284
+ test('page index is shallow; frame reads paginate and instances expand only on request', async () => {
285
+ const messages = [];
286
+ let childReads = 0;
287
+ const page = { id: 'page', type: 'PAGE', children: [] };
288
+ const figma = { currentPage: page, skipInvisibleInstanceChildren: false,
289
+ root: { name: 'File', getPluginData: () => '' }, ui: { postMessage: msg => messages.push(msg) } };
290
+ const text = i => ({ id: 'text:' + i, name: 'Label ' + i, type: 'TEXT', characters: 'Content ' + i,
291
+ width: 15.5, height: 20.25, visible: true,
292
+ get fills() { assert.fail('projected index must not read paints'); },
293
+ get fontSize() { assert.fail('projected index must not read typography'); } });
294
+ const visible = Array.from({ length: 1200 }, (_, i) => text(i));
295
+ const hidden = text('hidden'); hidden.visible = false;
296
+ const frame = { id:'frame', name:'Frame', type:'FRAME', parent:page };
297
+ const instance = { id:'instance', name:'Instance', type:'INSTANCE', parent:frame,
298
+ get children() { childReads++; return figma.skipInvisibleInstanceChildren ? visible : [...visible,hidden]; } };
299
+ for (const node of [...visible,hidden]) node.parent = instance;
300
+ frame.children = [instance]; page.children = [frame];
301
+ figma.getNodeByIdAsync = async id => id === frame.id ? frame : id === instance.id ? instance : null;
302
+ const r = runtime(figma);
303
+ r.handlers.get_styles = async () => ({}); r.handlers.get_variables = async () => ({});
304
+ r.yieldToUI = async () => assert.equal(figma.skipInvisibleInstanceChildren, false);
305
+ const startup = await r.handlers.index_scan({ deferComponents:true });
306
+ assert.equal(startup.scope.id, page.id);
307
+ assert.equal(startup.scope.depth, 0);
308
+ assert.deepEqual(messages.filter(msg => msg.type === 'index-chunk').flatMap(msg => msg.nodes.map(n => n.id)), ['frame']);
309
+ assert.equal(childReads, 0);
310
+ const opaque = await r.handlers.read_nodes({ id:frame.id });
311
+ assert.equal(opaque.nodes.length, 2); assert.equal(opaque.nodes[1].opaque, true); assert.equal(childReads, 0);
312
+ messages.length = 0;
313
+ const result = await r.handlers.index_scan({ id:frame.id, expandInstances:true, deferComponents:true });
314
+ const chunks = messages.filter(msg => msg.type === 'index-chunk');
315
+ assert.deepEqual(chunks.map(msg => msg.nodes.length), [500,500,202]);
316
+ const nodes = chunks.flatMap(msg => plain(msg.nodes));
317
+ assert.equal(childReads, 1); assert.equal(result.nodesTruncated, false);
318
+ assert.equal(nodes.at(-1).content, 'Content 1199'); assert.equal(nodes.at(-1).width, 15.5);
319
+ assert.equal(nodes[1].childIds.length, 1200);
320
+ assert.equal(figma.skipInvisibleInstanceChildren, false);
321
+ const first = await r.handlers.read_nodes({ id:frame.id, expandInstances:true, limit:2 });
322
+ assert.ok(first.nextCursor); assert.equal(first.complete, false);
323
+ const next = await r.handlers.read_nodes({ cursor:first.nextCursor, limit:2 });
324
+ assert.deepEqual(plain(next.nodes.map(n => n.id)), ['text:0','text:1']);
325
+ await assert.rejects(r.handlers.read_nodes({ cursor:first.nextCursor }), /expired/);
326
+ await assert.rejects(r.handlers.read_nodes({ cursor:next.nextCursor, fields:['style'] }), /options cannot change/);
327
+ r.nodeRevision++;
328
+ await assert.rejects(r.handlers.read_nodes({ cursor:next.nextCursor }), /expired/);
329
+ const full = await r.handlers.read_nodes({ id:instance.id, includeHidden:true, expandInstances:true, limit:500 });
330
+ let all = [...full.nodes], cursor = full.nextCursor;
331
+ while(cursor) { const page = await r.handlers.read_nodes({cursor,limit:500}); all.push(...page.nodes); cursor=page.nextCursor; }
332
+ assert.equal(all.length, 1202); assert.equal(all.at(-1).visible, false);
333
+ assert.throws(() => r.withInstanceVisibility(false, () => { throw Error('read failed'); }), /read failed/);
334
+ assert.equal(figma.skipInvisibleInstanceChildren, false);
335
+ });
336
+
337
+ test('index yields by elapsed time and cancels when the active page changes', async () => {
338
+ let elapsed = 0, reads = 0, previousReads = 0, yields = 0;
339
+ const messages = [];
340
+ const page = { id: 'page', children: Array.from({ length: 30 }, (_, i) => ({
341
+ id: `${i}`, type: 'FRAME', get width() { elapsed += 2; reads++; return 10; },
342
+ })) };
343
+ const r = runtime({ skipInvisibleInstanceChildren: false, currentPage: page,
344
+ root: { name: 'File', getPluginData: () => '' }, ui: { postMessage: msg => messages.push(msg) } });
345
+ r.Date = { now: () => elapsed };
346
+ r.handlers.get_styles = async () => ({});
347
+ r.handlers.get_variables = async () => ({});
348
+ r.yieldToUI = async () => {
349
+ yields++;
350
+ assert.ok(reads - previousReads <= 4, 'yield after an 8ms slice');
351
+ previousReads = reads;
352
+ assert.equal(r.figma.skipInvisibleInstanceChildren, false);
353
+ };
354
+ await r.handlers.index_scan({ deferComponents: true });
355
+ assert.equal(reads, 30);
356
+ assert.ok(yields >= 7);
357
+ r.yieldToUI = async () => { r.figma.currentPage = { id: 'other', children: [] }; };
358
+ messages.length = 0;
359
+ const result = await r.handlers.index_scan({ deferComponents: true });
360
+ assert.equal(result.cancelled, true);
361
+ assert.ok(!messages.some(msg => msg.type === 'index-update'));
362
+ });
363
+
284
364
  test('component catalogue walks pages cooperatively without bulk loading or root searches', async () => {
285
365
  let loaded = 0, yields = 0, reads = 0, lastYieldReads = 0, maxSliceReads = 0;
286
366
  const page = { type: 'PAGE', name: 'Components', loadAsync: async () => { loaded++; } };
@@ -334,19 +414,17 @@ test('startup subscribes to active-page changes without loading the whole file',
334
414
  assert.equal(scanOptions.deferComponents, true);
335
415
  pageListener({ nodeChanges: [{ type: 'PROPERTY_CHANGE', node: { id: 'changed:1' } }] });
336
416
  assert.ok(r.pendingChangedNodeIds.has('changed:1'));
337
- assert.ok(messages.some(msg => msg.type === 'document-change'));
338
- assert.deepEqual(plain(messages.filter(msg => msg.type === 'document-change').at(-1).changedNodeIds), ['changed:1']);
339
- r.findNodeByIdAsync = async id => id === 'changed:1'
340
- ? { id, parent: page, type: 'TEXT', fontSize: 15.5, fontWeight: 'Semi Bold' } : null;
341
- r.extractDesignTree = node => ({ id: node.id, type: node.type, fontSize: node.fontSize, fontWeight: node.fontWeight });
342
- pageListener({ nodeChanges: [{ type: 'DELETE', id: 'deleted:1' }] });
417
+ assert.ok(messages.some(msg => msg.type === 'nodes-invalidated'));
418
+ const changed = { id:'changed:1', type:'TEXT', parent:page, width:15.5, characters:'New' };
419
+ r.extractDesignTree = () => assert.fail('patch sync must not extract a tree');
420
+ pageListener({ nodeChanges:[{ type:'PROPERTY_CHANGE', properties:['width','characters'], node:changed }] });
421
+ pageListener({ nodeChanges:[{ type:'DELETE', id:'deleted:1' }] });
343
422
  await timers.filter(timer => timer.delay === 100).at(-1).callback();
344
- const diff = plain(messages.filter(msg => msg.type === 'node-diff').at(-1));
345
- assert.deepEqual(diff.deletedIds, ['deleted:1']);
346
- assert.equal(diff.nodes[0].fontSize, 15.5);
347
- assert.equal(diff.nodes[0].fontWeight, 'Semi Bold');
348
- assert.equal(diff.nodes[0].parentId, page.id);
349
- assert.deepEqual(diff.nodes[0].childIds, []);
423
+ const diff = plain(messages.filter(msg => msg.type === 'node-patch').at(-1));
424
+ assert.equal(diff.baseRevision, 0); assert.equal(diff.revision, 3);
425
+ assert.equal(diff.patches[0].values.width, 15.5); assert.equal(diff.patches[0].values.content, 'New');
426
+ assert.deepEqual(diff.patches[1], {kind:'delete',id:'deleted:1'});
427
+ assert.equal(r.nodeReadCursors.size, 0);
350
428
  const next = { id: 'page:2', on: () => {}, off: () => {} };
351
429
  r.figma.currentPage = next;
352
430
  r.handlers.index_scan = async () => ({ pageId: 'page:1' });