@atezer/figma-mcp-bridge 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +99 -9
  3. package/dist/cloudflare/cloud-cors.js +40 -0
  4. package/dist/cloudflare/cloud-mode-kv.js +86 -0
  5. package/dist/cloudflare/cloud-mode-routes.js +97 -0
  6. package/dist/cloudflare/cloud-relay-session.js +141 -0
  7. package/dist/cloudflare/core/config.js +1 -1
  8. package/dist/cloudflare/core/figma-url.js +48 -0
  9. package/dist/cloudflare/core/plugin-bridge-connector.js +52 -43
  10. package/dist/cloudflare/core/plugin-bridge-server.js +211 -87
  11. package/dist/cloudflare/index.js +243 -4
  12. package/dist/core/config.js +1 -1
  13. package/dist/core/config.js.map +1 -1
  14. package/dist/core/figma-url.d.ts +10 -0
  15. package/dist/core/figma-url.d.ts.map +1 -0
  16. package/dist/core/figma-url.js +49 -0
  17. package/dist/core/figma-url.js.map +1 -0
  18. package/dist/core/plugin-bridge-connector.d.ts +6 -1
  19. package/dist/core/plugin-bridge-connector.d.ts.map +1 -1
  20. package/dist/core/plugin-bridge-connector.js +52 -43
  21. package/dist/core/plugin-bridge-connector.js.map +1 -1
  22. package/dist/core/plugin-bridge-server.d.ts +47 -14
  23. package/dist/core/plugin-bridge-server.d.ts.map +1 -1
  24. package/dist/core/plugin-bridge-server.js +211 -87
  25. package/dist/core/plugin-bridge-server.js.map +1 -1
  26. package/dist/local-plugin-only.d.ts.map +1 -1
  27. package/dist/local-plugin-only.js +163 -43
  28. package/dist/local-plugin-only.js.map +1 -1
  29. package/f-mcp-plugin/README.md +13 -5
  30. package/f-mcp-plugin/code.js +216 -2
  31. package/f-mcp-plugin/manifest.json +6 -2
  32. package/f-mcp-plugin/ui.html +694 -213
  33. package/package.json +7 -6
@@ -1,9 +1,14 @@
1
1
  /**
2
2
  * Plugin Bridge WebSocket Server
3
3
  *
4
- * Listens for connections from the F-MCP ATezer Bridge plugin (no CDP needed).
5
- * When the plugin connects, MCP tools can send JSON-RPC style requests and get
6
- * responses (variables, execute, component, etc.).
4
+ * Listens on a FIXED port for connections from the F-MCP ATezer Bridge plugin
5
+ * (no CDP needed). Supports MULTIPLE simultaneous plugin connections
6
+ * (e.g. Figma Desktop + FigJam browser + Figma browser — all on one port).
7
+ * Each connected plugin identifies itself with a fileKey; requests are routed accordingly.
8
+ *
9
+ * Port strategy: no auto-scanning. If the configured port is busy, the server
10
+ * probes it to distinguish a live F-MCP instance from a stale/dead process.
11
+ * Stale ports get one automatic retry after a short delay.
7
12
  */
8
13
  import { WebSocketServer } from "ws";
9
14
  import { createServer, get as httpGet } from "http";
@@ -12,41 +17,84 @@ import { auditTool, auditPlugin } from "./audit-log.js";
12
17
  const HEARTBEAT_INTERVAL_MS = 3000;
13
18
  const MIN_PORT = 5454;
14
19
  const MAX_PORT = 5470;
