figma-console-mcp 0.1.0
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/LICENSE +21 -0
- package/README.md +328 -0
- package/dist/browser/base.d.ts +50 -0
- package/dist/browser/base.d.ts.map +1 -0
- package/dist/browser/base.js +6 -0
- package/dist/browser/base.js.map +1 -0
- package/dist/browser/local.d.ts +66 -0
- package/dist/browser/local.d.ts.map +1 -0
- package/dist/browser/local.js +223 -0
- package/dist/browser/local.js.map +1 -0
- package/dist/cloudflare/browser/base.js +5 -0
- package/dist/cloudflare/browser/cloudflare.js +156 -0
- package/dist/cloudflare/browser-manager.js +157 -0
- package/dist/cloudflare/core/config.js +161 -0
- package/dist/cloudflare/core/console-monitor.js +382 -0
- package/dist/cloudflare/core/enrichment/enrichment-service.js +272 -0
- package/dist/cloudflare/core/enrichment/index.js +7 -0
- package/dist/cloudflare/core/enrichment/relationship-mapper.js +351 -0
- package/dist/cloudflare/core/enrichment/style-resolver.js +326 -0
- package/dist/cloudflare/core/figma-api.js +273 -0
- package/dist/cloudflare/core/figma-desktop-connector.js +383 -0
- package/dist/cloudflare/core/figma-style-extractor.js +311 -0
- package/dist/cloudflare/core/figma-tools.js +2299 -0
- package/dist/cloudflare/core/logger.js +53 -0
- package/dist/cloudflare/core/snippet-injector.js +96 -0
- package/dist/cloudflare/core/types/enriched.js +5 -0
- package/dist/cloudflare/core/types/index.js +4 -0
- package/dist/cloudflare/index.js +1059 -0
- package/dist/cloudflare/test-browser.js +88 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +141 -0
- package/dist/config.js.map +1 -0
- package/dist/core/config.d.ts +17 -0
- package/dist/core/config.d.ts.map +1 -0
- package/dist/core/config.js +162 -0
- package/dist/core/config.js.map +1 -0
- package/dist/core/console-monitor.d.ts +81 -0
- package/dist/core/console-monitor.d.ts.map +1 -0
- package/dist/core/console-monitor.js +383 -0
- package/dist/core/console-monitor.js.map +1 -0
- package/dist/core/enrichment/enrichment-service.d.ts +52 -0
- package/dist/core/enrichment/enrichment-service.d.ts.map +1 -0
- package/dist/core/enrichment/enrichment-service.js +273 -0
- package/dist/core/enrichment/enrichment-service.js.map +1 -0
- package/dist/core/enrichment/index.d.ts +8 -0
- package/dist/core/enrichment/index.d.ts.map +1 -0
- package/dist/core/enrichment/index.js +8 -0
- package/dist/core/enrichment/index.js.map +1 -0
- package/dist/core/enrichment/relationship-mapper.d.ts +106 -0
- package/dist/core/enrichment/relationship-mapper.d.ts.map +1 -0
- package/dist/core/enrichment/relationship-mapper.js +352 -0
- package/dist/core/enrichment/relationship-mapper.js.map +1 -0
- package/dist/core/enrichment/style-resolver.d.ts +80 -0
- package/dist/core/enrichment/style-resolver.d.ts.map +1 -0
- package/dist/core/enrichment/style-resolver.js +327 -0
- package/dist/core/enrichment/style-resolver.js.map +1 -0
- package/dist/core/figma-api.d.ts +137 -0
- package/dist/core/figma-api.d.ts.map +1 -0
- package/dist/core/figma-api.js +274 -0
- package/dist/core/figma-api.js.map +1 -0
- package/dist/core/figma-desktop-connector.d.ts +52 -0
- package/dist/core/figma-desktop-connector.d.ts.map +1 -0
- package/dist/core/figma-desktop-connector.js +384 -0
- package/dist/core/figma-desktop-connector.js.map +1 -0
- package/dist/core/figma-style-extractor.d.ts +76 -0
- package/dist/core/figma-style-extractor.d.ts.map +1 -0
- package/dist/core/figma-style-extractor.js +312 -0
- package/dist/core/figma-style-extractor.js.map +1 -0
- package/dist/core/figma-tools.d.ts +15 -0
- package/dist/core/figma-tools.d.ts.map +1 -0
- package/dist/core/figma-tools.js +2300 -0
- package/dist/core/figma-tools.js.map +1 -0
- package/dist/core/logger.d.ts +22 -0
- package/dist/core/logger.d.ts.map +1 -0
- package/dist/core/logger.js +54 -0
- package/dist/core/logger.js.map +1 -0
- package/dist/core/snippet-injector.d.ts +24 -0
- package/dist/core/snippet-injector.d.ts.map +1 -0
- package/dist/core/snippet-injector.js +97 -0
- package/dist/core/snippet-injector.js.map +1 -0
- package/dist/core/types/enriched.d.ts +213 -0
- package/dist/core/types/enriched.d.ts.map +1 -0
- package/dist/core/types/enriched.js +6 -0
- package/dist/core/types/enriched.js.map +1 -0
- package/dist/core/types/index.d.ts +112 -0
- package/dist/core/types/index.d.ts.map +1 -0
- package/dist/core/types/index.js +5 -0
- package/dist/core/types/index.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +72 -0
- package/dist/index.js.map +1 -0
- package/dist/local.d.ts +57 -0
- package/dist/local.d.ts.map +1 -0
- package/dist/local.js +668 -0
- package/dist/local.js.map +1 -0
- package/dist/logger.d.ts +22 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +45 -0
- package/dist/logger.js.map +1 -0
- package/dist/server.d.ts +40 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +99 -0
- package/dist/server.js.map +1 -0
- package/dist/tools/index.d.ts +15 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +184 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/types/index.d.ts +102 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +6 -0
- package/dist/types/index.js.map +1 -0
- package/figma-desktop-bridge/README.md +232 -0
- package/figma-desktop-bridge/code.js +133 -0
- package/figma-desktop-bridge/manifest.json +13 -0
- package/figma-desktop-bridge/ui.html +200 -0
- package/package.json +77 -0
|
@@ -0,0 +1,2299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Figma API MCP Tools
|
|
3
|
+
* MCP tool definitions for Figma REST API data extraction
|
|
4
|
+
*/
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { extractFileKey, formatVariables, formatComponentData } from "./figma-api.js";
|
|
7
|
+
import { createChildLogger } from "./logger.js";
|
|
8
|
+
import { EnrichmentService } from "./enrichment/index.js";
|
|
9
|
+
import { SnippetInjector } from "./snippet-injector.js";
|
|
10
|
+
const logger = createChildLogger({ component: "figma-tools" });
|
|
11
|
+
// Initialize enrichment service
|
|
12
|
+
const enrichmentService = new EnrichmentService(logger);
|
|
13
|
+
// Initialize snippet injector
|
|
14
|
+
const snippetInjector = new SnippetInjector();
|
|
15
|
+
// ============================================================================
|
|
16
|
+
// Cache Management & Data Processing Helpers
|
|
17
|
+
// ============================================================================
|
|
18
|
+
/**
|
|
19
|
+
* Cache configuration
|
|
20
|
+
*/
|
|
21
|
+
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
22
|
+
const MAX_CACHE_ENTRIES = 10; // LRU eviction
|
|
23
|
+
/**
|
|
24
|
+
* Check if cache entry is still valid based on TTL
|
|
25
|
+
*/
|
|
26
|
+
function isCacheValid(timestamp, ttlMs = CACHE_TTL_MS) {
|
|
27
|
+
return Date.now() - timestamp < ttlMs;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Rough token estimation for response size checking
|
|
31
|
+
* Approximation: 1 token ≈ 4 characters for JSON
|
|
32
|
+
*/
|
|
33
|
+
function estimateTokens(data) {
|
|
34
|
+
const jsonString = JSON.stringify(data);
|
|
35
|
+
return Math.ceil(jsonString.length / 4);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Response size thresholds for adaptive verbosity
|
|
39
|
+
* Based on typical Claude Desktop context window limits
|
|
40
|
+
*/
|
|
41
|
+
const RESPONSE_SIZE_THRESHOLDS = {
|
|
42
|
+
// Conservative thresholds to leave room for conversation context
|
|
43
|
+
IDEAL_SIZE_KB: 100, // Target size for optimal performance
|
|
44
|
+
WARNING_SIZE_KB: 200, // Start considering compression
|
|
45
|
+
CRITICAL_SIZE_KB: 500, // Must compress to avoid context exhaustion
|
|
46
|
+
MAX_SIZE_KB: 1000, // Absolute maximum before emergency compression
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Calculate JSON string size in KB
|
|
50
|
+
*/
|
|
51
|
+
function calculateSizeKB(data) {
|
|
52
|
+
const jsonString = JSON.stringify(data);
|
|
53
|
+
return jsonString.length / 1024;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Generic adaptive response wrapper - automatically compresses responses that exceed size thresholds
|
|
57
|
+
* Can be used by any tool to prevent context window exhaustion
|
|
58
|
+
*
|
|
59
|
+
* @param responseData - The response data to potentially compress
|
|
60
|
+
* @param options - Configuration options for compression behavior
|
|
61
|
+
* @returns Response content array with optional AI instruction
|
|
62
|
+
*/
|
|
63
|
+
function adaptiveResponse(responseData, options) {
|
|
64
|
+
const sizeKB = calculateSizeKB(responseData);
|
|
65
|
+
// No compression needed
|
|
66
|
+
if (sizeKB <= RESPONSE_SIZE_THRESHOLDS.IDEAL_SIZE_KB) {
|
|
67
|
+
return {
|
|
68
|
+
content: [
|
|
69
|
+
{
|
|
70
|
+
type: "text",
|
|
71
|
+
text: JSON.stringify(responseData),
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
// Determine compression level and message
|
|
77
|
+
let compressionLevel = "info";
|
|
78
|
+
let aiInstruction = "";
|
|
79
|
+
let shouldCompress = false;
|
|
80
|
+
if (sizeKB > RESPONSE_SIZE_THRESHOLDS.MAX_SIZE_KB) {
|
|
81
|
+
compressionLevel = "emergency";
|
|
82
|
+
shouldCompress = true;
|
|
83
|
+
aiInstruction =
|
|
84
|
+
`⚠️ RESPONSE AUTO-COMPRESSED: The ${options.toolName} response was automatically reduced because the full response would be ${sizeKB.toFixed(0)}KB, which would exhaust Claude Desktop's context window.\n\n`;
|
|
85
|
+
}
|
|
86
|
+
else if (sizeKB > RESPONSE_SIZE_THRESHOLDS.CRITICAL_SIZE_KB) {
|
|
87
|
+
compressionLevel = "critical";
|
|
88
|
+
shouldCompress = true;
|
|
89
|
+
aiInstruction =
|
|
90
|
+
`⚠️ RESPONSE AUTO-COMPRESSED: The ${options.toolName} response was automatically reduced because it would be ${sizeKB.toFixed(0)}KB, risking context window exhaustion.\n\n`;
|
|
91
|
+
}
|
|
92
|
+
else if (sizeKB > RESPONSE_SIZE_THRESHOLDS.WARNING_SIZE_KB) {
|
|
93
|
+
compressionLevel = "warning";
|
|
94
|
+
shouldCompress = true;
|
|
95
|
+
aiInstruction =
|
|
96
|
+
`ℹ️ RESPONSE OPTIMIZED: The ${options.toolName} response was automatically reduced because it would be ${sizeKB.toFixed(0)}KB.\n\n`;
|
|
97
|
+
}
|
|
98
|
+
// Map compression level to verbosity level
|
|
99
|
+
const verbosityMap = {
|
|
100
|
+
"info": "standard",
|
|
101
|
+
"warning": "summary",
|
|
102
|
+
"critical": "summary",
|
|
103
|
+
"emergency": "inventory"
|
|
104
|
+
};
|
|
105
|
+
// If compression needed, apply callback to reduce data
|
|
106
|
+
let finalData = responseData;
|
|
107
|
+
if (shouldCompress && options.compressionCallback) {
|
|
108
|
+
const targetVerbosity = verbosityMap[compressionLevel] || "summary";
|
|
109
|
+
finalData = options.compressionCallback(targetVerbosity);
|
|
110
|
+
// Add compression metadata
|
|
111
|
+
finalData.compression = {
|
|
112
|
+
originalSizeKB: Math.round(sizeKB),
|
|
113
|
+
finalSizeKB: Math.round(calculateSizeKB(finalData)),
|
|
114
|
+
compressionLevel,
|
|
115
|
+
};
|
|
116
|
+
logger.info({
|
|
117
|
+
tool: options.toolName,
|
|
118
|
+
originalSizeKB: sizeKB.toFixed(2),
|
|
119
|
+
finalSizeKB: calculateSizeKB(finalData).toFixed(2),
|
|
120
|
+
compressionLevel,
|
|
121
|
+
}, "Response compressed to prevent context exhaustion");
|
|
122
|
+
}
|
|
123
|
+
// Build AI instruction with suggested actions
|
|
124
|
+
if (shouldCompress) {
|
|
125
|
+
if (options.suggestedActions && options.suggestedActions.length > 0) {
|
|
126
|
+
aiInstruction += `To get more detail:\n`;
|
|
127
|
+
options.suggestedActions.forEach(action => {
|
|
128
|
+
aiInstruction += `• ${action}\n`;
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// Build response content
|
|
133
|
+
const content = [
|
|
134
|
+
{
|
|
135
|
+
type: "text",
|
|
136
|
+
text: JSON.stringify(finalData),
|
|
137
|
+
},
|
|
138
|
+
];
|
|
139
|
+
// Add AI instruction as separate content block if needed
|
|
140
|
+
if (aiInstruction) {
|
|
141
|
+
content.unshift({
|
|
142
|
+
type: "text",
|
|
143
|
+
text: aiInstruction.trim(),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return { content };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Adaptive verbosity system - automatically downgrades verbosity based on response size
|
|
150
|
+
* Returns adjusted verbosity level and compression info for AI instructions
|
|
151
|
+
*
|
|
152
|
+
* @deprecated Use adaptiveResponse instead for more flexible compression
|
|
153
|
+
*/
|
|
154
|
+
function adaptiveVerbosity(data, requestedVerbosity) {
|
|
155
|
+
const sizeKB = calculateSizeKB(data);
|
|
156
|
+
// No adjustment needed - response is within ideal size
|
|
157
|
+
if (sizeKB <= RESPONSE_SIZE_THRESHOLDS.IDEAL_SIZE_KB) {
|
|
158
|
+
return {
|
|
159
|
+
adjustedVerbosity: requestedVerbosity,
|
|
160
|
+
sizeKB,
|
|
161
|
+
wasCompressed: false,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
// Determine appropriate verbosity based on size
|
|
165
|
+
let adjustedVerbosity = requestedVerbosity;
|
|
166
|
+
let compressionReason = "";
|
|
167
|
+
let aiInstruction = "";
|
|
168
|
+
if (sizeKB > RESPONSE_SIZE_THRESHOLDS.MAX_SIZE_KB) {
|
|
169
|
+
// Emergency: Force inventory mode
|
|
170
|
+
adjustedVerbosity = "inventory";
|
|
171
|
+
compressionReason = `Response size (${sizeKB.toFixed(0)}KB) exceeds maximum threshold (${RESPONSE_SIZE_THRESHOLDS.MAX_SIZE_KB}KB)`;
|
|
172
|
+
aiInstruction =
|
|
173
|
+
`⚠️ RESPONSE AUTO-COMPRESSED: The response was automatically reduced to 'inventory' verbosity (names/IDs only) because the full response would be ${sizeKB.toFixed(0)}KB, which would exhaust Claude Desktop's context window.\n\n` +
|
|
174
|
+
`To get more detail:\n` +
|
|
175
|
+
`• Use format='filtered' with collection/namePattern/mode filters to narrow the scope\n` +
|
|
176
|
+
`• Use pagination (page=1, pageSize=20) to retrieve data in smaller chunks\n` +
|
|
177
|
+
`• Use returnAsLinks=true to get resource_link references instead of full data\n\n` +
|
|
178
|
+
`Current response contains variable/collection names and IDs only.`;
|
|
179
|
+
}
|
|
180
|
+
else if (sizeKB > RESPONSE_SIZE_THRESHOLDS.CRITICAL_SIZE_KB) {
|
|
181
|
+
// Critical: Downgrade to summary if higher was requested
|
|
182
|
+
if (requestedVerbosity === "full" || requestedVerbosity === "standard") {
|
|
183
|
+
adjustedVerbosity = "summary";
|
|
184
|
+
compressionReason = `Response size (${sizeKB.toFixed(0)}KB) exceeds critical threshold (${RESPONSE_SIZE_THRESHOLDS.CRITICAL_SIZE_KB}KB)`;
|
|
185
|
+
aiInstruction =
|
|
186
|
+
`⚠️ RESPONSE AUTO-COMPRESSED: The response was automatically reduced to 'summary' verbosity because the ${requestedVerbosity} response would be ${sizeKB.toFixed(0)}KB, risking context window exhaustion.\n\n` +
|
|
187
|
+
`To get more detail, use filtering options:\n` +
|
|
188
|
+
`• format='filtered' with collection='CollectionName' to focus on specific collections\n` +
|
|
189
|
+
`• namePattern='color' to filter by variable name\n` +
|
|
190
|
+
`• mode='Light' to filter by mode\n` +
|
|
191
|
+
`• pagination with smaller pageSize values\n\n` +
|
|
192
|
+
`Current response includes variable names, types, and mode information.`;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
else if (sizeKB > RESPONSE_SIZE_THRESHOLDS.WARNING_SIZE_KB) {
|
|
196
|
+
// Warning: Downgrade full to standard
|
|
197
|
+
if (requestedVerbosity === "full") {
|
|
198
|
+
adjustedVerbosity = "standard";
|
|
199
|
+
compressionReason = `Response size (${sizeKB.toFixed(0)}KB) exceeds warning threshold (${RESPONSE_SIZE_THRESHOLDS.WARNING_SIZE_KB}KB)`;
|
|
200
|
+
aiInstruction =
|
|
201
|
+
`ℹ️ RESPONSE OPTIMIZED: The response was automatically reduced to 'standard' verbosity because the full response would be ${sizeKB.toFixed(0)}KB.\n\n` +
|
|
202
|
+
`This response includes essential variable properties. For specific details, use filtering:\n` +
|
|
203
|
+
`• format='filtered' with collection/namePattern/mode filters\n` +
|
|
204
|
+
`• Request verbosity='full' with specific filters to get complete data for a subset`;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
const wasCompressed = adjustedVerbosity !== requestedVerbosity;
|
|
208
|
+
if (wasCompressed) {
|
|
209
|
+
logger.info({
|
|
210
|
+
originalVerbosity: requestedVerbosity,
|
|
211
|
+
adjustedVerbosity,
|
|
212
|
+
sizeKB: sizeKB.toFixed(2),
|
|
213
|
+
threshold: compressionReason,
|
|
214
|
+
}, "Adaptive compression applied");
|
|
215
|
+
}
|
|
216
|
+
return {
|
|
217
|
+
adjustedVerbosity,
|
|
218
|
+
sizeKB,
|
|
219
|
+
wasCompressed,
|
|
220
|
+
compressionReason: wasCompressed ? compressionReason : undefined,
|
|
221
|
+
aiInstruction: wasCompressed ? aiInstruction : undefined,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Generate compact summary of variables data (~2K tokens)
|
|
226
|
+
* Returns high-level overview with counts and names
|
|
227
|
+
*/
|
|
228
|
+
function generateSummary(data) {
|
|
229
|
+
const summary = {
|
|
230
|
+
fileKey: data.fileKey,
|
|
231
|
+
timestamp: data.timestamp,
|
|
232
|
+
source: data.source || 'cache',
|
|
233
|
+
overview: {
|
|
234
|
+
total_variables: data.variables?.length || 0,
|
|
235
|
+
total_collections: data.variableCollections?.length || 0,
|
|
236
|
+
},
|
|
237
|
+
collections: data.variableCollections?.map((c) => ({
|
|
238
|
+
id: c.id,
|
|
239
|
+
name: c.name,
|
|
240
|
+
modes: c.modes?.map((m) => ({ id: m.modeId, name: m.name })),
|
|
241
|
+
variable_count: c.variableIds?.length || 0,
|
|
242
|
+
})) || [],
|
|
243
|
+
variables_by_type: {},
|
|
244
|
+
variable_names: [],
|
|
245
|
+
};
|
|
246
|
+
// Count variables by type
|
|
247
|
+
const typeCount = {};
|
|
248
|
+
const names = [];
|
|
249
|
+
data.variables?.forEach((v) => {
|
|
250
|
+
typeCount[v.resolvedType] = (typeCount[v.resolvedType] || 0) + 1;
|
|
251
|
+
names.push(v.name);
|
|
252
|
+
});
|
|
253
|
+
summary.variables_by_type = typeCount;
|
|
254
|
+
summary.variable_names = names;
|
|
255
|
+
return summary;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Apply filters to variables data
|
|
259
|
+
*/
|
|
260
|
+
function applyFilters(data, filters, verbosity = "standard") {
|
|
261
|
+
let filteredVariables = [...(data.variables || [])];
|
|
262
|
+
let filteredCollections = [...(data.variableCollections || [])];
|
|
263
|
+
// Filter by collection name or ID
|
|
264
|
+
if (filters.collection) {
|
|
265
|
+
const collectionFilter = filters.collection.toLowerCase();
|
|
266
|
+
filteredCollections = filteredCollections.filter((c) => c.name?.toLowerCase().includes(collectionFilter) ||
|
|
267
|
+
c.id === filters.collection);
|
|
268
|
+
const collectionIds = new Set(filteredCollections.map((c) => c.id));
|
|
269
|
+
filteredVariables = filteredVariables.filter((v) => collectionIds.has(v.variableCollectionId));
|
|
270
|
+
}
|
|
271
|
+
// Filter by variable name pattern (regex or substring)
|
|
272
|
+
if (filters.namePattern) {
|
|
273
|
+
try {
|
|
274
|
+
const regex = new RegExp(filters.namePattern, 'i');
|
|
275
|
+
filteredVariables = filteredVariables.filter((v) => regex.test(v.name));
|
|
276
|
+
}
|
|
277
|
+
catch (e) {
|
|
278
|
+
// If regex fails, fall back to substring match
|
|
279
|
+
const pattern = filters.namePattern.toLowerCase();
|
|
280
|
+
filteredVariables = filteredVariables.filter((v) => v.name?.toLowerCase().includes(pattern));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
// Find target mode ID if mode filter specified (needed for both filtering and transformation)
|
|
284
|
+
let targetModeId = null;
|
|
285
|
+
let targetModeName = null;
|
|
286
|
+
if (filters.mode) {
|
|
287
|
+
const modeFilter = filters.mode.toLowerCase();
|
|
288
|
+
// Try direct mode ID match first
|
|
289
|
+
if (data.variableCollections || filteredCollections.length > 0) {
|
|
290
|
+
for (const collection of filteredCollections) {
|
|
291
|
+
if (collection.modes) {
|
|
292
|
+
const mode = collection.modes.find((m) => m.modeId === filters.mode ||
|
|
293
|
+
m.name?.toLowerCase().includes(modeFilter));
|
|
294
|
+
if (mode) {
|
|
295
|
+
targetModeId = mode.modeId;
|
|
296
|
+
targetModeName = mode.name;
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// Filter by mode name or ID
|
|
304
|
+
if (filters.mode) {
|
|
305
|
+
filteredVariables = filteredVariables.filter((v) => {
|
|
306
|
+
// Check if variable has values for the specified mode
|
|
307
|
+
if (v.valuesByMode) {
|
|
308
|
+
// Try to match by mode ID directly
|
|
309
|
+
if (v.valuesByMode[filters.mode]) {
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
// Try using resolved targetModeId
|
|
313
|
+
if (targetModeId && v.valuesByMode[targetModeId]) {
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
// Try to match by mode name through collections
|
|
317
|
+
const collection = filteredCollections.find((c) => c.id === v.variableCollectionId);
|
|
318
|
+
if (collection?.modes) {
|
|
319
|
+
const mode = collection.modes.find((m) => m.name?.toLowerCase().includes(filters.mode.toLowerCase()) || m.modeId === filters.mode);
|
|
320
|
+
return mode && v.valuesByMode[mode.modeId];
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return false;
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
// Transform valuesByMode based on verbosity level
|
|
327
|
+
// This is critical for reducing response size with multi-mode variables
|
|
328
|
+
if (verbosity !== "full") {
|
|
329
|
+
filteredVariables = filteredVariables.map((v) => {
|
|
330
|
+
const variable = { ...v };
|
|
331
|
+
// Use original collections array for lookup, not filtered, since we need mode metadata
|
|
332
|
+
// Handle both variableCollections and collections property names
|
|
333
|
+
const collections = data.variableCollections || data.collections || [];
|
|
334
|
+
const collection = collections.find((c) => c.id === v.variableCollectionId);
|
|
335
|
+
if (verbosity === "inventory") {
|
|
336
|
+
// Inventory: Remove valuesByMode entirely, add mode count
|
|
337
|
+
delete variable.valuesByMode;
|
|
338
|
+
if (collection?.modes) {
|
|
339
|
+
variable.modeCount = collection.modes.length;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
else if (verbosity === "summary") {
|
|
343
|
+
// Summary: Replace valuesByMode with mode names array
|
|
344
|
+
if (variable.valuesByMode && collection?.modes) {
|
|
345
|
+
variable.modeNames = collection.modes.map((m) => m.name);
|
|
346
|
+
variable.modeCount = collection.modes.length;
|
|
347
|
+
}
|
|
348
|
+
delete variable.valuesByMode;
|
|
349
|
+
}
|
|
350
|
+
else if (verbosity === "standard") {
|
|
351
|
+
// Standard: If mode parameter specified, filter to that mode only
|
|
352
|
+
if (targetModeId && variable.valuesByMode) {
|
|
353
|
+
const singleModeValue = variable.valuesByMode[targetModeId];
|
|
354
|
+
variable.valuesByMode = { [targetModeId]: singleModeValue };
|
|
355
|
+
variable.selectedMode = {
|
|
356
|
+
modeId: targetModeId,
|
|
357
|
+
modeName: targetModeName,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
// If no mode specified, keep all valuesByMode but add metadata for context
|
|
361
|
+
else if (variable.valuesByMode && collection?.modes) {
|
|
362
|
+
variable.modeMetadata = collection.modes.map((m) => ({
|
|
363
|
+
modeId: m.modeId,
|
|
364
|
+
modeName: m.name,
|
|
365
|
+
}));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return variable;
|
|
369
|
+
});
|
|
370
|
+
// Apply field-level filtering based on verbosity
|
|
371
|
+
if (verbosity === "inventory") {
|
|
372
|
+
filteredVariables = filteredVariables.map((v) => ({
|
|
373
|
+
id: v.id,
|
|
374
|
+
name: v.name,
|
|
375
|
+
resolvedType: v.resolvedType,
|
|
376
|
+
variableCollectionId: v.variableCollectionId,
|
|
377
|
+
...(v.modeCount && { modeCount: v.modeCount }),
|
|
378
|
+
}));
|
|
379
|
+
}
|
|
380
|
+
else if (verbosity === "summary") {
|
|
381
|
+
filteredVariables = filteredVariables.map((v) => ({
|
|
382
|
+
id: v.id,
|
|
383
|
+
name: v.name,
|
|
384
|
+
resolvedType: v.resolvedType,
|
|
385
|
+
variableCollectionId: v.variableCollectionId,
|
|
386
|
+
...(v.modeNames && { modeNames: v.modeNames }),
|
|
387
|
+
...(v.modeCount && { modeCount: v.modeCount }),
|
|
388
|
+
}));
|
|
389
|
+
}
|
|
390
|
+
else if (verbosity === "standard") {
|
|
391
|
+
filteredVariables = filteredVariables.map((v) => ({
|
|
392
|
+
id: v.id,
|
|
393
|
+
name: v.name,
|
|
394
|
+
resolvedType: v.resolvedType,
|
|
395
|
+
valuesByMode: v.valuesByMode,
|
|
396
|
+
description: v.description,
|
|
397
|
+
variableCollectionId: v.variableCollectionId,
|
|
398
|
+
...(v.scopes && { scopes: v.scopes }),
|
|
399
|
+
...(v.selectedMode && { selectedMode: v.selectedMode }),
|
|
400
|
+
...(v.modeMetadata && { modeMetadata: v.modeMetadata }),
|
|
401
|
+
}));
|
|
402
|
+
}
|
|
403
|
+
// For "full" verbosity, return all fields (no filtering)
|
|
404
|
+
}
|
|
405
|
+
// IMPORTANT: Only return filtered data, not the entire original data object
|
|
406
|
+
// The ...data spread was including massive metadata that bloated responses
|
|
407
|
+
return {
|
|
408
|
+
variables: filteredVariables,
|
|
409
|
+
variableCollections: filteredCollections,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Apply pagination to variables
|
|
414
|
+
*/
|
|
415
|
+
function paginateVariables(data, page = 1, pageSize = 50) {
|
|
416
|
+
const variables = data.variables || [];
|
|
417
|
+
const totalVariables = variables.length;
|
|
418
|
+
const totalPages = Math.ceil(totalVariables / pageSize);
|
|
419
|
+
// Validate page number
|
|
420
|
+
const currentPage = Math.max(1, Math.min(page, totalPages || 1));
|
|
421
|
+
// Calculate pagination
|
|
422
|
+
const startIndex = (currentPage - 1) * pageSize;
|
|
423
|
+
const endIndex = startIndex + pageSize;
|
|
424
|
+
const paginatedVariables = variables.slice(startIndex, endIndex);
|
|
425
|
+
return {
|
|
426
|
+
data: {
|
|
427
|
+
...data,
|
|
428
|
+
variables: paginatedVariables,
|
|
429
|
+
},
|
|
430
|
+
pagination: {
|
|
431
|
+
currentPage,
|
|
432
|
+
pageSize,
|
|
433
|
+
totalVariables,
|
|
434
|
+
totalPages,
|
|
435
|
+
hasNextPage: currentPage < totalPages,
|
|
436
|
+
hasPrevPage: currentPage > 1,
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
/**
|
|
441
|
+
* Manage LRU cache eviction
|
|
442
|
+
*/
|
|
443
|
+
function evictOldestCacheEntry(cache) {
|
|
444
|
+
if (cache.size >= MAX_CACHE_ENTRIES) {
|
|
445
|
+
// Find oldest entry
|
|
446
|
+
let oldestKey = null;
|
|
447
|
+
let oldestTime = Infinity;
|
|
448
|
+
for (const [key, entry] of cache.entries()) {
|
|
449
|
+
if (entry.timestamp < oldestTime) {
|
|
450
|
+
oldestTime = entry.timestamp;
|
|
451
|
+
oldestKey = key;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (oldestKey) {
|
|
455
|
+
cache.delete(oldestKey);
|
|
456
|
+
logger.info({ evictedKey: oldestKey }, 'Evicted oldest cache entry (LRU)');
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Register Figma API tools with the MCP server
|
|
462
|
+
*/
|
|
463
|
+
export function registerFigmaAPITools(server, getFigmaAPI, getCurrentUrl, getConsoleMonitor, getBrowserManager, ensureInitialized, variablesCache) {
|
|
464
|
+
// Tool 8: Get File Data (General Purpose)
|
|
465
|
+
// NOTE: For specific use cases, consider using specialized tools:
|
|
466
|
+
// - figma_get_component_for_development: For UI component implementation
|
|
467
|
+
// - figma_get_file_for_plugin: For plugin development
|
|
468
|
+
server.tool("figma_get_file_data", "Get full file structure and document tree. WARNING: Can consume large amounts of tokens. NOT recommended for component descriptions (use figma_get_component instead). Best for understanding file structure or finding component nodeIds. Start with verbosity='summary' and depth=1 for initial exploration.", {
|
|
469
|
+
fileUrl: z
|
|
470
|
+
.string()
|
|
471
|
+
.url()
|
|
472
|
+
.optional()
|
|
473
|
+
.describe("Figma file URL (e.g., https://figma.com/design/abc123). REQUIRED unless figma_navigate was already called. If not provided, ask the user to share their Figma file URL (they can copy it from Figma Desktop via right-click → 'Copy link')."),
|
|
474
|
+
depth: z
|
|
475
|
+
.number()
|
|
476
|
+
.min(0)
|
|
477
|
+
.max(3)
|
|
478
|
+
.optional()
|
|
479
|
+
.default(1)
|
|
480
|
+
.describe("How many levels of children to include (default: 1, max: 3). Start with 1 to prevent context exhaustion. Use 0 for full tree only when absolutely necessary."),
|
|
481
|
+
verbosity: z
|
|
482
|
+
.enum(["summary", "standard", "full"])
|
|
483
|
+
.optional()
|
|
484
|
+
.default("summary")
|
|
485
|
+
.describe("Controls payload size: 'summary' (IDs/names/types only, ~90% smaller - RECOMMENDED), 'standard' (essential properties, ~50% smaller), 'full' (everything). Default: summary for token efficiency."),
|
|
486
|
+
nodeIds: z
|
|
487
|
+
.array(z.string())
|
|
488
|
+
.optional()
|
|
489
|
+
.describe("Specific node IDs to retrieve (optional)"),
|
|
490
|
+
enrich: z
|
|
491
|
+
.boolean()
|
|
492
|
+
.optional()
|
|
493
|
+
.describe("Set to true when user asks for: file statistics, health metrics, design system audit, or quality analysis. Adds statistics, health scores, and audit summaries. Default: false"),
|
|
494
|
+
}, async ({ fileUrl, depth, nodeIds, enrich, verbosity }) => {
|
|
495
|
+
try {
|
|
496
|
+
const api = await getFigmaAPI();
|
|
497
|
+
// Use provided URL or current URL from browser
|
|
498
|
+
const url = fileUrl || getCurrentUrl();
|
|
499
|
+
if (!url) {
|
|
500
|
+
throw new Error("No Figma file URL provided. Either pass fileUrl parameter or call figma_navigate first.");
|
|
501
|
+
}
|
|
502
|
+
const fileKey = extractFileKey(url);
|
|
503
|
+
if (!fileKey) {
|
|
504
|
+
throw new Error(`Invalid Figma URL: ${url}`);
|
|
505
|
+
}
|
|
506
|
+
logger.info({ fileKey, depth, nodeIds, enrich, verbosity }, "Fetching file data");
|
|
507
|
+
const fileData = await api.getFile(fileKey, {
|
|
508
|
+
depth,
|
|
509
|
+
ids: nodeIds,
|
|
510
|
+
});
|
|
511
|
+
// Apply verbosity filtering to reduce payload size
|
|
512
|
+
const filterNode = (node, level) => {
|
|
513
|
+
if (!node)
|
|
514
|
+
return node;
|
|
515
|
+
if (level === "summary") {
|
|
516
|
+
// Summary: Only IDs, names, types (~90% reduction)
|
|
517
|
+
return {
|
|
518
|
+
id: node.id,
|
|
519
|
+
name: node.name,
|
|
520
|
+
type: node.type,
|
|
521
|
+
...(node.children && {
|
|
522
|
+
children: node.children.map((child) => filterNode(child, level))
|
|
523
|
+
}),
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
if (level === "standard") {
|
|
527
|
+
// Standard: Essential properties for plugin development (~50% reduction)
|
|
528
|
+
const filtered = {
|
|
529
|
+
id: node.id,
|
|
530
|
+
name: node.name,
|
|
531
|
+
type: node.type,
|
|
532
|
+
visible: node.visible,
|
|
533
|
+
locked: node.locked,
|
|
534
|
+
};
|
|
535
|
+
// Include bounds for layout calculations
|
|
536
|
+
if (node.absoluteBoundingBox)
|
|
537
|
+
filtered.absoluteBoundingBox = node.absoluteBoundingBox;
|
|
538
|
+
if (node.size)
|
|
539
|
+
filtered.size = node.size;
|
|
540
|
+
// Include component/instance info for plugin work
|
|
541
|
+
if (node.componentId)
|
|
542
|
+
filtered.componentId = node.componentId;
|
|
543
|
+
if (node.componentPropertyReferences)
|
|
544
|
+
filtered.componentPropertyReferences = node.componentPropertyReferences;
|
|
545
|
+
// Include basic styling (but not full details)
|
|
546
|
+
if (node.fills && node.fills.length > 0) {
|
|
547
|
+
filtered.fills = node.fills.map((fill) => ({
|
|
548
|
+
type: fill.type,
|
|
549
|
+
visible: fill.visible,
|
|
550
|
+
...(fill.color && { color: fill.color }),
|
|
551
|
+
}));
|
|
552
|
+
}
|
|
553
|
+
// Include plugin data if present
|
|
554
|
+
if (node.pluginData)
|
|
555
|
+
filtered.pluginData = node.pluginData;
|
|
556
|
+
if (node.sharedPluginData)
|
|
557
|
+
filtered.sharedPluginData = node.sharedPluginData;
|
|
558
|
+
// Recursively filter children
|
|
559
|
+
if (node.children) {
|
|
560
|
+
filtered.children = node.children.map((child) => filterNode(child, level));
|
|
561
|
+
}
|
|
562
|
+
return filtered;
|
|
563
|
+
}
|
|
564
|
+
// Full: Return everything
|
|
565
|
+
return node;
|
|
566
|
+
};
|
|
567
|
+
const filteredDocument = verbosity !== "full"
|
|
568
|
+
? filterNode(fileData.document, verbosity || "standard")
|
|
569
|
+
: fileData.document;
|
|
570
|
+
let response = {
|
|
571
|
+
fileKey,
|
|
572
|
+
name: fileData.name,
|
|
573
|
+
lastModified: fileData.lastModified,
|
|
574
|
+
version: fileData.version,
|
|
575
|
+
document: filteredDocument,
|
|
576
|
+
components: fileData.components
|
|
577
|
+
? Object.keys(fileData.components).length
|
|
578
|
+
: 0,
|
|
579
|
+
styles: fileData.styles
|
|
580
|
+
? Object.keys(fileData.styles).length
|
|
581
|
+
: 0,
|
|
582
|
+
verbosity: verbosity || "standard",
|
|
583
|
+
...(nodeIds && {
|
|
584
|
+
requestedNodes: nodeIds,
|
|
585
|
+
nodes: fileData.nodes,
|
|
586
|
+
}),
|
|
587
|
+
};
|
|
588
|
+
// Apply enrichment if requested
|
|
589
|
+
if (enrich) {
|
|
590
|
+
const enrichmentOptions = {
|
|
591
|
+
enrich: true,
|
|
592
|
+
include_usage: true,
|
|
593
|
+
};
|
|
594
|
+
response = await enrichmentService.enrichFileData({ ...response, ...fileData }, enrichmentOptions);
|
|
595
|
+
}
|
|
596
|
+
const finalResponse = {
|
|
597
|
+
...response,
|
|
598
|
+
enriched: enrich || false,
|
|
599
|
+
};
|
|
600
|
+
// Use adaptive response to prevent context exhaustion
|
|
601
|
+
return adaptiveResponse(finalResponse, {
|
|
602
|
+
toolName: "figma_get_file_data",
|
|
603
|
+
compressionCallback: (adjustedLevel) => {
|
|
604
|
+
// Re-apply node filtering with lower verbosity
|
|
605
|
+
const level = adjustedLevel;
|
|
606
|
+
const refiltered = {
|
|
607
|
+
...finalResponse,
|
|
608
|
+
document: verbosity !== "full"
|
|
609
|
+
? filterNode(fileData.document, level)
|
|
610
|
+
: fileData.document,
|
|
611
|
+
verbosity: level,
|
|
612
|
+
};
|
|
613
|
+
return refiltered;
|
|
614
|
+
},
|
|
615
|
+
suggestedActions: [
|
|
616
|
+
"Use verbosity='summary' with depth=1 for initial exploration",
|
|
617
|
+
"Use verbosity='standard' for essential properties",
|
|
618
|
+
"Request specific nodeIds to narrow the scope",
|
|
619
|
+
"Reduce depth parameter (max 3, recommend 1-2)",
|
|
620
|
+
],
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
catch (error) {
|
|
624
|
+
logger.error({ error }, "Failed to get file data");
|
|
625
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
626
|
+
return {
|
|
627
|
+
content: [
|
|
628
|
+
{
|
|
629
|
+
type: "text",
|
|
630
|
+
text: JSON.stringify({
|
|
631
|
+
error: errorMessage,
|
|
632
|
+
message: "Failed to retrieve Figma file data",
|
|
633
|
+
hint: "Make sure FIGMA_ACCESS_TOKEN is configured and the file is accessible",
|
|
634
|
+
}, null, 2),
|
|
635
|
+
},
|
|
636
|
+
],
|
|
637
|
+
isError: true,
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
/**
|
|
642
|
+
* Tool 9: Get Variables (Design Tokens)
|
|
643
|
+
*
|
|
644
|
+
* WORKFLOW:
|
|
645
|
+
* - Primary: Attempts to fetch variables via Figma REST API (requires Enterprise plan)
|
|
646
|
+
* - Fallback: On 403 error, provides console-based extraction snippet
|
|
647
|
+
*
|
|
648
|
+
* TWO-CALL PATTERN (when API unavailable):
|
|
649
|
+
* 1. First call: Returns snippet + instructions (useConsoleFallback: true, default)
|
|
650
|
+
* 2. User runs snippet in Figma plugin console
|
|
651
|
+
* 3. Second call: Parses captured data (parseFromConsole: true)
|
|
652
|
+
*
|
|
653
|
+
* IMPORTANT: Snippet requires Figma Plugin API context, not browser DevTools console.
|
|
654
|
+
*/
|
|
655
|
+
server.tool("figma_get_variables", {
|
|
656
|
+
fileUrl: z
|
|
657
|
+
.string()
|
|
658
|
+
.url()
|
|
659
|
+
.optional()
|
|
660
|
+
.describe("Figma file URL (e.g., https://figma.com/design/abc123). REQUIRED unless figma_navigate was already called. If not provided, ask the user to share their Figma file URL (they can copy it from Figma Desktop via right-click → 'Copy link')."),
|
|
661
|
+
includePublished: z
|
|
662
|
+
.boolean()
|
|
663
|
+
.optional()
|
|
664
|
+
.default(true)
|
|
665
|
+
.describe("Include published variables from libraries"),
|
|
666
|
+
verbosity: z
|
|
667
|
+
.enum(["inventory", "summary", "standard", "full"])
|
|
668
|
+
.optional()
|
|
669
|
+
.default("standard")
|
|
670
|
+
.describe("Controls payload size: 'inventory' (names/IDs only, ~95% smaller, use with filtered), 'summary' (names/values only, ~80% smaller), 'standard' (essential properties, ~45% smaller), 'full' (everything). Default: standard"),
|
|
671
|
+
enrich: z
|
|
672
|
+
.boolean()
|
|
673
|
+
.optional()
|
|
674
|
+
.describe("Set to true when user asks for: CSS/Sass/Tailwind exports, code examples, design tokens, usage information, dependencies, or any export format. Adds resolved values, dependency graphs, and usage analysis. Default: false"),
|
|
675
|
+
include_usage: z
|
|
676
|
+
.boolean()
|
|
677
|
+
.optional()
|
|
678
|
+
.describe("Include usage in styles and components (requires enrich=true)"),
|
|
679
|
+
include_dependencies: z
|
|
680
|
+
.boolean()
|
|
681
|
+
.optional()
|
|
682
|
+
.describe("Include variable dependency graph (requires enrich=true)"),
|
|
683
|
+
include_exports: z
|
|
684
|
+
.boolean()
|
|
685
|
+
.optional()
|
|
686
|
+
.describe("Include export format examples (requires enrich=true)"),
|
|
687
|
+
export_formats: z
|
|
688
|
+
.array(z.enum(["css", "sass", "tailwind", "typescript", "json"]))
|
|
689
|
+
.optional()
|
|
690
|
+
.describe("Which code formats to generate examples for. Use when user mentions specific formats like 'CSS', 'Tailwind', 'SCSS', 'TypeScript', etc. Automatically enables enrichment."),
|
|
691
|
+
format: z
|
|
692
|
+
.enum(["summary", "filtered", "full"])
|
|
693
|
+
.optional()
|
|
694
|
+
.default("full")
|
|
695
|
+
.describe("Response format: 'summary' (~2K tokens with overview and names only), 'filtered' (apply collection/name/mode filters), 'full' (complete dataset from cache or fetch). " +
|
|
696
|
+
"Summary is recommended for initial exploration. Full format returns all data but may be auto-summarized if >25K tokens. Default: full"),
|
|
697
|
+
collection: z
|
|
698
|
+
.string()
|
|
699
|
+
.optional()
|
|
700
|
+
.describe("Filter variables by collection name or ID. Case-insensitive substring match. Only applies when format='filtered'. Example: 'Primitives' or 'VariableCollectionId:123'"),
|
|
701
|
+
namePattern: z
|
|
702
|
+
.string()
|
|
703
|
+
.optional()
|
|
704
|
+
.describe("Filter variables by name using regex pattern or substring. Case-insensitive. Only applies when format='filtered'. Example: 'color/brand' or '^typography'"),
|
|
705
|
+
mode: z
|
|
706
|
+
.string()
|
|
707
|
+
.optional()
|
|
708
|
+
.describe("Filter variables by mode name or ID. Only returns variables that have values for this mode. Only applies when format='filtered'. Example: 'Light' or 'Dark'"),
|
|
709
|
+
returnAsLinks: z
|
|
710
|
+
.boolean()
|
|
711
|
+
.optional()
|
|
712
|
+
.default(false)
|
|
713
|
+
.describe("Return variables as resource_link references instead of full data. Drastically reduces payload size (100+ variables = ~20KB vs >1MB). Use with figma_get_variable_by_id to fetch specific variables. Recommended for large variable sets. Default: false"),
|
|
714
|
+
refreshCache: z
|
|
715
|
+
.boolean()
|
|
716
|
+
.optional()
|
|
717
|
+
.default(false)
|
|
718
|
+
.describe("Force refresh cache by fetching fresh data from Figma. Use when data may have changed since last fetch. Default: false (use cached data if available and fresh)"),
|
|
719
|
+
useConsoleFallback: z
|
|
720
|
+
.boolean()
|
|
721
|
+
.optional()
|
|
722
|
+
.default(true)
|
|
723
|
+
.describe("Enable automatic fallback to console-based extraction when REST API returns 403 (Figma Enterprise plan required). " +
|
|
724
|
+
"When enabled, provides a JavaScript snippet that users run in Figma's plugin console. " +
|
|
725
|
+
"This is STEP 1 of a two-call workflow. After receiving the snippet, instruct the user to run it, then call this tool again with parseFromConsole=true. " +
|
|
726
|
+
"Default: true. Set to false only to disable the fallback entirely."),
|
|
727
|
+
parseFromConsole: z
|
|
728
|
+
.boolean()
|
|
729
|
+
.optional()
|
|
730
|
+
.default(false)
|
|
731
|
+
.describe("Parse variables from console logs after user has executed the snippet. " +
|
|
732
|
+
"This is STEP 2 of the two-call workflow. Set to true ONLY after: " +
|
|
733
|
+
"(1) you received a console snippet from the first call, " +
|
|
734
|
+
"(2) instructed the user to run it in Figma's PLUGIN console (Plugins → Development → Open Console or existing plugin), " +
|
|
735
|
+
"(3) user confirmed they ran the snippet and saw '✅ Variables data captured!' message. " +
|
|
736
|
+
"Default: false. Never set to true on the first call."),
|
|
737
|
+
page: z
|
|
738
|
+
.number()
|
|
739
|
+
.int()
|
|
740
|
+
.min(1)
|
|
741
|
+
.optional()
|
|
742
|
+
.default(1)
|
|
743
|
+
.describe("Page number for paginated results (1-based). Use when response is too large (>1MB). Each page returns up to 50 variables."),
|
|
744
|
+
pageSize: z
|
|
745
|
+
.number()
|
|
746
|
+
.int()
|
|
747
|
+
.min(1)
|
|
748
|
+
.max(100)
|
|
749
|
+
.optional()
|
|
750
|
+
.default(50)
|
|
751
|
+
.describe("Number of variables per page (1-100). Default: 50. Smaller values reduce response size."),
|
|
752
|
+
}, async ({ fileUrl, includePublished, verbosity, enrich, include_usage, include_dependencies, include_exports, export_formats, format, collection, namePattern, mode, returnAsLinks, refreshCache, useConsoleFallback, parseFromConsole, page, pageSize }) => {
|
|
753
|
+
// Extract fileKey outside try block so it's available in catch block
|
|
754
|
+
const url = fileUrl || getCurrentUrl();
|
|
755
|
+
if (!url) {
|
|
756
|
+
return {
|
|
757
|
+
content: [
|
|
758
|
+
{
|
|
759
|
+
type: "text",
|
|
760
|
+
text: JSON.stringify({
|
|
761
|
+
error: "No Figma file URL provided",
|
|
762
|
+
message: "Either pass fileUrl parameter or call figma_navigate first."
|
|
763
|
+
}, null, 2),
|
|
764
|
+
},
|
|
765
|
+
],
|
|
766
|
+
isError: true,
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
const fileKey = extractFileKey(url);
|
|
770
|
+
if (!fileKey) {
|
|
771
|
+
return {
|
|
772
|
+
content: [
|
|
773
|
+
{
|
|
774
|
+
type: "text",
|
|
775
|
+
text: JSON.stringify({
|
|
776
|
+
error: `Invalid Figma URL: ${url}`,
|
|
777
|
+
message: "Could not extract file key from URL"
|
|
778
|
+
}, null, 2),
|
|
779
|
+
},
|
|
780
|
+
],
|
|
781
|
+
isError: true,
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
try {
|
|
785
|
+
// =====================================================================
|
|
786
|
+
// CACHE-FIRST LOGIC: Check if we have cached data before fetching
|
|
787
|
+
// =====================================================================
|
|
788
|
+
let cachedData = null;
|
|
789
|
+
let shouldFetch = true;
|
|
790
|
+
if (variablesCache && !parseFromConsole) {
|
|
791
|
+
const cacheEntry = variablesCache.get(fileKey);
|
|
792
|
+
if (cacheEntry) {
|
|
793
|
+
const isValid = isCacheValid(cacheEntry.timestamp);
|
|
794
|
+
if (isValid && !refreshCache) {
|
|
795
|
+
// Cache hit! Use cached data
|
|
796
|
+
cachedData = cacheEntry.data;
|
|
797
|
+
shouldFetch = false;
|
|
798
|
+
logger.info({
|
|
799
|
+
fileKey,
|
|
800
|
+
cacheAge: Date.now() - cacheEntry.timestamp,
|
|
801
|
+
variableCount: cachedData.variables?.length,
|
|
802
|
+
}, 'Using cached variables data');
|
|
803
|
+
}
|
|
804
|
+
else if (!isValid) {
|
|
805
|
+
logger.info({ fileKey, cacheAge: Date.now() - cacheEntry.timestamp }, 'Cache expired, will refresh');
|
|
806
|
+
}
|
|
807
|
+
else if (refreshCache) {
|
|
808
|
+
logger.info({ fileKey }, 'Refresh cache requested, will fetch fresh data');
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
else {
|
|
812
|
+
logger.info({ fileKey }, 'No cache entry found, will fetch data');
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
// If we have cached data, skip fetching and jump to formatting
|
|
816
|
+
if (cachedData && !shouldFetch) {
|
|
817
|
+
// Apply format logic based on user request
|
|
818
|
+
let responseData = cachedData;
|
|
819
|
+
let paginationInfo = null;
|
|
820
|
+
if (format === 'summary') {
|
|
821
|
+
// Return compact summary
|
|
822
|
+
responseData = generateSummary(cachedData);
|
|
823
|
+
logger.info({ fileKey, estimatedTokens: estimateTokens(responseData) }, 'Generated summary from cache');
|
|
824
|
+
}
|
|
825
|
+
else if (format === 'filtered') {
|
|
826
|
+
// Apply filters with verbosity-aware valuesByMode transformation
|
|
827
|
+
responseData = applyFilters(cachedData, {
|
|
828
|
+
collection,
|
|
829
|
+
namePattern,
|
|
830
|
+
mode,
|
|
831
|
+
}, verbosity || 'standard');
|
|
832
|
+
// ALWAYS apply pagination for filtered results to prevent 1MB limit
|
|
833
|
+
// Default to page 1, pageSize 50 if not specified
|
|
834
|
+
const paginated = paginateVariables(responseData, page || 1, pageSize || 50);
|
|
835
|
+
responseData = paginated.data;
|
|
836
|
+
paginationInfo = paginated.pagination;
|
|
837
|
+
// Apply verbosity filtering to minimize payload size
|
|
838
|
+
// For filtered results, default to "inventory" for maximum size reduction
|
|
839
|
+
const effectiveVerbosity = verbosity || "inventory";
|
|
840
|
+
// CRITICAL FIX: Only include collections referenced by paginated variables
|
|
841
|
+
const referencedCollectionIds = new Set(responseData.variables.map((v) => v.variableCollectionId));
|
|
842
|
+
responseData.variableCollections = responseData.variableCollections.filter((c) => referencedCollectionIds.has(c.id));
|
|
843
|
+
// Filter variables to minimal needed fields
|
|
844
|
+
responseData.variables = responseData.variables.map((v) => {
|
|
845
|
+
if (effectiveVerbosity === "inventory") {
|
|
846
|
+
// Ultra-minimal: just names and IDs for inventory purposes
|
|
847
|
+
// If mode filter is specified, include only that mode's value
|
|
848
|
+
const result = {
|
|
849
|
+
id: v.id,
|
|
850
|
+
name: v.name,
|
|
851
|
+
collectionId: v.variableCollectionId,
|
|
852
|
+
};
|
|
853
|
+
// If mode filter specified, include just that single mode's value
|
|
854
|
+
if (mode && v.valuesByMode) {
|
|
855
|
+
// Find the mode ID from the collection
|
|
856
|
+
const collection = responseData.variableCollections.find((c) => c.id === v.variableCollectionId);
|
|
857
|
+
if (collection?.modes) {
|
|
858
|
+
const modeObj = collection.modes.find((m) => m.name?.toLowerCase().includes(mode.toLowerCase()) || m.modeId === mode);
|
|
859
|
+
if (modeObj && v.valuesByMode[modeObj.modeId]) {
|
|
860
|
+
result.value = v.valuesByMode[modeObj.modeId];
|
|
861
|
+
result.mode = modeObj.name;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
return result;
|
|
866
|
+
}
|
|
867
|
+
if (effectiveVerbosity === "summary") {
|
|
868
|
+
return {
|
|
869
|
+
id: v.id,
|
|
870
|
+
name: v.name,
|
|
871
|
+
resolvedType: v.resolvedType,
|
|
872
|
+
valuesByMode: v.valuesByMode,
|
|
873
|
+
variableCollectionId: v.variableCollectionId,
|
|
874
|
+
// Include modeNames and modeCount added by applyFilters
|
|
875
|
+
...(v.modeNames && { modeNames: v.modeNames }),
|
|
876
|
+
...(v.modeCount && { modeCount: v.modeCount }),
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
if (effectiveVerbosity === "standard") {
|
|
880
|
+
return {
|
|
881
|
+
id: v.id,
|
|
882
|
+
name: v.name,
|
|
883
|
+
resolvedType: v.resolvedType,
|
|
884
|
+
valuesByMode: v.valuesByMode,
|
|
885
|
+
description: v.description,
|
|
886
|
+
variableCollectionId: v.variableCollectionId,
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
return v; // full
|
|
890
|
+
});
|
|
891
|
+
// Filter collections to remove massive variableIds arrays
|
|
892
|
+
responseData.variableCollections = responseData.variableCollections.map((c) => {
|
|
893
|
+
if (effectiveVerbosity === "inventory") {
|
|
894
|
+
// Ultra-minimal: just ID and name, mode names only (no full mode objects)
|
|
895
|
+
return {
|
|
896
|
+
id: c.id,
|
|
897
|
+
name: c.name,
|
|
898
|
+
modeNames: c.modes?.map((m) => m.name) || [],
|
|
899
|
+
};
|
|
900
|
+
}
|
|
901
|
+
if (effectiveVerbosity === "summary") {
|
|
902
|
+
return {
|
|
903
|
+
id: c.id,
|
|
904
|
+
name: c.name,
|
|
905
|
+
modes: c.modes, // Keep modes for user to understand mode structure
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
if (effectiveVerbosity === "standard") {
|
|
909
|
+
return {
|
|
910
|
+
id: c.id,
|
|
911
|
+
name: c.name,
|
|
912
|
+
modes: c.modes,
|
|
913
|
+
defaultModeId: c.defaultModeId,
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
// For full, remove variableIds array to reduce size
|
|
917
|
+
const { variableIds, ...rest } = c;
|
|
918
|
+
return rest;
|
|
919
|
+
});
|
|
920
|
+
logger.info({
|
|
921
|
+
fileKey,
|
|
922
|
+
originalCount: cachedData.variables?.length,
|
|
923
|
+
filteredCount: paginationInfo.totalVariables,
|
|
924
|
+
returnedCount: responseData.variables?.length,
|
|
925
|
+
page: paginationInfo.currentPage,
|
|
926
|
+
totalPages: paginationInfo.totalPages,
|
|
927
|
+
verbosity: effectiveVerbosity,
|
|
928
|
+
}, 'Applied filters, pagination, and verbosity filtering to cached data');
|
|
929
|
+
}
|
|
930
|
+
else {
|
|
931
|
+
// format === 'full'
|
|
932
|
+
// Check if we need to auto-summarize
|
|
933
|
+
const estimatedTokens = estimateTokens(responseData);
|
|
934
|
+
if (estimatedTokens > 25000) {
|
|
935
|
+
logger.warn({ fileKey, estimatedTokens }, 'Full data exceeds MCP token limit (25K), auto-summarizing. Use format=summary or format=filtered to get specific data.');
|
|
936
|
+
const summary = generateSummary(responseData);
|
|
937
|
+
return {
|
|
938
|
+
content: [
|
|
939
|
+
{
|
|
940
|
+
type: "text",
|
|
941
|
+
text: JSON.stringify({
|
|
942
|
+
fileKey,
|
|
943
|
+
source: 'cache_auto_summarized',
|
|
944
|
+
warning: 'Full dataset exceeds MCP token limit (25,000 tokens)',
|
|
945
|
+
suggestion: 'Use format="summary" for overview or format="filtered" with collection/namePattern/mode filters to get specific variables',
|
|
946
|
+
estimatedTokens,
|
|
947
|
+
summary,
|
|
948
|
+
}, null, 2),
|
|
949
|
+
},
|
|
950
|
+
],
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
// Return cached/processed data
|
|
955
|
+
// If returnAsLinks=true, return resource_link references instead of full data
|
|
956
|
+
if (returnAsLinks) {
|
|
957
|
+
const summary = {
|
|
958
|
+
fileKey,
|
|
959
|
+
source: 'cache',
|
|
960
|
+
totalVariables: responseData.variables?.length || 0,
|
|
961
|
+
totalCollections: responseData.variableCollections?.length || 0,
|
|
962
|
+
...(paginationInfo && { pagination: paginationInfo }),
|
|
963
|
+
};
|
|
964
|
+
// Build resource_link content for each variable
|
|
965
|
+
const content = [
|
|
966
|
+
{
|
|
967
|
+
type: "text",
|
|
968
|
+
text: JSON.stringify(summary),
|
|
969
|
+
},
|
|
970
|
+
];
|
|
971
|
+
// Add resource_link for each variable (minimal overhead ~150 bytes each)
|
|
972
|
+
responseData.variables?.forEach((v) => {
|
|
973
|
+
content.push({
|
|
974
|
+
type: "resource_link",
|
|
975
|
+
uri: `figma://variable/${v.id}`,
|
|
976
|
+
name: v.name || v.id,
|
|
977
|
+
description: `${v.resolvedType || 'VARIABLE'} from ${fileKey}`,
|
|
978
|
+
});
|
|
979
|
+
});
|
|
980
|
+
logger.info({
|
|
981
|
+
fileKey,
|
|
982
|
+
format: 'resource_links',
|
|
983
|
+
variableCount: responseData.variables?.length || 0,
|
|
984
|
+
linkCount: content.length - 1, // -1 for summary text
|
|
985
|
+
estimatedSizeKB: (content.length * 150) / 1024,
|
|
986
|
+
}, `Returning variables as resource_links`);
|
|
987
|
+
return { content };
|
|
988
|
+
}
|
|
989
|
+
// Default: return full data
|
|
990
|
+
const responsePayload = {
|
|
991
|
+
fileKey,
|
|
992
|
+
source: 'cache',
|
|
993
|
+
format: format || 'full',
|
|
994
|
+
timestamp: cachedData.timestamp,
|
|
995
|
+
data: responseData,
|
|
996
|
+
...(paginationInfo && { pagination: paginationInfo }),
|
|
997
|
+
};
|
|
998
|
+
// Remove pretty printing to reduce payload size by 30-40%
|
|
999
|
+
const responseText = JSON.stringify(responsePayload);
|
|
1000
|
+
const responseSizeBytes = Buffer.byteLength(responseText, 'utf8');
|
|
1001
|
+
const responseSizeMB = (responseSizeBytes / (1024 * 1024)).toFixed(2);
|
|
1002
|
+
logger.info({
|
|
1003
|
+
fileKey,
|
|
1004
|
+
format: format || 'full',
|
|
1005
|
+
verbosity: verbosity || 'standard',
|
|
1006
|
+
variableCount: responseData.variables?.length || 0,
|
|
1007
|
+
collectionCount: responseData.variableCollections?.length || 0,
|
|
1008
|
+
responseSizeBytes,
|
|
1009
|
+
responseSizeMB: `${responseSizeMB} MB`,
|
|
1010
|
+
isUnder1MB: responseSizeBytes < 1024 * 1024,
|
|
1011
|
+
}, `Response size check: ${responseSizeMB} MB`);
|
|
1012
|
+
return {
|
|
1013
|
+
content: [
|
|
1014
|
+
{
|
|
1015
|
+
type: "text",
|
|
1016
|
+
text: responseText,
|
|
1017
|
+
},
|
|
1018
|
+
],
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
// =====================================================================
|
|
1022
|
+
// FETCH LOGIC: No cache or cache invalid/refresh requested
|
|
1023
|
+
// =====================================================================
|
|
1024
|
+
// BEST: Try Desktop connection first (like official Figma MCP does)
|
|
1025
|
+
// Ensure browser manager is initialized
|
|
1026
|
+
if (ensureInitialized && !parseFromConsole) {
|
|
1027
|
+
logger.info("Calling ensureInitialized to initialize browser manager");
|
|
1028
|
+
await ensureInitialized();
|
|
1029
|
+
}
|
|
1030
|
+
const browserManager = getBrowserManager?.();
|
|
1031
|
+
logger.info({ hasBrowserManager: !!browserManager, parseFromConsole }, "Desktop connection check");
|
|
1032
|
+
// Debug: Log why Desktop connection might be skipped
|
|
1033
|
+
if (!browserManager) {
|
|
1034
|
+
logger.error("Desktop connection skipped: browserManager is not available");
|
|
1035
|
+
}
|
|
1036
|
+
else if (parseFromConsole) {
|
|
1037
|
+
logger.info("Desktop connection skipped: parseFromConsole is true");
|
|
1038
|
+
}
|
|
1039
|
+
if (browserManager && !parseFromConsole) {
|
|
1040
|
+
try {
|
|
1041
|
+
logger.info({ fileKey }, "Attempting to get variables via Desktop connection");
|
|
1042
|
+
// Import and use the Desktop connector
|
|
1043
|
+
const { FigmaDesktopConnector } = await import('./figma-desktop-connector.js');
|
|
1044
|
+
const page = await browserManager.getPage();
|
|
1045
|
+
logger.info("Got page from browser manager");
|
|
1046
|
+
// Log to browser console for MCP capture
|
|
1047
|
+
await page.evaluate(() => {
|
|
1048
|
+
console.log('[FIGMA_TOOLS] 🚀 Got page from browser manager, creating Desktop connector...');
|
|
1049
|
+
});
|
|
1050
|
+
const connector = new FigmaDesktopConnector(page);
|
|
1051
|
+
await page.evaluate(() => {
|
|
1052
|
+
console.log('[FIGMA_TOOLS] ✅ Desktop connector created, initializing...');
|
|
1053
|
+
});
|
|
1054
|
+
await connector.initialize();
|
|
1055
|
+
logger.info("Desktop connector initialized, calling getVariablesFromPluginUI...");
|
|
1056
|
+
await page.evaluate(() => {
|
|
1057
|
+
console.log('[FIGMA_TOOLS] ✅ Desktop connector initialized, calling getVariablesFromPluginUI...');
|
|
1058
|
+
});
|
|
1059
|
+
const desktopResult = await connector.getVariablesFromPluginUI(fileKey);
|
|
1060
|
+
if (desktopResult.success && desktopResult.variables) {
|
|
1061
|
+
logger.info({
|
|
1062
|
+
variableCount: desktopResult.variables.length,
|
|
1063
|
+
collectionCount: desktopResult.variableCollections?.length
|
|
1064
|
+
}, "Successfully retrieved variables via Desktop connection!");
|
|
1065
|
+
// Prepare data for caching (using the raw data, not enriched)
|
|
1066
|
+
const dataForCache = {
|
|
1067
|
+
fileKey,
|
|
1068
|
+
source: "desktop_connection",
|
|
1069
|
+
timestamp: desktopResult.timestamp || Date.now(),
|
|
1070
|
+
variables: desktopResult.variables,
|
|
1071
|
+
variableCollections: desktopResult.variableCollections,
|
|
1072
|
+
};
|
|
1073
|
+
// Store in cache with LRU eviction
|
|
1074
|
+
if (variablesCache) {
|
|
1075
|
+
evictOldestCacheEntry(variablesCache);
|
|
1076
|
+
variablesCache.set(fileKey, {
|
|
1077
|
+
data: dataForCache,
|
|
1078
|
+
timestamp: Date.now(),
|
|
1079
|
+
});
|
|
1080
|
+
logger.info({ fileKey, cacheSize: variablesCache.size }, 'Stored variables in cache');
|
|
1081
|
+
}
|
|
1082
|
+
// Apply format logic
|
|
1083
|
+
let responseData = dataForCache;
|
|
1084
|
+
if (format === 'summary') {
|
|
1085
|
+
responseData = generateSummary(dataForCache);
|
|
1086
|
+
logger.info({ fileKey, estimatedTokens: estimateTokens(responseData) }, 'Generated summary from fetched data');
|
|
1087
|
+
}
|
|
1088
|
+
else if (format === 'filtered') {
|
|
1089
|
+
// Apply filters with verbosity-aware valuesByMode transformation
|
|
1090
|
+
responseData = applyFilters(dataForCache, {
|
|
1091
|
+
collection,
|
|
1092
|
+
namePattern,
|
|
1093
|
+
mode,
|
|
1094
|
+
}, verbosity || 'standard');
|
|
1095
|
+
logger.info({
|
|
1096
|
+
fileKey,
|
|
1097
|
+
originalCount: dataForCache.variables?.length,
|
|
1098
|
+
filteredCount: responseData.variables?.length,
|
|
1099
|
+
}, 'Applied filters to fetched data');
|
|
1100
|
+
// Apply pagination (CRITICAL - was missing!)
|
|
1101
|
+
let paginationInfo = null;
|
|
1102
|
+
const paginated = paginateVariables(responseData, page || 1, pageSize || 50);
|
|
1103
|
+
responseData = paginated.data;
|
|
1104
|
+
paginationInfo = paginated.pagination;
|
|
1105
|
+
// Apply verbosity filtering (CRITICAL - was missing!)
|
|
1106
|
+
const effectiveVerbosity = verbosity || "inventory";
|
|
1107
|
+
// Only include collections referenced by paginated variables
|
|
1108
|
+
const referencedCollectionIds = new Set(responseData.variables.map((v) => v.variableCollectionId));
|
|
1109
|
+
responseData.variableCollections = responseData.variableCollections.filter((c) => referencedCollectionIds.has(c.id));
|
|
1110
|
+
// Filter variables by verbosity
|
|
1111
|
+
responseData.variables = responseData.variables.map((v) => {
|
|
1112
|
+
if (effectiveVerbosity === "inventory") {
|
|
1113
|
+
return {
|
|
1114
|
+
id: v.id,
|
|
1115
|
+
name: v.name,
|
|
1116
|
+
collectionId: v.variableCollectionId,
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
if (effectiveVerbosity === "summary") {
|
|
1120
|
+
return {
|
|
1121
|
+
id: v.id,
|
|
1122
|
+
name: v.name,
|
|
1123
|
+
resolvedType: v.resolvedType,
|
|
1124
|
+
valuesByMode: v.valuesByMode,
|
|
1125
|
+
variableCollectionId: v.variableCollectionId,
|
|
1126
|
+
};
|
|
1127
|
+
}
|
|
1128
|
+
return v; // standard/full
|
|
1129
|
+
});
|
|
1130
|
+
// Filter collections by verbosity
|
|
1131
|
+
responseData.variableCollections = responseData.variableCollections.map((c) => {
|
|
1132
|
+
if (effectiveVerbosity === "inventory") {
|
|
1133
|
+
return {
|
|
1134
|
+
id: c.id,
|
|
1135
|
+
name: c.name,
|
|
1136
|
+
modeNames: c.modes?.map((m) => m.name) || [],
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
if (effectiveVerbosity === "summary") {
|
|
1140
|
+
return {
|
|
1141
|
+
id: c.id,
|
|
1142
|
+
name: c.name,
|
|
1143
|
+
modes: c.modes,
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
return c; // standard/full
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
else {
|
|
1150
|
+
// format === 'full'
|
|
1151
|
+
// Check if we need to auto-summarize
|
|
1152
|
+
const estimatedTokens = estimateTokens(responseData);
|
|
1153
|
+
if (estimatedTokens > 25000) {
|
|
1154
|
+
logger.warn({ fileKey, estimatedTokens }, 'Full data exceeds MCP token limit (25K), auto-summarizing. Use format=summary or format=filtered to get specific data.');
|
|
1155
|
+
const summary = generateSummary(responseData);
|
|
1156
|
+
return {
|
|
1157
|
+
content: [
|
|
1158
|
+
{
|
|
1159
|
+
type: "text",
|
|
1160
|
+
text: JSON.stringify({
|
|
1161
|
+
fileKey,
|
|
1162
|
+
source: 'desktop_connection_auto_summarized',
|
|
1163
|
+
warning: 'Full dataset exceeds MCP token limit (25,000 tokens)',
|
|
1164
|
+
suggestion: 'Use format="summary" for overview or format="filtered" with collection/namePattern/mode filters to get specific variables',
|
|
1165
|
+
estimatedTokens,
|
|
1166
|
+
summary,
|
|
1167
|
+
}),
|
|
1168
|
+
},
|
|
1169
|
+
],
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
// If returnAsLinks=true, return resource_link references
|
|
1174
|
+
if (returnAsLinks) {
|
|
1175
|
+
const summary = {
|
|
1176
|
+
fileKey,
|
|
1177
|
+
source: 'desktop_connection',
|
|
1178
|
+
totalVariables: responseData.variables?.length || 0,
|
|
1179
|
+
totalCollections: responseData.variableCollections?.length || 0,
|
|
1180
|
+
};
|
|
1181
|
+
const content = [
|
|
1182
|
+
{
|
|
1183
|
+
type: "text",
|
|
1184
|
+
text: JSON.stringify(summary),
|
|
1185
|
+
},
|
|
1186
|
+
];
|
|
1187
|
+
// Add resource_link for each variable
|
|
1188
|
+
responseData.variables?.forEach((v) => {
|
|
1189
|
+
content.push({
|
|
1190
|
+
type: "resource_link",
|
|
1191
|
+
uri: `figma://variable/${v.id}`,
|
|
1192
|
+
name: v.name || v.id,
|
|
1193
|
+
description: `${v.resolvedType || 'VARIABLE'} from ${fileKey}`,
|
|
1194
|
+
});
|
|
1195
|
+
});
|
|
1196
|
+
logger.info({
|
|
1197
|
+
fileKey,
|
|
1198
|
+
format: 'resource_links',
|
|
1199
|
+
variableCount: responseData.variables?.length || 0,
|
|
1200
|
+
linkCount: content.length - 1,
|
|
1201
|
+
}, `Returning Desktop variables as resource_links`);
|
|
1202
|
+
return { content };
|
|
1203
|
+
}
|
|
1204
|
+
// Default: return full data (removed pretty printing)
|
|
1205
|
+
return {
|
|
1206
|
+
content: [
|
|
1207
|
+
{
|
|
1208
|
+
type: "text",
|
|
1209
|
+
text: JSON.stringify({
|
|
1210
|
+
fileKey,
|
|
1211
|
+
source: "desktop_connection",
|
|
1212
|
+
format: format || 'full',
|
|
1213
|
+
timestamp: dataForCache.timestamp,
|
|
1214
|
+
data: responseData,
|
|
1215
|
+
cached: true,
|
|
1216
|
+
}),
|
|
1217
|
+
},
|
|
1218
|
+
],
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
catch (desktopError) {
|
|
1223
|
+
const errorMessage = desktopError instanceof Error ? desktopError.message : String(desktopError);
|
|
1224
|
+
const errorStack = desktopError instanceof Error ? desktopError.stack : undefined;
|
|
1225
|
+
logger.error({
|
|
1226
|
+
error: desktopError,
|
|
1227
|
+
message: errorMessage,
|
|
1228
|
+
stack: errorStack
|
|
1229
|
+
}, "Desktop connection failed, falling back to other methods");
|
|
1230
|
+
// Try to log to browser console if we have access to page
|
|
1231
|
+
try {
|
|
1232
|
+
if (browserManager) {
|
|
1233
|
+
const page = await browserManager.getPage();
|
|
1234
|
+
await page.evaluate((msg, stack) => {
|
|
1235
|
+
console.error('[FIGMA_TOOLS] ❌ Desktop connection failed:', msg);
|
|
1236
|
+
if (stack) {
|
|
1237
|
+
console.error('[FIGMA_TOOLS] Stack trace:', stack);
|
|
1238
|
+
}
|
|
1239
|
+
}, errorMessage, errorStack);
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
catch (logError) {
|
|
1243
|
+
// Ignore logging errors
|
|
1244
|
+
}
|
|
1245
|
+
// Continue to try other methods
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
// FALLBACK: Parse from console logs if requested
|
|
1249
|
+
if (parseFromConsole) {
|
|
1250
|
+
const consoleMonitor = getConsoleMonitor?.();
|
|
1251
|
+
if (!consoleMonitor) {
|
|
1252
|
+
throw new Error("Console monitoring not available. Make sure browser is connected to Figma.");
|
|
1253
|
+
}
|
|
1254
|
+
logger.info({ fileKey }, "Parsing variables from console logs");
|
|
1255
|
+
// Get recent logs
|
|
1256
|
+
const logs = consoleMonitor.getLogs({ count: 100, level: "log" });
|
|
1257
|
+
const varLog = snippetInjector.findVariablesLog(logs);
|
|
1258
|
+
if (!varLog) {
|
|
1259
|
+
throw new Error("No variables found in console logs.\n\n" +
|
|
1260
|
+
"Did you run the snippet in Figma's plugin console? Here's the correct workflow:\n\n" +
|
|
1261
|
+
"1. Call figma_get_variables() without parameters (you may have already done this)\n" +
|
|
1262
|
+
"2. Copy the provided snippet\n" +
|
|
1263
|
+
"3. Open Figma Desktop → Plugins → Development → Open Console\n" +
|
|
1264
|
+
"4. Paste and run the snippet in the PLUGIN console (not browser DevTools)\n" +
|
|
1265
|
+
"5. Wait for '✅ Variables data captured!' confirmation\n" +
|
|
1266
|
+
"6. Then call figma_get_variables({ parseFromConsole: true })\n\n" +
|
|
1267
|
+
"Note: The browser console won't work - you need a plugin console for the figma.variables API.");
|
|
1268
|
+
}
|
|
1269
|
+
// Parse variables from log
|
|
1270
|
+
const parsedData = snippetInjector.parseVariablesFromLog(varLog);
|
|
1271
|
+
if (!parsedData) {
|
|
1272
|
+
throw new Error("Failed to parse variables from console log");
|
|
1273
|
+
}
|
|
1274
|
+
return {
|
|
1275
|
+
content: [
|
|
1276
|
+
{
|
|
1277
|
+
type: "text",
|
|
1278
|
+
text: JSON.stringify({
|
|
1279
|
+
fileKey,
|
|
1280
|
+
source: "console_capture",
|
|
1281
|
+
local: {
|
|
1282
|
+
summary: {
|
|
1283
|
+
total_variables: parsedData.variables.length,
|
|
1284
|
+
total_collections: parsedData.variableCollections.length,
|
|
1285
|
+
},
|
|
1286
|
+
collections: parsedData.variableCollections,
|
|
1287
|
+
variables: parsedData.variables,
|
|
1288
|
+
},
|
|
1289
|
+
timestamp: parsedData.timestamp,
|
|
1290
|
+
enriched: false,
|
|
1291
|
+
}, null, 2),
|
|
1292
|
+
},
|
|
1293
|
+
],
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
// Try REST API
|
|
1297
|
+
logger.info({ fileKey, includePublished, verbosity, enrich }, "Fetching variables via REST API");
|
|
1298
|
+
const api = await getFigmaAPI();
|
|
1299
|
+
const { local, published } = await api.getAllVariables(fileKey);
|
|
1300
|
+
let localFormatted = formatVariables(local);
|
|
1301
|
+
let publishedFormatted = includePublished
|
|
1302
|
+
? formatVariables(published)
|
|
1303
|
+
: null;
|
|
1304
|
+
// DEBUG: Check if valuesByMode exists before filtering
|
|
1305
|
+
if (localFormatted.variables[0]) {
|
|
1306
|
+
logger.info({
|
|
1307
|
+
hasValuesByMode: !!localFormatted.variables[0].valuesByMode,
|
|
1308
|
+
variableKeys: Object.keys(localFormatted.variables[0]),
|
|
1309
|
+
collectionCount: localFormatted.collections?.length,
|
|
1310
|
+
}, 'Variable structure before filtering');
|
|
1311
|
+
}
|
|
1312
|
+
// Apply collection/name/mode filtering if format is 'filtered'
|
|
1313
|
+
if (format === 'filtered') {
|
|
1314
|
+
// Create properly structured data for applyFilters
|
|
1315
|
+
const dataToFilter = {
|
|
1316
|
+
variables: localFormatted.variables,
|
|
1317
|
+
variableCollections: localFormatted.collections,
|
|
1318
|
+
};
|
|
1319
|
+
// Apply filters with verbosity-aware valuesByMode transformation
|
|
1320
|
+
const filtered = applyFilters(dataToFilter, {
|
|
1321
|
+
collection,
|
|
1322
|
+
namePattern,
|
|
1323
|
+
mode,
|
|
1324
|
+
}, verbosity || 'standard');
|
|
1325
|
+
localFormatted.variables = filtered.variables;
|
|
1326
|
+
localFormatted.collections = filtered.variableCollections;
|
|
1327
|
+
// DEBUG: Check if transformation was applied
|
|
1328
|
+
if (localFormatted.variables[0]) {
|
|
1329
|
+
logger.info({
|
|
1330
|
+
hasValuesByMode: !!localFormatted.variables[0].valuesByMode,
|
|
1331
|
+
hasModeNames: !!localFormatted.variables[0].modeNames,
|
|
1332
|
+
variableKeys: Object.keys(localFormatted.variables[0]),
|
|
1333
|
+
}, 'Variable structure after applyFilters');
|
|
1334
|
+
}
|
|
1335
|
+
// Skip inline verbosity filtering - already handled by applyFilters
|
|
1336
|
+
}
|
|
1337
|
+
// Apply verbosity filtering for non-filtered formats
|
|
1338
|
+
const filterVariable = (variable, level) => {
|
|
1339
|
+
if (!variable)
|
|
1340
|
+
return variable;
|
|
1341
|
+
if (level === "summary") {
|
|
1342
|
+
// Summary: Only id, name, value (~80% reduction)
|
|
1343
|
+
return {
|
|
1344
|
+
id: variable.id,
|
|
1345
|
+
name: variable.name,
|
|
1346
|
+
resolvedType: variable.resolvedType,
|
|
1347
|
+
valuesByMode: variable.valuesByMode,
|
|
1348
|
+
// Include modeNames and modeCount added by applyFilters
|
|
1349
|
+
...(variable.modeNames && { modeNames: variable.modeNames }),
|
|
1350
|
+
...(variable.modeCount && { modeCount: variable.modeCount }),
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
if (level === "standard") {
|
|
1354
|
+
// Standard: Essential properties (~45% reduction)
|
|
1355
|
+
return {
|
|
1356
|
+
id: variable.id,
|
|
1357
|
+
name: variable.name,
|
|
1358
|
+
resolvedType: variable.resolvedType,
|
|
1359
|
+
valuesByMode: variable.valuesByMode,
|
|
1360
|
+
description: variable.description,
|
|
1361
|
+
variableCollectionId: variable.variableCollectionId,
|
|
1362
|
+
...(variable.scopes && { scopes: variable.scopes }),
|
|
1363
|
+
};
|
|
1364
|
+
}
|
|
1365
|
+
// Full: Return everything
|
|
1366
|
+
return variable;
|
|
1367
|
+
};
|
|
1368
|
+
const filterCollection = (collection, level) => {
|
|
1369
|
+
if (!collection)
|
|
1370
|
+
return collection;
|
|
1371
|
+
if (level === "summary") {
|
|
1372
|
+
return {
|
|
1373
|
+
id: collection.id,
|
|
1374
|
+
name: collection.name,
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1377
|
+
if (level === "standard") {
|
|
1378
|
+
return {
|
|
1379
|
+
id: collection.id,
|
|
1380
|
+
name: collection.name,
|
|
1381
|
+
modes: collection.modes,
|
|
1382
|
+
defaultModeId: collection.defaultModeId,
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
return collection;
|
|
1386
|
+
};
|
|
1387
|
+
// Apply inline verbosity filtering only for non-filtered formats
|
|
1388
|
+
// (filtered format already handled by applyFilters above)
|
|
1389
|
+
if (verbosity !== "full" && format !== 'filtered') {
|
|
1390
|
+
const level = verbosity || "standard";
|
|
1391
|
+
localFormatted.variables = localFormatted.variables.map((v) => filterVariable(v, level));
|
|
1392
|
+
localFormatted.collections = localFormatted.collections.map((c) => filterCollection(c, level));
|
|
1393
|
+
if (publishedFormatted) {
|
|
1394
|
+
publishedFormatted.variables = publishedFormatted.variables.map((v) => filterVariable(v, level));
|
|
1395
|
+
publishedFormatted.collections = publishedFormatted.collections.map((c) => filterCollection(c, level));
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
// Apply enrichment if requested
|
|
1399
|
+
if (enrich) {
|
|
1400
|
+
const enrichmentOptions = {
|
|
1401
|
+
enrich: true,
|
|
1402
|
+
include_usage: include_usage !== false,
|
|
1403
|
+
include_dependencies: include_dependencies !== false,
|
|
1404
|
+
include_exports: include_exports !== false,
|
|
1405
|
+
export_formats: export_formats || ["css", "sass", "tailwind", "typescript", "json"],
|
|
1406
|
+
};
|
|
1407
|
+
// Enrich local variables
|
|
1408
|
+
const enrichedLocal = await enrichmentService.enrichVariables(localFormatted.variables, fileKey, enrichmentOptions);
|
|
1409
|
+
localFormatted = { ...localFormatted, variables: enrichedLocal };
|
|
1410
|
+
// Enrich published variables if included
|
|
1411
|
+
if (publishedFormatted) {
|
|
1412
|
+
const enrichedPublished = await enrichmentService.enrichVariables(publishedFormatted.variables, fileKey, enrichmentOptions);
|
|
1413
|
+
publishedFormatted = { ...publishedFormatted, variables: enrichedPublished };
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
// Apply pagination for filtered format
|
|
1417
|
+
let paginationInfo = null;
|
|
1418
|
+
if (format === 'filtered') {
|
|
1419
|
+
const paginated = paginateVariables({ variables: localFormatted.variables, variableCollections: localFormatted.collections }, page || 1, pageSize || 50);
|
|
1420
|
+
localFormatted.variables = paginated.data.variables;
|
|
1421
|
+
localFormatted.collections = paginated.data.variableCollections;
|
|
1422
|
+
paginationInfo = paginated.pagination;
|
|
1423
|
+
// For inventory/summary modes, strip variableIds from collections to reduce payload
|
|
1424
|
+
if (verbosity === 'inventory' || verbosity === 'summary') {
|
|
1425
|
+
localFormatted.collections = localFormatted.collections.map((c) => ({
|
|
1426
|
+
id: c.id,
|
|
1427
|
+
name: c.name,
|
|
1428
|
+
key: c.key,
|
|
1429
|
+
modes: c.modes,
|
|
1430
|
+
// Omit variableIds array which can be huge
|
|
1431
|
+
}));
|
|
1432
|
+
}
|
|
1433
|
+
}
|
|
1434
|
+
// If returnAsLinks=true, return resource_link references
|
|
1435
|
+
if (returnAsLinks) {
|
|
1436
|
+
const summary = {
|
|
1437
|
+
fileKey,
|
|
1438
|
+
source: 'rest_api',
|
|
1439
|
+
totalVariables: localFormatted.variables.length,
|
|
1440
|
+
totalCollections: localFormatted.collections.length,
|
|
1441
|
+
...(paginationInfo && { pagination: paginationInfo }),
|
|
1442
|
+
};
|
|
1443
|
+
const content = [
|
|
1444
|
+
{
|
|
1445
|
+
type: "text",
|
|
1446
|
+
text: JSON.stringify(summary),
|
|
1447
|
+
},
|
|
1448
|
+
];
|
|
1449
|
+
// Add resource_link for each variable
|
|
1450
|
+
localFormatted.variables.forEach((v) => {
|
|
1451
|
+
content.push({
|
|
1452
|
+
type: "resource_link",
|
|
1453
|
+
uri: `figma://variable/${v.id}`,
|
|
1454
|
+
name: v.name || v.id,
|
|
1455
|
+
description: `${v.resolvedType || 'VARIABLE'} from ${fileKey}`,
|
|
1456
|
+
});
|
|
1457
|
+
});
|
|
1458
|
+
logger.info({
|
|
1459
|
+
fileKey,
|
|
1460
|
+
format: 'resource_links',
|
|
1461
|
+
variableCount: localFormatted.variables.length,
|
|
1462
|
+
linkCount: content.length - 1,
|
|
1463
|
+
}, `Returning REST API variables as resource_links`);
|
|
1464
|
+
return { content };
|
|
1465
|
+
}
|
|
1466
|
+
// Build initial response data
|
|
1467
|
+
const responseData = {
|
|
1468
|
+
fileKey,
|
|
1469
|
+
local: {
|
|
1470
|
+
summary: localFormatted.summary,
|
|
1471
|
+
collections: localFormatted.collections,
|
|
1472
|
+
variables: localFormatted.variables,
|
|
1473
|
+
},
|
|
1474
|
+
...(includePublished &&
|
|
1475
|
+
publishedFormatted && {
|
|
1476
|
+
published: {
|
|
1477
|
+
summary: publishedFormatted.summary,
|
|
1478
|
+
collections: publishedFormatted.collections,
|
|
1479
|
+
variables: publishedFormatted.variables,
|
|
1480
|
+
},
|
|
1481
|
+
}),
|
|
1482
|
+
verbosity: verbosity || "standard",
|
|
1483
|
+
enriched: enrich || false,
|
|
1484
|
+
...(paginationInfo && { pagination: paginationInfo }),
|
|
1485
|
+
};
|
|
1486
|
+
// Use adaptive response to prevent context exhaustion
|
|
1487
|
+
return adaptiveResponse(responseData, {
|
|
1488
|
+
toolName: "figma_get_variables",
|
|
1489
|
+
compressionCallback: (adjustedLevel) => {
|
|
1490
|
+
// Re-apply filters with adjusted verbosity
|
|
1491
|
+
const level = adjustedLevel;
|
|
1492
|
+
const refiltered = applyFilters({
|
|
1493
|
+
variables: localFormatted.variables,
|
|
1494
|
+
variableCollections: localFormatted.collections,
|
|
1495
|
+
}, { collection, namePattern, mode }, level);
|
|
1496
|
+
return {
|
|
1497
|
+
...responseData,
|
|
1498
|
+
local: {
|
|
1499
|
+
...responseData.local,
|
|
1500
|
+
variables: refiltered.variables,
|
|
1501
|
+
collections: refiltered.variableCollections,
|
|
1502
|
+
},
|
|
1503
|
+
verbosity: level,
|
|
1504
|
+
};
|
|
1505
|
+
},
|
|
1506
|
+
suggestedActions: [
|
|
1507
|
+
"Use verbosity='inventory' or 'summary' for large variable sets",
|
|
1508
|
+
"Apply filters: collection, namePattern, or mode parameters",
|
|
1509
|
+
"Use pagination with pageSize parameter (default 50, max 100)",
|
|
1510
|
+
"Use returnAsLinks=true to get resource_link references instead of full data",
|
|
1511
|
+
],
|
|
1512
|
+
});
|
|
1513
|
+
}
|
|
1514
|
+
catch (error) {
|
|
1515
|
+
logger.error({ error }, "Failed to get variables");
|
|
1516
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1517
|
+
// FIXED: Jump directly to Styles API (fast) instead of full file data (slow)
|
|
1518
|
+
if (errorMessage.includes("403")) {
|
|
1519
|
+
try {
|
|
1520
|
+
logger.info({ fileKey }, "Variables API requires Enterprise, falling back to Styles API");
|
|
1521
|
+
const api = await getFigmaAPI();
|
|
1522
|
+
// Use the Styles API directly - much faster than getFile!
|
|
1523
|
+
const stylesData = await api.getStyles(fileKey);
|
|
1524
|
+
// Format the styles data similar to variables
|
|
1525
|
+
const formattedStyles = {
|
|
1526
|
+
summary: {
|
|
1527
|
+
total_styles: stylesData.meta?.styles?.length || 0,
|
|
1528
|
+
message: "Variables API requires Enterprise. Here are your design styles instead.",
|
|
1529
|
+
note: "These are Figma Styles (not Variables). Styles are the traditional way to store design tokens in Figma."
|
|
1530
|
+
},
|
|
1531
|
+
styles: stylesData.meta?.styles || []
|
|
1532
|
+
};
|
|
1533
|
+
logger.info({ styleCount: formattedStyles.summary.total_styles }, "Successfully retrieved styles as fallback!");
|
|
1534
|
+
return {
|
|
1535
|
+
content: [
|
|
1536
|
+
{
|
|
1537
|
+
type: "text",
|
|
1538
|
+
text: JSON.stringify({
|
|
1539
|
+
fileKey,
|
|
1540
|
+
source: "styles_api",
|
|
1541
|
+
message: "Variables API requires an Enterprise plan. Retrieved your design system styles instead.",
|
|
1542
|
+
data: formattedStyles,
|
|
1543
|
+
fallback_method: true,
|
|
1544
|
+
}, null, 2),
|
|
1545
|
+
},
|
|
1546
|
+
],
|
|
1547
|
+
};
|
|
1548
|
+
}
|
|
1549
|
+
catch (styleError) {
|
|
1550
|
+
logger.warn({ error: styleError }, "Style extraction failed");
|
|
1551
|
+
// Return a simple error message without the console snippet
|
|
1552
|
+
return {
|
|
1553
|
+
content: [
|
|
1554
|
+
{
|
|
1555
|
+
type: "text",
|
|
1556
|
+
text: JSON.stringify({
|
|
1557
|
+
error: "Unable to extract variables or styles from this file",
|
|
1558
|
+
message: "The Variables API requires an Enterprise plan, and the automatic style extraction encountered an error.",
|
|
1559
|
+
possibleReasons: [
|
|
1560
|
+
"The file may be private or require additional permissions",
|
|
1561
|
+
"The file structure may not contain extractable styles",
|
|
1562
|
+
"There may be a network or authentication issue"
|
|
1563
|
+
],
|
|
1564
|
+
suggestion: "Please ensure the file is accessible and try again, or check if your token has the necessary permissions.",
|
|
1565
|
+
technical: styleError instanceof Error ? styleError.message : String(styleError)
|
|
1566
|
+
}, null, 2),
|
|
1567
|
+
},
|
|
1568
|
+
],
|
|
1569
|
+
};
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
// Standard error response
|
|
1573
|
+
return {
|
|
1574
|
+
content: [
|
|
1575
|
+
{
|
|
1576
|
+
type: "text",
|
|
1577
|
+
text: JSON.stringify({
|
|
1578
|
+
error: errorMessage,
|
|
1579
|
+
message: "Failed to retrieve Figma variables",
|
|
1580
|
+
hint: errorMessage.includes("403")
|
|
1581
|
+
? "Variables API requires Enterprise plan. Set useConsoleFallback=true for alternative method."
|
|
1582
|
+
: "Make sure FIGMA_ACCESS_TOKEN is configured and has appropriate permissions",
|
|
1583
|
+
}, null, 2),
|
|
1584
|
+
},
|
|
1585
|
+
],
|
|
1586
|
+
isError: true,
|
|
1587
|
+
};
|
|
1588
|
+
}
|
|
1589
|
+
});
|
|
1590
|
+
// Tool 10: Get Component Data
|
|
1591
|
+
server.tool("figma_get_component", "Get component metadata including reliable description fields. IMPORTANT: For local/unpublished components, ensure the Figma Desktop Bridge plugin is running (Right-click in Figma → Plugins → Development → Figma Desktop Bridge) to get complete description data. Without the plugin, descriptions may be missing due to REST API limitations.", {
|
|
1592
|
+
fileUrl: z
|
|
1593
|
+
.string()
|
|
1594
|
+
.url()
|
|
1595
|
+
.optional()
|
|
1596
|
+
.describe("Figma file URL (e.g., https://figma.com/design/abc123). REQUIRED unless figma_navigate was already called. If not provided, ask the user to share their Figma file URL (they can copy it from Figma Desktop via right-click → 'Copy link')."),
|
|
1597
|
+
nodeId: z
|
|
1598
|
+
.string()
|
|
1599
|
+
.describe("Component node ID (e.g., '123:456')"),
|
|
1600
|
+
enrich: z
|
|
1601
|
+
.boolean()
|
|
1602
|
+
.optional()
|
|
1603
|
+
.describe("Set to true when user asks for: design token coverage, hardcoded value analysis, or component quality metrics. Adds token coverage analysis and hardcoded value detection. Default: false"),
|
|
1604
|
+
}, async ({ fileUrl, nodeId, enrich }) => {
|
|
1605
|
+
try {
|
|
1606
|
+
const api = await getFigmaAPI();
|
|
1607
|
+
const url = fileUrl || getCurrentUrl();
|
|
1608
|
+
if (!url) {
|
|
1609
|
+
throw new Error("No Figma file URL provided. Either pass fileUrl parameter or call figma_navigate first.");
|
|
1610
|
+
}
|
|
1611
|
+
const fileKey = extractFileKey(url);
|
|
1612
|
+
if (!fileKey) {
|
|
1613
|
+
throw new Error(`Invalid Figma URL: ${url}`);
|
|
1614
|
+
}
|
|
1615
|
+
logger.info({ fileKey, nodeId, enrich }, "Fetching component data");
|
|
1616
|
+
// PRIORITY 1: Try Desktop Bridge plugin UI first (has reliable description field!)
|
|
1617
|
+
if (getBrowserManager && ensureInitialized) {
|
|
1618
|
+
try {
|
|
1619
|
+
logger.info({ nodeId }, "Attempting to get component via Desktop Bridge plugin UI");
|
|
1620
|
+
await ensureInitialized();
|
|
1621
|
+
const browserManager = getBrowserManager();
|
|
1622
|
+
if (!browserManager) {
|
|
1623
|
+
throw new Error("Browser manager not available after initialization");
|
|
1624
|
+
}
|
|
1625
|
+
const { FigmaDesktopConnector } = await import('./figma-desktop-connector.js');
|
|
1626
|
+
const page = await browserManager.getPage();
|
|
1627
|
+
const connector = new FigmaDesktopConnector(page);
|
|
1628
|
+
await connector.initialize();
|
|
1629
|
+
const desktopResult = await connector.getComponentFromPluginUI(nodeId);
|
|
1630
|
+
if (desktopResult.success && desktopResult.component) {
|
|
1631
|
+
logger.info({
|
|
1632
|
+
componentName: desktopResult.component.name,
|
|
1633
|
+
hasDescription: !!desktopResult.component.description,
|
|
1634
|
+
hasDescriptionMarkdown: !!desktopResult.component.descriptionMarkdown
|
|
1635
|
+
}, "Successfully retrieved component via Desktop Bridge plugin UI!");
|
|
1636
|
+
let formatted = desktopResult.component;
|
|
1637
|
+
// Apply enrichment if requested
|
|
1638
|
+
if (enrich) {
|
|
1639
|
+
const enrichmentOptions = {
|
|
1640
|
+
enrich: true,
|
|
1641
|
+
include_usage: true,
|
|
1642
|
+
};
|
|
1643
|
+
formatted = await enrichmentService.enrichComponent(formatted, fileKey, enrichmentOptions);
|
|
1644
|
+
}
|
|
1645
|
+
return {
|
|
1646
|
+
content: [
|
|
1647
|
+
{
|
|
1648
|
+
type: "text",
|
|
1649
|
+
text: JSON.stringify({
|
|
1650
|
+
fileKey,
|
|
1651
|
+
nodeId,
|
|
1652
|
+
component: formatted,
|
|
1653
|
+
source: "desktop_bridge_plugin",
|
|
1654
|
+
enriched: enrich || false,
|
|
1655
|
+
note: "Retrieved via Desktop Bridge plugin - description fields are reliable and current"
|
|
1656
|
+
}, null, 2),
|
|
1657
|
+
},
|
|
1658
|
+
],
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
catch (desktopError) {
|
|
1663
|
+
logger.warn({ error: desktopError, nodeId }, "Desktop Bridge plugin failed, falling back to REST API");
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
// FALLBACK: Use REST API (may have missing/outdated description)
|
|
1667
|
+
logger.info({ nodeId }, "Using REST API fallback");
|
|
1668
|
+
const componentData = await api.getComponentData(fileKey, nodeId);
|
|
1669
|
+
if (!componentData) {
|
|
1670
|
+
throw new Error(`Component not found: ${nodeId}`);
|
|
1671
|
+
}
|
|
1672
|
+
let formatted = formatComponentData(componentData.document);
|
|
1673
|
+
// Apply enrichment if requested
|
|
1674
|
+
if (enrich) {
|
|
1675
|
+
const enrichmentOptions = {
|
|
1676
|
+
enrich: true,
|
|
1677
|
+
include_usage: true,
|
|
1678
|
+
};
|
|
1679
|
+
formatted = await enrichmentService.enrichComponent(formatted, fileKey, enrichmentOptions);
|
|
1680
|
+
}
|
|
1681
|
+
return {
|
|
1682
|
+
content: [
|
|
1683
|
+
{
|
|
1684
|
+
type: "text",
|
|
1685
|
+
text: JSON.stringify({
|
|
1686
|
+
fileKey,
|
|
1687
|
+
nodeId,
|
|
1688
|
+
component: formatted,
|
|
1689
|
+
source: "rest_api",
|
|
1690
|
+
enriched: enrich || false,
|
|
1691
|
+
warning: "Retrieved via REST API - description field may be missing due to known Figma API bug",
|
|
1692
|
+
action_required: formatted.description || formatted.descriptionMarkdown ? null : "To get reliable component descriptions, run the Desktop Bridge plugin in Figma Desktop: Right-click → Plugins → Development → Figma Desktop Bridge, then try again."
|
|
1693
|
+
}, null, 2),
|
|
1694
|
+
},
|
|
1695
|
+
],
|
|
1696
|
+
};
|
|
1697
|
+
}
|
|
1698
|
+
catch (error) {
|
|
1699
|
+
logger.error({ error }, "Failed to get component");
|
|
1700
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1701
|
+
return {
|
|
1702
|
+
content: [
|
|
1703
|
+
{
|
|
1704
|
+
type: "text",
|
|
1705
|
+
text: JSON.stringify({
|
|
1706
|
+
error: errorMessage,
|
|
1707
|
+
message: "Failed to retrieve component data",
|
|
1708
|
+
hint: "Make sure the node ID is correct and the file is accessible",
|
|
1709
|
+
}, null, 2),
|
|
1710
|
+
},
|
|
1711
|
+
],
|
|
1712
|
+
isError: true,
|
|
1713
|
+
};
|
|
1714
|
+
}
|
|
1715
|
+
});
|
|
1716
|
+
// Tool 11: Get Styles
|
|
1717
|
+
server.tool("figma_get_styles", {
|
|
1718
|
+
fileUrl: z
|
|
1719
|
+
.string()
|
|
1720
|
+
.url()
|
|
1721
|
+
.optional()
|
|
1722
|
+
.describe("Figma file URL (e.g., https://figma.com/design/abc123). REQUIRED unless figma_navigate was already called. If not provided, ask the user to share their Figma file URL (they can copy it from Figma Desktop via right-click → 'Copy link')."),
|
|
1723
|
+
verbosity: z
|
|
1724
|
+
.enum(["summary", "standard", "full"])
|
|
1725
|
+
.optional()
|
|
1726
|
+
.default("standard")
|
|
1727
|
+
.describe("Controls payload size: 'summary' (names/types only, ~85% smaller), 'standard' (essential properties, ~40% smaller), 'full' (everything). Default: standard"),
|
|
1728
|
+
enrich: z
|
|
1729
|
+
.boolean()
|
|
1730
|
+
.optional()
|
|
1731
|
+
.describe("Set to true when user asks for: CSS/Sass/Tailwind code, export formats, usage information, code examples, or design system exports. Adds resolved values, usage analysis, and export format examples. Default: false for backward compatibility"),
|
|
1732
|
+
include_usage: z
|
|
1733
|
+
.boolean()
|
|
1734
|
+
.optional()
|
|
1735
|
+
.describe("Include component usage information (requires enrich=true)"),
|
|
1736
|
+
include_exports: z
|
|
1737
|
+
.boolean()
|
|
1738
|
+
.optional()
|
|
1739
|
+
.describe("Include export format examples (requires enrich=true)"),
|
|
1740
|
+
export_formats: z
|
|
1741
|
+
.array(z.enum(["css", "sass", "tailwind", "typescript", "json"]))
|
|
1742
|
+
.optional()
|
|
1743
|
+
.describe("Which code formats to generate examples for. Use when user mentions specific formats like 'CSS', 'Tailwind', 'SCSS', 'TypeScript', etc. Automatically enables enrichment. Default: all formats"),
|
|
1744
|
+
}, async ({ fileUrl, verbosity, enrich, include_usage, include_exports, export_formats }) => {
|
|
1745
|
+
try {
|
|
1746
|
+
const api = await getFigmaAPI();
|
|
1747
|
+
const url = fileUrl || getCurrentUrl();
|
|
1748
|
+
if (!url) {
|
|
1749
|
+
throw new Error("No Figma file URL provided. Either pass fileUrl parameter or call figma_navigate first.");
|
|
1750
|
+
}
|
|
1751
|
+
const fileKey = extractFileKey(url);
|
|
1752
|
+
if (!fileKey) {
|
|
1753
|
+
throw new Error(`Invalid Figma URL: ${url}`);
|
|
1754
|
+
}
|
|
1755
|
+
logger.info({ fileKey, verbosity, enrich }, "Fetching styles");
|
|
1756
|
+
const stylesData = await api.getStyles(fileKey);
|
|
1757
|
+
let styles = stylesData.meta?.styles || [];
|
|
1758
|
+
// Apply verbosity filtering
|
|
1759
|
+
const filterStyle = (style, level) => {
|
|
1760
|
+
if (!style)
|
|
1761
|
+
return style;
|
|
1762
|
+
if (level === "summary") {
|
|
1763
|
+
// Summary: Only key, name, type (~85% reduction)
|
|
1764
|
+
return {
|
|
1765
|
+
key: style.key,
|
|
1766
|
+
name: style.name,
|
|
1767
|
+
style_type: style.style_type,
|
|
1768
|
+
};
|
|
1769
|
+
}
|
|
1770
|
+
if (level === "standard") {
|
|
1771
|
+
// Standard: Essential properties (~40% reduction)
|
|
1772
|
+
return {
|
|
1773
|
+
key: style.key,
|
|
1774
|
+
name: style.name,
|
|
1775
|
+
description: style.description,
|
|
1776
|
+
style_type: style.style_type,
|
|
1777
|
+
...(style.remote && { remote: style.remote }),
|
|
1778
|
+
};
|
|
1779
|
+
}
|
|
1780
|
+
// Full: Return everything
|
|
1781
|
+
return style;
|
|
1782
|
+
};
|
|
1783
|
+
if (verbosity !== "full") {
|
|
1784
|
+
styles = styles.map((style) => filterStyle(style, verbosity || "standard"));
|
|
1785
|
+
}
|
|
1786
|
+
// Apply enrichment if requested
|
|
1787
|
+
if (enrich) {
|
|
1788
|
+
const enrichmentOptions = {
|
|
1789
|
+
enrich: true,
|
|
1790
|
+
include_usage: include_usage !== false,
|
|
1791
|
+
include_exports: include_exports !== false,
|
|
1792
|
+
export_formats: export_formats || [
|
|
1793
|
+
"css",
|
|
1794
|
+
"sass",
|
|
1795
|
+
"tailwind",
|
|
1796
|
+
"typescript",
|
|
1797
|
+
"json",
|
|
1798
|
+
],
|
|
1799
|
+
};
|
|
1800
|
+
styles = await enrichmentService.enrichStyles(styles, fileKey, enrichmentOptions);
|
|
1801
|
+
}
|
|
1802
|
+
const finalResponse = {
|
|
1803
|
+
fileKey,
|
|
1804
|
+
styles,
|
|
1805
|
+
totalStyles: styles.length,
|
|
1806
|
+
verbosity: verbosity || "standard",
|
|
1807
|
+
enriched: enrich || false,
|
|
1808
|
+
};
|
|
1809
|
+
// Use adaptive response to prevent context exhaustion
|
|
1810
|
+
return adaptiveResponse(finalResponse, {
|
|
1811
|
+
toolName: "figma_get_styles",
|
|
1812
|
+
compressionCallback: (adjustedLevel) => {
|
|
1813
|
+
// Re-apply style filtering with lower verbosity
|
|
1814
|
+
const level = adjustedLevel;
|
|
1815
|
+
const refilteredStyles = verbosity !== "full"
|
|
1816
|
+
? styles.map((style) => filterStyle(style, level))
|
|
1817
|
+
: styles;
|
|
1818
|
+
return {
|
|
1819
|
+
...finalResponse,
|
|
1820
|
+
styles: refilteredStyles,
|
|
1821
|
+
verbosity: level,
|
|
1822
|
+
};
|
|
1823
|
+
},
|
|
1824
|
+
suggestedActions: [
|
|
1825
|
+
"Use verbosity='summary' for style names and types only",
|
|
1826
|
+
"Use verbosity='standard' for essential style properties",
|
|
1827
|
+
"Filter to specific style types if needed",
|
|
1828
|
+
],
|
|
1829
|
+
});
|
|
1830
|
+
}
|
|
1831
|
+
catch (error) {
|
|
1832
|
+
logger.error({ error }, "Failed to get styles");
|
|
1833
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1834
|
+
return {
|
|
1835
|
+
content: [
|
|
1836
|
+
{
|
|
1837
|
+
type: "text",
|
|
1838
|
+
text: JSON.stringify({
|
|
1839
|
+
error: errorMessage,
|
|
1840
|
+
message: "Failed to retrieve styles",
|
|
1841
|
+
}, null, 2),
|
|
1842
|
+
},
|
|
1843
|
+
],
|
|
1844
|
+
isError: true,
|
|
1845
|
+
};
|
|
1846
|
+
}
|
|
1847
|
+
});
|
|
1848
|
+
// Tool 12: Get Component Image (Visual Reference)
|
|
1849
|
+
server.tool("figma_get_component_image", {
|
|
1850
|
+
fileUrl: z
|
|
1851
|
+
.string()
|
|
1852
|
+
.url()
|
|
1853
|
+
.optional()
|
|
1854
|
+
.describe("Figma file URL (e.g., https://figma.com/design/abc123). REQUIRED unless figma_navigate was already called. If not provided, ask the user to share their Figma file URL (they can copy it from Figma Desktop via right-click → 'Copy link')."),
|
|
1855
|
+
nodeId: z
|
|
1856
|
+
.string()
|
|
1857
|
+
.describe("Component node ID to render as image (e.g., '695:313')"),
|
|
1858
|
+
scale: z
|
|
1859
|
+
.number()
|
|
1860
|
+
.min(0.01)
|
|
1861
|
+
.max(4)
|
|
1862
|
+
.optional()
|
|
1863
|
+
.default(2)
|
|
1864
|
+
.describe("Image scale factor (0.01-4, default: 2 for high quality)"),
|
|
1865
|
+
format: z
|
|
1866
|
+
.enum(["png", "jpg", "svg", "pdf"])
|
|
1867
|
+
.optional()
|
|
1868
|
+
.default("png")
|
|
1869
|
+
.describe("Image format (default: png)"),
|
|
1870
|
+
}, async ({ fileUrl, nodeId, scale, format }) => {
|
|
1871
|
+
try {
|
|
1872
|
+
const api = await getFigmaAPI();
|
|
1873
|
+
const url = fileUrl || getCurrentUrl();
|
|
1874
|
+
if (!url) {
|
|
1875
|
+
throw new Error("No Figma file URL provided. Either pass fileUrl parameter or call figma_navigate first.");
|
|
1876
|
+
}
|
|
1877
|
+
const fileKey = extractFileKey(url);
|
|
1878
|
+
if (!fileKey) {
|
|
1879
|
+
throw new Error(`Invalid Figma URL: ${url}`);
|
|
1880
|
+
}
|
|
1881
|
+
logger.info({ fileKey, nodeId, scale, format }, "Rendering component image");
|
|
1882
|
+
// Call the new getImages method
|
|
1883
|
+
const result = await api.getImages(fileKey, nodeId, {
|
|
1884
|
+
scale,
|
|
1885
|
+
format,
|
|
1886
|
+
contents_only: true,
|
|
1887
|
+
});
|
|
1888
|
+
const imageUrl = result.images[nodeId];
|
|
1889
|
+
if (!imageUrl) {
|
|
1890
|
+
throw new Error(`Failed to render image for node ${nodeId}. The node may not exist or may not be renderable.`);
|
|
1891
|
+
}
|
|
1892
|
+
return {
|
|
1893
|
+
content: [
|
|
1894
|
+
{
|
|
1895
|
+
type: "text",
|
|
1896
|
+
text: JSON.stringify({
|
|
1897
|
+
fileKey,
|
|
1898
|
+
nodeId,
|
|
1899
|
+
imageUrl,
|
|
1900
|
+
scale,
|
|
1901
|
+
format,
|
|
1902
|
+
expiresIn: "30 days",
|
|
1903
|
+
note: "Use this image as visual reference for component development. Image URLs expire after 30 days.",
|
|
1904
|
+
}, null, 2),
|
|
1905
|
+
},
|
|
1906
|
+
],
|
|
1907
|
+
};
|
|
1908
|
+
}
|
|
1909
|
+
catch (error) {
|
|
1910
|
+
logger.error({ error }, "Failed to render component image");
|
|
1911
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
1912
|
+
return {
|
|
1913
|
+
content: [
|
|
1914
|
+
{
|
|
1915
|
+
type: "text",
|
|
1916
|
+
text: JSON.stringify({
|
|
1917
|
+
error: errorMessage,
|
|
1918
|
+
message: "Failed to render component image",
|
|
1919
|
+
hint: "Make sure the node ID is correct and the component is renderable",
|
|
1920
|
+
}, null, 2),
|
|
1921
|
+
},
|
|
1922
|
+
],
|
|
1923
|
+
isError: true,
|
|
1924
|
+
};
|
|
1925
|
+
}
|
|
1926
|
+
});
|
|
1927
|
+
// Tool 13: Get Component for Development (UI Implementation)
|
|
1928
|
+
server.tool("figma_get_component_for_development", {
|
|
1929
|
+
fileUrl: z
|
|
1930
|
+
.string()
|
|
1931
|
+
.url()
|
|
1932
|
+
.optional()
|
|
1933
|
+
.describe("Figma file URL (e.g., https://figma.com/design/abc123). REQUIRED unless figma_navigate was already called."),
|
|
1934
|
+
nodeId: z
|
|
1935
|
+
.string()
|
|
1936
|
+
.describe("Component node ID to get data for (e.g., '695:313')"),
|
|
1937
|
+
includeImage: z
|
|
1938
|
+
.boolean()
|
|
1939
|
+
.optional()
|
|
1940
|
+
.default(true)
|
|
1941
|
+
.describe("Include rendered image for visual reference (default: true)"),
|
|
1942
|
+
}, async ({ fileUrl, nodeId, includeImage }) => {
|
|
1943
|
+
try {
|
|
1944
|
+
const api = await getFigmaAPI();
|
|
1945
|
+
const url = fileUrl || getCurrentUrl();
|
|
1946
|
+
if (!url) {
|
|
1947
|
+
throw new Error("No Figma file URL provided. Either pass fileUrl parameter or call figma_navigate first.");
|
|
1948
|
+
}
|
|
1949
|
+
const fileKey = extractFileKey(url);
|
|
1950
|
+
if (!fileKey) {
|
|
1951
|
+
throw new Error(`Invalid Figma URL: ${url}`);
|
|
1952
|
+
}
|
|
1953
|
+
logger.info({ fileKey, nodeId, includeImage }, "Fetching component for development");
|
|
1954
|
+
// Get node data with depth for children
|
|
1955
|
+
const nodeData = await api.getNodes(fileKey, [nodeId], { depth: 2 });
|
|
1956
|
+
const node = nodeData.nodes?.[nodeId]?.document;
|
|
1957
|
+
if (!node) {
|
|
1958
|
+
throw new Error(`Component not found: ${nodeId}`);
|
|
1959
|
+
}
|
|
1960
|
+
// Filter to visual/layout properties only
|
|
1961
|
+
const filterForDevelopment = (n) => {
|
|
1962
|
+
if (!n)
|
|
1963
|
+
return n;
|
|
1964
|
+
const result = {
|
|
1965
|
+
id: n.id,
|
|
1966
|
+
name: n.name,
|
|
1967
|
+
type: n.type,
|
|
1968
|
+
description: n.description,
|
|
1969
|
+
descriptionMarkdown: n.descriptionMarkdown,
|
|
1970
|
+
};
|
|
1971
|
+
// Layout & positioning
|
|
1972
|
+
if (n.absoluteBoundingBox)
|
|
1973
|
+
result.absoluteBoundingBox = n.absoluteBoundingBox;
|
|
1974
|
+
if (n.relativeTransform)
|
|
1975
|
+
result.relativeTransform = n.relativeTransform;
|
|
1976
|
+
if (n.size)
|
|
1977
|
+
result.size = n.size;
|
|
1978
|
+
if (n.constraints)
|
|
1979
|
+
result.constraints = n.constraints;
|
|
1980
|
+
if (n.layoutAlign)
|
|
1981
|
+
result.layoutAlign = n.layoutAlign;
|
|
1982
|
+
if (n.layoutGrow)
|
|
1983
|
+
result.layoutGrow = n.layoutGrow;
|
|
1984
|
+
if (n.layoutPositioning)
|
|
1985
|
+
result.layoutPositioning = n.layoutPositioning;
|
|
1986
|
+
// Auto-layout
|
|
1987
|
+
if (n.layoutMode)
|
|
1988
|
+
result.layoutMode = n.layoutMode;
|
|
1989
|
+
if (n.primaryAxisSizingMode)
|
|
1990
|
+
result.primaryAxisSizingMode = n.primaryAxisSizingMode;
|
|
1991
|
+
if (n.counterAxisSizingMode)
|
|
1992
|
+
result.counterAxisSizingMode = n.counterAxisSizingMode;
|
|
1993
|
+
if (n.primaryAxisAlignItems)
|
|
1994
|
+
result.primaryAxisAlignItems = n.primaryAxisAlignItems;
|
|
1995
|
+
if (n.counterAxisAlignItems)
|
|
1996
|
+
result.counterAxisAlignItems = n.counterAxisAlignItems;
|
|
1997
|
+
if (n.paddingLeft !== undefined)
|
|
1998
|
+
result.paddingLeft = n.paddingLeft;
|
|
1999
|
+
if (n.paddingRight !== undefined)
|
|
2000
|
+
result.paddingRight = n.paddingRight;
|
|
2001
|
+
if (n.paddingTop !== undefined)
|
|
2002
|
+
result.paddingTop = n.paddingTop;
|
|
2003
|
+
if (n.paddingBottom !== undefined)
|
|
2004
|
+
result.paddingBottom = n.paddingBottom;
|
|
2005
|
+
if (n.itemSpacing !== undefined)
|
|
2006
|
+
result.itemSpacing = n.itemSpacing;
|
|
2007
|
+
if (n.itemReverseZIndex)
|
|
2008
|
+
result.itemReverseZIndex = n.itemReverseZIndex;
|
|
2009
|
+
if (n.strokesIncludedInLayout)
|
|
2010
|
+
result.strokesIncludedInLayout = n.strokesIncludedInLayout;
|
|
2011
|
+
// Visual properties
|
|
2012
|
+
if (n.fills)
|
|
2013
|
+
result.fills = n.fills;
|
|
2014
|
+
if (n.strokes)
|
|
2015
|
+
result.strokes = n.strokes;
|
|
2016
|
+
if (n.strokeWeight !== undefined)
|
|
2017
|
+
result.strokeWeight = n.strokeWeight;
|
|
2018
|
+
if (n.strokeAlign)
|
|
2019
|
+
result.strokeAlign = n.strokeAlign;
|
|
2020
|
+
if (n.strokeCap)
|
|
2021
|
+
result.strokeCap = n.strokeCap;
|
|
2022
|
+
if (n.strokeJoin)
|
|
2023
|
+
result.strokeJoin = n.strokeJoin;
|
|
2024
|
+
if (n.dashPattern)
|
|
2025
|
+
result.dashPattern = n.dashPattern;
|
|
2026
|
+
if (n.cornerRadius !== undefined)
|
|
2027
|
+
result.cornerRadius = n.cornerRadius;
|
|
2028
|
+
if (n.rectangleCornerRadii)
|
|
2029
|
+
result.rectangleCornerRadii = n.rectangleCornerRadii;
|
|
2030
|
+
if (n.effects)
|
|
2031
|
+
result.effects = n.effects;
|
|
2032
|
+
if (n.opacity !== undefined)
|
|
2033
|
+
result.opacity = n.opacity;
|
|
2034
|
+
if (n.blendMode)
|
|
2035
|
+
result.blendMode = n.blendMode;
|
|
2036
|
+
if (n.isMask)
|
|
2037
|
+
result.isMask = n.isMask;
|
|
2038
|
+
if (n.clipsContent)
|
|
2039
|
+
result.clipsContent = n.clipsContent;
|
|
2040
|
+
// Typography
|
|
2041
|
+
if (n.characters)
|
|
2042
|
+
result.characters = n.characters;
|
|
2043
|
+
if (n.style)
|
|
2044
|
+
result.style = n.style;
|
|
2045
|
+
if (n.characterStyleOverrides)
|
|
2046
|
+
result.characterStyleOverrides = n.characterStyleOverrides;
|
|
2047
|
+
if (n.styleOverrideTable)
|
|
2048
|
+
result.styleOverrideTable = n.styleOverrideTable;
|
|
2049
|
+
// Component properties & variants
|
|
2050
|
+
if (n.componentProperties)
|
|
2051
|
+
result.componentProperties = n.componentProperties;
|
|
2052
|
+
if (n.componentPropertyDefinitions)
|
|
2053
|
+
result.componentPropertyDefinitions = n.componentPropertyDefinitions;
|
|
2054
|
+
if (n.variantProperties)
|
|
2055
|
+
result.variantProperties = n.variantProperties;
|
|
2056
|
+
if (n.componentId)
|
|
2057
|
+
result.componentId = n.componentId;
|
|
2058
|
+
// State
|
|
2059
|
+
if (n.visible !== undefined)
|
|
2060
|
+
result.visible = n.visible;
|
|
2061
|
+
if (n.locked)
|
|
2062
|
+
result.locked = n.locked;
|
|
2063
|
+
// Recursively process children
|
|
2064
|
+
if (n.children) {
|
|
2065
|
+
result.children = n.children.map((child) => filterForDevelopment(child));
|
|
2066
|
+
}
|
|
2067
|
+
return result;
|
|
2068
|
+
};
|
|
2069
|
+
const componentData = filterForDevelopment(node);
|
|
2070
|
+
// Get image if requested
|
|
2071
|
+
let imageUrl = null;
|
|
2072
|
+
if (includeImage) {
|
|
2073
|
+
try {
|
|
2074
|
+
const imageResult = await api.getImages(fileKey, nodeId, {
|
|
2075
|
+
scale: 2,
|
|
2076
|
+
format: "png",
|
|
2077
|
+
contents_only: true,
|
|
2078
|
+
});
|
|
2079
|
+
imageUrl = imageResult.images[nodeId];
|
|
2080
|
+
}
|
|
2081
|
+
catch (error) {
|
|
2082
|
+
logger.warn({ error }, "Failed to render component image, continuing without it");
|
|
2083
|
+
}
|
|
2084
|
+
}
|
|
2085
|
+
// Build response with component data and image URL
|
|
2086
|
+
return {
|
|
2087
|
+
content: [
|
|
2088
|
+
{
|
|
2089
|
+
type: "text",
|
|
2090
|
+
text: JSON.stringify({
|
|
2091
|
+
fileKey,
|
|
2092
|
+
nodeId,
|
|
2093
|
+
imageUrl,
|
|
2094
|
+
component: componentData,
|
|
2095
|
+
metadata: {
|
|
2096
|
+
purpose: "component_development",
|
|
2097
|
+
note: imageUrl
|
|
2098
|
+
? "Image URL provided above (valid for 30 days). Full component data optimized for UI implementation."
|
|
2099
|
+
: "Full component data optimized for UI implementation.",
|
|
2100
|
+
},
|
|
2101
|
+
}, null, 2),
|
|
2102
|
+
},
|
|
2103
|
+
],
|
|
2104
|
+
};
|
|
2105
|
+
}
|
|
2106
|
+
catch (error) {
|
|
2107
|
+
logger.error({ error }, "Failed to get component for development");
|
|
2108
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2109
|
+
return {
|
|
2110
|
+
content: [
|
|
2111
|
+
{
|
|
2112
|
+
type: "text",
|
|
2113
|
+
text: JSON.stringify({
|
|
2114
|
+
error: errorMessage,
|
|
2115
|
+
message: "Failed to retrieve component development data",
|
|
2116
|
+
}, null, 2),
|
|
2117
|
+
},
|
|
2118
|
+
],
|
|
2119
|
+
isError: true,
|
|
2120
|
+
};
|
|
2121
|
+
}
|
|
2122
|
+
});
|
|
2123
|
+
// Tool 14: Get File for Plugin Development
|
|
2124
|
+
server.tool("figma_get_file_for_plugin", {
|
|
2125
|
+
fileUrl: z
|
|
2126
|
+
.string()
|
|
2127
|
+
.url()
|
|
2128
|
+
.optional()
|
|
2129
|
+
.describe("Figma file URL (e.g., https://figma.com/design/abc123). REQUIRED unless figma_navigate was already called."),
|
|
2130
|
+
depth: z
|
|
2131
|
+
.number()
|
|
2132
|
+
.min(0)
|
|
2133
|
+
.max(5)
|
|
2134
|
+
.optional()
|
|
2135
|
+
.default(2)
|
|
2136
|
+
.describe("How many levels of children to include (default: 2, max: 5). Higher depths are safe here due to filtering."),
|
|
2137
|
+
nodeIds: z
|
|
2138
|
+
.array(z.string())
|
|
2139
|
+
.optional()
|
|
2140
|
+
.describe("Specific node IDs to retrieve (optional)"),
|
|
2141
|
+
}, async ({ fileUrl, depth, nodeIds }) => {
|
|
2142
|
+
try {
|
|
2143
|
+
const api = await getFigmaAPI();
|
|
2144
|
+
const url = fileUrl || getCurrentUrl();
|
|
2145
|
+
if (!url) {
|
|
2146
|
+
throw new Error("No Figma file URL provided. Either pass fileUrl parameter or call figma_navigate first.");
|
|
2147
|
+
}
|
|
2148
|
+
const fileKey = extractFileKey(url);
|
|
2149
|
+
if (!fileKey) {
|
|
2150
|
+
throw new Error(`Invalid Figma URL: ${url}`);
|
|
2151
|
+
}
|
|
2152
|
+
logger.info({ fileKey, depth, nodeIds }, "Fetching file data for plugin development");
|
|
2153
|
+
const fileData = await api.getFile(fileKey, {
|
|
2154
|
+
depth,
|
|
2155
|
+
ids: nodeIds,
|
|
2156
|
+
});
|
|
2157
|
+
// Filter to plugin-relevant properties only
|
|
2158
|
+
const filterForPlugin = (node) => {
|
|
2159
|
+
if (!node)
|
|
2160
|
+
return node;
|
|
2161
|
+
const result = {
|
|
2162
|
+
id: node.id,
|
|
2163
|
+
name: node.name,
|
|
2164
|
+
type: node.type,
|
|
2165
|
+
description: node.description,
|
|
2166
|
+
descriptionMarkdown: node.descriptionMarkdown,
|
|
2167
|
+
};
|
|
2168
|
+
// Navigation & structure
|
|
2169
|
+
if (node.visible !== undefined)
|
|
2170
|
+
result.visible = node.visible;
|
|
2171
|
+
if (node.locked)
|
|
2172
|
+
result.locked = node.locked;
|
|
2173
|
+
if (node.removed)
|
|
2174
|
+
result.removed = node.removed;
|
|
2175
|
+
// Lightweight bounds (just position/size)
|
|
2176
|
+
if (node.absoluteBoundingBox) {
|
|
2177
|
+
result.bounds = {
|
|
2178
|
+
x: node.absoluteBoundingBox.x,
|
|
2179
|
+
y: node.absoluteBoundingBox.y,
|
|
2180
|
+
width: node.absoluteBoundingBox.width,
|
|
2181
|
+
height: node.absoluteBoundingBox.height,
|
|
2182
|
+
};
|
|
2183
|
+
}
|
|
2184
|
+
// Plugin data (CRITICAL for plugins)
|
|
2185
|
+
if (node.pluginData)
|
|
2186
|
+
result.pluginData = node.pluginData;
|
|
2187
|
+
if (node.sharedPluginData)
|
|
2188
|
+
result.sharedPluginData = node.sharedPluginData;
|
|
2189
|
+
// Component relationships (important for plugins)
|
|
2190
|
+
if (node.componentId)
|
|
2191
|
+
result.componentId = node.componentId;
|
|
2192
|
+
if (node.mainComponent)
|
|
2193
|
+
result.mainComponent = node.mainComponent;
|
|
2194
|
+
if (node.componentPropertyReferences)
|
|
2195
|
+
result.componentPropertyReferences = node.componentPropertyReferences;
|
|
2196
|
+
if (node.instanceOf)
|
|
2197
|
+
result.instanceOf = node.instanceOf;
|
|
2198
|
+
if (node.exposedInstances)
|
|
2199
|
+
result.exposedInstances = node.exposedInstances;
|
|
2200
|
+
// Component properties (for manipulation)
|
|
2201
|
+
if (node.componentProperties)
|
|
2202
|
+
result.componentProperties = node.componentProperties;
|
|
2203
|
+
// Characters for text nodes (plugins often need this)
|
|
2204
|
+
if (node.characters !== undefined)
|
|
2205
|
+
result.characters = node.characters;
|
|
2206
|
+
// Recursively process children
|
|
2207
|
+
if (node.children) {
|
|
2208
|
+
result.children = node.children.map((child) => filterForPlugin(child));
|
|
2209
|
+
}
|
|
2210
|
+
return result;
|
|
2211
|
+
};
|
|
2212
|
+
const filteredDocument = filterForPlugin(fileData.document);
|
|
2213
|
+
const finalResponse = {
|
|
2214
|
+
fileKey,
|
|
2215
|
+
name: fileData.name,
|
|
2216
|
+
lastModified: fileData.lastModified,
|
|
2217
|
+
version: fileData.version,
|
|
2218
|
+
document: filteredDocument,
|
|
2219
|
+
components: fileData.components
|
|
2220
|
+
? Object.keys(fileData.components).length
|
|
2221
|
+
: 0,
|
|
2222
|
+
styles: fileData.styles
|
|
2223
|
+
? Object.keys(fileData.styles).length
|
|
2224
|
+
: 0,
|
|
2225
|
+
...(nodeIds && {
|
|
2226
|
+
requestedNodes: nodeIds,
|
|
2227
|
+
nodes: fileData.nodes,
|
|
2228
|
+
}),
|
|
2229
|
+
metadata: {
|
|
2230
|
+
purpose: "plugin_development",
|
|
2231
|
+
note: "Optimized for plugin development. Contains IDs, structure, plugin data, and component relationships.",
|
|
2232
|
+
},
|
|
2233
|
+
};
|
|
2234
|
+
// Use adaptive response to prevent context exhaustion
|
|
2235
|
+
return adaptiveResponse(finalResponse, {
|
|
2236
|
+
toolName: "figma_get_file_for_plugin",
|
|
2237
|
+
compressionCallback: (adjustedLevel) => {
|
|
2238
|
+
// For plugin format, we can't reduce much without breaking functionality
|
|
2239
|
+
// But we can strip some less critical metadata
|
|
2240
|
+
const compressNode = (node) => {
|
|
2241
|
+
const result = {
|
|
2242
|
+
id: node.id,
|
|
2243
|
+
name: node.name,
|
|
2244
|
+
type: node.type,
|
|
2245
|
+
};
|
|
2246
|
+
// Keep only essential properties based on compression level
|
|
2247
|
+
if (adjustedLevel !== "inventory") {
|
|
2248
|
+
if (node.visible !== undefined)
|
|
2249
|
+
result.visible = node.visible;
|
|
2250
|
+
if (node.locked !== undefined)
|
|
2251
|
+
result.locked = node.locked;
|
|
2252
|
+
if (node.absoluteBoundingBox)
|
|
2253
|
+
result.absoluteBoundingBox = node.absoluteBoundingBox;
|
|
2254
|
+
if (node.pluginData)
|
|
2255
|
+
result.pluginData = node.pluginData;
|
|
2256
|
+
if (node.sharedPluginData)
|
|
2257
|
+
result.sharedPluginData = node.sharedPluginData;
|
|
2258
|
+
if (node.componentId)
|
|
2259
|
+
result.componentId = node.componentId;
|
|
2260
|
+
}
|
|
2261
|
+
if (node.children) {
|
|
2262
|
+
result.children = node.children.map(compressNode);
|
|
2263
|
+
}
|
|
2264
|
+
return result;
|
|
2265
|
+
};
|
|
2266
|
+
return {
|
|
2267
|
+
...finalResponse,
|
|
2268
|
+
document: compressNode(filteredDocument),
|
|
2269
|
+
metadata: {
|
|
2270
|
+
...finalResponse.metadata,
|
|
2271
|
+
compressionApplied: adjustedLevel,
|
|
2272
|
+
},
|
|
2273
|
+
};
|
|
2274
|
+
},
|
|
2275
|
+
suggestedActions: [
|
|
2276
|
+
"Reduce depth parameter (recommend 1-2)",
|
|
2277
|
+
"Request specific nodeIds to narrow the scope",
|
|
2278
|
+
"Filter to specific component types if possible",
|
|
2279
|
+
],
|
|
2280
|
+
});
|
|
2281
|
+
}
|
|
2282
|
+
catch (error) {
|
|
2283
|
+
logger.error({ error }, "Failed to get file for plugin");
|
|
2284
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2285
|
+
return {
|
|
2286
|
+
content: [
|
|
2287
|
+
{
|
|
2288
|
+
type: "text",
|
|
2289
|
+
text: JSON.stringify({
|
|
2290
|
+
error: errorMessage,
|
|
2291
|
+
message: "Failed to retrieve file data for plugin development",
|
|
2292
|
+
}, null, 2),
|
|
2293
|
+
},
|
|
2294
|
+
],
|
|
2295
|
+
isError: true,
|
|
2296
|
+
};
|
|
2297
|
+
}
|
|
2298
|
+
});
|
|
2299
|
+
}
|