figma-console-mcp 1.40.0 → 1.40.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -25,8 +25,8 @@
25
25
  * its source/generated/modes/conflictResolution settings as defaults. They
26
26
  * stay zero-arg in normal use.
27
27
  */
28
- import { writeFileSync, mkdirSync, existsSync, readFileSync, readdirSync, } from "node:fs";
29
- import { dirname, isAbsolute, join, resolve } from "node:path";
28
+ import { writeFileSync, mkdirSync, existsSync, readFileSync, readdirSync, statSync, } from "node:fs";
29
+ import { basename, dirname, extname, isAbsolute, join, resolve, } from "node:path";
30
30
  import { createChildLogger } from "./logger.js";
31
31
  import { buildTokenLookup, canonicalizeTokenValueForComparison, clamp01, convertFigmaVariablesToDocument, ExportTokensInputSchema, ImportTokensInputSchema, format as formatTokenDocument, hexToRawRgba, loadTokensConfig, parse as parseTokenPayload, resolveOutputTargets, stripRawColorFromValues, } from "./tokens/index.js";
32
32
  const logger = createChildLogger({ component: "tokens-tools" });
@@ -35,7 +35,7 @@ const logger = createChildLogger({ component: "tokens-tools" });
35
35
  * on every exported token document. Kept in sync with package.json by
36
36
  * scripts/release.sh — see step 3 of the release flow.
37
37
  */
