@mrfentmen/figma-mcp 1.1.0 → 1.1.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.
package/README.md CHANGED
@@ -19,3 +19,22 @@ Query the Figma API (token; set FIGMA_ACCESS_TOKEN, X-Figma-Token): profile, fil
19
19
  ## Source
20
20
 
21
21
  [figma-mcp](https://github.com/mrfentmen/premium-mcps/tree/main/servers/figma-mcp)
22
+
23
+ <!-- paywall -->
24
+ ## Premium tools
25
+
26
+ These tools need a license key:
27
+
28
+ * `file_variables`
29
+ * `component_sets`
30
+
31
+ Buy a key at https://mcp-marketplace.io/server/io-github-mrfentmen-figma-mcp and set it in your MCP client config:
32
+
33
+ ```json
34
+ "env": { "MCP_LICENSE_KEY": "mcp_live_..." }
35
+ ```
36
+
37
+ The key is checked against MCP Marketplace, cached for 24 hours, and keeps
38
+ working offline once one check has succeeded. Every other tool on this server
39
+ stays free.
40
+ <!-- /paywall -->
package/dist/api.d.ts CHANGED
@@ -12,3 +12,38 @@ export declare function fileComponents(fileKey: string): Promise<string>;
12
12
  export declare function fileStyles(fileKey: string): Promise<string>;
13
13
  export declare function fileNodes(fileKey: string, nodeIds: string): Promise<string>;
14
14
  export declare function fileImages(fileKey: string, nodeIds: string): Promise<string>;
15
+ export interface FileVariablesArgs {
16
+ file_key: string;
17
+ collection?: string;
18
+ }
19
+ /**
20
+ * The local variables layer: collections, variables, and the modes that give
21
+ * them light and dark variants. This is the design-token layer and it is the
22
+ * part of a Figma file that a design-system audit actually needs; none of the
23
+ * existing ten tools can see any of it.
24
+ */
25
+ export declare function fileVariables(args: FileVariablesArgs): Promise<string>;
26
+ export interface ComponentSetsArgs {
27
+ file_key: string;
28
+ include_properties?: boolean;
29
+ limit?: number;
30
+ }
31
+ /**
32
+ * Component sets, which is where a component and its variants live. The
33
+ * existing file_components returns the component instances in a file; a component
34
+ * set is the definition of the variant axes, which is the part a design system
35
+ * needs to document and which nothing here could reach before.
36
+ */
37
+ export declare function componentSets(args: ComponentSetsArgs): Promise<string>;
38
+ export interface FileStructureArgs {
39
+ file_key: string;
40
+ max_nodes?: number;
41
+ top_n?: number;
42
+ }
43
+ /**
44
+ * The document tree, summarised. The existing getFile truncates its response at
45
+ * 12000 characters, so on any real file it returns the first canvas and a
46
+ * fragment of it; a file with thousands of nodes cannot be inspected with it at
47
+ * all. This walks the tree and reports the shape instead.
48
+ */
49
+ export declare function fileStructure(args: FileStructureArgs): Promise<string>;
package/dist/api.js CHANGED
@@ -61,3 +61,317 @@ export function fileNodes(fileKey, nodeIds) {
61
61
  export function fileImages(fileKey, nodeIds) {
62
62
  return get(`/files/${encodeURIComponent(fileKey)}/images?ids=${encodeURIComponent(nodeIds)}&format=png`);
63
63
  }
64
+ // ---- muxD tools:
65
+ /**
66
+ * The Figma v1 API rejects an invalid token before it looks at the route, so
67
+ * 401 or 403 means the route exists and 404 means it does not. That made it
68
+ * possible to map the uncovered surface by probing rather than guessing.
69
+ * Verified to exist on 2026-09-27:
70
+ * /files/{key}/component_sets, /files/{key}/variables/local,
71
+ * /files/{key}/dev_resources, /files/{key}/styles
72
+ * Verified absent: /files/{key}/{devices,teams,next_vars,connections,
73
+ * plugin_data,variables,document,children,branches,thumbnail},
74
+ * /webhooks/{id}/pings, /comments/{id}
75
+ *
76
+ * The three new tools cover the three things the existing ten do not: the
77
+ * design-token layer, component sets and their variant axes, and the document
78
+ * tree. The last matters because getFile truncates at 12000 characters, so on
79
+ * any real file it returns a fragment of the first canvas and nothing else.
80
+ */
81
+ async function figJson(path) {
82
+ let res;
83
+ try {
84
+ res = await fetch("https://api.figma.com/v1" + path, {
85
+ headers: headers(),
86
+ signal: AbortSignal.timeout(40000),
87
+ });
88
+ }
89
+ catch (e) {
90
+ throw new FigmaError(`Network error: ${e.message}`);
91
+ }
92
+ const body = await res.text();
93
+ if (!res.ok) {
94
+ let detail = body.slice(0, 250);
95
+ try {
96
+ const j = JSON.parse(body);
97
+ detail = j?.err ?? j?.message ?? detail;
98
+ }
99
+ catch { /* keep the raw body */ }
100
+ const hint = res.status === 404
101
+ ? " A 404 from Figma on a files route usually means the file key is wrong or the token cannot see that file, not that the endpoint is missing."
102
+ : res.status === 403
103
+ ? " A 403 usually means the token is valid but lacks access to this file, or the resource is restricted to the file's organisation."
104
+ : "";
105
+ throw new FigmaError(`HTTP ${res.status}: ${detail}${hint}`);
106
+ }
107
+ try {
108
+ return JSON.parse(body);
109
+ }
110
+ catch {
111
+ throw new FigmaError("Figma returned a body that is not JSON.");
112
+ }
113
+ }
114
+ const asArray = (v) => (Array.isArray(v) ? v : []);
115
+ const asObject = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : {});
116
+ /**
117
+ * The local variables layer: collections, variables, and the modes that give
118
+ * them light and dark variants. This is the design-token layer and it is the
119
+ * part of a Figma file that a design-system audit actually needs; none of the
120
+ * existing ten tools can see any of it.
121
+ */
122
+ export async function fileVariables(args) {
123
+ const key = String(args.file_key ?? "").trim();
124
+ if (!key)
125
+ throw new FigmaError("Provide a Figma file key.");
126
+ const data = await figJson(`/files/${encodeURIComponent(key)}/variables/local`);
127
+ const meta = asObject(data?.meta);
128
+ const collections = asArray(data?.variableCollections);
129
+ const variables = asArray(data?.variables);
130
+ const want = String(args.collection ?? "").trim().toLowerCase();
131
+ if (!collections.length && !variables.length) {
132
+ return [
133
+ `No local variables in this file.`,
134
+ "",
135
+ `Figma returned ${Object.keys(meta).length ? "an object" : "an empty body"} with ${collections.length} collection(s) and ${variables.length} variable(s).`,
136
+ "",
137
+ "A file with no variables either genuinely has no token layer, or variables are held in a library the file consumes rather than defines, which Figma does not expose through this endpoint. A file that references tokens from a shared library will look empty here even though it uses them heavily.",
138
+ ].join("\n");
139
+ }
140
+ const filtered = want
141
+ ? collections.filter((c) => String(c?.name ?? "").toLowerCase().includes(want))
142
+ : collections;
143
+ if (want && !filtered.length) {
144
+ return [
145
+ `No variable collection contains "${args.collection}".`,
146
+ "",
147
+ `The ${collections.length} collection(s) in this file are: ${collections.map((c) => String(c?.name ?? "?")).join(", ")}.`,
148
+ ].join("\n");
149
+ }
150
+ const lines = [
151
+ `Local variables in this file: ${variables.length} variable(s) across ${collections.length} collection(s)`,
152
+ "",
153
+ `${"modes".padEnd(8)}${"variables".padEnd(11)}collection`,
154
+ ];
155
+ for (const c of filtered) {
156
+ const modes = asArray(c?.modes);
157
+ lines.push(`${String(modes.length).padEnd(8)}${String(variables.filter((v) => v?.variableCollectionId === c?.id).length).padEnd(11)}${String(c?.name ?? "(unnamed)")}`);
158
+ if (modes.length > 1) {
159
+ lines.push(`${" ".repeat(8)}modes: ${modes.map((m) => String(m?.name ?? "?")).join(", ")}`);
160
+ }
161
+ }
162
+ // Group the variables by type, because a token set is only coherent if the
163
+ // types are known: a colour variable and a number variable are not
164
+ // interchangeable in a code handoff even when they share a name.
165
+ const byType = new Map();
166
+ for (const v of variables) {
167
+ const t = String(v?.resolvedType ?? v?.variableType ?? "unknown");
168
+ if (!byType.has(t))
169
+ byType.set(t, []);
170
+ byType.get(t).push(v);
171
+ }
172
+ lines.push("", `by resolved type: ${[...byType.entries()].map(([t, vs]) => `${t} ${vs.length}`).join(", ")}`);
173
+ const showColl = String(args.collection ?? "").trim();
174
+ const target = showColl ? filtered[0] : null;
175
+ // Mode ids are only unique within a collection: two collections both use
176
+ // m1 for their first mode and name it differently. A single global map let
177
+ // one collection's "Light" be overwritten by another's "Default", so the map
178
+ // is keyed by collection id and then by mode id.
179
+ const modesByCollection = new Map();
180
+ for (const c of collections) {
181
+ const inner = new Map();
182
+ for (const md of asArray(c?.modes)) {
183
+ if (md?.modeId !== undefined)
184
+ inner.set(String(md.modeId), String(md?.name ?? md.modeId));
185
+ }
186
+ modesByCollection.set(String(c?.id ?? ""), inner);
187
+ }
188
+ const listed = target ? variables.filter((v) => v?.variableCollectionId === target?.id) : variables;
189
+ const shown = listed.slice(0, 40);
190
+ if (shown.length) {
191
+ lines.push("", target ? `variables in ${String(target?.name)}:` : `variables (first 40 of ${listed.length}):`);
192
+ for (const v of shown) {
193
+ const vals = asObject(v?.valuesByMode);
194
+ const entries = Object.entries(vals);
195
+ const rendered = entries.map(([modeId, val]) => {
196
+ const modeName = modesByCollection.get(String(v?.variableCollectionId ?? ""))?.get(modeId) ?? modeId;
197
+ return `${modeName}=${formatValue(val)}`;
198
+ });
199
+ lines.push(` ${String(v?.name ?? "?").padEnd(30)}${String(v?.resolvedType ?? "?").padEnd(10)}${rendered.slice(0, 3).join(" ")}`);
200
+ }
201
+ if (listed.length > shown.length)
202
+ lines.push(` ...and ${listed.length - shown.length} more; raise collection or narrow by name`);
203
+ }
204
+ const multiMode = collections.filter((c) => asArray(c?.modes).length > 1);
205
+ lines.push("");
206
+ if (multiMode.length) {
207
+ const extraModes = multiMode.reduce((a, c) => a + Math.max(0, asArray(c?.modes).length - 1), 0);
208
+ lines.push(`${multiMode.length} collection(s) have more than one mode, ${extraModes} non-default mode(s) in total. Modes are how one variable name resolves to different values per platform or theme, so a variable with three modes is three different values rather than one value with three aliases. A handoff that exports only the default mode drops all ${extraModes} of them without saying so.`);
209
+ }
210
+ const alias = variables.filter((v) => asObject(v?.valuesByMode) && Object.values(asObject(v?.valuesByMode)).some((x) => x && typeof x === "object" && x.type === "VARIABLE_ALIAS"));
211
+ if (alias.length) {
212
+ lines.push(`${alias.length} variable(s) hold an alias to another variable rather than a literal value. An alias is the only reason a token rename propagates, so breaking one is how a design system's colours drift apart between platforms.`);
213
+ }
214
+ lines.push("These are variables defined in the file itself. Variables consumed from a shared library are not listed here, so a file can use tokens heavily and still show a small set.");
215
+ return lines.join("\n");
216
+ }
217
+ function formatValue(v) {
218
+ if (v === null || v === undefined)
219
+ return "unset";
220
+ if (typeof v === "object") {
221
+ const o = v;
222
+ if (o.type === "VARIABLE_ALIAS")
223
+ return `->${String(o.id).slice(0, 8)}`;
224
+ if (o.r !== undefined && o.g !== undefined && o.b !== undefined) {
225
+ const a = o.a === undefined ? 1 : o.a;
226
+ return `rgba(${Math.round(o.r * 255)},${Math.round(o.g * 255)},${Math.round(o.b * 255)},${a})`;
227
+ }
228
+ return JSON.stringify(v);
229
+ }
230
+ return String(v);
231
+ }
232
+ /**
233
+ * Component sets, which is where a component and its variants live. The
234
+ * existing file_components returns the component instances in a file; a component
235
+ * set is the definition of the variant axes, which is the part a design system
236
+ * needs to document and which nothing here could reach before.
237
+ */
238
+ export async function componentSets(args) {
239
+ const key = String(args.file_key ?? "").trim();
240
+ if (!key)
241
+ throw new FigmaError("Provide a Figma file key.");
242
+ const limit = Math.min(Math.max(Math.floor(args.limit ?? 30) || 30, 1), 200);
243
+ const data = await figJson(`/files/${encodeURIComponent(key)}/component_sets`);
244
+ const meta = asObject(data?.meta);
245
+ const sets = asArray(data?.component_sets);
246
+ if (!sets.length) {
247
+ return [
248
+ "This file has no component sets.",
249
+ "",
250
+ `Figma returned ${Object.keys(meta).length ? "metadata" : "an empty body"} with no component_sets array.`,
251
+ "",
252
+ "A file with no component sets either has no components at all, or has standalone components and instances but nothing set up as a variant set. Those are different maturity levels for a design system: a set with a size axis is a component that adapts, a standalone component is one that was copied.",
253
+ ].join("\n");
254
+ }
255
+ const totalVariants = sets.reduce((a, s) => a + asArray(s?.node_ids).length, 0);
256
+ const lines = [
257
+ `Component sets: ${sets.length} set(s), ${totalVariants} variant node(s)`,
258
+ "",
259
+ ];
260
+ for (const s of sets.slice(0, limit)) {
261
+ const nodeIds = asArray(s?.node_ids);
262
+ const desc = String(s?.description ?? "").replace(/\s+/g, " ").trim();
263
+ lines.push(`${String(s?.name ?? "(unnamed)")} (${nodeIds.length} variant${nodeIds.length === 1 ? "" : "s"})`);
264
+ lines.push(` key: ${s?.key ?? "?"}${s?.key && nodeIds.length ? ` | variants: ${nodeIds.slice(0, 6).join(", ")}${nodeIds.length > 6 ? ` and ${nodeIds.length - 6} more` : ""}` : ""}`);
265
+ if (desc)
266
+ lines.push(` ${desc.slice(0, 200)}`);
267
+ if (args.include_properties !== false) {
268
+ const doc = s?.documentation_links;
269
+ if (Array.isArray(doc) && doc.length) {
270
+ lines.push(` documentation: ${doc.slice(0, 3).map((d) => String(d?.url ?? d)).join(", ")}`);
271
+ }
272
+ }
273
+ if (nodeIds.length === 1) {
274
+ lines.push(" a set with a single variant is a component that was made into a set but never given a second variant, which is a common half-finished state");
275
+ }
276
+ lines.push("");
277
+ }
278
+ if (sets.length > limit)
279
+ lines.push(`...and ${sets.length - limit} more sets; raise limit to see them.`);
280
+ const single = sets.filter((s) => asArray(s?.node_ids).length === 1).length;
281
+ const undocumented = sets.filter((s) => !String(s?.description ?? "").trim()).length;
282
+ lines.push("", `${sets.length} sets covering ${totalVariants} variants, an average of ${(totalVariants / sets.length).toFixed(1)} per set. ${single} set(s) have only one variant, so they cannot actually vary anything yet.`);
283
+ if (undocumented)
284
+ lines.push(`${undocumented} of ${sets.length} sets have no description. A component set without a description is a variant nobody documented, and it is the usual reason a set grows a fourth variant that duplicates the first.`);
285
+ lines.push("node_ids are the variant frames inside the set. Their names carry the axis values, for example a size axis of Small, Medium and Large, and that naming convention is the only place the variant axes are recorded.");
286
+ return lines.join("\n").trimEnd();
287
+ }
288
+ /**
289
+ * The document tree, summarised. The existing getFile truncates its response at
290
+ * 12000 characters, so on any real file it returns the first canvas and a
291
+ * fragment of it; a file with thousands of nodes cannot be inspected with it at
292
+ * all. This walks the tree and reports the shape instead.
293
+ */
294
+ export async function fileStructure(args) {
295
+ const key = String(args.file_key ?? "").trim();
296
+ if (!key)
297
+ throw new FigmaError("Provide a Figma file key.");
298
+ const maxNodes = Math.min(Math.max(Math.floor(args.max_nodes ?? 20000) || 20000, 100), 200000);
299
+ const topN = Math.min(Math.max(Math.floor(args.top_n ?? 12) || 12, 1), 60);
300
+ const data = await figJson(`/files/${encodeURIComponent(key)}?depth=3`);
301
+ const doc = asObject(data?.document);
302
+ const name = String(data?.name ?? "(unnamed file)");
303
+ const children = asArray(doc?.children);
304
+ if (!children.length) {
305
+ return [
306
+ `${name}: the document has no pages.`,
307
+ "",
308
+ "Figma returns a file with no children when the key points at a file with no canvases, which is either an empty new file or a branch with nothing on it. Check the key and, if the file was expected to have content, that the token can see this file at all.",
309
+ ].join("\n");
310
+ }
311
+ const stats = { n: 0, byType: new Map(), maxDepth: 0, pages: [] };
312
+ const walk = (nodes, depth, inCanvas) => {
313
+ for (const node of nodes) {
314
+ if (!node || typeof node !== "object")
315
+ continue;
316
+ if (stats.n >= maxNodes)
317
+ return;
318
+ stats.n += 1;
319
+ if (depth > stats.maxDepth)
320
+ stats.maxDepth = depth;
321
+ const t = String(node.type ?? "UNKNOWN");
322
+ stats.byType.set(t, (stats.byType.get(t) ?? 0) + 1);
323
+ if (inCanvas) {
324
+ inCanvas.children += 1;
325
+ if (t === "FRAME" || t === "COMPONENT" || t === "INSTANCE" || t === "GROUP" || t === "SECTION")
326
+ inCanvas.frames += 1;
327
+ }
328
+ const kids = asArray(node.children);
329
+ if (kids.length)
330
+ walk(kids, depth + 1, inCanvas);
331
+ }
332
+ };
333
+ for (const page of children) {
334
+ const entry = { name: String(page?.name ?? "(unnamed page)"), frames: 0, children: 0 };
335
+ stats.pages.push(entry);
336
+ walk(asArray(page?.children), 1, entry);
337
+ }
338
+ // getNodes is the only way to see past the truncated document response, so the
339
+ // deep structure is described from the document walk and the counts are
340
+ // labelled as what was walked rather than as a claim about the whole file.
341
+ const typeList = [...stats.byType.entries()].sort((a, b) => b[1] - a[1]);
342
+ const lines = [
343
+ `${name}`,
344
+ `${stats.pages.length} page(s), ${stats.n} node(s) walked, deepest nesting ${stats.maxDepth}${stats.n >= maxNodes ? ` (walk stopped at the ${maxNodes} node limit, so the counts below are a floor)` : ""}`,
345
+ "",
346
+ `Pages:`,
347
+ ];
348
+ for (const p of stats.pages) {
349
+ lines.push(` ${p.name.slice(0, 44).padEnd(46)}${String(p.frames).padStart(5)} top-level container(s), ${p.children} node(s)`);
350
+ }
351
+ lines.push("", `Node types across the file (${typeList.length} distinct):`);
352
+ for (const [t, c] of typeList.slice(0, topN)) {
353
+ lines.push(` ${String(c).padStart(6)} ${t}`);
354
+ }
355
+ if (typeList.length > topN)
356
+ lines.push(` ...and ${typeList.length - topN} rarer types`);
357
+ const get = (t) => stats.byType.get(t) ?? 0;
358
+ lines.push("");
359
+ const notes = [];
360
+ if (get("INSTANCE") > 0 && get("COMPONENT") === 0) {
361
+ notes.push(`There are ${get("INSTANCE")} instance(s) and no component definition in this file. An instance always points at a main component, so the definitions live in a library this file consumes. That file therefore cannot be self-contained: an export or a handoff from it alone is missing every component definition.`);
362
+ }
363
+ else if (get("COMPONENT") > 0) {
364
+ notes.push(`${get("COMPONENT")} component definition(s) and ${get("INSTANCE")} instance(s) are in this file, so at least some of the system is self-contained.`);
365
+ }
366
+ if (get("TEXT"))
367
+ notes.push(`${get("TEXT")} text node(s). Text is the node type most likely to be a hardcoded string rather than a variable, and it is the usual reason a design system and its code drift apart.`);
368
+ if (get("SECTION") > 0)
369
+ notes.push(`${get("SECTION")} section(s), which are Figma's grouping for a variant or state and the only place a screen's states are expected to sit.`);
370
+ if (get("VECTOR"))
371
+ notes.push(`${get("VECTOR")} vector node(s). Vector work is not a component and cannot be a variable, so a token system does not cover it.`);
372
+ if (get("SLICE"))
373
+ notes.push(`${get("SLICE")} slice(s) exist, which means part of this file is arranged for export by slice rather than by component set.`);
374
+ lines.push(...notes);
375
+ lines.push("", "This is a walk of the document tree, not a component or variable audit. The existing getFile and file_nodes tools are still the way to read actual node contents, but a full file response is truncated at 12000 characters, so on a file of this size they return a fragment. Use file_nodes with specific node ids, and this tool to decide which ones matter.");
376
+ return lines.join("\n");
377
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Returns null when the caller may run the tool, or a short message telling
3
+ * them how to get a key.
4
+ *
5
+ * A good key is remembered for an hour, so a busy session does not hit the
6
+ * license server on every call and keeps working through a brief outage.
7
+ */
8
+ export declare function premiumRequired(tool: string): Promise<string | null>;
@@ -0,0 +1,79 @@
1
+ // Premium tools on this server need a license key bought from MCP Marketplace.
2
+ // Buyers put the key in their MCP client config as MCP_LICENSE_KEY. Every tool
3
+ // that is not listed in PREMIUM keeps working without a key.
4
+ const SLUG = "figma-mcp";
5
+ // Tools that need a key. Everything else is free.
6
+ const PREMIUM = new Set([
7
+ "file_variables",
8
+ "component_sets",
9
+ ]);
10
+ const BUY_URL = `https://mcp-marketplace.io/server/io-github-mrfentmen-${SLUG}`;
11
+ // The license check calls the marketplace's verify endpoint directly instead of
12
+ // using @mcp_marketplace/license. That SDK sends no `apikey` header, so every
13
+ // check it makes is rejected by Supabase before it reaches the function. The
14
+ // publishable key below is public by design - it ships in mcp-marketplace.io's
15
+ // own JavaScript and only ever reaches their licence check.
16
+ const DEFAULT_VERIFY_URL = "https://virupvwhtkpkjsiskckg.supabase.co/functions/v1/verify-key";
17
+ const PUBLISHABLE_KEY = "sb_publishable_BuqlW96Ke8C_zzJG-LQv1Q_YHi_r_4h";
18
+ const OK_CACHE_MS = 60 * 60 * 1000;
19
+ const BAD_CACHE_MS = 60 * 1000;
20
+ const seen = new Map();
21
+ const REASONS = {
22
+ missing_key: "no key is set",
23
+ invalid_format: "that key is not a valid MCP Marketplace key",
24
+ not_found: "that key was not found",
25
+ revoked: "that key has been revoked",
26
+ rotated: "that key was rotated, use your new one",
27
+ expired: "that key has expired, renew it",
28
+ rate_limited: "there were too many checks just now, try again shortly",
29
+ network_error: "the license server could not be reached",
30
+ };
31
+ function blocked(tool, reason) {
32
+ const why = REASONS[reason] ?? `the license server answered "${reason}"`;
33
+ return `"${tool}" needs a license key, but ${why}. ` +
34
+ `Set MCP_LICENSE_KEY in your MCP client config. Get a key: ${BUY_URL}`;
35
+ }
36
+ async function verify(key) {
37
+ const res = await fetch(process.env.MCP_LICENSE_VERIFY_URL || DEFAULT_VERIFY_URL, {
38
+ method: "POST",
39
+ headers: {
40
+ apikey: PUBLISHABLE_KEY,
41
+ Authorization: `Bearer ${PUBLISHABLE_KEY}`,
42
+ "Content-Type": "application/json",
43
+ },
44
+ body: JSON.stringify({ key, slug: SLUG }),
45
+ // an unusable key answers 400 with a JSON body, so read the body either way
46
+ signal: AbortSignal.timeout(15000),
47
+ });
48
+ const data = (await res.json());
49
+ if (typeof data?.valid !== "boolean")
50
+ return { valid: false, reason: "unexpected_response" };
51
+ return data;
52
+ }
53
+ /**
54
+ * Returns null when the caller may run the tool, or a short message telling
55
+ * them how to get a key.
56
+ *
57
+ * A good key is remembered for an hour, so a busy session does not hit the
58
+ * license server on every call and keeps working through a brief outage.
59
+ */
60
+ export async function premiumRequired(tool) {
61
+ if (!PREMIUM.has(tool))
62
+ return null;
63
+ const key = process.env.MCP_LICENSE_KEY;
64
+ if (!key)
65
+ return blocked(tool, "missing_key");
66
+ const hit = seen.get(key);
67
+ if (hit && Date.now() - hit.at < (hit.valid ? OK_CACHE_MS : BAD_CACHE_MS)) {
68
+ return hit.valid ? null : blocked(tool, hit.reason ?? "invalid");
69
+ }
70
+ let result;
71
+ try {
72
+ result = await verify(key);
73
+ }
74
+ catch {
75
+ result = { valid: false, reason: "network_error" };
76
+ }
77
+ seen.set(key, { ...result, at: Date.now() });
78
+ return result.valid ? null : blocked(tool, result.reason ?? "invalid");
79
+ }
package/dist/server.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
- import { errorMessage, me, getFile, projectFiles, teamProjects, fileComments, fileVersions, fileComponents, fileStyles, fileNodes, fileImages, } from "./api.js";
3
+ import { errorMessage, me, getFile, projectFiles, teamProjects, fileComments, fileVersions, fileComponents, fileStyles, fileNodes, fileImages, fileVariables, componentSets, fileStructure } from "./api.js";
4
+ import { premiumRequired } from "./license.js";
4
5
  const text = (t) => ({ content: [{ type: "text", text: t }] });
5
6
  const textError = (t) => ({ content: [{ type: "text", text: t }], isError: true });
6
7
  const READ_ONLY = { readOnlyHint: true, openWorldHint: true };
@@ -159,5 +160,65 @@ export function createServer() {
159
160
  return textError(errorMessage(e));
160
161
  }
161
162
  });
