@refore-ai/html-to-figma-mcp 0.2.0 → 0.3.1

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 +3 -2
  2. package/index.mjs +301 -80
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -54,9 +54,10 @@ Now ask your agent to import a page.
54
54
 
55
55
  | Tool | Purpose |
56
56
  | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
57
- | `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` |
57
+ | `import_html` | Preferred import path: import inline HTML or a local `.html` file path. File paths take `source`: `user-file` (a local file you handed the agent — imported directly, no browser and no capture guide) or `browser-dump` (a rendered-DOM dump the agent captured in a browser — the guide must be read before its first import); when omitted the origin is guessed from where the file lives. 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` |
58
+ | `dev_import_demo` | **`--dev` only**: import a Refore demo share link (`app.demoway.cn|com/demo/<id>`) through the plugin's capture page — the same path the user takes by opening that demo, so it reproduces what they saw. Optional `selector`, `target`, `wait`. Single-step demos only, and the plugin must be a development build |
58
59
  | `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 |
59
- | `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 |
60
+ | `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. Dump-only — a local file you handed the agent does not need it |
60
61
  | `remove_import` | Remove the artifact of a previous import task (idempotent; only tasks of this connection) |
61
62
  | `wait_task` | Block until a task settles, then return its final result |
62
63
  | `get_task_status` | Non-blocking check of a task's current phase / result |
package/index.mjs CHANGED
@@ -6,45 +6,67 @@ import { mkdir, readFile, writeFile } from "node:fs/promises";
6
6
  import path, { dirname, resolve } from "node:path";
7
7
  import { z } from "zod";
8
8
  import { randomUUID } from "node:crypto";
9
+ import os from "node:os";
9
10
  import { createServer } from "node:http";
10
11
  import { Server } from "socket.io";
11
- //#region src/platform-meta.ts
12
+ //#region ../../libs/constants/src/mcp.ts
12
13
  /**
13
- * 面向 agent 的平台展示名(用于工具描述、npm README 等)。
14
+ * MCP 相关常量。
14
15
  *
15
- * 与 libs/constants 的 `PLATFORM_NAME`(插件 UI 用,jsdesign 显示「即时设计」)不是一回事。
16
- *
17
- * 本文件同时被 src/platform.ts(打进发布产物)和 tsdown.config.ts(构建时渲染 README)import,
18
- * 因此这里只放纯数据:不引 runtime 依赖(`PlatformType` / `Region` 这类 enum 会把整个
19
- * @UseringOfficial/utils 打进 server 包),也不依赖 `__MCP_PLATFORM__` 等构建期注入的常量
20
- * (否则 tsdown config 加载阶段 import 就会报错)。
16
+ * **本文件只放纯数据,且只能用 type import**:两个 MCP 的 tsdown 配置会绕开 barrel 直接引它,
17
+ * 而配置加载阶段不解析 `__PLATFORM__` 这类构建期常量、也不该把运行时依赖拖进 server 产物。
18
+ * 往这里加东西前先确认它满足这两条,否则放别处。
19
+ */
20
+ /**
21
+ * 平台叫法的唯一登记处:`name` 是英文主名,`zhName` 是中文名,`otherNames` 是其余写法。
22
+ * 下面几张表都从这里派生。
21
23
  */
