@atezer/figma-mcp-bridge 1.9.3 → 1.9.5

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.
@@ -19,6 +19,10 @@ import { PluginBridgeConnector } from "./core/plugin-bridge-connector.js";
19
19
  import { parseFigmaUrl } from "./core/figma-url.js";
20
20
  import { truncateRestResponse, truncatePluginResponse } from "./core/response-guard.js";
21
21
  import { analyzeCodeForWarnings } from "./core/code-warnings.js";
22
+ import { discoveryCounter } from "./core/discovery-counter.js";
23
+ import { writeFileSync, mkdirSync } from "node:fs";
24
+ import { join } from "node:path";
25
+ import { homedir } from "node:os";
22
26
  import { resolveDevice, DEVICE_PRESETS } from "./core/device-presets.js";
23
27
  import { closeAuditLog } from "./core/audit-log.js";
24
28
  import { FMCP_VERSION } from "./core/version.js";
@@ -34,6 +38,103 @@ const logger = createChildLogger({ component: "plugin-only-mcp" });
34
38
  */
35
39
  const LEGACY_DEFAULTS = process.env.FMCP_LEGACY_DEFAULTS === "1";
36
40
  /** Resolve fileKey from figmaUrl (parse) or explicit fileKey. Returns undefined if neither yields a key. */
