@refore-ai/html-to-figma-mcp 0.0.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 +54 -0
  2. package/index.mjs +487 -0
  3. package/package.json +17 -0
package/README.md ADDED
@@ -0,0 +1,54 @@
1
+ # @refore-ai/html-to-figma-mcp
2
+
3
+ MCP (Model Context Protocol) server that lets AI agents — Claude Code, Claude Desktop, Codex,
4
+ Cursor, Windsurf and any other MCP client — import HTML files and web pages onto your
5
+ Figma canvas, through the [Refore HTML to Figma](https://www.figma.com/community/plugin/1385944139259302061/) plugin.
6
+
7
+ ## Requirements
8
+
9
+ - Node.js 18+
10
+ - Figma, with the [Refore HTML to Figma](https://www.figma.com/community/plugin/1385944139259302061/) plugin installed
11
+
12
+ ## Setup
13
+
14
+ **Claude Code**
15
+
16
+ ```bash
17
+ claude mcp add html-to-figma -- npx -y @refore-ai/html-to-figma-mcp
18
+ ```
19
+
20
+ **Claude Desktop** — add to `claude_desktop_config.json`, then restart the app:
21
+
22
+ ```json
23
+ {
24
+ "mcpServers": {
25
+ "html-to-figma": {
26
+ "command": "npx",
27
+ "args": ["-y", "@refore-ai/html-to-figma-mcp"]
28
+ }
29
+ }
30
+ }
31
+ ```
32
+
33
+ **Codex** — add to `~/.codex/config.toml`:
34
+
35
+ ```toml
36
+ [mcp_servers.html-to-figma]
37
+ command = "npx"
38
+ args = ["-y", "@refore-ai/html-to-figma-mcp"]
39
+ ```
40
+
41
+ **Cursor / Windsurf / others** — use the same `command` / `args` as the Claude Desktop JSON above.
42
+
43
+ Then open the Refore HTML to Figma plugin in Figma, go to the **MCP** tab and connect.
44
+ Now ask your agent to import a page.
45
+
46
+ ## Tools
47
+
48
+ | Tool | Purpose |
49
+ | ---- | ------- |
50
+ | `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` |
51
+ | `import_url` | Import a web page by URL |
52
+ | `get_status` | Returns `ws_port` / `connected` / `queue { mine, total, running }` |
53
+ | `wait_task` | Block until a task settles, then return its final result |
54
+ | `get_task_status` | Non-blocking check of a task's current phase / result |
package/index.mjs ADDED
@@ -0,0 +1,487 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { randomUUID } from "node:crypto";
5
+ import { z } from "zod";
6
+ import { readFile } from "node:fs/promises";
7
+ import path from "node:path";
8
+ import { createServer } from "node:http";
9
+ import { Server } from "socket.io";
10
+ //#region src/platform-meta.ts
11
+ /**
12
+ * 面向 agent 的平台展示名(用于工具描述、npm README 等)。
13
+ *
14
+ * 与 libs/constants 的 `PLATFORM_NAME`(插件 UI 用,jsdesign 显示「即时设计」)不是一回事。
15
+ *
16
+ * 本文件同时被 src/platform.ts(打进发布产物)和 tsdown.config.ts(构建时渲染 README)import,
17
+ * 因此这里只放纯数据:不引 runtime 依赖(`PlatformType` / `Region` 这类 enum 会把整个
18
+ * @UseringOfficial/utils 打进 server 包),也不依赖 `__MCP_PLATFORM__` 等构建期注入的常量
19
+ * (否则 tsdown config 加载阶段 import 就会报错)。
20
+ */
21
+ const PLATFORM_DISPLAY_NAMES = {
22
+ figma: "Figma",
23
+ mastergo: "MasterGo",
24
+ jsdesign: "JSDesign",
25
+ "pixso-china": "Pixso",
26
+ "pixso-world": "Pixso"
27
+ };
28
+ //#endregion
29
+ //#region src/platform.ts
30
+ const MCP_PLATFORM = "figma";
31
+ /** 从源 package.json 的 version 由 tsdown define 注入;测试环境未注入 → 兜底 '0.0.0-dev' */
32
+ const MCP_SERVER_VERSION = "0.0.1";
33
+ /** 面向 agent 的平台展示名(用于工具描述等) */
34
+ const MCP_PLATFORM_NAME = PLATFORM_DISPLAY_NAMES[MCP_PLATFORM];
35
+ /** 本 MCP server 的包名 / 日志前缀基名 */
36
+ const MCP_SERVER_NAME = `html-to-${MCP_PLATFORM}-mcp`;
37
+ /** stdout 归 MCP JSON-RPC 独占,本进程所有日志走 stderr */
38
+ function mcpLog(message) {
39
+ process.stderr.write(`[${MCP_SERVER_NAME}] ${message}\n`);
40
+ }
41
+ //#endregion
42
+ //#region src/import-source.ts
43
+ const DEFAULT_VIEWPORT = {
44
+ width: 1920,
45
+ height: 1080
46
+ };
47
+ const MAX_PATH_LEN = 4096;
48
+ const defaultIO = { readFile: (p) => readFile(p) };
49
+ /** html 二义消解:绝对路径且不含换行且长度合理 → path,否则 content */
50
+ function resolveHtmlInput(html) {
51
+ if (path.isAbsolute(html) && !html.includes("\n") && html.length <= MAX_PATH_LEN) return { kind: "path" };
52
+ return { kind: "content" };
53
+ }
54
+ function isEnoent(e) {
55
+ return typeof e === "object" && e !== null && e.code === "ENOENT";
56
+ }
57
+ async function normalizeHtmlSource(args, io = defaultIO) {
58
+ const input = resolveHtmlInput(args.html);
59
+ let html;
60
+ let baseDir;
61
+ if (input.kind === "path") {
62
+ try {
63
+ html = (await io.readFile(args.html)).toString("utf8");
64
+ } catch (e) {
65
+ if (isEnoent(e)) throw new Error(`${args.html} (ENOENT)`);
66
+ throw e;
67
+ }
68
+ baseDir = path.dirname(args.html);
69
+ } else html = args.html;
70
+ const pageName = input.kind === "path" ? path.basename(args.html) : void 0;
71
+ const assets = [];
72
+ for (const asset of args.assets ?? []) {
73
+ let bytes;
74
+ try {
75
+ bytes = await io.readFile(asset.path);
76
+ } catch (e) {
77
+ if (isEnoent(e)) throw new Error(`${asset.path} (ENOENT)`);
78
+ throw e;
79
+ }
80
+ const as = (asset.as ?? (baseDir ? path.relative(baseDir, asset.path).split(path.sep).join("/") : path.basename(asset.path))).replace(/^\.\//, "");
81
+ assets.push({
82
+ as,
83
+ dataBase64: bytes.toString("base64")
84
+ });
85
+ }
86
+ return {
87
+ kind: "html",
88
+ pageName,
89
+ html,
90
+ assets,
91
+ viewport: {
92
+ width: args.width ?? DEFAULT_VIEWPORT.width,
93
+ height: args.height ?? DEFAULT_VIEWPORT.height
94
+ },
95
+ target: args.target
96
+ };
97
+ }
98
+ //#endregion
99
+ //#region src/tools.ts
100
+ const targetSchema = z.object({
101
+ mode: z.enum(["insert", "replace"]),
102
+ nodeId: z.string()
103
+ }).optional();
104
+ function textResult(obj) {
105
+ return { content: [{
106
+ type: "text",
107
+ text: JSON.stringify(obj)
108
+ }] };
109
+ }
110
+ function errorResult(message) {
111
+ return {
112
+ content: [{
113
+ type: "text",
114
+ text: message
115
+ }],
116
+ isError: true
117
+ };
118
+ }
119
+ function registerTools(server, deps) {
120
+ 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");
123
+ const taskId = randomUUID();
124
+ const submitP = hub.submit({
125
+ taskId,
126
+ kind,
127
+ payload
128
+ });
129
+ if (!wait) {
130
+ submitP.catch(() => {});
131
+ return textResult({ taskId });
132
+ }
133
+ try {
134
+ return textResult(await submitP);
135
+ } catch (e) {
136
+ return errorResult(e.message);
137
+ }
138
+ }
139
+ 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.`,
141
+ inputSchema: {
142
+ html: z.string(),
143
+ assets: z.array(z.object({
144
+ path: z.string(),
145
+ as: z.string().optional()
146
+ })).optional(),
147
+ width: z.number().optional(),
148
+ height: z.number().optional(),
149
+ target: targetSchema,
150
+ wait: z.boolean().optional()
151
+ }
152
+ }, async (args) => {
153
+ let source;
154
+ try {
155
+ source = await normalizeHtmlSource(args);
156
+ } catch (e) {
157
+ return errorResult(e.message);
158
+ }
159
+ return runTask("html", source, args.wait ?? true);
160
+ });
161
+ server.registerTool("import_url", {
162
+ description: `Import a web page URL into ${MCP_PLATFORM_NAME}.`,
163
+ inputSchema: {
164
+ url: z.string(),
165
+ width: z.number().optional(),
166
+ height: z.number().optional(),
167
+ theme: z.string().optional(),
168
+ locale: z.string().optional(),
169
+ target: targetSchema,
170
+ wait: z.boolean().optional()
171
+ }
172
+ }, async (args) => {
173
+ return runTask("url", {
174
+ kind: "url",
175
+ url: args.url,
176
+ viewport: {
177
+ width: args.width ?? DEFAULT_VIEWPORT.width,
178
+ height: args.height ?? DEFAULT_VIEWPORT.height
179
+ },
180
+ theme: args.theme,
181
+ locale: args.locale,
182
+ target: args.target
183
+ }, args.wait ?? true);
184
+ });
185
+ server.registerTool("wait_task", {
186
+ description: "Block until a task finishes and return its result.",
187
+ inputSchema: { taskId: z.string() }
188
+ }, async (args) => {
189
+ if (!hub.connected) return errorResult("Plugin not connected (NO_PLUGIN)");
190
+ try {
191
+ return textResult(await hub.taskWait(args.taskId));
192
+ } catch (e) {
193
+ return errorResult(e.message);
194
+ }
195
+ });
196
+ server.registerTool("get_task_status", {
197
+ 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
+ inputSchema: { taskId: z.string() }
199
+ }, async (args) => {
200
+ if (!hub.connected) return errorResult("Plugin not connected (NO_PLUGIN)");
201
+ try {
202
+ return textResult(await hub.taskQuery(args.taskId));
203
+ } catch (e) {
204
+ return errorResult(e.message);
205
+ }
206
+ });
207
+ server.registerTool("get_status", {
208
+ 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
+ inputSchema: { include_tasks: z.boolean().optional() }
210
+ }, async (args) => {
211
+ if (!hub.connected) return textResult({
212
+ ws_port: hub.port,
213
+ connected: false,
214
+ plugin: null
215
+ });
216
+ try {
217
+ const status = await hub.statusQuery({ includeTasks: args.include_tasks });
218
+ return textResult({
219
+ ws_port: hub.port,
220
+ connected: true,
221
+ plugin: {
222
+ queue: status.queue,
223
+ ...status.tasks ? { tasks: status.tasks } : {}
224
+ }
225
+ });
226
+ } catch (e) {
227
+ return errorResult(e.message);
228
+ }
229
+ });
230
+ }
231
+ //#endregion
232
+ //#region src/server.ts
233
+ function createServer$1(deps) {
234
+ const server = new McpServer({
235
+ name: MCP_SERVER_NAME,
236
+ version: MCP_SERVER_VERSION
237
+ });
238
+ registerTools(server, deps);
239
+ return server;
240
+ }
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–5499 段,按平台每 100 一格。
251
+ * 每个新 MCP 产品应选独立的 base(如 6000/7000/...),互不重叠。
252
+ */
253
+ const PLATFORM_PORT_BASE = {
254
+ figma: 5e3,
255
+ mastergo: 5100,
256
+ jsdesign: 5200,
257
+ "pixso-china": 5300,
258
+ "pixso-world": 5400
259
+ };
260
+ function getHtmlToFigmaPortRange(platform) {
261
+ return getPortRange(PLATFORM_PORT_BASE[platform], 100);
262
+ }
263
+ //#endregion
264
+ //#region ../../libs/html-to-figma-mcp-protocol/src/protocol.ts
265
+ /** 产品身份魔术字符串。服务端 hello 校验时,payload.magic 必须与此值相等 */
266
+ const MAGIC = "refore-html-to-design-mcp";
267
+ //#endregion
268
+ //#region ../../libs/mcp-transport/src/mcp/ws-hub-base.ts
269
+ const MAX_HTTP_BUFFER_SIZE = 100 * 1024 * 1024;
270
+ function defaultOriginCheck(origin) {
271
+ if (!origin || origin === "null") return true;
272
+ try {
273
+ const host = new URL(origin).hostname;
274
+ return host === "127.0.0.1" || host === "localhost";
275
+ } catch {
276
+ return false;
277
+ }
278
+ }
279
+ /**
280
+ * MCP server 通用基座:portRange 内挑空闲端口绑 socket.io + 处理应用层 hello 握手(含 occupied /
281
+ * force takeover)+ 维护主插件 socket。产品子类通过 opts 注入自己的 magic/事件监听器,
282
+ * 用 protected getPlugin() 主动 emit 业务事件。
283
+ */
284
+ var WsHubBase = class {
285
+ opts;
286
+ io = null;
287
+ plugin = null;
288
+ boundPort = 0;
289
+ pluginCleanups = /* @__PURE__ */ new Set();
290
+ /** 进程唯一身份,握手时通过 HelloAck 传给插件 —— 让插件能区分同 port 前后两个不同进程 */
291
+ connectionId = randomUUID();
292
+ constructor(opts) {
293
+ this.opts = opts;
294
+ }
295
+ get port() {
296
+ return this.boundPort;
297
+ }
298
+ get connected() {
299
+ return this.plugin !== null;
300
+ }
301
+ /** 返回当前活跃的插件 socket,供子类主动 emit 业务事件(如 task:submit)*/
302
+ getPlugin() {
303
+ return this.plugin;
304
+ }
305
+ async listen() {
306
+ const { start, end } = this.opts.portRange;
307
+ const originCheck = this.opts.allowedOrigins ?? defaultOriginCheck;
308
+ for (let p = start; p <= end; p++) try {
309
+ await this.tryListen(p, originCheck);
310
+ this.boundPort = p;
311
+ return p;
312
+ } catch (e) {
313
+ if (e.code === "EADDRINUSE") continue;
314
+ throw e;
315
+ }
316
+ throw new Error(`No free port in range ${start}-${end}`);
317
+ }
318
+ tryListen(port, originCheck) {
319
+ return new Promise((resolve, reject) => {
320
+ const http = createServer();
321
+ const io = new Server(http, {
322
+ maxHttpBufferSize: MAX_HTTP_BUFFER_SIZE,
323
+ transports: ["websocket"],
324
+ allowRequest: (req, cb) => cb(null, originCheck(req.headers.origin)),
325
+ cors: { origin: false }
326
+ });
327
+ io.on("connection", (socket) => this.onConnection(socket));
328
+ const onError = (e) => {
329
+ io.close();
330
+ http.close();
331
+ reject(e);
332
+ };
333
+ http.once("error", onError);
334
+ http.listen(port, "127.0.0.1", () => {
335
+ http.removeListener("error", onError);
336
+ this.io = io;
337
+ resolve();
338
+ });
339
+ });
340
+ }
341
+ onConnection(socket) {
342
+ socket.on("hello", (payload, ack) => {
343
+ if (payload?.magic !== this.opts.magic || payload?.protocolVersion !== this.opts.protocolVersion) {
344
+ ack({ error: { reason: "magic" } });
345
+ socket.disconnect(true);
346
+ return;
347
+ }
348
+ if (payload.platform !== this.opts.platformTag) {
349
+ ack({ error: { reason: "platform" } });
350
+ socket.disconnect(true);
351
+ return;
352
+ }
353
+ if (this.plugin && this.plugin.connected) {
354
+ if (!payload.force) {
355
+ ack({ error: {
356
+ reason: "occupied",
357
+ occupiedBy: this.opts.agent
358
+ } });
359
+ socket.disconnect(true);
360
+ return;
361
+ }
362
+ this.plugin.emit("superseded", {});
363
+ this.plugin.disconnect(true);
364
+ }
365
+ this.bindPlugin(socket);
366
+ ack({
367
+ agent: this.opts.agent,
368
+ wsPort: this.boundPort,
369
+ connectionId: this.connectionId
370
+ });
371
+ });
372
+ }
373
+ bindPlugin(socket) {
374
+ this.plugin = socket;
375
+ const cleanup = this.opts.onPluginBound?.(socket);
376
+ if (cleanup) this.pluginCleanups.add(cleanup);
377
+ socket.on("disconnect", () => {
378
+ if (this.plugin === socket) this.plugin = null;
379
+ const cleanups = [...this.pluginCleanups];
380
+ this.pluginCleanups.clear();
381
+ for (const fn of cleanups) try {
382
+ fn();
383
+ } catch {}
384
+ });
385
+ }
386
+ /** 握手完成后用真实 clientInfo 覆盖占位 agent(供子类/调用方 setAgent 使用)*/
387
+ setAgent(agent) {
388
+ this.opts.agent = agent;
389
+ }
390
+ async close() {
391
+ this.plugin?.disconnect(true);
392
+ this.plugin = null;
393
+ await new Promise((resolve) => {
394
+ if (!this.io) {
395
+ resolve();
396
+ return;
397
+ }
398
+ this.io.close(() => resolve());
399
+ });
400
+ this.io = null;
401
+ }
402
+ };
403
+ //#endregion
404
+ //#region src/ws-hub.ts
405
+ /**
406
+ * html-to-figma MCP 的 server:在 `WsHubBase`(端口绑定 + hello 握手 + plugin socket 生命
407
+ * 周期)之上加 task 命令与查询 RPC,全部走 socket.io ack 现问现答,本类不缓存任何 task 状态
408
+ * (真源在插件的 active + history)。
409
+ */
410
+ var WsHub = class extends WsHubBase {
411
+ /** 已 emit 但插件尚未 ack 的请求。插件掉线时统一 reject —— socket.io 不会自动结算掉线时的
412
+ * ack callback,不主动 reject 的话 wait:true 的 submit / taskWait / 查询类都会永远 hang */
413
+ pendingAcks = /* @__PURE__ */ new Set();
414
+ constructor(opts) {
415
+ super({
416
+ magic: MAGIC,
417
+ protocolVersion: 1,
418
+ platformTag: opts.platform,
419
+ agent: opts.agent,
420
+ portRange: getHtmlToFigmaPortRange(opts.platform),
421
+ allowedOrigins: opts.allowedOrigins,
422
+ onPluginBound: () => () => {
423
+ const pending = [...this.pendingAcks];
424
+ this.pendingAcks.clear();
425
+ for (const rejectFn of pending) rejectFn(/* @__PURE__ */ new Error("NO_PLUGIN: plugin disconnected before ack"));
426
+ }
427
+ });
428
+ }
429
+ emitWithAck(event, payload) {
430
+ const plugin = this.getPlugin();
431
+ if (!plugin || !plugin.connected) return Promise.reject(/* @__PURE__ */ new Error("NO_PLUGIN: plugin not connected"));
432
+ return new Promise((resolve, reject) => {
433
+ const abort = (err) => reject(err);
434
+ this.pendingAcks.add(abort);
435
+ plugin.emit(event, payload, (res) => {
436
+ this.pendingAcks.delete(abort);
437
+ if (res && typeof res === "object" && "error" in res && typeof res.error === "string") reject(new Error(res.error));
438
+ else resolve(res);
439
+ });
440
+ });
441
+ }
442
+ submit(payload) {
443
+ return this.emitWithAck("task:submit", payload);
444
+ }
445
+ cancel(taskId) {
446
+ this.getPlugin()?.emit("task:cancel", { taskId });
447
+ }
448
+ taskWait(taskId) {
449
+ return this.emitWithAck("task:wait", { taskId });
450
+ }
451
+ taskQuery(taskId) {
452
+ return this.emitWithAck("task:query", { taskId });
453
+ }
454
+ statusQuery(req) {
455
+ return this.emitWithAck("status:query", req);
456
+ }
457
+ };
458
+ //#endregion
459
+ //#region src/index.ts
460
+ async function main() {
461
+ const hub = new WsHub({
462
+ platform: MCP_PLATFORM,
463
+ agent: {
464
+ name: "unknown",
465
+ version: "0",
466
+ cwd: process.cwd()
467
+ }
468
+ });
469
+ mcpLog(`WS listening on 127.0.0.1:${await hub.listen()}`);
470
+ const server = createServer$1({ hub });
471
+ server.server.oninitialized = () => {
472
+ const info = server.server.getClientVersion();
473
+ if (info) hub.setAgent({
474
+ name: info.name,
475
+ version: info.version,
476
+ cwd: process.cwd()
477
+ });
478
+ };
479
+ const transport = new StdioServerTransport();
480
+ await server.connect(transport);
481
+ }
482
+ main().catch((e) => {
483
+ mcpLog(`fatal: ${e.stack ?? e}`);
484
+ process.exit(1);
485
+ });
486
+ //#endregion
487
+ export {};
package/package.json ADDED
@@ -0,0 +1,17 @@
1
+ {
2
+ "name": "@refore-ai/html-to-figma-mcp",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "bin": {
6
+ "html-to-figma-mcp": "index.mjs"
7
+ },
8
+ "publishConfig": {
9
+ "registry": "https://registry.npmjs.org/",
10
+ "access": "public"
11
+ },
12
+ "dependencies": {
13
+ "@modelcontextprotocol/sdk": "^1.29.0",
14
+ "socket.io": "^4.8.3",
15
+ "zod": "^3.25.76"
16
+ }
17
+ }