22
- const PLATFORM_DISPLAY_NAMES = {
23
- figma: "Figma",
24
- mastergo: "MasterGo",
25
- jsdesign: "JSDesign",
26
- "pixso-china": "Pixso",
27
- "pixso-world": "Pixso"
24
+ const PLATFORM_NAMING = {
25
+ figma: { name: "Figma" },
26
+ mastergo: { name: "MasterGo" },
27
+ jsdesign: {
28
+ name: "JSDesign",
29
+ zhName: "即时设计",
30
+ otherNames: ["js.design"]
31
+ },
32
+ "pixso-china": { name: "Pixso" },
33
+ "pixso-world": { name: "Pixso" }
28
34
  };
35
+ function mapPlatforms(pick) {
36
+ return Object.fromEntries(Object.entries(PLATFORM_NAMING).map(([platform, naming]) => [platform, pick(naming)]));
37
+ }
38
+ mapPlatforms((naming) => naming.name);
39
+ /**
40
+ * 平台展示名:有中文名的写成「JSDesign(即时设计)」。
41
+ *
42
+ * 凡是被读的正文都用这份 —— agent 侧的工具描述与 instructions、npm README 里的说明文字。
43
+ * 用户嘴里说的是中文名,只给英文名,读的人(和 agent)认不出说的是同一个平台。
44
+ */
45
+ const MCP_PLATFORM_DISPLAY_NAMES = mapPlatforms((naming) => naming.zhName ? `${naming.name}(${naming.zhName})` : naming.name);
46
+ /**
47
+ * 平台的全部叫法,主名在前。路由用(server instructions 的触发词):用户说哪个叫法都要落到
48
+ * 同一台 MCP,检索语料里没有的那个叫法就等于没这台 MCP。
49
+ */
50
+ const MCP_PLATFORM_ALL_NAMES = mapPlatforms((naming) => [
51
+ naming.name,
52
+ ...naming.zhName ? [naming.zhName] : [],
53
+ ...naming.otherNames ?? []
54
+ ]);
29
55
  //#endregion
30
56
  //#region src/platform.ts
31
57
  const MCP_PLATFORM = "figma";
32
58
  /** 从源 package.json 的 version 由 tsdown define 注入;测试环境未注入 → 兜底 '0.0.0-dev' */
33
- const MCP_SERVER_VERSION = "0.2.0";
34
- /** 面向 agent 的平台展示名(用于工具描述等) */
35
- const MCP_PLATFORM_NAME = PLATFORM_DISPLAY_NAMES[MCP_PLATFORM];
59
+ const MCP_SERVER_VERSION = "0.3.1";
60
+ /** 面向 agent 的平台展示名(用于工具描述等),中文名一并带上 */
61
+ const MCP_PLATFORM_NAME = MCP_PLATFORM_DISPLAY_NAMES[MCP_PLATFORM];
62
+ /** 本平台的全部叫法,主名在前:路由用,不是展示用 */
63
+ const MCP_PLATFORM_TRIGGER_NAMES = MCP_PLATFORM_ALL_NAMES[MCP_PLATFORM];
36
64
  /** 本 MCP server 的包名 / 日志前缀基名 */
37
65
  const MCP_SERVER_NAME = `html-to-${MCP_PLATFORM}-mcp`;
38
66
  /** stdout 归 MCP JSON-RPC 独占,本进程所有日志走 stderr */
39
67
  function mcpLog(message) {
40
68
  process.stderr.write(`[${MCP_SERVER_NAME}] ${message}\n`);
41
69
  }
42
- function getPortRange(base, size = 20) {
43
- return {
44
- start: base,
45
- end: base + size - 1
46
- };
47
- }
48
70
  //#endregion
49
71
  //#region ../../libs/design-inspect/src/mcp/toggle.ts
50
72
  const DEFAULT_GROUPS = ["read", "navigate"];
@@ -159,7 +181,7 @@ const DESIGN_INSPECT_INPUT_SCHEMAS = {
159
181
  depth: z.number().optional().describe("Levels below root to search. Omit to search all depths; 1 = direct children only."),
160
182
  name: z.string().optional().describe("Case-insensitive substring match on the node name."),
161
183
  type: z.union([z.string(), z.array(z.string())]).optional().describe("Node type(s), upper-case (FRAME, TEXT, INSTANCE, ...)."),
162
- where: z.record(z.unknown()).optional().describe("Platform-native property name → expected value, same names `get_nodes` returns. A node matches only when every entry matches. Colours compare with a small tolerance, so a value read back from `get_nodes` can be pasted straight in."),
184
+ where: z.record(z.string(), z.unknown()).optional().describe("Platform-native property name → expected value, same names `get_nodes` returns. A node matches only when every entry matches. Colours compare with a small tolerance, so a value read back from `get_nodes` can be pasted straight in."),
163
185
  includeHidden: z.boolean().optional().describe("Include nodes not visible on canvas. Defaults to false."),
164
186
  limit: z.number().optional().describe(`Page size, default 100.`),
165
187
  offset: z.number().optional().describe("Number of matches to skip, default 0.")
@@ -192,7 +214,7 @@ const DESIGN_INSPECT_INPUT_SCHEMAS = {
192
214
  ["inspect:set-current-page"]: { pageId: z.string() },
193
215
  ["inspect:set-node-properties"]: {
194
216
  nodeIds: z.array(z.string()),
195
- props: z.record(z.unknown()).describe("Platform-native property name → value.")
217
+ props: z.record(z.string(), z.unknown()).describe("Platform-native property name → value.")
196
218
  },
197
219
  ["inspect:set-text-style"]: {
198
220
  nodeId: z.string(),
@@ -213,12 +235,12 @@ const DESIGN_INSPECT_INPUT_SCHEMAS = {
213
235
  create: z.array(z.object({
214
236
  type: z.enum(LOCAL_STYLE_TYPES),
215
237
  name: z.string(),
216
- props: z.record(z.unknown()).optional().describe("Platform-native style properties, e.g. `paints`.")
238
+ props: z.record(z.string(), z.unknown()).optional().describe("Platform-native style properties, e.g. `paints`.")
217
239
  })).optional(),
218
240
  update: z.array(z.object({
219
241
  styleId: z.string(),
220
242
  name: z.string().optional(),
221
- props: z.record(z.unknown()).optional()
243
+ props: z.record(z.string(), z.unknown()).optional()
222
244
  })).optional()
223
245
  },
224
246
  ["inspect:write-variables"]: {
@@ -232,12 +254,12 @@ const DESIGN_INSPECT_INPUT_SCHEMAS = {
232
254
  "STRING",
233
255
  "BOOLEAN"
234
256
  ]),
235
- valuesByMode: z.record(z.unknown()).optional().describe("Mode id → value.")
257
+ valuesByMode: z.record(z.string(), z.unknown()).optional().describe("Mode id → value.")
236
258
  })).optional(),
237
259
  update: z.array(z.object({
238
260
  variableId: z.string(),
239
261
  name: z.string().optional(),
240
- valuesByMode: z.record(z.unknown()).optional()
262
+ valuesByMode: z.record(z.string(), z.unknown()).optional()
241
263
  })).optional()
242
264
  },
