figma-rust-mcp 3.2.5 → 3.2.8

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
@@ -28,7 +28,7 @@ Enables AI agents (Google Antigravity, Claude Code, Cursor, Windsurf, VS Code, Z
28
28
  ┌────────────────────────────────────────────────────────────────────────┐
29
29
  │ Terminal / Background Service │
30
30
  │ figma-mcp (Pure Rust Engine) │
31
- │ • MCP SSE Transport (/sse, /message) & Direct HTTP (/mcp) │
31
+ │ • MCP Streamable HTTP (/mcp) & legacy SSE (/sse, /message) │
32
32
  │ • Dynamic Runtime Server (/plugin/code.js, /plugin/ui.html) │
33
33
  │ • In-Memory Fast Index (<1ms Lookups & Incremental Diffs) │
34
34
  │ • Design-to-Code Compiler (React/Tailwind, Vue, RN, SwiftUI) │
@@ -36,8 +36,8 @@ 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/sse)
40
- │ (Dynamic Code Streaming & Hot-Reload) │ (JSON-RPC 2.0 / SSE)
39
+ │ (ws://127.0.0.1:38451/ws) │ (http://127.0.0.1:38451/mcp)
40
+ │ (Dynamic Code Streaming & Hot-Reload) │ (JSON-RPC 2.0 / Streamable HTTP)
41
41
  ┌─────────┴─────────┐ ┌─────────┴─────────┐
42
42
  │ Figma Desktop │ │ AI Assistant(s) │
43
43
  │ (Thin Loader) │ │ Google Antigravity│
@@ -107,6 +107,25 @@ npx -y figma-rust-mcp@latest --uninstall-service
107
107
 
108
108
  ---
109
109
 
110
+ ### Windows background mode
111
+
112
+ Double-clicking `figma-mcp.exe` starts the server in a detached process and closes
113
+ its launcher console. From PowerShell or Command Prompt, use:
114
+
115
+ ```powershell
116
+ .\figma-mcp.exe --background
117
+ ```
118
+
119
+ The server keeps running after the terminal closes. Logs are appended to
120
+ `%LOCALAPPDATA%\figma-mcp\logs\server.log`. Use `--server` for the foreground
121
+ terminal dashboard, or `--stdio` for an MCP client subprocess.
122
+
123
+ `--install-service` through the npm runner configures background startup at login.
124
+ Re-run the installation command to update an existing Windows task. Removing the
125
+ task disables future login startup; stop an already detached server through Task
126
+ Manager (`figma-mcp.exe`). The launcher console may appear briefly when opening
127
+ the executable; the server itself does not retain a console window.
128
+
110
129
  ### 4. Build from Source (Optional)
111
130
 
112
131
  If you prefer building directly with the [Rust toolchain](https://rustup.rs/) (`cargo >= 1.80`):
@@ -122,7 +141,22 @@ cargo build --release
122
141
 
123
142
  ### 5. Configure Your MCP Client
124
143
 
125
- #### Google Antigravity (SSE Transport - Recommended)
144
+ #### Codex and clients using Streamable HTTP (Recommended)
145
+
146
+ Use the `/mcp` endpoint. It accepts `POST` JSON-RPC requests directly:
147
+
148
+ ```toml
149
+ [mcp_servers.figma-mcp]
150
+ url = "http://127.0.0.1:38451/mcp"
151
+ ```
152
+
153
+ For Codex CLI, the equivalent command is:
154
+
155
+ ```bash
156
+ codex mcp add figma-mcp --url http://127.0.0.1:38451/mcp
157
+ ```
158
+
159
+ #### Google Antigravity (SSE Transport)
126
160
  Add to your `~/.gemini/config/mcp_config.json` or project `.agents/mcp_config.json`:
127
161
  ```json
128
162
  {
@@ -136,7 +170,7 @@ Add to your `~/.gemini/config/mcp_config.json` or project `.agents/mcp_config.js
136
170
 
137
171
  #### Claude Code / Cursor / Windsurf / VS Code / Zed
138
172
 
139
- **Option A: SSE Transport (Recommended for background service)**
173
+ **Option A: SSE Transport (for clients that require SSE)**
140
174
  ```json
141
175
  {
142
176
  "mcpServers": {
@@ -147,6 +181,11 @@ Add to your `~/.gemini/config/mcp_config.json` or project `.agents/mcp_config.js
147
181
  }
148
182
  ```
149
183
 
184
+ The SSE transport uses `GET /sse` to open the event stream and `POST /message`
185
+ (or `/messages`) for JSON-RPC messages. Do not configure a Streamable HTTP
186
+ client such as Codex against `/sse`: that endpoint does not accept `POST`, so
187
+ the initialize request will return HTTP 405. Use `/mcp` instead.
188
+
150
189
  **Option B: Stdio Subprocess (via NPX)**
151
190
  ```json
152
191
  {
@@ -159,6 +198,18 @@ Add to your `~/.gemini/config/mcp_config.json` or project `.agents/mcp_config.js
159
198
  }
160
199
  ```
161
200
 
201
+ ### 6. Diagnose a stale Figma runtime
202
+
203
+ Check the runtime bundle currently served to Figma Desktop:
204
+
205
+ ```bash
206
+ curl http://127.0.0.1:38451/plugin/version
207
+ ```
208
+
209
+ The response includes `version` and `runtimeHash`. If the hash changes after an
210
+ upgrade, the dynamic plugin runtime is refreshed automatically. Restart the
211
+ Figma plugin only if the reported hash does not change.
212
+
162
213
  ---
163
214
 
164
215
  ## 🛠️ MCP Tools Reference
@@ -183,6 +234,27 @@ Exports Figma Variables (Design Tokens), Color Styles, Typography Styles, and El
183
234
  - **`outputPath`**: Writes directly to disk (e.g. `src/styles/tokens.css` or `tailwind.config.js`).
184
235
  - **`collection`** / **`mode`** / **`prefix`**: Optional filters and naming prefixes.
185
236
 
237
+ Token exports use schema v2 and preserve full paint/effect stacks, alpha, gradient
238
+ transforms, collection modes and raw alias values. CSS, Tailwind, TypeScript and
239
+ JSON share one normalized model. The `w3c` output uses DTCG 2025.10 color,
240
+ dimension, gradient-stop and shadow values, with Figma-specific geometry and
241
+ unsupported values retained in `$extensions`.
242
+
243
+ Linear gradients on a node are projected using its actual dimensions. A diagonal
244
+ gradient style has no target aspect ratio: use `get_css` on its consuming node.
245
+ Radial/angular/diamond paints, unsupported blend modes and progressive blurs
246
+ retain their raw data and report diagnostics recommending SVG instead of emitting
247
+ approximate CSS. Unknown modes, unresolved aliases and sanitized name collisions
248
+ fail explicitly. CSS exports include non-default modes under `data-theme`
249
+ selectors; opacity/font-weight variables stay unitless.
250
+
251
+ The plugin UI badge is stamped from the build version and updated from the
252
+ connected server handshake. Package/Cargo/tag mismatches fail the build pipeline.
253
+ Run `npm run build:plugin` after changing plugin source, and `npm run test:tokens`
254
+ for the token/contract/version regression suite. Existing background servers need
255
+ a restart with the new binary to load the updated runtime.
256
+
257
+
186
258
  ### 5. `figma_get_selection`
187
259
  Token-compressed inspection of currently selected layers/frames on the Figma canvas. Returns compacted layout and typography hierarchy with **60–80% fewer tokens**.
188
260
  - **`detail`**: `"compact"` (recommended), `"minimal"`, `"full"`.
package/bin/figma-mcp.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * caches it locally, and executes it transparently.
7
7
  */
8
8
 
9
- import { spawn, execSync } from 'node:child_process';
9
+ import { spawn, execSync, execFileSync } from 'node:child_process';
10
10
  import fs from 'node:fs';
11
11
  import path from 'node:path';
12
12
  import os from 'node:os';
@@ -19,7 +19,7 @@ const __dirname = path.dirname(__filename);
19
19
 
20
20
  // Read package.json version
21
21
  const pkgPath = path.resolve(__dirname, '../package.json');
22
- let pkgVersion = '2.9.5';
22
+ let pkgVersion = null;
23
23
  let repoOwner = 'BuiHung1612';
24
24
  let repoName = 'figma-mcp';
25
25
 
@@ -36,6 +36,7 @@ try {
36
36
  }
37
37
  }
38
38
  } catch (_) {}
