figma-console-mcp 1.2.5 β†’ 1.3.0

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
@@ -1,9 +1,11 @@
1
1
  # Figma Console MCP Server
2
2
 
3
3
  [![MCP](https://img.shields.io/badge/MCP-Compatible-blue)](https://modelcontextprotocol.io/)
4
+ [![npm](https://img.shields.io/npm/v/figma-console-mcp)](https://www.npmjs.com/package/figma-console-mcp)
4
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+ [![Documentation](https://img.shields.io/badge/docs-docs.figma--console--mcp.southleft.com-0D9488)](https://docs.figma-console-mcp.southleft.com)
5
7
 
6
- > **Model Context Protocol server** that provides AI assistants with **real-time console access, visual debugging, design system extraction, and design creation** for Figma.
8
+ > **Your design system as an API.** Model Context Protocol server that bridges design and developmentβ€”giving AI assistants complete access to Figma for **extraction**, **creation**, and **debugging**.
7
9
 
8
10
  ## What is this?
9
11
 
@@ -593,7 +595,8 @@ MIT - See [LICENSE](LICENSE) file for details.
593
595
 
594
596
  ## πŸ”— Links
595
597
 
596
- - πŸ“– [Full Documentation](docs/)
598
+ - πŸ“š **[Documentation Site](https://docs.figma-console-mcp.southleft.com)** β€” Complete guides, tutorials, and API reference
599
+ - πŸ“– [Local Docs](docs/) β€” Documentation source files
597
600
  - πŸ› [Report Issues](https://github.com/southleft/figma-console-mcp/issues)
598
601
  - πŸ’¬ [Discussions](https://github.com/southleft/figma-console-mcp/discussions)
599
602
  - 🌐 [Model Context Protocol](https://modelcontextprotocol.io/)
@@ -21,6 +21,62 @@ export function extractFileKey(url) {
21
21
  return null;
22
22
  }
23
23
  }
24
+ /**
25
+ * Extract comprehensive URL info including branch and node IDs
26
+ * Supports both URL formats:
27
+ * - Path-based: /design/{fileKey}/branch/{branchKey}/{fileName}
28
+ * - Query-based: /design/{fileKey}/{fileName}?branch-id={branchId}
29
+ *
30
+ * @example https://www.figma.com/design/abc123/branch/xyz789/My-File?node-id=1-2
31
+ * -> { fileKey: 'abc123', branchId: 'xyz789', nodeId: '1:2' }
32
+ * @example https://www.figma.com/design/abc123/My-File?branch-id=xyz789&node-id=1-2
33
+ * -> { fileKey: 'abc123', branchId: 'xyz789', nodeId: '1:2' }
34
+ */
35
+ export function extractFigmaUrlInfo(url) {
36
+ try {
37
+ const urlObj = new URL(url);
38
+ // First try: Path-based branch format /design/{fileKey}/branch/{branchKey}/{fileName}
39
+ const branchPathMatch = urlObj.pathname.match(/\/(design|file)\/([a-zA-Z0-9]+)\/branch\/([a-zA-Z0-9]+)/);
40
+ if (branchPathMatch) {
41
+ const fileKey = branchPathMatch[2];
42
+ const branchId = branchPathMatch[3];
43
+ const nodeIdParam = urlObj.searchParams.get('node-id');
44
+ const nodeId = nodeIdParam ? nodeIdParam.replace(/-/g, ':') : undefined;
45
+ return { fileKey, branchId, nodeId };
46
+ }
47
+ // Second try: Standard format /design/{fileKey}/{fileName} with optional ?branch-id=
48
+ const standardMatch = urlObj.pathname.match(/\/(design|file)\/([a-zA-Z0-9]+)/);
49
+ if (!standardMatch)
50
+ return null;
51
+ const fileKey = standardMatch[2];
52
+ const branchId = urlObj.searchParams.get('branch-id') || undefined;
53
+ const nodeIdParam = urlObj.searchParams.get('node-id');
54
+ // Convert node-id from URL format (1-2) to Figma format (1:2)
55
+ const nodeId = nodeIdParam ? nodeIdParam.replace(/-/g, ':') : undefined;
56
+ return { fileKey, branchId, nodeId };
57
+ }
58
+ catch (error) {
59
+ logger.error({ error, url }, 'Failed to extract Figma URL info');
60
+ return null;
61
+ }
62
+ }
63
+ /**
64
+ * Wrap a promise with a timeout
65
+ * @param promise The promise to wrap
66
+ * @param ms Timeout in milliseconds
67
+ * @param label Label for error message
68
+ * @returns Promise that rejects if timeout exceeded
69
+ */
70
+ export function withTimeout(promise, ms, label) {
71
+ const timeoutPromise = new Promise((_, reject) => {
72
+ const timeoutId = setTimeout(() => {
73
+ reject(new Error(`${label} timed out after ${ms}ms`));
74
+ }, ms);
75
+ // Ensure timeout is cleared if promise resolves first
76
+ promise.finally(() => clearTimeout(timeoutId));
77
+ });
78
+ return Promise.race([promise, timeoutPromise]);
79
+ }
24
80
  /**
25
81
  * Figma API Client
26
82
  * Makes authenticated requests to Figma REST API
@@ -95,6 +151,43 @@ export class FigmaAPI {
95
151
  }
96
152
  return this.request(endpoint);
97
153
  }
154
+ /**
155
+ * Resolve a branch key from a branch ID
156
+ * If branchId is provided, fetches branch data and returns the branch's unique key
157
+ * Otherwise returns the main file key unchanged
158
+ * @param fileKey The main file key from the URL
159
+ * @param branchId Optional branch ID from URL query param (branch-id)
160
+ * @returns The effective file key to use for API calls (branch key if on branch, otherwise fileKey)
161
+ */
162
+ async getBranchKey(fileKey, branchId) {
163
+ if (!branchId) {
164
+ return fileKey;
165
+ }
166
+ try {
167
+ logger.info({ fileKey, branchId }, 'Resolving branch key');
168
+ const fileData = await this.getFile(fileKey, { branch_data: true });
169
+ const branches = fileData.branches || [];
170
+ // Try to find branch by key (branchId might already be the key)
171
+ // or by matching other identifiers
172
+ const branch = branches.find((b) => b.key === branchId || b.name === branchId);
173
+ if (branch?.key) {
174
+ logger.info({ fileKey, branchId, branchKey: branch.key, branchName: branch.name }, 'Resolved branch key');
175
+ return branch.key;
176
+ }
177
+ // If branchId looks like a file key (alphanumeric), it might already be the branch key
178
+ // In this case, return it directly as it may be usable
179
+ if (/^[a-zA-Z0-9]+$/.test(branchId)) {
180
+ logger.info({ fileKey, branchId }, 'Branch ID appears to be a key, using directly');
181
+ return branchId;
182
+ }
183
+ logger.warn({ fileKey, branchId, availableBranches: branches.map((b) => ({ key: b.key, name: b.name })) }, 'Branch not found in file, using main file key');
184
+ return fileKey;
185
+ }
186
+ catch (error) {
187
+ logger.error({ error, fileKey, branchId }, 'Failed to resolve branch key, using main file key');
188
+ return fileKey;
189
+ }
190
+ }
98
191
  /**
99
192
  * GET /v1/files/:file_key/variables/local
100
193
  * Get local variables (design tokens) from a file
@@ -101,6 +101,11 @@ export class FigmaDesktopConnector {
101
101
  // Try to find plugin UI iframe with variables data
102
102
  for (const frame of frames) {
103
103
  try {
104
+ // Check if frame is still attached before accessing it
105
+ if (frame.isDetached()) {
106
+ logger.debug('Skipping detached frame');
107
+ continue;
108
+ }
104
109
  const frameUrl = frame.url();
105
110
  await this.page.evaluate((url) => {
106
111
  console.log(`[DESKTOP_CONNECTOR] Checking frame: ${url}`);
@@ -128,10 +133,31 @@ export class FigmaDesktopConnector {
128
133
  }
129
134
  }
130
135
  catch (frameError) {
131
- await this.page.evaluate((url, err) => {
132
- console.log(`[DESKTOP_CONNECTOR] Frame ${url} check failed: ${err}`);
133
- }, frame.url(), frameError instanceof Error ? frameError.message : String(frameError));
134
- logger.debug({ error: frameError, frameUrl: frame.url() }, 'Frame check failed, trying next');
136
+ const errorMsg = frameError instanceof Error ? frameError.message : String(frameError);
137
+ const isDetachedError = errorMsg.includes('detached') || errorMsg.includes('Execution context was destroyed');
138
+ // Safely get frame URL (may fail if frame is detached)
139
+ let safeFrameUrl = 'unknown';
140
+ try {
141
+ safeFrameUrl = frame.url();
142
+ }
143
+ catch {
144
+ safeFrameUrl = '(detached)';
145
+ }
146
+ if (isDetachedError) {
147
+ logger.debug({ frameUrl: safeFrameUrl }, 'Frame was detached during variables check, trying next');
148
+ }
149
+ else {
150
+ // Only log to browser console if we can (page may still be accessible)
151
+ try {
152
+ await this.page.evaluate((url, err) => {
153
+ console.log(`[DESKTOP_CONNECTOR] Frame ${url} check failed: ${err}`);
154
+ }, safeFrameUrl, errorMsg);
155
+ }
156
+ catch {
157
+ // Page also unavailable, just log locally
158
+ }
159
+ logger.debug({ error: frameError, frameUrl: safeFrameUrl }, 'Frame check failed, trying next');
160
+ }
135
161
  continue;
136
162
  }
137
163
  }
@@ -167,6 +193,11 @@ export class FigmaDesktopConnector {
167
193
  // Try to find plugin UI iframe with requestComponentData function
168
194
  for (const frame of frames) {
169
195
  try {
196
+ // Check if frame is still attached before accessing it
197
+ if (frame.isDetached()) {
198
+ logger.debug('Skipping detached frame');
199
+ continue;
200
+ }
170
201
  const frameUrl = frame.url();
171
202
  await this.page.evaluate((url) => {
172
203
  console.log(`[DESKTOP_CONNECTOR] Checking frame: ${url}`);
@@ -196,10 +227,31 @@ export class FigmaDesktopConnector {
196
227
  }
197
228
  }
198
229
  catch (frameError) {
199
- await this.page.evaluate((url, err) => {
200
- console.log(`[DESKTOP_CONNECTOR] Frame ${url} check failed: ${err}`);
201
- }, frame.url(), frameError instanceof Error ? frameError.message : String(frameError));
202
- logger.debug({ error: frameError, frameUrl: frame.url() }, 'Frame check failed, trying next');
230
+ const errorMsg = frameError instanceof Error ? frameError.message : String(frameError);
231
+ const isDetachedError = errorMsg.includes('detached') || errorMsg.includes('Execution context was destroyed');
232
+ // Safely get frame URL (may fail if frame is detached)
233
+ let safeFrameUrl = 'unknown';
234
+ try {
235
+ safeFrameUrl = frame.url();
236
+ }
237
+ catch {
238
+ safeFrameUrl = '(detached)';
239
+ }
240
+ if (isDetachedError) {
241
+ logger.debug({ frameUrl: safeFrameUrl }, 'Frame was detached during component check, trying next');
242
+ }
243
+ else {
244
+ // Only log to browser console if we can (page may still be accessible)
245
+ try {
246
+ await this.page.evaluate((url, err) => {
247
+ console.log(`[DESKTOP_CONNECTOR] Frame ${url} check failed: ${err}`);
248
+ }, safeFrameUrl, errorMsg);
249
+ }
250
+ catch {
251
+ // Page also unavailable, just log locally
252
+ }
253
+ logger.debug({ error: frameError, frameUrl: safeFrameUrl }, 'Frame check failed, trying next');
254
+ }
203
255
  continue;
204
256
  }
205
257
  }
@@ -3,7 +3,7 @@
3
3
  * MCP tool definitions for Figma REST API data extraction
4
4
  */
5
5
  import { z } from "zod";
6
- import { extractFileKey, formatVariables, formatComponentData } from "./figma-api.js";
6
+ import { extractFileKey, extractFigmaUrlInfo, formatVariables, formatComponentData, withTimeout } from "./figma-api.js";
7
7
  import { createChildLogger } from "./logger.js";
8
8
  import { EnrichmentService } from "./enrichment/index.js";
9
9
  import { SnippetInjector } from "./snippet-injector.js";
@@ -886,7 +886,7 @@ export function registerFigmaAPITools(server, getFigmaAPI, getCurrentUrl, getCon
886
886
  "instead of just alias references. Useful for getting color hex values without manual resolution. " +
887
887
  "Default: false."),
888
888
  }, async ({ fileUrl, includePublished, verbosity, enrich, include_usage, include_dependencies, include_exports, export_formats, format, collection, namePattern, mode, returnAsLinks, refreshCache, useConsoleFallback, parseFromConsole, page, pageSize, resolveAliases }) => {
889
- // Extract fileKey outside try block so it's available in catch block
889
+ // Extract fileKey and optional branchId outside try block so they're available in catch block
890
890
  const url = fileUrl || getCurrentUrl();
891
891
  if (!url) {
892
892
  return {
@@ -902,8 +902,9 @@ export function registerFigmaAPITools(server, getFigmaAPI, getCurrentUrl, getCon
902
902
  isError: true,
903
903
  };
904
904
  }
905
- const fileKey = extractFileKey(url);
906
- if (!fileKey) {
905
+ // Use extractFigmaUrlInfo to get fileKey, branchId, and nodeId
906
+ const urlInfo = extractFigmaUrlInfo(url);
907
+ if (!urlInfo) {
907
908
  return {
908
909
  content: [
909
910
  {
@@ -917,6 +918,14 @@ export function registerFigmaAPITools(server, getFigmaAPI, getCurrentUrl, getCon
917
918
  isError: true,
918
919
  };
919
920
  }
921
+ // For branch URLs, the branchId IS the file key to use for API calls
922
+ // Figma branch URLs contain the branch key directly in the path
923
+ const fileKey = urlInfo.branchId || urlInfo.fileKey;
924
+ const mainFileKey = urlInfo.fileKey;
925
+ const branchId = urlInfo.branchId;
926
+ if (branchId) {
927
+ logger.info({ mainFileKey, branchId, effectiveFileKey: fileKey }, 'Branch URL detected, using branch key for API calls');
928
+ }
920
929
  try {
921
930
  // =====================================================================
922
931
  // CACHE-FIRST LOGIC: Check if we have cached data before fetching
@@ -1197,7 +1206,8 @@ export function registerFigmaAPITools(server, getFigmaAPI, getCurrentUrl, getCon
1197
1206
  try {
1198
1207
  logger.info({ fileKey, includePublished, verbosity, enrich }, "Fetching variables via REST API (priority: token detected)");
1199
1208
  const api = await getFigmaAPI();
1200
- const { local, published } = await api.getAllVariables(fileKey);
1209
+ // Wrap API call with timeout to prevent indefinite hangs (30s timeout)
1210
+ const { local, published } = await withTimeout(api.getAllVariables(fileKey), 30000, 'Figma Variables API');
1201
1211
  let localFormatted = formatVariables(local);
1202
1212
  let publishedFormatted = includePublished
1203
1213
  ? formatVariables(published)
@@ -1411,7 +1421,22 @@ export function registerFigmaAPITools(server, getFigmaAPI, getCurrentUrl, getCon
1411
1421
  }
1412
1422
  catch (restError) {
1413
1423
  const errorMessage = restError instanceof Error ? restError.message : String(restError);
1414
- logger.warn({ error: errorMessage }, "REST API failed, will try Desktop Bridge fallback");
1424
+ // Detect specific error types for better logging and handling
1425
+ const isTimeout = errorMessage.includes('timed out');
1426
+ const isRateLimit = errorMessage.includes('429') || errorMessage.toLowerCase().includes('rate limit');
1427
+ const isAuthError = errorMessage.includes('403') || errorMessage.includes('401');
1428
+ if (isTimeout) {
1429
+ logger.warn({ error: errorMessage, fileKey }, "REST API timed out after 30s, falling back to Desktop Bridge");
1430
+ }
1431
+ else if (isRateLimit) {
1432
+ logger.warn({ error: errorMessage, fileKey }, "REST API rate limited (429), falling back to Desktop Bridge");
1433
+ }
1434
+ else if (isAuthError) {
1435
+ logger.warn({ error: errorMessage, fileKey }, "REST API auth error, check FIGMA_ACCESS_TOKEN validity");
1436
+ }
1437
+ else {
1438
+ logger.warn({ error: errorMessage, fileKey }, "REST API failed, will try Desktop Bridge fallback");
1439
+ }
1415
1440
  // Don't throw - fall through to Desktop Bridge
1416
1441
  }
1417
1442
  }