@atezer/figma-mcp-bridge 1.9.4 → 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,6 +507,8 @@ 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");
@@ -425,8 +528,16 @@ export async function main() {
425
528
  },
426
529
  }
427
530
  : {};
428
- const warningsField = advisoryWarnings.length > 0
429
- ? { _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 }
430
541
  : {};
431
542
  const startTime = Date.now();
432
543
  try {
@@ -445,6 +556,7 @@ export async function main() {
445
556
  catch { /* safe fallback */ }
446
557
  return {
447
558
  content: [{ type: "text", text: JSON.stringify({
559
+ ...budgetBlockingField, // v1.9.5: discovery BLOCKING flag at top
448
560
  ...dsViolations, // v1.8.1: SEVERE warnings FIRST, before anything else
449
561
  ...result,
450
562
  errorCategory: category,
@@ -459,13 +571,14 @@ export async function main() {
459
571
  try {
460
572
  enriched = typeof result === "object" && result !== null
461
573
  ? {
574
+ ...budgetBlockingField, // v1.9.5: discovery BLOCKING flag at top
462
575
  ...dsViolations, // v1.8.1: SEVERE warnings at top level
463
576
  ...result,
464
577
  _metrics: { durationMs, timeoutMs: clampedTimeout },
465
578
  ...warningsField,
466
579
  }
467
- : severeWarnings.length > 0 || advisoryWarnings.length > 0
468
- ? { ...dsViolations, result, ...warningsField }
580
+ : severeWarnings.length > 0 || advisoryWarnings.length > 0 || combinedWarnings.length > 0
581
+ ? { ...budgetBlockingField, ...dsViolations, result, ...warningsField }
469
582
  : result;
470
583
  }
471
584
  catch {
@@ -610,10 +723,18 @@ export async function main() {
610
723
  const result = await conn.scanDsCompliance({ nodeId, threshold, expectedDs });
611
724
  return toolResult(result, "figma_scan_ds_compliance");
612
725
  }));
613
- // ---- figma_capture_screenshot ----
614
- // v1.8.0: Default JPG@1x (~80% smaller base64 vs PNG@2x). Override via params.
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).
615
731
  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.",
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.",
617
738
  inputSchema: {
618
739
  figmaUrl: z.string().optional().describe("Figma or FigJam file URL for routing."),
619
740
  fileKey: z.string().optional().describe("Target a specific connected file."),
@@ -621,23 +742,51 @@ export async function main() {
621
742
  format: z.enum(["PNG", "JPG"]).optional().default(LEGACY_DEFAULTS ? "PNG" : "JPG"),
622
743
  scale: z.number().optional().default(LEGACY_DEFAULTS ? 2 : 1),
623
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)."),
624
759
  },
625
760
  annotations: { readOnlyHint: true },
626
- }, 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");
627
764
  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 });
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 });
641
790
  }));
642
791
  // ---- figma_set_instance_properties ----
643
792
  server.registerTool("figma_set_instance_properties", {