@mp3wizard/figma-console-mcp 1.15.2 → 1.15.4

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 CHANGED
@@ -758,28 +758,6 @@ The architecture supports adding new apps with minimal boilerplate — each app
758
758
 
759
759
  ---
760
760
 
761
- ## šŸ”’ Security
762
-
763
- This repository has undergone **two security reviews** — most recently on 2026-03-18 (v1.14.0).
764
-
765
- ### What Was Reviewed
766
-
767
- - Authentication & token handling
768
- - Input validation and sanitization
769
- - Rate limiting and abuse prevention
770
- - Error message exposure
771
- - Dependency vulnerabilities
772
- - Secrets management
773
-
774
- ### Review Outcome
775
-
776
- All identified security findings have been addressed and patched in this fork. No sensitive credentials are exposed, and error messages have been sanitized to prevent information leakage.
777
-
778
- šŸ“„ **Latest report:** [SECURITY-REVIEW-2026-03-18.md](Security%20review%20report/SECURITY-REVIEW-2026-03-18.md)
779
- šŸ“„ **Previous report:** [SECURITY-REVIEW-2026-03-16.md](Security%20review%20report/SECURITY-REVIEW-2026-03-16.md)
780
-
781
- ---
782
-
783
761
  ## šŸ’» Development
784
762
 
