figma-rust-mcp 6.0.0 → 8.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 +84 -115
- package/package.json +3 -2
- package/scripts/codegen-accuracy.mjs +182 -0
- package/scripts/test-bridge-sessions.mjs +15 -10
- package/scripts/test-plugin-tokens.mjs +11 -0
- package/scripts/test-rust-mcp.sh +4 -4
package/README.md
CHANGED
|
@@ -8,14 +8,14 @@ Enables AI agents (Google Antigravity, Claude Code, Cursor, Windsurf, VS Code, Z
|
|
|
8
8
|
|
|
9
9
|
## ⚡ Highlights
|
|
10
10
|
|
|
11
|
-
- **Lossless Rust Tree Compression**:
|
|
11
|
+
- **Lossless Rust Tree Compression**: Node reads are compact by default: identical style bundles are shared and values every layer carries are stored once, only when the JSON gets smaller. Node IDs, hierarchy, text, geometry and state differences stay inline; `detail: "full"` returns the raw payload.
|
|
12
12
|
- **Pure Rust Native Performance**: Single native binary, no runtime or GC. Measure it on your machine with `npm run bench` or the plugin's **Bench** tab (see [Benchmarks](#-benchmarks)).
|
|
13
|
-
- **Scoped In-Memory Index (`
|
|
13
|
+
- **Scoped In-Memory Index (`figma_read` search)**: 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
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
|
-
- **Design Token Exporter (`
|
|
18
|
-
- **Batch Asset Extractor (`
|
|
17
|
+
- **Design Token Exporter (`figma_to_code` `target: "tokens"`)**: Exports variables and styles to **Tailwind config**, **CSS custom properties** (`:root` & dark mode), **TypeScript consts**, or **W3C DTCG Token Studio JSON**.
|
|
18
|
+
- **Batch Asset Extractor (`figma_prepare_design` `only: "assets"`)**: 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
21
|
- **100% Localhost Privacy**: All communication stays strictly on `127.0.0.1:41730`.
|
|
@@ -212,25 +212,57 @@ Figma plugin only if the reported hash does not change.
|
|
|
212
212
|
|
|
213
213
|
## 🛠️ MCP Tools Reference
|
|
214
214
|
|
|
215
|
-
`figma-rust-mcp`
|
|
215
|
+
`figma-rust-mcp` exposes **7 tools**. Each covers one job; modes select the
|
|
216
|
+
variant (`operation`, `task`, `only`, `target`). The plugin **Usage** tab shows
|
|
217
|
+
how often each tool and mode is called.
|
|
216
218
|
|
|
217
219
|
### 1. `figma_status`
|
|
218
|
-
|
|
220
|
+
Bridge connection, connected tabs (`sessionId`), index health, queue length and latency.
|
|
221
|
+
- **`task`**: `"start"` (with `frameId`) reserves a frame and returns a `taskId`; `"end"` (with `taskId`) releases it; `"list"` shows reservations. See [Multiple tabs and frame tasks](#multiple-tabs-and-frame-tasks).
|
|
222
|
+
|
|
223
|
+
### 2. `figma_read`
|
|
224
|
+
Every read, search and export. **`operation`**:
|
|
225
|
+
- **Nodes**: `read_nodes` (canonical flat API), `inspect_node` (one node with CSS, layout, tokens, typography; defaults to the selection), `get_selection`, `get_spec`, `get_page_nodes`, `scan_design`, `get_css`.
|
|
226
|
+
- **Search** (in-memory index): `search_nodes` (`query`, `nodeType`), `search_components`, `search_styles` (`styleType`), `search_variables` (`collection`), `subtree`, `typography` (text/font table with mixed runs), `index_status` (index health, cache hits, tool timings), `refresh` (page roots, or expand `nodeId`; `includeComponents`).
|
|
227
|
+
- **Design system**: `rules` (markdown rule sheet of tokens, text styles and components), `get_styles`, `get_variables`, `get_tokens`, `get_local_components`, `get_component_map`, `get_unmapped_components`.
|
|
228
|
+
- **Export**: `screenshot`, `export_image` (png/jpg, `scale`), `export_svg`. Pass `outputPath` to save to disk instead of returning data.
|
|
229
|
+
|
|
230
|
+
Node reads (`read_nodes`, `inspect_node`, `get_selection`, `subtree`, `typography`)
|
|
231
|
+
are compact by default. When it makes the JSON smaller, the response carries a
|
|
232
|
+
root `_compression` object (`version: 2`) with `styles` (exact style bundles shared
|
|
233
|
+
by `styleRef`) and `defaults` (a value every layer carried, stored once). To
|
|
234
|
+
reconstruct the plugin payload exactly: for each layer (object with string `id`,
|
|
235
|
+
`name` and `type`) merge `_compression.styles[styleRef]` and remove `styleRef`,
|
|
236
|
+
then add every `defaults` key the layer lacks; remove `_compression`. Identity,
|
|
237
|
+
geometry, text and hierarchy always stay inline. `detail: "full"` returns the raw
|
|
238
|
+
payload. On one 62-layer production frame read with every field group, the
|
|
239
|
+
response went from 81.5KB to 35.8KB (style sharing alone: 50.3KB); with default
|
|
240
|
+
fields, 19.0KB to 13.4KB. Bytes, not tokens; measure your own files.
|
|
241
|
+
|
|
242
|
+
### 3. `figma_write`
|
|
243
|
+
Executes JavaScript draw commands inside the sandboxed VM to build or modify designs on the Figma canvas.
|
|
244
|
+
- Supports Auto-layout (`layoutMode`, `itemSpacing`, `padding`), typography, fills, strokes, corner radius, drop shadows, and component creation.
|
|
245
|
+
- Supports icon loading: `figma.loadIcon("ionicons", "heart", { size: 24, fill: "#ff4757" })`.
|
|
246
|
+
- Supports image insertion: `figma.loadImage(url, { width: 300, height: 200 })`.
|
|
247
|
+
- Supports Design Tokens: `createVariableCollection`, `createVariable`, `addVariableMode`, `applyVariable`.
|
|
248
|
+
- Supports Prototyping & Reactions: `setReactions`, `getReactions`, `setScrollBehavior`.
|
|
219
249
|
|
|
220
|
-
###
|
|
221
|
-
|
|
250
|
+
### 4. `figma_docs`
|
|
251
|
+
Built-in documentation, design rules, layout guidelines and examples for `figma_write`.
|
|
252
|
+
- **`section`**: `"rules"` | `"layout"` | `"api"` | `"tokens"` | `"icons"`
|
|
222
253
|
|
|
223
|
-
###
|
|
224
|
-
|
|
225
|
-
- **`
|
|
226
|
-
- **`
|
|
227
|
-
- **`
|
|
254
|
+
### 5. `figma_prepare_design`
|
|
255
|
+
**Start here for design-to-code.** One call returns a grounding pack: screenshot, every visible text (through expanded instances, with coverage), SVG icons exported to `iconDir` with import statements, matched codebase components and an implementation checklist.
|
|
256
|
+
- **`nodeId`**, **`iconDir`** (default `src/assets/icons`), **`projectDir`** (default `.`), **`detail`**, **`outputPath`**.
|
|
257
|
+
- **`only: "assets"`**: just export SVG icons (`iconDir`) and raster images (`imageDir`, default `public/images`) with an `index.ts` barrel (`createBarrel`).
|
|
258
|
+
- **`only: "components"`**: just list codebase components (`src/components`, `components`, ...) and match `nodeId` against them.
|
|
228
259
|
|
|
229
|
-
###
|
|
230
|
-
|
|
231
|
-
- **`
|
|
232
|
-
- **`outputPath
|
|
233
|
-
- **`collection`** / **`mode`** / **`prefix
|
|
260
|
+
### 6. `figma_to_code`
|
|
261
|
+
Compiles a node (or the selection) into component code.
|
|
262
|
+
- **`framework`**: `"react-tailwind"` (default), `"react-shadcn"`, `"react-native"`, `"vue-tailwind"`, `"html"`, `"swiftui"`, `"clean-spec"`.
|
|
263
|
+
- **`outputPath`**, **`componentName`**.
|
|
264
|
+
- **`target: "tokens"`**: export variables and styles. **`format`**: `"css"` (default, `:root` + light/dark modes), `"tailwind"`, `"typescript"`, `"w3c"` (DTCG), `"json"`; **`collection`** / **`mode`** / **`prefix`**; **`outputPath`**.
|
|
265
|
+
- **`target: "project"`**: scaffold a design system — `globals.css` (Tailwind layers + CSS variables), `tailwind.config.ts` and one component per local component / component set in `componentDir`. For React, a component set becomes one component whose props (`size`, `state`, ...) pick the variant, each variant generated from its own node (up to 24); Vue/HTML use the default variant. Existing files and components already in the codebase are skipped unless **`overwrite`**. Options: **`projectDir`**, **`componentDir`** (`src/components/ui`), **`stylesPath`** (`src/styles/globals.css`), **`tailwindConfigPath`** (`tailwind.config.ts`), **`limit`** (30, max 100); frameworks `react-tailwind`, `react-shadcn`, `vue-tailwind`, `html`.
|
|
234
266
|
|
|
235
267
|
Token exports use schema v2 and preserve full paint/effect stacks, alpha, gradient
|
|
236
268
|
transforms, collection modes and raw alias values. CSS, Tailwind, TypeScript and
|
|
@@ -252,99 +284,26 @@ Run `npm run build:plugin` after changing plugin source, and `npm run test:token
|
|
|
252
284
|
for the token/contract/version regression suite. Existing background servers need
|
|
253
285
|
a restart with the new binary to load the updated runtime.
|
|
254
286
|
|
|
287
|
+
### 7. `figma_verify_ui`
|
|
288
|
+
Compares rendered HTML/React styles against the Figma spec: size, per-side padding, gap, colors, per-corner radius and typography. Returns a match percentage, discrepancies and CSS/Tailwind fixes.
|
|
289
|
+
- **`nodeId`** / **`nodeName`**, **`computedStyles`**, **`url`** / **`selector`**.
|
|
290
|
+
- Typography: pass a text `nodeId` and browser `computedStyles` (`font-size` in px, numeric `font-weight`, `font-family`). Mixed text accepts `computedStyles.segments`, an ordered array matching the Figma runs. Fractional pixels are preserved.
|
|
255
291
|
|
|
256
|
-
###
|
|
257
|
-
Inspection of currently selected layers/frames, with shared-style compression when it reduces response size.
|
|
258
|
-
- **`detail`**: `"compact"` (default), `"minimal"`, `"full"`.
|
|
259
|
-
|
|
260
|
-
Compact/minimal `get_selection` and `get_design` responses may contain a root
|
|
261
|
-
`_compression` object with `version: 1` and a `styles` dictionary. For each node
|
|
262
|
-
with `styleRef`, merge `_compression.styles[node.styleRef]` into that node and
|
|
263
|
-
remove `styleRef`; remove root `_compression` to reconstruct the plugin payload
|
|
264
|
-
exactly. Style references are dictionary lookups, not inherited CSS. Payloads
|
|
265
|
-
with reserved-field collisions or no net byte savings stay inline. `detail: "full"` bypasses this Rust transformation and queries the plugin rather than a
|
|
266
|
-
compact selection cache. Plugin detail, visibility, precision, depth and node
|
|
267
|
-
budgets still determine the source payload; compression cannot restore fields
|
|
268
|
-
omitted there. Code generation continues to consume inline styles.
|
|
269
|
-
|
|
270
|
-
No token-reduction or AI-latency percentage is claimed without a tokenizer and
|
|
271
|
-
end-to-end benchmark on real designs.
|
|
272
|
-
- **`depth`**: Tree depth limit or `"full"`.
|
|
273
|
-
|
|
274
|
-
### 6. `figma_export_asset`
|
|
275
|
-
Exports any Figma node/layer directly into PNG, JPG, or SVG.
|
|
276
|
-
- **`outputPath`**: When specified (e.g. `src/assets/logo.png`), saves directly to disk and returns minimal JSON metadata instead of bloating context with large base64 strings.
|
|
277
|
-
- **`format`**: `"png"` (default), `"jpg"`, `"svg"`.
|
|
278
|
-
- **`scale`**: Scaling factor (default `2` for high-res).
|
|
279
|
-
|
|
280
|
-
### 7. `figma_export_assets`
|
|
281
|
-
Batch extracts all SVG icons and PNG images from a Figma frame/page directly into project directories.
|
|
282
|
-
- **`iconDir`**: Directory path for SVG icons (e.g. `src/assets/icons`).
|
|
283
|
-
- **`imageDir`**: Directory path for raster images (e.g. `public/images`).
|
|
284
|
-
- **`createBarrel`**: Automatically creates an `index.ts` barrel export file in `iconDir`.
|
|
285
|
-
|
|
286
|
-
### 8. `figma_index`
|
|
287
|
-
In-memory queries against pre-indexed Figma file structures (latency for your file: plugin **Bench** tab).
|
|
288
|
-
- **`operation`**:
|
|
289
|
-
- `"status"`: View index health and node counts.
|
|
290
|
-
- `"search_nodes"`: Search nodes by text name, query, and type (`FRAME`, `TEXT`, `COMPONENT`, `INSTANCE`).
|
|
291
|
-
- `"get_node"`: Instant node lookup by ID.
|
|
292
|
-
- `"search_components"`: Find component sets and variants.
|
|
293
|
-
- `"search_styles"`: Find paint, text, and effect styles.
|
|
294
|
-
- `"search_variables"`: Find design token variables by name or collection.
|
|
295
|
-
- `"refresh"`: Refresh page roots, or expand the frame specified by `nodeId`.
|
|
296
|
-
- `"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.
|
|
297
|
-
- `"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.
|
|
298
|
-
|
|
299
|
-
`status` includes per-tab dispatch cache hits/misses, bridge calls and total bridge wait time, plus process-wide tool call counts and total/average milliseconds. Node changes invalidate related subtrees; style changes, unknown events and global writes invalidate the whole cache. Detailed subtree data is loaded on demand. The initial index remains shallow.
|
|
300
|
-
|
|
301
|
-
For typography verification, call `figma_verify_ui` with a text `nodeId` and browser `computedStyles` (`font-size` in px, numeric `font-weight`, `font-family`). Mixed text accepts `computedStyles.segments`, an ordered array of CSS objects matching the Figma runs. Size checks preserve fractional pixels.
|
|
302
|
-
|
|
303
|
-
### 9. `figma_read`
|
|
304
|
-
Universal reader for advanced queries:
|
|
305
|
-
- **Design-to-code**: `read_nodes`, `get_css`, `get_component_map`, `get_unmapped_components`.
|
|
306
|
-
- **Inspection & Hierarchy**: `read_nodes`, `get_selection`, `get_page_nodes`, `scan_design`, `search_nodes`.
|
|
307
|
-
- **Design Systems**: `get_styles`, `get_variables`, `get_local_components`, `get_tokens`.
|
|
308
|
-
- **Visuals & Canvas**: `screenshot`, `export_svg`, `export_image`, `get_viewport`.
|
|
309
|
-
|
|
310
|
-
### 10. `figma_write`
|
|
311
|
-
Executes JavaScript draw commands inside the sandboxed VM to build or modify designs on the Figma canvas.
|
|
312
|
-
- Supports Auto-layout (`layoutMode`, `itemSpacing`, `padding`), typography, fills, strokes, corner radius, drop shadows, and component creation.
|
|
313
|
-
- Supports icon loading: `figma.loadIcon("ionicons", "heart", { size: 24, fill: "#ff4757" })`.
|
|
314
|
-
- Supports image insertion: `figma.loadImage(url, { width: 300, height: 200 })`.
|
|
315
|
-
- Supports Design Tokens: `createVariableCollection`, `createVariable`, `addVariableMode`, `applyVariable`.
|
|
316
|
-
- Supports Prototyping & Reactions: `setReactions`, `getReactions`, `setScrollBehavior`.
|
|
317
|
-
|
|
318
|
-
### 11. `figma_rules`
|
|
319
|
-
Audits the current Figma document and generates a complete design system rule sheet (color tokens, typography styles, variables, component catalog) from the in-memory cache.
|
|
292
|
+
### Legacy tool names
|
|
320
293
|
|
|
321
|
-
|
|
322
|
-
Fetches built-in documentation, design rules, layout guidelines, and code examples for `figma_write`.
|
|
323
|
-
- **`section`**: `"rules"` | `"layout"` | `"api"` | `"tokens"` | `"icons"`
|
|
294
|
+
Tools from v6 and earlier are hidden from `tools/list` but still accepted, so existing prompts keep working:
|
|
324
295
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
- **`nodeId`**: Optional Figma node ID to check specific match against local components.
|
|
337
|
-
|
|
338
|
-
### 15. `figma_prepare_design`
|
|
339
|
-
**All-In-One Multimodal Grounding Pack for AI**: The ultimate single-call tool for code generation that eliminates missing UI elements and icon guesswork.
|
|
340
|
-
- Simultaneously extracts **100% of visible text elements** (greeting, badges, timestamps, labels).
|
|
341
|
-
- Automatically **exports all SVG vector icons** into your local project directory (`iconDir`) and provides exact TypeScript import statements.
|
|
342
|
-
- Captures high-res **visual canvas screenshots** saved locally for immediate multimodal LLM inspection.
|
|
343
|
-
- Discovers existing components in your codebase for direct reuse.
|
|
344
|
-
- Generates an actionable **Implementation Checklist** ensuring complete design fidelity in 1 single shot.
|
|
345
|
-
- **`nodeId`**: Target Figma screen/frame ID.
|
|
346
|
-
- **`iconDir`**: Target directory for SVG icons (e.g. `src/assets/icons` or `assets/images`).
|
|
347
|
-
- **`projectDir`**: Base directory for component discovery.
|
|
296
|
+
| v6 tool | v7 call |
|
|
297
|
+
|---|---|
|
|
298
|
+
| `figma_get_selection` | `figma_read` `operation: "get_selection"` |
|
|
299
|
+
| `figma_inspect_node` | `figma_read` `operation: "inspect_node"` |
|
|
300
|
+
| `figma_export_asset` | `figma_read` `operation: "export_image"` / `"export_svg"` |
|
|
301
|
+
| `figma_index` | `figma_read` search operations, `index_status`, `refresh` |
|
|
302
|
+
| `figma_rules` | `figma_read` `operation: "rules"` |
|
|
303
|
+
| `figma_get_tokens` | `figma_to_code` `target: "tokens"` |
|
|
304
|
+
| `figma_export_assets` | `figma_prepare_design` `only: "assets"` |
|
|
305
|
+
| `figma_match_components` | `figma_prepare_design` `only: "components"` |
|
|
306
|
+
| `figma_task` | `figma_status` `task: "start" \| "end" \| "list"` |
|
|
348
307
|
|
|
349
308
|
---
|
|
350
309
|
|
|
@@ -354,13 +313,13 @@ Each plugin runtime has its own `sessionId`; tabs of the same file share a
|
|
|
354
313
|
`documentId`. Use `figma_status` to discover tabs. With multiple connected tabs,
|
|
355
314
|
pass an exact `sessionId`; the server rejects ambiguous routing.
|
|
356
315
|
|
|
357
|
-
For multiple agents editing one file, call `
|
|
316
|
+
For multiple agents editing one file, call `figma_status` with `task: "start"`,
|
|
358
317
|
`sessionId` and `frameId`, then pass the returned `taskId` on subsequent tools.
|
|
359
318
|
Each task reserves a separate FRAME directly under a PAGE or SECTION, without
|
|
360
319
|
shared component definitions. Writes stay inside that frame; global styles,
|
|
361
320
|
variables, page and selection changes are blocked. A frame cannot be reserved
|
|
362
|
-
twice, including across tabs of the same document. Release it with `
|
|
363
|
-
using `
|
|
321
|
+
twice, including across tabs of the same document. Release it with `figma_status`
|
|
322
|
+
using `task: "end"` and `taskId`; restart tasks after a plugin reload.
|
|
364
323
|
|
|
365
324
|
Tools on one tab run sequentially, including every awaited operation in a
|
|
366
325
|
`figma_write` call. Different tabs run independently. `npm run test:sessions`
|
|
@@ -373,14 +332,14 @@ Version 4 requires both server and plugin runtime protocol 3. Update both and
|
|
|
373
332
|
restart the plugin. Opening the bridge indexes only visible top-level nodes on
|
|
374
333
|
the active page, plus local styles and variables. Selection expands the selected
|
|
375
334
|
frame. Component catalogue discovery is deferred; request it with
|
|
376
|
-
`
|
|
335
|
+
`figma_read({ operation: "refresh", includeComponents: true })`.
|
|
377
336
|
|
|
378
337
|
Search covers indexed scopes only. Inspect `scope` and `complete` in search
|
|
379
338
|
responses; an empty result does not prove a layer is absent from the file.
|
|
380
339
|
Refresh a specific frame to expand its scope:
|
|
381
340
|
|
|
382
341
|
```js
|
|
383
|
-
|
|
342
|
+
figma_read({ operation: "refresh", nodeId: "123:456", expandInstances: true })
|
|
384
343
|
figma_read({ operation: "read_nodes", nodeId: "123:456",
|
|
385
344
|
fields: ["geometry", "content", "text", "style", "layout", "tokens", "component"],
|
|
386
345
|
limit: 200, expandInstances: true })
|
|
@@ -406,9 +365,9 @@ options; no master component is substituted for an instance.
|
|
|
406
365
|
|
|
407
366
|
Breaking output changes: public `get_design`, `get_design_context` and
|
|
408
367
|
`get_node_detail` aliases now return the same envelope as `read_nodes`.
|
|
409
|
-
`
|
|
368
|
+
`figma_read` `inspect_node`, `subtree` and `typography` also return
|
|
410
369
|
that envelope. Replace nested `children` consumers with `nodes` and parent/child
|
|
411
|
-
IDs. Replace
|
|
370
|
+
IDs. Replace `subtree` `maxNodes` with `limit` and cursor pagination. Internal
|
|
412
371
|
script helpers used for code generation retain their specialized formats.
|
|
413
372
|
|
|
414
373
|
Canvas traversal reads each child list once, yields after an 8ms slice, and
|
|
@@ -480,8 +439,18 @@ cargo build --release
|
|
|
480
439
|
|
|
481
440
|
# Rebuild Figma plugin bundle (if plugin-src/ is modified)
|
|
482
441
|
node scripts/build-plugin.js
|
|
442
|
+
|
|
443
|
+
# Codegen accuracy: render html output in headless Chrome and compare every
|
|
444
|
+
# layer (size, padding, gap, radius, typography, colors) with the spec
|
|
445
|
+
npm run accuracy # bundled fixture
|
|
446
|
+
npm run accuracy -- path/to/context.json --all # your own get_design_context dump
|
|
483
447
|
```
|
|
484
448
|
|
|
449
|
+
`npm run accuracy` needs Chrome (`CHROME_PATH` to override) and network access
|
|
450
|
+
for the Tailwind play CDN and Google Fonts. `--min <percent>` fails below a
|
|
451
|
+
threshold. The bundled checkout fixture currently matches 98.9% of 981 checks;
|
|
452
|
+
remaining misses are sub-pixel text metrics.
|
|
453
|
+
|
|
485
454
|
---
|
|
486
455
|
|
|
487
456
|
## 📄 License
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "figma-rust-mcp",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "8.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,7 +21,8 @@
|
|
|
21
21
|
"test:tokens": "node --test scripts/test-plugin-tokens.mjs && cargo test",
|
|
22
22
|
"dev": "cargo run",
|
|
23
23
|
"bench": "cargo build --release && node scripts/bench.mjs",
|
|
24
|
-
"test:sessions": "cargo build && node --test scripts/test-bridge-sessions.mjs"
|
|
24
|
+
"test:sessions": "cargo build && node --test scripts/test-bridge-sessions.mjs",
|
|
25
|
+
"accuracy": "cargo build && node scripts/codegen-accuracy.mjs"
|
|
25
26
|
},
|
|
26
27
|
"engines": {
|
|
27
28
|
"node": ">=18"
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Measures figma_to_code accuracy: renders html-annotated output in headless Chrome
|
|
2
|
+
// and compares every layer's box and styles with the get_design_context spec.
|
|
3
|
+
// node scripts/codegen-accuracy.mjs [context.json ...] [--min <percent>] [--all]
|
|
4
|
+
// Default context: tests/fixtures/design-context-checkout.json. Needs Chrome and
|
|
5
|
+
// network access for the Tailwind play CDN and Google Fonts.
|
|
6
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
7
|
+
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
|
|
11
|
+
const args = process.argv.slice(2);
|
|
12
|
+
const minIndex = args.indexOf('--min');
|
|
13
|
+
const min = minIndex >= 0 ? Number(args.splice(minIndex, 2)[1]) : null;
|
|
14
|
+
const showAll = args.includes('--all') && args.splice(args.indexOf('--all'), 1);
|
|
15
|
+
const files = args.length ? args : ['tests/fixtures/design-context-checkout.json'];
|
|
16
|
+
const binary = process.env.FIGMA_TEST_BINARY || 'target/debug/figma-rust-mcp';
|
|
17
|
+
const chromePath = process.env.CHROME_PATH || {
|
|
18
|
+
darwin: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
|
19
|
+
win32: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
|
20
|
+
}[process.platform] || 'google-chrome';
|
|
21
|
+
|
|
22
|
+
const px = v => (typeof v === 'number' ? v : parseFloat(v));
|
|
23
|
+
const box = v => { const p = String(v ?? '').trim().split(/\s+/).map(px); return [p[0], p[1] ?? p[0], p[2] ?? p[0], p[3] ?? p[1] ?? p[0]]; };
|
|
24
|
+
const rgb = hex => { const h = hex.replace('#', ''); return `rgb(${parseInt(h.slice(0, 2), 16)}, ${parseInt(h.slice(2, 4), 16)}, ${parseInt(h.slice(4, 6), 16)})`; };
|
|
25
|
+
const WEIGHTS = { thin: 100, extralight: 200, light: 300, regular: 400, normal: 400, medium: 500, semibold: 600, bold: 700, extrabold: 800, black: 900 };
|
|
26
|
+
|
|
27
|
+
function walk(node, out = []) {
|
|
28
|
+
out.push(node);
|
|
29
|
+
for (const child of node.children || []) walk(child, out);
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Tokens resolve through the layer's own solid paint, so colors compare as rendered.
|
|
34
|
+
function tokenCss(layers) {
|
|
35
|
+
const vars = new Map();
|
|
36
|
+
for (const n of layers) {
|
|
37
|
+
const color = n.paintData?.find(p => p.type === 'SOLID' && p.color)?.color;
|
|
38
|
+
for (const ref of [n.fill, n.text?.color]) {
|
|
39
|
+
const name = /^var\((--[^)]+)\)$/.exec(ref || '')?.[1];
|
|
40
|
+
if (name && color && /^#[0-9a-f]{6}$/i.test(color)) vars.set(name, color);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return `:root{${[...vars].map(([k, v]) => `${k}:${v}`).join(';')}}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resolveColor(value, layer) {
|
|
47
|
+
if (!value) return null;
|
|
48
|
+
if (value.startsWith('var(')) value = layer.paintData?.find(p => p.type === 'SOLID')?.color;
|
|
49
|
+
return value && /^#[0-9a-f]{6}$/i.test(value) ? rgb(value) : null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// One entry per measurable property: [group, expected, actual, tolerance].
|
|
53
|
+
function checks(layer, got) {
|
|
54
|
+
const out = [];
|
|
55
|
+
const num = (group, expected, actual, tol = 1) => {
|
|
56
|
+
if (Number.isFinite(expected)) out.push([group, expected, actual, tol]);
|
|
57
|
+
};
|
|
58
|
+
num('width', layer.size?.width, got.width);
|
|
59
|
+
num('height', layer.size?.height, got.height);
|
|
60
|
+
const layout = layer.layout;
|
|
61
|
+
if (layout?.padding) box(layout.padding).forEach((v, i) => num('padding', v, got.padding[i], 0.5));
|
|
62
|
+
if (layout?.display === 'flex' && layout.gap != null) {
|
|
63
|
+
num('gap', px(layout.gap), layout.flexDirection === 'column' ? got.rowGap : got.columnGap, 0.5);
|
|
64
|
+
}
|
|
65
|
+
// Browsers clamp radii to half the shorter side (rounded-full is 9999px); compare what renders.
|
|
66
|
+
const clamp = v => Math.min(v, Math.min(got.width, got.height) / 2);
|
|
67
|
+
if (layer.borderRadius) box(layer.borderRadius).forEach((v, i) => num('radius', clamp(v), clamp(got.radius[i]), 0.5));
|
|
68
|
+
const text = layer.text;
|
|
69
|
+
if (layer.type === 'TEXT' && text) {
|
|
70
|
+
num('fontSize', text.fontSize, got.fontSize, 0.1);
|
|
71
|
+
num('fontWeight', text.fontWeightNumeric ?? WEIGHTS[String(text.fontWeight).toLowerCase().replace(/\s/g, '')], got.fontWeight, 0);
|
|
72
|
+
if (String(text.lineHeight).endsWith('%')) num('lineHeight', text.fontSize * px(text.lineHeight) / 100, got.lineHeight, 0.5);
|
|
73
|
+
else if (text.lineHeight != null && text.lineHeight !== 'auto') num('lineHeight', px(text.lineHeight), got.lineHeight, 0.5);
|
|
74
|
+
const color = resolveColor(text.color, layer);
|
|
75
|
+
if (color) out.push(['color', color, got.color, 0]);
|
|
76
|
+
} else if (layer.backgroundCss?.property === 'background-color') {
|
|
77
|
+
const bg = resolveColor(layer.backgroundCss.value, layer);
|
|
78
|
+
if (bg) out.push(['background', bg, got.background, 0]);
|
|
79
|
+
}
|
|
80
|
+
return out;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function launchChrome() {
|
|
84
|
+
const profile = mkdtempSync(join(tmpdir(), 'codegen-accuracy-'));
|
|
85
|
+
const chrome = spawn(chromePath, ['--headless=new', '--remote-debugging-port=0', `--user-data-dir=${profile}`, '--hide-scrollbars', 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
86
|
+
const port = await new Promise((resolve, reject) => {
|
|
87
|
+
let log = '';
|
|
88
|
+
chrome.stderr.on('data', b => { log += b; const m = /DevTools listening on ws:\/\/[^:]+:(\d+)/.exec(log); if (m) resolve(m[1]); });
|
|
89
|
+
chrome.on('exit', code => reject(new Error(`Chrome exited (${code}): ${log.slice(-500)}`)));
|
|
90
|
+
});
|
|
91
|
+
return { chrome, port };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function cdpPage(port) {
|
|
95
|
+
const target = await (await fetch(`http://127.0.0.1:${port}/json/new?about:blank`, { method: 'PUT' })).json();
|
|
96
|
+
const ws = new WebSocket(target.webSocketDebuggerUrl);
|
|
97
|
+
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
|
|
98
|
+
let id = 0;
|
|
99
|
+
const pending = new Map();
|
|
100
|
+
const events = [];
|
|
101
|
+
ws.onmessage = e => {
|
|
102
|
+
const msg = JSON.parse(e.data);
|
|
103
|
+
if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); } else events.push(msg);
|
|
104
|
+
};
|
|
105
|
+
const send = (method, params = {}) => new Promise((resolve, reject) => {
|
|
106
|
+
const msgId = ++id;
|
|
107
|
+
pending.set(msgId, msg => (msg.error ? reject(new Error(`${method}: ${msg.error.message}`)) : resolve(msg.result)));
|
|
108
|
+
ws.send(JSON.stringify({ id: msgId, method, params }));
|
|
109
|
+
});
|
|
110
|
+
return { send, events, close: () => ws.close() };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const MEASURE = `(() => [...document.querySelectorAll('[data-node-id]')].map(el => {
|
|
114
|
+
const r = el.getBoundingClientRect(), s = getComputedStyle(el), n = v => parseFloat(v) || 0;
|
|
115
|
+
return { id: el.dataset.nodeId, width: r.width, height: r.height,
|
|
116
|
+
padding: [n(s.paddingTop), n(s.paddingRight), n(s.paddingBottom), n(s.paddingLeft)],
|
|
117
|
+
rowGap: n(s.rowGap), columnGap: n(s.columnGap),
|
|
118
|
+
radius: [n(s.borderTopLeftRadius), n(s.borderTopRightRadius), n(s.borderBottomRightRadius), n(s.borderBottomLeftRadius)],
|
|
119
|
+
fontSize: n(s.fontSize), fontWeight: n(s.fontWeight), lineHeight: s.lineHeight === 'normal' ? NaN : n(s.lineHeight),
|
|
120
|
+
color: s.color, background: s.backgroundColor };
|
|
121
|
+
}))()`;
|
|
122
|
+
|
|
123
|
+
async function measure(page, file) {
|
|
124
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
125
|
+
const root = raw.context || raw;
|
|
126
|
+
const layers = walk(root);
|
|
127
|
+
const html = execFileSync(binary, ['--codegen', 'html-annotated', file], { encoding: 'utf8' });
|
|
128
|
+
const families = [...new Set(layers.map(n => n.text?.fontFamily).filter(Boolean))];
|
|
129
|
+
const fonts = families.map(f => `family=${encodeURIComponent(f)}:ital,wght@0,100;0,300;0,400;0,700;0,900;1,400`).join('&');
|
|
130
|
+
const doc = `<!doctype html><html><head><meta charset="utf-8">
|
|
131
|
+
<script src="https://cdn.tailwindcss.com/3.4.17"></script>
|
|
132
|
+
${fonts ? `<link rel="stylesheet" href="https://fonts.googleapis.com/css2?${fonts}&display=block">` : ''}
|
|
133
|
+
<style>${tokenCss(layers)} body{margin:0}</style></head><body>${html}</body></html>`;
|
|
134
|
+
const path = join(mkdtempSync(join(tmpdir(), 'codegen-page-')), 'index.html');
|
|
135
|
+
writeFileSync(path, doc);
|
|
136
|
+
const width = Math.ceil(root.size?.width || 1440);
|
|
137
|
+
await page.send('Emulation.setDeviceMetricsOverride', { width, height: Math.ceil(root.size?.height || 900), deviceScaleFactor: 1, mobile: false });
|
|
138
|
+
await page.send('Page.navigate', { url: `file://${path}` });
|
|
139
|
+
await page.send('Runtime.evaluate', { expression: 'new Promise(r => (document.readyState === "complete" ? r() : addEventListener("load", r))).then(() => document.fonts.ready).then(() => new Promise(r => setTimeout(r, 300)))', awaitPromise: true });
|
|
140
|
+
const { result } = await page.send('Runtime.evaluate', { expression: MEASURE, returnByValue: true });
|
|
141
|
+
const rendered = new Map(result.value.map(m => [m.id, m]));
|
|
142
|
+
const byGroup = new Map();
|
|
143
|
+
const misses = [];
|
|
144
|
+
let missing = 0;
|
|
145
|
+
for (const layer of layers) {
|
|
146
|
+
const got = rendered.get(layer.id);
|
|
147
|
+
if (!got) { if (layer.id && !layer.isMask) missing++; continue; }
|
|
148
|
+
for (const [group, expected, actual, tol] of checks(layer, got)) {
|
|
149
|
+
const ok = typeof expected === 'number' ? Math.abs(expected - actual) <= tol : expected === actual;
|
|
150
|
+
const g = byGroup.get(group) || { pass: 0, total: 0 };
|
|
151
|
+
g.total++; if (ok) g.pass++;
|
|
152
|
+
byGroup.set(group, g);
|
|
153
|
+
if (!ok) misses.push({ group, layer: `${layer.name} (${layer.id})`, expected, actual: typeof actual === 'number' ? Math.round(actual * 100) / 100 : actual });
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { byGroup, misses, missing, layers: layers.length };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const { chrome, port } = await launchChrome();
|
|
160
|
+
let worst = 100;
|
|
161
|
+
try {
|
|
162
|
+
const page = await cdpPage(port);
|
|
163
|
+
await page.send('Page.enable');
|
|
164
|
+
for (const file of files) {
|
|
165
|
+
const { byGroup, misses, missing, layers } = await measure(page, file);
|
|
166
|
+
const pass = [...byGroup.values()].reduce((a, g) => a + g.pass, 0);
|
|
167
|
+
const total = [...byGroup.values()].reduce((a, g) => a + g.total, 0);
|
|
168
|
+
const pct = total ? (pass * 100) / total : 0;
|
|
169
|
+
worst = Math.min(worst, pct);
|
|
170
|
+
console.log(`\n${file}: ${pct.toFixed(1)}% of ${total} checks match (${layers} layers${missing ? `, ${missing} not rendered` : ''})`);
|
|
171
|
+
for (const [group, g] of [...byGroup].sort()) console.log(` ${group.padEnd(11)} ${String(g.pass).padStart(4)}/${String(g.total).padEnd(4)} ${((g.pass * 100) / g.total).toFixed(1)}%`);
|
|
172
|
+
for (const m of showAll ? misses : misses.slice(0, 12)) console.log(` ✗ ${m.group} ${m.layer}: expected ${m.expected}, got ${m.actual}`);
|
|
173
|
+
if (!showAll && misses.length > 12) console.log(` … ${misses.length - 12} more`);
|
|
174
|
+
}
|
|
175
|
+
page.close();
|
|
176
|
+
} finally {
|
|
177
|
+
chrome.kill();
|
|
178
|
+
}
|
|
179
|
+
if (min != null && worst < min) {
|
|
180
|
+
console.error(`\nAccuracy ${worst.toFixed(1)}% is below --min ${min}%`);
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
@@ -3,9 +3,14 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import { createServer } from 'node:net';
|
|
5
5
|
import { createInterface } from 'node:readline';
|
|
6
|
-
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { mkdtempSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { tmpdir } from 'node:os';
|
|
8
|
+
import { join } from 'node:path';
|
|
7
9
|
import vm from 'node:vm';
|
|
8
10
|
|
|
11
|
+
// Spawned servers persist tool usage under HOME; keep the user's real file untouched.
|
|
12
|
+
process.env.HOME = process.env.USERPROFILE = mkdtempSync(join(tmpdir(), 'figma-mcp-test-'));
|
|
13
|
+
|
|
9
14
|
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
10
15
|
const deferred = () => { let resolve; const promise = new Promise(r => { resolve = r; }); return { promise, resolve }; };
|
|
11
16
|
async function until(predicate) {
|
|
@@ -106,11 +111,11 @@ test('multiple tabs, reconnect ownership and independent frame tasks across MCP
|
|
|
106
111
|
assert.equal(ambiguous.isError, true); assert.match(ambiguous.content[0].text, /Ambiguous/);
|
|
107
112
|
const missing = await rpc('figma_read', { operation: 'get_design', sessionId: 'missing' });
|
|
108
113
|
assert.equal(missing.isError, true);
|
|
109
|
-
const taskA = decode(ok(await first('
|
|
110
|
-
const collision = await second('
|
|
114
|
+
const taskA = decode(ok(await first('figma_status', { task: 'start', sessionId: 'tab-a', frameId: 'frame-a' })));
|
|
115
|
+
const collision = await second('figma_status', { task: 'start', sessionId: 'tab-b', frameId: 'frame-a' });
|
|
111
116
|
assert.equal(collision.isError, true); assert.match(collision.content[0].text, /reserved/);
|
|
112
|
-
const taskB = decode(ok(await second('
|
|
113
|
-
const taskC = decode(ok(await rpc('
|
|
117
|
+
const taskB = decode(ok(await second('figma_status', { task: 'start', sessionId: 'tab-a', frameId: 'frame-b' })));
|
|
118
|
+
const taskC = decode(ok(await rpc('figma_status', { task: 'start', sessionId: 'tab-b', frameId: 'frame-c' })));
|
|
114
119
|
assert.equal(decode(ok(await first('figma_get_selection', { taskId: taskA.taskId }))).id, 'frame-a');
|
|
115
120
|
assert.equal(decode(ok(await second('figma_get_selection', { taskId: taskB.taskId }))).id, 'frame-b');
|
|
116
121
|
const writeA = first('figma_write', { taskId: taskA.taskId, code: 'await figma.modify({id:"child-a",note:"paused"}); await figma.modify({id:"child-a",note:"second"});' });
|
|
@@ -149,14 +154,14 @@ test('multiple tabs, reconnect ownership and independent frame tasks across MCP
|
|
|
149
154
|
tabA.socket.send(JSON.stringify({ id: waiting.id, sessionId: 'tab-a', success: true, data: { id: 'frame-a', tab: 'tab-a' } }));
|
|
150
155
|
assert.equal(decode(ok(await routed)).tab, 'tab-a');
|
|
151
156
|
assert.ok(ignored.id);
|
|
152
|
-
ok(await first('
|
|
153
|
-
ok(await second('
|
|
157
|
+
ok(await first('figma_status', { task: 'end', taskId: taskA.taskId }));
|
|
158
|
+
ok(await second('figma_status', { task: 'end', taskId: taskB.taskId }));
|
|
154
159
|
tabB.socket.close();
|
|
155
160
|
await until(async () => !(await (await fetch(`${base}/sessions`)).json()).sessions.find(session => session.id === 'tab-b').connected);
|
|
156
|
-
assert.equal(decode(ok(await rpc('
|
|
161
|
+
assert.equal(decode(ok(await rpc('figma_status', { task: 'end', taskId: taskC.taskId }))).pluginDisconnected, true);
|
|
157
162
|
await plugin('tab-b', tabB);
|
|
158
|
-
const restarted = decode(ok(await rpc('
|
|
159
|
-
ok(await rpc('
|
|
163
|
+
const restarted = decode(ok(await rpc('figma_status', { task: 'start', sessionId: 'tab-b', frameId: 'frame-c' })));
|
|
164
|
+
ok(await rpc('figma_status', { task: 'end', taskId: restarted.taskId }));
|
|
160
165
|
});
|
|
161
166
|
|
|
162
167
|
test('index sync travels from plugin through UI to bridge and returns a matching confirmation', { timeout: 20000 }, async t => {
|
|
@@ -54,6 +54,17 @@ test('compact tree and scan preserve mixed typography instead of using the first
|
|
|
54
54
|
assert.equal(r.resolveTextStyle(node).fontWeight, undefined);
|
|
55
55
|
});
|
|
56
56
|
|
|
57
|
+
test('text list formatting is reported only when every paragraph shares it', () => {
|
|
58
|
+
const r = runtime({ root: { id: '0:0' }, currentPage: { id: '0:1' } });
|
|
59
|
+
vm.runInContext(readFileSync('plugin-src/read-helpers.js', 'utf8'), r);
|
|
60
|
+
const text = lists => ({ type: 'TEXT', characters: 'Account', fontSize: 16, fontName: { family: 'Lato', style: 'Regular' },
|
|
61
|
+
getStyledTextSegments: () => lists.map(type => ({ listOptions: { type } })) });
|
|
62
|
+
assert.equal(r.resolveTextStyle(text(['UNORDERED'])).listType, 'UNORDERED');
|
|
63
|
+
assert.equal(r.resolveTextStyle(text(['ORDERED', 'ORDERED'])).listType, 'ORDERED');
|
|
64
|
+
assert.equal(r.resolveTextStyle(text(['UNORDERED', 'NONE'])).listType, undefined);
|
|
65
|
+
assert.equal(r.resolveTextStyle(text(['NONE'])).listType, undefined);
|
|
66
|
+
});
|
|
67
|
+
|
|
57
68
|
test('scan_design expands instances by default and flags opaque ones as incomplete', async () => {
|
|
58
69
|
const text = { id: '1:3', name: 'Label', type: 'TEXT', characters: 'Hi', fontSize: 12, fontName: { family: 'Inter', style: 'Regular' }, visible: true };
|
|
59
70
|
const inst = { id: '1:2', name: 'Button', type: 'INSTANCE', visible: true, children: [text], getMainComponentAsync: async () => null };
|
package/scripts/test-rust-mcp.sh
CHANGED
|
@@ -22,13 +22,13 @@ INPUT=$(cat << 'JSON'
|
|
|
22
22
|
JSON
|
|
23
23
|
)
|
|
24
24
|
|
|
25
|
-
OUTPUT=$(echo "$INPUT" | $BIN_PATH --port 38499 2>/dev/null)
|
|
25
|
+
OUTPUT=$(echo "$INPUT" | HOME="$(mktemp -d)" $BIN_PATH --port 38499 2>/dev/null)
|
|
26
26
|
echo "$OUTPUT" | head -n 4
|
|
27
27
|
|
|
28
28
|
# Verify tool registration in JSON-RPC output
|
|
29
|
-
echo "--- Test 3: Verify Tool Registrations (
|
|
30
|
-
for TOOL in figma_status
|
|
31
|
-
echo "$OUTPUT" | grep -q "\"name\":\"$TOOL\"" && echo "✓ $TOOL registered"
|
|
29
|
+
echo "--- Test 3: Verify Tool Registrations (7 tools) ---"
|
|
30
|
+
for TOOL in figma_status figma_read figma_write figma_docs figma_prepare_design figma_to_code figma_verify_ui; do
|
|
31
|
+
echo "$OUTPUT" | grep -q "\"name\":\"$TOOL\"" && echo "✓ $TOOL registered" || { echo "✗ $TOOL missing"; exit 1; }
|
|
32
32
|
done
|
|
33
33
|
|
|
34
34
|
# Test 4: Node/NPX wrapper test
|