@atezer/figma-mcp-bridge 1.9.4 → 1.9.6

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();
@@ -389,7 +490,10 @@ export async function main() {
389
490
  "v1.8.1+: Static analysis detects design-system discipline violations (hardcoded colors, missing token bindings, no-instance usage, hardcoded typography). " +
390
491
  "SEVERE warnings are promoted to the top of the response as _designSystemViolations — Claude must read and self-correct. " +
391
492
  "Also detects gotchas: FILL/ABSOLUTE before appendChild, sync API usage, missing loadFontAsync, sync currentPage assignment. " +
392
- "For component instances: use setProperties({...}), NOT findAll(TEXT).",
493
+ "For component instances: use setProperties({...}), NOT findAll(TEXT). " +
494
+ "v1.9.6+: Post-execute scan — eğer kod `return { createdNodeIds: [...] }` veya `nodeIds`/`ids`/`frameId`/`rootId`/`nodeId` döndürürse " +
495
+ "plugin oluşturulan node'ları otomatik tarar, unbound fill/padding/radius/text-style varsa response'a `_POST_EXECUTE_SCAN_BLOCKING: true` ve " +
496
+ "`_postExecuteViolations` alanı ekler. Bu flag varsa execute geçersiz sayılır — kodu düzelt (setBoundVariable/setTextStyleIdAsync ekle) ve tekrar çalıştır.",
393
497
  inputSchema: {
394
498
  figmaUrl: z.string().optional().describe("Figma or FigJam file URL for routing."),
395
499
  fileKey: z.string().optional().describe("Target a specific connected file."),
@@ -406,6 +510,8 @@ export async function main() {
406
510
  }
407
511
  const clampedTimeout = Math.max(3000, Math.min(timeout ?? 15000, 30000));
408
512
  invalidateCache();
513
+ // v1.9.5: Discovery budget tracking — code pattern analysis (read-only vs mutation)
514
+ const budget = discoveryCounter.track("figma_execute", code);
409
515
  // v1.8.1: Structured warnings with SEVERE vs ADVISORY severity
410
516
  const codeWarnings = analyzeCodeForWarnings(code);
411
517
  const severeWarnings = codeWarnings.filter((w) => w.severity === "SEVERE");
@@ -425,8 +531,16 @@ export async function main() {
425
531
  },
426
532
  }
427
533
  : {};
428
- const warningsField = advisoryWarnings.length > 0
429
- ? { _warnings: advisoryWarnings.map((w) => w.message) }
534
+ // v1.9.5: Discovery budget warnings merge with advisory warnings
535
+ const combinedWarnings = [
536
+ ...advisoryWarnings.map((w) => w.message),
537
+ ...(budget.warnings ?? []),
538
+ ];
539
+ const warningsField = combinedWarnings.length > 0
540
+ ? { _warnings: combinedWarnings }
541
+ : {};
542
+ const budgetBlockingField = budget._DISCOVERY_BUDGET_EXCEEDED_BLOCKING
543
+ ? { _DISCOVERY_BUDGET_EXCEEDED_BLOCKING: true }
430
544
  : {};
431
545
  const startTime = Date.now();
432
546
  try {
@@ -445,6 +559,7 @@ export async function main() {
445
559
  catch { /* safe fallback */ }
446
560
  return {
447
561
  content: [{ type: "text", text: JSON.stringify({
562
+ ...budgetBlockingField, // v1.9.5: discovery BLOCKING flag at top
448
563
  ...dsViolations, // v1.8.1: SEVERE warnings FIRST, before anything else
449
564
  ...result,
450
565
  errorCategory: category,
@@ -455,17 +570,36 @@ export async function main() {
455
570
  isError: true,
456
571
  };
457
572
  }
573
+ // v1.9.6: Post-execute scan result → BLOCKING signal injection
574
+ const postScan = typeof result === "object" && result !== null
575
+ ? result._postExecuteScan
576
+ : undefined;
577
+ const postScanBlocking = {};
578
+ if (postScan && typeof postScan.violationCount === "number" && postScan.violationCount > 0) {
579
+ postScanBlocking._POST_EXECUTE_SCAN_BLOCKING = true;
580
+ postScanBlocking._postExecuteViolations = {
581
+ count: postScan.violationCount,
582
+ totalChecked: postScan.totalChecked ?? 0,
583
+ severity: "BLOCKING",
584
+ message: "❌ v1.9.6 POST-EXECUTE SCAN: Olusturulan node'larda " + postScan.violationCount + " unbound tespit edildi. Kodu duzelt — her unbound icin setBoundVariable veya setTextStyleIdAsync cagrisi ekle.",
585
+ violations: postScan.violations ?? [],
586
+ action: "❌ BLOCKING: Execute sonucu KABUL EDILMEZ. Listelenen nodeId'lerdeki unbound node'lari bagla ve kodu tekrar calistir. Skip edemezsin.",
587
+ retry_required: true,
588
+ };
589
+ }
458
590
  let enriched;
459
591
  try {
460
592
  enriched = typeof result === "object" && result !== null
461
593
  ? {
594
+ ...postScanBlocking, // v1.9.6: post-execute BLOCKING top
595
+ ...budgetBlockingField, // v1.9.5: discovery BLOCKING flag at top
462
596
  ...dsViolations, // v1.8.1: SEVERE warnings at top level
463
597
  ...result,
464
598
  _metrics: { durationMs, timeoutMs: clampedTimeout },
465
599
  ...warningsField,
466
600
  }
467
- : severeWarnings.length > 0 || advisoryWarnings.length > 0
468
- ? { ...dsViolations, result, ...warningsField }
601
+ : severeWarnings.length > 0 || advisoryWarnings.length > 0 || combinedWarnings.length > 0
602
+ ? { ...budgetBlockingField, ...dsViolations, result, ...warningsField }
469
603
  : result;
470
604
  }
471
605
  catch {
@@ -610,10 +744,18 @@ export async function main() {
610
744
  const result = await conn.scanDsCompliance({ nodeId, threshold, expectedDs });
611
745
  return toolResult(result, "figma_scan_ds_compliance");
612
746
  }));
613
- // ---- figma_capture_screenshot ----
614
- // v1.8.0: Default JPG@1x (~80% smaller base64 vs PNG@2x). Override via params.
747
+ // ---- figma_capture_screenshot (v1.9.5: method selection) ----
748
+ // v1.8.0: Default JPG@1x (~80% smaller base64 vs PNG@2x).
749
+ // v1.9.5: returnMode param — Claude bağlama göre file/summary/regions/base64 seçer.
750
+ // Default "file": screenshot ~/.fmcp/screenshots/ altına yazılır, filePath döner,
751
+ // base64 context'e girmez (30K → 0.3K token tasarrufu).
615
752
  server.registerTool("figma_capture_screenshot", {
616
- 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.",
753
+ description: "v1.9.5: 4 returnMode ile screenshot. Default 'file' (dosyaya yazar, base64 context'te YOK). " +
754
+ "'summary' screenshot çekmeden metadata özeti (planlama için), " +
755
+ "'regions' büyük ekranları children/slices olarak parçalar, " +
756
+ "'base64' eski davranış (opt-in, ~30K token maliyetli). " +
757
+ "Context-aware fallback: >%80 context kullanımında base64/file → summary'ye otomatik düşer. " +
758
+ "Karar ağacı: planlama→summary, teslimat→file, scroll'lu ekran→regions, son çare→base64.",
617
759
  inputSchema: {
618
760
  figmaUrl: z.string().optional().describe("Figma or FigJam file URL for routing."),
619
761
  fileKey: z.string().optional().describe("Target a specific connected file."),
@@ -621,23 +763,51 @@ export async function main() {
621
763
  format: z.enum(["PNG", "JPG"]).optional().default(LEGACY_DEFAULTS ? "PNG" : "JPG"),
622
764
  scale: z.number().optional().default(LEGACY_DEFAULTS ? 2 : 1),
623
765
  jpegQuality: z.number().min(30).max(100).optional().default(70).describe("JPEG quality 30-100. Ignored when format=PNG."),
766
+ returnMode: z
767
+ .enum(["file", "base64", "summary", "regions"])
768
+ .optional()
769
+ .default("file")
770
+ .describe("v1.9.5 method: 'file' (default, disk + filePath), 'base64' (legacy, context'e dahil), " +
771
+ "'summary' (metadata-only, screenshotsuz), 'regions' (parçalı — children veya slices)."),
772
+ regionStrategy: z
773
+ .enum(["children", "slices"])
774
+ .optional()
775
+ .default("children")
776
+ .describe("returnMode='regions' için: 'children' = node'un top-level child'ları ayrı ayrı, 'slices' = dikey slice'lar."),
777
+ maxRegions: z.number().min(1).max(20).optional().default(8).describe("returnMode='regions' için: maks region sayısı."),
778
+ sliceHeight: z.number().min(200).max(2000).optional().default(600).describe("regionStrategy='slices' için slice yüksekliği (px)."),
779
+ requestedSlices: z.array(z.number()).optional().describe("regionStrategy='slices' için spesifik slice index'leri (örn: [0,2] → sadece 1. ve 3. slice)."),
624
780
  },
625
781
  annotations: { readOnlyHint: true },
626
- }, safeToolHandler(async ({ figmaUrl, fileKey, nodeId, format, scale, jpegQuality }) => {
782
+ }, safeToolHandler(async ({ figmaUrl, fileKey, nodeId, format, scale, jpegQuality, returnMode, regionStrategy, maxRegions, sliceHeight, requestedSlices, }) => {
783
+ // v1.9.5: Track discovery budget
784
+ const budget = discoveryCounter.track("figma_capture_screenshot");
627
785
  const conn = getConnector(bridge, resolveFileKey(figmaUrl, fileKey));
628
- const result = await conn.captureScreenshot(nodeId ?? null, { format, scale, jpegQuality });
629
- // v1.9.6 Fix M1 — skipGuard: true → truncatePluginResponse'u bypass et.
630
- // Gerekçe: screenshot response {success, image: {base64, ...}} şeklinde.
631
- // pruneNodeTree screenshot payload'una (node tree değil) uymadığı için
632
- // final fallback truncateResponse({maxStringLength: 200}) devreye girip
633
- // base64 string'i 200 karaktere kırpıyordu → Claude Desktop `{}` empty
634
- // object görüyordu (FP-1-R-v2 Known Limitation #8 root cause).
635
- // skipGuard: true ile payload olduğu gibi JSON.stringify edilir, base64
636
- // intact kalır. Claude image render etmez (text content block) ama
637
- // base64 string'i görür, kullanabilir, böylece empty object problemi
638
- // çözülür. Image content block upgrade'i (MCP image type) Part 5
639
- // sonraki adımı safeToolHandler generic refactor'u gerektirir.
640
- return toolResult(result, "figma_capture_screenshot", { skipGuard: true });
786
+ const result = await conn.captureScreenshot(nodeId ?? null, {
787
+ format,
788
+ scale,
789
+ jpegQuality,
790
+ returnMode,
791
+ regionStrategy,
792
+ maxRegions,
793
+ sliceHeight,
794
+ requestedSlices,
795
+ });
796
+ // v1.9.5 post-processing: base64 payload'ları file'a yaz (mode='file' veya 'regions')
797
+ const processed = await postProcessScreenshotResult(result, returnMode, format, nodeId);
798
+ // Budget warning injection
799
+ const budgetFields = {};
800
+ if (budget.warnings && budget.warnings.length > 0) {
801
+ budgetFields._warnings = [...(processed._warnings ?? []), ...budget.warnings];
802
+ }
803
+ if (budget._DISCOVERY_BUDGET_EXCEEDED_BLOCKING) {
804
+ budgetFields._DISCOVERY_BUDGET_EXCEEDED_BLOCKING = true;
805
+ }
806
+ const enriched = typeof processed === "object" && processed !== null
807
+ ? { ...processed, ...budgetFields }
808
+ : processed;
809
+ // skipGuard: true — base64 intact kalır (legacy mode için gerekli)
810
+ return toolResult(enriched, "figma_capture_screenshot", { skipGuard: true });
641
811
  }));
642
812
  // ---- figma_set_instance_properties ----
643
813
  server.registerTool("figma_set_instance_properties", {