@createlex/figma-swiftui-mcp 1.0.6 → 1.0.8
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/bridge-server.cjs +80 -4
- package/companion/mcp-server.mjs +102 -2
- package/package.json +1 -1
|
@@ -51,13 +51,50 @@ async function waitForExistingBridge({ host, port }) {
|
|
|
51
51
|
throw new Error(`HTTP ${response.status}`);
|
|
52
52
|
}
|
|
53
53
|
const data = await response.json();
|
|
54
|
+
|
|
55
|
+
// Verify WebSocket is also healthy, not just HTTP
|
|
56
|
+
await new Promise((resolve, reject) => {
|
|
57
|
+
const testWs = new WebSocket(`ws://${host}:${port}/bridge`);
|
|
58
|
+
const timeout = setTimeout(() => {
|
|
59
|
+
testWs.terminate();
|
|
60
|
+
reject(new Error('WebSocket health check timed out'));
|
|
61
|
+
}, 3000);
|
|
62
|
+
testWs.on('open', () => {
|
|
63
|
+
clearTimeout(timeout);
|
|
64
|
+
testWs.close();
|
|
65
|
+
resolve();
|
|
66
|
+
});
|
|
67
|
+
testWs.on('error', (err) => {
|
|
68
|
+
clearTimeout(timeout);
|
|
69
|
+
reject(new Error(`WebSocket health check failed: ${err.message}`));
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
54
73
|
return {
|
|
55
74
|
ok: true,
|
|
56
75
|
alreadyRunning: true,
|
|
57
76
|
info: data,
|
|
58
77
|
};
|
|
59
78
|
} catch (error) {
|
|
60
|
-
throw new Error(`Port ${port} is already in use and does not look like a Figma SwiftUI bridge`);
|
|
79
|
+
throw new Error(`Port ${port} is already in use and does not look like a healthy Figma SwiftUI bridge (${error.message})`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function killStaleAndRetry({ host, port, logger }) {
|
|
84
|
+
const { execSync } = require('child_process');
|
|
85
|
+
try {
|
|
86
|
+
const lsofOutput = execSync(`lsof -ti :${port}`, { encoding: 'utf8' }).trim();
|
|
87
|
+
const pids = lsofOutput.split('\n').filter(Boolean);
|
|
88
|
+
if (pids.length > 0) {
|
|
89
|
+
logger.warn(`⚠️ Killing ${pids.length} stale process(es) on port ${port}: ${pids.join(', ')}`);
|
|
90
|
+
for (const pid of pids) {
|
|
91
|
+
try { process.kill(Number(pid), 'SIGTERM'); } catch (_) {}
|
|
92
|
+
}
|
|
93
|
+
// Wait for port to free up
|
|
94
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
95
|
+
}
|
|
96
|
+
} catch (_) {
|
|
97
|
+
// lsof returns non-zero if no matches — port is already free
|
|
61
98
|
}
|
|
62
99
|
}
|
|
63
100
|
|
|
@@ -527,7 +564,7 @@ function startBridgeServer(options = {}) {
|
|
|
527
564
|
if (error && error.code === 'EADDRINUSE') {
|
|
528
565
|
try {
|
|
529
566
|
const running = await waitForExistingBridge({ host, port });
|
|
530
|
-
logger.warn(`ℹ️
|
|
567
|
+
logger.warn(`ℹ️ Using existing healthy bridge at http://${host}:${port}`);
|
|
531
568
|
resolve({
|
|
532
569
|
app,
|
|
533
570
|
server: null,
|
|
@@ -542,8 +579,47 @@ function startBridgeServer(options = {}) {
|
|
|
542
579
|
});
|
|
543
580
|
return;
|
|
544
581
|
} catch (existingError) {
|
|
545
|
-
|
|
546
|
-
|
|
582
|
+
// Existing bridge is unhealthy — kill stale processes and start fresh
|
|
583
|
+
logger.warn(`⚠️ ${existingError.message}`);
|
|
584
|
+
try {
|
|
585
|
+
await killStaleAndRetry({ host, port, logger });
|
|
586
|
+
// Re-attempt listen after killing stale processes
|
|
587
|
+
server.listen(port, host, () => {
|
|
588
|
+
logger.info(`🚀 Figma SwiftUI bridge running at http://${host}:${port} (recovered from stale)`);
|
|
589
|
+
if (projectPath) {
|
|
590
|
+
logger.info(`📂 Project path: ${projectPath}`);
|
|
591
|
+
}
|
|
592
|
+
logger.info(`🌉 Bridge ready at ws://${host}:${port}/bridge`);
|
|
593
|
+
resolve({
|
|
594
|
+
app,
|
|
595
|
+
server,
|
|
596
|
+
bridgeWss,
|
|
597
|
+
port,
|
|
598
|
+
host,
|
|
599
|
+
alreadyRunning: false,
|
|
600
|
+
getProjectPath: () => projectPath,
|
|
601
|
+
getBridgeInfo: () => ({
|
|
602
|
+
protocolVersion: BRIDGE_PROTOCOL_VERSION,
|
|
603
|
+
pluginConnected: !!pluginBridgeClient,
|
|
604
|
+
connectedAgents: agentBridgeClients.size,
|
|
605
|
+
pendingRequests: pendingBridgeRequests.size,
|
|
606
|
+
supportedActions: SUPPORTED_BRIDGE_ACTIONS,
|
|
607
|
+
}),
|
|
608
|
+
close: () => new Promise((closeResolve, closeReject) => {
|
|
609
|
+
bridgeWss.close(() => {
|
|
610
|
+
server.close((closeError) => {
|
|
611
|
+
if (closeError) closeReject(closeError);
|
|
612
|
+
else closeResolve();
|
|
613
|
+
});
|
|
614
|
+
});
|
|
615
|
+
}),
|
|
616
|
+
});
|
|
617
|
+
});
|
|
618
|
+
return;
|
|
619
|
+
} catch (retryError) {
|
|
620
|
+
reject(new Error(`Failed to recover bridge: ${retryError.message}`));
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
547
623
|
}
|
|
548
624
|
}
|
|
549
625
|
|
package/companion/mcp-server.mjs
CHANGED
|
@@ -237,7 +237,15 @@ function handleBridgeMessage(rawMessage) {
|
|
|
237
237
|
}
|
|
238
238
|
|
|
239
239
|
if (message.type === 'bridge-event') {
|
|
240
|
-
|
|
240
|
+
// Log bridge events concisely — skip full JSON payloads for noisy events
|
|
241
|
+
const summary = message.data && message.event === 'selectionchange'
|
|
242
|
+
? `${message.data.selection?.count ?? 0} node(s) selected`
|
|
243
|
+
: message.data && message.event === 'currentpagechange'
|
|
244
|
+
? `page "${message.data.currentPage?.name || 'unknown'}"`
|
|
245
|
+
: '';
|
|
246
|
+
if (summary) {
|
|
247
|
+
console.error(`[figma-swiftui-mcp] ${message.event}: ${summary}`);
|
|
248
|
+
}
|
|
241
249
|
}
|
|
242
250
|
}
|
|
243
251
|
|
|
@@ -664,7 +672,7 @@ server.registerTool('write_generated_swiftui_to_xcode', {
|
|
|
664
672
|
});
|
|
665
673
|
|
|
666
674
|
server.registerTool('write_selection_to_xcode', {
|
|
667
|
-
description: 'Generate SwiftUI from the connected Figma selection
|
|
675
|
+
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.',
|
|
668
676
|
inputSchema: {
|
|
669
677
|
nodeIds: z.array(z.string()).optional().describe('Optional list of Figma node ids. If omitted, uses the current selection'),
|
|
670
678
|
includeOverflow: z.boolean().default(false).describe('Ignore Figma clipping when generating layout'),
|
|
@@ -673,6 +681,29 @@ server.registerTool('write_selection_to_xcode', {
|
|
|
673
681
|
},
|
|
674
682
|
}, async ({ nodeIds, includeOverflow, generationMode, projectPath }) => {
|
|
675
683
|
const targetDir = resolveTargetProjectPath(projectPath);
|
|
684
|
+
|
|
685
|
+
// Try hosted analysis first to get refinement hints alongside code
|
|
686
|
+
let analysisHints = null;
|
|
687
|
+
try {
|
|
688
|
+
const analysis = await tryHostedSemanticGeneration({
|
|
689
|
+
nodeIds,
|
|
690
|
+
generationMode,
|
|
691
|
+
includeOverflow,
|
|
692
|
+
analyze: true,
|
|
693
|
+
});
|
|
694
|
+
if (analysis) {
|
|
695
|
+
analysisHints = {
|
|
696
|
+
generationHints: analysis.generationHints,
|
|
697
|
+
manualRefinementHints: analysis.manualRefinementHints,
|
|
698
|
+
reusableComponents: analysis.reusableComponents,
|
|
699
|
+
assetExportPlan: analysis.assetExportPlan,
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
} catch (err) {
|
|
703
|
+
// Non-fatal — continue with generation even if analysis fails
|
|
704
|
+
console.error('[figma-swiftui-mcp] Hosted analysis failed (non-fatal):', err?.message ?? err);
|
|
705
|
+
}
|
|
706
|
+
|
|
676
707
|
const generated = await callBridge('generate_swiftui', {
|
|
677
708
|
nodeIds,
|
|
678
709
|
includeOverflow,
|
|
@@ -696,14 +727,83 @@ server.registerTool('write_selection_to_xcode', {
|
|
|
696
727
|
throw new Error(result.results.errors.join(' | ') || 'Failed to write generated selection to Xcode');
|
|
697
728
|
}
|
|
698
729
|
|
|
730
|
+
// Build refinement instructions from diagnostics and hints
|
|
731
|
+
const rasterNodes = (generated.diagnostics ?? []).filter(
|
|
732
|
+
(d) => d.rasterized || d.reason?.toLowerCase().includes('raster')
|
|
733
|
+
);
|
|
734
|
+
const refinementInstructions = buildRefinementInstructions({
|
|
735
|
+
diagnostics: generated.diagnostics ?? [],
|
|
736
|
+
rasterNodes,
|
|
737
|
+
analysisHints,
|
|
738
|
+
structName: effectiveStructName,
|
|
739
|
+
filePath: result.results?.swiftFile ?? null,
|
|
740
|
+
});
|
|
741
|
+
|
|
699
742
|
return jsonResult({
|
|
700
743
|
...result,
|
|
701
744
|
structName: effectiveStructName,
|
|
702
745
|
selection: generated.selection ?? null,
|
|
703
746
|
diagnostics: generated.diagnostics ?? [],
|
|
747
|
+
refinementInstructions,
|
|
748
|
+
...(analysisHints ? { analysisHints } : {}),
|
|
704
749
|
});
|
|
705
750
|
});
|
|
706
751
|
|
|
752
|
+
function buildRefinementInstructions({ diagnostics, rasterNodes, analysisHints, structName, filePath }) {
|
|
753
|
+
const instructions = [];
|
|
754
|
+
|
|
755
|
+
// Adaptive layout
|
|
756
|
+
instructions.push(
|
|
757
|
+
`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.`
|
|
758
|
+
);
|
|
759
|
+
|
|
760
|
+
// Raster replacements
|
|
761
|
+
if (rasterNodes.length > 0) {
|
|
762
|
+
instructions.push(
|
|
763
|
+
`${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.`
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// Hosted analysis hints
|
|
768
|
+
if (analysisHints?.manualRefinementHints?.length) {
|
|
769
|
+
for (const hint of analysisHints.manualRefinementHints) {
|
|
770
|
+
const text = typeof hint === 'string' ? hint : hint.description || hint.message || hint.text || hint.title || null;
|
|
771
|
+
if (text) {
|
|
772
|
+
instructions.push(text);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
if (analysisHints?.generationHints?.interactiveElements?.length) {
|
|
778
|
+
const names = analysisHints.generationHints.interactiveElements
|
|
779
|
+
.map((e) => e.name || e.nodeId || e.id || '(unnamed)')
|
|
780
|
+
.filter(Boolean)
|
|
781
|
+
.join(', ');
|
|
782
|
+
if (names) {
|
|
783
|
+
instructions.push(`These elements likely need interaction: ${names}`);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// Reusable components
|
|
788
|
+
if (analysisHints?.reusableComponents?.length) {
|
|
789
|
+
const names = analysisHints.reusableComponents
|
|
790
|
+
.map((c) => c.name || c.componentName || c.id || '(unnamed)')
|
|
791
|
+
.filter(Boolean)
|
|
792
|
+
.join(', ');
|
|
793
|
+
if (names) {
|
|
794
|
+
instructions.push(
|
|
795
|
+
`Consider extracting ${analysisHints.reusableComponents.length} reusable component(s): ${names}`
|
|
796
|
+
);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
return {
|
|
801
|
+
summary: `After writing ${structName}.swift, apply these refinements to make it production-ready:`,
|
|
802
|
+
steps: instructions,
|
|
803
|
+
filePath,
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
|
|
707
807
|
async function shutdownBridgeAndExit(message, exitCode = 1) {
|
|
708
808
|
console.error(message);
|
|
709
809
|
if (authValidationTimer) {
|
package/package.json
CHANGED