@mp3wizard/figma-console-mcp 1.22.6 → 1.23.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 (42) hide show
  1. package/README.md +15 -38
  2. package/dist/cloudflare/core/design-code-tools.js +3 -29
  3. package/dist/cloudflare/core/diff/changelog-formatter.js +275 -0
  4. package/dist/cloudflare/core/diff/diff-engine.js +334 -0
  5. package/dist/cloudflare/core/diff/property-compare.js +36 -0
  6. package/dist/cloudflare/core/diff/version-cache.js +74 -0
  7. package/dist/cloudflare/core/figma-api.js +19 -0
  8. package/dist/cloudflare/core/version-tools.js +1014 -0
  9. package/dist/cloudflare/index.js +17 -13
  10. package/dist/core/design-code-tools.d.ts +1 -12
  11. package/dist/core/design-code-tools.d.ts.map +1 -1
  12. package/dist/core/design-code-tools.js +3 -29
  13. package/dist/core/design-code-tools.js.map +1 -1
  14. package/dist/core/diff/changelog-formatter.d.ts +35 -0
  15. package/dist/core/diff/changelog-formatter.d.ts.map +1 -0
  16. package/dist/core/diff/changelog-formatter.js +276 -0
  17. package/dist/core/diff/changelog-formatter.js.map +1 -0
  18. package/dist/core/diff/diff-engine.d.ts +113 -0
  19. package/dist/core/diff/diff-engine.d.ts.map +1 -0
  20. package/dist/core/diff/diff-engine.js +335 -0
  21. package/dist/core/diff/diff-engine.js.map +1 -0
  22. package/dist/core/diff/property-compare.d.ts +19 -0
  23. package/dist/core/diff/property-compare.d.ts.map +1 -0
  24. package/dist/core/diff/property-compare.js +37 -0
  25. package/dist/core/diff/property-compare.js.map +1 -0
  26. package/dist/core/diff/version-cache.d.ts +40 -0
  27. package/dist/core/diff/version-cache.d.ts.map +1 -0
  28. package/dist/core/diff/version-cache.js +75 -0
  29. package/dist/core/diff/version-cache.js.map +1 -0
  30. package/dist/core/figma-api.d.ts +29 -0
  31. package/dist/core/figma-api.d.ts.map +1 -1
  32. package/dist/core/figma-api.js +19 -0
  33. package/dist/core/figma-api.js.map +1 -1
  34. package/dist/core/version-tools.d.ts +30 -0
  35. package/dist/core/version-tools.d.ts.map +1 -0
  36. package/dist/core/version-tools.js +1015 -0
  37. package/dist/core/version-tools.js.map +1 -0
  38. package/dist/local.d.ts.map +1 -1
  39. package/dist/local.js +8 -0
  40. package/dist/local.js.map +1 -1
  41. package/figma-desktop-bridge/code.js +1 -1
  42. package/package.json +108 -1