39
+ if (!pkgVersion) throw new Error(`Cannot read package version from ${pkgPath}`);
39
40
 
40
41
  function getPlatformArch() {
41
42
  const platform = process.platform;
@@ -293,8 +294,10 @@ WantedBy=default.target
293
294
  } else if (platform === 'win32') {
294
295
  const taskName = 'FigmaMCPServer';
295
296
  // Use schtasks to create a task that runs at login
296
- const cmd = `schtasks /Create /F /TN "${taskName}" /TR "${binPath}" /SC ONLOGON /RL HIGHEST`;
297
- execSync(cmd, { stdio: 'inherit' });
297
+ // The native launcher detaches the server and redirects output to a log file.
298
+ const taskCommand = `"${binPath}" --background`;
299
+ execFileSync('schtasks', ['/Create', '/F', '/TN', taskName,
300
+ '/TR', taskCommand, '/SC', 'ONLOGON'], { stdio: 'inherit', windowsHide: true });
298
301
 
299
302
  // Start it immediately too
300
303
  try { execSync(`schtasks /Run /TN "${taskName}"`, { stdio: 'pipe' }); } catch (_) {}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "figma-rust-mcp",
3
- "version": "3.2.5",
3
+ "version": "3.2.8",
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-mcp",
6
6
  "type": "module",
@@ -16,9 +16,10 @@
16
16
  "LICENSE"
17
17
  ],
