@mp3wizard/figma-console-mcp 1.32.3 → 1.34.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 (137) hide show
  1. package/README.md +25 -17
  2. package/dist/cloudflare/core/cloud-websocket-connector.js +18 -0
  3. package/dist/cloudflare/core/design-system-manifest.js +19 -14
  4. package/dist/cloudflare/core/design-system-tools.js +43 -34
  5. package/dist/cloudflare/core/diagnose-tool.js +4 -0
  6. package/dist/cloudflare/core/enrichment/enrichment-service.js +11 -5
  7. package/dist/cloudflare/core/enrichment/style-resolver.js +38 -18
  8. package/dist/cloudflare/core/figma-api.js +118 -54
  9. package/dist/cloudflare/core/figma-tools.js +179 -63
  10. package/dist/cloudflare/core/port-discovery.js +404 -31
  11. package/dist/cloudflare/core/tokens/alias-resolver.js +75 -5
  12. package/dist/cloudflare/core/tokens/config.js +10 -0
  13. package/dist/cloudflare/core/tokens/dialect.js +232 -0
  14. package/dist/cloudflare/core/tokens/figma-converter.js +144 -16
  15. package/dist/cloudflare/core/tokens/formatters/css-vars.js +21 -12
  16. package/dist/cloudflare/core/tokens/formatters/dtcg.js +106 -30
  17. package/dist/cloudflare/core/tokens/formatters/json.js +28 -10
  18. package/dist/cloudflare/core/tokens/formatters/scss.js +19 -13
  19. package/dist/cloudflare/core/tokens/formatters/style-dictionary-v3.js +15 -9
  20. package/dist/cloudflare/core/tokens/formatters/tailwind-v4.js +14 -9
  21. package/dist/cloudflare/core/tokens/formatters/tokens-studio.js +11 -5
  22. package/dist/cloudflare/core/tokens/index.js +2 -1
  23. package/dist/cloudflare/core/tokens/parsers/dtcg.js +32 -5
  24. package/dist/cloudflare/core/tokens/schemas.js +4 -0
  25. package/dist/cloudflare/core/tokens-tools.js +1017 -88
  26. package/dist/cloudflare/core/version-tools.js +44 -3
  27. package/dist/cloudflare/core/websocket-connector.js +42 -0
  28. package/dist/cloudflare/core/websocket-server.js +99 -8
  29. package/dist/cloudflare/core/write-tools.js +355 -86
  30. package/dist/cloudflare/index.js +7 -7
  31. package/dist/core/design-system-manifest.d.ts +1 -0
  32. package/dist/core/design-system-manifest.d.ts.map +1 -1
  33. package/dist/core/design-system-manifest.js +19 -14
  34. package/dist/core/design-system-manifest.js.map +1 -1
  35. package/dist/core/design-system-tools.d.ts.map +1 -1
  36. package/dist/core/design-system-tools.js +43 -34
  37. package/dist/core/design-system-tools.js.map +1 -1
  38. package/dist/core/diagnose-tool.d.ts +8 -0
  39. package/dist/core/diagnose-tool.d.ts.map +1 -1
  40. package/dist/core/diagnose-tool.js +4 -0
  41. package/dist/core/diagnose-tool.js.map +1 -1
  42. package/dist/core/enrichment/enrichment-service.d.ts.map +1 -1
  43. package/dist/core/enrichment/enrichment-service.js +11 -5
  44. package/dist/core/enrichment/enrichment-service.js.map +1 -1
  45. package/dist/core/enrichment/style-resolver.d.ts +7 -2
  46. package/dist/core/enrichment/style-resolver.d.ts.map +1 -1
  47. package/dist/core/enrichment/style-resolver.js +38 -18
  48. package/dist/core/enrichment/style-resolver.js.map +1 -1
  49. package/dist/core/figma-api.d.ts +18 -9
  50. package/dist/core/figma-api.d.ts.map +1 -1
  51. package/dist/core/figma-api.js +118 -54
  52. package/dist/core/figma-api.js.map +1 -1
  53. package/dist/core/figma-connector.d.ts +12 -0
  54. package/dist/core/figma-connector.d.ts.map +1 -1
  55. package/dist/core/figma-tools.d.ts.map +1 -1
  56. package/dist/core/figma-tools.js +179 -63
  57. package/dist/core/figma-tools.js.map +1 -1
  58. package/dist/core/port-discovery.d.ts +40 -0
  59. package/dist/core/port-discovery.d.ts.map +1 -1
  60. package/dist/core/port-discovery.js +404 -31
  61. package/dist/core/port-discovery.js.map +1 -1
  62. package/dist/core/tokens/alias-resolver.d.ts +45 -3
  63. package/dist/core/tokens/alias-resolver.d.ts.map +1 -1
  64. package/dist/core/tokens/alias-resolver.js +75 -5
  65. package/dist/core/tokens/alias-resolver.js.map +1 -1
  66. package/dist/core/tokens/config.d.ts +28 -0
  67. package/dist/core/tokens/config.d.ts.map +1 -1
  68. package/dist/core/tokens/config.js +10 -0
  69. package/dist/core/tokens/config.js.map +1 -1
  70. package/dist/core/tokens/dialect.d.ts +107 -0
  71. package/dist/core/tokens/dialect.d.ts.map +1 -0
  72. package/dist/core/tokens/dialect.js +233 -0
  73. package/dist/core/tokens/dialect.js.map +1 -0
  74. package/dist/core/tokens/figma-converter.d.ts +23 -2
  75. package/dist/core/tokens/figma-converter.d.ts.map +1 -1
  76. package/dist/core/tokens/figma-converter.js +144 -16
  77. package/dist/core/tokens/figma-converter.js.map +1 -1
  78. package/dist/core/tokens/formatters/css-vars.d.ts.map +1 -1
  79. package/dist/core/tokens/formatters/css-vars.js +21 -12
  80. package/dist/core/tokens/formatters/css-vars.js.map +1 -1
  81. package/dist/core/tokens/formatters/dtcg.d.ts +2 -2
  82. package/dist/core/tokens/formatters/dtcg.d.ts.map +1 -1
  83. package/dist/core/tokens/formatters/dtcg.js +106 -30
  84. package/dist/core/tokens/formatters/dtcg.js.map +1 -1
  85. package/dist/core/tokens/formatters/json.d.ts.map +1 -1
  86. package/dist/core/tokens/formatters/json.js +28 -10
  87. package/dist/core/tokens/formatters/json.js.map +1 -1
  88. package/dist/core/tokens/formatters/scss.d.ts.map +1 -1
  89. package/dist/core/tokens/formatters/scss.js +19 -13
  90. package/dist/core/tokens/formatters/scss.js.map +1 -1
  91. package/dist/core/tokens/formatters/style-dictionary-v3.d.ts.map +1 -1
  92. package/dist/core/tokens/formatters/style-dictionary-v3.js +15 -9
  93. package/dist/core/tokens/formatters/style-dictionary-v3.js.map +1 -1
  94. package/dist/core/tokens/formatters/tailwind-v4.d.ts.map +1 -1
  95. package/dist/core/tokens/formatters/tailwind-v4.js +14 -9
  96. package/dist/core/tokens/formatters/tailwind-v4.js.map +1 -1
  97. package/dist/core/tokens/formatters/tokens-studio.d.ts.map +1 -1
  98. package/dist/core/tokens/formatters/tokens-studio.js +11 -5
  99. package/dist/core/tokens/formatters/tokens-studio.js.map +1 -1
  100. package/dist/core/tokens/index.d.ts +2 -1
  101. package/dist/core/tokens/index.d.ts.map +1 -1
  102. package/dist/core/tokens/index.js +2 -1
  103. package/dist/core/tokens/index.js.map +1 -1
  104. package/dist/core/tokens/parsers/dtcg.js +32 -5
  105. package/dist/core/tokens/parsers/dtcg.js.map +1 -1
  106. package/dist/core/tokens/schemas.d.ts +3 -0
  107. package/dist/core/tokens/schemas.d.ts.map +1 -1
  108. package/dist/core/tokens/schemas.js +4 -0
  109. package/dist/core/tokens/schemas.js.map +1 -1
  110. package/dist/core/tokens/types.d.ts +57 -1
  111. package/dist/core/tokens/types.d.ts.map +1 -1
  112. package/dist/core/tokens/types.js.map +1 -1
  113. package/dist/core/tokens-tools.d.ts +250 -7
  114. package/dist/core/tokens-tools.d.ts.map +1 -1
  115. package/dist/core/tokens-tools.js +1017 -88
  116. package/dist/core/tokens-tools.js.map +1 -1
  117. package/dist/core/version-tools.d.ts.map +1 -1
  118. package/dist/core/version-tools.js +44 -3
  119. package/dist/core/version-tools.js.map +1 -1
  120. package/dist/core/websocket-connector.d.ts +38 -0
  121. package/dist/core/websocket-connector.d.ts.map +1 -1
  122. package/dist/core/websocket-connector.js +42 -0
  123. package/dist/core/websocket-connector.js.map +1 -1
  124. package/dist/core/websocket-server.d.ts +23 -0
  125. package/dist/core/websocket-server.d.ts.map +1 -1
  126. package/dist/core/websocket-server.js +99 -8
  127. package/dist/core/websocket-server.js.map +1 -1
  128. package/dist/core/write-tools.d.ts.map +1 -1
  129. package/dist/core/write-tools.js +355 -86
  130. package/dist/core/write-tools.js.map +1 -1
  131. package/dist/local.d.ts +0 -1
  132. package/dist/local.d.ts.map +1 -1
  133. package/dist/local.js +253 -63
  134. package/dist/local.js.map +1 -1
  135. package/figma-desktop-bridge/code.js +382 -28
  136. package/figma-desktop-bridge/ui.html +578 -292
  137. package/package.json +2 -2
