@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.
- package/README.md +16 -11
- package/index.mjs +355 -52
- 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
|
|
53
|
-
|
|
|
54
|
-
| `import_html`
|
|
55
|
-
| `
|
|
56
|
-
| `
|
|
57
|
-
| `
|
|
58
|
-
| `
|
|
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.
|
|
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
|
-
}).
|
|
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
|
-
|
|
122
|
-
|
|
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("
|
|
162
|
-
description: `
|
|
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
|
|
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
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
438
|
-
|
|
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
|
}
|