243
265
  ["inspect:set-node-style-id"]: {
@@ -251,7 +273,7 @@ const DESIGN_INSPECT_INPUT_SCHEMAS = {
251
273
  type: z.enum(CREATABLE_NODE_TYPES),
252
274
  parentId: z.string().optional(),
253
275
  index: z.number().optional().describe("Insertion position; appended to the end when omitted."),
254
- props: z.record(z.unknown()).optional()
276
+ props: z.record(z.string(), z.unknown()).optional()
255
277
  },
256
278
  ["inspect:duplicate-nodes"]: { nodeIds: z.array(z.string()) },
257
279
  ["inspect:reparent-nodes"]: {
@@ -266,7 +288,9 @@ const DESIGN_INSPECT_INPUT_SCHEMAS = {
266
288
  ["inspect:ungroup-node"]: { nodeId: z.string() },
267
289
  ["inspect:delete-nodes"]: { nodeIds: z.array(z.string()) },
268
290
  ["inspect:dev:eval-script"]: { script: z.string().describe("JavaScript to run in the plugin sandbox. `api` and `Platform` are in scope.") },
269
- ["inspect:dev:tail-logs"]: { limit: z.number().optional().describe("Return at most this many of the most recent entries.") }
291
+ ["inspect:dev:eval-ui-script"]: { script: z.string().describe("JavaScript to run in the plugin UI (the iframe document). No injected scope.") },
292
+ ["inspect:dev:tail-logs"]: { limit: z.number().optional().describe("Return at most this many of the most recent entries.") },
293
+ ["inspect:dev:reload-ui"]: {}
270
294
  };
271
295
  //#endregion
272
296
  //#region ../../libs/design-inspect/src/mcp/tools.ts
@@ -282,6 +306,20 @@ async function saveExport(target, result) {
282
306
  }
283
307
  /** 查询类 RPC 的 ack 超时。插件端不响应时不能无限挂着 */
284
308
  const QUERY_TIMEOUT_MS = 3e4;
309
+ /** UI 重载的等待参数:先等 socket 断开确认重载真的发生了,再等它自己连回来 */
310
+ const RELOAD_POLL_MS = 200;
311
+ const RELOAD_DROP_TIMEOUT_MS = 1e4;
312
+ const RELOAD_RECONNECT_TIMEOUT_MS = 6e4;
313
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
314
+ /** 轮询到 predicate 为真,返回耗时;超时返回 null */
315
+ async function waitUntil(predicate, timeoutMs) {
316
+ const startedAt = Date.now();
317
+ while (Date.now() - startedAt < timeoutMs) {
318
+ if (predicate()) return Date.now() - startedAt;
319
+ await sleep(RELOAD_POLL_MS);
320
+ }
321
+ return null;
322
+ }
285
323
  /** 平台能力一律查 common/capabilities 的统一表,别在这里再写一份平台判断 */
286
324
  const SUPPORTS_VARIABLE = (platform) => platformSupports(platform, "readVariables");
287
325
  const SUPPORTS_VARIABLE_WRITE = (platform) => platformSupports(platform, "writeVariables");
@@ -403,10 +441,109 @@ function registerInspectTools(server, bridge, options) {
403
441
  tool("group_nodes", "write", "Wrap nodes that share a parent in a new group.", "inspect:group-nodes");
404
442
  tool("ungroup_node", "write", "Ungroup a group — its children move up to its parent and the group itself is removed.", "inspect:ungroup-node");
405
443
  tool("delete_nodes", "write", "**Destructive — ask the user to confirm before calling this.** Deletes nodes from the document. The user can undo in the design tool, but do not rely on that. The response lists the id and name of everything that was deleted so you can report exactly what went. Already-deleted ids come back in `notFound` rather than failing the call.", "inspect:delete-nodes");
406
- tool("eval_script", "dev", "DEV ONLY — run JavaScript inside the plugin sandbox. `api` (the host global, e.g. figma / pixso) and `Platform` (the cross-platform adapter) are in scope; top-level await works; return a JSON-serialisable value. Use it to try things the other tools do not cover while developing the plugin itself — not for end-user workflows, and **not as a way around a write tool that rejected your input**: the rejection is the API telling you which tool to use, and hand-written assignment hits platform quirks the write tools already handle (text styling needs `setRange*`, size needs `resize()`, `characters` needs every font on the node loaded first). The code backing this tool ships only in development builds, so it is absent from released plugins.", "inspect:dev:eval-script");
444
+ tool("eval_script", "dev", "DEV ONLY — run JavaScript inside the plugin sandbox. `api` (the host global, e.g. figma / pixso) and `Platform` (the cross-platform adapter) are in scope; top-level await works; return a JSON-serialisable value. Use it to try things the other tools do not cover while developing the plugin itself — not for end-user workflows, and **not as a way around a write tool that rejected your input**: the rejection is the API telling you which tool to use, and hand-written assignment hits platform quirks the write tools already handle (text styling needs `setRange*`, size needs `resize()`, `characters` needs every font on the node loaded first). **Not for reading the document either** — to inspect nodes use `query_nodes` and `get_nodes`. They normalize the core fields across platforms, while the host API does not: MasterGo has no `getNodeByIdAsync` (only the sync `getNodeById`) and its nodes have no `findOne` / `findAll`, so a traversal written against Figma dies with \"not a function\" on other platforms. Reach for this tool when you need something the read tools genuinely cannot express — probing whether an API exists, or what a platform does at runtime (write-order constraints, values the host rewrites behind your back). Verifying an import result is NOT that: read it back with `query_nodes` / `get_nodes` (note `bounds` there is absolute canvas coordinates, so subtract the parent origin to compare against parent-relative values). The code backing this tool ships only in development builds, so it is absent from released plugins.", "inspect:dev:eval-script");
445
+ tool("dev_eval_ui_script", "dev", "DEV ONLY — run JavaScript inside the plugin UI (the iframe document), the counterpart to `eval_script` which runs in the core sandbox. Most of the plugin lives on the UI side (the Vue app, its stores and router, the MCP queue wiring), and that state is unreachable from core and does not show up in `get_tail_logs` — this is how you inspect it while developing the plugin. Nothing is injected into scope: reach the app through the container Vue tags on mount, e.g. `document.querySelector('#app').__vue_app__`. Top-level await works; return a JSON-serialisable value. It runs in the live UI, so a script that mutates state or navigates changes what the user is looking at. Not for end-user workflows.", "inspect:dev:eval-ui-script");
446
+ if (shouldRegister("dev_reload_plugin_ui", "dev")) server.registerTool("dev_reload_plugin_ui", {
447
+ description: "DEV ONLY — reload the plugin UI so edited UI code is re-fetched from the dev server, then wait for the plugin to reconnect before returning. Call it after changing UI-side code and before any check whose result you will draw conclusions from — dev builds apply some edits through HMR and others not at all, so without a reload a stale module can look exactly like a code bug. **It refreshes the UI bundle only.** The core bundle (core.js) is read by the design tool when the plugin launches and no plugin-side API can reload it, so changes under any `src/core` path need the user to re-run the plugin by hand — this tool cannot do it and does not claim to. The plugin drops its connection while reloading; the reconnect is awaited here, so a successful result means the plugin is ready for the next call.",
448
+ inputSchema: DESIGN_INSPECT_INPUT_SCHEMAS["inspect:dev:reload-ui"]
449
+ }, async () => {
450
+ if (!bridge.connected) return noPlugin();
451
+ try {
452
+ await bridge.request("inspect:dev:reload-ui", {}, QUERY_TIMEOUT_MS);
453
+ } catch (e) {
454
+ return {
455
+ content: [{
456
+ type: "text",
457
+ text: e.message
458
+ }],
459
+ isError: true
460
+ };
461
+ }
462
+ const droppedInMs = await waitUntil(() => !bridge.connected, RELOAD_DROP_TIMEOUT_MS);
463
+ if (droppedInMs === null) return {
464
+ content: [{
465
+ type: "text",
466
+ text: "Reload was acknowledged but the plugin connection never dropped, so the UI probably did not reload. Treat the running code as unchanged and reload the plugin manually."
467
+ }],
468
+ isError: true
469
+ };
470
+ const reconnectedInMs = await waitUntil(() => bridge.connected, RELOAD_RECONNECT_TIMEOUT_MS);
471
+ if (reconnectedInMs === null) return {
472
+ content: [{
473
+ type: "text",
474
+ text: `Plugin UI reloaded but did not reconnect within ${RELOAD_RECONNECT_TIMEOUT_MS}ms. In the design tool, open the plugin's MCP tab, click rescan, and confirm port ${bridge.port} shows as connected.`
475
+ }],
476
+ isError: true
477
+ };
478
+ return { content: [{
479
+ type: "text",
480
+ text: JSON.stringify({
481
+ reloaded: "ui",
482
+ droppedInMs,
483
+ reconnectedInMs,
484
+ note: "UI bundle only — core.js is unchanged until the plugin is re-run by hand."
485
+ })
486
+ }] };
487
+ });
407
488
  tool("get_tail_logs", "dev", "DEV ONLY — read the plugin-side log buffer. Intended for developing the plugin itself, not for end-user workflows. This tool is outside the read-only guarantees of the other tools: log lines may contain arbitrary internal state. Only available when the MCP server was started with --dev AND the connected plugin is a development build.", "inspect:dev:tail-logs");
408
489
  }
409
490
  //#endregion
491
+ //#region ../../libs/html-to-figma-mcp-protocol/src/messages.ts
492
+ /**
493
+ * 导入落点:
494
+ * - insert:作为 nodeId 的子节点追加
495
+ * - replace + nodeId:移到该节点的父级/层序/坐标并删除它。可指任意节点(含用户自己画的占位框)
496
+ * - replace + taskId:同上,目标是该任务的导入产物。只认插件任务注册表内、同 connection 的
497
+ * 任务;插件侧会校验形状与可解析性,解析不出时 ack 明确错误
498
+ */
499
+ const ImportTargetSchema = z.union([z.object({
500
+ mode: z.enum(["insert", "replace"]),
501
+ nodeId: z.string()
502
+ }).strict(), z.object({
503
+ mode: z.literal("replace"),
504
+ taskId: z.string()
505
+ }).strict()]);
506
+ /**
507
+ * 单次导入对插件侧 MCP 配置的覆盖,未给的字段沿用插件配置。
508
+ *
509
+ * 只收「结构性且不额外扣费」的项:agent 传的是用户在对话里说的话(「这次别开自动布局」),
510
+ * 而扣费项(自动重命名图层走 AI,按次扣额度)一旦可被单次调用打开,就成了 agent 替用户花钱,
511
+ * 因此只能在插件里由用户自己开。字体统一项同理排除——它只是开关,用哪个字体存在插件的另一处
512
+ * 设置里,agent 看不见那个值。
513
+ */
514
+ const ImportSettingsOverrideSchema = z.object({
515
+ autoLayout: z.boolean().optional(),
516
+ localStyleVariable: z.boolean().optional()
517
+ });
518
+ function getPortRange(base, size = 20) {
519
+ return {
520
+ start: base,
521
+ end: base + size - 1
522
+ };
523
+ }
524
+ //#endregion
525
+ //#region ../../libs/html-to-figma-mcp-protocol/src/port-segments.ts
526
+ /**
527
+ * html-to-figma MCP 在整个 MCP 端口空间里占用 5000–5599 段,平台 base 按整百间隔
528
+ * (5000–5099 空置不用),实际绑定/扫描只用每个 base 起的前 DEFAULT_PORT_SEGMENT_SIZE
529
+ * 个端口(如 figma 为 5500–5519),其余留作扩容余量。
530
+ * 每个新 MCP 产品应选独立的 base(如 6000/7000/...),互不重叠。
531
+ */
532
+ const PLATFORM_PORT_BASE = {
533
+ figma: 5500,
534
+ mastergo: 5100,
535
+ jsdesign: 5200,
536
+ "pixso-china": 5300,
537
+ "pixso-world": 5400
538
+ };
539
+ function getHtmlToFigmaPortRange(platform) {
540
+ return getPortRange(PLATFORM_PORT_BASE[platform], 20);
541
+ }
542
+ //#endregion
543
+ //#region ../../libs/html-to-figma-mcp-protocol/src/protocol.ts
544
+ /** 产品身份魔术字符串。服务端 hello 校验时,payload.magic 必须与此值相等 */
545
+ const MAGIC = "refore-html-to-design-mcp";
546
+ //#endregion
410
547
  //#region src/capture-guide.ts
411
548
  /**
412
549
  * agent 抓取页面 DOM 的标准作业指南,由 `get_capture_guide` 工具原文返回。
@@ -416,6 +553,15 @@ function registerInspectTools(server, bridge, options) {
416
553
  */
417
554
  const CAPTURE_GUIDE = `# Capturing a rendered page for import_html
418
555
 
556
+ Scope — this guide is for pages that must be RENDERED in a browser before their DOM can be imported
557
+ (a live site, a flow you walk, a page behind login, a state after interaction). It does NOT apply to a
558
+ local HTML file the user handed you directly (their own page, a static prototype, an exported HTML):
559
+ import that as-is with import_html, passing the absolute path and source:"user-file" (plus the files it
560
+ references from the same directory in assets). Opening such a file in a browser and dumping it back out
561
+ only adds a localhost server, a browser round-trip and extension noise — the dump is the same file minus
562
+ its scripts. Capture it only if the file is NOT self-contained: it needs login state, lazy-loads content,
563
+ draws in <canvas>, or its scripts build the main DOM.
564
+
419
565
  Workflow rhythm — capture is part of walking the flow, not a phase after it. When the task spans
420
566
  several pages/states, import each one the moment you first reach it: capture, submit with
421
567
  wait:false, keep walking while the plugin imports, then wait_task the previous submission
@@ -542,7 +688,7 @@ actually open in the browser (no login state, no interaction state).
542
688
  the taskId immediately), keep walking the flow and capture state N+1 while the plugin imports
543
689
  (its task queue is serial), then wait_task(N) and verify state N — any failure costs at most one
544
690
  wasted capture instead of N.
545
- - Verification runs entirely on the Figma side (get_node_info + export_node_screenshot) against
691
+ - Verification runs entirely on the ${MCP_PLATFORM_NAME} side (get_nodes + export_node_image) against
546
692
  the baseline you froze at capture time — the browser never needs to navigate back. Take a
547
693
  viewport screenshot of the page right when you capture it, as the comparison baseline. Check:
548
694
  visibleAreaRatio is 1 and the root size matches the viewport; the exported image has no large
@@ -569,6 +715,62 @@ const DEFAULT_VIEWPORT = {
569
715
  height: 1080
570
716
  };
571
717
  const MAX_PATH_LEN = 4096;
718
+ /**
719
+ * 路径模式下 HTML 文件的来历:用户直接交给 agent 的源文件(`user-file`),还是 agent 从浏览器
720
+ * 抓下来落盘的 dump(`browser-dump`)。两者走同一个 `html` 路径参数,但适用的工作流完全不同——
721
+ * 只有 dump 才需要先读抓取指南;用户源文件直接导即可。
722
+ */
723
+ const ImportSourceSchema = z.enum(["user-file", "browser-dump"]);
724
+ /**
725
+ * `import_html` 的来源侧入参形状,**是这组字段的唯一来源**:工具注册直接铺开它,
726
+ * `ImportHtmlArgs` 用 `z.infer` 派生。手写第二份的漂移是静默的——schema 加了字段而接口漏改,
727
+ * handler 照样编译(结构类型允许多字段),字段在这里被悄悄丢掉。
728
+ */
729
+ const importHtmlSourceShape = {
730
+ html: z.string(),
731
+ source: ImportSourceSchema.optional(),
732
+ assets: z.array(z.object({
733
+ path: z.string(),
734
+ as: z.string().optional()
735
+ })).optional(),
736
+ width: z.number().optional(),
737
+ height: z.number().optional(),
738
+ target: ImportTargetSchema.optional(),
739
+ settings: ImportSettingsOverrideSchema.optional()
740
+ };
741
+ function defaultPathOriginEnv() {
742
+ return {
743
+ tmpdir: os.tmpdir(),
744
+ home: os.homedir()
745
+ };
746
+ }
747
+ /** macOS 的 `os.tmpdir()` 给的是 `/var/folders/...`,而工具落盘时常写成 `/private/var/...`——两者是同一处 */
748
+ function stripPrivatePrefix(p) {
749
+ return p.startsWith("/private/") ? p.slice(8) : p;
750
+ }
751
+ function isInside(target, root) {
752
+ const rel = path.relative(root, target);
753
+ return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
754
+ }
755
+ /** 目录段命中即视为 dump 落点:OS 临时目录之外,agent 工具的 scratchpad 与各种 `/tmp/` 也是常见写盘处 */
756
+ const DUMP_DIR_SEGMENTS = /* @__PURE__ */ new Set(["tmp", "scratchpad"]);
757
+ /**
758
+ * 未标 `source` 时按路径位置猜来历。dump 的落点高度集中(OS 临时目录 / 用户 Downloads / scratchpad /
759
+ * `tmp` 段),其余位置默认是用户自己的文件。这只是缺省,显式 `source` 永远优先——猜错的两个方向都有
760
+ * 出路:用户文件放在 Downloads 被拦 → 报错文案指路标 `user-file` 重试;dump 写进工程目录漏拦 →
761
+ * description 要求 dump 必标 `browser-dump`。
762
+ */
763
+ function classifyPathOrigin(p, env = defaultPathOriginEnv()) {
764
+ const abs = stripPrivatePrefix(path.resolve(p));
765
+ if ([env.tmpdir, path.join(env.home, "Downloads")].map((r) => stripPrivatePrefix(path.resolve(r))).some((root) => isInside(abs, root))) return "dump-like";
766
+ if (path.dirname(abs).split(path.sep).some((s) => DUMP_DIR_SEGMENTS.has(s))) return "dump-like";
767
+ return "user-like";
768
+ }
769
+ /** 路径模式下的有效来历:显式 `source` 优先,否则按路径位置猜。content 模式没有来历概念,不要调它 */
770
+ function resolveImportSource(args, env = defaultPathOriginEnv()) {
771
+ if (args.source) return args.source;
772
+ return classifyPathOrigin(args.html, env) === "dump-like" ? "browser-dump" : "user-file";
773
+ }
572
774
  const defaultIO = { readFile: (p) => readFile(p) };
573
775
  /** html 二义消解:绝对路径且不含换行且长度合理 → path,否则 content */
574
776
  function resolveHtmlInput(html) {
@@ -578,7 +780,16 @@ function resolveHtmlInput(html) {
578
780
  function isEnoent(e) {
579
781
  return typeof e === "object" && e !== null && e.code === "ENOENT";
580
782
  }
581
- async function normalizeHtmlSource(args, io = defaultIO) {
783
+ /**
784
+ * 读不到文件时的指路按来历分流:dump 落盘失败的高频原因是浏览器工具的写盘目录受限,指向 OS
785
+ * 临时目录;用户源文件读不到则多半是路径抄错(空格 / 引号 / `~` 没展开),这时把 agent 引去
786
+ * 临时目录或浏览器抓取都是误导。
787
+ */
788
+ function enoentMessage(p, source) {
789
+ if (source === "user-file") return `${p} (ENOENT). The file the user pointed you to does not exist at this path. Re-check the path exactly as the user gave it (spaces, quotes, ~ expansion, escaping) and ask them if it still cannot be found — do not fall back to capturing the page in a browser just because the file was not found.`;
790
+ return `${p} (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.`;
791
+ }
792
+ async function normalizeHtmlSource(args, io = defaultIO, env) {
582
793
  const input = resolveHtmlInput(args.html);
583
794
  let html;
584
795
  let baseDir;
@@ -586,7 +797,7 @@ async function normalizeHtmlSource(args, io = defaultIO) {
586
797
  try {
587
798
  html = (await io.readFile(args.html)).toString("utf8");
588
799
  } catch (e) {
589
- 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.`);
800
+ if (isEnoent(e)) throw new Error(enoentMessage(args.html, resolveImportSource(args, env)));
590
801
  throw e;
591
802
  }
592
803
  baseDir = path.dirname(args.html);
@@ -616,18 +827,20 @@ async function normalizeHtmlSource(args, io = defaultIO) {
616
827
  width: args.width ?? DEFAULT_VIEWPORT.width,
617
828
  height: args.height ?? DEFAULT_VIEWPORT.height
618
829
  },
619
- target: args.target
830
+ target: args.target,
831
+ settings: args.settings
620
832
  };
