@atezer/figma-mcp-bridge 1.7.29 → 1.9.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +403 -0
  2. package/README.md +4 -3
  3. package/agents/_orchestrator-protocol.md +185 -0
  4. package/agents/ds-auditor.md +73 -22
  5. package/agents/screen-builder.md +60 -22
  6. package/agents/token-syncer.md +63 -19
  7. package/dist/core/code-warnings.d.ts +38 -0
  8. package/dist/core/code-warnings.d.ts.map +1 -0
  9. package/dist/core/code-warnings.js +191 -0
  10. package/dist/core/code-warnings.js.map +1 -0
  11. package/dist/core/device-presets.d.ts +49 -0
  12. package/dist/core/device-presets.d.ts.map +1 -0
  13. package/dist/core/device-presets.js +141 -0
  14. package/dist/core/device-presets.js.map +1 -0
  15. package/dist/core/instructions.d.ts +4 -2
  16. package/dist/core/instructions.d.ts.map +1 -1
  17. package/dist/core/instructions.js +239 -29
  18. package/dist/core/instructions.js.map +1 -1
  19. package/dist/core/plugin-bridge-connector.d.ts +32 -0
  20. package/dist/core/plugin-bridge-connector.d.ts.map +1 -1
  21. package/dist/core/plugin-bridge-connector.js +31 -2
  22. package/dist/core/plugin-bridge-connector.js.map +1 -1
  23. package/dist/core/plugin-bridge-server.d.ts +8 -0
  24. package/dist/core/plugin-bridge-server.d.ts.map +1 -1
  25. package/dist/core/plugin-bridge-server.js +27 -2
  26. package/dist/core/plugin-bridge-server.js.map +1 -1
  27. package/dist/core/response-guard.d.ts +23 -0
  28. package/dist/core/response-guard.d.ts.map +1 -1
  29. package/dist/core/response-guard.js +113 -0
  30. package/dist/core/response-guard.js.map +1 -1
  31. package/dist/core/version.d.ts +1 -1
  32. package/dist/core/version.d.ts.map +1 -1
  33. package/dist/core/version.js +1 -1
  34. package/dist/core/version.js.map +1 -1
  35. package/dist/local-plugin-only.d.ts.map +1 -1
  36. package/dist/local-plugin-only.js +504 -85
  37. package/dist/local-plugin-only.js.map +1 -1
  38. package/f-mcp-plugin/code.js +514 -29
  39. package/f-mcp-plugin/ui.html +62 -6
  40. package/package.json +1 -1
  41. package/skills/SKILL_INDEX.md +13 -1
  42. package/skills/apply-figma-design-system/SKILL.md +37 -0
  43. package/skills/audit-figma-design-system/SKILL.md +38 -0
  44. package/skills/code-design-mapper/SKILL.md +37 -0
  45. package/skills/design-token-pipeline/SKILL.md +44 -0
  46. package/skills/figma-canvas-ops/SKILL.md +200 -240
  47. package/skills/fmcp-ds-audit-orchestrator/SKILL.md +205 -0
  48. package/skills/fmcp-intent-router/SKILL.md +574 -0
  49. package/skills/fmcp-project-rules/SKILL.md +9 -5
  50. package/skills/fmcp-screen-orchestrator/SKILL.md +166 -0
  51. package/skills/fmcp-screen-recipes/SKILL.md +528 -0
  52. package/skills/fmcp-token-sync-orchestrator/SKILL.md +198 -0
  53. package/skills/generate-figma-library/SKILL.md +38 -0
  54. package/skills/generate-figma-screen/SKILL.md +382 -19
  55. package/skills/implement-design/SKILL.md +32 -0
  56. package/skills/inspiration-intake/SKILL.md +220 -0
  57. package/skills/visual-qa-compare/SKILL.md +33 -0
@@ -17,11 +17,22 @@ import { createChildLogger } from "./core/logger.js";
17
17
  import { PluginBridgeServer } from "./core/plugin-bridge-server.js";
18
18
  import { PluginBridgeConnector } from "./core/plugin-bridge-connector.js";
19
19
  import { parseFigmaUrl } from "./core/figma-url.js";
20
- import { truncateRestResponse } from "./core/response-guard.js";
20
+ import { truncateRestResponse, truncatePluginResponse } from "./core/response-guard.js";
21
+ import { analyzeCodeForWarnings } from "./core/code-warnings.js";
22
+ import { resolveDevice, DEVICE_PRESETS } from "./core/device-presets.js";
21
23
  import { closeAuditLog } from "./core/audit-log.js";
22
24
  import { FMCP_VERSION } from "./core/version.js";
23
25
  import { ResponseCache } from "./core/response-cache.js";
24
26
  const logger = createChildLogger({ component: "plugin-only-mcp" });
27
+ /**
28
+ * Legacy default flag — when set, restores pre-v1.8.0 default values for
29
+ * read-only tools (depth=2, verbosity="standard", scale=2, format="PNG").
30
+ * Allows downstream consumers to opt back into the heavier defaults during
31
+ * the v1.8.0 → v1.9.0 transition. Will be removed in v1.9.0.
32
+ *
33
+ * Set: FMCP_LEGACY_DEFAULTS=1
34
+ */
35
+ const LEGACY_DEFAULTS = process.env.FMCP_LEGACY_DEFAULTS === "1";
25
36
  /** Resolve fileKey from figmaUrl (parse) or explicit fileKey. Returns undefined if neither yields a key. */
26
37
  function resolveFileKey(figmaUrl, explicitFileKey) {
27
38
  if (explicitFileKey && explicitFileKey.trim())
@@ -98,6 +109,8 @@ function getErrorHint(category) {
98
109
  default: return "Hata mesajini kontrol et.";
99
110
  }
100
111
  }
112
+ // analyzeCodeForWarnings + CodeWarning type moved to ./core/code-warnings.ts (v1.8.1)
113
+ // Imported at the top of this file — this comment marks where the helper used to live.
101
114
  /** Wrap a tool handler with try-catch to prevent unhandled rejections. */