@@ -0,0 +1,1015 @@
1
+ /**
2
+ * Figma Version History MCP Tools
3
+ *
4
+ * - figma_get_file_versions: list a file's version history with
5
+ * auto-pagination, labeled-only filtering by default, and a hard cap.
6
+ * - figma_get_file_at_version: snapshot a file (or selected nodes) at a
7
+ * specific version_id. Thin wrapper over getFile/getNodes which already
8
+ * accept the `version` query param.
9
+ * - figma_diff_versions: compare two versions. Always returns a page-structure
10
+ * diff (cheap, 2 API calls). When component_ids are passed, also returns
11
+ * per-node diffs at depth=2 (added/removed children, name/description
12
+ * changes, componentPropertyDefinitions changes, boundVariables deltas).
13
+ * - figma_get_changes_since_version: convenience wrapper for diff against HEAD.
14
+ * - figma_generate_changelog: human-readable markdown changelog on top of
15
+ * the diff, with author enrichment via figma_get_file_versions lookback.
16
+ *
17
+ * All tools work in local and Cloudflare Workers modes. Required scope is
18
+ * file_versions:read on OAuth, or "Versions" Read on a Personal Access Token,
19
+ * plus the standard file_content:read for fetching file snapshots.
20
+ *
21
+ * Design notes at .notes/VERSION-HISTORY-DIFF-DESIGN.md.
22
+ */
23
+ import { z } from "zod";
24
+ import { extractFileKey } from "./figma-api.js";
25
+ import { createChildLogger } from "./logger.js";
26
+ import { VersionSnapshotCache } from "./diff/version-cache.js";
27
+ import { diffNode, diffPageStructure, } from "./diff/diff-engine.js";
28
+ import { formatChangelogMarkdown, } from "./diff/changelog-formatter.js";
29
+ const logger = createChildLogger({ component: "version-tools" });
30
+ // Module-scoped cache shared across all tool calls within a process.
31
+ // Past versions are immutable so the cache can live indefinitely.
32
+ const versionSnapshotCache = new VersionSnapshotCache({ maxEntries: 50 });
33
+ /** Test-only: clears the module-scoped snapshot cache so unit tests see fresh state. */
34
+ export function _clearVersionSnapshotCacheForTesting() {
35
+ versionSnapshotCache.clear();
36
+ }
37
+ // Sentinel for "use HEAD instead of a specific version_id"
38
+ const CURRENT_VERSION_SENTINEL = "current";
39
+ function isCurrentSentinel(versionId) {
40
+ return versionId === CURRENT_VERSION_SENTINEL;
41
+ }
42
+ /**
43
+ * Fetch the document at depth=1 for either a specific version_id or HEAD.
44
+ * HEAD responses are not cached (they're mutable). Past versions are cached.
45
+ * Returns { data, cached } so callers can report accurate live-call counts.
46
+ */
47
+ async function fetchDocumentAtVersion(api, fileKey, versionId) {
48
+ const isHead = isCurrentSentinel(versionId);
49
+ const cacheKey = isHead ? null : versionSnapshotCache.makeKey(fileKey, versionId, 1);
50
+ const cached = versionSnapshotCache.get(cacheKey);
51
+ if (cached)
52
+ return { data: cached, cached: true };
53
+ const opts = isHead ? { depth: 1 } : { version: versionId, depth: 1 };
54
+ const data = await api.getFile(fileKey, opts);
55
+ if (cacheKey)
56
+ versionSnapshotCache.set(cacheKey, data);
57
+ return { data, cached: false };
58
+ }
59
+ /**
60
+ * Fetch a single node at depth=2 for either a specific version_id or HEAD.
61
+ * Same caching policy and return contract as above.
62
+ */
63
+ async function fetchNodeAtVersion(api, fileKey, nodeId, versionId) {
64
+ const isHead = isCurrentSentinel(versionId);
65
+ const cacheKey = isHead ? null : versionSnapshotCache.makeKey(fileKey, versionId, 2, [nodeId]);
66
+ const cached = versionSnapshotCache.get(cacheKey);
67
+ if (cached)
68
+ return { data: cached, cached: true };
69
+ const opts = isHead ? { depth: 2 } : { version: versionId, depth: 2 };
70
+ const data = await api.getNodes(fileKey, [nodeId], opts);
71
+ if (cacheKey)
72
+ versionSnapshotCache.set(cacheKey, data);
73
+ return { data, cached: false };
74
+ }
75
+ // Hard safety cap — 20 pages × 50 page_size = 1000 versions scanned worst-case.
76
+ // Prevents an infinite loop if Figma returns inconsistent pagination metadata.
77
+ const MAX_SCAN_PAGES = 20;
78
+ // Figma's documented page_size max
79
+ const FIGMA_PAGE_SIZE_MAX = 50;
80
+ // Tool-level cap on max_versions; design brief §4.
81
+ const MAX_VERSIONS_HARD_CAP = 200;
82
+ // ============================================================================
83
+ // Tool Registration
84
+ // ============================================================================
85
+ export function registerVersionTools(server, getFigmaAPI, getCurrentUrl, _options, getCurrentSelectedNodeIds) {
86
+ // Helper: read the current Figma selection as a list of node IDs, or null
87
+ // if no selection getter is wired (cloud mode) or selection is empty.
88
+ const readSelection = () => {
89
+ if (!getCurrentSelectedNodeIds)
90
+ return null;
91
+ const ids = getCurrentSelectedNodeIds();
92
+ return ids && ids.length > 0 ? ids : null;
93
+ };
94
+ // -----------------------------------------------------------------------
95
+ // Tool: figma_get_file_versions
96
+ // -----------------------------------------------------------------------
97
+ server.tool("figma_get_file_versions", "List a Figma file's version history with metadata (label, description, author, timestamp). Auto-paginates up to max_versions. By default returns only labeled versions (skips auto-saves). Pass include_autosaves=true to see every saved state. Use the returned pagination.next_cursor to continue paging. Required scope: file_versions:read (OAuth) or 'Versions' Read (PAT).", {
98
+ fileUrl: z
99
+ .string()
100
+ .url()
101
+ .optional()
102
+ .describe("Figma file URL. Uses current URL if omitted."),
103
+ include_autosaves: z
104
+ .boolean()
105
+ .optional()
106
+ .default(false)
107
+ .describe("Include auto-saved versions (those without a label). Default: false."),
108
+ max_versions: z
109
+ .number()
110
+ .int()
111
+ .min(1)
112
+ .max(MAX_VERSIONS_HARD_CAP)
113
+ .optional()
114
+ .default(50)
115
+ .describe("Hard cap on returned versions. Default 50, max 200."),
116
+ cursor: z
117
+ .string()
118
+ .optional()
119
+ .describe("Version ID returned as pagination.next_cursor on a previous call. Pass to continue from where the last call stopped."),
120
+ }, async ({ fileUrl, include_autosaves = false, max_versions = 50, cursor }) => {
121
+ try {
122
+ const url = fileUrl || getCurrentUrl();
123
+ if (!url) {
124
+ return {
125
+ content: [
126
+ {
127
+ type: "text",
128
+ text: JSON.stringify({
129
+ error: "no_file_url",
130
+ message: "No Figma file URL available. Pass the fileUrl parameter or ensure the Desktop Bridge plugin is open in Figma.",
131
+ }),
132
+ },
133
+ ],
134
+ isError: true,
135
+ };
136
+ }
137
+ const fileKey = extractFileKey(url);
138
+ if (!fileKey) {
139
+ return {
140
+ content: [
141
+ {
142
+ type: "text",
143
+ text: JSON.stringify({
144
+ error: "invalid_url",
145
+ message: `Invalid Figma URL: ${url}`,
146
+ }),
147
+ },
148
+ ],
149
+ isError: true,
150
+ };
151
+ }
152
+ const cap = Math.min(Math.max(1, max_versions), MAX_VERSIONS_HARD_CAP);
153
+ logger.info({ fileKey, cap, include_autosaves, cursor }, "Fetching file versions");
154
+ const api = await getFigmaAPI();
155
+ const collected = [];
156
+ let totalFiltered = 0;
157
+ let cursorForNextPage = cursor;
158
+ let figmaSaysMore = true;
159
+ let lastReceivedId = null;
160
+ let pages = 0;
161
+ let apiCalls = 0;
162
+ while (pages < MAX_SCAN_PAGES && figmaSaysMore && collected.length < cap) {
163
+ // Figma's pagination semantics: in a newest-first list, `after=X`
164
+ // returns versions that come AFTER X in list order, i.e. OLDER in time.
165
+ // (Empirically verified — `before=X` returns newer items, which is the
166
+ // opposite of what we want when paging into history.)
167
+ const response = await api.getFileVersions(fileKey, {
168
+ page_size: FIGMA_PAGE_SIZE_MAX,
169
+ after: cursorForNextPage,
170
+ });
171
+ pages++;
172
+ apiCalls++;
173
+ const versions = response.versions || [];
174
+ if (versions.length === 0) {
175
+ figmaSaysMore = false;
176
+ break;
177
+ }
178
+ lastReceivedId = versions[versions.length - 1].id;
179
+ for (const v of versions) {
180
+ const isLabeled = v.label != null && v.label !== "";
181
+ if (!include_autosaves && !isLabeled) {
182
+ totalFiltered++;
183
+ continue;
184
+ }
185
+ if (collected.length >= cap)
186
+ break;
187
+ collected.push({
188
+ id: v.id,
189
+ label: v.label || "",
190
+ description: v.description || "",
191
+ created_at: v.created_at,
192
+ user: v.user,
193
+ is_labeled: isLabeled,
194
+ });
195
+ }
196
+ figmaSaysMore = !!response.pagination?.next_page;
197
+ // Defensive: stop if cursor didn't advance (would otherwise loop forever)
198
+ if (lastReceivedId === cursorForNextPage)
199
+ break;
200
+ cursorForNextPage = lastReceivedId;
201
+ }
202
+ // next_cursor must be the LAST DISPLAYED item, not the last RECEIVED.
203
+ // If the user paged forward with the last-received id, they would skip the
204
+ // items between their last visible row and the page boundary.
205
+ // Edge case: if labeled-only mode collected zero items but Figma has more
206
+ // data to scan, expose lastReceivedId so the caller can keep scanning past
207
+ // the autosave-only stretch.
208
+ const lastCollectedId = collected.length > 0 ? collected[collected.length - 1].id : null;
209
+ const hasMore = collected.length >= cap || figmaSaysMore;
210
+ const nextCursor = hasMore
211
+ ? (lastCollectedId ?? lastReceivedId)
212
+ : null;
213
+ const result = {
214
+ file_key: fileKey,
215
+ versions: collected,
216
+ pagination: {
217
+ has_more: hasMore,
218
+ next_cursor: nextCursor,
219
+ returned: collected.length,
220
+ filtered_out_autosaves: totalFiltered,
221
+ },
222
+ _meta: {
223
+ api_calls_made: apiCalls,
224
+ pages_scanned: pages,
225
+ },
226
+ };
227
+ return {
228
+ content: [
229
+ {
230
+ type: "text",
231
+ text: JSON.stringify(result),
232
+ },
233
+ ],
234
+ };
235
+ }
236
+ catch (error) {
237
+ const message = error instanceof Error ? error.message : String(error);
238
+ logger.error({ error }, "Failed to get file versions");
239
+ const hint = message.includes("403")
240
+ ? " Hint: this endpoint requires the 'file_versions:read' OAuth scope, or the 'Versions' Read permission on a Personal Access Token. Add it at figma.com/developers/api#access-tokens and reissue your token."
241
+ : "";
242
+ return {
243
+ content: [
244
+ {
245
+ type: "text",
246
+ text: JSON.stringify({
247
+ error: "get_file_versions_failed",
248
+ message: message + hint,
249
+ }),
250
+ },
251
+ ],
252
+ isError: true,
253
+ };
254
+ }
255
+ });
256
+ // -----------------------------------------------------------------------
257
+ // Tool: figma_get_file_at_version
258
+ // -----------------------------------------------------------------------
259
+ server.tool("figma_get_file_at_version", "Fetch a Figma file (or specific nodes) as it existed at a past version_id. Thin snapshot tool — same shape as figma_get_file_data but bound to a historical version. Use figma_get_file_versions to discover version IDs. Combine with depth and node_ids to keep payloads small. Required scope: file_content:read (already standard).", {
260
+ fileUrl: z
261
+ .string()
262
+ .url()
263
+ .optional()
264
+ .describe("Figma file URL. Uses current URL if omitted."),
265
+ version_id: z
266
+ .string()
267
+ .describe("The version ID to snapshot (from figma_get_file_versions)."),
268
+ node_ids: z
269
+ .array(z.string())
270
+ .optional()
271
+ .describe("Optional: snapshot only these node IDs instead of the full file. Reduces payload significantly for targeted inspection."),
272
+ depth: z
273
+ .number()
274
+ .int()
275
+ .min(1)
276
+ .max(10)
277
+ .optional()
278
+ .describe("How deep into the document tree to recurse. Lower is cheaper. Default: full depth (no limit)."),
279
+ }, async ({ fileUrl, version_id, node_ids, depth }) => {
280
+ try {
281
+ const url = fileUrl || getCurrentUrl();
282
+ if (!url) {
283
+ return {
284
+ content: [
285
+ {
286
+ type: "text",
287
+ text: JSON.stringify({
288
+ error: "no_file_url",
289
+ message: "No Figma file URL available. Pass the fileUrl parameter or ensure the Desktop Bridge plugin is open in Figma.",
290
+ }),
291
+ },
292
+ ],
293
+ isError: true,
294
+ };
295
+ }
296
+ const fileKey = extractFileKey(url);
297
+ if (!fileKey) {
298
+ return {
299
+ content: [
300
+ {
301
+ type: "text",
302
+ text: JSON.stringify({
303
+ error: "invalid_url",
304
+ message: `Invalid Figma URL: ${url}`,
305
+ }),
306
+ },
307
+ ],
308
+ isError: true,
309
+ };
310
+ }
311
+ logger.info({ fileKey, version_id, node_ids, depth }, "Snapshotting file at version");
312
+ const api = await getFigmaAPI();
313
+ const fileData = node_ids && node_ids.length > 0
314
+ ? await api.getNodes(fileKey, node_ids, { version: version_id, depth })
315
+ : await api.getFile(fileKey, { version: version_id, depth });
316
+ const result = {
317
+ _version: {
318
+ id: version_id,
319
+ fetched_at: new Date().toISOString(),
320
+ fileKey,
321
+ scope: node_ids && node_ids.length > 0 ? "nodes" : "file",
322
+ },
323
+ ...fileData,
324
+ };
325
+ return {
326
+ content: [
327
+ {
328
+ type: "text",
329
+ text: JSON.stringify(result),
330
+ },
331
+ ],
332
+ };
333
+ }
334
+ catch (error) {
335
+ const message = error instanceof Error ? error.message : String(error);
336
+ logger.error({ error }, "Failed to snapshot file at version");
337
+ const hint = message.includes("404")
338
+ ? " Hint: the version_id may have been pruned by Figma's plan-tier retention policy, or it may not belong to this file. Use figma_get_file_versions to list valid version IDs."
339
+ : "";
340
+ return {
341
+ content: [
342
+ {
343
+ type: "text",
344
+ text: JSON.stringify({
345
+ error: "get_file_at_version_failed",
346
+ message: message + hint,
347
+ }),
348
+ },
349
+ ],
350
+ isError: true,
351
+ };
352
+ }
353
+ });
354
+ // Core diff computation. Returns the structured result so other tools
355
+ // (changelog, blame, etc.) can compose without re-parsing JSON. The thin
356
+ // MCP-wrapper handlers below format this for tool responses.
357
+ const computeDiff = async (args) => {
358
+ const { fileUrl, from_version, to_version } = args;
359
+ const mode = args.mode ?? "standard";
360
+ // Selection fallback: if caller didn't pass component_ids, use the
361
+ // current Figma selection (if any). Empty array is treated as "no
362
+ // scope" (intentional opt-out), undefined triggers the fallback.
363
+ let component_ids = args.component_ids;
364
+ let usedSelection = false;
365
+ if (component_ids === undefined) {
366
+ const selectedIds = readSelection();
367
+ if (selectedIds) {
368
+ component_ids = selectedIds;
369
+ usedSelection = true;
370
+ }
371
+ }
372
+ try {
373
+ const url = fileUrl || getCurrentUrl();
374
+ if (!url) {
375
+ return {
376
+ ok: false,
377
+ error: "no_file_url",
378
+ message: "No Figma file URL available. Pass the fileUrl parameter or ensure the Desktop Bridge plugin is open in Figma.",
379
+ };
380
+ }
381
+ const fileKey = extractFileKey(url);
382
+ if (!fileKey) {
383
+ return { ok: false, error: "invalid_url", message: `Invalid Figma URL: ${url}` };
384
+ }
385
+ if (from_version === to_version) {
386
+ return {
387
+ ok: false,
388
+ error: "same_version",
389
+ message: "from_version and to_version are identical — nothing to diff.",
390
+ };
391
+ }
392
+ logger.info({ fileKey, from_version, to_version, mode, scoped: !!component_ids?.length }, "Diffing versions");
393
+ const api = await getFigmaAPI();
394
+ // Phase A: cheap orientation, parallel fetch
395
+ let apiCalls = 0;
396
+ let cacheHits = 0;
397
+ const [fromFile, toFile] = await Promise.all([
398
+ fetchDocumentAtVersion(api, fileKey, from_version),
399
+ fetchDocumentAtVersion(api, fileKey, to_version),
400
+ ]);
401
+ for (const r of [fromFile, toFile]) {
402
+ if (r.cached)
403
+ cacheHits++;
404
+ else
405
+ apiCalls++;
406
+ }
407
+ const pageDiff = diffPageStructure(fromFile.data.document, toFile.data.document);
408
+ // Phase B: scoped node diffs (only if user provided component_ids)
409
+ const scoped = [];
410
+ const fetchErrors = [];
411
+ if (component_ids && component_ids.length > 0) {
412
+ for (const nodeId of component_ids) {
413
+ try {
414
+ const [fromResp, toResp] = await Promise.all([
415
+ fetchNodeAtVersion(api, fileKey, nodeId, from_version),
416
+ fetchNodeAtVersion(api, fileKey, nodeId, to_version),
417
+ ]);
418
+ for (const r of [fromResp, toResp]) {
419
+ if (r.cached)
420
+ cacheHits++;
421
+ else
422
+ apiCalls++;
423
+ }
424
+ const fromNode = fromResp.data?.nodes?.[nodeId]?.document ?? null;
425
+ const toNode = toResp.data?.nodes?.[nodeId]?.document ?? null;
426
+ scoped.push(diffNode(fromNode, toNode, mode));
427
+ }
428
+ catch (e) {
429
+ fetchErrors.push({
430
+ node_id: nodeId,
431
+ error: e instanceof Error ? e.message : String(e),
432
+ });
433
+ }
434
+ }
435
+ }
436
+ const fromMeta = extractFileMeta(fromFile.data, from_version);
437
+ const toMeta = extractFileMeta(toFile.data, to_version);
438
+ const notes = [];
439
+ if (!component_ids || component_ids.length === 0) {
440
+ notes.push("Only page-structure diff returned. Pass component_ids to get per-component analysis (added/removed children, property changes, binding changes), or have a node selected in Figma when you call.");
441
+ }
442
+ if (usedSelection) {
443
+ notes.push(`Auto-scoped to ${component_ids?.length ?? 0} node(s) from the current Figma selection. Pass component_ids explicitly to override.`);
444
+ }
445
+ notes.push("Variable VALUE history is not retrievable from Figma REST API. Variable definition value changes between these versions are not represented; only binding-reference changes on scoped nodes are detected.");
446
+ if (fetchErrors.length > 0) {
447
+ notes.push(`Failed to fetch ${fetchErrors.length} of ${component_ids?.length ?? 0} requested nodes — see _fetch_errors.`);
448
+ }
449
+ const scopedChanged = scoped.filter((n) => n.change_count > 0).length;
450
+ const data = {
451
+ file_key: fileKey,
452
+ from: fromMeta,
453
+ to: toMeta,
454
+ page_structure: pageDiff,
455
+ scoped_nodes: component_ids && component_ids.length > 0 ? scoped : undefined,
456
+ summary: {
457
+ page_changes: pageDiff.summary.added + pageDiff.summary.removed + pageDiff.summary.renamed,
458
+ scoped_nodes_requested: component_ids?.length ?? 0,
459
+ scoped_nodes_returned: scoped.length,
460
+ scoped_nodes_with_changes: scopedChanged,
461
+ used_selection: usedSelection,
462
+ api_calls_made: apiCalls,
463
+ cache_hits: cacheHits,
464
+ },
465
+ notes,
466
+ _fetch_errors: fetchErrors.length > 0 ? fetchErrors : undefined,
467
+ };
468
+ return {
469
+ ok: true,
470
+ data,
471
+ fileKey,
472
+ fileName: fromFile.data?.name ?? toFile.data?.name ?? "",
473
+ fromFile: fromFile.data,
474
+ toFile: toFile.data,
475
+ usedSelection,
476
+ };
477
+ }
478
+ catch (error) {
479
+ const message = error instanceof Error ? error.message : String(error);
480
+ logger.error({ error }, "Failed to diff versions");
481
+ const hint = message.includes("403")
482
+ ? " Hint: ensure your token has both file_content:read and file_versions:read scopes."
483
+ : message.includes("404")
484
+ ? " Hint: a version_id may have been pruned or may not belong to this file. Use figma_get_file_versions to list valid IDs."
485
+ : "";
486
+ return { ok: false, error: "diff_versions_failed", message: message + hint };
487
+ }
488
+ };
489
+ // Thin wrapper: call computeDiff and format the response for the MCP tool
490
+ // surface. Used by figma_diff_versions and figma_get_changes_since_version.
491
+ const runDiff = async (args) => {
492
+ const result = await computeDiff(args);
493
+ if (!result.ok) {
494
+ return errorResponse(result.error, result.message);
495
+ }
496
+ return {
497
+ content: [{ type: "text", text: JSON.stringify(result.data) }],
498
+ };
499
+ };
500
+ // Author/label/timestamp lookup for specific version IDs. Paginates
501
+ // figma_get_file_versions until both targets are found OR a hard lookback
502
+ // cap is hit. Returns null entries for any versions not found within
503
+ // the cap. Never throws — enrichment is best-effort.
504
+ const findVersionAuthorMetadata = async (api, fileKey, versionIds) => {
505
+ const result = new Map();
506
+ for (const id of versionIds)
507
+ result.set(id, null);
508
+ const wanted = new Set(versionIds.filter((id) => !isCurrentSentinel(id)));
509
+ if (wanted.size === 0)
510
+ return result;
511
+ const MAX_PAGES = 4; // 4 × 50 = 200 versions of lookback
512
+ let cursor;
513
+ for (let page = 0; page < MAX_PAGES && wanted.size > 0; page++) {
514
+ let response;
515
+ try {
516
+ response = await api.getFileVersions(fileKey, {
517
+ page_size: 50,
518
+ after: cursor,
519
+ });
520
+ }
521
+ catch (e) {
522
+ logger.warn({ err: e }, "Author enrichment lookup failed; continuing without it");
523
+ break;
524
+ }
525
+ const versions = response?.versions || [];
526
+ if (versions.length === 0)
527
+ break;
528
+ for (const v of versions) {
529
+ if (wanted.has(v.id)) {
530
+ result.set(v.id, {
531
+ version_id: v.id,
532
+ label: v.label || null,
533
+ created_at: v.created_at || null,
534
+ user_handle: v.user?.handle || null,
535
+ });
536
+ wanted.delete(v.id);
537
+ }
538
+ }
539
+ if (wanted.size === 0)
540
+ break;
541
+ if (!response?.pagination?.next_page)
542
+ break;
543
+ const last = versions[versions.length - 1];
544
+ if (!last?.id || last.id === cursor)
545
+ break;
546
+ cursor = last.id;
547
+ }
548
+ return result;
549
+ };
550
+ // -----------------------------------------------------------------------
551
+ // Tool: figma_diff_versions
552
+ // -----------------------------------------------------------------------
553
+ server.tool("figma_diff_versions", "Diff two versions of a Figma file. Always returns a cheap page-structure diff (added/removed/renamed pages, 2 API calls). Pass component_ids to additionally get per-node deep diffs at depth=2 (added/removed children, name/description changes, componentPropertyDefinitions changes for COMPONENT_SETs, boundVariables deltas) — costs 2 API calls per scoped node. Use 'current' for to_version to diff against HEAD. NOTE: variable VALUE history is not retrievable from Figma REST and is not represented in this diff.", {
554
+ fileUrl: z
555
+ .string()
556
+ .url()
557
+ .optional()
558
+ .describe("Figma file URL. Uses current URL if omitted."),
559
+ from_version: z
560
+ .string()
561
+ .describe("The earlier version_id to compare from. Get from figma_get_file_versions."),
562
+ to_version: z
563
+ .string()
564
+ .describe("The later version_id to compare to. Use 'current' for HEAD."),
565
+ component_ids: z
566
+ .array(z.string())
567
+ .optional()
568
+ .describe("Optional. Node IDs (typically COMPONENT_SETs) to diff in detail. If omitted, falls back to the current Figma selection. If neither is available, only the page-structure diff is returned. Use figma_get_design_system_kit or figma_search_components to discover IDs explicitly."),
569
+ mode: z
570
+ .enum(["summary", "standard", "detailed"])
571
+ .optional()
572
+ .default("standard")
573
+ .describe("Output verbosity. summary=counts only, standard=names+counts (default), detailed=full property/binding details."),
574
+ }, async (args) => runDiff(args));
575
+ // -----------------------------------------------------------------------
576
+ // Tool: figma_get_changes_since_version
577
+ // -----------------------------------------------------------------------
578
+ server.tool("figma_get_changes_since_version", "Convenience wrapper for figma_diff_versions: compares a given version against the current HEAD. Same output shape as figma_diff_versions, with to_version implicitly 'current'. Useful for 'what's changed since the last code-sync' workflows.", {
579
+ fileUrl: z
580
+ .string()
581
+ .url()
582
+ .optional()
583
+ .describe("Figma file URL. Uses current URL if omitted."),
584
+ since_version: z
585
+ .string()
586
+ .describe("The version_id to compare against the current HEAD."),
587
+ component_ids: z
588
+ .array(z.string())
589
+ .optional()
590
+ .describe("Optional. Node IDs to diff in detail. If omitted, falls back to the current Figma selection. Same semantics as figma_diff_versions otherwise."),
591
+ mode: z
592
+ .enum(["summary", "standard", "detailed"])
593
+ .optional()
594
+ .default("standard"),
595
+ }, async ({ fileUrl, since_version, component_ids, mode }) => runDiff({
596
+ fileUrl,
597
+ from_version: since_version,
598
+ to_version: CURRENT_VERSION_SENTINEL,
599
+ component_ids,
600
+ mode: mode,
601
+ }));
602
+ // -----------------------------------------------------------------------
603
+ // Tool: figma_generate_changelog
604
+ // -----------------------------------------------------------------------
605
+ server.tool("figma_generate_changelog", "Generate a human-readable markdown changelog between two versions. Wraps figma_diff_versions and enriches the output with author labels and timestamps via figma_get_file_versions lookback (one extra cheap API call). Returns BOTH a `markdown` string (paste into release notes / PRs / Storybook MDX) and the structured diff data. Same component_ids and mode semantics as figma_diff_versions. Use 'current' for to_version to changelog against HEAD.", {
606
+ fileUrl: z
607
+ .string()
608
+ .url()
609
+ .optional()
610
+ .describe("Figma file URL. Uses current URL if omitted."),
611
+ from_version: z
612
+ .string()
613
+ .describe("The earlier version_id. Get from figma_get_file_versions."),
614
+ to_version: z
615
+ .string()
616
+ .describe("The later version_id. Use 'current' for HEAD."),
617
+ component_ids: z
618
+ .array(z.string())
619
+ .optional()
620
+ .describe("Optional. Node IDs to include in the per-component changelog section. If omitted, falls back to the current Figma selection."),
621
+ mode: z
622
+ .enum(["summary", "standard", "detailed"])
623
+ .optional()
624
+ .default("standard")
625
+ .describe("Output verbosity. summary=one-liner, standard=sectioned with counts (default), detailed=full per-property/per-binding bullets."),
626
+ }, async ({ fileUrl, from_version, to_version, component_ids, mode }) => {
627
+ const effectiveMode = mode ?? "standard";
628
+ try {
629
+ const result = await computeDiff({
630
+ fileUrl,
631
+ from_version,
632
+ to_version,
633
+ component_ids,
634
+ mode: effectiveMode,
635
+ });
636
+ if (!result.ok) {
637
+ return errorResponse(result.error, result.message);
638
+ }
639
+ // Best-effort author enrichment. If lookup fails or comes up empty,
640
+ // the formatter degrades gracefully.
641
+ const api = await getFigmaAPI();
642
+ const idsToLookup = [from_version, to_version].filter((id) => !isCurrentSentinel(id));
643
+ const authorMap = idsToLookup.length > 0
644
+ ? await findVersionAuthorMetadata(api, result.fileKey, idsToLookup)
645
+ : new Map();
646
+ let fromMeta = isCurrentSentinel(from_version)
647
+ ? buildHeadMeta(result.fromFile)
648
+ : authorMap.get(from_version) ?? null;
649
+ let toMeta = isCurrentSentinel(to_version)
650
+ ? buildHeadMeta(result.toFile)
651
+ : authorMap.get(to_version) ?? null;
652
+ const markdown = formatChangelogMarkdown({
653
+ file_key: result.fileKey,
654
+ file_name: result.fileName || null,
655
+ from_version_id: from_version,
656
+ to_version_id: to_version,
657
+ from_meta: fromMeta,
658
+ to_meta: toMeta,
659
+ page_structure: result.data.page_structure,
660
+ scoped_nodes: result.data.scoped_nodes,
661
+ notes: result.data.notes,
662
+ }, effectiveMode);
663
+ const response = {
664
+ markdown,
665
+ structured: result.data,
666
+ _meta: {
667
+ authors_enriched: idsToLookup.length > 0,
668
+ from_author_found: !!fromMeta && !fromMeta.is_head,
669
+ to_author_found: !!toMeta && !toMeta.is_head,
670
+ },
671
+ };
672
+ return {
673
+ content: [{ type: "text", text: JSON.stringify(response) }],
674
+ };
675
+ }
676
+ catch (error) {
677
+ const message = error instanceof Error ? error.message : String(error);
678
+ logger.error({ error }, "Failed to generate changelog");
679
+ return errorResponse("generate_changelog_failed", message);
680
+ }
681
+ });
682
+ // -----------------------------------------------------------------------
683
+ // Tool: figma_blame_node
684
+ // -----------------------------------------------------------------------
685
+ server.tool("figma_blame_node", "Find the version that introduced a specific change to a node — answers 'who/when added this'. Walks version history backward via binary search (~log2(N) API calls instead of N) to localize the introduction point. Returns the version's metadata (label/author/timestamp). Default includes autosaves for finer attribution; system 'Figma' user appears occasionally for scheduled snapshots and is flagged via attribution_certainty='system_attributed'. Specify EXACTLY ONE of target_component_property or target_child_node_id. node_id is optional — if omitted, falls back to the first node in the current Figma selection.", {
686
+ fileUrl: z
687
+ .string()
688
+ .url()
689
+ .optional()
690
+ .describe("Figma file URL. Uses current URL if omitted."),
691
+ node_id: z
692
+ .string()
693
+ .optional()
694
+ .describe("The parent node ID to inspect (typically a COMPONENT_SET). If omitted, falls back to the first node in the current Figma selection."),
695
+ target_component_property: z
696
+ .string()
697
+ .optional()
698
+ .describe("A componentPropertyDefinitions key (e.g. 'Disabled#1:2') — find when this property was first added to the node."),
699
+ target_child_node_id: z
700
+ .string()
701
+ .optional()
702
+ .describe("A descendant node ID — find when this child was first added under node_id."),
703
+ start_version: z
704
+ .string()
705
+ .optional()
706
+ .default("current")
707
+ .describe("Version to walk backward from. Default 'current' (HEAD)."),
708
+ max_versions_to_walk: z
709
+ .number()
710
+ .int()
711
+ .min(2)
712
+ .max(500)
713
+ .optional()
714
+ .default(200)
715
+ .describe("Lookback cap. Binary search probes ~log2(N) of these. Default 200, max 500."),
716
+ include_autosaves: z
717
+ .boolean()
718
+ .optional()
719
+ .default(true)
720
+ .describe("Include auto-saved versions in the search range. Default true (better attribution accuracy; most autosaves carry the real human user)."),
721
+ }, async ({ fileUrl, node_id, target_component_property, target_child_node_id, start_version, max_versions_to_walk, include_autosaves, }) => {
722
+ const lookback = max_versions_to_walk ?? 200;
723
+ const includeAuto = include_autosaves ?? true;
724
+ const startVer = start_version ?? CURRENT_VERSION_SENTINEL;
725
+ try {
726
+ // Selection fallback: if node_id is omitted, use the first selected node.
727
+ let resolvedNodeId = node_id;
728
+ let usedSelection = false;
729
+ if (!resolvedNodeId) {
730
+ const selectedIds = readSelection();
731
+ if (selectedIds && selectedIds.length > 0) {
732
+ resolvedNodeId = selectedIds[0];
733
+ usedSelection = true;
734
+ }
735
+ else {
736
+ return errorResponse("no_node_id", "No node_id provided and no node is currently selected in Figma. Pass node_id explicitly or select a node first.");
737
+ }
738
+ }
739
+ // Validate exactly one target type
740
+ const targets = [target_component_property, target_child_node_id].filter((t) => t !== undefined && t !== null);
741
+ if (targets.length !== 1) {
742
+ return errorResponse("invalid_target", "Specify exactly one of target_component_property or target_child_node_id.");
743
+ }
744
+ const url = fileUrl || getCurrentUrl();
745
+ if (!url) {
746
+ return errorResponse("no_file_url", "No Figma file URL available. Pass the fileUrl parameter or ensure the Desktop Bridge plugin is open in Figma.");
747
+ }
748
+ const fileKey = extractFileKey(url);
749
+ if (!fileKey) {
750
+ return errorResponse("invalid_url", `Invalid Figma URL: ${url}`);
751
+ }
752
+ logger.info({
753
+ fileKey,
754
+ node_id: resolvedNodeId,
755
+ usedSelection,
756
+ target_component_property,
757
+ target_child_node_id,
758
+ startVer,
759
+ lookback,
760
+ includeAuto,
761
+ }, "Blaming node");
762
+ const api = await getFigmaAPI();
763
+ let apiCalls = 0;
764
+ let cacheHits = 0;
765
+ // Step 1: Confirm target exists at start_version
766
+ const startResp = await fetchNodeAtVersion(api, fileKey, resolvedNodeId, startVer);
767
+ if (startResp.cached)
768
+ cacheHits++;
769
+ else
770
+ apiCalls++;
771
+ const startNode = startResp.data?.nodes?.[resolvedNodeId]?.document ?? null;
772
+ if (!startNode) {
773
+ return errorResponse("node_not_at_start", `Node ${resolvedNodeId} not found at start_version. Verify the node_id and start_version.`);
774
+ }
775
+ if (!targetExists(startNode, { target_component_property, target_child_node_id })) {
776
+ return errorResponse("target_not_at_start", `Target was not found at start_version. The blame walker requires the target to exist at start_version (you're asking 'when was this introduced'). If you're tracking something that was REMOVED, you want to look in the opposite direction — pick a start_version where it still existed.`);
777
+ }
778
+ // Step 2: Build the candidate version list — versions strictly OLDER than start.
779
+ // Use the file's resolved version id (not the 'current' sentinel) so the
780
+ // collector can correctly skip past start to begin collecting older versions.
781
+ const resolvedStartVer = isCurrentSentinel(startVer)
782
+ ? startResp.data?.version || startVer
783
+ : startVer;
784
+ const candidates = await collectCandidateVersions(api, fileKey, resolvedStartVer, lookback, includeAuto);
785
+ apiCalls += candidates.apiCalls;
786
+ cacheHits += candidates.cacheHits;
787
+ const versions = candidates.versions; // newest-first, all OLDER than start
788
+ // Step 3: Binary search for the LARGEST index (oldest version) where target exists.
789
+ // Existence is assumed monotonic: if target exists at an OLDER version (larger
790
+ // index), it must also exist at all NEWER versions up to start. We search for
791
+ // the OLDEST version that still has the target — that's the introduction point.
792
+ // Empty range is fine (handled below).
793
+ let lo = 0;
794
+ let hi = versions.length - 1;
795
+ const probedExists = new Map();
796
+ let oldestExistsIdx = -1;
797
+ while (lo <= hi) {
798
+ const mid = Math.floor((lo + hi) / 2);
799
+ const midVer = versions[mid].id;
800
+ const resp = await fetchNodeAtVersion(api, fileKey, resolvedNodeId, midVer);
801
+ if (resp.cached)
802
+ cacheHits++;
803
+ else
804
+ apiCalls++;
805
+ const midNode = resp.data?.nodes?.[resolvedNodeId]?.document ?? null;
806
+ const exists = midNode
807
+ ? targetExists(midNode, {
808
+ target_component_property,
809
+ target_child_node_id,
810
+ })
811
+ : false;
812
+ probedExists.set(mid, exists);
813
+ if (exists) {
814
+ if (mid > oldestExistsIdx)
815
+ oldestExistsIdx = mid;
816
+ lo = mid + 1; // search older
817
+ }
818
+ else {
819
+ hi = mid - 1; // search newer
820
+ }
821
+ }
822
+ // Three outcomes:
823
+ // oldestExistsIdx === -1 -> target introduced AT start_version itself
824
+ // oldestExistsIdx === versions.len-1 -> introduction is OLDER than our lookback
825
+ // otherwise -> oldestExistsIdx is the introduction point
826
+ const notes = [];
827
+ let introducedVersionMeta;
828
+ let certainty;
829
+ if (oldestExistsIdx === -1) {
830
+ // Target was introduced at start_version itself. Look up start's metadata.
831
+ const lookupId = isCurrentSentinel(startVer) ? resolvedStartVer : startVer;
832
+ const authorMap = await findVersionAuthorMetadata(api, fileKey, [lookupId]);
833
+ apiCalls += 1; // helper makes 1-4 paginated calls; conservative under-count
834
+ const meta = authorMap.get(lookupId);
835
+ introducedVersionMeta = {
836
+ version_id: lookupId,
837
+ label: meta?.label ?? null,
838
+ created_at: meta?.created_at ?? startResp.data?.lastModified ?? null,
839
+ user_handle: meta?.user_handle ?? null,
840
+ is_labeled: !!(meta?.label && meta.label !== ""),
841
+ };
842
+ certainty =
843
+ introducedVersionMeta.user_handle === "Figma"
844
+ ? "system_attributed"
845
+ : introducedVersionMeta.user_handle
846
+ ? "exact"
847
+ : "metadata_unavailable";
848
+ if (certainty === "metadata_unavailable") {
849
+ notes.push("Target was introduced at start_version itself, but author metadata for that version was not found within the version-list lookback. The introduction is real; the user is just not attributable from REST data alone.");
850
+ }
851
+ }
852
+ else {
853
+ const introducedVersion = versions[oldestExistsIdx];
854
+ introducedVersionMeta = {
855
+ version_id: introducedVersion.id,
856
+ label: introducedVersion.label || null,
857
+ created_at: introducedVersion.created_at,
858
+ user_handle: introducedVersion.user?.handle ?? null,
859
+ is_labeled: !!(introducedVersion.label && introducedVersion.label !== ""),
860
+ };
861
+ if (oldestExistsIdx === versions.length - 1) {
862
+ certainty = "exists_at_lookback_horizon";
863
+ notes.push(`Target also exists at the oldest scanned version (${introducedVersion.id}). The actual introduction is older than the search range. Increase max_versions_to_walk (currently ${lookback}) to keep searching.`);
864
+ }
865
+ else if (introducedVersionMeta.user_handle === "Figma") {
866
+ certainty = "system_attributed";
867
+ notes.push("The introduction version was a system-triggered autosave (user='Figma'). For a human author, set include_autosaves=false and re-run — that finds the nearest LABELED version that includes the change.");
868
+ }
869
+ else {
870
+ certainty = "exact";
871
+ }
872
+ }
873
+ notes.push("Binary search assumes the target's existence is monotonic (added once, never removed). If the target was added, removed, then re-added, this tool may report a different introduction point than the original.");
874
+ if (usedSelection) {
875
+ notes.push(`Auto-scoped to node ${resolvedNodeId} from the current Figma selection. Pass node_id explicitly to override.`);
876
+ }
877
+ const result = {
878
+ file_key: fileKey,
879
+ node_id: resolvedNodeId,
880
+ target: target_component_property
881
+ ? { type: "component_property", name: target_component_property }
882
+ : { type: "child_node", node_id: target_child_node_id },
883
+ introduced_at: introducedVersionMeta,
884
+ attribution_certainty: certainty,
885
+ summary: {
886
+ versions_in_search_range: versions.length,
887
+ probes_made: probedExists.size,
888
+ used_selection: usedSelection,
889
+ api_calls_made: apiCalls,
890
+ cache_hits: cacheHits,
891
+ },
892
+ notes,
893
+ };
894
+ return {
895
+ content: [{ type: "text", text: JSON.stringify(result) }],
896
+ };
897
+ }
898
+ catch (error) {
899
+ const message = error instanceof Error ? error.message : String(error);
900
+ logger.error({ error }, "Blame walker failed");
901
+ const hint = message.includes("403")
902
+ ? " Hint: ensure your token has both file_content:read and file_versions:read scopes."
903
+ : message.includes("404")
904
+ ? " Hint: a node or version may not exist. Verify node_id and start_version."
905
+ : "";
906
+ return errorResponse("blame_node_failed", message + hint);
907
+ }
908
+ });
909
+ // Build the candidate version list for binary search. Returns versions
910
+ // strictly OLDER than start_version (so the search range doesn't include
911
+ // the version we already confirmed at), capped at lookback. Newest-first.
912
+ const collectCandidateVersions = async (api, fileKey, startVer, lookback, includeAutosaves) => {
913
+ const collected = [];
914
+ let cursor;
915
+ let apiCalls = 0;
916
+ const cacheHits = 0; // version-list pagination is not snapshot-cached
917
+ // Once we hit start_version's id in the list, switch to "collecting older" mode
918
+ let foundStart = isCurrentSentinel(startVer);
919
+ const MAX_PAGES = 10; // 10 × 50 = 500 versions hard cap on scan
920
+ for (let page = 0; page < MAX_PAGES && collected.length < lookback; page++) {
921
+ let response;
922
+ try {
923
+ response = await api.getFileVersions(fileKey, {
924
+ page_size: 50,
925
+ after: cursor,
926
+ });
927
+ apiCalls++;
928
+ }
929
+ catch (e) {
930
+ logger.warn({ err: e }, "Version list fetch failed during blame walk");
931
+ break;
932
+ }
933
+ const versions = response?.versions || [];
934
+ if (versions.length === 0)
935
+ break;
936
+ for (const v of versions) {
937
+ if (!foundStart) {
938
+ if (v.id === startVer)
939
+ foundStart = true;
940
+ continue;
941
+ }
942
+ const isLabeled = v.label && v.label !== "";
943
+ if (!includeAutosaves && !isLabeled)
944
+ continue;
945
+ collected.push(v);
946
+ if (collected.length >= lookback)
947
+ break;
948
+ }
949
+ if (collected.length >= lookback)
950
+ break;
951
+ if (!response?.pagination?.next_page)
952
+ break;
953
+ const last = versions[versions.length - 1];
954
+ if (!last?.id || last.id === cursor)
955
+ break;
956
+ cursor = last.id;
957
+ }
958
+ return { versions: collected, apiCalls, cacheHits };
959
+ };
960
+ }
961
+ // Returns true if the target is present in the given node tree.
962
+ function targetExists(node, target) {
963
+ if (target.target_component_property) {
964
+ return !!node?.componentPropertyDefinitions?.[target.target_component_property];
965
+ }
966
+ if (target.target_child_node_id) {
967
+ return findChildById(node, target.target_child_node_id);
968
+ }
969
+ return false;
970
+ }
971
+ function findChildById(node, targetId) {
972
+ if (!node)
973
+ return false;
974
+ if (node.id === targetId)
975
+ return true;
976
+ if (Array.isArray(node.children)) {
977
+ for (const c of node.children) {
978
+ if (findChildById(c, targetId))
979
+ return true;
980
+ }
981
+ }
982
+ return false;
983
+ }
984
+ function buildHeadMeta(fileData) {
985
+ return {
986
+ version_id: fileData?.version ?? "current",
987
+ label: null,
988
+ created_at: fileData?.lastModified ?? null,
989
+ user_handle: null,
990
+ is_head: true,
991
+ };
992
+ }
993
+ // ============================================================================
994
+ // Helpers
995
+ // ============================================================================
996
+ function errorResponse(code, message) {
997
+ return {
998
+ content: [
999
+ {
1000
+ type: "text",
1001
+ text: JSON.stringify({ error: code, message }),
1002
+ },
1003
+ ],
1004
+ isError: true,
1005
+ };
1006
+ }
1007
+ function extractFileMeta(fileData, requestedVersionId) {
1008
+ return {
1009
+ version_id: requestedVersionId,
1010
+ resolved_version_id: fileData?.version ?? null,
1011
+ last_modified: fileData?.lastModified ?? null,
1012
+ thumbnail_url: fileData?.thumbnailUrl ?? null,
1013
+ };
1014
+ }
1015
+ //# sourceMappingURL=version-tools.js.map