621
833
  }
622
834
  //#endregion
623
835
  //#region src/tools.ts
624
- const targetSchema = z.union([z.object({
625
- mode: z.enum(["insert", "replace"]),
626
- nodeId: z.string()
627
- }).strict(), z.object({
628
- mode: z.literal("replace"),
629
- taskId: z.string()
630
- }).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();
836
+ /**
837
+ * 重导(`target` 的 taskId 形式)的使用纪律,编码进 schema description 让 agent 遵守:重试只能
838
+ * 修输入侧错误,解析引擎的保真度缺口同输入重导只会确定性复现,且每次导入都真实扣费。
839
+ */
840
+ const TARGET_DESCRIPTION = "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.";
841
+ const settingsSchema = ImportSettingsOverrideSchema.strict().describe("Per-import overrides of the user's plugin-side import settings; omitted fields keep the plugin setting. Set a field ONLY when the user asked for it in this conversation — these are their preferences, not knobs for you to tune. `autoLayout`: build the imported frames with auto layout, so the result stays editable and reflows, instead of absolutely positioned children. `localStyleVariable`: register the page's colors and text styles as local styles in the file. Every task result echoes back the settings that actually applied — trust that echo over what you sent: a plugin too old to know this parameter ignores it silently and returns no echo.").optional();
842
+ const targetSchema = ImportTargetSchema.describe(TARGET_DESCRIPTION).optional();
843
+ const sourceSchema = importHtmlSourceShape.source.describe("Only meaningful when `html` is a file path. `\"user-file\"`: a local HTML file the user handed you as-is (their own page, a designer export, a static prototype) — imported directly, no capture guide, no browser. `\"browser-dump\"`: a rendered-DOM dump you captured from a browser following get_capture_guide. When omitted, the origin is guessed from where the file lives (OS temp dir, ~/Downloads, a `tmp` or `scratchpad` directory count as dump locations; anywhere else counts as a user file). Always set it explicitly when you know — a user file saved under ~/Downloads is otherwise treated as a dump and gated, and a dump written elsewhere skips the gate.");
631
844
  function textResult(obj) {
632
845
  return { content: [{
633
846
  type: "text",
@@ -643,7 +856,7 @@ function errorResult(message) {
643
856
  isError: true
644
857
  };
645
858
  }
646
- function registerTools(server, hub) {
859
+ function registerTools(server, hub, options = {}) {
647
860
  function noPluginError() {
648
861
  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.`);
649
862
  }
@@ -667,20 +880,16 @@ function registerTools(server, hub) {
667
880
  }
668
881
  let captureGuideServed = false;
669
882
  server.registerTool("import_html", {
670
- 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.`,
883
+ description: `Import HTML into ${MCP_PLATFORM_NAME}. If \`html\` is an absolute path it is read as a file, otherwise it is treated as HTML content. Three kinds of input, pick by where the HTML comes from: (1) A LOCAL HTML FILE THE USER GAVE YOU (their own page, a static prototype, an exported HTML): pass the absolute path with \`source:"user-file"\` and list the files it references from the same directory in \`assets\` — do NOT open it in a browser and do NOT read get_capture_guide, that path is only for dumps and would only add a localhost server, a browser round-trip and extension noise for nothing. Direct import is right when the file is self-contained: no login state, no lazy-loaded content, no <canvas>, and its scripts do not build the main DOM (a clock or a click handler does not count). If any of those does apply, treat it as kind 2. (2) A PAGE YOU CAN OPEN OR RENDER YOURSELF (walking a flow, pages behind login, states after interaction, or the user only gave you a URL — a URL is the target address, not a method choice): call get_capture_guide BEFORE opening the first page (it fixes the rhythm — import each page the moment you reach it, never walk the whole flow first — and the traps that waste paid imports: lazy loading, canvas snapshots, where dump files can be written), dump the rendered DOM and import the dump with \`source:"browser-dump"\`; a dump imported without having read the guide is rejected. One exception: a Refore demo share link (\`app.demoway.cn|com/demo/<id>\`) belongs to \`dev_import_demo\` when that tool is available — it replays the demo through the plugin itself, whereas capturing such a link in a browser and importing the dump goes through a different resource-rewriting path and can hide or invent differences. (3) A PAGE WHOSE DOM YOU CANNOT GET 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.`,
671
884
  inputSchema: {
672
- html: z.string(),
673
- assets: z.array(z.object({
674
- path: z.string(),
675
- as: z.string().optional()
676
- })).optional(),
677
- width: z.number().optional(),
678
- height: z.number().optional(),
885
+ ...importHtmlSourceShape,
886
+ source: sourceSchema,
679
887
  target: targetSchema,
888
+ settings: settingsSchema,
680
889
  wait: z.boolean().optional()
681
890
  }
682
891
  }, async (args) => {
683
- 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.");
892
+ if (!captureGuideServed && resolveHtmlInput(args.html).kind === "path" && resolveImportSource(args) === "browser-dump") return errorResult(`${args.source === void 0 ? "This file lives in a location where browser dumps are usually written, so it is treated as a dump. If it is actually a file the user handed you directly, retry with `source:\"user-file\"` — no guide needed. If it IS a dump: " : ""}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.`);
684
893
  let source;
685
894
  try {
686
895
  source = await normalizeHtmlSource(args);
@@ -698,6 +907,7 @@ function registerTools(server, hub) {
698
907
  theme: z.string().optional(),
699
908
  locale: z.string().optional(),
700
909
  target: targetSchema,
910
+ settings: settingsSchema,
701
911
  wait: z.boolean().optional()
702
912
  }
703
913
  }, async (args) => {
@@ -710,11 +920,37 @@ function registerTools(server, hub) {
710
920
  },
711
921
  theme: args.theme,
712
922
  locale: args.locale,
713
- target: args.target
923
+ target: args.target,
924
+ settings: args.settings
925
+ }, { wait: args.wait ?? true });
926
+ });
927
+ if (options.dev) server.registerTool("dev_import_demo", {
928
+ description: `Import a Refore demo share link into ${MCP_PLATFORM_NAME}. Takes a demo URL of the form \`https://app.demoway.cn/demo/<id>\` (Chinese edition) or \`https://app.demoway.com/demo/<id>\` (international edition) — the link a user pastes into a bug report. This runs the same import path the user gets by opening that demo from the plugin home, so it reproduces what they saw; do NOT re-capture the demo page through a browser and feed it to import_html when a demo link is what you have, because a re-captured dump goes through a different resource-rewriting path and can hide or invent differences. Only single-step demos are supported; a multi-step (tour) demo is rejected with an explicit error. Requires a development build of the plugin. Authentication is supplied by the plugin from its own signed-in state — never append tokens to the URL yourself.`,
929
+ inputSchema: {
930
+ demoUrl: z.string(),
931
+ selector: z.string().optional().describe("Import only the subtree matching this CSS selector inside the recorded page."),
932
+ target: targetSchema,
933
+ settings: settingsSchema,
934
+ wait: z.boolean().optional()
935
+ }
936
+ }, async (args) => {
937
+ let parsed;
938
+ try {
939
+ parsed = new URL(args.demoUrl);
940
+ } catch {
941
+ return errorResult(`Not a valid URL: ${args.demoUrl}`);
942
+ }
943
+ if (!/\/demo\/[^/]+/.test(parsed.pathname)) return errorResult(`Not a demo share link: ${args.demoUrl}. Expected https://app.demoway.cn|com/demo/<id>.`);
944
+ return runTask("demo", {
945
+ kind: "demo",
946
+ demoUrl: args.demoUrl,
947
+ selector: args.selector,
948
+ target: args.target,
949
+ settings: args.settings
714
950
  }, { wait: args.wait ?? true });
715
951
  });
716
952
  server.registerTool("get_capture_guide", {
717
- 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.",
953
+ 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. Not needed for a local HTML file the user handed you directly — import that with `import_html` and `source:\"user-file\"` instead of capturing it.",
718
954
  inputSchema: {}
719
955
  }, async () => {
720
956
  captureGuideServed = true;
@@ -792,15 +1028,22 @@ function registerTools(server, hub) {
792
1028
  * 截断);Codex 把它渲染进 tool_search 的来源清单并计入 BM25 检索语料(plugin 形态只保留
793
1029
  * 前 1000 字节)。因此路由声明必须是第一句、全文不得超过 1000 字节(有测试锁定);中文
794
1030
  * 触发词是给 BM25 命中中文 query 用的——其余语料全是英文,纯中文搜索词否则一个都对不上。
1031
+ *
1032
+ * 中文触发词按 `triggerNames` 的每个叫法各写一遍,且写成「导入到X」的连写形态、当中不留
1033
+ * 空格,与用户的原句逐字一致:按空白切词的检索器会把「导入到 JSDesign」切成两个 token,
1034
+ * 连着写的 query 一个都对不上,语料里缺席的那个叫法就等于没这台 MCP。
795
1035
  */
796
- 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.`;
797
- /** 工具面的组装只在这一处:本 MCP 自己的导入工具 + design-inspect 的组 + dev 组 */
1036
+ function buildServerInstructions(platformName, triggerNames) {
1037
+ return `Use this MCP whenever the user asks to import, convert or restore a web page, URL, or HTML into ${platformName} (中文指令如「把网页/URL/HTML ${triggerNames.map((name) => `导入到${name}`).join("/")}」,产品名「网页转设计」). A local HTML file the user hands you goes straight to import_html with source:"user-file" — no browser, no capture guide. For web pages, 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.`;
1038
+ }
1039
+ const SERVER_INSTRUCTIONS = buildServerInstructions(MCP_PLATFORM_NAME, MCP_PLATFORM_TRIGGER_NAMES);
1040
+ /** 工具面的组装只在这一处:本 MCP 自己的导入工具(含 dev 专属的 demo 导入)+ design-inspect 的组 + dev 组 */
798
1041
  function createServer$1(hub, options = {}) {
799
1042
  const server = new McpServer({
800
1043
  name: MCP_SERVER_NAME,
801
1044
  version: MCP_SERVER_VERSION
802
1045
  }, { instructions: SERVER_INSTRUCTIONS });
803
- registerTools(server, hub);
1046
+ registerTools(server, hub, { dev: options.dev });
804
1047
  registerInspectTools(server, hub, {
805
1048
  platform: MCP_PLATFORM,
806
1049
  groups: [
@@ -813,28 +1056,6 @@ function createServer$1(hub, options = {}) {
813
1056
  return server;
814
1057
  }
815
1058
  //#endregion
816
- //#region ../../libs/html-to-figma-mcp-protocol/src/port-segments.ts
817
- /**
818
- * html-to-figma MCP 在整个 MCP 端口空间里占用 5000–5599 段,平台 base 按整百间隔
819
- * (5000–5099 空置不用),实际绑定/扫描只用每个 base 起的前 DEFAULT_PORT_SEGMENT_SIZE
820
- * 个端口(如 figma 为 5500–5519),其余留作扩容余量。
821
- * 每个新 MCP 产品应选独立的 base(如 6000/7000/...),互不重叠。
822
- */
823
- const PLATFORM_PORT_BASE = {
824
- figma: 5500,
825
- mastergo: 5100,
826
- jsdesign: 5200,
827
- "pixso-china": 5300,
828
- "pixso-world": 5400
829
- };
830
- function getHtmlToFigmaPortRange(platform) {
831
- return getPortRange(PLATFORM_PORT_BASE[platform], 20);
832
- }
833
- //#endregion
834
- //#region ../../libs/html-to-figma-mcp-protocol/src/protocol.ts
835
- /** 产品身份魔术字符串。服务端 hello 校验时,payload.magic 必须与此值相等 */
836
- const MAGIC = "refore-html-to-design-mcp";
837
- //#endregion
838
1059
  //#region ../../libs/mcp-transport/src/mcp/ws-hub-base.ts
839
1060
  const MAX_HTTP_BUFFER_SIZE = 100 * 1024 * 1024;
840
1061
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@refore-ai/html-to-figma-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "html-to-figma-mcp": "index.mjs"
@@ -12,6 +12,6 @@
12
12
  "dependencies": {
13
13
  "@modelcontextprotocol/sdk": "^1.29.0",
14
14
  "socket.io": "^4.8.3",
15
- "zod": "^3.25.76"
15
+ "zod": "^4.5.4"
16
16
  }
17
17
  }