102
115
  function safeToolHandler(handler) {
103
116
  return async (params) => {
@@ -136,6 +149,49 @@ export async function main() {
136
149
  const cache = new ResponseCache();
137
150
  /** Invalidate cache after any mutating operation. */
138
151
  function invalidateCache() { cache.invalidate(); }
152
+ /**
153
+ * Build a stable cache key from tool name + params. Sort keys for determinism.
154
+ * fileKey is included to avoid cross-file leakage in multi-file sessions.
155
+ */
156
+ function makeCacheKey(toolName, params) {
157
+ const sortedEntries = Object.entries(params)
158
+ .filter(([_, v]) => v !== undefined)
159
+ .sort(([a], [b]) => a.localeCompare(b));
160
+ return `${toolName}::${JSON.stringify(sortedEntries)}`;
161
+ }
162
+ /**
163
+ * Shared envelope wrapper for plugin tool results. Applies truncatePluginResponse
164
+ * unless skipGuard is true (for cache hits whose data was already guarded).
165
+ * When debug=true, the _responseGuard marker is preserved; otherwise stripped.
166
+ */
167
+ function toolResult(data, toolName, opts) {
168
+ let payload;
169
+ if (data === undefined || data === null) {
170
+ payload = { success: false, error: "No data from plugin" };
171
+ }
172
+ else if (opts?.skipGuard) {
173
+ payload = data;
174
+ }
175
+ else {
176
+ const result = truncatePluginResponse(data, toolName);
177
+ payload = result.data;
178
+ // Strip _responseGuard marker unless debug=true
179
+ if (!opts?.debug && payload && typeof payload === "object" && payload._responseGuard) {
180
+ const stripped = { ...payload };
181
+ delete stripped._responseGuard;
182
+ payload = stripped;
183
+ }
184
+ }
185
+ const text = typeof payload === "string" ? payload : JSON.stringify(payload);
186
+ return { content: [{ type: "text", text }] };
187
+ }
188
+ /** Helper for error responses with consistent shape. */
189
+ function errorResult(msg) {
190
+ return {
191
+ content: [{ type: "text", text: JSON.stringify({ success: false, error: msg }) }],
192
+ isError: true,
193
+ };
194
+ }
139
195
  const server = new McpServer({
140
196
  name: "F-MCP ATezer Bridge (Plugin-only)",
141
197
  version: FMCP_VERSION,
@@ -162,8 +218,9 @@ export async function main() {
162
218
  };
163
219
  });
164
220
  // ---- figma_get_file_data_plugin (no REST, no token) ----
221
+ // v1.8.0: Defaults already conservative (depth=1, verbosity="summary"). Cached + truncated.
165
222
  server.registerTool("figma_get_file_data", {
166
- description: "Get file structure and document tree from the open Figma file. No REST API or token. Use fileKey or figmaUrl to target a specific file when multiple plugins are connected (Figma Desktop, FigJam browser, Figma browser). Pass a Figma/FigJam URL in figmaUrl to route by link.",
223
+ description: "Get file structure and document tree from the open Figma file. No REST API or token. Use fileKey or figmaUrl to target a specific file when multiple plugins are connected. Defaults to depth=1, verbosity='summary'. Cached 60s per session.",
167
224
  inputSchema: {
168
225
  figmaUrl: z.string().optional().describe("Figma or FigJam file URL; fileKey is extracted from the link for routing."),
169
226
  fileKey: z.string().optional().describe("Target a specific connected file. Use figma_list_connected_files to see available files."),
@@ -174,16 +231,14 @@ export async function main() {
174
231
  includeTypography: z.boolean().optional(),
175
232
  includeCodeReady: z.boolean().optional(),
176
233
  outputHint: z.enum(["react", "tailwind"]).optional(),
234
+ debug: z.boolean().optional().describe("Bypass cache and include _responseGuard fields."),
177
235
  },
178
236
  annotations: { readOnlyHint: true },
179
- }, async ({ figmaUrl, fileKey, depth, verbosity, includeLayout, includeVisual, includeTypography, includeCodeReady, outputHint }) => {
237
+ }, async ({ figmaUrl, fileKey, depth, verbosity, includeLayout, includeVisual, includeTypography, includeCodeReady, outputHint, debug }) => {
180
238
  try {
181
239
  const resolvedKey = resolveFileKey(figmaUrl, fileKey);
182
240
  if (figmaUrl && !resolvedKey) {
183
- return {
184
- content: [{ type: "text", text: JSON.stringify({ success: false, error: "Invalid Figma/FigJam URL: could not extract file key." }) }],
185
- isError: true,
186
- };
241
+ return errorResult("Invalid Figma/FigJam URL: could not extract file key.");
187
242
  }
188
243
  const conn = getConnector(bridge, resolvedKey);
189
244
  const opts = includeLayout !== undefined ||
@@ -193,13 +248,12 @@ export async function main() {
193
248
  outputHint !== undefined
194
249
  ? { includeLayout, includeVisual, includeTypography, includeCodeReady, outputHint }
195
250
  : undefined;
196
- const data = await conn.getDocumentStructure(depth, verbosity, opts);
197
- const text = data === undefined || data === null
198
- ? JSON.stringify({ success: false, error: "No data from plugin" })
199
- : typeof data === "string"
200
- ? data
201
- : JSON.stringify(data);
202
- return { content: [{ type: "text", text }] };
251
+ const cacheK = makeCacheKey("figma_get_file_data", { resolvedKey, depth, verbosity, opts });
252
+ const cached = debug ? null : cache.get(cacheK, 60_000);
253
+ const data = cached ?? await conn.getDocumentStructure(depth, verbosity, opts);
254
+ if (!cached)
255
+ cache.set(cacheK, data);
256
+ return toolResult(data, "figma_get_file_data", { skipGuard: !!cached, debug });
203
257
  }
204
258
  catch (err) {
205
259
  const msg = err instanceof Error ? err.message : String(err);
@@ -210,23 +264,26 @@ export async function main() {
210
264
  }
211
265
  });
212
266
  // ---- figma_get_design_context (get_design_context tarzı, token tasarruflu, Figma token yok) ----
267
+ // v1.8.0: Context-safe defaults — depth=1, verbosity="summary"
268
+ // Override with FMCP_LEGACY_DEFAULTS=1 env var or pass explicit params.
213
269
  server.registerTool("figma_get_design_context", {
214
- description: "Design context for a node or whole file: structure + text, layout/visual/typography. Use fileKey or figmaUrl to target a file when multiple plugins are connected. Pass a Figma/FigJam URL in figmaUrl; fileKey and node-id (if present in the link) are extracted automatically.",
270
+ description: "Design context for a node or whole file: structure + text, layout/visual/typography. Defaults to depth=1, verbosity='summary' for context safety. Pass depth/verbosity explicitly for deeper data. Cached 60s per session.",
215
271
  inputSchema: {
216
272
  figmaUrl: z.string().optional().describe("Figma or FigJam file URL; fileKey and optional node-id are extracted for routing."),
217
273
  fileKey: z.string().optional().describe("Target a specific connected file."),
218
274
  nodeId: z.string().optional(),
219
- depth: z.number().min(0).max(3).optional().default(2),
220
- verbosity: z.enum(["summary", "standard", "full"]).optional().default("standard"),
221
- excludeScreenshot: z.boolean().optional(),
275
+ depth: z.number().min(0).max(3).optional().default(LEGACY_DEFAULTS ? 2 : 1),
276
+ verbosity: z.enum(["summary", "standard", "full"]).optional().default(LEGACY_DEFAULTS ? "standard" : "summary"),
277
+ excludeScreenshot: z.boolean().optional().describe("Reserved for future use; plugin currently does not embed screenshots in design_context."),
222
278
  includeLayout: z.boolean().optional(),
223
279
  includeVisual: z.boolean().optional(),
224
280
  includeTypography: z.boolean().optional(),
225
281
  includeCodeReady: z.boolean().optional(),
226
282
  outputHint: z.enum(["react", "tailwind"]).optional(),
283
+ debug: z.boolean().optional().describe("Bypass cache and include _responseGuard/_metrics fields."),
227
284
  },
228
285
  annotations: { readOnlyHint: true },
229
- }, async ({ figmaUrl, fileKey, nodeId, depth, verbosity, excludeScreenshot, includeLayout, includeVisual, includeTypography, includeCodeReady, outputHint }) => {
286
+ }, async ({ figmaUrl, fileKey, nodeId, depth, verbosity, excludeScreenshot, includeLayout, includeVisual, includeTypography, includeCodeReady, outputHint, debug }) => {
230
287
  try {
231
288
  const { fileKey: resolvedKey, nodeId: resolvedNodeId } = resolveDesignContextParams({ figmaUrl, fileKey, nodeId });
232
289
  if (figmaUrl && !resolvedKey) {
@@ -245,15 +302,15 @@ export async function main() {
245
302
  ? { excludeScreenshot, includeLayout, includeVisual, includeTypography, includeCodeReady, outputHint }
246
303
  : undefined;
247
304
  const effectiveNodeId = resolvedNodeId ?? nodeId?.trim();
248
- const data = effectiveNodeId
305
+ // Cache lookup (60s TTL) — bypassed when debug=true
306
+ const cacheK = makeCacheKey("figma_get_design_context", { resolvedKey, effectiveNodeId, depth, verbosity, opts });
307
+ const cached = debug ? null : cache.get(cacheK, 60_000);
308
+ const data = cached ?? (effectiveNodeId
249
309
  ? await conn.getNodeContext(effectiveNodeId, depth, verbosity, opts)
250
- : await conn.getDocumentStructure(depth, verbosity, opts);
251
- const text = data === undefined || data === null
252
- ? JSON.stringify({ success: false, error: "No data from plugin" })
253
- : typeof data === "string"
254
- ? data
255
- : JSON.stringify(data);
256
- return { content: [{ type: "text", text }] };
310
+ : await conn.getDocumentStructure(depth, verbosity, opts));
311
+ if (!cached)
312
+ cache.set(cacheK, data);
313
+ return toolResult(data, "figma_get_design_context", { skipGuard: !!cached, debug });
257
314
  }
258
315
  catch (err) {
259
316
  const msg = err instanceof Error ? err.message : String(err);
@@ -328,7 +385,11 @@ export async function main() {
328
385
  }));
329
386
  // ---- figma_execute ----
330
387
  server.registerTool("figma_execute", {
331
- description: "Run JavaScript in the Figma plugin context. Full Plugin API available. Use fileKey or figmaUrl to target a specific file.",
388
+ description: "Run JavaScript in the Figma plugin context. Full Plugin API available. Use fileKey or figmaUrl to target a specific file. " +
389
+ "v1.8.1+: Static analysis detects design-system discipline violations (hardcoded colors, missing token bindings, no-instance usage, hardcoded typography). " +
390
+ "SEVERE warnings are promoted to the top of the response as _designSystemViolations — Claude must read and self-correct. " +
391
+ "Also detects gotchas: FILL/ABSOLUTE before appendChild, sync API usage, missing loadFontAsync, sync currentPage assignment. " +
392
+ "For component instances: use setProperties({...}), NOT findAll(TEXT).",
332
393
  inputSchema: {
333
394
  figmaUrl: z.string().optional().describe("Figma or FigJam file URL for routing."),
334
395
  fileKey: z.string().optional().describe("Target a specific connected file."),
@@ -343,8 +404,26 @@ export async function main() {
343
404
  isError: true,
344
405
  };
345
406
  }
346
- const clampedTimeout = Math.max(3000, Math.min(timeout ?? 15000, 120000));
407
+ const clampedTimeout = Math.max(3000, Math.min(timeout ?? 15000, 30000));
347
408
  invalidateCache();
409
+ // v1.8.1: Structured warnings with SEVERE vs ADVISORY severity
410
+ const codeWarnings = analyzeCodeForWarnings(code);
411
+ const severeWarnings = codeWarnings.filter((w) => w.severity === "SEVERE");
412
+ const advisoryWarnings = codeWarnings.filter((w) => w.severity === "ADVISORY");
413
+ // SEVERE warnings go to a prominent field that Claude cannot ignore
414
+ const dsViolations = severeWarnings.length > 0
415
+ ? {
416
+ _designSystemViolations: {
417
+ count: severeWarnings.length,
418
+ message: "⚠️ DESIGN SYSTEM DISIPLIN IHLALI TESPIT EDILDI — kodu duzelt ve tekrar calistir. Bu ekran kabul edilmez.",
419
+ violations: severeWarnings.map((w) => ({ category: w.category, message: w.message })),
420
+ action: "Her ihlal icin uygun DS token binding veya component instance kullan. Detay: figma-canvas-ops SKILL Kural 10.",
421
+ },
422
+ }
423
+ : {};
424
+ const warningsField = advisoryWarnings.length > 0
425
+ ? { _warnings: advisoryWarnings.map((w) => w.message) }
426
+ : {};
348
427
  const startTime = Date.now();
349
428
  try {
350
429
  const conn = getConnector(bridge, resolveFileKey(figmaUrl, fileKey));
@@ -362,10 +441,12 @@ export async function main() {
362
441
  catch { /* safe fallback */ }
363
442
  return {
364
443
  content: [{ type: "text", text: JSON.stringify({
444
+ ...dsViolations, // v1.8.1: SEVERE warnings FIRST, before anything else
365
445
  ...result,
366
446
  errorCategory: category,
367
447
  _metrics: { durationMs, timeoutMs: clampedTimeout },
368
448
  hint,
449
+ ...warningsField,
369
450
  }) }],
370
451
  isError: true,
371
452
  };
@@ -373,8 +454,15 @@ export async function main() {
373
454
  let enriched;
374
455
  try {
375
456
  enriched = typeof result === "object" && result !== null
376
- ? { ...result, _metrics: { durationMs, timeoutMs: clampedTimeout } }
377
- : result;
457
+ ? {
458
+ ...dsViolations, // v1.8.1: SEVERE warnings at top level
459
+ ...result,
460
+ _metrics: { durationMs, timeoutMs: clampedTimeout },
461
+ ...warningsField,
462
+ }
463
+ : severeWarnings.length > 0 || advisoryWarnings.length > 0
464
+ ? { ...dsViolations, result, ...warningsField }
465
+ : result;
378
466
  }
379
467
  catch {
380
468
  enriched = result;
@@ -399,26 +487,122 @@ export async function main() {
399
487
  error: msg,
400
488
  _metrics: { durationMs, timeoutMs: clampedTimeout },
401
489
  hint,
490
+ ...warningsField,
402
491
  }) }],
403
492
  isError: true,
404
493
  };
405
494
  }
406
495
  }));
496
+ // ============================================================================
497
+ // v1.8.1: HIGH-LEVEL "FAST AND CORRECT" TOOLS
498
+ // ============================================================================
499
+ //
500
+ // These tools sit above figma_execute and provide one-call solutions for
501
+ // common workflows. Claude should prefer these over hand-written execute
502
+ // code when possible — they handle token binding, auto-layout preservation,
503
+ // and DS instance preservation automatically.
504
+ // ============================================================================
505
+ // ---- figma_clone_screen_to_device ----
506
+ // v1.8.2: NARROW USE CASE ONLY — device migration.
507
+ // For alternatives/variations/new designs, use figma_execute with the
508
+ // generate-figma-screen SKILL Step 5 "build from scratch" pattern.
509
+ // Clone will copy benchmark's existing mistakes (hardcoded rectangles,
510
+ // missing token bindings, non-responsive layouts).
511
+ server.registerTool("figma_clone_screen_to_device", {
512
+ description: "⚠️ NARROW USE CASE — Device migration ONLY. Clone a Figma screen to a target device " +
513
+ "dimension, preserving library instances, bound variables, and auto-layout. " +
514
+ "USE ONLY WHEN: same design system + same layout structure + only screen size changes. " +
515
+ "DO NOT USE FOR: creating alternatives, variations, or new designs — these REQUIRE " +
516
+ "building from scratch with figma_execute following the generate-figma-screen SKILL " +
517
+ "Step 5 pattern (search_assets → instantiate_component → setBoundVariable → auto-layout FILL). " +
518
+ "Clone copies benchmark's EXISTING mistakes (hardcoded rectangles, missing token bindings, " +
519
+ "non-responsive layouts). Benchmark is INSPIRATION, not a copy source for variations. " +
520
+ "If the user says 'alternatif', 'varyasyon', 'farklı', 'yeni', 'tasarla' — USE figma_execute + Step 5, NOT this tool. " +
521
+ "Device presets: iPhone 17, iPhone 16 Pro Max, Android Compact, iPad Pro 11, Desktop, " +
522
+ "and more. Custom: 'WxH' format.",
523
+ inputSchema: {
524
+ figmaUrl: z.string().optional().describe("Figma file URL for routing."),
525
+ fileKey: z.string().optional().describe("Target a specific connected file."),
526
+ sourceNodeId: z.string().describe("Node ID of the source screen to clone (e.g. '139:3407')"),
527
+ targetDevice: z.string().describe("Device preset name (e.g. 'iPhone 17', 'Android Compact') or custom 'WxH' (e.g. '1200x800')"),
528
+ newName: z.string().optional().describe("Name for the cloned screen (default: source name + device suffix)"),
529
+ targetParentId: z.string().optional().describe("Parent node to place the clone under (default: current page)"),
530
+ position: z
531
+ .object({ x: z.number(), y: z.number() })
532
+ .optional()
533
+ .describe("Explicit position for the clone (default: auto-placed right of source)"),
534
+ },
535
+ annotations: { destructiveHint: true },
536
+ }, safeToolHandler(async ({ figmaUrl, fileKey, sourceNodeId, targetDevice, newName, targetParentId, position, }) => {
537
+ // Resolve device name to concrete dimensions
538
+ const resolved = resolveDevice(targetDevice);
539
+ if (!resolved) {
540
+ return errorResult(`Unknown device preset '${targetDevice}'. Supported presets: ${DEVICE_PRESETS.map((p) => p.name).join(", ")}. For custom, use 'WxH' format (e.g. '1200x800').`);
541
+ }
542
+ invalidateCache();
543
+ const conn = getConnector(bridge, resolveFileKey(figmaUrl, fileKey));
544
+ const result = await conn.cloneScreenToDevice({
545
+ sourceNodeId,
546
+ targetWidth: resolved.width,
547
+ targetHeight: resolved.height,
548
+ targetDeviceName: resolved.name,
549
+ newName,
550
+ targetParentId,
551
+ position,
552
+ });
553
+ return toolResult(result, "figma_clone_screen_to_device");
554
+ }));
555
+ // ---- figma_validate_screen ----
556
+ // Post-creation audit tool. Walks a node tree and scores its DS compliance
557
+ // across 3 dimensions: instance coverage, token binding coverage, auto-layout
558
+ // coverage. Returns pass/fail + actionable violations.
559
+ server.registerTool("figma_validate_screen", {
560
+ description: "Validate a screen against design-system discipline criteria. Returns a compliance score (0-100) " +
561
+ "across 3 dimensions: instance coverage (library usage), token binding coverage (bound variables), " +
562
+ "and auto-layout coverage. Use this AFTER creating a screen to verify DS compliance. " +
563
+ "If score < minScore, Claude should delete the screen and rebuild it using DS components + token bindings. " +
564
+ "Read-only — never mutates the file.",
565
+ inputSchema: {
566
+ figmaUrl: z.string().optional().describe("Figma file URL for routing."),
567
+ fileKey: z.string().optional().describe("Target a specific connected file."),
568
+ nodeId: z.string().describe("Node ID of the screen to validate"),
569
+ expectedDs: z.string().optional().describe("Expected DS library name (e.g. '❖ SUI') for library match scoring"),
570
+ minScore: z.number().min(0).max(100).optional().default(80).describe("Minimum acceptable score (0-100). Below this, the screen is considered non-compliant."),
571
+ },
572
+ annotations: { readOnlyHint: true },
573
+ }, safeToolHandler(async ({ figmaUrl, fileKey, nodeId, expectedDs, minScore, }) => {
574
+ const conn = getConnector(bridge, resolveFileKey(figmaUrl, fileKey));
575
+ const result = await conn.validateScreen({ nodeId, expectedDs, minScore });
576
+ return toolResult(result, "figma_validate_screen");
577
+ }));
407
578
  // ---- figma_capture_screenshot ----
579
+ // v1.8.0: Default JPG@1x (~80% smaller base64 vs PNG@2x). Override via params.
408
580
  server.registerTool("figma_capture_screenshot", {
409
- description: "Capture screenshot of a node or current view from the plugin. No REST API. Use fileKey or figmaUrl to target a specific file.",
581
+ description: "Capture screenshot of a node or current view from the plugin. No REST API. Defaults to JPG@1x (q70) for context safety. Use scale/format/jpegQuality to override.",
410
582
  inputSchema: {
411
583
  figmaUrl: z.string().optional().describe("Figma or FigJam file URL for routing."),
412
584
  fileKey: z.string().optional().describe("Target a specific connected file."),
413
585
  nodeId: z.string().optional(),
414
- format: z.enum(["PNG", "JPG"]).optional().default("PNG"),
415
- scale: z.number().optional().default(2),
586
+ format: z.enum(["PNG", "JPG"]).optional().default(LEGACY_DEFAULTS ? "PNG" : "JPG"),
587
+ scale: z.number().optional().default(LEGACY_DEFAULTS ? 2 : 1),
588
+ jpegQuality: z.number().min(30).max(100).optional().default(70).describe("JPEG quality 30-100. Ignored when format=PNG."),
416
589
  },
417
590
  annotations: { readOnlyHint: true },
418
- }, safeToolHandler(async ({ figmaUrl, fileKey, nodeId, format, scale }) => {
591
+ }, safeToolHandler(async ({ figmaUrl, fileKey, nodeId, format, scale, jpegQuality }) => {
419
592
  const conn = getConnector(bridge, resolveFileKey(figmaUrl, fileKey));
420
- const result = await conn.captureScreenshot(nodeId ?? null, { format, scale });
421
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
593
+ const result = await conn.captureScreenshot(nodeId ?? null, { format, scale, jpegQuality });
594
+ // v1.9.6 Fix M1 — skipGuard: true truncatePluginResponse'u bypass et.
595
+ // Gerekçe: screenshot response {success, image: {base64, ...}} şeklinde.
596
+ // pruneNodeTree screenshot payload'una (node tree değil) uymadığı için
597
+ // final fallback truncateResponse({maxStringLength: 200}) devreye girip
598
+ // base64 string'i 200 karaktere kırpıyordu → Claude Desktop `{}` empty
599
+ // object görüyordu (FP-1-R-v2 Known Limitation #8 root cause).
600
+ // skipGuard: true ile payload olduğu gibi JSON.stringify edilir, base64
601
+ // intact kalır. Claude image render etmez (text content block) ama
602
+ // base64 string'i görür, kullanabilir, böylece empty object problemi
603
+ // çözülür. Image content block upgrade'i (MCP image type) Part 5
604
+ // sonraki adımı — safeToolHandler generic refactor'u gerektirir.
605
+ return toolResult(result, "figma_capture_screenshot", { skipGuard: true });
422
606
  }));
423
607
  // ---- figma_set_instance_properties ----
424
608
  server.registerTool("figma_set_instance_properties", {
@@ -581,7 +765,9 @@ export async function main() {
581
765
  }));
582
766
  // ---- Node operations (short list) ----
583
767
  server.registerTool("figma_instantiate_component", {
584
- description: "Create a component instance. Use componentKey from figma_search_components or nodeId for local components.",
768
+ description: "Create a component instance. Use componentKey from figma_search_components, figma_search_assets, or REST API. " +
769
+ "Supports library components (importComponentByKeyAsync) and local components (by nodeId). " +
770
+ "After creation: use overrides with setProperties({...}) for component properties — do NOT use findAll(TEXT) to modify instance text.",
585
771
  inputSchema: {
586
772
  componentKey: z.string(),
587
773
  options: z
@@ -684,35 +870,37 @@ export async function main() {
684
870
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
685
871
  }));
686
872
  server.registerTool("figma_get_component_image", {
687
- description: "Get screenshot of a node (component/frame). Returns base64 image. No REST API.",
873
+ description: "Get screenshot of a node (component/frame). Returns base64 image. Defaults to JPG@1x q70 (v1.8.0 context-safe).",
688
874
  inputSchema: {
689
875
  nodeId: z.string(),
690
- scale: z.number().min(0.5).max(4).optional().default(2),
691
- format: z.enum(["PNG", "JPG"]).optional().default("PNG"),
876
+ scale: z.number().min(0.5).max(4).optional().default(LEGACY_DEFAULTS ? 2 : 1),
877
+ format: z.enum(["PNG", "JPG"]).optional().default(LEGACY_DEFAULTS ? "PNG" : "JPG"),
878
+ jpegQuality: z.number().min(30).max(100).optional().default(70),
692
879
  },
693
880
  annotations: { readOnlyHint: true },
694
- }, safeToolHandler(async ({ nodeId, scale, format }) => {
881
+ }, safeToolHandler(async ({ nodeId, scale, format, jpegQuality }) => {
695
882
  const conn = getConnector(bridge);
696
- const result = await conn.captureScreenshot(nodeId, { scale, format });
697
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
883
+ const result = await conn.captureScreenshot(nodeId, { scale, format, jpegQuality });
884
+ return toolResult(result, "figma_get_component_image");
698
885
  }));
699
886
  server.registerTool("figma_get_component_for_development", {
700
- description: "Get component metadata plus base64 screenshot in one call. For design-to-code workflows.",
887
+ description: "Get component metadata plus base64 screenshot in one call. For design-to-code workflows. Defaults to JPG@1x q70 (v1.8.0 context-safe).",
701
888
  inputSchema: {
702
889
  nodeId: z.string(),
703
- scale: z.number().min(0.5).max(4).optional().default(2),
704
- format: z.enum(["PNG", "JPG"]).optional().default("PNG"),
890
+ scale: z.number().min(0.5).max(4).optional().default(LEGACY_DEFAULTS ? 2 : 1),
891
+ format: z.enum(["PNG", "JPG"]).optional().default(LEGACY_DEFAULTS ? "PNG" : "JPG"),
892
+ jpegQuality: z.number().min(30).max(100).optional().default(70),
705
893
  },
706
894
  annotations: { readOnlyHint: true },
707
- }, safeToolHandler(async ({ nodeId, scale, format }) => {
895
+ }, safeToolHandler(async ({ nodeId, scale, format, jpegQuality }) => {
708
896
  const conn = getConnector(bridge);
709
897
  const [component, screenshot] = await Promise.all([
710
898
  conn.getComponentFromPluginUI(nodeId),
711
- conn.captureScreenshot(nodeId, { scale, format }),
899
+ conn.captureScreenshot(nodeId, { scale, format, jpegQuality }),
712
900
  ]);
713
901
  const comp = component?.component ?? component;
714
902
  const out = { success: true, component: comp, image: screenshot?.image ?? screenshot?.data };
715
- return { content: [{ type: "text", text: JSON.stringify(out) }] };
903
+ return toolResult(out, "figma_get_component_for_development");
716
904
  }));
717
905
  // ---- Batch variables & setup_design_tokens & arrange_component_set ----
718
906
  server.registerTool("figma_batch_create_variables", {
@@ -989,6 +1177,11 @@ export async function main() {
989
1177
  const listening = bridge.isListening();
990
1178
  const currentPort = bridge.getPort();
991
1179
  const startError = bridge.getStartError();
1180
+ // v1.8.0+: detect plugin/server version mismatch
1181
+ const outdatedPlugins = connectedFiles.filter((f) => f.pluginVersion === null || (f.pluginVersion && f.pluginVersion !== FMCP_VERSION));
1182
+ const versionWarning = outdatedPlugins.length > 0
1183
+ ? `⚠️ Plugin version mismatch detected: ${outdatedPlugins.map(f => `${f.fileName || "?"} (plugin v${f.pluginVersion ?? "<1.8.0"}, server v${FMCP_VERSION})`).join(", ")}. Reinstall the plugin from f-mcp-plugin/ for full v1.8.0 context-safe defaults.`
1184
+ : undefined;
992
1185
  let msg;
993
1186
  if (!listening) {
994
1187
  msg = startError
@@ -997,6 +1190,8 @@ export async function main() {
997
1190
  }
998
1191
  else if (connected) {
999
1192
  msg = `F-MCP ATezer Bridge: ${clientCount} plugin(s) connected on port ${currentPort}. You can use all figma_* tools.`;
1193
+ if (versionWarning)
1194
+ msg += " " + versionWarning;
1000
1195
  }
1001
1196
  else {
1002
1197
  msg = PLUGIN_NOT_CONNECTED;
@@ -1018,30 +1213,57 @@ export async function main() {
1018
1213
  connectedClients: clientCount,
1019
1214
  connectedFiles,
1020
1215
  bridgePort: currentPort,
1216
+ serverVersion: FMCP_VERSION,
1021
1217
  ...(autoIncremented && { preferredPort: bridge.getPreferredPort(), autoIncremented }),
1022
1218
  message: msg,
1023
1219
  ...(startError && { startError }),
1024
1220
  ...(portHint && { portHint }),
1221
+ ...(versionWarning && { versionWarning }),
1025
1222
  }),
1026
1223
  }],
1027
1224
  };
1028
1225
  });
1029
1226
  // ---- Node Creation Tools ----
1227
+ // v1.8.0: figma_create_frame now supports auto-layout out of the box.
1228
+ // Default layoutMode="VERTICAL" with sensible padding/gap. Pass layoutMode="NONE"
1229
+ // for a free-form frame (legacy behavior).
1030
1230
  server.registerTool("figma_create_frame", {
1031
- description: "Create a new frame node on the current page. Returns the created node ID.",
1231
+ description: "Create a new frame node with optional auto-layout. Returns the created node ID. " +
1232
+ "v1.8.0: defaults to layoutMode='VERTICAL' with paddingTop/Bottom=16, paddingLeft/Right=16, itemSpacing=12, " +
1233
+ "primaryAxisSizingMode='AUTO', counterAxisSizingMode='AUTO'. Pass layoutMode='NONE' for legacy free-form frames.",
1032
1234
  inputSchema: {
1033
1235
  name: z.string().optional().default("Frame").describe("Frame name"),
1034
1236
  x: z.number().optional().describe("X position. If omitted, auto-positions to the right of existing content"),
1035
1237
  y: z.number().optional().default(0),
1036
1238
  width: z.number().optional().default(200),
1037
1239
  height: z.number().optional().default(200),
1038
- fillColor: z.string().optional().describe("Hex color e.g. '#ffffff'"),
1240
+ fillColor: z.string().optional().describe("Hex color e.g. '#ffffff'. DEPRECATED — prefer fillVariableKey for DS token binding (v1.8.1+)."),
1039
1241
  parentId: z.string().optional().describe("Parent node ID (default: current page)"),
1242
+ // Auto-layout parameters (v1.8.0)
1243
+ layoutMode: z.enum(["NONE", "HORIZONTAL", "VERTICAL"]).optional().default("VERTICAL").describe("Auto-layout direction. VERTICAL by default; pass 'NONE' for free-form frames."),
1244
+ paddingTop: z.number().optional().default(16),
1245
+ paddingBottom: z.number().optional().default(16),
1246
+ paddingLeft: z.number().optional().default(16),
1247
+ paddingRight: z.number().optional().default(16),
1248
+ itemSpacing: z.number().optional().default(12).describe("Gap between auto-layout children"),
1249
+ primaryAxisSizingMode: z.enum(["FIXED", "AUTO"]).optional().default("AUTO").describe("AUTO = hug contents, FIXED = use width/height"),
1250
+ counterAxisSizingMode: z.enum(["FIXED", "AUTO"]).optional().default("AUTO"),
1251
+ primaryAxisAlignItems: z.enum(["MIN", "CENTER", "MAX", "SPACE_BETWEEN"]).optional().describe("Main-axis alignment (MIN=top/left, MAX=bottom/right)"),
1252
+ counterAxisAlignItems: z.enum(["MIN", "CENTER", "MAX", "BASELINE"]).optional().describe("Cross-axis alignment"),
1253
+ layoutWrap: z.enum(["NO_WRAP", "WRAP"]).optional().describe("Wrap children when they exceed primary axis"),
1254
+ // v1.8.1+: DS token binding params — PREFER over hardcoded fillColor / padding / radius
1255
+ fillVariableKey: z.string().optional().describe("DS variable key for fill binding (from figma_get_library_variables). Takes precedence over fillColor."),
1256
+ paddingVariableKey: z.string().optional().describe("DS spacing variable key — applies to all 4 paddings via setBoundVariable."),
1257
+ itemSpacingVariableKey: z.string().optional().describe("DS spacing variable key for itemSpacing via setBoundVariable."),
1258
+ cornerRadiusVariableKey: z.string().optional().describe("DS radius variable key for cornerRadius via setBoundVariable."),
1259
+ cornerRadius: z.number().optional().describe("Hardcoded corner radius in px. DEPRECATED — prefer cornerRadiusVariableKey."),
1040
1260
  },
1041
- }, async ({ name, x, y, width, height, fillColor, parentId }) => {
1261
+ }, async ({ name, x, y, width, height, fillColor, parentId, layoutMode, paddingTop, paddingBottom, paddingLeft, paddingRight, itemSpacing, primaryAxisSizingMode, counterAxisSizingMode, primaryAxisAlignItems, counterAxisAlignItems, layoutWrap, fillVariableKey, paddingVariableKey, itemSpacingVariableKey, cornerRadiusVariableKey, cornerRadius }) => {
1042
1262
  try {
1263
+ invalidateCache();
1043
1264
  const conn = getConnector(bridge);
1044
1265
  const autoPosition = x === undefined && !parentId;
1266
+ const useAutoLayout = layoutMode !== "NONE";
1045
1267
  const code = `
1046
1268
  ${autoPosition ? `
1047
1269
  let posX = 0;
@@ -1059,9 +1281,64 @@ export async function main() {
1059
1281
  frame.name = ${JSON.stringify(name)};
1060
1282
  frame.x = posX; frame.y = ${y};
1061
1283
  frame.resize(${width}, ${height});
1062
- ${fillColor ? `frame.fills = [{ type: 'SOLID', color: { r: parseInt('${fillColor}'.slice(1,3),16)/255, g: parseInt('${fillColor}'.slice(3,5),16)/255, b: parseInt('${fillColor}'.slice(5,7),16)/255 } }];` : ""}
1284
+ ${useAutoLayout ? `
1285
+ frame.layoutMode = ${JSON.stringify(layoutMode)};
1286
+ frame.paddingTop = ${paddingTop};
1287
+ frame.paddingBottom = ${paddingBottom};
1288
+ frame.paddingLeft = ${paddingLeft};
1289
+ frame.paddingRight = ${paddingRight};
1290
+ frame.itemSpacing = ${itemSpacing};
1291
+ frame.primaryAxisSizingMode = ${JSON.stringify(primaryAxisSizingMode)};
1292
+ frame.counterAxisSizingMode = ${JSON.stringify(counterAxisSizingMode)};
1293
+ ${primaryAxisAlignItems ? `frame.primaryAxisAlignItems = ${JSON.stringify(primaryAxisAlignItems)};` : ""}
1294
+ ${counterAxisAlignItems ? `frame.counterAxisAlignItems = ${JSON.stringify(counterAxisAlignItems)};` : ""}
1295
+ ${layoutWrap ? `frame.layoutWrap = ${JSON.stringify(layoutWrap)};` : ""}
1296
+ ` : ""}
1297
+ ${cornerRadius != null ? `frame.cornerRadius = ${cornerRadius};` : ""}
1298
+ // v1.8.1: DS token binding takes precedence over hardcoded values
1299
+ ${fillVariableKey ? `
1300
+ try {
1301
+ const fillVar = await figma.variables.importVariableByKeyAsync(${JSON.stringify(fillVariableKey)});
1302
+ const baseFill = { type: 'SOLID', color: { r: 1, g: 1, b: 1 }, opacity: 1 };
1303
+ const boundFill = figma.variables.setBoundVariableForPaint(baseFill, 'color', fillVar);
1304
+ frame.fills = [boundFill];
1305
+ } catch (fillBindErr) {
1306
+ console.warn('[figma_create_frame] fillVariableKey binding failed:', fillBindErr.message);
1307
+ }
1308
+ ` : fillColor ? `frame.fills = [{ type: 'SOLID', color: { r: parseInt('${fillColor}'.slice(1,3),16)/255, g: parseInt('${fillColor}'.slice(3,5),16)/255, b: parseInt('${fillColor}'.slice(5,7),16)/255 } }];` : ""}
1309
+ ${paddingVariableKey ? `
1310
+ try {
1311
+ const padVar = await figma.variables.importVariableByKeyAsync(${JSON.stringify(paddingVariableKey)});
1312
+ frame.setBoundVariable('paddingTop', padVar);
1313
+ frame.setBoundVariable('paddingBottom', padVar);
1314
+ frame.setBoundVariable('paddingLeft', padVar);
1315
+ frame.setBoundVariable('paddingRight', padVar);
1316
+ } catch (padBindErr) {
1317
+ console.warn('[figma_create_frame] paddingVariableKey binding failed:', padBindErr.message);
1318
+ }
1319
+ ` : ""}
1320
+ ${itemSpacingVariableKey ? `
1321
+ try {
1322
+ const gapVar = await figma.variables.importVariableByKeyAsync(${JSON.stringify(itemSpacingVariableKey)});
1323
+ frame.setBoundVariable('itemSpacing', gapVar);
1324
+ } catch (gapBindErr) {
1325
+ console.warn('[figma_create_frame] itemSpacingVariableKey binding failed:', gapBindErr.message);
1326
+ }
1327
+ ` : ""}
1328
+ ${cornerRadiusVariableKey ? `
1329
+ try {
1330
+ const radVar = await figma.variables.importVariableByKeyAsync(${JSON.stringify(cornerRadiusVariableKey)});
1331
+ frame.setBoundVariable('topLeftRadius', radVar);
1332
+ frame.setBoundVariable('topRightRadius', radVar);
1333
+ frame.setBoundVariable('bottomLeftRadius', radVar);
1334
+ frame.setBoundVariable('bottomRightRadius', radVar);
1335
+ } catch (radBindErr) {
1336
+ console.warn('[figma_create_frame] cornerRadiusVariableKey binding failed:', radBindErr.message);
1337
+ }
1338
+ ` : ""}
1063
1339
  ${parentId ? `const parent = await figma.getNodeByIdAsync(${JSON.stringify(parentId)}); if (parent && 'appendChild' in parent) parent.appendChild(frame);` : ""}
1064
- return { id: frame.id, name: frame.name, width: frame.width, height: frame.height, x: frame.x, y: frame.y };
1340
+ const boundCount = frame.boundVariables ? Object.keys(frame.boundVariables).length : 0;
1341
+ return { id: frame.id, name: frame.name, width: frame.width, height: frame.height, x: frame.x, y: frame.y, layoutMode: frame.layoutMode, boundVariableCount: boundCount };
1065
1342
  `;
1066
1343
  const result = await conn.executeCodeViaUI(code, 10000);
1067
1344
  return { content: [{ type: "text", text: JSON.stringify({ success: true, ...result }) }] };
@@ -1071,14 +1348,16 @@ export async function main() {
1071
1348
  }
1072
1349
  });
1073
1350
  server.registerTool("figma_create_text", {
1074
- description: "Create a new text node on the current page. Returns the created node ID.",
1351
+ description: "Create a new text node on the current page. Returns the created node ID. " +
1352
+ "IMPORTANT: fontFamily defaults to 'Inter' — if using a design system (e.g. SUI uses SHBGrotesk), specify the DS font. " +
1353
+ "For DS text with proper token binding, prefer figma_execute with importStyleByKeyAsync + setTextStyleIdAsync instead.",
1075
1354
  inputSchema: {
1076
1355
  text: z.string().describe("Text content"),
1077
1356
  x: z.number().optional().default(0),
1078
1357
  y: z.number().optional().default(0),
1079
1358
  name: z.string().optional().describe("Node name (default: text content)"),
1080
1359
  fontSize: z.number().optional().default(16),
1081
- fontFamily: z.string().optional().default("Inter"),
1360
+ fontFamily: z.string().optional().default("Inter").describe("Font family — defaults to Inter. Specify DS font if using a design system (e.g. SHBGrotesk for SUI)."),
1082
1361
  fontStyle: z.string().optional().default("Regular"),
1083
1362
  fillColor: z.string().optional().describe("Text color hex e.g. '#000000'"),
1084
1363
  parentId: z.string().optional().describe("Parent node ID"),
@@ -1167,11 +1446,12 @@ export async function main() {
1167
1446
  server.registerTool("figma_export_nodes", {
1168
1447
  description: "Export one or multiple nodes as SVG, PNG, JPG, or PDF. Returns base64-encoded data for each node. " +
1169
1448
  "Supports batch export (up to 50 nodes). No REST API token needed — uses plugin exportAsync. " +
1170
- "SVG preserves vectors; PNG/JPG are rasterized at configurable scale.",
1449
+ "SVG preserves vectors; PNG/JPG are rasterized at configurable scale. " +
1450
+ "v1.8.0: default scale=1 for context safety (was 2). Override for high-DPI exports.",
1171
1451
  inputSchema: {
1172
1452
  nodeIds: z.array(z.string()).min(1).max(50).describe("Node IDs to export (1-50)"),
1173
1453
  format: z.enum(["PNG", "SVG", "JPG", "PDF"]).optional().default("PNG").describe("Export format"),
1174
- scale: z.number().min(0.5).max(4).optional().default(2).describe("Scale factor (0.5-4, default 2)"),
1454
+ scale: z.number().min(0.5).max(4).optional().default(LEGACY_DEFAULTS ? 2 : 1).describe("Scale factor (0.5-4, default 1)"),
1175
1455
  svgOutlineText: z.boolean().optional().describe("SVG: render text as outlines (default true)"),
1176
1456
  svgIncludeId: z.boolean().optional().describe("SVG: include node IDs in attributes"),
1177
1457
  },
@@ -1226,36 +1506,175 @@ export async function main() {
1226
1506
  });
1227
1507
  // ---- figma_search_assets (team library search via plugin) ----
1228
1508
  server.registerTool("figma_search_assets", {
1229
- description: "Search for published team library components and styles available in the current file. " +
1230
- "Uses Figma's teamLibrary API via plugin. Returns available components from enabled libraries.",
1509
+ description: "Search for design system assets in the current Figma file. Returns: " +
1510
+ "(1) team library VARIABLES via figma.teamLibrary API (all enabled libraries), " +
1511
+ "(2) file-local COMPONENTS / COMPONENT_SETS, and " +
1512
+ "(3) v1.8.0+: REMOTE LIBRARY COMPONENTS discovered by scanning existing INSTANCE nodes (returned as 'libraryComponents'). " +
1513
+ "For library components to appear, at least one DS instance must exist in the file — place one manually first if empty. " +
1514
+ "Pass currentPageOnly=false to scan all pages for instance discovery. " +
1515
+ "Use the returned componentKey with figma_instantiate_component to place new instances. " +
1516
+ "Pass assetTypes to filter: ['variables'], ['components'], or both (default).",
1231
1517
  inputSchema: {
1518
+ figmaUrl: z.string().optional().describe("Figma or FigJam file URL for routing."),
1519
+ fileKey: z.string().optional().describe("Target a specific connected file."),
1232
1520
  query: z.string().optional().describe("Search query to filter by name"),
1521
+ assetTypes: z.array(z.string()).optional().describe("Asset types to search: 'variables', 'components'. Default: both."),
1522
+ limit: z.number().min(1).max(80).optional().describe("Max results per asset type (default 25, max 80)"),
1523
+ currentPageOnly: z.boolean().optional().describe("For components: search current page only (default true)"),
1233
1524
  },
1234
1525
  annotations: { readOnlyHint: true },
1235
- }, async ({ query }) => {
1236
- try {
1237
- const conn = getConnector(bridge);
1238
- const code = `
1239
- if (!figma.teamLibrary) return { success: false, error: "teamLibrary API not available" };
1240
- const availableLibs = await figma.teamLibrary.getAvailableLibraryVariableCollectionsAsync();
1241
- const availableComps = typeof figma.teamLibrary.getAvailableLibraryComponentsAsync === 'function' ? await figma.teamLibrary.getAvailableLibraryComponentsAsync() : [];
1242
- return {
1243
- variableCollections: availableLibs.map(c => ({ name: c.name, key: c.key, libraryName: c.libraryName })),
1244
- note: "Use figma_search_components for file-local components. Team library component search requires REST API (figma_rest_api)."
1245
- };
1246
- `;
1247
- const result = await conn.executeCodeViaUI(code, 15000);
1248
- const data = result;
1249
- if (query && data.variableCollections && Array.isArray(data.variableCollections)) {
1250
- const q = query.toLowerCase();
1251
- data.variableCollections = data.variableCollections.filter((c) => (c.name || "").toLowerCase().includes(q) || (c.libraryName || "").toLowerCase().includes(q));
1252
- }
1253
- return { content: [{ type: "text", text: JSON.stringify({ success: true, ...data }) }] };
1254
- }
1255
- catch (err) {
1256
- return { content: [{ type: "text", text: JSON.stringify({ success: false, error: err instanceof Error ? err.message : String(err) }) }], isError: true };
1257
- }
1258
- });
1526
+ }, safeToolHandler(async ({ figmaUrl, fileKey, query, assetTypes, limit, currentPageOnly }) => {
1527
+ const conn = getConnector(bridge, resolveFileKey(figmaUrl, fileKey));
1528
+ const result = await conn.searchLibraryAssets({
1529
+ query: query || undefined,
1530
+ assetTypes: assetTypes?.length ? assetTypes : undefined,
1531
+ limit: limit ?? undefined,
1532
+ currentPageOnly,
1533
+ });
1534
+ const data = result;
1535
+ return { content: [{ type: "text", text: JSON.stringify({ success: true, ...data }) }] };
1536
+ }));
1537
+ // ---- figma_get_library_variables (team library variable discovery with import keys) ----
1538
+ server.registerTool("figma_get_library_variables", {
1539
+ description: "List variables from team library collections with import keys. " +
1540
+ "Uses figma.teamLibrary API works in the TARGET file, no need to connect the DS source file. " +
1541
+ "Returns variable name, key (for importVariableByKeyAsync), resolvedType, collection, and library name. " +
1542
+ "Use the returned keys with figma_bind_variable or figma.variables.importVariableByKeyAsync() in figma_execute.",
1543
+ inputSchema: {
1544
+ figmaUrl: z.string().optional().describe("Figma file URL for routing."),
1545
+ fileKey: z.string().optional().describe("Target a specific connected file."),
1546
+ query: z.string().optional().describe("Filter variables by name (case-insensitive contains)"),
1547
+ collectionName: z.string().optional().describe("Filter by collection name (exact match)"),
1548
+ libraryName: z.string().optional().describe("Filter by library name (exact match, e.g. '❖ SUI')"),
1549
+ limit: z.number().min(1).max(500).optional().describe("Max results (default 100)"),
1550
+ },
1551
+ annotations: { readOnlyHint: true },
1552
+ }, safeToolHandler(async ({ figmaUrl, fileKey, query, collectionName, libraryName, limit }) => {
1553
+ const conn = getConnector(bridge, resolveFileKey(figmaUrl, fileKey));
1554
+ const maxResults = limit ?? 100;
1555
+ const q = query ? query.toLowerCase() : "";
1556
+ const code = `
1557
+ if (!figma.teamLibrary) return { success: false, error: "teamLibrary API not available" };
1558
+ var cols = await figma.teamLibrary.getAvailableLibraryVariableCollectionsAsync();
1559
+ var filtered = cols;
1560
+ ${collectionName ? `filtered = filtered.filter(function(c) { return c.name === ${JSON.stringify(collectionName)}; });` : ""}
1561
+ ${libraryName ? `filtered = filtered.filter(function(c) { return c.libraryName === ${JSON.stringify(libraryName)}; });` : ""}
1562
+ var results = [];
1563
+ for (var ci = 0; ci < filtered.length && results.length < ${maxResults}; ci++) {
1564
+ var col = filtered[ci];
1565
+ var vars = await figma.teamLibrary.getVariablesInLibraryCollectionAsync(col.key);
1566
+ for (var vi = 0; vi < vars.length && results.length < ${maxResults}; vi++) {
1567
+ var v = vars[vi];
1568
+ var nm = (v.name || "").toLowerCase();
1569
+ if (!${JSON.stringify(q)} || nm.indexOf(${JSON.stringify(q)}) >= 0) {
1570
+ results.push({ name: v.name, key: v.key, resolvedType: v.resolvedType, collection: col.name, library: col.libraryName });
1571
+ }
1572
+ }
1573
+ }
1574
+ return { success: true, count: results.length, variables: results };
1575
+ `;
1576
+ const result = await conn.executeCodeViaUI(code, 30000);
1577
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
1578
+ }));
1579
+ // ---- figma_bind_variable (import variable and bind to node property) ----
1580
+ server.registerTool("figma_bind_variable", {
1581
+ description: "Import a library variable by key and bind it to a node property. " +
1582
+ "For colors: binds to fills or strokes via setBoundVariableForPaint. " +
1583
+ "For spacing/sizing: binds via setBoundVariable (paddingLeft, itemSpacing, cornerRadius, etc.). " +
1584
+ "Get variableKey from figma_get_library_variables. " +
1585
+ "The node's fill/spacing will dynamically update when the DS token changes.",
1586
+ inputSchema: {
1587
+ figmaUrl: z.string().optional().describe("Figma file URL for routing."),
1588
+ fileKey: z.string().optional().describe("Target a specific connected file."),
1589
+ nodeId: z.string().describe("Target node ID"),
1590
+ variableKey: z.string().describe("Variable import key from figma_get_library_variables"),
1591
+ property: z.enum([
1592
+ "fills", "strokes",
1593
+ "paddingLeft", "paddingRight", "paddingTop", "paddingBottom",
1594
+ "itemSpacing", "counterAxisSpacing",
1595
+ "topLeftRadius", "topRightRadius", "bottomLeftRadius", "bottomRightRadius", "cornerRadius",
1596
+ "strokeWeight", "opacity",
1597
+ "width", "height", "minWidth", "minHeight", "maxWidth", "maxHeight",
1598
+ ]).describe("Node property to bind the variable to"),
1599
+ paintIndex: z.number().optional().default(0).describe("For fills/strokes: which paint index (default 0)"),
1600
+ },
1601
+ annotations: { destructiveHint: true },
1602
+ }, safeToolHandler(async ({ figmaUrl, fileKey, nodeId, variableKey, property, paintIndex }) => {
1603
+ invalidateCache();
1604
+ const conn = getConnector(bridge, resolveFileKey(figmaUrl, fileKey));
1605
+ const idx = paintIndex ?? 0;
1606
+ const code = `
1607
+ var variable = await figma.variables.importVariableByKeyAsync(${JSON.stringify(variableKey)});
1608
+ var node = await figma.getNodeByIdAsync(${JSON.stringify(nodeId)});
1609
+ if (!node) throw new Error("Node not found: " + ${JSON.stringify(nodeId)});
1610
+ var prop = ${JSON.stringify(property)};
1611
+ if (prop === "fills" || prop === "strokes") {
1612
+ var paints = [];
1613
+ for (var i = 0; i < node[prop].length; i++) paints.push(node[prop][i]);
1614
+ if (!paints[${idx}]) throw new Error("No paint at index ${idx} on " + prop);
1615
+ var boundPaint = figma.variables.setBoundVariableForPaint(paints[${idx}], "color", variable);
1616
+ paints[${idx}] = boundPaint;
1617
+ node[prop] = paints;
1618
+ } else {
1619
+ node.setBoundVariable(prop, variable);
1620
+ }
1621
+ return { success: true, nodeId: ${JSON.stringify(nodeId)}, property: prop, variableName: variable.name, variableId: variable.id };
1622
+ `;
1623
+ const result = await conn.executeCodeViaUI(code, 10000);
1624
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
1625
+ }));
1626
+ // ---- figma_import_style (import text/paint/effect style from library) ----
1627
+ server.registerTool("figma_import_style", {
1628
+ description: "Import a text, paint, or effect style from a team library by key, and optionally apply it to a node. " +
1629
+ "IMPORTANT: This API only imports PUBLISHED LIBRARY styles, NOT local file styles. " +
1630
+ "For local styles, use 'node.fillStyleId = style.id' (or textStyleId/effectStyleId) directly via figma_execute. " +
1631
+ "Get library style keys from .claude/libraries/ cache or REST API: figma_rest_api GET /v1/files/{fileKey}/styles. " +
1632
+ "For TEXT styles: applies via setTextStyleIdAsync (includes font, size, weight). " +
1633
+ "For PAINT styles: applies via fillStyleId. For EFFECT styles: applies via effectStyleId.",
1634
+ inputSchema: {
1635
+ figmaUrl: z.string().optional().describe("Figma file URL for routing."),
1636
+ fileKey: z.string().optional().describe("Target a specific connected file."),
1637
+ styleKey: z.string().describe("Library style key (must be from a PUBLISHED team library, not a local style)"),
1638
+ nodeId: z.string().optional().describe("Node ID to apply the style to (optional — omit to just import)"),
1639
+ },
1640
+ annotations: { destructiveHint: true },
1641
+ }, safeToolHandler(async ({ figmaUrl, fileKey, styleKey, nodeId }) => {
1642
+ invalidateCache();
1643
+ const conn = getConnector(bridge, resolveFileKey(figmaUrl, fileKey));
1644
+ const code = `
1645
+ var style;
1646
+ try {
1647
+ style = await figma.importStyleByKeyAsync(${JSON.stringify(styleKey)});
1648
+ } catch (e) {
1649
+ var origMsg = e && e.message ? e.message : String(e);
1650
+ throw new Error(
1651
+ "importStyleByKeyAsync failed for key '" + ${JSON.stringify(styleKey)} + "'. " +
1652
+ "This API only works with PUBLISHED LIBRARY styles. " +
1653
+ "Local file styles cannot be imported this way — use 'node.fillStyleId/textStyleId/effectStyleId = <localStyleId>' directly via figma_execute. " +
1654
+ "To find library style keys, use REST API: GET /v1/files/{fileKey}/styles. " +
1655
+ "Original error: " + origMsg
1656
+ );
1657
+ }
1658
+ var applied = false;
1659
+ ${nodeId ? `
1660
+ var node = await figma.getNodeByIdAsync(${JSON.stringify(nodeId)});
1661
+ if (!node) throw new Error("Node not found: " + ${JSON.stringify(nodeId)});
1662
+ if (style.type === "TEXT" && node.type === "TEXT") {
1663
+ await node.setTextStyleIdAsync(style.id);
1664
+ applied = true;
1665
+ } else if (style.type === "PAINT") {
1666
+ node.fillStyleId = style.id;
1667
+ applied = true;
1668
+ } else if (style.type === "EFFECT") {
1669
+ node.effectStyleId = style.id;
1670
+ applied = true;
1671
+ }
1672
+ ` : ""}
1673
+ return { success: true, styleId: style.id, styleName: style.name, styleType: style.type, applied: applied };
1674
+ `;
1675
+ const result = await conn.executeCodeViaUI(code, 10000);
1676
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
1677
+ }));
1259
1678
  // ---- figma_plugin_diagnostics ----
1260
1679
  server.registerTool("figma_plugin_diagnostics", {
1261
1680
  description: "Get diagnostic info about plugin connection health: uptime, connected clients, " +