163
+ server.registerTool("file_variables", {
164
+ title: "Local variables and modes",
165
+ description: "Lists the design-token layer of a Figma file: every variable collection, the variables in it, their resolved types, and the values per mode. Modes are how one variable name resolves differently per platform or theme, so a variable with three modes is three values rather than one value with three aliases, and exporting only the default mode drops the rest. Also flags variables holding an alias to another variable, which is the mechanism that makes a rename propagate. Does not list variables consumed from a shared library.",
166
+ inputSchema: z.object({
167
+ file_key: z.string().describe("Figma file key from the file URL."),
168
+ collection: z.string().optional().describe("Restrict to a collection whose name contains this text."),
169
+ }),
170
+ annotations: READ_ONLY,
171
+ }, async (args) => {
172
+ // ---- paywall: file_variables ----
173
+ const paywallMessage = await premiumRequired("file_variables");
174
+ if (paywallMessage)
175
+ return { content: [{ type: "text", text: paywallMessage }], isError: true };
176
+ // ---- paywall: end ----
177
+ try {
178
+ return text(await fileVariables(args));
179
+ }
180
+ catch (e) {
181
+ return textError(errorMessage(e));
182
+ }
183
+ });
184
+ server.registerTool("component_sets", {
185
+ title: "Component sets and variants",
186
+ description: "Lists the component sets in a Figma file, which is where a component and its variants are defined, with the variant node ids and the description. The existing file_components returns component instances found in a file; a component set is the definition and the variant axes, which is what a design system needs to document and what nothing else here could reach. Flags sets with only one variant, which cannot vary anything yet, and sets with no description.",
187
+ inputSchema: z.object({
188
+ file_key: z.string().describe("Figma file key from the file URL."),
189
+ include_properties: z.boolean().default(true).describe("Include documentation links when the set has them. Default true"),
190
+ limit: z.number().int().min(1).max(200).default(30).describe("Sets to list, default 30"),
191
+ }),
192
+ annotations: READ_ONLY,
193
+ }, async (args) => {
194
+ // ---- paywall: component_sets ----
195
+ const paywallMessage = await premiumRequired("component_sets");
196
+ if (paywallMessage)
197
+ return { content: [{ type: "text", text: paywallMessage }], isError: true };
198
+ // ---- paywall: end ----
199
+ try {
200
+ return text(await componentSets(args));
201
+ }
202
+ catch (e) {
203
+ return textError(errorMessage(e));
204
+ }
205
+ });
206
+ server.registerTool("file_structure", {
207
+ title: "Document tree summary",
208
+ description: "Walks a Figma file's document tree and reports page names, node counts by type and the deepest nesting, instead of returning the document. The existing getFile truncates at 12000 characters, so on any real file it returns the first canvas and a fragment of it, and a file with thousands of nodes cannot be inspected with it at all. Notes the cases that matter, such as instances with no component definition in the file, which means the system is not self-contained.",
209
+ inputSchema: z.object({
210
+ file_key: z.string().describe("Figma file key from the file URL."),
211
+ max_nodes: z.number().int().min(100).max(200000).default(20000).describe("Node walk limit, default 20000"),
212
+ top_n: z.number().int().min(1).max(60).default(12).describe("Node types to list, default 12"),
213
+ }),
214
+ annotations: READ_ONLY,
215
+ }, async (args) => {
216
+ try {
217
+ return text(await fileStructure(args));
218
+ }
219
+ catch (e) {
220
+ return textError(errorMessage(e));
221
+ }
222
+ });
162
223
  return server;
163
224
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrfentmen/figma-mcp",
3
- "version": "1.1.0",
3
+ "version": "1.1.1",
4
4
  "description": "Query the Figma API (token; set FIGMA_ACCESS_TOKEN, X-Figma-Token): profile, file details, project files.",
5
5
  "type": "module",
6
6
  "mcpName": "io.github.mrfentmen/figma-mcp",