@@ -7,13 +7,23 @@ const logger = createChildLogger({ component: 'figma-api' });
7
7
  const FIGMA_API_BASE = 'https://api.figma.com/v1';
8
8
  /**
9
9
  * Extract file key from Figma URL
10
+ * Branch-aware: for branch URLs (/design/KEY/branch/BRANCHKEY/Name) returns the
11
+ * BRANCH key — the branch is its own file in the REST API, and returning the
12
+ * main key would silently target the wrong file (comments/diffs/file-data).
10
13
  * @example https://www.figma.com/design/abc123/My-File -> abc123
14
+ * @example https://www.figma.com/design/abc123/branch/xyz789/My-File -> xyz789
11
15
  */
12
16
  export function extractFileKey(url) {
13
17
  try {
14
18
  const urlObj = new URL(url);
15
- // Match patterns like /design/FILE_KEY or /file/FILE_KEY
16
- const match = urlObj.pathname.match(/\/(design|file)\/([a-zA-Z0-9]+)/);
19
+ // Branch URLs: /design/FILE_KEY/branch/BRANCH_KEY the branch key is the
20
+ // effective file key for all REST API calls.
21
+ const branchMatch = urlObj.pathname.match(/\/(design|file|board|slides)\/[a-zA-Z0-9]+\/branch\/([a-zA-Z0-9]+)/);
22
+ if (branchMatch) {
23
+ return branchMatch[2];
24
+ }
25
+ // Match patterns like /design/FILE_KEY, /file/FILE_KEY, /board/FILE_KEY (FigJam), /slides/FILE_KEY
26
+ const match = urlObj.pathname.match(/\/(design|file|board|slides)\/([a-zA-Z0-9]+)/);
17
27
  return match ? match[2] : null;
18
28
  }
19
29
  catch (error) {
@@ -21,6 +31,15 @@ export function extractFileKey(url) {
21
31
  return null;
22
32
  }
23
33
  }
34
+ /**
35
+ * Normalize a node ID to Figma's canonical colon form.
36
+ * URLs use dashes ("695-313") but the REST API keys response maps by colon
37
+ * form ("695:313") — indexing a response with a dashed ID silently returns
38
+ * undefined, which reads as "node may not exist".
39
+ */
40
+ export function normalizeNodeId(nodeId) {
41
+ return nodeId.replace(/-/g, ':');
42
+ }
24
43
  /**
25
44
  * Extract comprehensive URL info including branch and node IDs
26
45
  * Supports both URL formats:
@@ -36,7 +55,7 @@ export function extractFigmaUrlInfo(url) {
36
55
  try {
37
56
  const urlObj = new URL(url);
38
57
  // 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]+)/);
58
+ const branchPathMatch = urlObj.pathname.match(/\/(design|file|board|slides)\/([a-zA-Z0-9]+)\/branch\/([a-zA-Z0-9]+)/);
40
59
  if (branchPathMatch) {
41
60
  const fileKey = branchPathMatch[2];
42
61
  const branchId = branchPathMatch[3];
@@ -45,7 +64,7 @@ export function extractFigmaUrlInfo(url) {
45
64
  return { fileKey, branchId, nodeId };
46
65
  }
47
66
  // Second try: Standard format /design/{fileKey}/{fileName} with optional ?branch-id=
48
- const standardMatch = urlObj.pathname.match(/\/(design|file)\/([a-zA-Z0-9]+)/);
67
+ const standardMatch = urlObj.pathname.match(/\/(design|file|board|slides)\/([a-zA-Z0-9]+)/);
49
68
  if (!standardMatch)
50
69
  return null;
51
70
  const fileKey = standardMatch[2];
@@ -109,14 +128,54 @@ export class FigmaAPI {
109
128
  else {
110
129
  headers['X-Figma-Token'] = this.accessToken;
111
130
  }
112
- const response = await fetch(url, {
113
- ...options,
114
- headers,
115
- });
131
+ // Up to 3 total attempts on 429 (rate limit), honoring Retry-After when
132
+ // present (seconds), else exponential backoff (1s, 2s). Delays here run
133
+ // inside the request promise — callers wrapping with withTimeout() still
134
+ // get their race-based rejection if the overall wait exceeds their budget.
135
+ const MAX_ATTEMPTS = 3;
136
+ let response;
137
+ for (let attempt = 1;; attempt++) {
138
+ response = await fetch(url, {
139
+ ...options,
140
+ headers,
141
+ });
142
+ if (response.status !== 429 || attempt >= MAX_ATTEMPTS) {
143
+ break;
144
+ }
145
+ const retryAfterHeader = response.headers.get('retry-after');
146
+ const retryAfterSeconds = retryAfterHeader ? Number(retryAfterHeader) : NaN;
147
+ // Cap Retry-After waits at 30s so a pathological header can't hang callers.
148
+ const delayMs = Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
149
+ ? Math.min(retryAfterSeconds, 30) * 1000
150
+ : attempt * 1000; // 1s after attempt 1, 2s after attempt 2
151
+ logger.warn({ url, attempt, delayMs, retryAfterHeader }, 'Figma API rate limited (429), retrying');
152
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
153
+ }
116
154
  if (!response.ok) {
117
155
  const errorText = await response.text();
118
156
  logger.error({ status: response.status, statusText: response.statusText, body: errorText }, 'Figma API request failed');
119
- throw new Error(`Figma API error (${response.status}): ${errorText}`);
157
+ // The `Figma API error (<status>):` prefix is LOAD-BEARING — downstream
158
+ // tool handlers branch on errorMessage.includes("403") etc. Keep it
159
+ // verbatim at the start of the message; only APPEND guidance after it.
160
+ let message = `Figma API error (${response.status}): ${errorText}`;
161
+ // Token-auth failures (expired/invalid token) get actionable guidance.
162
+ // Plan-gate 403s (e.g. Variables API on non-Enterprise) do NOT match the
163
+ // token pattern and must not be flagged as auth errors.
164
+ const isAuthError = (response.status === 401 || response.status === 403) &&
165
+ /invalid token|token expired|unauthorized/i.test(errorText);
166
+ if (isAuthError) {
167
+ message +=
168
+ ' — Your Figma access token is expired or invalid. Generate a new personal access token at figma.com → Settings → Security → Personal access tokens, then update FIGMA_ACCESS_TOKEN in your MCP config. If Figma Desktop is open with the Desktop Bridge plugin running, most tools work without any REST token.';
169
+ }
170
+ else if (response.status === 429) {
171
+ message += ' Rate limited by Figma — wait a moment and retry.';
172
+ }
173
+ const err = new Error(message);
174
+ err.status = response.status;
175
+ if (isAuthError) {
176
+ err.isAuthError = true;
177
+ }
178
+ throw err;
120
179
  }
121
180
  const data = await response.json();
122
181
  return data;
@@ -145,43 +204,6 @@ export class FigmaAPI {
145
204
  }
146
205
  return this.request(endpoint);
147
206
  }
148
- /**
149
- * Resolve a branch key from a branch ID
150
- * If branchId is provided, fetches branch data and returns the branch's unique key
151
- * Otherwise returns the main file key unchanged
152
- * @param fileKey The main file key from the URL
153
- * @param branchId Optional branch ID from URL query param (branch-id)
154
- * @returns The effective file key to use for API calls (branch key if on branch, otherwise fileKey)
155
- */
156
- async getBranchKey(fileKey, branchId) {
157
- if (!branchId) {
158
- return fileKey;
159
- }
160
- try {
161
- logger.info({ fileKey, branchId }, 'Resolving branch key');
162
- const fileData = await this.getFile(fileKey, { branch_data: true });
163
- const branches = fileData.branches || [];
164
- // Try to find branch by key (branchId might already be the key)
165
- // or by matching other identifiers
166
- const branch = branches.find((b) => b.key === branchId || b.name === branchId);
167
- if (branch?.key) {
168
- logger.info({ fileKey, branchId, branchKey: branch.key, branchName: branch.name }, 'Resolved branch key');
169
- return branch.key;
170
- }
171
- // If branchId looks like a file key (alphanumeric), it might already be the branch key
172
- // In this case, return it directly as it may be usable
173
- if (/^[a-zA-Z0-9]+$/.test(branchId)) {
174
- logger.info({ fileKey, branchId }, 'Branch ID appears to be a key, using directly');
175
- return branchId;
176
- }
177
- logger.warn({ fileKey, branchId, availableBranches: branches.map((b) => ({ key: b.key, name: b.name })) }, 'Branch not found in file, using main file key');
178
- return fileKey;
179
- }
180
- catch (error) {
181
- logger.error({ error, fileKey, branchId }, 'Failed to resolve branch key, using main file key');
182
- return fileKey;
183
- }
184
- }
185
207
  /**
186
208
  * GET /v1/files/:file_key/variables/local
187
209
  * Get local variables (design tokens) from a file
@@ -205,11 +227,17 @@ export class FigmaAPI {
205
227
  /**
206
228
  * GET /v1/files/:file_key/nodes
207
229
  * Get specific nodes by ID
230
+ *
231
+ * Node IDs are normalized to Figma's colon form ("695-313" -> "695:313")
232
+ * before the request, and the response map is aliased back under any
233
+ * dashed IDs the caller passed — Figma keys the response by colon-format
234
+ * IDs, so without the alias a dashed lookup silently returns undefined.
208
235
  */
209
236
  async getNodes(fileKey, nodeIds, options) {
210
237
  let endpoint = `/files/${fileKey}/nodes`;
238
+ const normalizedIds = nodeIds.map((id) => normalizeNodeId(id));
211
239
  const params = new URLSearchParams();
212
- params.append('ids', nodeIds.join(','));
240
+ params.append('ids', normalizedIds.join(','));
213
241
  if (options?.version)
214
242
  params.append('version', options.version);
215
243
  if (options?.depth !== undefined)
@@ -219,7 +247,19 @@ export class FigmaAPI {
219
247
  if (options?.plugin_data)
220
248
  params.append('plugin_data', options.plugin_data);
221
249
  endpoint += `?${params.toString()}`;
222
- return this.request(endpoint);
250
+ const response = await this.request(endpoint);
251
+ // Defensive aliasing: let callers index response.nodes by the ID form
252
+ // they originally passed (dashed), not just Figma's colon form.
253
+ if (response?.nodes) {
254
+ for (let i = 0; i < nodeIds.length; i++) {
255
+ const original = nodeIds[i];
256
+ const normalized = normalizedIds[i];
257
+ if (original !== normalized && response.nodes[normalized] !== undefined && response.nodes[original] === undefined) {
258
+ response.nodes[original] = response.nodes[normalized];
259
+ }
260
+ }
261
+ }
262
+ return response;
223
263
  }
224
264
  /**
225
265
  * GET /v1/files/:file_key/styles
@@ -271,8 +311,12 @@ export class FigmaAPI {
271
311
  */
272
312
  async getImages(fileKey, nodeIds, options) {
273
313
  const params = new URLSearchParams();
274
- // Handle single or multiple node IDs
275
- const ids = Array.isArray(nodeIds) ? nodeIds.join(',') : nodeIds;
314
+ // Handle single or multiple node IDs. Normalize to Figma's colon form
315
+ // ("695-313" -> "695:313") the response map is keyed by colon IDs, so
316
+ // a dashed request ID would otherwise never match its own result.
317
+ const originalIds = Array.isArray(nodeIds) ? nodeIds : [nodeIds];
318
+ const normalizedIds = originalIds.map((id) => normalizeNodeId(id));
319
+ const ids = normalizedIds.join(',');
276
320
  params.append('ids', ids);
277
321
  // Add optional parameters
278
322
  if (options?.scale !== undefined)
@@ -291,7 +335,19 @@ export class FigmaAPI {
291
335
  params.append('contents_only', options.contents_only.toString());
292
336
  const endpoint = `/images/${fileKey}?${params.toString()}`;
293
337
  logger.info({ fileKey, ids, options }, 'Rendering images');
294
- return this.request(endpoint);
338
+ const response = await this.request(endpoint);
339
+ // Defensive aliasing: callers may index the images map by the dashed
340
+ // ID they passed. Figma keys it by colon-format ID — alias both forms.
341
+ if (response?.images) {
342
+ for (let i = 0; i < originalIds.length; i++) {
343
+ const original = originalIds[i];
344
+ const normalized = normalizedIds[i];
345
+ if (original !== normalized && response.images[normalized] !== undefined && response.images[original] === undefined) {
346
+ response.images[original] = response.images[normalized];
347
+ }
348
+ }
349
+ }
350
+ return response;
295
351
  }
296
352
  /**
297
353
  * GET /v1/files/:file_key/comments
@@ -356,15 +412,20 @@ export class FigmaAPI {
356
412
  const [localResult, publishedResult] = await Promise.all([
357
413
  this.getLocalVariables(fileKey).catch((err) => {
358
414
  const errorMsg = err instanceof Error ? err.message : String(err);
415
+ logger.warn({ fileKey, error: errorMsg }, 'getLocalVariables failed; returning empty fallback');
359
416
  return { error: errorMsg, variables: {}, variableCollections: {} };
360
417
  }),
361
418
  this.getPublishedVariables(fileKey).catch((err) => {
362
419
  const errorMsg = err instanceof Error ? err.message : String(err);
420
+ logger.warn({ fileKey, error: errorMsg }, 'getPublishedVariables failed; returning empty fallback');
363
421
  return { error: errorMsg, variables: {} };
364
422
  }),
365
423
  ]);
424
+ // Fallback shape must match the success path (already-unwrapped meta):
425
+ // { variables, variableCollections } — NOT { meta: {...} } — so consumers
426
+ // like formatVariables() see a consistent shape either way.
366
427
  return {
367
- local: 'error' in localResult ? { meta: { variables: {}, variableCollections: {} } } : localResult,
428
+ local: 'error' in localResult ? { variables: {}, variableCollections: {} } : localResult,
368
429
  published: 'error' in publishedResult ? { variables: {} } : publishedResult,
369
430
  ...(('error' in localResult) && { localError: localResult.error }),
370
431
  ...(('error' in publishedResult) && { publishedError: publishedResult.error }),
@@ -372,10 +433,13 @@ export class FigmaAPI {
372
433
  }
373
434
  /**
374
435
  * Helper: Get component metadata with properties
436
+ * Normalizes dashed node IDs ("695-313") to colon form ("695:313") so the
437
+ * response-map lookup matches Figma's colon-keyed response.
375
438
  */
376
439
  async getComponentData(fileKey, nodeId, depth = 4) {
377
- const response = await this.getNodes(fileKey, [nodeId], { depth });
378
- return response.nodes?.[nodeId];
440
+ const normalizedId = normalizeNodeId(nodeId);
441
+ const response = await this.getNodes(fileKey, [normalizedId], { depth });
442
+ return response.nodes?.[normalizedId] ?? response.nodes?.[nodeId];
379
443
  }
380
444
  /**
381
445
  * Helper: Search for components by name
@@ -147,27 +147,34 @@ function adaptiveResponse(responseData, options) {
147
147
  ],
148
148
  };
149
149
  }
150
- // Determine compression level and message
150
+ // Determine compression level and message. The "AUTO-COMPRESSED" wording is
151
+ // only truthful when a compressionCallback actually runs — without one the
152
+ // full payload is returned and the banner must say so instead of claiming
153
+ // a reduction that never happened.
151
154
  let compressionLevel = "info";
152
155
  let aiInstruction = "";
153
156
  let shouldCompress = false;
157
+ const canCompress = !!options.compressionCallback;
154
158
  if (sizeKB > RESPONSE_SIZE_THRESHOLDS.MAX_SIZE_KB) {
155
159
  compressionLevel = "emergency";
156
160
  shouldCompress = true;
157
- aiInstruction =
158
- `⚠️ RESPONSE AUTO-COMPRESSED: The ${options.toolName} response was automatically reduced because the full response would be ${sizeKB.toFixed(0)}KB, which would exhaust Claude Desktop's context window.\n\n`;
161
+ aiInstruction = canCompress
162
+ ? `⚠️ RESPONSE AUTO-COMPRESSED: The ${options.toolName} response was automatically reduced because the full response would be ${sizeKB.toFixed(0)}KB, which would exhaust Claude Desktop's context window.\n\n`
163
+ : `⚠️ LARGE RESPONSE: The ${options.toolName} response is ${sizeKB.toFixed(0)}KB and may exhaust the context window. Retry with filters or pagination to reduce it.\n\n`;
159
164
  }
160
165
  else if (sizeKB > RESPONSE_SIZE_THRESHOLDS.CRITICAL_SIZE_KB) {
161
166
  compressionLevel = "critical";
162
167
  shouldCompress = true;
163
- aiInstruction =
164
- `⚠️ RESPONSE AUTO-COMPRESSED: The ${options.toolName} response was automatically reduced because it would be ${sizeKB.toFixed(0)}KB, risking context window exhaustion.\n\n`;
168
+ aiInstruction = canCompress
169
+ ? `⚠️ RESPONSE AUTO-COMPRESSED: The ${options.toolName} response was automatically reduced because it would be ${sizeKB.toFixed(0)}KB, risking context window exhaustion.\n\n`
170
+ : `⚠️ LARGE RESPONSE: The ${options.toolName} response is ${sizeKB.toFixed(0)}KB, risking context window exhaustion. Retry with filters or pagination to reduce it.\n\n`;
165
171
  }
166
172
  else if (sizeKB > RESPONSE_SIZE_THRESHOLDS.WARNING_SIZE_KB) {
167
173
  compressionLevel = "warning";
168
174
  shouldCompress = true;
169
- aiInstruction =
170
- `ℹ️ RESPONSE OPTIMIZED: The ${options.toolName} response was automatically reduced because it would be ${sizeKB.toFixed(0)}KB.\n\n`;
175
+ aiInstruction = canCompress
176
+ ? `ℹ️ RESPONSE OPTIMIZED: The ${options.toolName} response was automatically reduced because it would be ${sizeKB.toFixed(0)}KB.\n\n`
177
+ : `ℹ️ LARGE RESPONSE: The ${options.toolName} response is ${sizeKB.toFixed(0)}KB. Filters or pagination can reduce it.\n\n`;
171
178
  }
172
179
  // Map compression level to verbosity level
173
180
  const verbosityMap = {
@@ -816,13 +823,17 @@ export function registerFigmaAPITools(server, getFigmaAPI, getCurrentUrl, variab
816
823
  return adaptiveResponse(finalResponse, {
817
824
  toolName: "figma_get_file_data",
818
825
  compressionCallback: (adjustedLevel) => {
819
- // Re-apply node filtering with lower verbosity
820
- const level = adjustedLevel;
826
+ // Re-apply node filtering with lower verbosity. "inventory"
827
+ // (the emergency tier) isn't a filterNode level clamp it to
828
+ // "summary", the strongest filter, instead of letting it fall
829
+ // through filterNode's default branch which returns the RAW
830
+ // node (that ballooned "compressed" responses). Always
831
+ // re-filter, even when the caller asked for verbosity='full' —
832
+ // an emergency downgrade must never echo the raw document.
833
+ const level = (adjustedLevel === "standard" ? "standard" : "summary");
821
834
  const refiltered = {
822
835
  ...finalResponse,
823
- document: verbosity !== "full"
824
- ? filterNode(fileData.document, level)
825
- : fileData.document,
836
+ document: filterNode(fileData.document, level),
826
837
  verbosity: level,
827
838
  };
828
839
  return refiltered;
@@ -866,7 +877,7 @@ export function registerFigmaAPITools(server, getFigmaAPI, getCurrentUrl, variab
866
877
  * removed in the Phase 3 cleanup. Setting parseFromConsole=true now
867
878
  * throws an identified error pointing the caller at the bridge.
868
879
  */
869
- server.tool("figma_get_variables", "Extract design tokens and variables from a Figma file with code export support (CSS, Tailwind, TypeScript, Sass). Use when user asks for: design system tokens, variables, color/spacing values, theme data, or code exports. Handles multi-mode variables (Light/Dark themes). NOT for component metadata (use figma_get_component). Supports filtering by collection/mode/name and verbosity control to prevent token exhaustion. Resolution order: Desktop Bridge plugin (works on any plan) → Variables REST API (Enterprise only) → Styles API as a partial fallback. TIP: For full design system extraction (tokens + components + styles combined), prefer figma_get_design_system_kit instead — it returns everything in one optimized call.", {
880
+ server.tool("figma_get_variables", "Extract design tokens and variables from a Figma file with code export support (CSS, Tailwind, TypeScript, Sass). Use when user asks for: design system tokens, variables, color/spacing values, theme data, or code exports. Handles multi-mode variables (Light/Dark themes). NOT for component metadata (use figma_get_component). Returns a compact summary by default — pass format='full' for the complete dataset or format='filtered' with collection/namePattern/mode filters for specific variables. Supports verbosity control to prevent token exhaustion. Resolution order: Desktop Bridge plugin (works on any plan) → Variables REST API (Enterprise only) → Styles API as a partial fallback. TIP: For full design system extraction (tokens + components + styles combined), prefer figma_get_design_system_kit instead — it returns everything in one optimized call.", {
870
881
  fileUrl: z
871
882
  .string()
872
883
  .url()
@@ -905,9 +916,9 @@ export function registerFigmaAPITools(server, getFigmaAPI, getCurrentUrl, variab
905
916
  format: z
906
917
  .enum(["summary", "filtered", "full"])
907
918
  .optional()
908
- .default("full")
909
919
  .describe("Response format: 'summary' (~2K tokens with overview and names only), 'filtered' (apply collection/name/mode filters), 'full' (complete dataset from cache or fetch). " +
910
- "Summary is recommended for initial exploration. Full format returns all data but may be auto-summarized if >25K tokens. Default: full"),
920
+ "Default: summary (token-efficient). When format is omitted, filter params (collection/namePattern/mode) auto-select 'filtered', and enrichment/export/resolveAliases/returnAsLinks params auto-select 'full'. " +
921
+ "Full format returns all data but may be auto-summarized if >25K tokens."),
911
922
  collection: z
912
923
  .string()
913
924
  .optional()
@@ -964,6 +975,28 @@ export function registerFigmaAPITools(server, getFigmaAPI, getCurrentUrl, variab
964
975
  "instead of just alias references. Useful for getting color hex values without manual resolution. " +
965
976
  "Default: false."),
966
977
  }, async ({ fileUrl, includePublished, verbosity, enrich, include_usage, include_dependencies, include_exports, export_formats, format, collection, namePattern, mode, returnAsLinks, refreshCache, useConsoleFallback, parseFromConsole, page, pageSize, resolveAliases }) => {
978
+ // Smart format default (token efficiency): when the caller doesn't pick a
979
+ // format, use the cheapest one their other parameters allow. Explicit
980
+ // format callers are unaffected. Only parameters without schema defaults
981
+ // (or defaulting to false) can signal intent — verbosity, includePublished,
982
+ // and page/pageSize are always populated by their defaults.
983
+ if (!format) {
984
+ if (collection || namePattern || mode) {
985
+ format = "filtered";
986
+ }
987
+ else if (enrich ||
988
+ include_usage ||
989
+ include_dependencies ||
990
+ include_exports ||
991
+ (export_formats && export_formats.length > 0) ||
992
+ resolveAliases ||
993
+ returnAsLinks) {
994
+ format = "full";
995
+ }
996
+ else {
997
+ format = "summary";
998
+ }
999
+ }
967
1000
  // Extract fileKey and optional branchId outside try block so they're available in catch block
968
1001
  const url = fileUrl || getCurrentUrl();
969
1002
  if (!url) {
@@ -1297,6 +1330,53 @@ export function registerFigmaAPITools(server, getFigmaAPI, getCurrentUrl, variab
1297
1330
  let publishedFormatted = includePublished
1298
1331
  ? formatVariables(published)
1299
1332
  : null;
1333
+ // Cache the FULL dataset in the Desktop-connection shape BEFORE
1334
+ // any format/verbosity/pagination mutation. The cache reader
1335
+ // expects { variables, variableCollections }; caching the
1336
+ // stripped, paginated page (the old behavior) poisoned every
1337
+ // follow-up filtered/summary call for the whole TTL window.
1338
+ // Variable objects are shallow-copied so downstream verbosity
1339
+ // stripping can't reach into the cached entries.
1340
+ if (variablesCache) {
1341
+ evictOldestCacheEntry(variablesCache);
1342
+ variablesCache.set(fileKey, {
1343
+ data: {
1344
+ fileKey,
1345
+ source: "rest_api",
1346
+ timestamp: Date.now(),
1347
+ variables: (localFormatted.variables || []).map((v) => ({ ...v })),
1348
+ variableCollections: (localFormatted.collections || []).map((c) => ({ ...c })),
1349
+ },
1350
+ timestamp: Date.now(),
1351
+ });
1352
+ logger.info({ fileKey, variableCount: localFormatted.variables?.length }, "Cached full REST API variables dataset");
1353
+ }
1354
+ // Honor format='summary' on the REST path (historically only the
1355
+ // cache and Desktop Bridge paths summarized). Runs AFTER the
1356
+ // cache write so summary calls still populate the full dataset.
1357
+ if (format === 'summary') {
1358
+ const summary = generateSummary({
1359
+ fileKey,
1360
+ timestamp: Date.now(),
1361
+ source: 'rest_api',
1362
+ variables: localFormatted.variables,
1363
+ variableCollections: localFormatted.collections,
1364
+ });
1365
+ logger.info({ fileKey, estimatedTokens: estimateTokens(summary) }, 'Generated summary from REST API data');
1366
+ return {
1367
+ content: [
1368
+ {
1369
+ type: "text",
1370
+ text: JSON.stringify({
1371
+ fileKey,
1372
+ source: "rest_api",
1373
+ format: "summary",
1374
+ data: summary,
1375
+ }),
1376
+ },
1377
+ ],
1378
+ };
1379
+ }
1300
1380
  // DEBUG: Check if valuesByMode exists before filtering
1301
1381
  if (localFormatted.variables[0]) {
1302
1382
  logger.info({
@@ -1374,31 +1454,8 @@ export function registerFigmaAPITools(server, getFigmaAPI, getCurrentUrl, variab
1374
1454
  publishedFormatted.variables = publishedFormatted.variables.slice(startIdx, endIdx);
1375
1455
  }
1376
1456
  }
1377
- // Cache the successful REST API response
1378
- const dataForCache = {
1379
- fileKey,
1380
- local: {
1381
- summary: localFormatted.summary,
1382
- collections: localFormatted.collections,
1383
- variables: localFormatted.variables,
1384
- },
1385
- ...(includePublished &&
1386
- publishedFormatted && {
1387
- published: {
1388
- summary: publishedFormatted.summary,
1389
- collections: publishedFormatted.collections,
1390
- variables: publishedFormatted.variables,
1391
- },
1392
- }),
1393
- verbosity: verbosity || "standard",
1394
- enriched: enrich || false,
1395
- timestamp: Date.now(),
1396
- source: "rest_api",
1397
- };
1398
- if (variablesCache) {
1399
- variablesCache.set(fileKey, { data: dataForCache, timestamp: Date.now() });
1400
- logger.info({ fileKey }, "Cached REST API variables");
1401
- }
1457
+ // (Cache write happens above, pre-mutation — the response below
1458
+ // is built from the filtered/paginated working copies.)
1402
1459
  // Apply alias resolution if requested (REST API format has local.variables)
1403
1460
  if (resolveAliases && localFormatted.variables?.length > 0) {
1404
1461
  // Build maps from local variables and collections
@@ -1742,6 +1799,46 @@ export function registerFigmaAPITools(server, getFigmaAPI, getCurrentUrl, variab
1742
1799
  if (!localError && local) {
1743
1800
  let localFormatted = formatVariables(local);
1744
1801
  let publishedFormatted = includePublished ? formatVariables(published) : null;
1802
+ // Cache the FULL dataset pre-mutation in the Desktop shape
1803
+ // (same poisoning fix as the primary REST path above).
1804
+ if (variablesCache) {
1805
+ evictOldestCacheEntry(variablesCache);
1806
+ variablesCache.set(fileKey, {
1807
+ data: {
1808
+ fileKey,
1809
+ source: "rest_api",
1810
+ timestamp: Date.now(),
1811
+ variables: (localFormatted.variables || []).map((v) => ({ ...v })),
1812
+ variableCollections: (localFormatted.collections || []).map((c) => ({ ...c })),
1813
+ },
1814
+ timestamp: Date.now(),
1815
+ });
1816
+ }
1817
+ // Honor format='summary' on the REST fallback path too
1818
+ // (after the cache write, so the full dataset is retained).
1819
+ if (format === 'summary') {
1820
+ const summary = generateSummary({
1821
+ fileKey,
1822
+ timestamp: Date.now(),
1823
+ source: 'rest_api',
1824
+ variables: localFormatted.variables,
1825
+ variableCollections: localFormatted.collections,
1826
+ });
1827
+ logger.info({ fileKey, estimatedTokens: estimateTokens(summary) }, 'Generated summary from REST API fallback data');
1828
+ return {
1829
+ content: [
1830
+ {
1831
+ type: "text",
1832
+ text: JSON.stringify({
1833
+ fileKey,
1834
+ source: "rest_api",
1835
+ format: "summary",
1836
+ data: summary,
1837
+ }),
1838
+ },
1839
+ ],
1840
+ };
1841
+ }
1745
1842
  // Apply filters
1746
1843
  if (format === 'filtered') {
1747
1844
  const filteredLocal = applyFilters({ variables: localFormatted.variables, variableCollections: localFormatted.collections }, { collection, namePattern, mode }, verbosity || "standard");
@@ -1752,19 +1849,6 @@ export function registerFigmaAPITools(server, getFigmaAPI, getCurrentUrl, variab
1752
1849
  const verbosityFiltered = applyFilters({ variables: localFormatted.variables, variableCollections: localFormatted.collections }, {}, verbosity);
1753
1850
  localFormatted = { ...localFormatted, collections: verbosityFiltered.variableCollections, variables: verbosityFiltered.variables };
1754
1851
  }
1755
- // Cache
1756
- const dataForCache = {
1757
- fileKey,
1758
- local: { summary: localFormatted.summary, collections: localFormatted.collections, variables: localFormatted.variables },
1759
- ...(includePublished && publishedFormatted && { published: { summary: publishedFormatted.summary, collections: publishedFormatted.collections, variables: publishedFormatted.variables } }),
1760
- verbosity: verbosity || "standard",
1761
- enriched: enrich || false,
1762
- timestamp: Date.now(),
1763
- source: "rest_api",
1764
- };
1765
- if (variablesCache) {
1766
- variablesCache.set(fileKey, { data: dataForCache, timestamp: Date.now() });
1767
- }
1768
1852
  // Apply alias resolution
1769
1853
  if (resolveAliases && localFormatted.variables?.length > 0) {
1770
1854
  const allVariablesMap = new Map();
@@ -1825,6 +1909,27 @@ export function registerFigmaAPITools(server, getFigmaAPI, getCurrentUrl, variab
1825
1909
  catch (error) {
1826
1910
  logger.error({ error }, "Failed to get variables");
1827
1911
  const errorMessage = error instanceof Error ? error.message : String(error);
1912
+ // A 403 here has two very different causes: an expired/invalid token
1913
+ // (isAuthError, set centrally by FigmaAPI.request) or the Variables
1914
+ // API's Enterprise plan gate. Only the latter should be diagnosed as
1915
+ // "requires Enterprise" — telling a user with a dead token to buy
1916
+ // Enterprise is actively wrong.
1917
+ const isTokenError = error?.isAuthError === true;
1918
+ if (isTokenError) {
1919
+ return {
1920
+ content: [
1921
+ {
1922
+ type: "text",
1923
+ text: JSON.stringify({
1924
+ error: errorMessage,
1925
+ message: "Failed to retrieve Figma variables — REST token is expired or invalid",
1926
+ hint: "Generate a new personal access token at figma.com → Settings → Security → Personal access tokens and update FIGMA_ACCESS_TOKEN. Or open the Desktop Bridge plugin in Figma Desktop — variables work on any plan without a REST token.",
1927
+ }),
1928
+ },
1929
+ ],
1930
+ isError: true,
1931
+ };
1932
+ }
1828
1933
  // FIXED: Jump directly to Styles API (fast) instead of full file data (slow)
1829
1934
  if (errorMessage.includes("403")) {
1830
1935
  try {
@@ -1879,11 +1984,12 @@ export function registerFigmaAPITools(server, getFigmaAPI, getCurrentUrl, variab
1879
1984
  "The file structure may not contain extractable styles",
1880
1985
  "There may be a network or authentication issue"
1881
1986
  ],
1882
- suggestion: "Please ensure the file is accessible and try again, or check if your token has the necessary permissions.",
1987
+ suggestion: "Please ensure the file is accessible and try again, or check if your token has the necessary permissions. With the Desktop Bridge plugin open in Figma Desktop, variables work on any plan without a REST token.",
1883
1988
  technical: styleError instanceof Error ? styleError.message : String(styleError)
1884
1989
  }),
1885
1990
  },
1886
1991
  ],
1992
+ isError: true,
1887
1993
  };
1888
1994
  }
1889
1995
  }
@@ -1896,8 +2002,8 @@ export function registerFigmaAPITools(server, getFigmaAPI, getCurrentUrl, variab
1896
2002
  error: errorMessage,
1897
2003
  message: "Failed to retrieve Figma variables",
1898
2004
  hint: errorMessage.includes("403")
1899
- ? "Variables API requires Enterprise plan. Set useConsoleFallback=true for alternative method."
1900
- : "Make sure FIGMA_ACCESS_TOKEN is configured and has appropriate permissions",
2005
+ ? "The Variables REST API requires an Enterprise plan. Open the Desktop Bridge plugin in Figma Desktop instead — variables work on any plan through the bridge."
2006
+ : "Make sure FIGMA_ACCESS_TOKEN is configured and has appropriate permissions, or open the Desktop Bridge plugin in Figma Desktop",
1901
2007
  }),
1902
2008
  },
1903
2009
  ],
@@ -2251,14 +2357,15 @@ export function registerFigmaAPITools(server, getFigmaAPI, getCurrentUrl, variab
2251
2357
  return adaptiveResponse(finalResponse, {
2252
2358
  toolName: "figma_get_styles",
2253
2359
  compressionCallback: (adjustedLevel) => {
2254
- // Re-apply style filtering with lower verbosity
2255
- const level = adjustedLevel;
2256
- const refilteredStyles = verbosity !== "full"
2257
- ? styles.map((style) => filterStyle(style, level))
2258
- : styles;
2360
+ // Re-apply style filtering with lower verbosity. Clamp the
2361
+ // emergency "inventory" tier to "summary" (filterStyle has no
2362
+ // inventory branch) and always re-filter — even for
2363
+ // verbosity='full' callers so compression can never echo
2364
+ // the unfiltered payload.
2365
+ const level = (adjustedLevel === "standard" ? "standard" : "summary");
2259
2366
  return {
2260
2367
  ...finalResponse,
2261
- styles: refilteredStyles,
2368
+ styles: styles.map((style) => filterStyle(style, level)),
2262
2369
  verbosity: level,
2263
2370
  };
2264
2371
  },
@@ -2310,6 +2417,9 @@ export function registerFigmaAPITools(server, getFigmaAPI, getCurrentUrl, variab
2310
2417
  .describe("Image format (default: png)"),
2311
2418
  }, async ({ fileUrl, nodeId, scale, format }) => {
2312
2419
  try {
2420
+ // Accept URL-format ids (123-456); the REST response maps key by
2421
+ // colon format (123:456).
2422
+ nodeId = nodeId.replace(/-/g, ":");
2313
2423
  let api;
2314
2424
  try {
2315
2425
  api = await getFigmaAPI();
@@ -3151,8 +3261,14 @@ export function registerFigmaAPITools(server, getFigmaAPI, getCurrentUrl, variab
3151
3261
  text: JSON.stringify({
3152
3262
  success: true,
3153
3263
  instance: result.instance,
3264
+ // Plugin-side warnings (e.g. property names that didn't
3265
+ // match anything) — without these a partial apply looks
3266
+ // like a full success.
3267
+ warnings: result.warnings?.length ? result.warnings : undefined,
3154
3268
  metadata: {
3155
- note: "Instance properties updated successfully. Use figma_capture_screenshot to verify visual changes.",
3269
+ note: result.warnings?.length
3270
+ ? "Some properties were applied, but see warnings — not every requested property matched. Use figma_capture_screenshot to verify visual changes."
3271
+ : "Instance properties updated successfully. Use figma_capture_screenshot to verify visual changes.",
3156
3272
  },
3157
3273
  }),
3158
3274
  },