18
18
  "scripts": {
19
- "build": "cargo build --release",
19
+ "build": "npm run build:plugin && cargo build --release",
20
20
  "build:plugin": "node scripts/build-plugin.js",
21
21
  "test": "./scripts/test-rust-mcp.sh",
22
+ "test:tokens": "node --test scripts/test-plugin-tokens.mjs && cargo test",
22
23
  "dev": "cargo run"
23
24
  },
24
25
  "engines": {
package/plugin/code.js CHANGED
@@ -1,4 +1,4 @@
1
- // ─── FIGMA MCP BRIDGE — DYNAMIC THIN LOADER (v3.0.0) ───────────────────────────
1
+ // ─── FIGMA MCP BRIDGE — DYNAMIC THIN LOADER ───────────────────────────
2
2
  // This file is a permanent micro-stub. You never need to reinstall or edit this file.
3
3
  // The actual runtime is loaded dynamically from the local figma-mcp Rust engine.
4
4
 
@@ -1,13 +1,19 @@
1
- // Build script — concatenate src/plugin/*.js into plugin/code.js
1
+ // Build script — concatenate plugin-src/*.js into plugin-runtime/code.js
2
2
  // No module system used: files are plain JS sharing a single scope when concatenated.
3
3
  import { readFileSync, writeFileSync } from "fs";
4
4
 
5
5
  const { version } = JSON.parse(readFileSync("package.json", "utf8"));
6
+ const cargoVersion = readFileSync("Cargo.toml", "utf8").match(/^version\s*=\s*"([^"]+)"/m)?.[1];
7
+ if (version !== cargoVersion) throw new Error(`Version mismatch: package.json ${version}, Cargo.toml ${cargoVersion}`);
8
+ if (process.env.GITHUB_REF_TYPE === "tag" && process.env.GITHUB_REF_NAME !== `v${version}`) {
9
+ throw new Error(`Release tag ${process.env.GITHUB_REF_NAME} does not match build version v${version}`);
10
+ }
6
11
 
