@refore-ai/html-to-figma-mcp 0.0.5 → 0.1.2

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.
Files changed (3) hide show
  1. package/README.md +16 -11
  2. package/index.mjs +355 -52
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,3 +1,4 @@
1
+
1
2
  # @refore-ai/html-to-figma-mcp
2
3
 
3
4
  Your agent opens page after page — browsing through whatever you asked for, say a whole user flow
@@ -12,13 +13,13 @@ Figma canvas, through the [Refore HTML to Figma](https://www.figma.com/community
12
13
 
13
14
  - Node.js 18+
14
15
  - Figma, with the [Refore HTML to Figma](https://www.figma.com/community/plugin/1385944139259302061/) plugin installed
15
-
16
+
16
17
  ## Setup
17
18
 
18
19
  **Claude Code**
19
20
 
20
21
  ```bash
21
- claude mcp add html-to-figma -- npx -y @refore-ai/html-to-figma-mcp
22
+ claude mcp add refore-html-to-figma -- npx -y @refore-ai/html-to-figma-mcp
22
23
  ```
23
24
 
24
25
  **Claude Desktop** — add to `claude_desktop_config.json`, then restart the app:
@@ -26,7 +27,7 @@ claude mcp add html-to-figma -- npx -y @refore-ai/html-to-figma-mcp
26
27
  ```json
27
28
  {
28
29
  "mcpServers": {
29
- "html-to-figma": {
30
+ "refore-html-to-figma": {
30
31
  "command": "npx",
31
32
  "args": ["-y", "@refore-ai/html-to-figma-mcp"]
32
33
  }
@@ -37,7 +38,7 @@ claude mcp add html-to-figma -- npx -y @refore-ai/html-to-figma-mcp
37
38
  **Codex** — add to `~/.codex/config.toml`:
38
39
 
39
40
  ```toml
40
- [mcp_servers.html-to-figma]
41
+ [mcp_servers.refore-html-to-figma]
41
42
  command = "npx"
42
43
  args = ["-y", "@refore-ai/html-to-figma-mcp"]
43
44
  ```
@@ -49,10 +50,14 @@ Now ask your agent to import a page.
49
50
 
50
51
  ## Tools
51
52
 
52
- | Tool | Purpose |
53
- | ---- | ------- |
54
- | `import_html` | Import inline HTML or a local `.html` file path. Optional `assets` (local files the HTML references), `viewport`, `target` (insert under / replace an existing node), `wait` |
55
- | `import_url` | Import a web page by URL |
56
- | `get_status` | Returns `ws_port` / `connected` / `queue { mine, total, running }` |
57
- | `wait_task` | Block until a task settles, then return its final result |
58
- | `get_task_status` | Non-blocking check of a task's current phase / result |
53
+ | Tool | Purpose |
54
+ | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
55
+ | `import_html` | Preferred import path: import inline HTML or a local `.html` file path. Optional `assets` (local files the HTML references), `viewport`, `target` (insert under / replace an existing node — replace accepts a `nodeId` or a previous import's `taskId`), `wait` |
56
+ | `no_browser_fallback` | Fallback for when the agent cannot get the page DOM for any reason: the agent first asks the user to choose between plugin-side URL fetching (public pages, no login state, fetched from the plugin edition's service region) and recording with the Refore browser extension; only calls this tool if the user picks the former |
57
+ | `get_capture_guide` | Returns the standard DOM-capture playbook: capture script (strip scripts / canvas to img / base injection), strategies for getting a large dump out of browser tooling, and the import-verify-redo loop |
58
+ | `get_node_info` | Inspect a node's bounds / visibility / direct children to verify an import |
59
+ | `export_node_screenshot` | Export a PNG screenshot of a node (longest edge defaults to 1024px) |
60
+ | `remove_import` | Remove the artifact of a previous import task (idempotent; only tasks of this connection) |
61
+ | `get_status` | Returns `ws_port` / `connected` / `queue { mine, total, running }` |
62
+ | `wait_task` | Block until a task settles, then return its final result |
63
+ | `get_task_status` | Non-blocking check of a task's current phase / result |
package/index.mjs CHANGED
@@ -29,7 +29,7 @@ const PLATFORM_DISPLAY_NAMES = {
29
29
  //#region src/platform.ts
30
30
  const MCP_PLATFORM = "figma";
31
31
  /** 从源 package.json 的 version 由 tsdown define 注入;测试环境未注入 → 兜底 '0.0.0-dev' */
32
- const MCP_SERVER_VERSION = "0.0.5";
32
+ const MCP_SERVER_VERSION = "0.1.2";
33
33
  /** 面向 agent 的平台展示名(用于工具描述等) */
34
34
  const MCP_PLATFORM_NAME = PLATFORM_DISPLAY_NAMES[MCP_PLATFORM];
35
35
  /** 本 MCP server 的包名 / 日志前缀基名 */
@@ -38,6 +38,190 @@ const MCP_SERVER_NAME = `html-to-${MCP_PLATFORM}-mcp`;
38
38
  function mcpLog(message) {
39
39
  process.stderr.write(`[${MCP_SERVER_NAME}] ${message}\n`);
40
40
  }
41
+ function getPortRange(base, size = 30) {
42
+ return {
43
+ start: base,
44
+ end: base + size - 1
45
+ };
46
+ }
47
+ //#endregion
48
+ //#region ../../libs/html-to-figma-mcp-protocol/src/port-segments.ts
49
+ /**
50
+ * html-to-figma MCP 在整个 MCP 端口空间里占用 5000–5599 段,平台 base 按整百间隔
51
+ * (5000–5099 空置不用),实际绑定/扫描只用每个 base 起的前 DEFAULT_PORT_SEGMENT_SIZE
52
+ * 个端口(如 figma 为 5500–5529),其余留作扩容余量。
53
+ * 每个新 MCP 产品应选独立的 base(如 6000/7000/...),互不重叠。
54
+ */
55
+ const PLATFORM_PORT_BASE = {
56
+ figma: 5500,
57
+ mastergo: 5100,
58
+ jsdesign: 5200,
59
+ "pixso-china": 5300,
60
+ "pixso-world": 5400
61
+ };
62
+ function getHtmlToFigmaPortRange(platform) {
63
+ return getPortRange(PLATFORM_PORT_BASE[platform], 30);
64
+ }
65
+ //#endregion
66
+ //#region ../../libs/html-to-figma-mcp-protocol/src/protocol.ts
67
+ /** 产品身份魔术字符串。服务端 hello 校验时,payload.magic 必须与此值相等 */
68
+ const MAGIC = "refore-html-to-design-mcp";
69
+ //#endregion
70
+ //#region src/capture-guide.ts
71
+ /**
72
+ * agent 抓取页面 DOM 的标准作业指南,由 `get_capture_guide` 工具原文返回。
73
+ * 只写通用方法论与本 MCP 自身工具的用法;第三方浏览器工具的能力按特征描述(能否写文件 /
74
+ * 是否截断返回值 / 是否有内容过滤),不点名易变的工具细节——指南随 npm 包发版,改动成本高。
75
+ * 代码块用 `~~~` 围栏而不是反引号围栏:正文放在模板字面量里,避免逐处转义反引号。
76
+ */
77
+ const CAPTURE_GUIDE = `# Capturing a rendered page for import_html
78
+
79
+ Workflow rhythm — capture is part of walking the flow, not a phase after it. When the task spans
80
+ several pages/states, import each one the moment you first reach it: capture, submit with
81
+ wait:false, keep walking while the plugin imports, then wait_task the previous submission
82
+ (mechanics in section 4). Never navigate the whole flow to the end and only then come back to
83
+ capture page by page — the second walk doubles the work, and interaction states (opened dialogs,
84
+ filled forms, drag mid-states) may not be reproducible on re-navigation.
85
+
86
+ ## 1. Capture script (run inside the page)
87
+
88
+ Before capturing, scroll through the page once — bottom, then back to top — so lazy-loaded content
89
+ (especially images below the fold) actually loads. Scroll incrementally, not in one jump:
90
+ IntersectionObserver-based lazy loading only triggers for content that has entered the viewport.
91
+
92
+ ~~~js
93
+ for (let y = 0; y < document.body.scrollHeight; y += innerHeight) {
94
+ window.scrollTo(0, y);
95
+ await new Promise((r) => setTimeout(r, 300));
96
+ if (y > innerHeight * 100) break; // infinite feeds never end; cap and move on
97
+ }
98
+ window.scrollTo(0, 0);
99
+ await new Promise((r) => setTimeout(r, 500));
100
+ ~~~
101
+
102
+ Then run this in the page context and use the returned string as the HTML to import:
103
+
104
+ ~~~js
105
+ (() => {
106
+ const clone = document.documentElement.cloneNode(true);
107
+
108
+ // canvas pixels do not serialize with HTML: snapshot each one into an <img>.
109
+ // Size the img with the CSS layout box from getBoundingClientRect() — canvas.width/height are
110
+ // DEVICE pixels (2x on Retina displays) and would blow the img out of its container.
111
+ const liveCanvases = document.querySelectorAll('canvas');
112
+ clone.querySelectorAll('canvas').forEach((c, i) => {
113
+ const live = liveCanvases[i];
114
+ if (!live) return;
115
+ const rect = live.getBoundingClientRect();
116
+ const img = document.createElement('img');
117
+ try {
118
+ img.src = live.toDataURL('image/png');
119
+ } catch {
120
+ // tainted canvas (cross-origin content) cannot be exported; keep the size so layout holds
121
+ }
122
+ img.style.cssText = getComputedStyle(live).cssText;
123
+ img.style.width = rect.width + 'px';
124
+ img.style.height = rect.height + 'px';
125
+ img.style.maxWidth = '100%';
126
+ c.replaceWith(img);
127
+ });
128
+
129
+ // scripts must not re-run inside the import renderer (SPA re-execution would wipe the captured DOM)
130
+ clone.querySelectorAll('script').forEach((s) => s.remove());
131
+
132
+ // make relative asset URLs resolve against the original origin
133
+ const base = document.createElement('base');
134
+ base.href = location.href;
135
+ const head =
136
+ clone.querySelector('head') ?? clone.insertBefore(document.createElement('head'), clone.firstChild);
137
+ head.prepend(base);
138
+
139
+ return '<!DOCTYPE html>\\n' + clone.outerHTML;
140
+ })();
141
+ ~~~
142
+
143
+ Capture per page STATE, not per URL: walk the flow and dump once for every state you want imported
144
+ (opened dialog, filled form, drag mid-state, ...).
145
+
146
+ Dumps are single-use artifacts: capture fresh from the live browser on every import run. Never
147
+ reuse dump files left over from earlier sessions or runs — they may predate capture-script fixes,
148
+ and the page data or viewport may have changed since; none of that is visible from the file itself.
149
+
150
+ ## 2. Getting a large dump out of the browser tooling
151
+
152
+ SPA pages easily reach 300 KB+. Try these channels in order:
153
+
154
+ 1. **File path**: if your browser tool can write results to a file, write the HTML to an absolute
155
+ path and pass that path as import_html's "html" argument. If the write is denied as outside the
156
+ tool's allowed/workspace roots, do NOT brute-force other directories — the allowed set comes from
157
+ that tool's own MCP configuration, and clients without MCP "roots" support are often limited to
158
+ the OS temp directory: macOS is $TMPDIR (/var/folders/..., not /tmp), Windows is %TEMP%
159
+ (C:\\Users\\<name>\\AppData\\Local\\Temp), Linux is /tmp. Try the OS temp directory once, then
160
+ move on to channel 2 or 3.
161
+ 2. **gzip + base64** (when tool return values get truncated): compress inside the page —
162
+
163
+ ~~~js
164
+ const bytes = new TextEncoder().encode(html);
165
+ const buf = await new Response(
166
+ new Blob([bytes]).stream().pipeThrough(new CompressionStream('gzip')),
167
+ ).arrayBuffer();
168
+ let bin = '';
169
+ for (const b of new Uint8Array(buf)) bin += String.fromCharCode(b);
170
+ return { htmlLen: bytes.length, gzBase64: btoa(bin) };
171
+ ~~~
172
+
173
+ then locally: printf '%s' "$GZ_BASE64" | base64 -d | gunzip > page.html — and verify the restored
174
+ byte count matches htmlLen.
175
+ 3. **Download**: if the return channel filters or blocks content, trigger a download inside the page
176
+ (a.href = URL.createObjectURL(new Blob([html])); a.download = 'page.html'; a.click()) and read the
177
+ file from the user's Downloads directory.
178
+ 4. **None of the above works**: follow the no_browser_fallback tool's instructions.
179
+
180
+ Never substitute a curl / anonymous re-fetch of the URL for the captured DOM — that is not the page
181
+ actually open in the browser (no login state, no interaction state).
182
+
183
+ ## 3. Validate the dump before importing
184
+
185
+ - The file must start with <!DOCTYPE html>. Some tools serialize the evaluate result as JSON when
186
+ writing it to a file (the whole content wrapped in quotes with escaped characters, sometimes with
187
+ the extension changed to .json) — if so, JSON.parse it back to plain HTML first:
188
+
189
+ ~~~bash
190
+ node -e "const fs=require('fs');fs.writeFileSync('page.html',JSON.parse(fs.readFileSync('page.json','utf8')))"
191
+ ~~~
192
+
193
+ - The number of data:image/png occurrences should be at least the number of exportable canvases on
194
+ the page — confirms the chart snapshots made it into the dump.
195
+ - If a check fails, fix the transfer channel or re-capture; never import a dump that fails
196
+ validation.
197
+
198
+ ## 4. Import, verify, redo
199
+
200
+ - Pipeline, do not batch: import each state right after capturing it, instead of dumping all pages
201
+ first and importing at the end. Interleave with depth 1: submit state N with wait:false (returns
202
+ the taskId immediately), keep walking the flow and capture state N+1 while the plugin imports
203
+ (its task queue is serial), then wait_task(N) and verify state N — any failure costs at most one
204
+ wasted capture instead of N.
205
+ - Verification runs entirely on the Figma side (get_node_info + export_node_screenshot) against
206
+ the baseline you froze at capture time — the browser never needs to navigate back. Take a
207
+ viewport screenshot of the page right when you capture it, as the comparison baseline. Check:
208
+ visibleAreaRatio is 1 and the root size matches the viewport; the exported image has no large
209
+ blank areas or overflowing elements and is the same page you captured.
210
+ - import_html with "width" matching the capture viewport.
211
+ - To fix a bad result, first diagnose WHERE the problem lives by inspecting the saved dump: does
212
+ it actually contain the content that came out wrong (e.g. grep for the data:image snapshot of a
213
+ blank chart, or the missing section's text)? If the dump has it, fix WITHOUT the browser —
214
+ re-import the SAME dump with corrected params (width), or edit the dump in place when content
215
+ is present but wrongly expressed or obstructed (strip leftover scripts, fix the <base> href,
216
+ correct snapshot img sizes, delete stray overlays/tooltips) — then re-import with
217
+ target: {mode:"replace", taskId} to swap the result in place. Only when the dump itself lacks the
218
+ content is the fix capture-side: restore the page state and re-capture. Editing the dump cannot
219
+ fix MISSING content. At most 2 replace re-imports on top of the initial import (3 imports total —
220
+ every import consumes quota), each changing one input-side variable; if it still mismatches, keep
221
+ the closest result and report the differences to the user.
222
+ - Only when the user explicitly asks to import and verify each page before moving on: stay on the
223
+ page, submit with wait:true, verify, and only then continue the flow. Slower; not the default.
224
+ `;
41
225
  //#endregion
42
226
  //#region src/import-source.ts
43
227
  const DEFAULT_VIEWPORT = {
@@ -62,7 +246,7 @@ async function normalizeHtmlSource(args, io = defaultIO) {
62
246
  try {
63
247
  html = (await io.readFile(args.html)).toString("utf8");
64
248
  } catch (e) {
65
- if (isEnoent(e)) throw new Error(`${args.html} (ENOENT)`);
249
+ if (isEnoent(e)) throw new Error(`${args.html} (ENOENT). If you could not write the dump due to path restrictions, use the OS temp directory (macOS: \$TMPDIR under /var/folders, NOT /tmp; Windows: %TEMP%) or see get_capture_guide for other transfer channels.`);
66
250
  throw e;
67
251
  }
68
252
  baseDir = path.dirname(args.html);
@@ -97,10 +281,13 @@ async function normalizeHtmlSource(args, io = defaultIO) {
97
281
  }
98
282
  //#endregion
99
283
  //#region src/tools.ts
100
- const targetSchema = z.object({
284
+ const targetSchema = z.union([z.object({
101
285
  mode: z.enum(["insert", "replace"]),
102
286
  nodeId: z.string()
103
- }).optional();
287
+ }).strict(), z.object({
288
+ mode: z.literal("replace"),
289
+ taskId: z.string()
290
+ }).strict()]).describe("Where the imported root node lands. `{mode:\"insert\", nodeId}` appends it as a child of that node; `{mode:\"replace\", nodeId}` moves it into that node's place (parent / stacking order / coordinates) and deletes it; `{mode:\"replace\", taskId}` does the same against a previous import of this MCP connection — that is the way to redo a bad import in place. Replacing is safe: the target is only deleted after this import succeeds, and if it was already deleted manually the import falls back to the default placement with `targetApplied: false`. Redo discipline: at most 2 replace re-imports on top of the initial import (3 imports total), and only when you changed an input-side variable (different HTML dump, added assets, viewport, params) — identical input reproduces identical output, and every import consumes paid quota. If the result still doesn't match after that, KEEP the closest result (do not remove it) and report the concrete differences to the user — it is likely a parsing-engine limitation; do not attempt node-by-node canvas fixes.").optional();
104
291
  function textResult(obj) {
105
292
  return { content: [{
106
293
  type: "text",
@@ -118,15 +305,25 @@ function errorResult(message) {
118
305
  }
119
306
  function registerTools(server, deps) {
120
307
  const { hub } = deps;
121
- async function runTask(kind, payload, wait) {
122
- if (!hub.connected) return errorResult("Plugin not connected (NO_PLUGIN); open and connect the plugin MCP tab");
308
+ function noPluginError() {
309
+ return errorResult(`Plugin not connected (NO_PLUGIN). In the design tool, open the plugin's MCP tab, click rescan, and confirm port ${hub.port} (${hub.agent.name}) shows as connected.`);
310
+ }
311
+ /** capability 门控:未连接 / 插件太老没有该能力时给出明确文案,而不是 emit 出去等超时 */
312
+ function gateCapability(capability, label) {
313
+ if (!hub.connected) return noPluginError();
314
+ if (!hub.hasCapability(capability)) return errorResult(`Connected plugin version does not support ${label}; please update the plugin`);
315
+ return null;
316
+ }
317
+ async function runTask(kind, payload, opts) {
318
+ if (!hub.connected) return noPluginError();
319
+ if (payload.target && "taskId" in payload.target && !hub.hasCapability("task:submit#target.taskId")) return errorResult("Connected plugin version does not support `target` by taskId; please update the plugin (or target the imported node directly with `{mode:\"replace\", nodeId}`)");
123
320
  const taskId = randomUUID();
124
321
  const submitP = hub.submit({
125
322
  taskId,
126
323
  kind,
127
324
  payload
128
325
  });
129
- if (!wait) {
326
+ if (!opts.wait) {
130
327
  submitP.catch(() => {});
131
328
  return textResult({ taskId });
132
329
  }
@@ -136,8 +333,9 @@ function registerTools(server, deps) {
136
333
  return errorResult(e.message);
137
334
  }
138
335
  }
336
+ let captureGuideServed = false;
139
337
  server.registerTool("import_html", {
140
- description: `Import a snippet of HTML or an HTML file into ${MCP_PLATFORM_NAME}. If \`html\` is an absolute path it is read as a file, otherwise treated as HTML content.`,
338
+ description: `Import a snippet of HTML or an HTML file into ${MCP_PLATFORM_NAME}. If \`html\` is an absolute path it is read as a file, otherwise treated as HTML content. BEFORE opening the first target page, call \`get_capture_guide\` once — importing a dumped file without having read it is rejected, and it fixes the workflow rhythm (import each page the moment you reach it — never walk the whole flow first) plus traps that otherwise waste paid imports (lazy loading, canvas snapshots, where dump files can be written). This is the preferred import path: whenever you can open or render the page yourself (walking a flow, pages behind login, states after interaction), dump the rendered DOM and import it with this tool. When the user only gives you a URL, still default to this tool — a URL is just the target's address, not a method choice. If you cannot get the page DOM into your hands for ANY reason (no browser tooling, return-value truncation, content filters blocking the transfer, ...), do not fetch the page by other means — follow the instructions of the \`no_browser_fallback\` tool instead.`,
141
339
  inputSchema: {
142
340
  html: z.string(),
143
341
  assets: z.array(z.object({
@@ -150,16 +348,17 @@ function registerTools(server, deps) {
150
348
  wait: z.boolean().optional()
151
349
  }
152
350
  }, async (args) => {
351
+ if (!captureGuideServed && resolveHtmlInput(args.html).kind === "path") return errorResult("Call get_capture_guide first, then retry this import. It covers capture traps that waste paid imports (lazy-loaded content, canvas snapshots, which directories dump files can be written to). This check fires only once per session.");
153
352
  let source;
154
353
  try {
155
354
  source = await normalizeHtmlSource(args);
156
355
  } catch (e) {
157
356
  return errorResult(e.message);
158
357
  }
159
- return runTask("html", source, args.wait ?? true);
358
+ return runTask("html", source, { wait: args.wait ?? true });
160
359
  });
161
- server.registerTool("import_url", {
162
- description: `Import a web page URL into ${MCP_PLATFORM_NAME}.`,
360
+ server.registerTool("no_browser_fallback", {
361
+ description: `Fallback URL import into ${MCP_PLATFORM_NAME} for when you cannot get the page DOM into your hands for ANY reason: no browser tooling at all, return-value truncation, content filters blocking the DOM transfer, etc. If the DOM is not in your hands after reasonable attempts, this tool IS the designated next step — never invent detours like re-fetching the URL with curl (an anonymous re-fetch is NOT the page actually open in the browser). Before calling, present the user with this choice and wait for their answer: (A) send the URL to the plugin, which fetches and imports the page server-side from the plugin edition's service region — the Chinese edition fetches from mainland China, so sites unreachable there (e.g. google.com) are bound to fail; suits public no-login pages reachable from that region; (B) the user records the page with the Refore HTML to Figma browser extension (named Refore HTML to Design in the Chinese edition) — suits pages behind login, with post-interaction state, or unreachable from the service region. Only call this tool after the user chooses A; if they choose B, guide them to use the browser extension instead. Server-side fetching is anonymous and carries no login state. Whenever you can obtain the DOM yourself, use import_html instead.`,
163
362
  inputSchema: {
164
363
  url: z.string(),
165
364
  width: z.number().optional(),
@@ -180,13 +379,23 @@ function registerTools(server, deps) {
180
379
  theme: args.theme,
181
380
  locale: args.locale,
182
381
  target: args.target
183
- }, args.wait ?? true);
382
+ }, { wait: args.wait ?? true });
383
+ });
384
+ server.registerTool("get_capture_guide", {
385
+ description: "Return the standard playbook for capturing a rendered page DOM for import_html: the workflow rhythm (import each page the moment you reach it — never walk the whole flow first and capture afterwards), a ready-to-run capture script (strips <script>, snapshots <canvas> into <img>, injects <base>), strategies for getting a large dump out of browser tooling (file path / gzip+base64 / download), and the import-verify-redo loop. Call it once BEFORE opening the first target page — reading it only at import time is too late to fix the rhythm. Importing a dumped file without having read it is rejected.",
386
+ inputSchema: {}
387
+ }, async () => {
388
+ captureGuideServed = true;
389
+ return { content: [{
390
+ type: "text",
391
+ text: CAPTURE_GUIDE
392
+ }] };
184
393
  });
185
394
  server.registerTool("wait_task", {
186
395
  description: "Block until a task finishes and return its result.",
187
396
  inputSchema: { taskId: z.string() }
188
397
  }, async (args) => {
189
- if (!hub.connected) return errorResult("Plugin not connected (NO_PLUGIN)");
398
+ if (!hub.connected) return noPluginError();
190
399
  try {
191
400
  return textResult(await hub.taskWait(args.taskId));
192
401
  } catch (e) {
@@ -197,13 +406,67 @@ function registerTools(server, deps) {
197
406
  description: "Return a task's current status snapshot (non-blocking). Returns null if the taskId is unknown or was submitted by a different MCP connection.",
198
407
  inputSchema: { taskId: z.string() }
199
408
  }, async (args) => {
200
- if (!hub.connected) return errorResult("Plugin not connected (NO_PLUGIN)");
409
+ if (!hub.connected) return noPluginError();
201
410
  try {
202
411
  return textResult(await hub.taskQuery(args.taskId));
203
412
  } catch (e) {
204
413
  return errorResult(e.message);
205
414
  }
206
415
  });
416
+ server.registerTool("get_node_info", {
417
+ description: "Inspect a node's position, size, visibility and children — use it after an import to verify the result landed where expected (pass the rootNodeId from the import result). Returns id/name/type, absolute bounds, visible flags, parentId, visibleAreaRatio (fraction of the node area inside all clipping ancestors: 1 = fully visible, 0 = fully clipped out of view), childrenTotal and up to 50 direct-children summaries. Returns {\"node\":null} if the node no longer exists. Verification only — this MCP has no node-editing tools; fix problems by re-importing with corrected input (see `target: {mode:\"replace\", taskId}` on the import tools).",
418
+ inputSchema: { nodeId: z.string() }
419
+ }, async (args) => {
420
+ const gate = gateCapability("node:info", "get_node_info");
421
+ if (gate) return gate;
422
+ try {
423
+ return textResult(await hub.nodeInfo({ nodeId: args.nodeId }));
424
+ } catch (e) {
425
+ return errorResult(e.message);
426
+ }
427
+ });
428
+ server.registerTool("export_node_screenshot", {
429
+ description: "Export a rendered PNG screenshot of a node (e.g. an imported rootNodeId) to visually verify the import shows the intended page — catches coarse errors like a blank frame, a login page, or a clipped/misplaced result. Not for pixel-perfect comparison. `maxDimension` caps the longest edge in px (default 1024; downscale only). Also returns a text part with the exported width/height as fallback for clients that cannot display images.",
430
+ inputSchema: {
431
+ nodeId: z.string(),
432
+ maxDimension: z.number().optional()
433
+ }
434
+ }, async (args) => {
435
+ const gate = gateCapability("node:export", "export_node_screenshot");
436
+ if (gate) return gate;
437
+ try {
438
+ const res = await hub.nodeExport({
439
+ nodeId: args.nodeId,
440
+ maxDimension: args.maxDimension
441
+ });
442
+ return { content: [{
443
+ type: "image",
444
+ data: res.pngBase64,
445
+ mimeType: "image/png"
446
+ }, {
447
+ type: "text",
448
+ text: JSON.stringify({
449
+ nodeId: args.nodeId,
450
+ width: res.width,
451
+ height: res.height
452
+ })
453
+ }] };
454
+ } catch (e) {
455
+ return errorResult(e.message);
456
+ }
457
+ });
458
+ server.registerTool("remove_import", {
459
+ description: "Remove the imported artifact of a previous import task (by taskId; only tasks submitted through this MCP connection). Use it to undo a wrong import or clean up test imports — it cannot delete arbitrary nodes and is not a general editing tool. Idempotent: returns \"already-gone\" if the node was already deleted. To re-import a fixed version into the old node's place, prefer `target: {mode:\"replace\", taskId}` on the import tools over remove + import (that keeps the old node if the re-import fails). When giving up after failed retries, keep the closest result instead of removing it, and report the differences to the user.",
460
+ inputSchema: { taskId: z.string() }
461
+ }, async (args) => {
462
+ const gate = gateCapability("import:remove", "remove_import");
463
+ if (gate) return gate;
464
+ try {
465
+ return textResult(await hub.removeImport({ taskId: args.taskId }));
466
+ } catch (e) {
467
+ return errorResult(e.message);
468
+ }
469
+ });
207
470
  server.registerTool("get_status", {
208
471
  description: "Return this MCP's WS port, plugin connection state, and queue status. Set `include_tasks` to also get the list of tasks submitted by this connection.",
209
472
  inputSchema: { include_tasks: z.boolean().optional() }
@@ -230,41 +493,26 @@ function registerTools(server, deps) {
230
493
  }
231
494
  //#endregion
232
495
  //#region src/server.ts
496
+ /**
497
+ * MCP server instructions:客户端握手时就进入 agent 上下文,是唯一早于「开始打开页面」的
498
+ * 引导通道——tool description 要等工具被加载才可见,get_capture_guide 的门禁要到首次导入
499
+ * 才触发,都晚于 agent 决定「先把流程走完再回头抓」的时刻。这里只放必须在走流程前就位的
500
+ * 内容:路由声明 + 节奏规则,其余细节仍归 get_capture_guide。
501
+ *
502
+ * 客户端延迟加载工具时 instructions 是唯一的发现面:Claude Code 把它注入系统提示词(2KB
503
+ * 截断);Codex 把它渲染进 tool_search 的来源清单并计入 BM25 检索语料(plugin 形态只保留
504
+ * 前 1000 字节)。因此路由声明必须是第一句、全文不得超过 1000 字节(有测试锁定);中文
505
+ * 触发词是给 BM25 命中中文 query 用的——其余语料全是英文,纯中文搜索词否则一个都对不上。
506
+ */
507
+ const SERVER_INSTRUCTIONS = `Use this MCP whenever the user asks to import, convert or restore a web page, URL, or HTML into ${MCP_PLATFORM_NAME} (中文指令如「把网页/URL/HTML 导入到 ${MCP_PLATFORM_NAME}」,产品名「网页转设计」). Capturing is part of walking the flow, not a phase after it: call get_capture_guide BEFORE opening the first target page, then capture and import each page/state the moment you first reach it (submit with wait:false, keep walking while the plugin imports, then wait_task the previous submission). Never walk the whole flow to the end and only then start capturing — the second walk doubles the work and interaction states may not be reproducible on re-navigation.`;
233
508
  function createServer$1(deps) {
234
509
  const server = new McpServer({
235
510
  name: MCP_SERVER_NAME,
236
511
  version: MCP_SERVER_VERSION
237
- });
512
+ }, { instructions: SERVER_INSTRUCTIONS });
238
513
  registerTools(server, deps);
239
514
  return server;
240
515
  }
241
- function getPortRange(base, size = 100) {
242
- return {
243
- start: base,
244
- end: base + size - 1
245
- };
246
- }
247
- //#endregion
248
- //#region ../../libs/html-to-figma-mcp-protocol/src/port-segments.ts
249
- /**
250
- * html-to-figma MCP 在整个 MCP 端口空间里占用 5000–5599 段,按平台每 100 一格;
251
- * 其中 5000–5099 空置不用,figma 落在 5500–5599。
252
- * 每个新 MCP 产品应选独立的 base(如 6000/7000/...),互不重叠。
253
- */
254
- const PLATFORM_PORT_BASE = {
255
- figma: 5500,
256
- mastergo: 5100,
257
- jsdesign: 5200,
258
- "pixso-china": 5300,
259
- "pixso-world": 5400
260
- };
261
- function getHtmlToFigmaPortRange(platform) {
262
- return getPortRange(PLATFORM_PORT_BASE[platform], 100);
263
- }
264
- //#endregion
265
- //#region ../../libs/html-to-figma-mcp-protocol/src/protocol.ts
266
- /** 产品身份魔术字符串。服务端 hello 校验时,payload.magic 必须与此值相等 */
267
- const MAGIC = "refore-html-to-design-mcp";
268
516
  //#endregion
269
517
  //#region ../../libs/mcp-transport/src/mcp/ws-hub-base.ts
270
518
  const MAX_HTTP_BUFFER_SIZE = 100 * 1024 * 1024;
@@ -288,6 +536,8 @@ var WsHubBase = class {
288
536
  plugin = null;
289
537
  boundPort = 0;
290
538
  pluginCleanups = /* @__PURE__ */ new Set();
539
+ /** 当前绑定插件在 hello 中声明的能力集;老插件不声明 → 空集 */
540
+ pluginCapabilities = /* @__PURE__ */ new Set();
291
541
  /** 进程唯一身份,握手时通过 HelloAck 传给插件 —— 让插件能区分同 port 前后两个不同进程 */
292
542
  connectionId = randomUUID();
293
543
  constructor(opts) {
@@ -299,10 +549,18 @@ var WsHubBase = class {
299
549
  get connected() {
300
550
  return this.plugin !== null;
301
551
  }
552
+ /** 本进程对外展示的 agent 元信息(初始为占位,握手后由 setAgent 覆盖为真实 clientInfo) */
553
+ get agent() {
554
+ return this.opts.agent;
555
+ }
302
556
  /** 返回当前活跃的插件 socket,供子类主动 emit 业务事件(如 task:submit)*/
303
557
  getPlugin() {
304
558
  return this.plugin;
305
559
  }
560
+ /** 当前绑定插件是否声明了某能力。无插件连接时一律 false */
561
+ hasCapability(capability) {
562
+ return this.plugin !== null && this.pluginCapabilities.has(capability);
563
+ }
306
564
  async listen() {
307
565
  const { start, end } = this.opts.portRange;
308
566
  const originCheck = this.opts.allowedOrigins ?? defaultOriginCheck;
@@ -363,7 +621,7 @@ var WsHubBase = class {
363
621
  this.plugin.emit("superseded", {});
364
622
  this.plugin.disconnect(true);
365
623
  }
366
- this.bindPlugin(socket);
624
+ this.bindPlugin(socket, payload.capabilities ?? []);
367
625
  ack({
368
626
  agent: this.opts.agent,
369
627
  wsPort: this.boundPort,
@@ -371,12 +629,16 @@ var WsHubBase = class {
371
629
  });
372
630
  });
373
631
  }
374
- bindPlugin(socket) {
632
+ bindPlugin(socket, capabilities) {
375
633
  this.plugin = socket;
634
+ this.pluginCapabilities = new Set(capabilities);
376
635
  const cleanup = this.opts.onPluginBound?.(socket);
377
636
  if (cleanup) this.pluginCleanups.add(cleanup);
378
637
  socket.on("disconnect", () => {
379
- if (this.plugin === socket) this.plugin = null;
638
+ if (this.plugin === socket) {
639
+ this.plugin = null;
640
+ this.pluginCapabilities = /* @__PURE__ */ new Set();
641
+ }
380
642
  const cleanups = [...this.pluginCleanups];
381
643
  this.pluginCleanups.clear();
382
644
  for (const fn of cleanups) try {
@@ -404,6 +666,12 @@ var WsHubBase = class {
404
666
  //#endregion
405
667
  //#region src/ws-hub.ts
406
668
  /**
669
+ * 查询类 RPC 的 ack 超时:向不支持某事件的老插件 emit 时 ack 永不回来,没有超时就是永久
670
+ * hang。只用于查询/轻命令;TaskSubmit / TaskWait 的 ack 是任务完成信号(wait:true 时分钟级
671
+ * 是正常的),绝不能套。
672
+ */
673
+ const DEFAULT_QUERY_TIMEOUT_MS = 3e4;
674
+ /**
407
675
  * html-to-figma MCP 的 server:在 `WsHubBase`(端口绑定 + hello 握手 + plugin socket 生命
408
676
  * 周期)之上加 task 命令与查询 RPC,全部走 socket.io ack 现问现答,本类不缓存任何 task 状态
409
677
  * (真源在插件的 active + history)。
@@ -412,6 +680,7 @@ var WsHub = class extends WsHubBase {
412
680
  /** 已 emit 但插件尚未 ack 的请求。插件掉线时统一 reject —— socket.io 不会自动结算掉线时的
413
681
  * ack callback,不主动 reject 的话 wait:true 的 submit / taskWait / 查询类都会永远 hang */
414
682
  pendingAcks = /* @__PURE__ */ new Set();
683
+ queryTimeoutMs;
415
684
  constructor(opts) {
416
685
  super({
417
686
  magic: MAGIC,
@@ -426,17 +695,31 @@ var WsHub = class extends WsHubBase {
426
695
  for (const rejectFn of pending) rejectFn(/* @__PURE__ */ new Error("NO_PLUGIN: plugin disconnected before ack"));
427
696
  }
428
697
  });
698
+ this.queryTimeoutMs = opts.queryTimeoutMs ?? DEFAULT_QUERY_TIMEOUT_MS;
429
699
  }
430
- emitWithAck(event, payload) {
700
+ /**
701
+ * @param timeoutMs 可选 ack 超时。只给查询类 RPC 传;命令类(TaskSubmit)的 ack 是任务
702
+ * 完成信号,不能传(见 DEFAULT_QUERY_TIMEOUT_MS 注释)
703
+ */
704
+ emitWithAck(event, payload, timeoutMs) {
431
705
  const plugin = this.getPlugin();
432
706
  if (!plugin || !plugin.connected) return Promise.reject(/* @__PURE__ */ new Error("NO_PLUGIN: plugin not connected"));
433
707
  return new Promise((resolve, reject) => {
434
- const abort = (err) => reject(err);
708
+ let settled = false;
709
+ let timer;
710
+ const settle = (fn) => {
711
+ if (settled) return;
712
+ settled = true;
713
+ if (timer !== void 0) clearTimeout(timer);
714
+ this.pendingAcks.delete(abort);
715
+ fn();
716
+ };
717
+ const abort = (err) => settle(() => reject(err));
435
718
  this.pendingAcks.add(abort);
719
+ if (timeoutMs !== void 0) timer = setTimeout(() => abort(/* @__PURE__ */ new Error(`TIMEOUT: plugin did not ack ${event} within ${timeoutMs}ms`)), timeoutMs);
436
720
  plugin.emit(event, payload, (res) => {
437
- this.pendingAcks.delete(abort);
438
- if (res && typeof res === "object" && "error" in res && typeof res.error === "string") reject(new Error(res.error));
439
- else resolve(res);
721
+ if (res && typeof res === "object" && "error" in res && typeof res.error === "string") settle(() => reject(new Error(res.error)));
722
+ else settle(() => resolve(res));
440
723
  });
441
724
  });
442
725
  }
@@ -450,10 +733,19 @@ var WsHub = class extends WsHubBase {
450
733
  return this.emitWithAck("task:wait", { taskId });
451
734
  }
452
735
  taskQuery(taskId) {
453
- return this.emitWithAck("task:query", { taskId });
736
+ return this.emitWithAck("task:query", { taskId }, this.queryTimeoutMs);
454
737
  }
455
738
  statusQuery(req) {
456
- return this.emitWithAck("status:query", req);
739
+ return this.emitWithAck("status:query", req, this.queryTimeoutMs);
740
+ }
741
+ nodeInfo(req) {
742
+ return this.emitWithAck("node:info", req, this.queryTimeoutMs);
743
+ }
744
+ nodeExport(req) {
745
+ return this.emitWithAck("node:export", req, this.queryTimeoutMs);
746
+ }
747
+ removeImport(req) {
748
+ return this.emitWithAck("import:remove", req, this.queryTimeoutMs);
457
749
  }
458
750
  };
459
751
  //#endregion
@@ -477,6 +769,17 @@ async function main() {
477
769
  cwd: process.cwd()
478
770
  });
479
771
  };
772
+ let shuttingDown = false;
773
+ const shutdown = () => {
774
+ if (shuttingDown) return;
775
+ shuttingDown = true;
776
+ mcpLog("stdio closed; shutting down");
777
+ setTimeout(() => process.exit(0), 2e3).unref();
778
+ hub.close().catch((e) => mcpLog(`hub close failed: ${e.message}`)).finally(() => process.exit(0));
779
+ };
780
+ server.server.onclose = shutdown;
781
+ process.stdin.once("end", shutdown);
782
+ process.stdin.once("close", shutdown);
480
783
  const transport = new StdioServerTransport();
481
784
  await server.connect(transport);
482
785
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@refore-ai/html-to-figma-mcp",
3
- "version": "0.0.5",
3
+ "version": "0.1.2",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "html-to-figma-mcp": "index.mjs"