38
- const MCP_VERSION = "1.40.0";
38
+ const MCP_VERSION = "1.40.2";
39
39
  const EXPORT_TOOL_DESCRIPTION = `Export Figma variables to design token files in your codebase. Bidirectional with figma_import_tokens — together they replace Style Dictionary and Tokens Studio's export pipeline for the popular styling methods.
40
40
 
41
41
  FULLY-IMPLEMENTED OUTPUT FORMATS:
@@ -52,7 +52,7 @@ FULLY-IMPLEMENTED OUTPUT FORMATS:
52
52
 
53
53
  ZERO-ARG USAGE: With a tokens.config.json at your project root, just call the tool with no args — it picks up source dir, output formats, modes, prefix, etc. from config. See the response's \`suggestedScaffold\` payload when no config is detected — present it to the user, write the scaffold via your file tools, then call again.
54
54
 
55
- MERGE STRATEGY: Default \`strategy: "merge"\` only writes tokens that actually changed in Figma since the last sync. Use \`dry-run\` to preview what would change. Use \`replace\` to wipe and rewrite (rare; for resetting drift).
55
+ WRITE SAFETY: Export REPLACES the contents of each target file with the current Figma state — it does not merge into it. Under the default \`strategy: "merge"\` the tool refuses to write (and changes nothing) when overwriting an existing DTCG file would delete tokens it does not manage: tokens added by hand, or tokens from collections outside this export. It also refuses when a requested collectionId is not in the file it read, or when the export is empty — both usually mean a different file is active in Figma than you intended. Variables deleted in Figma are removed from the file as normal. Use \`dry-run\` to preview, \`replace\` to overwrite unconditionally. Generated files record the Figma file they came from (a \`Source: Figma file <key>\` header line; \`figmaFileKey\` in DTCG), and the default strategy also refuses to overwrite a file that was generated from a DIFFERENT Figma file. Plain-JSON formats (json-flat, json-nested, style-dictionary-v3, tokens-studio) cannot carry that record — pin \`figmaFile\` in tokens.config.json for a hard guarantee. Every response includes \`source\` (the Figma file name/key the data was read from) — check it.
56
56
 
57
57
  DTCG DIALECT (\`dtcgDialect\`, applies to dtcg/json-flat/json-nested outputs): legacy (default): hex-string colors, maximum compatibility (Style Dictionary v4, Tokens Studio). 2025: DTCG 2025.10 object colors/dimensions (Style Dictionary v5+). figma_import_tokens accepts BOTH dialects regardless of this setting.
58
58
 
@@ -81,7 +81,12 @@ export function registerExportTokensTool(server, getDesktopConnector, opts = {})
81
81
  type: "text",
82
82
  text: JSON.stringify({
83
83
  error: err instanceof Error ? err.message : String(err),
84
- hint: "If this is a TokenFormatNotImplementedError for a non-DTCG/non-CSS format, export to 'dtcg' or 'css-vars' instead — those are the fully-implemented formats. The canonical DTCG JSON can be consumed by Style Dictionary v4 or any other DTCG-aware tooling.",
84
+ // Output-location errors already say what to do; the format hint would only mislead.
85
+ ...(/outputPath|Cannot write|[Nn]othing was written/.test(err instanceof Error ? err.message : String(err))
86
+ ? {}
87
+ : {
88
+ hint: "If this is a TokenFormatNotImplementedError for a non-DTCG/non-CSS format, export to 'dtcg' or 'css-vars' instead — those are the fully-implemented formats. The canonical DTCG JSON can be consumed by Style Dictionary v4 or any other DTCG-aware tooling.",
89
+ }),
85
90
  }),
86
91
  },
87
92
  ],
@@ -166,9 +171,27 @@ async function handleExport(args, getDesktopConnector, opts) {
166
171
  }
167
172
  // 3. Normalize to the converter's expected shape.
168
173
  const payload = normalizeFigmaPayload(variableData);
174
+ const sourceFileKey = (typeof variableData.fileKey === "string" && variableData.fileKey) ||
175
+ fileKey ||
176
+ null;
177
+ // 3b. Identify the Figma file the data ACTUALLY came from. Without a
178
+ // tokens.config.json `figmaFile`, the bridge reads whichever file is
179
+ // active in Figma Desktop — which may not be the one the caller means.
180
+ // (With `figmaFile` pinned, sendCommand routes to that file's plugin
181
+ // connection and rejects if it isn't connected, so no check is needed.)
182
+ const source = {
183
+ fileKey: sourceFileKey,
184
+ fileName: (sourceFileKey && opts.resolveFileName?.(sourceFileKey)) || null,
185
+ };
186
+ // 3c. Every requested collection must exist in that file. A partial or
187
+ // empty match would otherwise be written over the target as if it were
188
+ // the full export.
189
+ assertRequestedCollectionsFound(args.collectionIds, payload.collections, source);
169
190
  // 4. Convert to canonical TokenDocument.
170
191
  const { document, warnings } = convertFigmaVariablesToDocument(payload, {
171
- figmaFileKey: fileKey,
192
+ // The file the data was ACTUALLY read from — stamped into DTCG output so a
193
+ // later export from a different Figma file can be recognized and refused.
194
+ figmaFileKey: sourceFileKey ?? undefined,
172
195
  collectionIds: args.collectionIds,
173
196
  modes: args.modes,
174
197
  stripPrefix: args.prefix,
@@ -179,6 +202,10 @@ async function handleExport(args, getDesktopConnector, opts) {
179
202
  // 6. Format the document for each target.
180
203
  const allFiles = [];
181
204
  const allWarnings = [...warnings];
205
+ if (args.scope === "collection" &&
206
+ !(Array.isArray(args.collectionIds) && args.collectionIds.length > 0)) {
207
+ allWarnings.push("scope is 'collection' but no collectionIds were passed — every collection was exported. Pass collectionIds (see figma_get_variables) to narrow the export.");
208
+ }
182
209
  for (const target of targets) {
183
210
  try {
184
211
  const result = formatTokenDocument(document, {
@@ -198,7 +225,12 @@ async function handleExport(args, getDesktopConnector, opts) {
198
225
  projectRoot: loaded?.projectRoot,
199
226
  });
200
227
  for (const file of result.files) {
201
- allFiles.push({ format: target.format, ...file });
228
+ allFiles.push({
229
+ format: target.format,
230
+ ...file,
231
+ content: stampSourceFile(file.content, sourceFileKey),
232
+ splitByCollection: !!(args.splitByCollection ?? target.splitByCollection),
233
+ });
202
234
  }
203
235
  allWarnings.push(...result.warnings);
204
236
  }
@@ -211,19 +243,94 @@ async function handleExport(args, getDesktopConnector, opts) {
211
243
  // 7. If outputPath is set, write to disk. Otherwise return inline.
212
244
  // Output routing: canonical-format files (matching config.source.canonical)
213
245
  // go to source.dir; everything else goes to generated.dir.
246
+ //
247
+ // The write plan is computed (and validated) up front — for dry-runs too —
248
+ // so a bad outputPath fails with an actionable message before any file is
249
+ // written, instead of surfacing a raw EEXIST/ENOTDIR half-way through.
214
250
  const dryRun = args.strategy === "dry-run";
215
251
  const writtenPaths = [];
252
+ const plan = planExportWrites(allFiles, args.outputPath, loaded);
253
+ allWarnings.push(...plan.warnings);
254
+ // Validate EVERY planned write before performing ANY of them — a refused
255
+ // export must leave the disk exactly as it found it.
256
+ const totalTokens = document.sets.reduce((n, s) => n + s.tokens.length, 0);
257
+ if (totalTokens === 0) {
258
+ if (plan.writes.length > 0) {
259
+ throw new Error(`[figma-console-mcp] The export contains 0 tokens (read from ${describeSource(source)}), so nothing was written — writing it would have emptied ${plan.writes.length === 1 ? `"${plan.writes[0].fullPath}"` : `${plan.writes.length} files`}. Check that the right file is active in Figma and that the collectionIds/modes filters match it.`);
260
+ }
261
+ allWarnings.push(`The export contains 0 tokens (read from ${describeSource(source)}). Check that the right file is active in Figma and that the collectionIds/modes filters match it.`);
262
+ }
263
+ if (args.strategy !== "replace") {
264
+ const allExportedCollectionIds = document.sets
265
+ .map((s) => s.meta?.figmaCollectionId)
266
+ .filter((id) => typeof id === "string");
267
+ // Exported collections that ended up with no tokens appear in no file's
268
+ // content — yet a token stamped with one that vanishes was still
269
+ // legitimately deleted in Figma.
270
+ const emptyExportedCollectionIds = document.sets
271
+ .filter((s) => s.tokens.length === 0)
272
+ .map((s) => s.meta?.figmaCollectionId)
273
+ .filter((id) => typeof id === "string");
274
+ for (const write of plan.writes) {
275
+ if (!existsSync(write.fullPath))
276
+ continue;
277
+ let existing;
278
+ try {
279
+ existing = readFileSync(write.fullPath, "utf-8");
280
+ }
281
+ catch (err) {
282
+ // Unreadable target: we cannot prove the overwrite is safe, so don't.
283
+ throw new Error(`[figma-console-mcp] Nothing was written. "${write.fullPath}" exists but could not be read to check what overwriting it would destroy (${err instanceof Error ? err.message : String(err)}). Fix the file's permissions, or pass strategy: "replace" to overwrite it unconditionally.`);
284
+ }
285
+ // (a) Any format: was this file generated from a DIFFERENT Figma file?
286
+ const stampedKey = readSourceFileStamp(existing);
287
+ if (stampedKey && !sourceFileKey) {
288
+ // The target records its source, but this run can't say where ITS data
289
+ // came from — we cannot prove they match, so don't overwrite.
290
+ throw new Error(`[figma-console-mcp] Nothing was written. "${write.fullPath}" was generated from Figma file ${stampedKey}, but the Desktop Bridge plugin did not report which file this export read from, so it can't be confirmed to be the same one. Re-import the plugin manifest in Figma (the bundled manifest enables file-key reporting) and retry, or pass strategy: "replace" to overwrite anyway.`);
291
+ }
292
+ if (stampedKey && sourceFileKey && stampedKey !== sourceFileKey) {
293
+ throw new Error(`[figma-console-mcp] Nothing was written. "${write.fullPath}" was generated from Figma file ${stampedKey}, but this export read from ${describeSource(source)} — most likely a different file/tab is active in Figma than you intended. Switch to the right file (or use figma_navigate) and retry. If you really mean to re-source this file, pass strategy: "replace".`);
294
+ }
295
+ // (b) DTCG: would the overwrite destroy tokens this export doesn't manage?
296
+ // A DTCG target we cannot parse (merge-conflict markers, a truncated
297
+ // write) can't be checked — refuse rather than bulldoze it.
298
+ if (write.format === "dtcg" &&
299
+ stripBom(existing).trim() !== "" &&
300
+ !isParsableJson(existing)) {
301
+ throw new Error(`[figma-console-mcp] Nothing was written. "${write.fullPath}" exists but is not valid JSON (unresolved merge conflict? partial write?), so what overwriting it would destroy can't be checked. Fix the file, or pass strategy: "replace" to overwrite it unconditionally.`);
302
+ }
303
+ // A DTCG token file must never be replaced by a DIFFERENT kind of
304
+ // output (css-vars aimed at typography.tokens.json): the new side
305
+ // isn't JSON, so the token comparison below would see nothing lost.
306
+ if (dtcgTokenCount(existing) > 0 && !isParsableJson(write.content)) {
307
+ throw new Error(`[figma-console-mcp] Nothing was written. "${write.fullPath}" is a DTCG token file (${dtcgTokenCount(existing)} tokens), and this export would replace it with ${write.format} output — every token in it would be lost. Point outputPath at a ${write.format} file instead, or pass strategy: "replace" if you really mean to convert this file.`);
308
+ }
309
+ // Which collections does THIS file speak for? With splitByCollection
310
+ // a file holds one collection, so only that one (a token from another
311
+ // collection pasted in is NOT ours to drop). Otherwise every exported
312
+ // collection feeds the file — including one that has just lost the
313
+ // mode a splitByMode file is for, whose tokens rightly disappear.
314
+ const loss = findUnmanagedTokenLoss(existing, write.content, new Set(write.splitByCollection
315
+ ? [
316
+ ...collectionIdsInDtcg(write.content),
317
+ ...emptyExportedCollectionIds,
318
+ ]
319
+ : allExportedCollectionIds));
320
+ if (loss)
321
+ throw unmanagedLossError(loss, write.fullPath, source);
322
+ }
323
+ }
216
324
  if (!dryRun) {
217
- for (const file of allFiles) {
218
- const base = resolveOutputBaseForFormat(args.outputPath, loaded, file.format);
219
- if (!base)
220
- continue; // No config or outputPath → caller will get content inline.
221
- const fullPath = isAbsolute(file.path)
222
- ? file.path
223
- : join(base, file.path);
224
- mkdirSync(dirname(fullPath), { recursive: true });
225
- writeFileSync(fullPath, file.content, "utf-8");
226
- writtenPaths.push(fullPath);
325
+ for (const write of plan.writes) {
326
+ try {
327
+ mkdirSync(dirname(write.fullPath), { recursive: true });
328
+ writeFileSync(write.fullPath, write.content, "utf-8");
329
+ }
330
+ catch (err) {
331
+ throw describeExportWriteError(err, write.fullPath);
332
+ }
333
+ writtenPaths.push(write.fullPath);
227
334
  }
228
335
  }
229
336
  const outputBase = writtenPaths.length > 0 ? "(multiple)" : null;
@@ -236,6 +343,10 @@ async function handleExport(args, getDesktopConnector, opts) {
236
343
  mode: dryRun ? "dry-run" : outputBase ? "written" : "inline",
237
344
  configFound: !!loaded,
238
345
  configPath: loaded?.configPath ?? null,
346
+ // Which Figma file the variables were read from. Verify this is
347
+ // the file you meant — the bridge reads the ACTIVE file unless
348
+ // tokens.config.json pins `figmaFile`.
349
+ source,
239
350
  collections: document.sets.map((s) => ({
240
351
  name: s.name,
241
352
  modes: s.modes,
@@ -243,11 +354,15 @@ async function handleExport(args, getDesktopConnector, opts) {
243
354
  figmaCollectionId: s.meta?.figmaCollectionId,
244
355
  })),
245
356
  outputs: dryRun
246
- ? allFiles.map((f) => ({
247
- format: f.format,
248
- path: f.path,
249
- preview: f.content.slice(0, 500) + (f.content.length > 500 ? "…" : ""),
250
- }))
357
+ ? allFiles.map((f) => {
358
+ const planned = plan.writes.find((w) => w.format === f.format && w.sourcePath === f.path);
359
+ return {
360
+ format: f.format,
361
+ path: f.path,
362
+ ...(planned ? { wouldWriteTo: planned.fullPath } : {}),
363
+ preview: f.content.slice(0, 500) + (f.content.length > 500 ? "…" : ""),
364
+ };
365
+ })
251
366
  : outputBase
252
367
  ? writtenPaths.map((p) => ({ writtenTo: p }))
253
368
  : allFiles,
@@ -557,6 +672,341 @@ function resolveOutputBaseForFormat(outputPath, loaded, format) {
557
672
  }
558
673
  return null;
559
674
  }
675
+ function describeSource(source) {
676
+ if (source.fileName && source.fileKey)
677
+ return `Figma file "${source.fileName}" (${source.fileKey})`;
678
+ if (source.fileKey)
679
+ return `Figma file ${source.fileKey}`;
680
+ return "the file currently active in Figma";
681
+ }
682
+ /**
683
+ * Throw unless every requested collection id exists in the pulled payload.
684
+ * All-or-nothing on purpose: exporting 1 of 2 requested collections over an
685
+ * existing file is still data loss, just a quieter one.
686
+ */
687
+ export function assertRequestedCollectionsFound(requested, available, source) {
688
+ if (!Array.isArray(requested) || requested.length === 0)
689
+ return;
690
+ const have = new Set(available.map((c) => c.id));
691
+ const missing = requested.filter((id) => !have.has(id));
692
+ if (missing.length === 0)
693
+ return;
694
+ const listed = available
695
+ .slice(0, 12)
696
+ .map((c) => `${c.name ?? "(unnamed)"} [${c.id}]`)
697
+ .join(", ");
698
+ throw new Error(`[figma-console-mcp] ${missing.length} of ${requested.length} requested collection(s) not found in ${describeSource(source)}: ${missing.join(", ")}. Nothing was written. ` +
699
+ "The Desktop Bridge reads from the file that is active in Figma Desktop, so this usually means a different file/tab is focused than the one that owns the collection — switch to it (or use figma_navigate) and retry. " +
700
+ (available.length > 0
701
+ ? `Collections in that file: ${listed}${available.length > 12 ? ", …" : ""}.`
702
+ : "That file has no variable collections."));
703
+ }
704
+ const GENERATED_HEADER = "Generated by figma-console-mcp";
705
+ /** Editors on Windows love a UTF-8 BOM; JSON.parse does not. */
706
+ function stripBom(content) {
707
+ return content.charCodeAt(0) === 0xfeff ? content.slice(1) : content;
708
+ }
709
+ function isParsableJson(content) {
710
+ try {
711
+ JSON.parse(stripBom(content));
712
+ return true;
713
+ }
714
+ catch {
715
+ return false;
716
+ }
717
+ }
718
+ /**
719
+ * Record which Figma file a generated TEXT output came from, as a
720
+ * `Source: Figma file <key>` comment line directly under the formatter's
721
+ * existing "Generated by figma-console-mcp" header — in that header's own
722
+ * comment style, so this works for every current and future text format
723
+ * without touching the formatters. JSON outputs are left alone here: DTCG
724
+ * carries the key in its root `$extensions`, and the plain-JSON formats have
725
+ * nowhere to put a comment.
726
+ */
727
+ export function stampSourceFile(content, sourceFileKey) {
728
+ if (!sourceFileKey)
729
+ return content;
730
+ const lines = content.split("\n");
731
+ const idx = lines
732
+ .slice(0, 6)
733
+ .findIndex((line) => line.includes(GENERATED_HEADER));
734
+ if (idx === -1)
735
+ return content;
736
+ const header = lines[idx];
737
+ const label = `Source: Figma file ${sourceFileKey}`;
738
+ const indent = header.match(/^\s*/)?.[0] ?? "";
739
+ const trimmed = header.trimStart();
740
+ let stamp;
741
+ if (trimmed.startsWith("/*") && trimmed.trimEnd().endsWith("*/")) {
742
+ stamp = `${indent}/* ${label} */`;
743
+ }
744
+ else if (trimmed.startsWith("//")) {
745
+ stamp = `${indent}// ${label}`;
746
+ }
747
+ else if (trimmed.startsWith("*")) {
748
+ stamp = `${indent}* ${label}`;
749
+ }
750
+ else {
751
+ return content; // unknown comment style — never guess at syntax
752
+ }
753
+ lines.splice(idx + 1, 0, stamp);
754
+ return lines.join("\n");
755
+ }
756
+ /**
757
+ * Read back the Figma file key a previously exported file was generated from:
758
+ * DTCG root `$extensions["figma-console-mcp"].figmaFileKey`, or the
759
+ * `Source: Figma file <key>` header line of a text output. Null when the file
760
+ * carries no stamp (exported before stamping existed, or a plain-JSON format).
761
+ */
762
+ export function readSourceFileStamp(rawContent) {
763
+ const content = stripBom(rawContent);
764
+ const head = content.slice(0, 800);
765
+ if (head.includes(GENERATED_HEADER)) {
766
+ const m = head.match(/Source: Figma file ([A-Za-z0-9_-]+)/);
767
+ if (m)
768
+ return m[1];
769
+ }
770
+ if (content.trimStart().startsWith("{")) {
771
+ try {
772
+ const key = JSON.parse(content)?.$extensions?.["figma-console-mcp"]
773
+ ?.figmaFileKey;
774
+ if (typeof key === "string" && key)
775
+ return key;
776
+ }
777
+ catch {
778
+ /* not JSON — no stamp */
779
+ }
780
+ }
781
+ return null;
782
+ }
783
+ /** Number of `$value` leaves in a DTCG document; 0 when it isn't one. */
784
+ function dtcgTokenCount(content) {
785
+ let root;
786
+ try {
787
+ root = JSON.parse(stripBom(content));
788
+ }
789
+ catch {
790
+ return 0;
791
+ }
792
+ let count = 0;
793
+ const walk = (node) => {
794
+ if (!node || typeof node !== "object" || Array.isArray(node))
795
+ return;
796
+ const obj = node;
797
+ if ("$value" in obj) {
798
+ count++;
799
+ return;
800
+ }
801
+ for (const [key, child] of Object.entries(obj)) {
802
+ if (!key.startsWith("$"))
803
+ walk(child);
804
+ }
805
+ };
806
+ walk(root);
807
+ return count;
808
+ }
809
+ /** Collection ids present in a DTCG document (set-level and token-level stamps). */
810
+ function collectionIdsInDtcg(content) {
811
+ let root;
812
+ try {
813
+ root = JSON.parse(stripBom(content));
814
+ }
815
+ catch {
816
+ return [];
817
+ }
818
+ const ids = new Set();
819
+ const walk = (node) => {
820
+ if (!node || typeof node !== "object" || Array.isArray(node))
821
+ return;
822
+ const obj = node;
823
+ const ext = obj.$extensions?.["figma-console-mcp"];
824
+ if (typeof ext?.figmaCollectionId === "string")
825
+ ids.add(ext.figmaCollectionId);
826
+ if (typeof ext?.collectionId === "string")
827
+ ids.add(ext.collectionId);
828
+ if ("$value" in obj)
829
+ return;
830
+ for (const [key, child] of Object.entries(obj)) {
831
+ if (!key.startsWith("$"))
832
+ walk(child);
833
+ }
834
+ };
835
+ walk(root);
836
+ return [...ids];
837
+ }
838
+ /**
839
+ * Compare an existing DTCG token file with the content about to replace it.
840
+ *
841
+ * A token that disappears is only a legitimate drop when the export MANAGES
842
+ * it: stamped with a variableId AND belonging to a collection included in this
843
+ * export — i.e. the variable was deleted in Figma. Anything else that would
844
+ * vanish is loss the caller never asked for:
845
+ * • code-only tokens (no stamp) someone added by hand, and
846
+ * • tokens from collections outside this export — a scoped export pointed at
847
+ * a shared file, or an export read from the WRONG Figma file.
848
+ *
849
+ * Returns null when nothing unmanaged is lost, or when the existing file is
850
+ * not a DTCG JSON document (CSS/SCSS/TS outputs are generated artifacts).
851
+ */
852
+ export function findUnmanagedTokenLoss(existingContent, newContent, exportedCollectionIds) {
853
+ let existing;
854
+ let next;
855
+ try {
856
+ existing = JSON.parse(stripBom(existingContent));
857
+ next = JSON.parse(stripBom(newContent));
858
+ }
859
+ catch {
860
+ return null;
861
+ }
862
+ const collect = (node, path, out) => {
863
+ if (!node || typeof node !== "object" || Array.isArray(node))
864
+ return;
865
+ const obj = node;
866
+ if ("$value" in obj) {
867
+ const ext = obj.$extensions?.["figma-console-mcp"];
868
+ out.set(path.join("."), ext && typeof ext === "object"
869
+ ? ext
870
+ : undefined);
871
+ return;
872
+ }
873
+ for (const [key, child] of Object.entries(obj)) {
874
+ if (key.startsWith("$"))
875
+ continue;
876
+ collect(child, [...path, key], out);
877
+ }
878
+ };
879
+ const before = new Map();
880
+ const after = new Map();
881
+ collect(existing, [], before);
882
+ collect(next, [], after);
883
+ if (before.size === 0)
884
+ return null;
885
+ const loss = { codeOnly: [], otherCollections: [] };
886
+ for (const [path, stamp] of before) {
887
+ if (after.has(path))
888
+ continue;
889
+ if (!stamp || typeof stamp.variableId !== "string") {
890
+ loss.codeOnly.push(path);
891
+ }
892
+ else if (typeof stamp.collectionId !== "string" ||
893
+ !exportedCollectionIds.has(stamp.collectionId)) {
894
+ loss.otherCollections.push(path);
895
+ }
896
+ // else: stamped + collection exported + gone = deleted in Figma. Fine.
897
+ }
898
+ return loss.codeOnly.length + loss.otherCollections.length > 0 ? loss : null;
899
+ }
900
+ function unmanagedLossError(loss, fullPath, source) {
901
+ const sample = (paths) => `${paths.slice(0, 5).join(", ")}${paths.length > 5 ? `, +${paths.length - 5} more` : ""}`;
902
+ const parts = [];
903
+ if (loss.otherCollections.length > 0) {
904
+ parts.push(`${loss.otherCollections.length} token(s) from collections that are not part of this export (${sample(loss.otherCollections)})`);
905
+ }
906
+ if (loss.codeOnly.length > 0) {
907
+ parts.push(`${loss.codeOnly.length} token(s) that were not exported from Figma, e.g. added by hand (${sample(loss.codeOnly)})`);
908
+ }
909
+ return new Error(`[figma-console-mcp] Nothing was written. Overwriting "${fullPath}" with this export (read from ${describeSource(source)}) would delete ${parts.join(" and ")}. ` +
910
+ "Export replaces the whole file — it does not merge into it. If the source file above is not the one you intended, switch files in Figma and retry. " +
911
+ 'If the loss is intended, pass strategy: "replace" to overwrite anyway; otherwise export to a different path and reconcile by hand.');
912
+ }
913
+ /**
914
+ * Extensions the formatters emit. An `outputPath` ending in one of these is a
915
+ * FILE target, not a directory — nobody wants a directory named
916
+ * `typography.tokens.json`.
917
+ */
918
+ const EXPORT_FILE_EXTENSIONS = new Set([
919
+ ".json",
920
+ ".css",
921
+ ".scss",
922
+ ".less",
923
+ ".ts",
924
+ ".js",
925
+ ".mjs",
926
+ ".cjs",
927
+ ]);
928
+ /**
929
+ * Decide where every formatted file lands. `outputPath` is interpreted as:
930
+ *
931
+ * • a DIRECTORY when it exists as one, ends in a path separator, or has no
932
+ * recognized file extension — formatter filenames are joined onto it
933
+ * (the long-standing behavior);
934
+ * • a FILE when it exists as a regular file or ends in a token-file
935
+ * extension — valid only when the export produces exactly one file, which
936
+ * is then written at that exact path (so callers can re-export straight
937
+ * into an existing `typography.tokens.json`).
938
+ *
939
+ * Throws a descriptive error — before anything is written — when a file
940
+ * target is combined with a multi-file export.
941
+ */
942
+ export function planExportWrites(files, outputPath, loaded) {
943
+ const warnings = [];
944
+ if (outputPath) {
945
+ const target = resolveOutputBaseForFormat(outputPath, loaded, "");
946
+ const stat = statSync(target, { throwIfNoEntry: false });
947
+ const explicitDir = /[\\/]$/.test(outputPath);
948
+ const isFileTarget = !explicitDir &&
949
+ !stat?.isDirectory() &&
950
+ (stat?.isFile() === true ||
951
+ EXPORT_FILE_EXTENSIONS.has(extname(target).toLowerCase()));
952
+ if (isFileTarget) {
953
+ if (files.length > 1) {
954
+ const names = files.map((f) => f.path);
955
+ const shown = names.slice(0, 6).join(", ");
956
+ throw new Error(`[figma-console-mcp] outputPath "${outputPath}" ${stat?.isFile() ? "is an existing file" : "looks like a file path"}, but this export produces ${files.length} files (${shown}${names.length > 6 ? ", …" : ""}). ` +
957
+ "Either pass a DIRECTORY as outputPath (each file is written inside it), or narrow the export to a single file — one `format`, `collectionIds` for the collection you want, and no splitByMode/splitByCollection — and it will be written at exactly that path.");
958
+ }
959
+ if (files.length === 0)
960
+ return { writes: [], warnings };
961
+ const file = files[0];
962
+ const wantExt = extname(file.path).toLowerCase();
963
+ const gotExt = extname(target).toLowerCase();
964
+ if (wantExt && gotExt && wantExt !== gotExt) {
965
+ warnings.push(`outputPath "${basename(target)}" ends in ${gotExt} but the ${file.format} format produces ${wantExt} content — written as requested; double-check the filename.`);
966
+ }
967
+ return {
968
+ writes: [
969
+ {
970
+ format: file.format,
971
+ sourcePath: file.path,
972
+ fullPath: target,
973
+ content: file.content,
974
+ splitByCollection: file.splitByCollection,
975
+ },
976
+ ],
977
+ warnings,
978
+ };
979
+ }
980
+ }
981
+ const writes = [];
982
+ for (const file of files) {
983
+ const base = resolveOutputBaseForFormat(outputPath, loaded, file.format);
984
+ if (!base)
985
+ continue; // No config or outputPath → caller gets content inline.
986
+ writes.push({
987
+ format: file.format,
988
+ sourcePath: file.path,
989
+ fullPath: isAbsolute(file.path) ? file.path : join(base, file.path),
990
+ content: file.content,
991
+ splitByCollection: file.splitByCollection,
992
+ });
993
+ }
994
+ return { writes, warnings };
995
+ }
996
+ /**
997
+ * Translate the raw fs errors a bad output location produces (a regular file
998
+ * sitting where a directory is needed) into something the caller can act on.
999
+ */
1000
+ function describeExportWriteError(err, fullPath) {
1001
+ const code = err?.code;
1002
+ if (code === "EEXIST" || code === "ENOTDIR") {
1003
+ return new Error(`[figma-console-mcp] Cannot write "${fullPath}": part of that path already exists as a regular file, so the directory can't be created. Point outputPath at a directory (or at the exact file to overwrite, for a single-file export).`);
1004
+ }
1005
+ if (code === "EISDIR") {
1006
+ return new Error(`[figma-console-mcp] Cannot write "${fullPath}": a directory already exists at that path.`);
1007
+ }
1008
+ return err instanceof Error ? err : new Error(String(err));
1009
+ }
560
1010
  /**
561
1011
  * Gather input files for import. Priority:
562
1012
  * 1. Explicit payload string.
@@ -75,7 +75,7 @@ export class FigmaConsoleMCPv3 extends McpAgent {
75
75
  this.server = (() => {
76
76
  const s = new McpServer({
77
77
  name: "Figma Console MCP",
78
- version: "1.40.0",
78
+ version: "1.40.2",
79
79
  });
80
80
  // Identity wrap — every tool's response and thrown error gets stamped
81
81
  // with our MCP name so cross-MCP attribution is unambiguous.
@@ -1085,7 +1085,7 @@ export default {
1085
1085
  });
1086
1086
  const statelessServer = new McpServer({
1087
1087
  name: "Figma Console MCP",
1088
- version: "1.40.0",
1088
+ version: "1.40.2",
1089
1089
  });
1090
1090
  wrapServerForIdentity(statelessServer);
1091
1091
  // ================================================================
@@ -1723,7 +1723,7 @@ export default {
1723
1723
  return new Response(JSON.stringify({
1724
1724
  status: "healthy",
1725
1725
  service: "Figma Console MCP",
1726
- version: "1.40.0",
1726
+ version: "1.40.2",
1727
1727
  endpoints: {
1728
1728
  mcp: ["/sse", "/mcp"],
1729
1729
  oauth_mcp_spec: ["/.well-known/oauth-authorization-server", "/authorize", "/token", "/oauth/register"],