41
+ /**
42
+ * v1.9.5: Screenshot dosya kayıt konumu.
43
+ * ~/.fmcp/screenshots/ altında timestamp-nodeId.ext formatı.
44
+ */
45
+ const SCREENSHOT_DIR = join(homedir(), ".fmcp", "screenshots");
46
+ function ensureScreenshotDir() {
47
+ try {
48
+ mkdirSync(SCREENSHOT_DIR, { recursive: true });
49
+ }
50
+ catch { /* ignore EEXIST */ }
51
+ return SCREENSHOT_DIR;
52
+ }
53
+ /**
54
+ * v1.9.5: Base64 image payload'u disk'e yaz, filePath döndür.
55
+ * Base64 decode edilir, dosyaya binary olarak yazılır.
56
+ */
57
+ function saveBase64ToFile(base64, format, nodeIdHint) {
58
+ ensureScreenshotDir();
59
+ const ts = Date.now();
60
+ const safeNodeId = (nodeIdHint ?? "node").replace(/[^a-zA-Z0-9_-]/g, "_");
61
+ const ext = format.toLowerCase() === "png" ? "png" : "jpg";
62
+ const fileName = `${ts}-${safeNodeId}.${ext}`;
63
+ const filePath = join(SCREENSHOT_DIR, fileName);
64
+ const buffer = Buffer.from(base64, "base64");
65
+ writeFileSync(filePath, buffer);
66
+ return { filePath, fileSize: buffer.length };
67
+ }
68
+ /**
69
+ * v1.9.5: Screenshot result'ı returnMode'a göre post-process.
70
+ * - file: base64'u disk'e yaz, image.base64 yerine filePath döndür
71
+ * - regions: regions[].image.base64'u tek tek disk'e yaz, regions[].filePath döndür
72
+ * - summary: plugin zaten metadata döndürdü, dokunma
73
+ * - base64: eski davranış, dokunma (ama _warning ekle)
74
+ */
75
+ async function postProcessScreenshotResult(result, returnMode, format, nodeId) {
76
+ if (!result || typeof result !== "object" || !result.success)
77
+ return result;
78
+ if (returnMode === "file") {
79
+ // Plugin single-image response: { success, image: { base64, format, width, height } }
80
+ if (result.image && typeof result.image === "object" && typeof result.image.base64 === "string") {
81
+ try {
82
+ const { filePath, fileSize } = saveBase64ToFile(result.image.base64, format, nodeId);
83
+ const { base64, ...imageMetaRest } = result.image;
84
+ return {
85
+ success: true,
86
+ filePath,
87
+ fileSize,
88
+ dimensions: { width: result.image.width, height: result.image.height },
89
+ format: result.image.format ?? format,
90
+ nodeId: nodeId ?? null,
91
+ imageMeta: imageMetaRest,
92
+ hint: "v1.9.5 file mode: Screenshot diske yazildi. Claude Desktop 'open <filePath>' ile acabilir. Base64 context'te YOK (~30K token tasarrufu). Onceki davranis icin returnMode: 'base64'.",
93
+ };
94
+ }
95
+ catch (err) {
96
+ return { ...result, _warning: `File save failed: ${err instanceof Error ? err.message : String(err)}. Falling back to base64 mode.` };
97
+ }
98
+ }
99
+ }
100
+ if (returnMode === "regions" && Array.isArray(result.regions)) {
101
+ const processedRegions = result.regions.map((region, idx) => {
102
+ if (region.image && typeof region.image.base64 === "string") {
103
+ try {
104
+ const regionNodeId = region.nodeId ?? `${nodeId}-region-${idx}`;
105
+ const { filePath, fileSize } = saveBase64ToFile(region.image.base64, format, regionNodeId);
106
+ const { base64, ...imageMetaRest } = region.image;
107
+ return {
108
+ name: region.name,
109
+ nodeId: region.nodeId,
110
+ filePath,
111
+ fileSize,
112
+ dimensions: { width: region.image.width, height: region.image.height },
113
+ imageMeta: imageMetaRest,
114
+ };
115
+ }
116
+ catch (err) {
117
+ return { ...region, _warning: `File save failed: ${err instanceof Error ? err.message : String(err)}` };
118
+ }
119
+ }
120
+ return region;
121
+ });
122
+ return {
123
+ ...result,
124
+ regions: processedRegions,
125
+ hint: "v1.9.5 regions mode: Her region ayri dosyada. Ilgili olanlari 'open <filePath>' ile ac. Tumune bakmana gerek yok. Base64 context'te YOK.",
126
+ };
127
+ }
128
+ if (returnMode === "base64") {
129
+ // Legacy — add warning
130
+ return {
131
+ ...result,
132
+ _warning: "BASE64_MODE: Context'e ~30K token eklendi. Bir sonraki call'da 'file' veya 'regions' tercih et.",
133
+ };
134
+ }
135
+ // summary mode — plugin already returned metadata-only, no post-processing needed
136
+ return result;
137
+ }
37
138
  function resolveFileKey(figmaUrl, explicitFileKey) {
38
139
  if (explicitFileKey && explicitFileKey.trim())
39
140
  return explicitFileKey.trim();
@@ -406,23 +507,37 @@ export async function main() {
406
507
  }
407
508
  const clampedTimeout = Math.max(3000, Math.min(timeout ?? 15000, 30000));
408
509
  invalidateCache();
510
+ // v1.9.5: Discovery budget tracking — code pattern analysis (read-only vs mutation)
511
+ const budget = discoveryCounter.track("figma_execute", code);
409
512
  // v1.8.1: Structured warnings with SEVERE vs ADVISORY severity
410
513
  const codeWarnings = analyzeCodeForWarnings(code);
411
514
  const severeWarnings = codeWarnings.filter((w) => w.severity === "SEVERE");
412
515
  const advisoryWarnings = codeWarnings.filter((w) => w.severity === "ADVISORY");
413
- // SEVERE warnings go to a prominent field that Claude cannot ignore
516
+ // SEVERE warnings go to a prominent field that Claude cannot ignore.
517
+ // v1.9.4: action text elevated to BLOCKING — Claude must retry, cannot proceed.
414
518
  const dsViolations = severeWarnings.length > 0
415
519
  ? {
520
+ _DESIGN_SYSTEM_VIOLATIONS_BLOCKING: true, // v1.9.4: top-level flag Claude Desktop cannot scroll past
416
521
  _designSystemViolations: {
417
522
  count: severeWarnings.length,
418
- message: "⚠️ DESIGN SYSTEM DISIPLIN IHLALI TESPIT EDILDI — kodu duzelt ve tekrar calistir. Bu ekran kabul edilmez.",
523
+ severity: "BLOCKING",
524
+ message: "❌ DESIGN SYSTEM DISIPLIN IHLALI — BLOCKING. Bu kod KABUL EDILMEZ. Execute sonucu gecersizdir.",
419
525
  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.",
526
+ action: "❌ BLOCKING: Bu kodu simdi duzelt ve tekrar calistir. Skip edemezsin. Her SOLID fill icin setBoundVariableForPaint zorunlu; her padding/itemSpacing/cornerRadius icin setBoundVariable zorunlu; her TEXT icin setTextStyleIdAsync zorunlu. Detay: figma-canvas-ops SKILL Kural 10, fmcp-screen-recipes SKILL Adim 9.",
527
+ retry_required: true,
421
528
  },
422
529
  }
423
530
  : {};
424
- const warningsField = advisoryWarnings.length > 0
425
- ? { _warnings: advisoryWarnings.map((w) => w.message) }
531
+ // v1.9.5: Discovery budget warnings merge with advisory warnings
532
+ const combinedWarnings = [
533
+ ...advisoryWarnings.map((w) => w.message),
534
+ ...(budget.warnings ?? []),
535
+ ];
536
+ const warningsField = combinedWarnings.length > 0
537
+ ? { _warnings: combinedWarnings }
538
+ : {};
539
+ const budgetBlockingField = budget._DISCOVERY_BUDGET_EXCEEDED_BLOCKING
540
+ ? { _DISCOVERY_BUDGET_EXCEEDED_BLOCKING: true }
426
541
  : {};
427
542
  const startTime = Date.now();
428
543
  try {
@@ -441,6 +556,7 @@ export async function main() {
441
556
  catch { /* safe fallback */ }
442
557
  return {
443
558
  content: [{ type: "text", text: JSON.stringify({
559
+ ...budgetBlockingField, // v1.9.5: discovery BLOCKING flag at top
444
560
  ...dsViolations, // v1.8.1: SEVERE warnings FIRST, before anything else
445
561
  ...result,
446
562
  errorCategory: category,
@@ -455,13 +571,14 @@ export async function main() {
455
571
  try {
456
572
  enriched = typeof result === "object" && result !== null
457
573
  ? {
574
+ ...budgetBlockingField, // v1.9.5: discovery BLOCKING flag at top
458
575
  ...dsViolations, // v1.8.1: SEVERE warnings at top level
459
576
  ...result,
460
577
  _metrics: { durationMs, timeoutMs: clampedTimeout },
461
578
  ...warningsField,
462
579
  }
463
- : severeWarnings.length > 0 || advisoryWarnings.length > 0
464
- ? { ...dsViolations, result, ...warningsField }
580
+ : severeWarnings.length > 0 || advisoryWarnings.length > 0 || combinedWarnings.length > 0
581
+ ? { ...budgetBlockingField, ...dsViolations, result, ...warningsField }
465
582
  : result;
466
583
  }
467
584
  catch {
@@ -561,6 +678,8 @@ export async function main() {
561
678
  "across 3 dimensions: instance coverage (library usage), token binding coverage (bound variables), " +
562
679
  "and auto-layout coverage. Use this AFTER creating a screen to verify DS compliance. " +
563
680
  "If score < minScore, Claude should delete the screen and rebuild it using DS components + token bindings. " +
681
+ "v1.9.4: `breakdown` now always includes `coverage` (granular fills/paddings/radius/itemSpacing/textStyle bind ratios) + `overflow` (root auto-layout overflow). " +
682
+ "For hardcoded samples + primitive fallback list, use figma_scan_ds_compliance instead. " +
564
683
  "Read-only — never mutates the file.",
565
684
  inputSchema: {
566
685
  figmaUrl: z.string().optional().describe("Figma file URL for routing."),
@@ -575,10 +694,47 @@ export async function main() {
575
694
  const result = await conn.validateScreen({ nodeId, expectedDs, minScore });
576
695
  return toolResult(result, "figma_validate_screen");
577
696
  }));
578
- // ---- figma_capture_screenshot ----
579
- // v1.8.0: Default JPG@1x (~80% smaller base64 vs PNG@2x). Override via params.
697
+ // ---- figma_scan_ds_compliance (v1.9.4) ----
698
+ // Full DS compliance audit superset of figma_validate_screen with hardcoded samples,
699
+ // primitive fallback list, and overflow detection. Call this as the FINAL GATE before
700
+ // considering a screen complete. Threshold default is 85 (stricter than validate's 80)
701
+ // because detailed mode flags granular binding gaps that brief mode smooths over.
702
+ server.registerTool("figma_scan_ds_compliance", {
703
+ description: "v1.9.4: FINAL GATE — Full DS compliance scan for a completed screen. " +
704
+ "Returns the same score + breakdown as figma_validate_screen PLUS: " +
705
+ "(1) `coverage` = granular bind percentages (fills/paddings/radius/itemSpacing/textStyle/textColor/strokes), " +
706
+ "(2) `samples.hardcodedHex` = up to 8 nodes with hardcoded SOLID colors, " +
707
+ "(3) `samples.hardcodedFontSize` = up to 8 text nodes with hardcoded fontSize (no textStyleId), " +
708
+ "(4) `samples.primitiveFrames` = up to 8 frames that should have been DS component instances, " +
709
+ "(5) `overflow` = root auto-layout overflow analysis (frameSize vs contentSize). " +
710
+ "If `passed: false`, Claude MUST fix listed violations before presenting the screen as complete. " +
711
+ "Threshold 85 default (stricter than validate_screen's 80) because this flags granular gaps. " +
712
+ "Read-only — never mutates the file.",
713
+ inputSchema: {
714
+ figmaUrl: z.string().optional().describe("Figma file URL for routing."),
715
+ fileKey: z.string().optional().describe("Target a specific connected file."),
716
+ nodeId: z.string().describe("Node ID of the completed screen to audit"),
717
+ threshold: z.number().min(0).max(100).optional().default(85).describe("Pass threshold (0-100). Default 85. Below this, screen is non-compliant and must be fixed."),
718
+ expectedDs: z.string().optional().describe("Expected DS library name (e.g. '❖ SUI')"),
719
+ },
720
+ annotations: { readOnlyHint: true },
721
+ }, safeToolHandler(async ({ figmaUrl, fileKey, nodeId, threshold, expectedDs, }) => {
722
+ const conn = getConnector(bridge, resolveFileKey(figmaUrl, fileKey));
723
+ const result = await conn.scanDsCompliance({ nodeId, threshold, expectedDs });
724
+ return toolResult(result, "figma_scan_ds_compliance");
725
+ }));
726
+ // ---- figma_capture_screenshot (v1.9.5: method selection) ----
727
+ // v1.8.0: Default JPG@1x (~80% smaller base64 vs PNG@2x).
728
+ // v1.9.5: returnMode param — Claude bağlama göre file/summary/regions/base64 seçer.
729
+ // Default "file": screenshot ~/.fmcp/screenshots/ altına yazılır, filePath döner,
730
+ // base64 context'e girmez (30K → 0.3K token tasarrufu).
580
731
  server.registerTool("figma_capture_screenshot", {
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.",
732
+ description: "v1.9.5: 4 returnMode ile screenshot. Default 'file' (dosyaya yazar, base64 context'te YOK). " +
733
+ "'summary' screenshot çekmeden metadata özeti (planlama için), " +
734
+ "'regions' büyük ekranları children/slices olarak parçalar, " +
735
+ "'base64' eski davranış (opt-in, ~30K token maliyetli). " +
736
+ "Context-aware fallback: >%80 context kullanımında base64/file → summary'ye otomatik düşer. " +
737
+ "Karar ağacı: planlama→summary, teslimat→file, scroll'lu ekran→regions, son çare→base64.",
582
738
  inputSchema: {
583
739
  figmaUrl: z.string().optional().describe("Figma or FigJam file URL for routing."),
584
740
  fileKey: z.string().optional().describe("Target a specific connected file."),
@@ -586,23 +742,51 @@ export async function main() {
586
742
  format: z.enum(["PNG", "JPG"]).optional().default(LEGACY_DEFAULTS ? "PNG" : "JPG"),
587
743
  scale: z.number().optional().default(LEGACY_DEFAULTS ? 2 : 1),
588
744
  jpegQuality: z.number().min(30).max(100).optional().default(70).describe("JPEG quality 30-100. Ignored when format=PNG."),
745
+ returnMode: z
746
+ .enum(["file", "base64", "summary", "regions"])
747
+ .optional()
748
+ .default("file")
749
+ .describe("v1.9.5 method: 'file' (default, disk + filePath), 'base64' (legacy, context'e dahil), " +
750
+ "'summary' (metadata-only, screenshotsuz), 'regions' (parçalı — children veya slices)."),
751
+ regionStrategy: z
752
+ .enum(["children", "slices"])
753
+ .optional()
754
+ .default("children")
755
+ .describe("returnMode='regions' için: 'children' = node'un top-level child'ları ayrı ayrı, 'slices' = dikey slice'lar."),
756
+ maxRegions: z.number().min(1).max(20).optional().default(8).describe("returnMode='regions' için: maks region sayısı."),
757
+ sliceHeight: z.number().min(200).max(2000).optional().default(600).describe("regionStrategy='slices' için slice yüksekliği (px)."),
758
+ requestedSlices: z.array(z.number()).optional().describe("regionStrategy='slices' için spesifik slice index'leri (örn: [0,2] → sadece 1. ve 3. slice)."),
589
759
  },
590
760
  annotations: { readOnlyHint: true },
591
- }, safeToolHandler(async ({ figmaUrl, fileKey, nodeId, format, scale, jpegQuality }) => {
761
+ }, safeToolHandler(async ({ figmaUrl, fileKey, nodeId, format, scale, jpegQuality, returnMode, regionStrategy, maxRegions, sliceHeight, requestedSlices, }) => {
762
+ // v1.9.5: Track discovery budget
763
+ const budget = discoveryCounter.track("figma_capture_screenshot");
592
764
  const conn = getConnector(bridge, resolveFileKey(figmaUrl, fileKey));
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 });
765
+ const result = await conn.captureScreenshot(nodeId ?? null, {
766
+ format,
767
+ scale,
768
+ jpegQuality,
769
+ returnMode,
770
+ regionStrategy,
771
+ maxRegions,
772
+ sliceHeight,
773
+ requestedSlices,
774
+ });
775
+ // v1.9.5 post-processing: base64 payload'ları file'a yaz (mode='file' veya 'regions')
776
+ const processed = await postProcessScreenshotResult(result, returnMode, format, nodeId);
777
+ // Budget warning injection
778
+ const budgetFields = {};
779
+ if (budget.warnings && budget.warnings.length > 0) {
780
+ budgetFields._warnings = [...(processed._warnings ?? []), ...budget.warnings];
781
+ }
782
+ if (budget._DISCOVERY_BUDGET_EXCEEDED_BLOCKING) {
783
+ budgetFields._DISCOVERY_BUDGET_EXCEEDED_BLOCKING = true;
784
+ }
785
+ const enriched = typeof processed === "object" && processed !== null
786
+ ? { ...processed, ...budgetFields }
787
+ : processed;
788
+ // skipGuard: true — base64 intact kalır (legacy mode için gerekli)
789
+ return toolResult(enriched, "figma_capture_screenshot", { skipGuard: true });
606
790
  }));
607
791
  // ---- figma_set_instance_properties ----
608
792
  server.registerTool("figma_set_instance_properties", {