@mrfentmen/figma-mcp 1.1.0 → 1.1.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.
- package/README.md +19 -0
- package/dist/api.d.ts +35 -0
- package/dist/api.js +314 -0
- package/dist/server.js +53 -1
- package/package.json +1 -1
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
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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
4
|
const text = (t) => ({ content: [{ type: "text", text: t }] });
|
|
5
5
|
const textError = (t) => ({ content: [{ type: "text", text: t }], isError: true });
|
|
6
6
|
const READ_ONLY = { readOnlyHint: true, openWorldHint: true };
|
|
@@ -159,5 +159,57 @@ export function createServer() {
|
|
|
159
159
|
return textError(errorMessage(e));
|
|
160
160
|
}
|
|
161
161
|
});
|
|
162
|
+
server.registerTool("file_variables", {
|
|
163
|
+
title: "Local variables and modes",
|
|
164
|
+
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.",
|
|
165
|
+
inputSchema: z.object({
|
|
166
|
+
file_key: z.string().describe("Figma file key from the file URL."),
|
|
167
|
+
collection: z.string().optional().describe("Restrict to a collection whose name contains this text."),
|
|
168
|
+
}),
|
|
169
|
+
annotations: READ_ONLY,
|
|
170
|
+
}, async (args) => {
|
|
171
|
+
// ---- paywall: file_variables ----
|
|
172
|
+
try {
|
|
173
|
+
return text(await fileVariables(args));
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
return textError(errorMessage(e));
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
server.registerTool("component_sets", {
|
|
180
|
+
title: "Component sets and variants",
|
|
181
|
+
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.",
|
|
182
|
+
inputSchema: z.object({
|
|
183
|
+
file_key: z.string().describe("Figma file key from the file URL."),
|
|
184
|
+
include_properties: z.boolean().default(true).describe("Include documentation links when the set has them. Default true"),
|
|
185
|
+
limit: z.number().int().min(1).max(200).default(30).describe("Sets to list, default 30"),
|
|
186
|
+
}),
|
|
187
|
+
annotations: READ_ONLY,
|
|
188
|
+
}, async (args) => {
|
|
189
|
+
// ---- paywall: component_sets ----
|
|
190
|
+
try {
|
|
191
|
+
return text(await componentSets(args));
|
|
192
|
+
}
|
|
193
|
+
catch (e) {
|
|
194
|
+
return textError(errorMessage(e));
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
server.registerTool("file_structure", {
|
|
198
|
+
title: "Document tree summary",
|
|
199
|
+
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.",
|
|
200
|
+
inputSchema: z.object({
|
|
201
|
+
file_key: z.string().describe("Figma file key from the file URL."),
|
|
202
|
+
max_nodes: z.number().int().min(100).max(200000).default(20000).describe("Node walk limit, default 20000"),
|
|
203
|
+
top_n: z.number().int().min(1).max(60).default(12).describe("Node types to list, default 12"),
|
|
204
|
+
}),
|
|
205
|
+
annotations: READ_ONLY,
|
|
206
|
+
}, async (args) => {
|
|
207
|
+
try {
|
|
208
|
+
return text(await fileStructure(args));
|
|
209
|
+
}
|
|
210
|
+
catch (e) {
|
|
211
|
+
return textError(errorMessage(e));
|
|
212
|
+
}
|
|
213
|
+
});
|
|
162
214
|
return server;
|
|
163
215
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@mrfentmen/figma-mcp",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
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",
|