7
12
  const files = [
8
13
  "plugin-src/utils.js",
9
14
  "plugin-src/svg-path-helpers.js",
10
15
  "plugin-src/paint-and-effects.js",
16
+ "plugin-src/token-helpers.js",
11
17
  "plugin-src/read-helpers.js",
12
18
  "plugin-src/handlers-write.js",
13
19
  "plugin-src/handlers-library.js",
@@ -18,10 +24,9 @@ const files = [
18
24
  "plugin-src/main.js",
19
25
  ];
20
26
 
21
- const banner = `// figma-mcp — Figma Plugin main thread v1.2.1
27
+ const banner = `// figma-mcp — Figma Plugin main thread v${version}
22
28
  // Handles both WRITE (draw UI) and READ (extract design) operations.
23
29
  // AUTO-GENERATED by scripts/build-plugin.js — edit plugin-src/*.js instead
24
- // Generated: ${new Date().toISOString()}
25
30
  `;
26
31
 
27
32
  const parts = files.map(f => {
@@ -30,5 +35,17 @@ const parts = files.map(f => {
30
35
  });
31
36
 
32
37
  const output = (banner + parts.join("\n")).replace(/\{\{PLUGIN_VERSION\}\}/g, version);
33
- writeFileSync("plugin-runtime/code.js", output);
38
+
39
+ const uiPath = "plugin-runtime/ui.html";
40
+ const ui = readFileSync(uiPath, "utf8").replace(/(<span class="brand-ver"[^>]*>)[^<]*(<\/span>)/,
41
+ `$1v${version}$2`);
42
+ if (!ui.includes(`id="runtime-version">v${version}</span>`)) throw new Error("Missing runtime version badge");
43
+ if (process.argv.includes('--check')) {
44
+ if (readFileSync("plugin-runtime/code.js", "utf8") !== output || readFileSync(uiPath, "utf8") !== ui) {
45
+ throw new Error("Plugin bundle is stale; run npm run build:plugin");
46
+ }
47
+ } else {
48
+ writeFileSync("plugin-runtime/code.js", output);
49
+ writeFileSync(uiPath, ui);
50
+ }
34
51
  console.log(`✓ plugin-runtime/code.js written (${output.length} chars, ${output.split("\n").length} lines)`);
@@ -0,0 +1,147 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { readFileSync } from 'node:fs';
4
+ import vm from 'node:vm';
5
+ import { spawnSync } from 'node:child_process';
6
+
7
+ function runtime(figma = {}) {
8
+ const context = vm.createContext({ figma, handlers: {}, console, setTimeout, Map, Set, Uint8Array });
9
+ for (const file of ['utils', 'token-helpers', 'handlers-read-detail', 'handlers-read', 'handlers-tokens', 'handlers-write-ops']) {
10
+ vm.runInContext(readFileSync(`plugin-src/${file}.js`, 'utf8'), context, { filename: file });
11
+ }
12
+ return context;
13
+ }
14
+ const plain = value => JSON.parse(JSON.stringify(value));
15
+ const gradient = (transform = [[1, 0, 0], [0, 1, 0]]) => ({
16
+ type: 'GRADIENT_LINEAR', gradientTransform: transform, opacity: .5,
17
+ gradientStops: [{ position: 0, color: { r: 1, g: 0, b: 0, a: .5 } }, { position: 1, color: { r: 0, g: 0, b: 1, a: 1 } }],
18
+ });
19
+ const shadow = type => ({ type, color: { r: 0, g: 0, b: 0, a: .125 }, offset: { x: -1, y: 3 }, radius: 6, spread: -2, visible: true });
20
+
21
+ test('equivalent CSS syntaxes preserve alpha in reads and writes', () => {
22
+ const r = runtime();
23
+ for (const color of ['rgba(255, 0, 0, .5)', 'rgb(100% 0% 0% / 50%)', 'hsl(0 100% 50% / .5)', 'hsla(0, 100%, 50%, .5)']) {
24
+ assert.deepEqual(plain(r.parseColorValue(color)), { r: 1, g: 0, b: 0, a: .5 });
25
+ assert.equal(r.hexToRgbA(color).a, .5);
26
+ assert.equal(r.solidFill(color)[0].opacity, .5);
27
+ }
28
+ assert.equal(r.parseColorValue('#f008').a, 136 / 255);
29
+ assert.equal(r.parseColorValue('transparent').a, 0);
30
+ assert.equal(r.colorToCss({ r: 0, g: 0, b: 0, a: .9999 }), 'rgba(0, 0, 0, 0.9999)');
31
+ assert.throws(() => r.parseColorValue('rgba(garbage)'), /Invalid/);
32
+ });
33
+
34
+ test('gradient projection includes translations, aspect ratio, stop and paint alpha', () => {
35
+ const r = runtime();
36
+ const p = r.serializePaint(gradient());
37
+ assert.deepEqual(plain(p.gradientTransform), [[1, 0, 0], [0, 1, 0]]);
38
+ assert.match(r.paintToCss(p, 200, 100), /^linear-gradient\(90deg, rgba\(255, 0, 0, 0.25\) 0%, rgba\(0, 0, 255, 0.5\) 100%\)$/);
39
+ const diagonal = r.paintToCss(r.serializePaint(gradient([[1, 1, 0], [0, 1, 0]])), 200, 100);
40
+ assert.match(diagonal, /153\.434948/);
41
+ assert.match(diagonal, /50%/);
42
+ const shifted = r.paintToCss(r.serializePaint(gradient([[.5, 0, .25], [0, 1, 0]])), 200, 100);
43
+ assert.match(shifted, /-50%/);
44
+ assert.match(shifted, /150%/);
45
+ assert.throws(() => r.paintToCss({ ...p, type: 'GRADIENT_DIAMOND' }, 100, 100), /SVG/);
46
+ });
47
+
48
+ test('get_css uses the numeric paint, never hex slicing a serialized rgba', async () => {
49
+ const node = { id: '1:2', name: 'alpha', type: 'RECTANGLE', x: 0, y: 0, width: 200, height: 100,
50
+ fills: [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 }, opacity: .25 }], effects: [shadow('DROP_SHADOW'), shadow('INNER_SHADOW')] };
51
+ const r = runtime();
52
+ r.findNodeByIdAsync = async () => node;
53
+ const result = await r.handlers.get_css({ id: node.id });
54
+ assert.match(result.css, /background-color: rgba\(255, 0, 0, 0.25\);/);
55
+ assert.doesNotMatch(result.css, /NaN|0.0625/);
56
+ assert.match(result.css, /inset -1px 3px 6px -2px rgba\(0, 0, 0, 0.125\)/);
57
+ assert.equal(result.detail.effects.length, 2);
58
+ });
59
+
60
+ test('styles retain full paints, effects, units and disabled layers', async () => {
61
+ const r = runtime({
62
+ getLocalPaintStylesAsync: async () => [{ id: 'p', name: 'Gradient', paints: [gradient()] }],
63
+ getLocalTextStylesAsync: async () => [{ id: 't', name: 'Body', fontName: { family: 'Inter', style: 'Regular' }, fontSize: 16, lineHeight: { unit: 'PERCENT', value: 150 }, letterSpacing: { unit: 'PERCENT', value: 2 } }],
64
+ getLocalEffectStylesAsync: async () => [{ id: 's', name: 'Card', effects: [shadow('DROP_SHADOW'), shadow('INNER_SHADOW'), { type: 'LAYER_BLUR', radius: 8, visible: false }] }],
65
+ getLocalGridStylesAsync: async () => [],
66
+ });
67
+ const styles = await r.handlers.get_styles();
68
+ assert.equal(styles.schemaVersion, 2);
69
+ assert.equal(styles.paintStyles[0].paints[0].gradientStops[0].rgba.a, .5);
70
+ assert.equal(styles.effectStyles[0].effects.length, 3);
71
+ assert.equal(styles.textStyles[0].lineHeightUnit, 'PERCENT');
72
+ assert.equal(r.effectsToCss(styles.effectStyles[0].effects).filter, '');
73
+ });
74
+
75
+ test('aliases use mode names across collections and retain false/zero values', async () => {
76
+ const cols = [
77
+ { id: 'semantic', name: 'Semantic', defaultModeId: 's-light', modes: [{ modeId: 's-light', name: 'Light' }, { modeId: 's-dark', name: 'Dark' }], variableIds: ['alias', 'zero'] },
78
+ { id: 'primitive', name: 'Primitive', defaultModeId: 'p-light', modes: [{ modeId: 'p-light', name: 'Light' }, { modeId: 'p-dark', name: 'Dark' }], variableIds: ['base'] },
79
+ ];
80
+ const variables = [
81
+ { id: 'alias', name: 'semantic', variableCollectionId: 'semantic', resolvedType: 'BOOLEAN', valuesByMode: { 's-light': { type: 'VARIABLE_ALIAS', id: 'base' }, 's-dark': { type: 'VARIABLE_ALIAS', id: 'base' } } },
82
+ { id: 'zero', name: 'zero', variableCollectionId: 'semantic', resolvedType: 'FLOAT', valuesByMode: { 's-light': 0, 's-dark': 1 } },
83
+ { id: 'base', name: 'base', variableCollectionId: 'primitive', resolvedType: 'BOOLEAN', valuesByMode: { 'p-light': false, 'p-dark': true } },
84
+ ];
85
+ const r = runtime({ variables: {
86
+ getLocalVariableCollectionsAsync: async () => cols, getLocalVariablesAsync: async () => variables,
87
+ getVariableByIdAsync: async id => variables.find(v => v.id === id),
88
+ getVariableCollectionByIdAsync: async id => cols.find(c => c.id === id),
89
+ } });
90
+ const result = await r.handlers.get_variables();
91
+ const alias = result.collections[0].variables[0];
92
+ assert.equal(alias.values['s-light'].resolvedValue, false);
93
+ assert.equal(alias.values['s-dark'].resolvedValue, true);
94
+ assert.equal(result.resolvedTokens.semantic, false);
95
+ assert.equal(result.resolvedTokens.zero, 0);
96
+ assert.equal(result.collections[0].defaultModeId, 's-light');
97
+ assert.deepEqual(plain(result.diagnostics), []);
98
+ const consumer = { resolvedVariableModes: { semantic: 's-dark', primitive: 'p-light' } };
99
+ variables[0].resolveForConsumer = () => ({ value: false, resolvedType: 'BOOLEAN' });
100
+ assert.equal((await r.resolveVariableValueAsync(variables[0], consumer)).resolvedValue, false);
101
+ });
102
+
103
+ test('export_node validates format and works through batch and aliases', async () => {
104
+ const r = runtime();
105
+ r.handlers.export_svg = async () => ({ svg: '<svg/>' });
106
+ r.handlers.export_image = async params => ({ format: params.format });
107
+ assert.equal((await r.resolveOperationHandler('exportNode')({ format: 'svg' })).svg, '<svg/>');
108
+ assert.equal((await r.handlers.export_node({ format: 'jpeg' })).format, 'JPG');
109
+ const batch = await r.handlers.batch({ operations: [{ operation: 'export_node', params: { format: 'SVG' } }, { operation: 'export_node', params: { format: 'bad' } }] });
110
+ assert.equal(batch.succeeded, 1);
111
+ assert.match(batch.results[1].error, /supports PNG/);
112
+ });
113
+
114
+ test('multiple paints preserve top-first ordering and node opacity stays separate', () => {
115
+ const r = runtime();
116
+ const paints = [{ type: 'SOLID', color: { r: 1, g: 0, b: 0 }, opacity: .5 }, { type: 'SOLID', color: { r: 0, g: 0, b: 1 }, opacity: 1 }].map(r.serializePaint);
117
+ const css = r.paintsToCss(paints, 200, 100);
118
+ assert.match(css.value, /^linear-gradient\(rgba\(255, 0, 0, 0.5\)/);
119
+ assert.match(css.value, /linear-gradient\(#0000ff, #0000ff\)$/);
120
+ });
121
+
122
+ test('UI badge uses release version initially and follows the server on reconnect', () => {
123
+ const pkg = JSON.parse(readFileSync('package.json', 'utf8'));
124
+ const html = readFileSync('plugin-runtime/ui.html', 'utf8');
125
+ assert.ok(html.includes(`id="runtime-version">v${pkg.version}</span>`));
126
+ const start = html.indexOf(' function updateRuntimeVersion(');
127
+ const end = html.indexOf(' var runtimeCapabilities', start);
128
+ const badge = {};
129
+ const context = vm.createContext({ document: { getElementById: () => badge } });
130
+ vm.runInContext(html.slice(start, end), context);
131
+ context.updateRuntimeVersion('3.2.7');
132
+ assert.equal(badge.textContent, 'v3.2.7');
133
+ context.updateRuntimeVersion('3.3.0');
134
+ assert.equal(badge.textContent, 'v3.3.0');
135
+ assert.ok(html.includes('updateRuntimeVersion(serverVer)'));
136
+ assert.ok(html.includes('type: "runtime-ready"'));
137
+ });
138
+
139
+ test('build rejects wrong release tags and checked-in bundle matches source', () => {
140
+ const check = spawnSync(process.execPath, ['scripts/build-plugin.js', '--check'], { encoding: 'utf8' });
141
+ assert.equal(check.status, 0, check.stderr);
142
+ const rejected = spawnSync(process.execPath, ['scripts/build-plugin.js'], {
143
+ encoding: 'utf8', env: { ...process.env, GITHUB_REF_TYPE: 'tag', GITHUB_REF_NAME: 'v0.0.0-invalid' },
144
+ });
145
+ assert.notEqual(rejected.status, 0);
146
+ assert.match(rejected.stderr, /does not match build version/);
147
+ });
@@ -26,8 +26,8 @@ OUTPUT=$(echo "$INPUT" | $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 (12 tools) ---"
30
- for TOOL in figma_status figma_get_selection figma_inspect_node figma_to_code figma_get_tokens figma_export_asset figma_export_assets figma_index figma_read figma_write figma_rules figma_docs; do
29
+ echo "--- Test 3: Verify Tool Registrations (15 tools) ---"
30
+ for TOOL in figma_status figma_get_selection figma_inspect_node figma_to_code figma_get_tokens figma_export_asset figma_export_assets figma_index figma_read figma_write figma_rules figma_docs figma_match_components figma_prepare_design figma_verify_ui; do
31
31
  echo "$OUTPUT" | grep -q "\"name\":\"$TOOL\"" && echo "✓ $TOOL registered"
32
32
  done
33
33