@createlex/figma-swiftui-mcp 1.0.7 → 1.0.9
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/companion/mcp-server.mjs +208 -5
- package/companion/package.json +1 -1
- package/package.json +1 -1
package/companion/mcp-server.mjs
CHANGED
|
@@ -30,6 +30,8 @@ const BRIDGE_HTTP_URL = process.env.FIGMA_SWIFTUI_BRIDGE_HTTP_URL || 'http://loc
|
|
|
30
30
|
const BRIDGE_WS_URL = process.env.FIGMA_SWIFTUI_BRIDGE_WS_URL || 'ws://localhost:7765/bridge';
|
|
31
31
|
const REQUEST_TIMEOUT_MS = Number(process.env.FIGMA_SWIFTUI_BRIDGE_TIMEOUT_MS || 30000);
|
|
32
32
|
const AUTH_REVALIDATION_INTERVAL_MS = Number(process.env.FIGMA_SWIFTUI_AUTH_REVALIDATION_MS || (10 * 60 * 1000));
|
|
33
|
+
const RESPONSE_SIZE_CAP = Number(process.env.FIGMA_SWIFTUI_RESPONSE_SIZE_CAP || 102400); // 100 KB default
|
|
34
|
+
const MAX_DIAGNOSTICS = 20;
|
|
33
35
|
|
|
34
36
|
let bridgeSocket = null;
|
|
35
37
|
let connectPromise = null;
|
|
@@ -666,13 +668,13 @@ server.registerTool('write_generated_swiftui_to_xcode', {
|
|
|
666
668
|
}
|
|
667
669
|
|
|
668
670
|
return jsonResult({
|
|
669
|
-
...result,
|
|
671
|
+
...stripImageData(result),
|
|
670
672
|
structName: effectiveStructName,
|
|
671
673
|
});
|
|
672
674
|
});
|
|
673
675
|
|
|
674
676
|
server.registerTool('write_selection_to_xcode', {
|
|
675
|
-
description: 'Generate SwiftUI from the connected Figma selection
|
|
677
|
+
description: 'Generate SwiftUI from the connected Figma selection, write it into the configured Xcode project, and return refinement hints. After writing, the AI assistant SHOULD automatically apply the returned refinementInstructions to make the generated code adaptive and interactive — replacing raster placeholders with native SwiftUI controls, adding GeometryReader for adaptive layout, and wiring basic interactions.',
|
|
676
678
|
inputSchema: {
|
|
677
679
|
nodeIds: z.array(z.string()).optional().describe('Optional list of Figma node ids. If omitted, uses the current selection'),
|
|
678
680
|
includeOverflow: z.boolean().default(false).describe('Ignore Figma clipping when generating layout'),
|
|
@@ -681,6 +683,29 @@ server.registerTool('write_selection_to_xcode', {
|
|
|
681
683
|
},
|
|
682
684
|
}, async ({ nodeIds, includeOverflow, generationMode, projectPath }) => {
|
|
683
685
|
const targetDir = resolveTargetProjectPath(projectPath);
|
|
686
|
+
|
|
687
|
+
// Try hosted analysis first to get refinement hints alongside code
|
|
688
|
+
let analysisHints = null;
|
|
689
|
+
try {
|
|
690
|
+
const analysis = await tryHostedSemanticGeneration({
|
|
691
|
+
nodeIds,
|
|
692
|
+
generationMode,
|
|
693
|
+
includeOverflow,
|
|
694
|
+
analyze: true,
|
|
695
|
+
});
|
|
696
|
+
if (analysis) {
|
|
697
|
+
analysisHints = {
|
|
698
|
+
generationHints: analysis.generationHints,
|
|
699
|
+
manualRefinementHints: analysis.manualRefinementHints,
|
|
700
|
+
reusableComponents: analysis.reusableComponents,
|
|
701
|
+
assetExportPlan: analysis.assetExportPlan,
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
} catch (err) {
|
|
705
|
+
// Non-fatal — continue with generation even if analysis fails
|
|
706
|
+
console.error('[figma-swiftui-mcp] Hosted analysis failed (non-fatal):', err?.message ?? err);
|
|
707
|
+
}
|
|
708
|
+
|
|
684
709
|
const generated = await callBridge('generate_swiftui', {
|
|
685
710
|
nodeIds,
|
|
686
711
|
includeOverflow,
|
|
@@ -704,14 +729,192 @@ server.registerTool('write_selection_to_xcode', {
|
|
|
704
729
|
throw new Error(result.results.errors.join(' | ') || 'Failed to write generated selection to Xcode');
|
|
705
730
|
}
|
|
706
731
|
|
|
707
|
-
|
|
708
|
-
|
|
732
|
+
// Build refinement instructions from diagnostics and hints
|
|
733
|
+
const rasterNodes = (generated.diagnostics ?? []).filter(
|
|
734
|
+
(d) => d.rasterized || d.reason?.toLowerCase().includes('raster')
|
|
735
|
+
);
|
|
736
|
+
const refinementInstructions = buildRefinementInstructions({
|
|
737
|
+
diagnostics: generated.diagnostics ?? [],
|
|
738
|
+
rasterNodes,
|
|
739
|
+
analysisHints,
|
|
740
|
+
structName: effectiveStructName,
|
|
741
|
+
filePath: result.results?.swiftFile ?? null,
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
return jsonResult(buildCompactResponse({
|
|
745
|
+
result,
|
|
709
746
|
structName: effectiveStructName,
|
|
710
747
|
selection: generated.selection ?? null,
|
|
711
748
|
diagnostics: generated.diagnostics ?? [],
|
|
712
|
-
|
|
749
|
+
refinementInstructions,
|
|
750
|
+
analysisHints,
|
|
751
|
+
}));
|
|
713
752
|
});
|
|
714
753
|
|
|
754
|
+
// ---------------------------------------------------------------------------
|
|
755
|
+
// Response-size mitigation helpers
|
|
756
|
+
// ---------------------------------------------------------------------------
|
|
757
|
+
|
|
758
|
+
/**
|
|
759
|
+
* Remove base64 / svg binary data from any image results.
|
|
760
|
+
* Files are already on disk — the model only needs names and paths.
|
|
761
|
+
*/
|
|
762
|
+
function stripImageData(obj) {
|
|
763
|
+
if (!obj || typeof obj !== 'object') return obj;
|
|
764
|
+
|
|
765
|
+
// Deep-clone to avoid mutating the original
|
|
766
|
+
const clone = JSON.parse(JSON.stringify(obj));
|
|
767
|
+
|
|
768
|
+
const walk = (node) => {
|
|
769
|
+
if (Array.isArray(node)) {
|
|
770
|
+
node.forEach(walk);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
if (node && typeof node === 'object') {
|
|
774
|
+
delete node.base64;
|
|
775
|
+
delete node.svg;
|
|
776
|
+
delete node.data;
|
|
777
|
+
for (const value of Object.values(node)) {
|
|
778
|
+
walk(value);
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
walk(clone);
|
|
784
|
+
return clone;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Cap the diagnostics array, prioritising rasterized nodes first.
|
|
789
|
+
*/
|
|
790
|
+
function capDiagnostics(diagnostics) {
|
|
791
|
+
if (!Array.isArray(diagnostics) || diagnostics.length <= MAX_DIAGNOSTICS) {
|
|
792
|
+
return diagnostics;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Sort: rasterized nodes first (most useful for refinement)
|
|
796
|
+
const sorted = [...diagnostics].sort((a, b) => {
|
|
797
|
+
const aRaster = a.rasterized || a.reason?.toLowerCase().includes('raster') ? 1 : 0;
|
|
798
|
+
const bRaster = b.rasterized || b.reason?.toLowerCase().includes('raster') ? 1 : 0;
|
|
799
|
+
return bRaster - aRaster;
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
const kept = sorted.slice(0, MAX_DIAGNOSTICS);
|
|
803
|
+
kept.push({
|
|
804
|
+
_truncated: true,
|
|
805
|
+
totalCount: diagnostics.length,
|
|
806
|
+
shownCount: MAX_DIAGNOSTICS,
|
|
807
|
+
message: `${diagnostics.length - MAX_DIAGNOSTICS} additional diagnostic(s) omitted to reduce response size.`,
|
|
808
|
+
});
|
|
809
|
+
return kept;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Build a compact MCP response for write_selection_to_xcode.
|
|
814
|
+
*
|
|
815
|
+
* Strategy:
|
|
816
|
+
* 1. Strip all binary (base64/svg) data from image results.
|
|
817
|
+
* 2. Cap diagnostics to MAX_DIAGNOSTICS most relevant entries.
|
|
818
|
+
* 3. Estimate serialised size; if still over RESPONSE_SIZE_CAP,
|
|
819
|
+
* compress analysis hints to summary-only.
|
|
820
|
+
*/
|
|
821
|
+
function buildCompactResponse({ result, structName, selection, diagnostics, refinementInstructions, analysisHints }) {
|
|
822
|
+
const compact = {
|
|
823
|
+
...stripImageData(result),
|
|
824
|
+
structName,
|
|
825
|
+
selection,
|
|
826
|
+
diagnostics: capDiagnostics(diagnostics),
|
|
827
|
+
refinementInstructions,
|
|
828
|
+
...(analysisHints ? { analysisHints } : {}),
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
// First size check
|
|
832
|
+
let serialised = JSON.stringify(compact);
|
|
833
|
+
if (serialised.length <= RESPONSE_SIZE_CAP) {
|
|
834
|
+
return compact;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
// Compress: remove verbose analysis hints, keep only summary-level data
|
|
838
|
+
if (compact.analysisHints) {
|
|
839
|
+
const { generationHints, manualRefinementHints, ...rest } = compact.analysisHints;
|
|
840
|
+
compact.analysisHints = {
|
|
841
|
+
...rest,
|
|
842
|
+
_compressed: true,
|
|
843
|
+
generationHintCount: generationHints?.interactiveElements?.length ?? 0,
|
|
844
|
+
manualRefinementHintCount: manualRefinementHints?.length ?? 0,
|
|
845
|
+
};
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// Second size check — drop the full Swift code echo if still too large
|
|
849
|
+
serialised = JSON.stringify(compact);
|
|
850
|
+
if (serialised.length > RESPONSE_SIZE_CAP && compact.results?.swiftFile) {
|
|
851
|
+
// The code is already on disk at swiftFile; no need to echo it
|
|
852
|
+
if (compact.results) {
|
|
853
|
+
compact.results = { ...compact.results };
|
|
854
|
+
delete compact.results.code;
|
|
855
|
+
}
|
|
856
|
+
compact._responseCapped = true;
|
|
857
|
+
compact._responseNote = `Response was compressed to stay under ${Math.round(RESPONSE_SIZE_CAP / 1024)}KB. All files were written successfully to disk.`;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
return compact;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function buildRefinementInstructions({ diagnostics, rasterNodes, analysisHints, structName, filePath }) {
|
|
864
|
+
const instructions = [];
|
|
865
|
+
|
|
866
|
+
// Adaptive layout
|
|
867
|
+
instructions.push(
|
|
868
|
+
`Make ${structName} adaptive: replace any fixed .frame(width:height:) on the root body with GeometryReader or .frame(maxWidth: .infinity, maxHeight: .infinity) so the screen fills any device.`
|
|
869
|
+
);
|
|
870
|
+
|
|
871
|
+
// Raster replacements
|
|
872
|
+
if (rasterNodes.length > 0) {
|
|
873
|
+
instructions.push(
|
|
874
|
+
`${rasterNodes.length} element(s) were exported as raster images because Figma could not map them to native SwiftUI. Review these and replace obvious UI controls (buttons, text fields, toggles, tab bars) with native SwiftUI equivalents. Keep actual images/illustrations as Image assets.`
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Hosted analysis hints
|
|
879
|
+
if (analysisHints?.manualRefinementHints?.length) {
|
|
880
|
+
for (const hint of analysisHints.manualRefinementHints) {
|
|
881
|
+
const text = typeof hint === 'string' ? hint : hint.description || hint.message || hint.text || hint.title || null;
|
|
882
|
+
if (text) {
|
|
883
|
+
instructions.push(text);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
if (analysisHints?.generationHints?.interactiveElements?.length) {
|
|
889
|
+
const names = analysisHints.generationHints.interactiveElements
|
|
890
|
+
.map((e) => e.name || e.nodeId || e.id || '(unnamed)')
|
|
891
|
+
.filter(Boolean)
|
|
892
|
+
.join(', ');
|
|
893
|
+
if (names) {
|
|
894
|
+
instructions.push(`These elements likely need interaction: ${names}`);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// Reusable components
|
|
899
|
+
if (analysisHints?.reusableComponents?.length) {
|
|
900
|
+
const names = analysisHints.reusableComponents
|
|
901
|
+
.map((c) => c.name || c.componentName || c.id || '(unnamed)')
|
|
902
|
+
.filter(Boolean)
|
|
903
|
+
.join(', ');
|
|
904
|
+
if (names) {
|
|
905
|
+
instructions.push(
|
|
906
|
+
`Consider extracting ${analysisHints.reusableComponents.length} reusable component(s): ${names}`
|
|
907
|
+
);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
return {
|
|
912
|
+
summary: `After writing ${structName}.swift, apply these refinements to make it production-ready:`,
|
|
913
|
+
steps: instructions,
|
|
914
|
+
filePath,
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
|
|
715
918
|
async function shutdownBridgeAndExit(message, exitCode = 1) {
|
|
716
919
|
console.error(message);
|
|
717
920
|
if (authValidationTimer) {
|
package/companion/package.json
CHANGED
package/package.json
CHANGED