@createlex/figma-swiftui-mcp 1.0.0
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/README.md +384 -0
- package/bin/figma-swiftui-mcp.js +81 -0
- package/companion/bridge-server.cjs +599 -0
- package/companion/createlex-auth.cjs +364 -0
- package/companion/login.mjs +190 -0
- package/companion/mcp-server.mjs +788 -0
- package/companion/package.json +17 -0
- package/companion/server.js +64 -0
- package/companion/xcode-writer.cjs +429 -0
- package/package.json +39 -0
|
@@ -0,0 +1,788 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createRequire } from 'node:module';
|
|
4
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
5
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
|
+
import WebSocket from 'ws';
|
|
7
|
+
import * as z from 'zod/v4';
|
|
8
|
+
|
|
9
|
+
const require = createRequire(import.meta.url);
|
|
10
|
+
const {
|
|
11
|
+
GENERATED_LAYOUT_VERSION,
|
|
12
|
+
getConfigPath,
|
|
13
|
+
getSavedProjectPath,
|
|
14
|
+
inferStructName,
|
|
15
|
+
loadProjectPath,
|
|
16
|
+
resolveWritableProjectPath,
|
|
17
|
+
setSavedProjectPath,
|
|
18
|
+
writeAssetCatalogEntries,
|
|
19
|
+
writeSwiftUIScreen,
|
|
20
|
+
} = require('./xcode-writer.cjs');
|
|
21
|
+
const { startBridgeServer } = require('./bridge-server.cjs');
|
|
22
|
+
const {
|
|
23
|
+
AUTH_FILE,
|
|
24
|
+
authorizeRuntimeStartup,
|
|
25
|
+
postAuthorizedApi,
|
|
26
|
+
validateRuntimeSession,
|
|
27
|
+
} = require('./createlex-auth.cjs');
|
|
28
|
+
|
|
29
|
+
const BRIDGE_HTTP_URL = process.env.FIGMA_SWIFTUI_BRIDGE_HTTP_URL || 'http://localhost:7765';
|
|
30
|
+
const BRIDGE_WS_URL = process.env.FIGMA_SWIFTUI_BRIDGE_WS_URL || 'ws://localhost:7765/bridge';
|
|
31
|
+
const REQUEST_TIMEOUT_MS = Number(process.env.FIGMA_SWIFTUI_BRIDGE_TIMEOUT_MS || 30000);
|
|
32
|
+
const AUTH_REVALIDATION_INTERVAL_MS = Number(process.env.FIGMA_SWIFTUI_AUTH_REVALIDATION_MS || (10 * 60 * 1000));
|
|
33
|
+
|
|
34
|
+
let bridgeSocket = null;
|
|
35
|
+
let connectPromise = null;
|
|
36
|
+
let reconnectTimer = null;
|
|
37
|
+
let authValidationTimer = null;
|
|
38
|
+
let bridgeRuntimeHandle = null;
|
|
39
|
+
let lastBridgeStatus = {
|
|
40
|
+
protocolVersion: 1,
|
|
41
|
+
pluginConnected: false,
|
|
42
|
+
connectedAgents: 0,
|
|
43
|
+
pendingRequests: 0,
|
|
44
|
+
supportedActions: [],
|
|
45
|
+
};
|
|
46
|
+
let runtimeAuthState = {
|
|
47
|
+
authorized: false,
|
|
48
|
+
bypass: false,
|
|
49
|
+
apiBaseUrl: null,
|
|
50
|
+
validatedAt: null,
|
|
51
|
+
userId: null,
|
|
52
|
+
email: null,
|
|
53
|
+
tokenSource: null,
|
|
54
|
+
expiresAt: null,
|
|
55
|
+
startupEndpoint: null,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const pendingRequests = new Map();
|
|
59
|
+
|
|
60
|
+
function readProjectPathArg(argv) {
|
|
61
|
+
const argIdx = argv.indexOf('--project');
|
|
62
|
+
if (argIdx !== -1 && argv[argIdx + 1]) {
|
|
63
|
+
return argv[argIdx + 1];
|
|
64
|
+
}
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function jsonResult(data) {
|
|
69
|
+
return {
|
|
70
|
+
content: [
|
|
71
|
+
{
|
|
72
|
+
type: 'text',
|
|
73
|
+
text: JSON.stringify(data, null, 2),
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function getPublicAuthState() {
|
|
80
|
+
return {
|
|
81
|
+
authorized: runtimeAuthState.authorized,
|
|
82
|
+
bypass: runtimeAuthState.bypass,
|
|
83
|
+
apiBaseUrl: runtimeAuthState.apiBaseUrl,
|
|
84
|
+
validatedAt: runtimeAuthState.validatedAt,
|
|
85
|
+
userId: runtimeAuthState.userId,
|
|
86
|
+
email: runtimeAuthState.email,
|
|
87
|
+
tokenSource: runtimeAuthState.tokenSource,
|
|
88
|
+
expiresAt: runtimeAuthState.expiresAt,
|
|
89
|
+
startupEndpoint: runtimeAuthState.startupEndpoint,
|
|
90
|
+
authFile: AUTH_FILE,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function ensureRuntimeAuthorized() {
|
|
95
|
+
if (!runtimeAuthState.authorized) {
|
|
96
|
+
throw new Error('CreateLex authentication required. Run "npx @createlex/figma-swiftui-mcp login" and ensure your subscription is active before starting figma-swiftui MCP.');
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function tryHostedSemanticGeneration({ nodeIds, generationMode, includeOverflow, analyze }) {
|
|
101
|
+
if (generationMode !== 'editable') {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const context = await callBridge('get_design_context', {
|
|
106
|
+
nodeIds,
|
|
107
|
+
maxDepth: 4,
|
|
108
|
+
includeScreenshot: false,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const metadata = context?.metadata;
|
|
112
|
+
const isSingleRootNode = !!metadata && !Array.isArray(metadata?.nodes) ? true : Array.isArray(metadata?.nodes) && metadata.nodes.length === 1;
|
|
113
|
+
if (!isSingleRootNode) {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const endpoint = analyze ? '/mcp/figma-swiftui/analyze' : '/mcp/figma-swiftui/generate';
|
|
118
|
+
const { response, data } = await postAuthorizedApi(runtimeAuthState, endpoint, {
|
|
119
|
+
context,
|
|
120
|
+
generationMode,
|
|
121
|
+
includeOverflow,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (!response.ok) {
|
|
125
|
+
throw new Error(data?.error || `Hosted figma-swiftui request failed (${response.status})`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (!data?.handled) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const selection = Array.isArray(metadata?.nodes)
|
|
133
|
+
? {
|
|
134
|
+
ids: metadata.nodes.map((node) => node.id),
|
|
135
|
+
names: metadata.nodes.map((node) => node.name),
|
|
136
|
+
}
|
|
137
|
+
: {
|
|
138
|
+
ids: [metadata.id],
|
|
139
|
+
names: [metadata.name],
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
if (!analyze) {
|
|
143
|
+
return {
|
|
144
|
+
code: data.code,
|
|
145
|
+
selection,
|
|
146
|
+
imageCount: 0,
|
|
147
|
+
imageNames: [],
|
|
148
|
+
diagnostics: data.diagnostics || [],
|
|
149
|
+
provider: data.provider || 'createlex-hosted',
|
|
150
|
+
screenType: data.screenType || null,
|
|
151
|
+
assetRequests: data.assetRequests || [],
|
|
152
|
+
hosted: true,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
selection: metadata,
|
|
158
|
+
generated: {
|
|
159
|
+
code: data.code,
|
|
160
|
+
imageCount: 0,
|
|
161
|
+
imageNames: [],
|
|
162
|
+
diagnostics: data.diagnostics || [],
|
|
163
|
+
provider: data.provider || 'createlex-hosted',
|
|
164
|
+
screenType: data.screenType || null,
|
|
165
|
+
assetRequests: data.assetRequests || [],
|
|
166
|
+
hosted: true,
|
|
167
|
+
},
|
|
168
|
+
assetExportPlan: data.contextSummary?.assetExportPlan || context?.assetExportPlan || null,
|
|
169
|
+
reusableComponents: data.contextSummary?.reusableComponents || context?.reusableComponents || null,
|
|
170
|
+
generationHints: data.contextSummary?.generationHints || context?.generationHints || null,
|
|
171
|
+
manualRefinementHints: context?.generationHints?.manualRefinementHints || [],
|
|
172
|
+
hosted: true,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function resolveTargetProjectPath(projectPath) {
|
|
177
|
+
const resolved = loadProjectPath({ explicitPath: projectPath });
|
|
178
|
+
if (!resolved) {
|
|
179
|
+
throw new Error('No Xcode project path is configured. Use set_project_path first or pass projectPath explicitly.');
|
|
180
|
+
}
|
|
181
|
+
return resolved;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function scheduleReconnect() {
|
|
185
|
+
if (reconnectTimer) {
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
reconnectTimer = setTimeout(() => {
|
|
190
|
+
reconnectTimer = null;
|
|
191
|
+
connectBridge().catch((error) => {
|
|
192
|
+
console.error('[figma-swiftui-mcp] Bridge reconnect failed:', error.message);
|
|
193
|
+
});
|
|
194
|
+
}, 1500);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function rejectPendingRequests(message) {
|
|
198
|
+
for (const [requestId, pending] of pendingRequests.entries()) {
|
|
199
|
+
clearTimeout(pending.timer);
|
|
200
|
+
pending.reject(new Error(message));
|
|
201
|
+
pendingRequests.delete(requestId);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function handleBridgeMessage(rawMessage) {
|
|
206
|
+
let message;
|
|
207
|
+
try {
|
|
208
|
+
message = JSON.parse(rawMessage.toString());
|
|
209
|
+
} catch (error) {
|
|
210
|
+
console.error('[figma-swiftui-mcp] Failed to parse bridge message:', error);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (message.type === 'hello-ack' || message.type === 'bridge-status') {
|
|
215
|
+
lastBridgeStatus = {
|
|
216
|
+
...lastBridgeStatus,
|
|
217
|
+
...message,
|
|
218
|
+
};
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (message.type === 'bridge-response') {
|
|
223
|
+
const pending = pendingRequests.get(message.requestId);
|
|
224
|
+
if (!pending) {
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
clearTimeout(pending.timer);
|
|
229
|
+
pendingRequests.delete(message.requestId);
|
|
230
|
+
|
|
231
|
+
if (message.ok) {
|
|
232
|
+
pending.resolve(message.data);
|
|
233
|
+
} else {
|
|
234
|
+
pending.reject(new Error(message.error || `Bridge action ${message.action} failed`));
|
|
235
|
+
}
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (message.type === 'bridge-event') {
|
|
240
|
+
console.error(`[figma-swiftui-mcp] Bridge event ${message.event}: ${JSON.stringify(message.data)}`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function connectBridge() {
|
|
245
|
+
if (bridgeSocket && bridgeSocket.readyState === WebSocket.OPEN) {
|
|
246
|
+
return bridgeSocket;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (connectPromise) {
|
|
250
|
+
return connectPromise;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
connectPromise = new Promise((resolve, reject) => {
|
|
254
|
+
const socket = new WebSocket(BRIDGE_WS_URL);
|
|
255
|
+
let settled = false;
|
|
256
|
+
|
|
257
|
+
socket.on('open', () => {
|
|
258
|
+
bridgeSocket = socket;
|
|
259
|
+
socket.send(JSON.stringify({
|
|
260
|
+
type: 'hello',
|
|
261
|
+
role: 'agent',
|
|
262
|
+
protocolVersion: 1,
|
|
263
|
+
}));
|
|
264
|
+
|
|
265
|
+
settled = true;
|
|
266
|
+
resolve(socket);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
socket.on('message', (message) => {
|
|
270
|
+
handleBridgeMessage(message);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
socket.on('close', () => {
|
|
274
|
+
if (bridgeSocket === socket) {
|
|
275
|
+
bridgeSocket = null;
|
|
276
|
+
}
|
|
277
|
+
rejectPendingRequests('Bridge socket closed');
|
|
278
|
+
scheduleReconnect();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
socket.on('error', (error) => {
|
|
282
|
+
if (!settled) {
|
|
283
|
+
reject(error);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
}).finally(() => {
|
|
287
|
+
connectPromise = null;
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
return connectPromise;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function fetchBridgeStatus() {
|
|
294
|
+
try {
|
|
295
|
+
const response = await fetch(`${BRIDGE_HTTP_URL}/bridge/info`);
|
|
296
|
+
if (!response.ok) {
|
|
297
|
+
throw new Error(`HTTP ${response.status}`);
|
|
298
|
+
}
|
|
299
|
+
const data = await response.json();
|
|
300
|
+
lastBridgeStatus = {
|
|
301
|
+
...lastBridgeStatus,
|
|
302
|
+
...data,
|
|
303
|
+
};
|
|
304
|
+
return data;
|
|
305
|
+
} catch (error) {
|
|
306
|
+
return {
|
|
307
|
+
...lastBridgeStatus,
|
|
308
|
+
ok: false,
|
|
309
|
+
error: error instanceof Error ? error.message : 'Unable to reach bridge info endpoint',
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function callBridge(action, params = {}) {
|
|
315
|
+
ensureRuntimeAuthorized();
|
|
316
|
+
await connectBridge();
|
|
317
|
+
const status = await fetchBridgeStatus();
|
|
318
|
+
|
|
319
|
+
if (!status.pluginConnected) {
|
|
320
|
+
throw new Error('No active Figma plugin session is connected to the bridge');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const requestId = `mcp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
324
|
+
|
|
325
|
+
return new Promise((resolve, reject) => {
|
|
326
|
+
const timer = setTimeout(() => {
|
|
327
|
+
pendingRequests.delete(requestId);
|
|
328
|
+
reject(new Error(`Timed out waiting for bridge action "${action}"`));
|
|
329
|
+
}, REQUEST_TIMEOUT_MS);
|
|
330
|
+
|
|
331
|
+
pendingRequests.set(requestId, {
|
|
332
|
+
resolve,
|
|
333
|
+
reject,
|
|
334
|
+
timer,
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
bridgeSocket.send(JSON.stringify({
|
|
338
|
+
type: 'bridge-request',
|
|
339
|
+
requestId,
|
|
340
|
+
action,
|
|
341
|
+
params,
|
|
342
|
+
}));
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const server = new McpServer({
|
|
347
|
+
name: 'figma-swiftui-bridge',
|
|
348
|
+
version: '1.0.0',
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
server.registerTool('bridge_status', {
|
|
352
|
+
description: 'Check the local Figma SwiftUI bridge status, including whether a live plugin session is connected.',
|
|
353
|
+
}, async () => {
|
|
354
|
+
const data = await fetchBridgeStatus();
|
|
355
|
+
return jsonResult({
|
|
356
|
+
...data,
|
|
357
|
+
auth: getPublicAuthState(),
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
server.registerTool('auth_status', {
|
|
362
|
+
description: 'Check whether the local figma-swiftui MCP runtime is authorized for a paid CreateLex subscription.',
|
|
363
|
+
}, async () => {
|
|
364
|
+
return jsonResult(getPublicAuthState());
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
server.registerTool('get_metadata', {
|
|
368
|
+
description: 'Inspect selection, page, or node metadata in a format similar to Figma Dev MCP.',
|
|
369
|
+
inputSchema: {
|
|
370
|
+
scope: z.enum(['selection', 'page', 'node']).default('selection').describe('Metadata scope to inspect'),
|
|
371
|
+
nodeId: z.string().optional().describe('Required when scope=node'),
|
|
372
|
+
maxDepth: z.number().int().min(0).max(8).default(3).describe('Maximum child depth to include'),
|
|
373
|
+
},
|
|
374
|
+
}, async ({ scope, nodeId, maxDepth }) => {
|
|
375
|
+
const data = await callBridge('get_metadata', { scope, nodeId, maxDepth });
|
|
376
|
+
return jsonResult(data);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
server.registerTool('get_design_context', {
|
|
380
|
+
description: 'Return node metadata, asset export candidates, and generation hints for the current selection or an explicit node.',
|
|
381
|
+
inputSchema: {
|
|
382
|
+
nodeIds: z.array(z.string()).optional().describe('Optional list of Figma node ids. If omitted, uses the current selection'),
|
|
383
|
+
nodeId: z.string().optional().describe('Optional single Figma node id'),
|
|
384
|
+
maxDepth: z.number().int().min(0).max(8).default(3).describe('Maximum child depth to include'),
|
|
385
|
+
includeScreenshot: z.boolean().default(false).describe('Include a PNG screenshot when exactly one node is targeted'),
|
|
386
|
+
},
|
|
387
|
+
}, async ({ nodeIds, nodeId, maxDepth, includeScreenshot }) => {
|
|
388
|
+
const data = await callBridge('get_design_context', {
|
|
389
|
+
nodeIds,
|
|
390
|
+
nodeId,
|
|
391
|
+
maxDepth,
|
|
392
|
+
includeScreenshot,
|
|
393
|
+
});
|
|
394
|
+
return jsonResult(data);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
server.registerTool('get_screenshot', {
|
|
398
|
+
description: 'Export a PNG screenshot for a single target node or the single current selection.',
|
|
399
|
+
inputSchema: {
|
|
400
|
+
nodeIds: z.array(z.string()).optional().describe('Optional list of Figma node ids. If omitted, uses the current selection'),
|
|
401
|
+
nodeId: z.string().optional().describe('Optional single Figma node id'),
|
|
402
|
+
},
|
|
403
|
+
}, async ({ nodeIds, nodeId }) => {
|
|
404
|
+
const data = await callBridge('get_screenshot', { nodeIds, nodeId });
|
|
405
|
+
return jsonResult(data);
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
server.registerTool('export_svg', {
|
|
409
|
+
description: 'Export an exact SVG for a single vector-friendly node or the single current selection.',
|
|
410
|
+
inputSchema: {
|
|
411
|
+
nodeIds: z.array(z.string()).optional().describe('Optional list of Figma node ids. If omitted, uses the current selection'),
|
|
412
|
+
nodeId: z.string().optional().describe('Optional single Figma node id'),
|
|
413
|
+
},
|
|
414
|
+
}, async ({ nodeIds, nodeId }) => {
|
|
415
|
+
const data = await callBridge('export_svg', { nodeIds, nodeId });
|
|
416
|
+
return jsonResult(data);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
server.registerTool('write_svg_to_xcode', {
|
|
420
|
+
description: 'Export an exact SVG from Figma and write it directly into Assets.xcassets.',
|
|
421
|
+
inputSchema: {
|
|
422
|
+
nodeIds: z.array(z.string()).optional().describe('Optional list of Figma node ids. If omitted, uses the current selection'),
|
|
423
|
+
nodeId: z.string().optional().describe('Optional single Figma node id'),
|
|
424
|
+
assetName: z.string().optional().describe('Optional asset name override'),
|
|
425
|
+
projectPath: z.string().optional().describe('Optional Xcode source folder override'),
|
|
426
|
+
},
|
|
427
|
+
}, async ({ nodeIds, nodeId, assetName, projectPath }) => {
|
|
428
|
+
const targetDir = resolveTargetProjectPath(projectPath);
|
|
429
|
+
const svgExport = await callBridge('export_svg', { nodeIds, nodeId });
|
|
430
|
+
const effectiveAssetName = assetName || svgExport.suggestedAssetName || 'VectorAsset';
|
|
431
|
+
const result = writeAssetCatalogEntries({
|
|
432
|
+
targetDir,
|
|
433
|
+
assets: [
|
|
434
|
+
{
|
|
435
|
+
name: effectiveAssetName,
|
|
436
|
+
format: 'svg',
|
|
437
|
+
svg: svgExport.svg,
|
|
438
|
+
},
|
|
439
|
+
],
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
if (!result.ok) {
|
|
443
|
+
throw new Error(result.errors.join(' | ') || 'Failed to write SVG asset to Xcode');
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return jsonResult({
|
|
447
|
+
ok: true,
|
|
448
|
+
projectPath: targetDir,
|
|
449
|
+
assetName: effectiveAssetName,
|
|
450
|
+
xcassetsDir: result.xcassetsDir,
|
|
451
|
+
files: result.files,
|
|
452
|
+
sourceNode: {
|
|
453
|
+
nodeId: svgExport.nodeId,
|
|
454
|
+
nodeName: svgExport.nodeName,
|
|
455
|
+
nodeType: svgExport.nodeType,
|
|
456
|
+
},
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
server.registerTool('get_project_path', {
|
|
461
|
+
description: 'Read the saved Xcode project source folder used for local SwiftUI writes.',
|
|
462
|
+
}, async () => {
|
|
463
|
+
return jsonResult({
|
|
464
|
+
projectPath: getSavedProjectPath(),
|
|
465
|
+
configPath: getConfigPath(),
|
|
466
|
+
layoutVersion: GENERATED_LAYOUT_VERSION,
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
server.registerTool('set_project_path', {
|
|
471
|
+
description: 'Persist the Xcode project source folder used for local SwiftUI writes.',
|
|
472
|
+
inputSchema: {
|
|
473
|
+
projectPath: z.string().describe('Path to the Xcode source folder containing Swift files and Assets.xcassets'),
|
|
474
|
+
},
|
|
475
|
+
}, async ({ projectPath }) => {
|
|
476
|
+
const resolved = resolveWritableProjectPath(projectPath);
|
|
477
|
+
const saved = setSavedProjectPath(resolved);
|
|
478
|
+
return jsonResult({
|
|
479
|
+
ok: true,
|
|
480
|
+
projectPath: saved,
|
|
481
|
+
configPath: getConfigPath(),
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
server.registerTool('get_document_summary', {
|
|
486
|
+
description: 'Read high-level Figma document, page, selection, and viewport metadata from the connected plugin session.',
|
|
487
|
+
}, async () => {
|
|
488
|
+
const data = await callBridge('get_document_summary');
|
|
489
|
+
return jsonResult(data);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
server.registerTool('get_viewport_context', {
|
|
493
|
+
description: 'Read the active Figma viewport center, zoom, and current page metadata.',
|
|
494
|
+
}, async () => {
|
|
495
|
+
const data = await callBridge('get_viewport_context');
|
|
496
|
+
return jsonResult(data);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
server.registerTool('get_selection_snapshot', {
|
|
500
|
+
description: 'Read a rich structural snapshot of the current Figma selection.',
|
|
501
|
+
inputSchema: {
|
|
502
|
+
maxDepth: z.number().int().min(0).max(8).default(3).describe('Maximum child depth to include in the snapshot'),
|
|
503
|
+
},
|
|
504
|
+
}, async ({ maxDepth }) => {
|
|
505
|
+
const data = await callBridge('get_selection_snapshot', { maxDepth });
|
|
506
|
+
return jsonResult(data);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
server.registerTool('get_page_snapshot', {
|
|
510
|
+
description: 'Read the current Figma page and its child hierarchy.',
|
|
511
|
+
inputSchema: {
|
|
512
|
+
maxDepth: z.number().int().min(0).max(6).default(2).describe('Maximum child depth to include for the page snapshot'),
|
|
513
|
+
},
|
|
514
|
+
}, async ({ maxDepth }) => {
|
|
515
|
+
const data = await callBridge('get_page_snapshot', { maxDepth });
|
|
516
|
+
return jsonResult(data);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
server.registerTool('get_node_snapshot', {
|
|
520
|
+
description: 'Read a rich structural snapshot for a specific Figma node id.',
|
|
521
|
+
inputSchema: {
|
|
522
|
+
nodeId: z.string().describe('Figma node id, for example "123:456"'),
|
|
523
|
+
maxDepth: z.number().int().min(0).max(8).default(3).describe('Maximum child depth to include for the node snapshot'),
|
|
524
|
+
},
|
|
525
|
+
}, async ({ nodeId, maxDepth }) => {
|
|
526
|
+
const data = await callBridge('get_node_snapshot', { nodeId, maxDepth });
|
|
527
|
+
return jsonResult(data);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
server.registerTool('find_nodes', {
|
|
531
|
+
description: 'Search the current Figma page for nodes by name substring and optional node type.',
|
|
532
|
+
inputSchema: {
|
|
533
|
+
query: z.string().default('').describe('Case-insensitive name substring to search for'),
|
|
534
|
+
type: z.string().optional().describe('Optional Figma node type to filter, for example FRAME or TEXT'),
|
|
535
|
+
limit: z.number().int().min(1).max(200).default(25).describe('Maximum number of matching nodes to return'),
|
|
536
|
+
maxDepth: z.number().int().min(0).max(2).default(0).describe('Maximum child depth to include for each match'),
|
|
537
|
+
},
|
|
538
|
+
}, async ({ query, type, limit, maxDepth }) => {
|
|
539
|
+
const data = await callBridge('find_nodes', { query, type, limit, maxDepth });
|
|
540
|
+
return jsonResult(data);
|
|
541
|
+
});
|
|
542
|
+
|
|
543
|
+
server.registerTool('get_asset_export_plan', {
|
|
544
|
+
description: 'List vector/icon/image export candidates for the current selection or explicit node ids.',
|
|
545
|
+
inputSchema: {
|
|
546
|
+
nodeIds: z.array(z.string()).optional().describe('Optional list of Figma node ids. If omitted, uses the current selection'),
|
|
547
|
+
nodeId: z.string().optional().describe('Optional single Figma node id'),
|
|
548
|
+
},
|
|
549
|
+
}, async ({ nodeIds, nodeId }) => {
|
|
550
|
+
const data = await callBridge('get_asset_export_plan', { nodeIds, nodeId });
|
|
551
|
+
return jsonResult(data);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
server.registerTool('extract_reusable_components', {
|
|
555
|
+
description: 'Identify repeated Figma components and repeated sibling structures that should become reusable SwiftUI components.',
|
|
556
|
+
inputSchema: {
|
|
557
|
+
nodeIds: z.array(z.string()).optional().describe('Optional list of Figma node ids. If omitted, uses the current selection or current page when scope=page'),
|
|
558
|
+
nodeId: z.string().optional().describe('Optional single Figma node id'),
|
|
559
|
+
scope: z.enum(['selection', 'page']).default('selection').describe('Analyze the current selection or scan the full current page'),
|
|
560
|
+
},
|
|
561
|
+
}, async ({ nodeIds, nodeId, scope }) => {
|
|
562
|
+
const data = await callBridge('extract_reusable_components', { nodeIds, nodeId, scope });
|
|
563
|
+
return jsonResult(data);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
server.registerTool('dump_tree', {
|
|
567
|
+
description: 'Return a readable text tree for the current selection or explicit node ids.',
|
|
568
|
+
inputSchema: {
|
|
569
|
+
nodeIds: z.array(z.string()).optional().describe('Optional list of Figma node ids. If omitted, uses the current selection'),
|
|
570
|
+
},
|
|
571
|
+
}, async ({ nodeIds }) => {
|
|
572
|
+
const data = await callBridge('dump_tree', { nodeIds });
|
|
573
|
+
return jsonResult(data);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
server.registerTool('generate_swiftui', {
|
|
577
|
+
description: 'Run the same SwiftUI export path used by the plugin UI, optionally including image payloads.',
|
|
578
|
+
inputSchema: {
|
|
579
|
+
nodeIds: z.array(z.string()).optional().describe('Optional list of Figma node ids. If omitted, uses the current selection'),
|
|
580
|
+
includeOverflow: z.boolean().default(false).describe('Ignore Figma clipping when generating layout'),
|
|
581
|
+
generationMode: z.enum(['editable', 'fidelity']).default('editable').describe('Editable keeps more native SwiftUI structure; fidelity rasterizes more complex layouts'),
|
|
582
|
+
includeImages: z.boolean().default(false).describe('Include base64 image payloads in the tool result'),
|
|
583
|
+
},
|
|
584
|
+
}, async ({ nodeIds, includeOverflow, generationMode, includeImages }) => {
|
|
585
|
+
if (!includeImages) {
|
|
586
|
+
const hosted = await tryHostedSemanticGeneration({
|
|
587
|
+
nodeIds,
|
|
588
|
+
generationMode,
|
|
589
|
+
includeOverflow,
|
|
590
|
+
analyze: false,
|
|
591
|
+
});
|
|
592
|
+
if (hosted) {
|
|
593
|
+
return jsonResult(hosted);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const data = await callBridge('generate_swiftui', {
|
|
598
|
+
nodeIds,
|
|
599
|
+
includeOverflow,
|
|
600
|
+
generationMode,
|
|
601
|
+
includeImages,
|
|
602
|
+
});
|
|
603
|
+
return jsonResult(data);
|
|
604
|
+
});
|
|
605
|
+
|
|
606
|
+
server.registerTool('analyze_generation', {
|
|
607
|
+
description: 'Return the generated SwiftUI plus per-node diagnostics and rasterization reasons for the current selection or explicit node ids.',
|
|
608
|
+
inputSchema: {
|
|
609
|
+
nodeIds: z.array(z.string()).optional().describe('Optional list of Figma node ids. If omitted, uses the current selection'),
|
|
610
|
+
includeOverflow: z.boolean().default(false).describe('Ignore Figma clipping when generating layout'),
|
|
611
|
+
generationMode: z.enum(['editable', 'fidelity']).default('editable').describe('Editable keeps more native SwiftUI structure; fidelity rasterizes more complex layouts'),
|
|
612
|
+
maxDepth: z.number().int().min(0).max(8).default(3).describe('Maximum child depth to include in the diagnostic node snapshot'),
|
|
613
|
+
},
|
|
614
|
+
}, async ({ nodeIds, includeOverflow, generationMode, maxDepth }) => {
|
|
615
|
+
const hosted = await tryHostedSemanticGeneration({
|
|
616
|
+
nodeIds,
|
|
617
|
+
generationMode,
|
|
618
|
+
includeOverflow,
|
|
619
|
+
analyze: true,
|
|
620
|
+
});
|
|
621
|
+
if (hosted) {
|
|
622
|
+
return jsonResult(hosted);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
const data = await callBridge('analyze_generation', {
|
|
626
|
+
nodeIds,
|
|
627
|
+
includeOverflow,
|
|
628
|
+
generationMode,
|
|
629
|
+
maxDepth,
|
|
630
|
+
});
|
|
631
|
+
return jsonResult(data);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
server.registerTool('write_generated_swiftui_to_xcode', {
|
|
635
|
+
description: 'Write generated SwiftUI code and optional exported images directly into the configured Xcode project.',
|
|
636
|
+
inputSchema: {
|
|
637
|
+
code: z.string().describe('SwiftUI source code to write'),
|
|
638
|
+
structName: z.string().optional().describe('Optional Swift struct name; if omitted it will be inferred from the code'),
|
|
639
|
+
projectPath: z.string().optional().describe('Optional Xcode source folder override'),
|
|
640
|
+
images: z.array(z.object({
|
|
641
|
+
name: z.string(),
|
|
642
|
+
base64: z.string(),
|
|
643
|
+
})).default([]).describe('Optional exported image payloads'),
|
|
644
|
+
selectionNames: z.array(z.string()).default([]).describe('Optional original Figma selection names for struct inference'),
|
|
645
|
+
},
|
|
646
|
+
}, async ({ code, structName, projectPath, images, selectionNames }) => {
|
|
647
|
+
const targetDir = resolveTargetProjectPath(projectPath);
|
|
648
|
+
const effectiveStructName = inferStructName({ structName, code, selectionNames });
|
|
649
|
+
const result = writeSwiftUIScreen({
|
|
650
|
+
targetDir,
|
|
651
|
+
code,
|
|
652
|
+
structName: effectiveStructName,
|
|
653
|
+
images,
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
if (!result.ok) {
|
|
657
|
+
throw new Error(result.results.errors.join(' | ') || 'Failed to write generated SwiftUI to Xcode');
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
return jsonResult({
|
|
661
|
+
...result,
|
|
662
|
+
structName: effectiveStructName,
|
|
663
|
+
});
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
server.registerTool('write_selection_to_xcode', {
|
|
667
|
+
description: 'Generate SwiftUI from the connected Figma selection and write it directly into the configured Xcode project.',
|
|
668
|
+
inputSchema: {
|
|
669
|
+
nodeIds: z.array(z.string()).optional().describe('Optional list of Figma node ids. If omitted, uses the current selection'),
|
|
670
|
+
includeOverflow: z.boolean().default(false).describe('Ignore Figma clipping when generating layout'),
|
|
671
|
+
generationMode: z.enum(['editable', 'fidelity']).default('editable').describe('Editable keeps more native SwiftUI structure; fidelity rasterizes more complex layouts'),
|
|
672
|
+
projectPath: z.string().optional().describe('Optional Xcode source folder override'),
|
|
673
|
+
},
|
|
674
|
+
}, async ({ nodeIds, includeOverflow, generationMode, projectPath }) => {
|
|
675
|
+
const targetDir = resolveTargetProjectPath(projectPath);
|
|
676
|
+
const generated = await callBridge('generate_swiftui', {
|
|
677
|
+
nodeIds,
|
|
678
|
+
includeOverflow,
|
|
679
|
+
generationMode,
|
|
680
|
+
includeImages: true,
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
const effectiveStructName = inferStructName({
|
|
684
|
+
code: generated.code,
|
|
685
|
+
selectionNames: generated.selection?.names ?? [],
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
const result = writeSwiftUIScreen({
|
|
689
|
+
targetDir,
|
|
690
|
+
code: generated.code,
|
|
691
|
+
structName: effectiveStructName,
|
|
692
|
+
images: Array.isArray(generated.images) ? generated.images : [],
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
if (!result.ok) {
|
|
696
|
+
throw new Error(result.results.errors.join(' | ') || 'Failed to write generated selection to Xcode');
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
return jsonResult({
|
|
700
|
+
...result,
|
|
701
|
+
structName: effectiveStructName,
|
|
702
|
+
selection: generated.selection ?? null,
|
|
703
|
+
diagnostics: generated.diagnostics ?? [],
|
|
704
|
+
});
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
async function shutdownBridgeAndExit(message, exitCode = 1) {
|
|
708
|
+
console.error(message);
|
|
709
|
+
if (authValidationTimer) {
|
|
710
|
+
clearInterval(authValidationTimer);
|
|
711
|
+
authValidationTimer = null;
|
|
712
|
+
}
|
|
713
|
+
if (bridgeRuntimeHandle && typeof bridgeRuntimeHandle.close === 'function' && !bridgeRuntimeHandle.alreadyRunning) {
|
|
714
|
+
try {
|
|
715
|
+
await bridgeRuntimeHandle.close();
|
|
716
|
+
} catch (error) {
|
|
717
|
+
console.error('[figma-swiftui-mcp] Failed to close bridge cleanly:', error.message);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
process.exit(exitCode);
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function startAuthValidationLoop() {
|
|
724
|
+
if (authValidationTimer) {
|
|
725
|
+
clearInterval(authValidationTimer);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
authValidationTimer = setInterval(async () => {
|
|
729
|
+
try {
|
|
730
|
+
const validation = await validateRuntimeSession(runtimeAuthState);
|
|
731
|
+
if (!validation.valid) {
|
|
732
|
+
await shutdownBridgeAndExit(`[figma-swiftui-mcp] Authorization lost: ${validation.error}`);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
runtimeAuthState = {
|
|
737
|
+
...runtimeAuthState,
|
|
738
|
+
...validation.session,
|
|
739
|
+
authorized: true,
|
|
740
|
+
};
|
|
741
|
+
|
|
742
|
+
if (validation.refreshed) {
|
|
743
|
+
console.error('[figma-swiftui-mcp] Refreshed CreateLex MCP authorization');
|
|
744
|
+
}
|
|
745
|
+
} catch (error) {
|
|
746
|
+
await shutdownBridgeAndExit(`[figma-swiftui-mcp] Authorization revalidation failed: ${error.message}`);
|
|
747
|
+
}
|
|
748
|
+
}, AUTH_REVALIDATION_INTERVAL_MS);
|
|
749
|
+
|
|
750
|
+
authValidationTimer.unref?.();
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
async function main() {
|
|
754
|
+
runtimeAuthState = await authorizeRuntimeStartup();
|
|
755
|
+
console.error(
|
|
756
|
+
runtimeAuthState.bypass
|
|
757
|
+
? '[figma-swiftui-mcp] Authorization bypass enabled'
|
|
758
|
+
: `[figma-swiftui-mcp] Authorized CreateLex user ${runtimeAuthState.email || runtimeAuthState.userId || 'unknown-user'}`
|
|
759
|
+
);
|
|
760
|
+
|
|
761
|
+
const bridgeHttpUrl = new URL(BRIDGE_HTTP_URL);
|
|
762
|
+
bridgeRuntimeHandle = await startBridgeServer({
|
|
763
|
+
host: bridgeHttpUrl.hostname,
|
|
764
|
+
port: Number(bridgeHttpUrl.port || (bridgeHttpUrl.protocol === 'https:' ? 443 : 80)),
|
|
765
|
+
projectPath: readProjectPathArg(process.argv),
|
|
766
|
+
logger: {
|
|
767
|
+
info: console.error,
|
|
768
|
+
warn: console.error,
|
|
769
|
+
error: console.error,
|
|
770
|
+
},
|
|
771
|
+
});
|
|
772
|
+
|
|
773
|
+
try {
|
|
774
|
+
await connectBridge();
|
|
775
|
+
} catch (error) {
|
|
776
|
+
console.error('[figma-swiftui-mcp] Bridge connect failed on startup:', error.message);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const transport = new StdioServerTransport();
|
|
780
|
+
await server.connect(transport);
|
|
781
|
+
startAuthValidationLoop();
|
|
782
|
+
console.error('[figma-swiftui-mcp] MCP server running on stdio');
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
main().catch((error) => {
|
|
786
|
+
console.error('[figma-swiftui-mcp] Server error:', error);
|
|
787
|
+
process.exit(1);
|
|
788
|
+
});
|