@seed-design/mcp 1.2.1 → 1.3.1
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 +1 -28
- package/bin/index.mjs +701 -363
- package/dist/index.d.ts +46 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +537 -0
- package/package.json +13 -5
- package/src/bin/index.ts +147 -207
- package/src/bin/websocket-server.ts +196 -0
- package/src/figma-rest-client.ts +55 -0
- package/src/index.ts +4 -0
- package/src/tools-helpers.ts +113 -0
- package/src/tools.ts +455 -266
package/bin/index.mjs
CHANGED
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
import { cac } from 'cac';
|
|
3
3
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
4
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { Api } from 'figma-api';
|
|
5
8
|
import { v4 } from 'uuid';
|
|
6
9
|
import WebSocket$1 from 'ws';
|
|
7
10
|
import { createRestNormalizer, figma, react, getFigmaColorVariableNames } from '@seed-design/figma';
|
|
8
11
|
import { z } from 'zod';
|
|
9
|
-
|
|
10
|
-
|
|
12
|
+
|
|
13
|
+
var version = "1.3.1";
|
|
11
14
|
|
|
12
15
|
/**
|
|
13
16
|
* Custom logging module that writes to stderr instead of stdout
|
|
@@ -38,6 +41,76 @@ import path from 'node:path';
|
|
|
38
41
|
return String(error);
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
// Config loader
|
|
45
|
+
async function loadConfig(configPath) {
|
|
46
|
+
try {
|
|
47
|
+
const resolvedPath = path.resolve(process.cwd(), configPath);
|
|
48
|
+
if (!fs.existsSync(resolvedPath)) {
|
|
49
|
+
logger.error(`Config file not found: ${resolvedPath}`);
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
// Handle different file types
|
|
53
|
+
if (resolvedPath.endsWith(".json")) {
|
|
54
|
+
const content = fs.readFileSync(resolvedPath, "utf-8");
|
|
55
|
+
return JSON.parse(content);
|
|
56
|
+
}
|
|
57
|
+
if (resolvedPath.endsWith(".js") || resolvedPath.endsWith(".mjs") || resolvedPath.endsWith(".ts") || resolvedPath.endsWith(".mts")) {
|
|
58
|
+
// For JS/MJS/TS/MTS files, we can dynamically import with Bun
|
|
59
|
+
// Bun has built-in TypeScript support without requiring transpilation
|
|
60
|
+
const config = await import(resolvedPath);
|
|
61
|
+
return config.default || config;
|
|
62
|
+
}
|
|
63
|
+
logger.error(`Unsupported config file format: ${resolvedPath}`);
|
|
64
|
+
return null;
|
|
65
|
+
} catch (error) {
|
|
66
|
+
logger.error(`Failed to load config file: ${error instanceof Error ? error.message : String(error)}`);
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function createFigmaRestClient(personalAccessToken) {
|
|
72
|
+
const api = new Api({
|
|
73
|
+
personalAccessToken
|
|
74
|
+
});
|
|
75
|
+
return {
|
|
76
|
+
async getFileNodes (fileKey, nodeIds) {
|
|
77
|
+
const response = await api.getFileNodes({
|
|
78
|
+
file_key: fileKey
|
|
79
|
+
}, {
|
|
80
|
+
ids: nodeIds.join(",")
|
|
81
|
+
});
|
|
82
|
+
return response;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* https://www.figma.com/:file_type/:file_key/:file_name?node-id=:id
|
|
88
|
+
*
|
|
89
|
+
* file_type:
|
|
90
|
+
* - design
|
|
91
|
+
* - file (legacy)
|
|
92
|
+
*
|
|
93
|
+
* Note: While node-id is separated by hyphens ('-') in the URL,
|
|
94
|
+
* it must be converted to colons (':') when making API calls.
|
|
95
|
+
* e.g. URL "node-id=794-1987" → API "794:1987"
|
|
96
|
+
*/ function parseFigmaUrl(url) {
|
|
97
|
+
const __url = (()=>{
|
|
98
|
+
try {
|
|
99
|
+
return new URL(url);
|
|
100
|
+
} catch {
|
|
101
|
+
throw new Error(`Invalid URL format: ${url}`);
|
|
102
|
+
}
|
|
103
|
+
})();
|
|
104
|
+
const pathMatch = __url.pathname.match(/^\/(design|file)\/([A-Za-z0-9]+)/);
|
|
105
|
+
const rawNodeId = __url.searchParams.get("node-id");
|
|
106
|
+
if (!pathMatch) throw new Error("Invalid Figma URL: Expected format https://www.figma.com/design/{fileKey}/... or /file/{fileKey}/...");
|
|
107
|
+
if (!rawNodeId) throw new Error("Invalid Figma URL: Missing node-id query parameter");
|
|
108
|
+
return {
|
|
109
|
+
fileKey: pathMatch[2],
|
|
110
|
+
nodeId: rawNodeId.replace(/-/g, ":")
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
41
114
|
function createFigmaWebSocketClient(serverUrl) {
|
|
42
115
|
const WS_URL = serverUrl === "localhost" ? `ws://${serverUrl}` : `wss://${serverUrl}`;
|
|
43
116
|
// Track which channel each client is in
|
|
@@ -263,85 +336,158 @@ function createFigmaWebSocketClient(serverUrl) {
|
|
|
263
336
|
};
|
|
264
337
|
}
|
|
265
338
|
|
|
266
|
-
function
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
}, async ({ annotations })=>{
|
|
315
|
-
try {
|
|
316
|
-
await sendCommandToFigma("add_annotations", {
|
|
317
|
-
annotations
|
|
318
|
-
});
|
|
319
|
-
return formatTextResponse(`Annotations added to nodes ${annotations.map((annotation)=>annotation.nodeId).join(", ")}`);
|
|
320
|
-
} catch (error) {
|
|
321
|
-
return formatErrorResponse("add_annotations", error);
|
|
322
|
-
}
|
|
323
|
-
});
|
|
324
|
-
// Get Annotations Tool
|
|
325
|
-
server.tool("get_annotations", "Get annotations for a specific node in Figma", {
|
|
326
|
-
nodeId: z.string().describe("The ID of the node to get annotations for")
|
|
327
|
-
}, async ({ nodeId })=>{
|
|
328
|
-
try {
|
|
329
|
-
const result = await sendCommandToFigma("get_annotations", {
|
|
339
|
+
function createToolContext(figmaClient, restClient, config, mode) {
|
|
340
|
+
return {
|
|
341
|
+
sendCommandToFigma: figmaClient?.sendCommandToFigma ?? null,
|
|
342
|
+
restClient,
|
|
343
|
+
mode,
|
|
344
|
+
extend: config?.extend
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
function resolveRestClient(personalAccessToken, context) {
|
|
348
|
+
if (context.mode === "websocket") {
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
351
|
+
if (personalAccessToken) {
|
|
352
|
+
return createFigmaRestClient(personalAccessToken);
|
|
353
|
+
}
|
|
354
|
+
return context.restClient;
|
|
355
|
+
}
|
|
356
|
+
async function fetchNodeData(params, context) {
|
|
357
|
+
const { fileKey, nodeId, personalAccessToken } = params;
|
|
358
|
+
const restClient = resolveRestClient(personalAccessToken, context);
|
|
359
|
+
const { sendCommandToFigma } = context;
|
|
360
|
+
if (restClient && fileKey) {
|
|
361
|
+
const response = await restClient.getFileNodes(fileKey, [
|
|
362
|
+
nodeId
|
|
363
|
+
]);
|
|
364
|
+
const nodeData = response.nodes[nodeId];
|
|
365
|
+
if (!nodeData) throw new Error(`Node ${nodeId} not found in file ${fileKey}`);
|
|
366
|
+
return nodeData;
|
|
367
|
+
}
|
|
368
|
+
if (sendCommandToFigma) {
|
|
369
|
+
return await sendCommandToFigma("get_node_info", {
|
|
370
|
+
nodeId
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
throw new Error("No connection available. Provide figmaUrl/fileKey with personalAccessToken or FIGMA_PERSONAL_ACCESS_TOKEN, or use WebSocket mode with Figma Plugin.");
|
|
374
|
+
}
|
|
375
|
+
async function fetchMultipleNodesData(params, context) {
|
|
376
|
+
const { fileKey, nodeIds, personalAccessToken } = params;
|
|
377
|
+
const restClient = resolveRestClient(personalAccessToken, context);
|
|
378
|
+
const { sendCommandToFigma } = context;
|
|
379
|
+
if (restClient && fileKey) {
|
|
380
|
+
const response = await restClient.getFileNodes(fileKey, nodeIds);
|
|
381
|
+
return response.nodes;
|
|
382
|
+
}
|
|
383
|
+
if (sendCommandToFigma) {
|
|
384
|
+
const results = {};
|
|
385
|
+
await Promise.all(nodeIds.map(async (nodeId)=>{
|
|
386
|
+
const data = await sendCommandToFigma("get_node_info", {
|
|
330
387
|
nodeId
|
|
331
388
|
});
|
|
332
|
-
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
389
|
+
results[nodeId] = data;
|
|
390
|
+
}));
|
|
391
|
+
return results;
|
|
392
|
+
}
|
|
393
|
+
throw new Error("No connection available. Provide figmaUrl/fileKey with personalAccessToken or FIGMA_PERSONAL_ACCESS_TOKEN, or use WebSocket mode with Figma Plugin.");
|
|
394
|
+
}
|
|
395
|
+
function requireWebSocket(context) {
|
|
396
|
+
if (!context.sendCommandToFigma) throw new Error("WebSocket not available. This tool requires Figma Plugin connection.");
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const singleNodeBaseSchema = z.object({
|
|
400
|
+
figmaUrl: z.url().optional().describe("Figma node URL. Extracts fileKey and nodeId automatically when provided."),
|
|
401
|
+
fileKey: z.string().optional().describe("Figma file key. Use with nodeId when not using figmaUrl."),
|
|
402
|
+
nodeId: z.string().optional().describe("Node ID (e.g., '0:1')."),
|
|
403
|
+
personalAccessToken: z.string().optional().describe("Figma PAT. Falls back to FIGMA_PERSONAL_ACCESS_TOKEN env when not provided.")
|
|
404
|
+
});
|
|
405
|
+
const multiNodeBaseSchema = z.object({
|
|
406
|
+
fileKey: z.string().optional().describe("Figma file key. Required when WebSocket connection is not available."),
|
|
407
|
+
nodeIds: z.array(z.string()).describe("Array of node IDs (e.g., ['0:1', '0:2'])"),
|
|
408
|
+
personalAccessToken: z.string().optional().describe("Figma PAT. Falls back to FIGMA_PERSONAL_ACCESS_TOKEN env when not provided to be used when WebSocket connection is not available.")
|
|
409
|
+
});
|
|
410
|
+
function getSingleNodeParamsSchema(mode) {
|
|
411
|
+
switch(mode){
|
|
412
|
+
case "websocket":
|
|
413
|
+
return singleNodeBaseSchema.pick({
|
|
414
|
+
nodeId: true
|
|
415
|
+
}).required();
|
|
416
|
+
default:
|
|
417
|
+
return singleNodeBaseSchema;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
function getMultiNodeParamsSchema(mode) {
|
|
421
|
+
switch(mode){
|
|
422
|
+
case "websocket":
|
|
423
|
+
return multiNodeBaseSchema.pick({
|
|
424
|
+
nodeIds: true
|
|
425
|
+
});
|
|
426
|
+
case "rest":
|
|
427
|
+
return multiNodeBaseSchema.required({
|
|
428
|
+
fileKey: true
|
|
344
429
|
});
|
|
430
|
+
default:
|
|
431
|
+
return multiNodeBaseSchema;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function resolveSingleNodeParams(params) {
|
|
435
|
+
if (params.figmaUrl) {
|
|
436
|
+
const parsed = parseFigmaUrl(params.figmaUrl);
|
|
437
|
+
return {
|
|
438
|
+
fileKey: parsed.fileKey,
|
|
439
|
+
nodeId: parsed.nodeId,
|
|
440
|
+
personalAccessToken: params.personalAccessToken
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
if (!params.nodeId) {
|
|
444
|
+
throw new Error("Either figmaUrl or nodeId must be provided. Use figmaUrl for automatic parsing, or provide fileKey + nodeId directly.");
|
|
445
|
+
}
|
|
446
|
+
return {
|
|
447
|
+
fileKey: params.fileKey,
|
|
448
|
+
nodeId: params.nodeId,
|
|
449
|
+
personalAccessToken: params.personalAccessToken
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
function getSingleNodeDescription(baseDescription, mode) {
|
|
453
|
+
switch(mode){
|
|
454
|
+
case "rest":
|
|
455
|
+
return `${baseDescription} Provide either: (1) figmaUrl (e.g., https://www.figma.com/design/ABC/Name?node-id=0-1), or (2) fileKey + nodeId.`;
|
|
456
|
+
case "websocket":
|
|
457
|
+
return `${baseDescription} Provide nodeId. Requires WebSocket connection with Figma Plugin.`;
|
|
458
|
+
case "all":
|
|
459
|
+
return `${baseDescription} Provide either: (1) figmaUrl (e.g., https://www.figma.com/design/ABC/Name?node-id=0-1), (2) fileKey + nodeId, or (3) nodeId only for WebSocket mode.`;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
function getMultiNodeDescription(baseDescription, mode) {
|
|
463
|
+
switch(mode){
|
|
464
|
+
case "rest":
|
|
465
|
+
return `${baseDescription} Provide fileKey + nodeIds.`;
|
|
466
|
+
case "websocket":
|
|
467
|
+
return `${baseDescription} Provide nodeIds. Requires WebSocket connection with Figma Plugin.`;
|
|
468
|
+
case "all":
|
|
469
|
+
return `${baseDescription} Provide either: (1) fileKey + nodeIds for REST API, or (2) nodeIds only for WebSocket mode. If you have multiple URLs, call get_node_info for each URL instead.`;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
function registerTools(server, figmaClient, restClient, config, mode) {
|
|
473
|
+
const context = createToolContext(figmaClient, restClient, config, mode);
|
|
474
|
+
const singleNodeParamsSchema = getSingleNodeParamsSchema(mode);
|
|
475
|
+
const multiNodeParamsSchema = getMultiNodeParamsSchema(mode);
|
|
476
|
+
const shouldRegisterWebSocketOnlyTools = mode === "websocket" || mode === "all";
|
|
477
|
+
// REST API + WebSocket Tools (hybrid)
|
|
478
|
+
// These tools support both REST API and WebSocket modes
|
|
479
|
+
// Component Info Tool (REST API + WebSocket)
|
|
480
|
+
server.registerTool("get_component_info", {
|
|
481
|
+
description: getSingleNodeDescription("Get detailed information about a specific component node in Figma.", mode),
|
|
482
|
+
inputSchema: singleNodeParamsSchema
|
|
483
|
+
}, async (params)=>{
|
|
484
|
+
try {
|
|
485
|
+
const { fileKey, nodeId, personalAccessToken } = resolveSingleNodeParams(params);
|
|
486
|
+
const result = await fetchNodeData({
|
|
487
|
+
fileKey,
|
|
488
|
+
nodeId,
|
|
489
|
+
personalAccessToken
|
|
490
|
+
}, context);
|
|
345
491
|
const node = result.document;
|
|
346
492
|
if (node.type !== "COMPONENT" && node.type !== "COMPONENT_SET") {
|
|
347
493
|
return formatErrorResponse("get_component_info", new Error(`Node with ID ${nodeId} is not a component node`));
|
|
@@ -356,17 +502,21 @@ function registerTools(server, figmaClient, config = {}) {
|
|
|
356
502
|
componentPropertyDefinitions: node.componentPropertyDefinitions
|
|
357
503
|
});
|
|
358
504
|
} catch (error) {
|
|
359
|
-
return formatErrorResponse("
|
|
505
|
+
return formatErrorResponse("get_component_info", error);
|
|
360
506
|
}
|
|
361
507
|
});
|
|
362
|
-
// Node Info Tool
|
|
363
|
-
server.
|
|
364
|
-
|
|
365
|
-
|
|
508
|
+
// Node Info Tool (REST API + WebSocket)
|
|
509
|
+
server.registerTool("get_node_info", {
|
|
510
|
+
description: getSingleNodeDescription("Get detailed information about a specific node in Figma.", mode),
|
|
511
|
+
inputSchema: singleNodeParamsSchema
|
|
512
|
+
}, async (params)=>{
|
|
366
513
|
try {
|
|
367
|
-
const
|
|
368
|
-
|
|
369
|
-
|
|
514
|
+
const { fileKey, nodeId, personalAccessToken } = resolveSingleNodeParams(params);
|
|
515
|
+
const result = await fetchNodeData({
|
|
516
|
+
fileKey,
|
|
517
|
+
nodeId,
|
|
518
|
+
personalAccessToken
|
|
519
|
+
}, context);
|
|
370
520
|
const normalizer = createRestNormalizer(result);
|
|
371
521
|
const node = normalizer(result.document);
|
|
372
522
|
const noInferPipeline = figma.createPipeline({
|
|
@@ -390,24 +540,37 @@ function registerTools(server, figmaClient, config = {}) {
|
|
|
390
540
|
},
|
|
391
541
|
inferred: {
|
|
392
542
|
data: inferred,
|
|
393
|
-
description: "AutoLayout Inferred Figma node info
|
|
543
|
+
description: "AutoLayout Inferred Figma node info"
|
|
394
544
|
}
|
|
395
545
|
});
|
|
396
546
|
} catch (error) {
|
|
397
|
-
return formatTextResponse(`Error in get_node_info: ${formatError(error)}\n\n⚠️
|
|
547
|
+
return formatTextResponse(`Error in get_node_info: ${formatError(error)}\n\n⚠️ Please make sure you have the latest version of the Figma library.`);
|
|
398
548
|
}
|
|
399
549
|
});
|
|
400
|
-
// Nodes Info Tool
|
|
401
|
-
server.
|
|
402
|
-
|
|
403
|
-
|
|
550
|
+
// Nodes Info Tool (REST API + WebSocket)
|
|
551
|
+
server.registerTool("get_nodes_info", {
|
|
552
|
+
description: getMultiNodeDescription("Get detailed information about multiple nodes in Figma.", mode),
|
|
553
|
+
inputSchema: multiNodeParamsSchema
|
|
554
|
+
}, async ({ fileKey, nodeIds, personalAccessToken })=>{
|
|
404
555
|
try {
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
556
|
+
if (nodeIds.length === 0) {
|
|
557
|
+
return formatErrorResponse("get_nodes_info", new Error("No node IDs provided"));
|
|
558
|
+
}
|
|
559
|
+
const nodesData = await fetchMultipleNodesData({
|
|
560
|
+
fileKey,
|
|
561
|
+
nodeIds,
|
|
562
|
+
personalAccessToken
|
|
563
|
+
}, context);
|
|
564
|
+
const results = nodeIds.map((nodeId)=>{
|
|
565
|
+
const nodeData = nodesData[nodeId];
|
|
566
|
+
if (!nodeData) {
|
|
567
|
+
return {
|
|
568
|
+
nodeId,
|
|
569
|
+
error: `Node ${nodeId} not found`
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
const normalizer = createRestNormalizer(nodeData);
|
|
573
|
+
const node = normalizer(nodeData.document);
|
|
411
574
|
const noInferPipeline = figma.createPipeline({
|
|
412
575
|
shouldInferAutoLayout: false,
|
|
413
576
|
shouldInferVariableName: false
|
|
@@ -430,70 +593,56 @@ function registerTools(server, figmaClient, config = {}) {
|
|
|
430
593
|
},
|
|
431
594
|
inferred: {
|
|
432
595
|
data: inferred,
|
|
433
|
-
description: "AutoLayout Inferred Figma node info
|
|
596
|
+
description: "AutoLayout Inferred Figma node info"
|
|
434
597
|
}
|
|
435
598
|
};
|
|
436
|
-
})
|
|
599
|
+
});
|
|
437
600
|
return formatObjectResponse(results);
|
|
438
601
|
} catch (error) {
|
|
439
|
-
return formatTextResponse(`Error in get_nodes_info: ${formatError(error)}\n\n⚠️
|
|
602
|
+
return formatTextResponse(`Error in get_nodes_info: ${formatError(error)}\n\n⚠️ Please make sure you have the latest version of the Figma library.`);
|
|
440
603
|
}
|
|
441
604
|
});
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
605
|
+
// Get Node React Code Tool (REST API + WebSocket)
|
|
606
|
+
server.registerTool("get_node_react_code", {
|
|
607
|
+
description: getSingleNodeDescription("Get the React code for a specific node in Figma.", mode),
|
|
608
|
+
inputSchema: singleNodeParamsSchema
|
|
609
|
+
}, async (params)=>{
|
|
445
610
|
try {
|
|
446
|
-
const
|
|
447
|
-
|
|
448
|
-
|
|
611
|
+
const { fileKey, nodeId, personalAccessToken } = resolveSingleNodeParams(params);
|
|
612
|
+
const result = await fetchNodeData({
|
|
613
|
+
fileKey,
|
|
614
|
+
nodeId,
|
|
615
|
+
personalAccessToken
|
|
616
|
+
}, context);
|
|
449
617
|
const normalizer = createRestNormalizer(result);
|
|
450
618
|
const pipeline = react.createPipeline({
|
|
451
619
|
shouldInferAutoLayout: true,
|
|
452
620
|
shouldInferVariableName: true,
|
|
453
|
-
extend
|
|
621
|
+
extend: context.extend
|
|
454
622
|
});
|
|
455
623
|
const generated = pipeline.generateCode(normalizer(result.document), {
|
|
456
624
|
shouldPrintSource: false
|
|
457
625
|
});
|
|
458
626
|
if (!generated) {
|
|
459
|
-
return formatTextResponse("Failed to generate code\n\n⚠️
|
|
627
|
+
return formatTextResponse("Failed to generate code\n\n⚠️ Please make sure you have the latest version of the Figma library.");
|
|
460
628
|
}
|
|
461
629
|
return formatTextResponse(`${generated.imports}\n\n${generated.jsx}`);
|
|
462
630
|
} catch (error) {
|
|
463
|
-
return formatTextResponse(`Error in get_node_react_code: ${formatError(error)}\n\n⚠️
|
|
464
|
-
}
|
|
465
|
-
});
|
|
466
|
-
// Export Node as Image Tool
|
|
467
|
-
server.tool("export_node_as_image", "Export a node as an image from Figma", {
|
|
468
|
-
nodeId: z.string().describe("The ID of the node to export"),
|
|
469
|
-
format: z.enum([
|
|
470
|
-
"PNG",
|
|
471
|
-
"JPG",
|
|
472
|
-
"SVG",
|
|
473
|
-
"PDF"
|
|
474
|
-
]).optional().describe("Export format"),
|
|
475
|
-
scale: z.number().positive().optional().describe("Export scale")
|
|
476
|
-
}, async ({ nodeId, format, scale })=>{
|
|
477
|
-
try {
|
|
478
|
-
const result = await sendCommandToFigma("export_node_as_image", {
|
|
479
|
-
nodeId,
|
|
480
|
-
format: format || "PNG",
|
|
481
|
-
scale: scale || 1
|
|
482
|
-
});
|
|
483
|
-
const typedResult = result;
|
|
484
|
-
return formatImageResponse(typedResult.base64, typedResult.mimeType || "image/png");
|
|
485
|
-
} catch (error) {
|
|
486
|
-
return formatErrorResponse("export_node_as_image", error);
|
|
631
|
+
return formatTextResponse(`Error in get_node_react_code: ${formatError(error)}\n\n⚠️ Please make sure you have the latest version of the Figma library.`);
|
|
487
632
|
}
|
|
488
633
|
});
|
|
634
|
+
// Utility Tools (No Figma connection required)
|
|
489
635
|
// Retrieve Color Variable Names Tool
|
|
490
|
-
server.
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
636
|
+
server.registerTool("retrieve_color_variable_names", {
|
|
637
|
+
description: "Retrieve available SEED Design color variable names by scope. No Figma connection required.",
|
|
638
|
+
inputSchema: z.object({
|
|
639
|
+
scope: z.enum([
|
|
640
|
+
"fg",
|
|
641
|
+
"bg",
|
|
642
|
+
"stroke",
|
|
643
|
+
"palette"
|
|
644
|
+
]).array().describe("The scope of the color variable names to retrieve")
|
|
645
|
+
})
|
|
497
646
|
}, async ({ scope })=>{
|
|
498
647
|
try {
|
|
499
648
|
const result = getFigmaColorVariableNames(scope);
|
|
@@ -502,14 +651,131 @@ function registerTools(server, figmaClient, config = {}) {
|
|
|
502
651
|
return formatErrorResponse("retrieve_color_variable_names", error);
|
|
503
652
|
}
|
|
504
653
|
});
|
|
654
|
+
if (shouldRegisterWebSocketOnlyTools) {
|
|
655
|
+
// WebSocket Only Tools
|
|
656
|
+
server.registerTool("join_channel", {
|
|
657
|
+
description: "Join a specific channel to communicate with Figma (WebSocket mode only)",
|
|
658
|
+
inputSchema: z.object({
|
|
659
|
+
channel: z.string().describe("The name of the channel to join").default("")
|
|
660
|
+
})
|
|
661
|
+
}, async ({ channel })=>{
|
|
662
|
+
try {
|
|
663
|
+
if (!figmaClient) return formatErrorResponse("join_channel", new Error("WebSocket not available. This tool requires Figma Plugin connection."));
|
|
664
|
+
if (!channel) // If no channel provided, ask the user for input
|
|
665
|
+
return {
|
|
666
|
+
...formatTextResponse("Please provide a channel name to join:"),
|
|
667
|
+
followUp: {
|
|
668
|
+
tool: "join_channel",
|
|
669
|
+
description: "Join the specified channel"
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
await figmaClient.joinChannel(channel);
|
|
673
|
+
return formatTextResponse(`Successfully joined channel: ${channel}`);
|
|
674
|
+
} catch (error) {
|
|
675
|
+
return formatErrorResponse("join_channel", error);
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
// Document Info Tool
|
|
679
|
+
server.registerTool("get_document_info", {
|
|
680
|
+
description: "Get detailed information about the current Figma document (WebSocket mode only)"
|
|
681
|
+
}, async ()=>{
|
|
682
|
+
try {
|
|
683
|
+
requireWebSocket(context);
|
|
684
|
+
const result = await context.sendCommandToFigma("get_document_info");
|
|
685
|
+
return formatObjectResponse(result);
|
|
686
|
+
} catch (error) {
|
|
687
|
+
return formatErrorResponse("get_document_info", error);
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
// Selection Tool
|
|
691
|
+
server.registerTool("get_selection", {
|
|
692
|
+
description: "Get information about the current selection in Figma (WebSocket mode only)"
|
|
693
|
+
}, async ()=>{
|
|
694
|
+
try {
|
|
695
|
+
requireWebSocket(context);
|
|
696
|
+
const result = await context.sendCommandToFigma("get_selection");
|
|
697
|
+
return formatObjectResponse(result);
|
|
698
|
+
} catch (error) {
|
|
699
|
+
return formatErrorResponse("get_selection", error);
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
// Annotation Tool
|
|
703
|
+
server.registerTool("add_annotations", {
|
|
704
|
+
description: "Add annotations to multiple nodes in Figma (WebSocket mode only)",
|
|
705
|
+
inputSchema: z.object({
|
|
706
|
+
annotations: z.array(z.object({
|
|
707
|
+
nodeId: z.string().describe("The ID of the node to add an annotation to"),
|
|
708
|
+
labelMarkdown: z.string().describe("The markdown label for the annotation, do not escape newlines")
|
|
709
|
+
}))
|
|
710
|
+
})
|
|
711
|
+
}, async ({ annotations })=>{
|
|
712
|
+
try {
|
|
713
|
+
requireWebSocket(context);
|
|
714
|
+
await context.sendCommandToFigma("add_annotations", {
|
|
715
|
+
annotations
|
|
716
|
+
});
|
|
717
|
+
return formatTextResponse(`Annotations added to nodes ${annotations.map((annotation)=>annotation.nodeId).join(", ")}`);
|
|
718
|
+
} catch (error) {
|
|
719
|
+
return formatErrorResponse("add_annotations", error);
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
// Get Annotations Tool
|
|
723
|
+
server.registerTool("get_annotations", {
|
|
724
|
+
description: "Get annotations for a specific node in Figma (WebSocket mode only)",
|
|
725
|
+
inputSchema: z.object({
|
|
726
|
+
nodeId: z.string().describe("The ID of the node to get annotations for")
|
|
727
|
+
})
|
|
728
|
+
}, async ({ nodeId })=>{
|
|
729
|
+
try {
|
|
730
|
+
requireWebSocket(context);
|
|
731
|
+
const result = await context.sendCommandToFigma("get_annotations", {
|
|
732
|
+
nodeId
|
|
733
|
+
});
|
|
734
|
+
return formatObjectResponse(result);
|
|
735
|
+
} catch (error) {
|
|
736
|
+
return formatErrorResponse("get_annotations", error);
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
// Export Node as Image Tool
|
|
740
|
+
server.registerTool("export_node_as_image", {
|
|
741
|
+
description: "Export a node as an image from Figma (WebSocket mode only)",
|
|
742
|
+
inputSchema: z.object({
|
|
743
|
+
nodeId: z.string().describe("The ID of the node to export"),
|
|
744
|
+
format: z.enum([
|
|
745
|
+
"PNG",
|
|
746
|
+
"JPG",
|
|
747
|
+
"SVG",
|
|
748
|
+
"PDF"
|
|
749
|
+
]).optional().describe("Export format"),
|
|
750
|
+
scale: z.number().positive().optional().describe("Export scale")
|
|
751
|
+
})
|
|
752
|
+
}, async ({ nodeId, format, scale })=>{
|
|
753
|
+
try {
|
|
754
|
+
requireWebSocket(context);
|
|
755
|
+
const result = await context.sendCommandToFigma("export_node_as_image", {
|
|
756
|
+
nodeId,
|
|
757
|
+
format: format || "PNG",
|
|
758
|
+
scale: scale || 1
|
|
759
|
+
});
|
|
760
|
+
const typedResult = result;
|
|
761
|
+
return formatImageResponse(typedResult.base64, typedResult.mimeType || "image/png");
|
|
762
|
+
} catch (error) {
|
|
763
|
+
return formatErrorResponse("export_node_as_image", error);
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
}
|
|
505
767
|
}
|
|
768
|
+
// editing tools require WebSocket client
|
|
506
769
|
function registerEditingTools(server, figmaClient) {
|
|
507
770
|
const { sendCommandToFigma } = figmaClient;
|
|
508
771
|
// Clone Node Tool
|
|
509
|
-
server.
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
772
|
+
server.registerTool("clone_node", {
|
|
773
|
+
description: "Clone an existing node in Figma (WebSocket mode only)",
|
|
774
|
+
inputSchema: z.object({
|
|
775
|
+
nodeId: z.string().describe("The ID of the node to clone"),
|
|
776
|
+
x: z.number().optional().describe("New X position for the clone"),
|
|
777
|
+
y: z.number().optional().describe("New Y position for the clone")
|
|
778
|
+
})
|
|
513
779
|
}, async ({ nodeId, x, y })=>{
|
|
514
780
|
try {
|
|
515
781
|
const result = await sendCommandToFigma("clone_node", {
|
|
@@ -523,9 +789,12 @@ function registerEditingTools(server, figmaClient) {
|
|
|
523
789
|
return formatErrorResponse("clone_node", error);
|
|
524
790
|
}
|
|
525
791
|
});
|
|
526
|
-
server.
|
|
527
|
-
|
|
528
|
-
|
|
792
|
+
server.registerTool("set_fill_color", {
|
|
793
|
+
description: "Set the fill color of a node (WebSocket mode only)",
|
|
794
|
+
inputSchema: z.object({
|
|
795
|
+
nodeId: z.string().describe("The ID of the node to set the fill color of"),
|
|
796
|
+
colorToken: z.string().describe("The color token to set the fill color to. Format: `{category}/{name}`. Example: `bg/brand`")
|
|
797
|
+
})
|
|
529
798
|
}, async ({ nodeId, colorToken })=>{
|
|
530
799
|
try {
|
|
531
800
|
await sendCommandToFigma("set_fill_color", {
|
|
@@ -537,9 +806,12 @@ function registerEditingTools(server, figmaClient) {
|
|
|
537
806
|
return formatErrorResponse("set_fill_color", error);
|
|
538
807
|
}
|
|
539
808
|
});
|
|
540
|
-
server.
|
|
541
|
-
|
|
542
|
-
|
|
809
|
+
server.registerTool("set_stroke_color", {
|
|
810
|
+
description: "Set the stroke color of a node (WebSocket mode only)",
|
|
811
|
+
inputSchema: z.object({
|
|
812
|
+
nodeId: z.string().describe("The ID of the node to set the stroke color of"),
|
|
813
|
+
colorToken: z.string().describe("The color token to set the stroke color to. Format: `{category}/{name}`. Example: `stroke/neutral`")
|
|
814
|
+
})
|
|
543
815
|
}, async ({ nodeId, colorToken })=>{
|
|
544
816
|
try {
|
|
545
817
|
await sendCommandToFigma("set_stroke_color", {
|
|
@@ -551,35 +823,38 @@ function registerEditingTools(server, figmaClient) {
|
|
|
551
823
|
return formatErrorResponse("set_stroke_color", error);
|
|
552
824
|
}
|
|
553
825
|
});
|
|
554
|
-
server.
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
"
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
"
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
"
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
"
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
826
|
+
server.registerTool("set_auto_layout", {
|
|
827
|
+
description: "Set the auto layout of a node (WebSocket mode only)",
|
|
828
|
+
inputSchema: z.object({
|
|
829
|
+
nodeId: z.string().describe("The ID of the node to set the auto layout of"),
|
|
830
|
+
layoutMode: z.enum([
|
|
831
|
+
"HORIZONTAL",
|
|
832
|
+
"VERTICAL"
|
|
833
|
+
]).optional().describe("The layout mode to set"),
|
|
834
|
+
layoutWrap: z.enum([
|
|
835
|
+
"NO_WRAP",
|
|
836
|
+
"WRAP"
|
|
837
|
+
]).optional().describe("The layout wrap to set"),
|
|
838
|
+
primaryAxisAlignItems: z.enum([
|
|
839
|
+
"MIN",
|
|
840
|
+
"MAX",
|
|
841
|
+
"CENTER",
|
|
842
|
+
"SPACE_BETWEEN"
|
|
843
|
+
]).optional().describe("The primary axis align items to set"),
|
|
844
|
+
counterAxisAlignItems: z.enum([
|
|
845
|
+
"MIN",
|
|
846
|
+
"MAX",
|
|
847
|
+
"CENTER",
|
|
848
|
+
"BASELINE"
|
|
849
|
+
]).optional().describe("The counter axis align items to set"),
|
|
850
|
+
itemSpacing: z.number().optional().describe("The item spacing to set"),
|
|
851
|
+
horizontalPadding: z.number().optional().describe("The horizontal padding to set"),
|
|
852
|
+
verticalPadding: z.number().optional().describe("The vertical padding to set"),
|
|
853
|
+
paddingLeft: z.number().optional().describe("The padding left to set (when left != right)"),
|
|
854
|
+
paddingRight: z.number().optional().describe("The padding right to set (when left != right)"),
|
|
855
|
+
paddingTop: z.number().optional().describe("The padding top to set (when top != bottom)"),
|
|
856
|
+
paddingBottom: z.number().optional().describe("The padding bottom to set (when top != bottom)")
|
|
857
|
+
})
|
|
583
858
|
}, async ({ nodeId, layoutMode, layoutWrap, primaryAxisAlignItems, counterAxisAlignItems, itemSpacing, horizontalPadding, verticalPadding, paddingLeft, paddingRight, paddingTop, paddingBottom })=>{
|
|
584
859
|
try {
|
|
585
860
|
await sendCommandToFigma("set_auto_layout", {
|
|
@@ -601,18 +876,21 @@ function registerEditingTools(server, figmaClient) {
|
|
|
601
876
|
return formatErrorResponse("set_auto_layout", error);
|
|
602
877
|
}
|
|
603
878
|
});
|
|
604
|
-
server.
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
"
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
"
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
879
|
+
server.registerTool("set_size", {
|
|
880
|
+
description: "Set the size of a node (WebSocket mode only)",
|
|
881
|
+
inputSchema: z.object({
|
|
882
|
+
nodeId: z.string().describe("The ID of the node to set the size of"),
|
|
883
|
+
layoutSizingHorizontal: z.enum([
|
|
884
|
+
"HUG",
|
|
885
|
+
"FILL"
|
|
886
|
+
]).optional().describe("The horizontal layout sizing to set (exclusive with width)"),
|
|
887
|
+
layoutSizingVertical: z.enum([
|
|
888
|
+
"HUG",
|
|
889
|
+
"FILL"
|
|
890
|
+
]).optional().describe("The vertical layout sizing to set (exclusive with height)"),
|
|
891
|
+
width: z.number().optional().describe("The width to set (raw value)"),
|
|
892
|
+
height: z.number().optional().describe("The height to set (raw value)")
|
|
893
|
+
})
|
|
616
894
|
}, async ({ nodeId, layoutSizingHorizontal, layoutSizingVertical, width, height })=>{
|
|
617
895
|
try {
|
|
618
896
|
await sendCommandToFigma("set_size", {
|
|
@@ -678,63 +956,135 @@ function registerPrompts(server) {
|
|
|
678
956
|
});
|
|
679
957
|
}
|
|
680
958
|
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
// Handle different file types
|
|
692
|
-
if (resolvedPath.endsWith(".json")) {
|
|
693
|
-
const content = fs.readFileSync(resolvedPath, "utf-8");
|
|
694
|
-
return JSON.parse(content);
|
|
959
|
+
const channels = new Map();
|
|
960
|
+
function sendJson(ws, data) {
|
|
961
|
+
ws.send(JSON.stringify(data));
|
|
962
|
+
}
|
|
963
|
+
function broadcastToChannel(channelName, message, excludeWs) {
|
|
964
|
+
const clients = channels.get(channelName);
|
|
965
|
+
if (!clients) return;
|
|
966
|
+
for (const client of clients){
|
|
967
|
+
if (client !== excludeWs && client.readyState === WebSocket.OPEN) {
|
|
968
|
+
sendJson(client, message);
|
|
695
969
|
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
function handleJoin(ws, data) {
|
|
973
|
+
const { channel: channelName, id } = data;
|
|
974
|
+
if (!channelName || typeof channelName !== "string") {
|
|
975
|
+
sendJson(ws, {
|
|
976
|
+
type: "error",
|
|
977
|
+
message: "Channel name is required"
|
|
978
|
+
});
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
if (!channels.has(channelName)) {
|
|
982
|
+
channels.set(channelName, new Set());
|
|
983
|
+
}
|
|
984
|
+
const channelClients = channels.get(channelName);
|
|
985
|
+
if (!channelClients) {
|
|
986
|
+
sendJson(ws, {
|
|
987
|
+
type: "error",
|
|
988
|
+
message: "Failed to join channel"
|
|
989
|
+
});
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
channelClients.add(ws);
|
|
993
|
+
// Notify client they joined successfully
|
|
994
|
+
sendJson(ws, {
|
|
995
|
+
type: "system",
|
|
996
|
+
message: `Joined channel: ${channelName}`,
|
|
997
|
+
channel: channelName
|
|
998
|
+
});
|
|
999
|
+
// Send connection confirmation with ID if provided
|
|
1000
|
+
if (id) {
|
|
1001
|
+
console.log("Sending message to client:", id);
|
|
1002
|
+
sendJson(ws, {
|
|
1003
|
+
type: "system",
|
|
1004
|
+
message: {
|
|
1005
|
+
id,
|
|
1006
|
+
result: `Connected to channel: ${channelName}`
|
|
1007
|
+
},
|
|
1008
|
+
channel: channelName
|
|
1009
|
+
});
|
|
1010
|
+
}
|
|
1011
|
+
// Notify other clients in channel
|
|
1012
|
+
broadcastToChannel(channelName, {
|
|
1013
|
+
type: "system",
|
|
1014
|
+
message: "A new user has joined the channel",
|
|
1015
|
+
channel: channelName
|
|
1016
|
+
}, ws);
|
|
1017
|
+
}
|
|
1018
|
+
function handleMessage(ws, data) {
|
|
1019
|
+
const { channel: channelName, message } = data;
|
|
1020
|
+
if (!channelName || typeof channelName !== "string") {
|
|
1021
|
+
sendJson(ws, {
|
|
1022
|
+
type: "error",
|
|
1023
|
+
message: "Channel name is required"
|
|
1024
|
+
});
|
|
1025
|
+
return;
|
|
1026
|
+
}
|
|
1027
|
+
const channelClients = channels.get(channelName);
|
|
1028
|
+
if (!channelClients?.has(ws)) {
|
|
1029
|
+
sendJson(ws, {
|
|
1030
|
+
type: "error",
|
|
1031
|
+
message: "You must join the channel first"
|
|
1032
|
+
});
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
// Broadcast to all clients in the channel
|
|
1036
|
+
for (const client of channelClients){
|
|
1037
|
+
if (client.readyState === WebSocket.OPEN) {
|
|
1038
|
+
console.log("Broadcasting message to client:", message);
|
|
1039
|
+
sendJson(client, {
|
|
1040
|
+
type: "broadcast",
|
|
1041
|
+
message,
|
|
1042
|
+
sender: client === ws ? "You" : "User",
|
|
1043
|
+
channel: channelName
|
|
1044
|
+
});
|
|
701
1045
|
}
|
|
702
|
-
logger.error(`Unsupported config file format: ${resolvedPath}`);
|
|
703
|
-
return null;
|
|
704
|
-
} catch (error) {
|
|
705
|
-
logger.error(`Failed to load config file: ${error instanceof Error ? error.message : String(error)}`);
|
|
706
|
-
return null;
|
|
707
1046
|
}
|
|
708
1047
|
}
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
const cli = cac("@seed-design/mcp");
|
|
712
|
-
// Store WebSocket clients by channel
|
|
713
|
-
const channels = new Map();
|
|
714
|
-
function handleWebSocketConnection(ws) {
|
|
1048
|
+
// WebSocket Event Handlers
|
|
1049
|
+
function handleConnection(ws) {
|
|
715
1050
|
console.log("New client connected");
|
|
716
|
-
ws
|
|
1051
|
+
sendJson(ws, {
|
|
717
1052
|
type: "system",
|
|
718
1053
|
message: "Please join a channel to start chatting"
|
|
719
|
-
})
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
});
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
function handleWebSocketMessage(ws, rawMessage) {
|
|
1057
|
+
try {
|
|
1058
|
+
console.log("Received message from client:", rawMessage);
|
|
1059
|
+
const data = JSON.parse(rawMessage);
|
|
1060
|
+
switch(data.type){
|
|
1061
|
+
case "join":
|
|
1062
|
+
handleJoin(ws, data);
|
|
1063
|
+
break;
|
|
1064
|
+
case "message":
|
|
1065
|
+
handleMessage(ws, data);
|
|
1066
|
+
break;
|
|
1067
|
+
default:
|
|
1068
|
+
console.warn(`Unknown message type: ${data.type}`);
|
|
1069
|
+
}
|
|
1070
|
+
} catch (err) {
|
|
1071
|
+
console.error("Error handling message:", err);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
function handleClose(ws) {
|
|
1075
|
+
console.log("Client disconnected");
|
|
1076
|
+
for (const [channelName, clients] of channels){
|
|
1077
|
+
if (clients.has(ws)) {
|
|
1078
|
+
clients.delete(ws);
|
|
1079
|
+
broadcastToChannel(channelName, {
|
|
1080
|
+
type: "system",
|
|
1081
|
+
message: "A user has left the channel",
|
|
1082
|
+
channel: channelName
|
|
1083
|
+
});
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
737
1086
|
}
|
|
1087
|
+
// Server
|
|
738
1088
|
async function startWebSocketServer(port) {
|
|
739
1089
|
const server = Bun.serve({
|
|
740
1090
|
port,
|
|
@@ -752,12 +1102,14 @@ async function startWebSocketServer(port) {
|
|
|
752
1102
|
});
|
|
753
1103
|
}
|
|
754
1104
|
// Handle WebSocket upgrade
|
|
755
|
-
|
|
1105
|
+
if (server.upgrade(req, {
|
|
756
1106
|
headers: {
|
|
757
1107
|
"Access-Control-Allow-Origin": "*"
|
|
758
|
-
}
|
|
759
|
-
|
|
760
|
-
|
|
1108
|
+
},
|
|
1109
|
+
data: {}
|
|
1110
|
+
})) {
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
761
1113
|
// Return response for non-WebSocket requests
|
|
762
1114
|
return new Response("WebSocket server running", {
|
|
763
1115
|
headers: {
|
|
@@ -766,143 +1118,130 @@ async function startWebSocketServer(port) {
|
|
|
766
1118
|
});
|
|
767
1119
|
},
|
|
768
1120
|
websocket: {
|
|
769
|
-
open:
|
|
770
|
-
message
|
|
771
|
-
|
|
772
|
-
console.log("Received message from client:", message);
|
|
773
|
-
const data = JSON.parse(message);
|
|
774
|
-
if (data.type === "join") {
|
|
775
|
-
const channelName = data.channel;
|
|
776
|
-
if (!channelName || typeof channelName !== "string") {
|
|
777
|
-
ws.send(JSON.stringify({
|
|
778
|
-
type: "error",
|
|
779
|
-
message: "Channel name is required"
|
|
780
|
-
}));
|
|
781
|
-
return;
|
|
782
|
-
}
|
|
783
|
-
// Create channel if it doesn't exist
|
|
784
|
-
if (!channels.has(channelName)) {
|
|
785
|
-
channels.set(channelName, new Set());
|
|
786
|
-
}
|
|
787
|
-
// Add client to channel
|
|
788
|
-
const channelClients = channels.get(channelName);
|
|
789
|
-
channelClients.add(ws);
|
|
790
|
-
// Notify client they joined successfully
|
|
791
|
-
ws.send(JSON.stringify({
|
|
792
|
-
type: "system",
|
|
793
|
-
message: `Joined channel: ${channelName}`,
|
|
794
|
-
channel: channelName
|
|
795
|
-
}));
|
|
796
|
-
console.log("Sending message to client:", data.id);
|
|
797
|
-
ws.send(JSON.stringify({
|
|
798
|
-
type: "system",
|
|
799
|
-
message: {
|
|
800
|
-
id: data.id,
|
|
801
|
-
result: "Connected to channel: " + channelName
|
|
802
|
-
},
|
|
803
|
-
channel: channelName
|
|
804
|
-
}));
|
|
805
|
-
// Notify other clients in channel
|
|
806
|
-
channelClients.forEach((client)=>{
|
|
807
|
-
if (client !== ws && client.readyState === WebSocket.OPEN) {
|
|
808
|
-
client.send(JSON.stringify({
|
|
809
|
-
type: "system",
|
|
810
|
-
message: "A new user has joined the channel",
|
|
811
|
-
channel: channelName
|
|
812
|
-
}));
|
|
813
|
-
}
|
|
814
|
-
});
|
|
815
|
-
return;
|
|
816
|
-
}
|
|
817
|
-
// Handle regular messages
|
|
818
|
-
if (data.type === "message") {
|
|
819
|
-
const channelName = data.channel;
|
|
820
|
-
if (!channelName || typeof channelName !== "string") {
|
|
821
|
-
ws.send(JSON.stringify({
|
|
822
|
-
type: "error",
|
|
823
|
-
message: "Channel name is required"
|
|
824
|
-
}));
|
|
825
|
-
return;
|
|
826
|
-
}
|
|
827
|
-
const channelClients = channels.get(channelName);
|
|
828
|
-
if (!channelClients || !channelClients.has(ws)) {
|
|
829
|
-
ws.send(JSON.stringify({
|
|
830
|
-
type: "error",
|
|
831
|
-
message: "You must join the channel first"
|
|
832
|
-
}));
|
|
833
|
-
return;
|
|
834
|
-
}
|
|
835
|
-
// Broadcast to all clients in the channel
|
|
836
|
-
channelClients.forEach((client)=>{
|
|
837
|
-
if (client.readyState === WebSocket.OPEN) {
|
|
838
|
-
console.log("Broadcasting message to client:", data.message);
|
|
839
|
-
client.send(JSON.stringify({
|
|
840
|
-
type: "broadcast",
|
|
841
|
-
message: data.message,
|
|
842
|
-
sender: client === ws ? "You" : "User",
|
|
843
|
-
channel: channelName
|
|
844
|
-
}));
|
|
845
|
-
}
|
|
846
|
-
});
|
|
847
|
-
}
|
|
848
|
-
} catch (err) {
|
|
849
|
-
console.error("Error handling message:", err);
|
|
850
|
-
}
|
|
851
|
-
},
|
|
852
|
-
close (ws) {
|
|
853
|
-
// Remove client from their channel
|
|
854
|
-
channels.forEach((clients)=>{
|
|
855
|
-
clients.delete(ws);
|
|
856
|
-
});
|
|
857
|
-
}
|
|
1121
|
+
open: handleConnection,
|
|
1122
|
+
message: handleWebSocketMessage,
|
|
1123
|
+
close: handleClose
|
|
858
1124
|
}
|
|
859
1125
|
});
|
|
860
1126
|
console.log(`WebSocket server running on port ${server.port}`);
|
|
861
1127
|
return server;
|
|
862
1128
|
}
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
1129
|
+
|
|
1130
|
+
// Helper Functions
|
|
1131
|
+
function getFigmaAccessToken() {
|
|
1132
|
+
return process.env["FIGMA_PERSONAL_ACCESS_TOKEN"]?.trim();
|
|
1133
|
+
}
|
|
1134
|
+
function createFigmaClient(serverUrl, mode) {
|
|
1135
|
+
const pat = getFigmaAccessToken();
|
|
1136
|
+
const resolvedUrl = serverUrl ?? "localhost";
|
|
1137
|
+
switch(mode){
|
|
1138
|
+
case "rest":
|
|
1139
|
+
{
|
|
1140
|
+
if (!pat) {
|
|
1141
|
+
logger.warn("REST mode requires FIGMA_PERSONAL_ACCESS_TOKEN. Running without Figma client.");
|
|
1142
|
+
} else {
|
|
1143
|
+
logger.info("REST mode enabled. Using REST API only.");
|
|
1144
|
+
}
|
|
1145
|
+
return null;
|
|
1146
|
+
}
|
|
1147
|
+
case "websocket":
|
|
1148
|
+
{
|
|
1149
|
+
logger.info(`WebSocket mode enabled. Client connecting to: ${resolvedUrl}`);
|
|
1150
|
+
return createFigmaWebSocketClient(resolvedUrl);
|
|
1151
|
+
}
|
|
1152
|
+
case "all":
|
|
1153
|
+
{
|
|
1154
|
+
if (pat) {
|
|
1155
|
+
logger.info("FIGMA_PERSONAL_ACCESS_TOKEN found. REST API enabled for figmaUrl/fileKey requests.");
|
|
875
1156
|
}
|
|
1157
|
+
logger.info(`WebSocket client connecting to: ${resolvedUrl}`);
|
|
1158
|
+
return createFigmaWebSocketClient(resolvedUrl);
|
|
876
1159
|
}
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
function createRestClient(mode) {
|
|
1163
|
+
if (mode === "websocket") {
|
|
1164
|
+
return null;
|
|
1165
|
+
}
|
|
1166
|
+
const pat = getFigmaAccessToken();
|
|
1167
|
+
if (!pat) {
|
|
1168
|
+
if (mode === "rest") {
|
|
1169
|
+
logger.warn("REST mode requires FIGMA_PERSONAL_ACCESS_TOKEN. REST API will not be available.");
|
|
877
1170
|
}
|
|
1171
|
+
return null;
|
|
1172
|
+
}
|
|
1173
|
+
logger.info("Initializing REST API client with PAT from environment");
|
|
1174
|
+
return createFigmaRestClient(pat);
|
|
1175
|
+
}
|
|
1176
|
+
async function loadMcpConfig(configPath) {
|
|
1177
|
+
if (!configPath) return null;
|
|
1178
|
+
const config = await loadConfig(configPath);
|
|
1179
|
+
if (!config) return null;
|
|
1180
|
+
logger.info(`Loaded configuration from: ${configPath}`);
|
|
1181
|
+
if (config.extend?.componentHandlers?.length) {
|
|
1182
|
+
logger.info(`Found ${config.extend.componentHandlers.length} custom component handlers`);
|
|
878
1183
|
}
|
|
879
|
-
|
|
1184
|
+
return config;
|
|
1185
|
+
}
|
|
1186
|
+
function connectFigmaClient(figmaClient) {
|
|
1187
|
+
if (!figmaClient) return;
|
|
1188
|
+
try {
|
|
1189
|
+
figmaClient.connectToFigma();
|
|
1190
|
+
} catch (error) {
|
|
1191
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1192
|
+
logger.warn(`Could not connect to Figma initially: ${message}`);
|
|
1193
|
+
if (getFigmaAccessToken()) {
|
|
1194
|
+
logger.info("REST API fallback available via FIGMA_PERSONAL_ACCESS_TOKEN");
|
|
1195
|
+
} else {
|
|
1196
|
+
logger.warn("Will try to connect when the first command is sent");
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
async function startMcpServer(options = {}) {
|
|
1201
|
+
const { serverUrl, experimental, configPath, mode = "all" } = options;
|
|
1202
|
+
const config = await loadMcpConfig(configPath);
|
|
1203
|
+
const figmaClient = createFigmaClient(serverUrl, mode);
|
|
1204
|
+
const restClient = createRestClient(mode);
|
|
880
1205
|
const server = new McpServer({
|
|
881
1206
|
name: "SEED Design MCP",
|
|
882
1207
|
version
|
|
883
1208
|
});
|
|
884
|
-
registerTools(server, figmaClient,
|
|
885
|
-
if (experimental) {
|
|
886
|
-
registerEditingTools(server, figmaClient);
|
|
887
|
-
}
|
|
1209
|
+
registerTools(server, figmaClient, restClient, config, mode);
|
|
888
1210
|
registerPrompts(server);
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
1211
|
+
if (experimental) {
|
|
1212
|
+
if (mode === "rest") {
|
|
1213
|
+
logger.warn("Experimental editing tools not available in REST mode. Skipping.");
|
|
1214
|
+
} else if (figmaClient) {
|
|
1215
|
+
registerEditingTools(server, figmaClient);
|
|
1216
|
+
} else {
|
|
1217
|
+
logger.warn("Experimental editing tools require WebSocket connection. Skipping.");
|
|
1218
|
+
}
|
|
894
1219
|
}
|
|
1220
|
+
connectFigmaClient(figmaClient);
|
|
895
1221
|
const transport = new StdioServerTransport();
|
|
896
1222
|
await server.connect(transport);
|
|
897
|
-
logger.info(
|
|
1223
|
+
logger.info(`FigmaMCP server running on stdio (mode: ${mode})`);
|
|
898
1224
|
}
|
|
899
|
-
//
|
|
900
|
-
cli
|
|
901
|
-
|
|
902
|
-
}).option("--experimental", "Enable experimental features", {
|
|
1225
|
+
// CLI
|
|
1226
|
+
const cli = cac("@seed-design/mcp");
|
|
1227
|
+
cli.command("", "Start the MCP server").option("--server <server>", "WebSocket server URL. If not provided and FIGMA_PERSONAL_ACCESS_TOKEN is set, REST API mode will be used.").option("--experimental", "Enable experimental features", {
|
|
903
1228
|
default: false
|
|
904
|
-
}).option("--config <config>", "Path to configuration file (.js, .mjs, .ts, .mts)").action(async (options)=>{
|
|
905
|
-
|
|
1229
|
+
}).option("--config <config>", "Path to configuration file (.js, .mjs, .ts, .mts)").option("--mode <mode>", "Tool registration mode: 'rest' (REST API tools only), 'websocket' (WebSocket tools only), or 'all' (default)").action(async (options)=>{
|
|
1230
|
+
const mode = options.mode;
|
|
1231
|
+
if (mode && ![
|
|
1232
|
+
"rest",
|
|
1233
|
+
"websocket",
|
|
1234
|
+
"all"
|
|
1235
|
+
].includes(mode)) {
|
|
1236
|
+
console.error(`Invalid mode: ${mode}. Use 'rest', 'websocket', or 'all'.`);
|
|
1237
|
+
process.exit(1);
|
|
1238
|
+
}
|
|
1239
|
+
await startMcpServer({
|
|
1240
|
+
serverUrl: options.server,
|
|
1241
|
+
experimental: options.experimental,
|
|
1242
|
+
configPath: options.config,
|
|
1243
|
+
mode
|
|
1244
|
+
});
|
|
906
1245
|
});
|
|
907
1246
|
cli.command("socket", "Start the WebSocket server").option("--port <port>", "Port number", {
|
|
908
1247
|
default: 3055
|
|
@@ -911,5 +1250,4 @@ cli.command("socket", "Start the WebSocket server").option("--port <port>", "Por
|
|
|
911
1250
|
});
|
|
912
1251
|
cli.help();
|
|
913
1252
|
cli.version(version);
|
|
914
|
-
// Parse CLI args
|
|
915
1253
|
cli.parse();
|