@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/src/bin/index.ts CHANGED
@@ -3,244 +3,186 @@
3
3
  import { cac } from "cac";
4
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { version } from "../../package.json" with { type: "json" };
6
7
  import { logger } from "../logger";
7
- import { createFigmaWebSocketClient } from "../websocket";
8
+ import { loadConfig, type McpConfig } from "../config";
9
+ import { createFigmaRestClient, type FigmaRestClient } from "../figma-rest-client";
10
+ import { createFigmaWebSocketClient, type FigmaWebSocketClient } from "../websocket";
8
11
  import { registerEditingTools, registerTools } from "../tools";
12
+ import type { ToolMode } from "../tools-helpers";
9
13
  import { registerPrompts } from "../prompts";
10
- import { version } from "../../package.json" with { type: "json" };
11
- import type { ServerWebSocket } from "bun";
12
- import { loadConfig, type McpConfig } from "../config";
14
+ import { startWebSocketServer } from "./websocket-server";
13
15
 
14
- // Initialize CLI
15
- const cli = cac("@seed-design/mcp");
16
+ // Helper Functions
16
17
 
17
- // Store WebSocket clients by channel
18
- const channels = new Map<string, Set<ServerWebSocket<any>>>();
19
-
20
- function handleWebSocketConnection(ws: ServerWebSocket<any>) {
21
- console.log("New client connected");
22
-
23
- ws.send(
24
- JSON.stringify({
25
- type: "system",
26
- message: "Please join a channel to start chatting",
27
- }),
28
- );
29
-
30
- ws.close = () => {
31
- console.log("Client disconnected");
32
- channels.forEach((clients, channelName) => {
33
- if (clients.has(ws)) {
34
- clients.delete(ws);
35
- clients.forEach((client) => {
36
- if (client.readyState === WebSocket.OPEN) {
37
- client.send(
38
- JSON.stringify({
39
- type: "system",
40
- message: "A user has left the channel",
41
- channel: channelName,
42
- }),
43
- );
44
- }
45
- });
46
- }
47
- });
48
- };
18
+ function getFigmaAccessToken(): string | undefined {
19
+ return process.env["FIGMA_PERSONAL_ACCESS_TOKEN"]?.trim();
49
20
  }
50
21
 
51
- async function startWebSocketServer(port: number) {
52
- const server = Bun.serve({
53
- port,
54
- // uncomment this to allow connections in windows wsl
55
- // hostname: "0.0.0.0",
56
- fetch(req, server) {
57
- // Handle CORS preflight
58
- if (req.method === "OPTIONS") {
59
- return new Response(null, {
60
- headers: {
61
- "Access-Control-Allow-Origin": "*",
62
- "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
63
- "Access-Control-Allow-Headers": "Content-Type, Authorization",
64
- },
65
- });
22
+ function createFigmaClient(
23
+ serverUrl: string | undefined,
24
+ mode: ToolMode,
25
+ ): FigmaWebSocketClient | null {
26
+ const pat = getFigmaAccessToken();
27
+ const resolvedUrl = serverUrl ?? "localhost";
28
+
29
+ switch (mode) {
30
+ case "rest": {
31
+ if (!pat) {
32
+ logger.warn(
33
+ "REST mode requires FIGMA_PERSONAL_ACCESS_TOKEN. Running without Figma client.",
34
+ );
35
+ } else {
36
+ logger.info("REST mode enabled. Using REST API only.");
66
37
  }
67
38
 
68
- // Handle WebSocket upgrade
69
- const success = server.upgrade(req, {
70
- headers: {
71
- "Access-Control-Allow-Origin": "*",
72
- },
73
- });
74
-
75
- if (success) return;
76
-
77
- // Return response for non-WebSocket requests
78
- return new Response("WebSocket server running", {
79
- headers: {
80
- "Access-Control-Allow-Origin": "*",
81
- },
82
- });
83
- },
84
- websocket: {
85
- open: handleWebSocketConnection,
86
- message(ws, message) {
87
- try {
88
- console.log("Received message from client:", message);
89
- const data = JSON.parse(message as string);
90
-
91
- if (data.type === "join") {
92
- const channelName = data.channel;
93
- if (!channelName || typeof channelName !== "string") {
94
- ws.send(JSON.stringify({ type: "error", message: "Channel name is required" }));
95
- return;
96
- }
97
-
98
- // Create channel if it doesn't exist
99
- if (!channels.has(channelName)) {
100
- channels.set(channelName, new Set());
101
- }
102
-
103
- // Add client to channel
104
- const channelClients = channels.get(channelName)!;
105
- channelClients.add(ws);
106
-
107
- // Notify client they joined successfully
108
- ws.send(
109
- JSON.stringify({
110
- type: "system",
111
- message: `Joined channel: ${channelName}`,
112
- channel: channelName,
113
- }),
114
- );
115
-
116
- console.log("Sending message to client:", data.id);
117
-
118
- ws.send(
119
- JSON.stringify({
120
- type: "system",
121
- message: {
122
- id: data.id,
123
- result: "Connected to channel: " + channelName,
124
- },
125
- channel: channelName,
126
- }),
127
- );
128
-
129
- // Notify other clients in channel
130
- channelClients.forEach((client) => {
131
- if (client !== ws && client.readyState === WebSocket.OPEN) {
132
- client.send(
133
- JSON.stringify({
134
- type: "system",
135
- message: "A new user has joined the channel",
136
- channel: channelName,
137
- }),
138
- );
139
- }
140
- });
141
- return;
142
- }
143
-
144
- // Handle regular messages
145
- if (data.type === "message") {
146
- const channelName = data.channel;
147
- if (!channelName || typeof channelName !== "string") {
148
- ws.send(JSON.stringify({ type: "error", message: "Channel name is required" }));
149
- return;
150
- }
151
-
152
- const channelClients = channels.get(channelName);
153
- if (!channelClients || !channelClients.has(ws)) {
154
- ws.send(
155
- JSON.stringify({ type: "error", message: "You must join the channel first" }),
156
- );
157
- return;
158
- }
159
-
160
- // Broadcast to all clients in the channel
161
- channelClients.forEach((client) => {
162
- if (client.readyState === WebSocket.OPEN) {
163
- console.log("Broadcasting message to client:", data.message);
164
- client.send(
165
- JSON.stringify({
166
- type: "broadcast",
167
- message: data.message,
168
- sender: client === ws ? "You" : "User",
169
- channel: channelName,
170
- }),
171
- );
172
- }
173
- });
174
- }
175
- } catch (err) {
176
- console.error("Error handling message:", err);
177
- }
178
- },
179
- close(ws) {
180
- // Remove client from their channel
181
- channels.forEach((clients) => {
182
- clients.delete(ws);
183
- });
184
- },
185
- },
186
- });
39
+ return null;
40
+ }
187
41
 
188
- console.log(`WebSocket server running on port ${server.port}`);
189
- return server;
190
- }
42
+ case "websocket": {
43
+ logger.info(`WebSocket mode enabled. Client connecting to: ${resolvedUrl}`);
44
+
45
+ return createFigmaWebSocketClient(resolvedUrl);
46
+ }
191
47
 
192
- async function startMcpServer(serverUrl: string, experimental: boolean, configPath?: string) {
193
- // Load config if provided
194
- let configData: McpConfig | null = null;
195
- if (configPath) {
196
- configData = await loadConfig(configPath);
197
- if (configData) {
198
- logger.info(`Loaded configuration from: ${configPath}`);
199
-
200
- // Log component transformers if present
201
- if (configData.extend?.componentHandlers) {
202
- const handlers = configData.extend.componentHandlers;
203
- if (handlers.length > 0) {
204
- logger.info(`Found ${handlers.length} custom component handlers`);
205
- }
48
+ case "all": {
49
+ if (pat) {
50
+ logger.info(
51
+ "FIGMA_PERSONAL_ACCESS_TOKEN found. REST API enabled for figmaUrl/fileKey requests.",
52
+ );
206
53
  }
54
+
55
+ logger.info(`WebSocket client connecting to: ${resolvedUrl}`);
56
+
57
+ return createFigmaWebSocketClient(resolvedUrl);
207
58
  }
208
59
  }
60
+ }
61
+
62
+ function createRestClient(mode: ToolMode): FigmaRestClient | null {
63
+ if (mode === "websocket") {
64
+ return null;
65
+ }
66
+
67
+ const pat = getFigmaAccessToken();
68
+ if (!pat) {
69
+ if (mode === "rest") {
70
+ logger.warn(
71
+ "REST mode requires FIGMA_PERSONAL_ACCESS_TOKEN. REST API will not be available.",
72
+ );
73
+ }
74
+
75
+ return null;
76
+ }
77
+
78
+ logger.info("Initializing REST API client with PAT from environment");
79
+
80
+ return createFigmaRestClient(pat);
81
+ }
82
+
83
+ async function loadMcpConfig(configPath?: string): Promise<McpConfig | null> {
84
+ if (!configPath) return null;
85
+
86
+ const config = await loadConfig(configPath);
87
+ if (!config) return null;
88
+
89
+ logger.info(`Loaded configuration from: ${configPath}`);
90
+
91
+ if (config.extend?.componentHandlers?.length) {
92
+ logger.info(`Found ${config.extend.componentHandlers.length} custom component handlers`);
93
+ }
94
+
95
+ return config;
96
+ }
97
+
98
+ function connectFigmaClient(figmaClient: FigmaWebSocketClient | null): void {
99
+ if (!figmaClient) return;
100
+
101
+ try {
102
+ figmaClient.connectToFigma();
103
+ } catch (error) {
104
+ const message = error instanceof Error ? error.message : String(error);
105
+ logger.warn(`Could not connect to Figma initially: ${message}`);
106
+
107
+ if (getFigmaAccessToken()) {
108
+ logger.info("REST API fallback available via FIGMA_PERSONAL_ACCESS_TOKEN");
109
+ } else {
110
+ logger.warn("Will try to connect when the first command is sent");
111
+ }
112
+ }
113
+ }
114
+
115
+ // MCP Server
116
+
117
+ interface McpServerOptions {
118
+ serverUrl?: string;
119
+ experimental?: boolean;
120
+ configPath?: string;
121
+ mode?: ToolMode;
122
+ }
123
+
124
+ async function startMcpServer(options: McpServerOptions = {}): Promise<void> {
125
+ const { serverUrl, experimental, configPath, mode = "all" } = options;
126
+
127
+ const config = await loadMcpConfig(configPath);
128
+ const figmaClient = createFigmaClient(serverUrl, mode);
129
+ const restClient = createRestClient(mode);
209
130
 
210
- const figmaClient = createFigmaWebSocketClient(serverUrl);
211
131
  const server = new McpServer({
212
132
  name: "SEED Design MCP",
213
133
  version,
214
134
  });
215
135
 
216
- registerTools(server, figmaClient, configData);
217
- if (experimental) {
218
- registerEditingTools(server, figmaClient);
219
- }
136
+ registerTools(server, figmaClient, restClient, config, mode);
220
137
  registerPrompts(server);
221
138
 
222
- try {
223
- figmaClient.connectToFigma();
224
- } catch (error) {
225
- logger.warn(
226
- `Could not connect to Figma initially: ${error instanceof Error ? error.message : String(error)}`,
227
- );
228
- logger.warn("Will try to connect when the first command is sent");
139
+ if (experimental) {
140
+ if (mode === "rest") {
141
+ logger.warn("Experimental editing tools not available in REST mode. Skipping.");
142
+ } else if (figmaClient) {
143
+ registerEditingTools(server, figmaClient);
144
+ } else {
145
+ logger.warn("Experimental editing tools require WebSocket connection. Skipping.");
146
+ }
229
147
  }
230
148
 
149
+ connectFigmaClient(figmaClient);
150
+
231
151
  const transport = new StdioServerTransport();
232
152
  await server.connect(transport);
233
- logger.info("FigmaMCP server running on stdio");
153
+
154
+ logger.info(`FigmaMCP server running on stdio (mode: ${mode})`);
234
155
  }
235
156
 
236
- // Define CLI commands
157
+ // CLI
158
+
159
+ const cli = cac("@seed-design/mcp");
160
+
237
161
  cli
238
162
  .command("", "Start the MCP server")
239
- .option("--server <server>", "Server URL", { default: "localhost" })
163
+ .option(
164
+ "--server <server>",
165
+ "WebSocket server URL. If not provided and FIGMA_PERSONAL_ACCESS_TOKEN is set, REST API mode will be used.",
166
+ )
240
167
  .option("--experimental", "Enable experimental features", { default: false })
241
168
  .option("--config <config>", "Path to configuration file (.js, .mjs, .ts, .mts)")
169
+ .option(
170
+ "--mode <mode>",
171
+ "Tool registration mode: 'rest' (REST API tools only), 'websocket' (WebSocket tools only), or 'all' (default)",
172
+ )
242
173
  .action(async (options) => {
243
- await startMcpServer(options.server, options.experimental, options.config);
174
+ const mode = options.mode as ToolMode | undefined;
175
+ if (mode && !["rest", "websocket", "all"].includes(mode)) {
176
+ console.error(`Invalid mode: ${mode}. Use 'rest', 'websocket', or 'all'.`);
177
+ process.exit(1);
178
+ }
179
+
180
+ await startMcpServer({
181
+ serverUrl: options.server,
182
+ experimental: options.experimental,
183
+ configPath: options.config,
184
+ mode,
185
+ });
244
186
  });
245
187
 
246
188
  cli
@@ -252,6 +194,4 @@ cli
252
194
 
253
195
  cli.help();
254
196
  cli.version(version);
255
-
256
- // Parse CLI args
257
197
  cli.parse();
@@ -0,0 +1,196 @@
1
+ import type { ServerWebSocket } from "bun";
2
+
3
+ interface JoinMessage {
4
+ type: "join";
5
+ channel: string;
6
+ id?: string;
7
+ }
8
+
9
+ interface ChatMessage {
10
+ type: "message";
11
+ channel: string;
12
+ message: unknown;
13
+ }
14
+
15
+ type WebSocketMessage = JoinMessage | ChatMessage;
16
+
17
+ const channels = new Map<string, Set<ServerWebSocket<unknown>>>();
18
+
19
+ function sendJson(ws: ServerWebSocket<unknown>, data: object): void {
20
+ ws.send(JSON.stringify(data));
21
+ }
22
+
23
+ function broadcastToChannel(
24
+ channelName: string,
25
+ message: object,
26
+ excludeWs?: ServerWebSocket<unknown>,
27
+ ): void {
28
+ const clients = channels.get(channelName);
29
+ if (!clients) return;
30
+
31
+ for (const client of clients) {
32
+ if (client !== excludeWs && client.readyState === WebSocket.OPEN) {
33
+ sendJson(client, message);
34
+ }
35
+ }
36
+ }
37
+
38
+ function handleJoin(ws: ServerWebSocket<unknown>, data: JoinMessage): void {
39
+ const { channel: channelName, id } = data;
40
+
41
+ if (!channelName || typeof channelName !== "string") {
42
+ sendJson(ws, { type: "error", message: "Channel name is required" });
43
+ return;
44
+ }
45
+
46
+ if (!channels.has(channelName)) {
47
+ channels.set(channelName, new Set());
48
+ }
49
+
50
+ const channelClients = channels.get(channelName);
51
+
52
+ if (!channelClients) {
53
+ sendJson(ws, { type: "error", message: "Failed to join channel" });
54
+
55
+ return;
56
+ }
57
+
58
+ channelClients.add(ws);
59
+
60
+ // Notify client they joined successfully
61
+ sendJson(ws, {
62
+ type: "system",
63
+ message: `Joined channel: ${channelName}`,
64
+ channel: channelName,
65
+ });
66
+
67
+ // Send connection confirmation with ID if provided
68
+ if (id) {
69
+ console.log("Sending message to client:", id);
70
+
71
+ sendJson(ws, {
72
+ type: "system",
73
+ message: { id, result: `Connected to channel: ${channelName}` },
74
+ channel: channelName,
75
+ });
76
+ }
77
+
78
+ // Notify other clients in channel
79
+ broadcastToChannel(
80
+ channelName,
81
+ { type: "system", message: "A new user has joined the channel", channel: channelName },
82
+ ws,
83
+ );
84
+ }
85
+
86
+ function handleMessage(ws: ServerWebSocket<unknown>, data: ChatMessage): void {
87
+ const { channel: channelName, message } = data;
88
+
89
+ if (!channelName || typeof channelName !== "string") {
90
+ sendJson(ws, { type: "error", message: "Channel name is required" });
91
+ return;
92
+ }
93
+
94
+ const channelClients = channels.get(channelName);
95
+ if (!channelClients?.has(ws)) {
96
+ sendJson(ws, { type: "error", message: "You must join the channel first" });
97
+ return;
98
+ }
99
+
100
+ // Broadcast to all clients in the channel
101
+ for (const client of channelClients) {
102
+ if (client.readyState === WebSocket.OPEN) {
103
+ console.log("Broadcasting message to client:", message);
104
+ sendJson(client, {
105
+ type: "broadcast",
106
+ message,
107
+ sender: client === ws ? "You" : "User",
108
+ channel: channelName,
109
+ });
110
+ }
111
+ }
112
+ }
113
+
114
+ // WebSocket Event Handlers
115
+
116
+ function handleConnection(ws: ServerWebSocket<unknown>): void {
117
+ console.log("New client connected");
118
+ sendJson(ws, { type: "system", message: "Please join a channel to start chatting" });
119
+ }
120
+
121
+ function handleWebSocketMessage(ws: ServerWebSocket<unknown>, rawMessage: string | Buffer): void {
122
+ try {
123
+ console.log("Received message from client:", rawMessage);
124
+ const data = JSON.parse(rawMessage as string) as WebSocketMessage;
125
+
126
+ switch (data.type) {
127
+ case "join":
128
+ handleJoin(ws, data);
129
+ break;
130
+ case "message":
131
+ handleMessage(ws, data);
132
+ break;
133
+ default:
134
+ console.warn(`Unknown message type: ${(data as { type: string }).type}`);
135
+ }
136
+ } catch (err) {
137
+ console.error("Error handling message:", err);
138
+ }
139
+ }
140
+
141
+ function handleClose(ws: ServerWebSocket<unknown>): void {
142
+ console.log("Client disconnected");
143
+
144
+ for (const [channelName, clients] of channels) {
145
+ if (clients.has(ws)) {
146
+ clients.delete(ws);
147
+ broadcastToChannel(channelName, {
148
+ type: "system",
149
+ message: "A user has left the channel",
150
+ channel: channelName,
151
+ });
152
+ }
153
+ }
154
+ }
155
+
156
+ // Server
157
+
158
+ export async function startWebSocketServer(port: number) {
159
+ const server = Bun.serve({
160
+ port,
161
+ // uncomment this to allow connections in windows wsl
162
+ // hostname: "0.0.0.0",
163
+ fetch(req, server) {
164
+ // Handle CORS preflight
165
+ if (req.method === "OPTIONS") {
166
+ return new Response(null, {
167
+ headers: {
168
+ "Access-Control-Allow-Origin": "*",
169
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
170
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
171
+ },
172
+ });
173
+ }
174
+
175
+ // Handle WebSocket upgrade
176
+ if (server.upgrade(req, { headers: { "Access-Control-Allow-Origin": "*" }, data: {} })) {
177
+ return;
178
+ }
179
+
180
+ // Return response for non-WebSocket requests
181
+ return new Response("WebSocket server running", {
182
+ headers: { "Access-Control-Allow-Origin": "*" },
183
+ });
184
+ },
185
+
186
+ websocket: {
187
+ open: handleConnection,
188
+ message: handleWebSocketMessage,
189
+ close: handleClose,
190
+ },
191
+ });
192
+
193
+ console.log(`WebSocket server running on port ${server.port}`);
194
+
195
+ return server;
196
+ }
@@ -0,0 +1,55 @@
1
+ import { Api as FigmaApi } from "figma-api";
2
+ import type { GetFileNodesResponse } from "@figma/rest-api-spec";
3
+
4
+ export interface FigmaRestClient {
5
+ getFileNodes(fileKey: string, nodeIds: string[]): Promise<GetFileNodesResponse>;
6
+ }
7
+
8
+ export function createFigmaRestClient(personalAccessToken: string): FigmaRestClient {
9
+ const api = new FigmaApi({ personalAccessToken });
10
+
11
+ return {
12
+ async getFileNodes(fileKey: string, nodeIds: string[]): Promise<GetFileNodesResponse> {
13
+ const response = await api.getFileNodes({ file_key: fileKey }, { ids: nodeIds.join(",") });
14
+
15
+ return response;
16
+ },
17
+ };
18
+ }
19
+
20
+ /**
21
+ * https://www.figma.com/:file_type/:file_key/:file_name?node-id=:id
22
+ *
23
+ * file_type:
24
+ * - design
25
+ * - file (legacy)
26
+ *
27
+ * Note: While node-id is separated by hyphens ('-') in the URL,
28
+ * it must be converted to colons (':') when making API calls.
29
+ * e.g. URL "node-id=794-1987" → API "794:1987"
30
+ */
31
+ export function parseFigmaUrl(url: string): { fileKey: string; nodeId: string } {
32
+ const __url: URL = (() => {
33
+ try {
34
+ return new URL(url);
35
+ } catch {
36
+ throw new Error(`Invalid URL format: ${url}`);
37
+ }
38
+ })();
39
+
40
+ const pathMatch = __url.pathname.match(/^\/(design|file)\/([A-Za-z0-9]+)/);
41
+
42
+ const rawNodeId = __url.searchParams.get("node-id");
43
+
44
+ if (!pathMatch)
45
+ throw new Error(
46
+ "Invalid Figma URL: Expected format https://www.figma.com/design/{fileKey}/... or /file/{fileKey}/...",
47
+ );
48
+
49
+ if (!rawNodeId) throw new Error("Invalid Figma URL: Missing node-id query parameter");
50
+
51
+ return {
52
+ fileKey: pathMatch[2],
53
+ nodeId: rawNodeId.replace(/-/g, ":"),
54
+ };
55
+ }
package/src/index.ts ADDED
@@ -0,0 +1,4 @@
1
+ export { registerTools } from "./tools";
2
+ export { createFigmaRestClient, parseFigmaUrl } from "./figma-rest-client";
3
+ export type { FigmaRestClient } from "./figma-rest-client";
4
+ export type { ToolMode } from "./tools-helpers";