20
+ const STALE_PORT_RETRY_DELAY_MS = 1500;
15
21
  export class PluginBridgeServer {
16
22
  constructor(port, options) {
17
23
  this.wss = null;
18
24
  this.httpServer = null;
19
- this.client = null;
20
- this.clientAlive = false;
21
- this.missedHeartbeats = 0;
25
+ this.clients = new Map();
22
26
  this.pending = new Map();
23
27
  this.requestTimeoutMs = 120000;
24
28
  this.heartbeatTimer = null;
25
- this.port = port;
29
+ this.clientIdCounter = 0;
30
+ const clamped = Math.max(MIN_PORT, Math.min(MAX_PORT, port));
31
+ this.preferredPort = clamped;
32
+ this.port = clamped;
26
33
  this.auditLogPath = options?.auditLogPath;
27
34
  }
28
- /**
29
- * Start the WebSocket server on the configured port. Fails loudly if port is in use. Idempotent.
30
- */
31
35
  start() {
32
36
  if (this.wss) {
33
37
  logger.debug({ port: this.port }, "Plugin bridge server already running");
34
38
  return;
35
39
  }
36
- this.tryListen(this.port);
40
+ this.tryListenFixed(this.preferredPort, false);
41
+ }
42
+ generateClientId() {
43
+ return `client_${Date.now()}_${++this.clientIdCounter}`;
44
+ }
45
+ findClientByFileKey(fileKey) {
46
+ for (const client of this.clients.values()) {
47
+ if (client.fileKey === fileKey && client.ws.readyState === 1) {
48
+ return client;
49
+ }
50
+ }
51
+ return undefined;
52
+ }
53
+ getDefaultClient() {
54
+ let latest;
55
+ for (const client of this.clients.values()) {
56
+ if (client.ws.readyState !== 1)
57
+ continue;
58
+ if (!latest || client.connectedAt > latest.connectedAt) {
59
+ latest = client;
60
+ }
61
+ }
62
+ return latest;
63
+ }
64
+ resolveClient(fileKey) {
65
+ if (fileKey) {
66
+ return this.findClientByFileKey(fileKey) ?? this.getDefaultClient();
67
+ }
68
+ return this.getDefaultClient();
37
69
  }
38
- checkPortConflict(port) {
70
+ removeClient(clientId, reason) {
71
+ const info = this.clients.get(clientId);
72
+ if (!info)
73
+ return;
74
+ this.clients.delete(clientId);
75
+ this.rejectPendingForClient(clientId, reason);
76
+ auditPlugin(this.auditLogPath, "plugin_disconnect");
77
+ logger.info({ clientId, fileKey: info.fileKey, fileName: info.fileName }, "Plugin bridge: client disconnected (%s)", reason);
78
+ }
79
+ /**
80
+ * Probe a port via HTTP to determine if a live F-MCP bridge is already
81
+ * running or if the port is held by a stale/dead process.
82
+ * Returns "fmcp" | "other" | "dead".
83
+ */
84
+ probePort(port, host) {
39
85
  return new Promise((resolve) => {
40
- const req = httpGet(`http://127.0.0.1:${port}`, (res) => {
86
+ const req = httpGet({ hostname: host, port, path: "/", timeout: 2000 }, (res) => {
41
87
  let body = "";
42
- res.on("data", (c) => { body += c.toString(); });
43
- res.on("end", () => resolve(body));
88
+ res.on("data", (chunk) => { body += chunk; });
89
+ res.on("end", () => {
90
+ resolve(body.includes("F-MCP") ? "fmcp" : "other");
91
+ });
44
92
  });
45
- req.on("error", () => resolve(null));
46
- req.setTimeout(2000, () => { req.destroy(); resolve(null); });
93
+ req.on("error", () => resolve("dead"));
94
+ req.on("timeout", () => { req.destroy(); resolve("dead"); });
47
95
  });
48
96
  }
49
- tryListen(port) {
97
+ tryListenFixed(port, isRetry) {
50
98
  const server = createServer((_req, res) => {
51
99
  res.writeHead(200, {
52
100
  "Content-Type": "text/plain",
@@ -55,53 +103,90 @@ export class PluginBridgeServer {
55
103
  });
56
104
  res.end("F-MCP ATezer Bridge (connect via WebSocket)\n");
57
105
  });
106
+ const bindHost = process.env.FIGMA_BRIDGE_HOST || "127.0.0.1";
58
107
  server.on("error", (err) => {
59
108
  if (err.code === "EADDRINUSE") {
60
- this.checkPortConflict(port).then((body) => {
61
- const isFmcp = body !== null && body.includes("F-MCP");
62
- const hint = process.platform === "win32"
63
- ? `netstat -ano | findstr :${port}`
64
- : `lsof -i :${port}`;
65
- if (isFmcp) {
66
- console.error(`\n❌ Port ${port} is already used by another F-MCP bridge instance.\n` +
67
- ` Find it: ${hint}\n` +
68
- ` Kill it and retry, or set FIGMA_PLUGIN_BRIDGE_PORT to a different port.\n` +
69
- ` ⚠️ Cursor/Claude starts the bridge automatically do NOT also run 'npm run dev:local'.\n`);
109
+ server.close();
110
+ if (isRetry) {
111
+ console.error(`\n❌ Port ${port} is still busy after retry.\n` +
112
+ ` A process may be holding this port. Find and stop it:\n` +
113
+ ` lsof -i :${port} (macOS/Linux)\n` +
114
+ ` Then restart, or set FIGMA_PLUGIN_BRIDGE_PORT to a different value (${MIN_PORT}–${MAX_PORT}).\n`);
115
+ process.exit(1);
116
+ return;
117
+ }
118
+ const probeHost = bindHost === "0.0.0.0" ? "127.0.0.1" : bindHost;
119
+ this.probePort(port, probeHost).then((status) => {
120
+ if (status === "fmcp") {
121
+ console.error(`\n⚠️ Port ${port} is already in use by another F-MCP bridge instance.\n` +
122
+ ` One bridge is enough for all Figma/FigJam windows.\n` +
123
+ ` If you need a separate session, set FIGMA_PLUGIN_BRIDGE_PORT to a different value (${MIN_PORT}–${MAX_PORT}).\n`);
124
+ process.exit(1);
125
+ }
126
+ else if (status === "dead") {
127
+ console.error(`\n⚠️ Port ${port} is busy but not responding (stale process).\n` +
128
+ ` Retrying in ${STALE_PORT_RETRY_DELAY_MS}ms…\n`);
129
+ setTimeout(() => this.tryListenFixed(port, true), STALE_PORT_RETRY_DELAY_MS);
70
130
  }
71
131
  else {
72
- console.error(`\n❌ Port ${port} is already in use by another application.\n` +
73
- ` Find it: ${hint}\n` +
74
- ` Free the port and retry, or set FIGMA_PLUGIN_BRIDGE_PORT to a different port.\n`);
132
+ console.error(`\n❌ Port ${port} is in use by a different service (not F-MCP).\n` +
133
+ ` Free the port or set FIGMA_PLUGIN_BRIDGE_PORT to a different value (${MIN_PORT}–${MAX_PORT}).\n`);
134
+ process.exit(1);
75
135
  }
76
- process.exit(1);
77
136
  });
78
- server.close();
79
137
  return;
80
138
  }
81
139
  logger.error({ err }, "Plugin bridge server error");
82
140
  });
83
- const bindHost = process.env.FIGMA_BRIDGE_HOST || "127.0.0.1";
84
141
  server.listen(port, bindHost, () => {
85
142
  this.port = port;
143
+ process.env.FIGMA_PLUGIN_BRIDGE_PORT = String(port);
144
+ process.env.FIGMA_MCP_BRIDGE_PORT = String(port);
145
+ console.error(`F-MCP bridge listening on ws://${bindHost}:${port}\n`);
86
146
  this.httpServer = server;
87
147
  this.wss = new WebSocketServer({ server });
88
148
  this.wss.on("connection", (ws) => {
89
- if (this.client && this.client !== ws) {
90
- this.rejectAllPending("Replaced by new plugin connection");
91
- logger.info("Plugin bridge: new connection arrived, switching to it");
92
- }
93
- this.client = ws;
94
- this.clientAlive = true;
95
- this.missedHeartbeats = 0;
96
- logger.info({ port: this.port }, "Plugin bridge: plugin connected");
149
+ const clientId = this.generateClientId();
150
+ const clientInfo = {
151
+ ws,
152
+ clientId,
153
+ fileKey: null,
154
+ fileName: null,
155
+ alive: true,
156
+ missedHeartbeats: 0,
157
+ connectedAt: Date.now(),
158
+ };
159
+ this.clients.set(clientId, clientInfo);
160
+ logger.info({ port: this.port, clientId, totalClients: this.clients.size }, "Plugin bridge: new plugin connected");
97
161
  auditPlugin(this.auditLogPath, "plugin_connect");
98
162
  ws.on("message", (data) => {
99
- this.clientAlive = true;
163
+ clientInfo.alive = true;
100
164
  try {
101
165
  const msg = JSON.parse(data.toString());
102
166
  if (msg.type === "ready") {
103
- logger.info("Plugin bridge: plugin sent ready, sending welcome");
104
- ws.send(JSON.stringify({ type: "welcome", bridgeVersion: "1.0.0", port: this.port }));
167
+ const incomingFileKey = msg.fileKey || null;
168
+ const incomingFileName = msg.fileName || null;
169
+ if (incomingFileKey) {
170
+ const existing = this.findClientByFileKey(incomingFileKey);
171
+ if (existing && existing.clientId !== clientId) {
172
+ logger.info({ oldClientId: existing.clientId, newClientId: clientId, fileKey: incomingFileKey }, "Plugin bridge: replacing existing client for same fileKey");
173
+ this.removeClient(existing.clientId, "Replaced by new connection for same file");
174
+ try {
175
+ existing.ws.close();
176
+ }
177
+ catch { /* ignore */ }
178
+ }
179
+ }
180
+ clientInfo.fileKey = incomingFileKey;
181
+ clientInfo.fileName = incomingFileName;
182
+ logger.info({ clientId, fileKey: incomingFileKey, fileName: incomingFileName }, "Plugin bridge: client registered (fileKey=%s, fileName=%s)", incomingFileKey, incomingFileName);
183
+ ws.send(JSON.stringify({
184
+ type: "welcome",
185
+ bridgeVersion: "1.1.0",
186
+ port: this.port,
187
+ clientId,
188
+ multiClient: true,
189
+ }));
105
190
  return;
106
191
  }
107
192
  if (msg.type === "pong" || msg.type === "keepalive") {
@@ -127,42 +212,37 @@ export class PluginBridgeServer {
127
212
  }
128
213
  });
129
214
  ws.on("close", () => {
130
- if (this.client === ws) {
131
- this.client = null;
132
- this.clientAlive = false;
133
- this.clearHeartbeatTimers();
134
- this.rejectAllPending("Plugin disconnected");
135
- auditPlugin(this.auditLogPath, "plugin_disconnect");
136
- logger.info("Plugin bridge: plugin disconnected");
137
- }
215
+ this.removeClient(clientId, "WebSocket closed");
138
216
  });
139
217
  ws.on("error", (err) => {
140
- logger.warn({ err }, "Plugin bridge: client error");
218
+ logger.warn({ err, clientId }, "Plugin bridge: client error");
141
219
  });
142
220
  });
143
- logger.info({ port: this.port, host: bindHost }, "Plugin bridge server listening (ws://%s:%s)", bindHost, this.port);
221
+ logger.info({ port: this.port, host: bindHost }, "Plugin bridge server listening (ws://%s:%s) — multi-client enabled", bindHost, this.port);
144
222
  this.heartbeatTimer = setInterval(() => {
145
- if (this.client && this.client.readyState === 1) {
146
- if (!this.clientAlive) {
147
- this.missedHeartbeats++;
148
- if (this.missedHeartbeats >= 3) {
149
- logger.warn("Plugin bridge: client not responding to heartbeat, terminating connection");
223
+ for (const [clientId, info] of this.clients) {
224
+ if (info.ws.readyState !== 1) {
225
+ this.removeClient(clientId, "WebSocket not open");
226
+ continue;
227
+ }
228
+ if (!info.alive) {
229
+ info.missedHeartbeats++;
230
+ if (info.missedHeartbeats >= 3) {
231
+ logger.warn({ clientId, fileKey: info.fileKey }, "Plugin bridge: client not responding to heartbeat, terminating");
150
232
  try {
151
- this.client.terminate();
233
+ info.ws.terminate();
152
234
  }
153
235
  catch { /* ignore */ }
154
- this.client = null;
155
- this.clientAlive = false;
156
- this.missedHeartbeats = 0;
157
- return;
236
+ this.removeClient(clientId, "Heartbeat timeout");
237
+ continue;
158
238
  }
159
239
  }
160
240
  else {
161
- this.missedHeartbeats = 0;
162
- this.clientAlive = false;
241
+ info.missedHeartbeats = 0;
242
+ info.alive = false;
163
243
  }
164
244
  try {
165
- this.client.send(JSON.stringify({ type: "ping" }));
245
+ info.ws.send(JSON.stringify({ type: "ping" }));
166
246
  }
167
247
  catch { /* ignore */ }
168
248
  }
@@ -170,10 +250,21 @@ export class PluginBridgeServer {
170
250
  });
171
251
  }
172
252
  /**
173
- * Send a request to the plugin and wait for the response.
253
+ * Send a request to a plugin and wait for the response.
254
+ * If fileKey is specified, routes to the client serving that file.
255
+ * Otherwise routes to the most recently connected client.
174
256
  */
175
- async request(method, params) {
176
- if (!this.client || this.client.readyState !== 1) {
257
+ async request(method, params, fileKey) {
258
+ const client = this.resolveClient(fileKey);
259
+ if (!client || client.ws.readyState !== 1) {
260
+ if (fileKey) {
261
+ const available = this.listConnectedFiles();
262
+ const fileList = available.length > 0
263
+ ? ` Connected files: ${available.map(f => `${f.fileName || "?"} (${f.fileKey || "?"})`).join(", ")}`
264
+ : "";
265
+ throw new Error(`No plugin connected for fileKey "${fileKey}".${fileList} ` +
266
+ "Open the target file in Figma and run the F-MCP ATezer Bridge plugin.");
267
+ }
177
268
  throw new Error("F-MCP ATezer Bridge plugin not connected. Open Figma, run the F-MCP ATezer Bridge plugin, and ensure it shows 'Bridge active' (no debug port needed).");
178
269
  }
179
270
  const id = `req_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
@@ -192,9 +283,10 @@ export class PluginBridgeServer {
192
283
  timeout,
193
284
  method,
194
285
  startTime,
286
+ clientId: client.clientId,
195
287
  });
196
288
  try {
197
- this.client.send(JSON.stringify(req));
289
+ client.ws.send(JSON.stringify(req));
198
290
  }
199
291
  catch (err) {
200
292
  this.pending.delete(id);
@@ -204,11 +296,49 @@ export class PluginBridgeServer {
204
296
  }
205
297
  });
206
298
  }
207
- isConnected() {
208
- return !!this.client && this.client.readyState === 1;
299
+ isConnected(fileKey) {
300
+ if (fileKey) {
301
+ const client = this.findClientByFileKey(fileKey);
302
+ return !!client && client.ws.readyState === 1;
303
+ }
304
+ for (const client of this.clients.values()) {
305
+ if (client.ws.readyState === 1)
306
+ return true;
307
+ }
308
+ return false;
209
309
  }
210
- clearHeartbeatTimers() {
211
- // placeholder for future timers
310
+ listConnectedFiles() {
311
+ const result = [];
312
+ for (const client of this.clients.values()) {
313
+ if (client.ws.readyState === 1) {
314
+ result.push({
315
+ clientId: client.clientId,
316
+ fileKey: client.fileKey,
317
+ fileName: client.fileName,
318
+ connectedAt: client.connectedAt,
319
+ });
320
+ }
321
+ }
322
+ return result.sort((a, b) => b.connectedAt - a.connectedAt);
323
+ }
324
+ connectedClientCount() {
325
+ let count = 0;
326
+ for (const client of this.clients.values()) {
327
+ if (client.ws.readyState === 1)
328
+ count++;
329
+ }
330
+ return count;
331
+ }
332
+ rejectPendingForClient(clientId, reason) {
333
+ for (const [id, p] of this.pending) {
334
+ if (p.clientId === clientId) {
335
+ clearTimeout(p.timeout);
336
+ const durationMs = Date.now() - p.startTime;
337
+ auditTool(this.auditLogPath, p.method, false, reason, durationMs);
338
+ p.reject(new Error(`Plugin bridge request '${p.method}' failed: ${reason}`));
339
+ this.pending.delete(id);
340
+ }
341
+ }
212
342
  }
213
343
  rejectAllPending(reason) {
214
344
  for (const [id, p] of this.pending) {
@@ -224,20 +354,14 @@ export class PluginBridgeServer {
224
354
  clearInterval(this.heartbeatTimer);
225
355
  this.heartbeatTimer = null;
226
356
  }
227
- for (const p of this.pending.values()) {
228
- clearTimeout(p.timeout);
229
- p.reject(new Error("Plugin bridge server stopped"));
230
- }
231
- this.pending.clear();
232
- if (this.client) {
357
+ this.rejectAllPending("Plugin bridge server stopped");
358
+ for (const client of this.clients.values()) {
233
359
  try {
234
- this.client.close();
235
- }
236
- catch {
237
- // ignore
360
+ client.ws.close();
238
361
  }
239
- this.client = null;
362
+ catch { /* ignore */ }
240
363
  }
364
+ this.clients.clear();
241
365
  if (this.wss) {
242
366
  this.wss.close();
243
367
  this.wss = null;