785
763
  ```bash
@@ -164,7 +164,6 @@ export class PluginRelayDO extends DurableObject {
164
164
  }
165
165
  const body = await request.json();
166
166
  const { method, params = {}, timeoutMs = 15000 } = body;
167
- const safeTimeout = Math.min(Math.max(timeoutMs, 1000), 60000); // clamp 1s–60s
168
167
  const id = `relay_${++this.requestIdCounter}_${Date.now()}`;
169
168
  // Send command to plugin
170
169
  try {
@@ -178,9 +177,9 @@ export class PluginRelayDO extends DurableObject {
178
177
  const timeoutId = setTimeout(() => {
179
178
  if (this.pendingRequests.has(id)) {
180
179
  this.pendingRequests.delete(id);
181
- resolve(new Response(JSON.stringify({ error: `Command ${method} timed out after ${safeTimeout}ms` }), { status: 504, headers: { 'Content-Type': 'application/json' } }));
180
+ resolve(new Response(JSON.stringify({ error: `Command ${method} timed out after ${timeoutMs}ms` }), { status: 504, headers: { 'Content-Type': 'application/json' } }));
182
181
  }
183
- }, safeTimeout);
182
+ }, timeoutMs);
184
183
  this.pendingRequests.set(id, { resolve, reject: () => { }, timeoutId });
185
184
  });
186
185
  }
@@ -30,7 +30,7 @@ const DEFAULT_CONFIG = {
30
30
  args: [
31
31
  '--disable-blink-features=AutomationControlled',
32
32
  '--disable-dev-shm-usage',
33
- // '--no-sandbox' removed from defaults; enable via config file if required by your environment
33
+ '--no-sandbox', // Note: Only use in trusted environments
34
34
  ],
35
35
  },
36
36
  console: {
@@ -94,12 +94,16 @@ export class FigmaAPI {
94
94
  // OAuth tokens start with 'figu_' and require Authorization: Bearer header
95
95
  // Personal Access Tokens use X-Figma-Token header
96
96
  const isOAuthToken = this.accessToken.startsWith('figu_');
97
- logger.debug({
97
+ // Debug logging to verify token is being used
98
+ const tokenPreview = this.accessToken ? `${this.accessToken.substring(0, 10)}...` : 'NO TOKEN';
99
+ logger.info({
98
100
  url,
101
+ tokenPreview,
99
102
  hasToken: !!this.accessToken,
103
+ tokenLength: this.accessToken?.length,
100
104
  isOAuthToken,
101
105
  authMethod: isOAuthToken ? 'Bearer' : 'X-Figma-Token'
102
- }, 'Making Figma API request');
106
+ }, 'Making Figma API request with token');
103
107
  const headers = {
104
108
  'Content-Type': 'application/json',
105
109
  ...(options.headers || {}),
@@ -429,8 +429,7 @@ export class FigmaDesktopConnector {
429
429
  } catch (error) {
430
430
  return {
431
431
  success: false,
432
- error: error.message,
433
- stack: error.stack
432
+ error: error.message
434
433
  };
435
434
  }
436
435
  })()
@@ -80,7 +80,7 @@ export function advertisePort(port, host = 'localhost') {
80
80
  };
81
81
  const filePath = getPortFilePath(port);
82
82
  try {
83
- writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 0o600 });
83
+ writeFileSync(filePath, JSON.stringify(data, null, 2));
84
84
  logger.info({ port, filePath }, 'Port advertised');
85
85
  }
86
86
  catch (error) {
@@ -103,7 +103,7 @@ export function refreshPortAdvertisement(port) {
103
103
  if (data.pid !== process.pid)
104
104
  return;
105
105
  data.lastSeen = new Date().toISOString();
106
- writeFileSync(filePath, JSON.stringify(data, null, 2), { mode: 0o600 });
106
+ writeFileSync(filePath, JSON.stringify(data, null, 2));
107
107
  }
108
108
  catch {
109
109
  // Best-effort — heartbeat failures are non-fatal
@@ -265,6 +265,77 @@ export function cleanupStalePortFiles() {
265
265
  }
266
266
  return cleaned;
267
267
  }
268
+ /**
269
+ * Deep scan for orphaned MCP server processes that hold ports but have no port files.
270
+ * These are processes left behind by Claude Desktop when tabs close without proper cleanup.
271
+ *
272
+ * Uses lsof (macOS/Linux) to find PIDs listening on each port in the range,
273
+ * then verifies they're figma-console-mcp before terminating.
274
+ *
275
+ * Call AFTER cleanupStalePortFiles() — that handles the port-file-based cleanup first,
276
+ * then this catches any remaining ghosts.
277
+ */
278
+ export function cleanupOrphanedProcesses(preferredPort = DEFAULT_WS_PORT) {
279
+ // Only supported on macOS/Linux (lsof)
280
+ if (process.platform === 'win32')
281
+ return 0;
282
+ let cleaned = 0;
283
+ const myPid = process.pid;
284
+ const ports = getPortRange(preferredPort);
285
+ // Collect PIDs that have valid port files (known-good servers)
286
+ const knownPids = new Set();
287
+ for (const port of ports) {
288
+ const data = readPortFile(port);
289
+ if (data)
290
+ knownPids.add(data.pid);
291
+ }
292
+ knownPids.add(myPid); // Never kill ourselves
293
+ for (const port of ports) {
294
+ try {
295
+ // Find PIDs listening on this port via lsof
296
+ const { execSync } = require('child_process');
297
+ const output = execSync(`lsof -i :${port} -sTCP:LISTEN -t 2>/dev/null`, {
298
+ encoding: 'utf-8',
299
+ timeout: 3000,
300
+ }).trim();
301
+ if (!output)
302
+ continue;
303
+ const pids = output.split('\n').map(Number).filter(Boolean);
304
+ for (const pid of pids) {
305
+ if (knownPids.has(pid))
306
+ continue; // Skip known-good servers
307
+ // Verify this is actually a figma-console-mcp process before killing
308
+ try {
309
+ const cmdline = execSync(`ps -p ${pid} -o command= 2>/dev/null`, {
310
+ encoding: 'utf-8',
311
+ timeout: 2000,
312
+ }).trim();
313
+ if (cmdline.includes('figma-console-mcp') || cmdline.includes('figma_console_mcp') || cmdline.includes('local.js')) {
314
+ logger.info({ port, pid, command: cmdline.substring(0, 120) }, 'Terminating orphaned MCP server (no port file, holding port)');
315
+ terminateProcess(pid);
316
+ cleaned++;
317
+ }
318
+ }
319
+ catch {
320
+ // Can't read process info — skip to be safe
321
+ }
322
+ }
323
+ }
324
+ catch {
325
+ // lsof failed for this port — skip
326
+ }
327
+ }
328
+ if (cleaned > 0) {
329
+ // Give terminated processes a moment to release their ports
330
+ try {
331
+ const { execSync } = require('child_process');
332
+ execSync('sleep 0.5', { timeout: 2000 });
333
+ }
334
+ catch { /* non-critical */ }
335
+ logger.info({ cleaned }, `Cleaned up ${cleaned} orphaned MCP server process(es)`);
336
+ }
337
+ return cleaned;
338
+ }
268
339
  /**
269
340
  * Register process exit handlers to clean up port advertisement file.
270
341
  * Should be called once after the port is successfully bound.
@@ -14,7 +14,8 @@
14
14
  */
15
15
  import { WebSocketServer as WSServer, WebSocket } from 'ws';
16
16
  import { EventEmitter } from 'events';
17
- import { readFileSync } from 'fs';
17
+ import { createServer as createHttpServer } from 'http';
18
+ import { readFileSync, existsSync } from 'fs';
18
19
  import { join } from 'path';
19
20
  import { createChildLogger } from './logger.js';
20
21
  // Read version from package.json
@@ -27,11 +28,37 @@ try {
27
28
  catch {
28
29
  // Non-critical — version will show as 0.0.0
29
30
  }
31
+ /**
32
+ * Load the full plugin UI HTML content that gets served to the bootloader.
33
+ * Falls back to a minimal error page if the file isn't found.
34
+ */
35
+ function loadPluginUIContent() {
36
+ const candidates = [
37
+ // ESM runtime: dist/core/ → ../../figma-desktop-bridge/
38
+ typeof __dirname !== 'undefined'
39
+ ? join(__dirname, '..', '..', 'figma-desktop-bridge', 'ui-full.html')
40
+ : join(process.cwd(), 'figma-desktop-bridge', 'ui-full.html'),
41
+ // Direct from project root
42
+ join(process.cwd(), 'figma-desktop-bridge', 'ui-full.html'),
43
+ ];
44
+ for (const path of candidates) {
45
+ try {
46
+ if (existsSync(path)) {
47
+ return readFileSync(path, 'utf-8');
48
+ }
49
+ }
50
+ catch {
51
+ // try next candidate
52
+ }
53
+ }
54
+ return '<html><body><p>Plugin UI not found. Please reinstall figma-console-mcp.</p></body></html>';
55
+ }
30
56
  const logger = createChildLogger({ component: 'websocket-server' });
31
57
  export class FigmaWebSocketServer extends EventEmitter {
32
58
  constructor(options) {
33
59
  super();
34
60
  this.wss = null;
61
+ this.httpServer = null;
35
62
  /** Named clients indexed by fileKey — each represents a connected Figma file */
36
63
  this.clients = new Map();
37
64
  /** Clients awaiting FILE_INFO identification, mapped to their pending timeout */
@@ -44,21 +71,75 @@ export class FigmaWebSocketServer extends EventEmitter {
44
71
  this._startedAt = Date.now();
45
72
  this.consoleBufferSize = 1000;
46
73
  this.documentChangeBufferSize = 200;
74
+ /** Cached plugin UI HTML content — loaded once and served to bootloader requests */
75
+ this._pluginUIContent = null;
47
76
  this.options = options;
48
77
  this._startedAt = Date.now();
49
78
  }
50
79
  /**
51
- * Start the WebSocket server
80
+ * Handle HTTP requests on the same port as WebSocket.
81
+ * Serves plugin UI content for the bootloader and health checks.
82
+ */
83
+ handleHttpRequest(req, res) {
84
+ // CORS headers for Figma plugin iframe (sandboxed, origin: null)
85
+ res.setHeader('Access-Control-Allow-Origin', '*');
86
+ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
87
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
88
+ if (req.method === 'OPTIONS') {
89
+ res.writeHead(204);
90
+ res.end();
91
+ return;
92
+ }
93
+ const url = req.url || '/';
94
+ // Plugin UI endpoint — bootloader redirects here
95
+ if (url === '/plugin/ui' || url === '/plugin/ui/') {
96
+ if (!this._pluginUIContent) {
97
+ this._pluginUIContent = loadPluginUIContent();
98
+ }
99
+ res.writeHead(200, {
100
+ 'Content-Type': 'text/html; charset=utf-8',
101
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
102
+ });
103
+ res.end(this._pluginUIContent);
104
+ return;
105
+ }
106
+ // Health/version endpoint
107
+ if (url === '/health' || url === '/') {
108
+ res.writeHead(200, { 'Content-Type': 'application/json' });
109
+ res.end(JSON.stringify({
110
+ status: 'ok',
111
+ version: SERVER_VERSION,
112
+ clients: this.clients.size,
113
+ uptime: Math.floor((Date.now() - this._startedAt) / 1000),
114
+ }));
115
+ return;
116
+ }
117
+ // 404 for anything else
118
+ res.writeHead(404, { 'Content-Type': 'text/plain' });
119
+ res.end('Not Found');
120
+ }
121
+ /**
122
+ * Start the HTTP + WebSocket server.
123
+ * HTTP serves the plugin UI content; WebSocket handles plugin communication.
52
124
  */
53
125
  async start() {
54
126
  if (this._isStarted)
55
127
  return;
56
128
  return new Promise((resolve, reject) => {
129
+ let rejected = false;
130
+ const rejectOnce = (error) => {
131
+ if (!rejected) {
132
+ rejected = true;
133
+ reject(error);
134
+ }
135
+ };
57
136
  try {
137
+ // Create HTTP server first — handles plugin UI requests
138
+ this.httpServer = createHttpServer((req, res) => this.handleHttpRequest(req, res));
139
+ // Attach WebSocket server to the HTTP server (shares the same port)
58
140
  this.wss = new WSServer({
59
- port: this.options.port,
60
- host: this.options.host || 'localhost',
61
- maxPayload: 25 * 1024 * 1024, // 25MB — sufficient for screenshots; set FIGMA_WS_MAX_PAYLOAD_MB env var to override
141
+ server: this.httpServer,
142
+ maxPayload: 100 * 1024 * 1024, // 100MB — screenshots and large component data can be big
62
143
  verifyClient: (info, callback) => {
63
144
  // Mitigate Cross-Site WebSocket Hijacking (CSWSH):
64
145
  // Reject connections from unexpected browser origins.
@@ -76,18 +157,38 @@ export class FigmaWebSocketServer extends EventEmitter {
76
157
  }
77
158
  },
78
159
  });
79
- this.wss.on('listening', () => {
80
- this._isStarted = true;
81
- logger.info({ port: this.options.port, host: this.options.host || 'localhost' }, 'WebSocket bridge server started');
82
- resolve();
83
- });
84
- this.wss.on('error', (error) => {
160
+ // Error handler for startup failures (EADDRINUSE, etc.)
161
+ // Must be on BOTH httpServer and wss — the WSS re-emits HTTP server errors
162
+ // and throws if no listener is attached.
163
+ const onStartupError = (error) => {
85
164
  if (!this._isStarted) {
86
- reject(error);
165
+ try {
166
+ if (this.wss) {
167
+ this.wss.close();
168
+ this.wss = null;
169
+ }
170
+ }
171
+ catch { /* ignore */ }
172
+ try {
173
+ if (this.httpServer) {
174
+ this.httpServer.close();
175
+ this.httpServer = null;
176
+ }
177
+ }
178
+ catch { /* ignore */ }
179
+ rejectOnce(error);
87
180
  }
88
181
  else {
89
- logger.error({ error }, 'WebSocket server error');
182
+ logger.error({ error }, 'HTTP/WebSocket server error');
90
183
  }
184
+ };
185
+ this.httpServer.on('error', onStartupError);
186
+ this.wss.on('error', onStartupError);
187
+ // Start listening on the HTTP server (which also handles WS upgrades)
188
+ this.httpServer.listen(this.options.port, this.options.host || 'localhost', () => {
189
+ this._isStarted = true;
190
+ logger.info({ port: this.options.port, host: this.options.host || 'localhost' }, 'WebSocket bridge server started (with HTTP plugin UI endpoint)');
191
+ resolve();
91
192
  });
92
193
  this.wss.on('connection', (ws) => {
93
194
  // Add to pending until FILE_INFO identifies the file
@@ -146,7 +247,7 @@ export class FigmaWebSocketServer extends EventEmitter {
146
247
  });
147
248
  }
148
249
  catch (error) {
149
- reject(error);
250
+ rejectOnce(error);
150
251
  }
151
252
  });
152
253
  }
@@ -177,6 +278,22 @@ export class FigmaWebSocketServer extends EventEmitter {
177
278
  }
178
279
  return;
179
280
  }
281
+ // Bootloader request: send the full plugin UI HTML
282
+ if (message.type === 'GET_PLUGIN_UI') {
283
+ if (!this._pluginUIContent) {
284
+ this._pluginUIContent = loadPluginUIContent();
285
+ }
286
+ try {
287
+ ws.send(JSON.stringify({
288
+ type: 'PLUGIN_UI_CONTENT',
289
+ html: this._pluginUIContent,
290
+ }));
291
+ }
292
+ catch {
293
+ // Non-critical — bootloader will show error
294
+ }
295
+ return;
296
+ }
180
297
  // Unsolicited data from plugin (FILE_INFO, events, forwarded data)
181
298
  if (message.type) {
182
299
  // FILE_INFO promotes pending clients to named clients
@@ -434,11 +551,19 @@ export class FigmaWebSocketServer extends EventEmitter {
434
551
  * Returns the actual port — critical when using port 0 for OS-assigned ports.
435
552
  */
436
553
  address() {
554
+ // Use the HTTP server's address (which is the actual listening socket)
555
+ if (this.httpServer) {
556
+ const addr = this.httpServer.address();
557
+ if (typeof addr === 'string' || !addr)
558
+ return null;
559
+ return addr;
560
+ }
561
+ // Fallback for backward compat
437
562
  if (!this.wss)
438
563
  return null;
439
564
  const addr = this.wss.address();
440
565
  if (typeof addr === 'string')
441
- return null; // Unix socket path, not applicable
566
+ return null;
442
567
  return addr;
443
568
  }
444
569
  // ============================================================================
@@ -632,15 +757,20 @@ export class FigmaWebSocketServer extends EventEmitter {
632
757
  }
633
758
  this.clients.clear();
634
759
  this._activeFileKey = null;
760
+ // Close WS server first (handles WebSocket connections)
635
761
  if (this.wss) {
636
- return new Promise((resolve) => {
637
- this.wss.close(() => {
638
- this._isStarted = false;
639
- logger.info('WebSocket bridge server stopped');
640
- resolve();
641
- });
762
+ await new Promise((resolve) => {
763
+ this.wss.close(() => resolve());
764
+ });
765
+ }
766
+ // Then close HTTP server (releases the port)
767
+ if (this.httpServer) {
768
+ await new Promise((resolve) => {
769
+ this.httpServer.close(() => resolve());
642
770
  });
771
+ this.httpServer = null;
643
772
  }
644
773
  this._isStarted = false;
774
+ logger.info('WebSocket bridge server stopped');
645
775
  }
646
776
  }
@@ -17,7 +17,15 @@ export function registerWriteTools(server, getDesktopConnector) {
17
17
 
18
18
  **VALIDATION:** After creating/modifying visuals: screenshot with figma_capture_screenshot, check alignment/spacing/proportions, iterate up to 3x.
19
19
 
20
- **PLACEMENT:** Always create components inside a Section or Frame, never on blank canvas. Use parent.insertChild(0, bg) for z-ordering backgrounds behind content.`, {
20
+ **PLACEMENT:** Always create components inside a Section or Frame, never on blank canvas. Use parent.insertChild(0, bg) for z-ordering backgrounds behind content.
21
+
22
+ **HOUSEKEEPING (MANDATORY):**
23
+ Before creating: screenshot the target page to see existing content and find clear space.
24
+ When creating: place inside a named Section, positioned BELOW or AWAY from existing content. Never overlap.
25
+ After creating: screenshot to verify clean placement and no overlaps.
26
+ On failure/retry: DELETE any partial artifacts (empty frames, orphaned layers, blank pages) before retrying. Use node.remove() to clean up.
27
+ Pages: NEVER create a new page if one with that name already exists — use the existing one. If you created a blank page during a failed attempt, delete it.
28
+ Layers: If your code creates helper frames, placeholder nodes, or intermediate layers that aren't part of the final result, remove them.`, {
21
29
  code: z
22
30
  .string()
23
31
  .describe("JavaScript code to execute. Has access to the 'figma' global object. " +
@@ -1476,7 +1484,7 @@ After instantiating components, use figma_take_screenshot to verify the result l
1476
1484
  }
1477
1485
  });
1478
1486
  // Tool: Create Child Node
1479
- server.tool("figma_create_child", "Create a new child node inside a parent container. Useful for adding shapes, text, or frames to existing structures.", {
1487
+ server.tool("figma_create_child", "Create a new child node inside a parent container. Always place inside an existing Section or Frame — never on a bare page. If no suitable parent exists, create a Section first. Clean up any empty or orphaned nodes if the operation fails.", {
1480
1488
  parentId: z.string().describe("The parent node ID"),
1481
1489
  nodeType: z
1482
1490
  .enum(["RECTANGLE", "ELLIPSE", "FRAME", "TEXT", "LINE"])