@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.
- package/CHANGELOG.md +93 -0
- package/README.md +29 -0
- package/dist/core/discovery-counter.d.ts +54 -0
- package/dist/core/discovery-counter.d.ts.map +1 -0
- package/dist/core/discovery-counter.js +140 -0
- package/dist/core/discovery-counter.js.map +1 -0
- package/dist/core/plugin-bridge-connector.d.ts +16 -0
- package/dist/core/plugin-bridge-connector.d.ts.map +1 -1
- package/dist/core/plugin-bridge-connector.js +13 -0
- package/dist/core/plugin-bridge-connector.js.map +1 -1
- package/dist/local-plugin-only.d.ts.map +1 -1
- package/dist/local-plugin-only.js +208 -24
- package/dist/local-plugin-only.js.map +1 -1
- package/f-mcp-plugin/code.js +370 -34
- package/f-mcp-plugin/ui.html +1 -1
- package/package.json +1 -1
- package/skills/figma-canvas-ops/SKILL.md +68 -0
- package/skills/fmcp-intent-router/SKILL.md +9 -0
- package/skills/fmcp-screen-orchestrator/SKILL.md +29 -0
- package/skills/fmcp-screen-recipes/SKILL.md +31 -5
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
425
|
-
|
|
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
|
-
// ----
|
|
579
|
-
//
|
|
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: "
|
|
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, {
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
//
|
|
604
|
-
|
|
605
|
-
|
|
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", {
|