@seed-design/mcp 0.0.0-alpha-20260324091316

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.
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { cac } from "cac";
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { version } from "../../package.json" with { type: "json" };
7
+ import { logger } from "../logger";
8
+ import { loadConfig, type McpConfig } from "../config";
9
+ import { createFigmaRestClient, type FigmaRestClient } from "../figma-rest-client";
10
+ import { createFigmaWebSocketClient, type FigmaWebSocketClient } from "../websocket";
11
+ import { registerEditingTools, registerTools } from "../tools";
12
+ import type { ToolMode } from "../tools-helpers";
13
+ import { registerPrompts } from "../prompts";
14
+ import { startWebSocketServer } from "./websocket-server";
15
+
16
+ // Helper Functions
17
+
18
+ function getFigmaAccessToken(): string | undefined {
19
+ return process.env["FIGMA_PERSONAL_ACCESS_TOKEN"]?.trim();
20
+ }
21
+
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.");
37
+ }
38
+
39
+ return null;
40
+ }
41
+
42
+ case "websocket": {
43
+ logger.info(`WebSocket mode enabled. Client connecting to: ${resolvedUrl}`);
44
+
45
+ return createFigmaWebSocketClient(resolvedUrl);
46
+ }
47
+
48
+ case "all": {
49
+ if (pat) {
50
+ logger.info(
51
+ "FIGMA_PERSONAL_ACCESS_TOKEN found. REST API enabled for figmaUrl/fileKey requests.",
52
+ );
53
+ }
54
+
55
+ logger.info(`WebSocket client connecting to: ${resolvedUrl}`);
56
+
57
+ return createFigmaWebSocketClient(resolvedUrl);
58
+ }
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);
130
+
131
+ const server = new McpServer({
132
+ name: "SEED Design MCP",
133
+ version,
134
+ });
135
+
136
+ registerTools(server, figmaClient, restClient, config, mode);
137
+ registerPrompts(server);
138
+
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
+ }
147
+ }
148
+
149
+ connectFigmaClient(figmaClient);
150
+
151
+ const transport = new StdioServerTransport();
152
+ await server.connect(transport);
153
+
154
+ logger.info(`FigmaMCP server running on stdio (mode: ${mode})`);
155
+ }
156
+
157
+ // CLI
158
+
159
+ const cli = cac("@seed-design/mcp");
160
+
161
+ cli
162
+ .command("", "Start the MCP server")
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
+ )
167
+ .option("--experimental", "Enable experimental features", { default: false })
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
+ )
173
+ .action(async (options) => {
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
+ });
186
+ });
187
+
188
+ cli
189
+ .command("socket", "Start the WebSocket server")
190
+ .option("--port <port>", "Port number", { default: 3055 })
191
+ .action(async (options) => {
192
+ await startWebSocketServer(options.port);
193
+ });
194
+
195
+ cli.help();
196
+ cli.version(version);
197
+ cli.parse();
@@ -0,0 +1,193 @@
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
68
+ console.log("Sending message to client:", id);
69
+ sendJson(ws, {
70
+ type: "system",
71
+ message: { id, result: `Connected to channel: ${channelName}` },
72
+ channel: channelName,
73
+ });
74
+
75
+ // Notify other clients in channel
76
+ broadcastToChannel(
77
+ channelName,
78
+ { type: "system", message: "A new user has joined the channel", channel: channelName },
79
+ ws,
80
+ );
81
+ }
82
+
83
+ function handleMessage(ws: ServerWebSocket<unknown>, data: ChatMessage): void {
84
+ const { channel: channelName, message } = data;
85
+
86
+ if (!channelName || typeof channelName !== "string") {
87
+ sendJson(ws, { type: "error", message: "Channel name is required" });
88
+ return;
89
+ }
90
+
91
+ const channelClients = channels.get(channelName);
92
+ if (!channelClients?.has(ws)) {
93
+ sendJson(ws, { type: "error", message: "You must join the channel first" });
94
+ return;
95
+ }
96
+
97
+ // Broadcast to all clients in the channel
98
+ for (const client of channelClients) {
99
+ if (client.readyState === WebSocket.OPEN) {
100
+ console.log("Broadcasting message to client:", message);
101
+ sendJson(client, {
102
+ type: "broadcast",
103
+ message,
104
+ sender: client === ws ? "You" : "User",
105
+ channel: channelName,
106
+ });
107
+ }
108
+ }
109
+ }
110
+
111
+ // WebSocket Event Handlers
112
+
113
+ function handleConnection(ws: ServerWebSocket<unknown>): void {
114
+ console.log("New client connected");
115
+ sendJson(ws, { type: "system", message: "Please join a channel to start chatting" });
116
+ }
117
+
118
+ function handleWebSocketMessage(ws: ServerWebSocket<unknown>, rawMessage: string | Buffer): void {
119
+ try {
120
+ console.log("Received message from client:", rawMessage);
121
+ const data = JSON.parse(rawMessage as string) as WebSocketMessage;
122
+
123
+ switch (data.type) {
124
+ case "join":
125
+ handleJoin(ws, data);
126
+ break;
127
+ case "message":
128
+ handleMessage(ws, data);
129
+ break;
130
+ default:
131
+ console.warn(`Unknown message type: ${(data as { type: string }).type}`);
132
+ }
133
+ } catch (err) {
134
+ console.error("Error handling message:", err);
135
+ }
136
+ }
137
+
138
+ function handleClose(ws: ServerWebSocket<unknown>): void {
139
+ console.log("Client disconnected");
140
+
141
+ for (const [channelName, clients] of channels) {
142
+ if (clients.has(ws)) {
143
+ clients.delete(ws);
144
+ broadcastToChannel(channelName, {
145
+ type: "system",
146
+ message: "A user has left the channel",
147
+ channel: channelName,
148
+ });
149
+ }
150
+ }
151
+ }
152
+
153
+ // Server
154
+
155
+ export async function startWebSocketServer(port: number) {
156
+ const server = Bun.serve({
157
+ port,
158
+ // uncomment this to allow connections in windows wsl
159
+ // hostname: "0.0.0.0",
160
+ fetch(req, server) {
161
+ // Handle CORS preflight
162
+ if (req.method === "OPTIONS") {
163
+ return new Response(null, {
164
+ headers: {
165
+ "Access-Control-Allow-Origin": "*",
166
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
167
+ "Access-Control-Allow-Headers": "Content-Type, Authorization",
168
+ },
169
+ });
170
+ }
171
+
172
+ // Handle WebSocket upgrade
173
+ if (server.upgrade(req, { headers: { "Access-Control-Allow-Origin": "*" }, data: {} })) {
174
+ return;
175
+ }
176
+
177
+ // Return response for non-WebSocket requests
178
+ return new Response("WebSocket server running", {
179
+ headers: { "Access-Control-Allow-Origin": "*" },
180
+ });
181
+ },
182
+
183
+ websocket: {
184
+ open: handleConnection,
185
+ message: handleWebSocketMessage,
186
+ close: handleClose,
187
+ },
188
+ });
189
+
190
+ console.log(`WebSocket server running on port ${server.port}`);
191
+
192
+ return server;
193
+ }
package/src/config.ts ADDED
@@ -0,0 +1,47 @@
1
+ import type { CreatePipelineConfig } from "@seed-design/figma/codegen/targets/react";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { logger } from "./logger";
5
+
6
+ // Define config type
7
+ export interface McpConfig {
8
+ extend?: CreatePipelineConfig["extend"];
9
+ }
10
+
11
+ // Config loader
12
+ export async function loadConfig(configPath: string) {
13
+ try {
14
+ const resolvedPath = path.resolve(process.cwd(), configPath);
15
+
16
+ if (!fs.existsSync(resolvedPath)) {
17
+ logger.error(`Config file not found: ${resolvedPath}`);
18
+ return null;
19
+ }
20
+
21
+ // Handle different file types
22
+ if (resolvedPath.endsWith(".json")) {
23
+ const content = fs.readFileSync(resolvedPath, "utf-8");
24
+ return JSON.parse(content);
25
+ }
26
+
27
+ if (
28
+ resolvedPath.endsWith(".js") ||
29
+ resolvedPath.endsWith(".mjs") ||
30
+ resolvedPath.endsWith(".ts") ||
31
+ resolvedPath.endsWith(".mts")
32
+ ) {
33
+ // For JS/MJS/TS/MTS files, we can dynamically import with Bun
34
+ // Bun has built-in TypeScript support without requiring transpilation
35
+ const config = await import(resolvedPath);
36
+ return config.default || config;
37
+ }
38
+
39
+ logger.error(`Unsupported config file format: ${resolvedPath}`);
40
+ return null;
41
+ } catch (error) {
42
+ logger.error(
43
+ `Failed to load config file: ${error instanceof Error ? error.message : String(error)}`,
44
+ );
45
+ return null;
46
+ }
47
+ }
@@ -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";
package/src/logger.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Custom logging module that writes to stderr instead of stdout
3
+ * to avoid being captured in MCP protocol communication
4
+ */
5
+
6
+ export const logger = {
7
+ /**
8
+ * Log an informational message
9
+ */
10
+ info: (message: string) => process.stderr.write(`[INFO] ${message}\n`),
11
+
12
+ /**
13
+ * Log a debug message
14
+ */
15
+ debug: (message: string) => process.stderr.write(`[DEBUG] ${message}\n`),
16
+
17
+ /**
18
+ * Log a warning message
19
+ */
20
+ warn: (message: string) => process.stderr.write(`[WARN] ${message}\n`),
21
+
22
+ /**
23
+ * Log an error message
24
+ */
25
+ error: (message: string) => process.stderr.write(`[ERROR] ${message}\n`),
26
+
27
+ /**
28
+ * Log a general message
29
+ */
30
+ log: (message: string) => process.stderr.write(`[LOG] ${message}\n`),
31
+ };
32
+
33
+ /**
34
+ * Format an error for logging
35
+ */
36
+ export function formatError(error: unknown): string {
37
+ if (error instanceof Error) {
38
+ return error.message;
39
+ }
40
+ return String(error);
41
+ }
package/src/prompts.ts ADDED
@@ -0,0 +1,55 @@
1
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+
3
+ export function registerPrompts(server: McpServer): void {
4
+ server.prompt(
5
+ "react_implementation_strategy",
6
+ "Best practices for implementing React components",
7
+ (_extra) => {
8
+ return {
9
+ messages: [
10
+ {
11
+ role: "assistant",
12
+ content: {
13
+ type: "text",
14
+ text: `When implementing React components, follow these best practices:
15
+
16
+ 1. Start with selection:
17
+ - First use get_selection() to understand the current selection
18
+ - If no selection ask user to select single node
19
+
20
+ 2. Get React code of the selected node:
21
+ - Use get_node_react_code() to get the React code of the selected node
22
+ - If no selection ask user to select single node
23
+ `,
24
+ },
25
+ },
26
+ ],
27
+ description: "Best practices for implementing React components",
28
+ };
29
+ },
30
+ );
31
+
32
+ server.prompt("read_design_strategy", "Best practices for reading Figma designs", (_extra) => {
33
+ return {
34
+ messages: [
35
+ {
36
+ role: "assistant",
37
+ content: {
38
+ type: "text",
39
+ text: `When reading Figma designs, follow these best practices:
40
+
41
+ 1. Start with selection:
42
+ - First use get_selection() to understand the current selection
43
+ - If no selection ask user to select single or multiple nodes
44
+
45
+ 2. Get node infos of the selected nodes:
46
+ - Use get_nodes_info() to get the information of the selected nodes
47
+ - If no selection ask user to select single or multiple nodes
48
+ `,
49
+ },
50
+ },
51
+ ],
52
+ description: "Best practices for reading Figma designs",
53
+ };
54
+ });
55
+ }
@@ -0,0 +1,77 @@
1
+ import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
2
+ import { formatError } from "./logger";
3
+
4
+ /**
5
+ * Format an object response
6
+ */
7
+ export function formatObjectResponse(result: unknown): CallToolResult {
8
+ return {
9
+ content: [
10
+ {
11
+ type: "text",
12
+ text: JSON.stringify(result),
13
+ },
14
+ ],
15
+ };
16
+ }
17
+
18
+ /**
19
+ * Format a text response
20
+ */
21
+ export function formatTextResponse(text: string): CallToolResult {
22
+ return {
23
+ content: [
24
+ {
25
+ type: "text",
26
+ text,
27
+ },
28
+ ],
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Format an image response
34
+ */
35
+ export function formatImageResponse(imageData: string, mimeType = "image/png"): CallToolResult {
36
+ return {
37
+ content: [
38
+ {
39
+ type: "image",
40
+ data: imageData,
41
+ mimeType,
42
+ },
43
+ ],
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Format an error response
49
+ */
50
+ export function formatErrorResponse(toolName: string, error: unknown): CallToolResult {
51
+ return {
52
+ content: [
53
+ {
54
+ type: "text",
55
+ text: `Error in ${toolName}: ${formatError(error)}`,
56
+ },
57
+ ],
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Format a progress response with initial message
63
+ */
64
+ export function formatProgressResponse(initialMessage: string, result: unknown): CallToolResult {
65
+ return {
66
+ content: [
67
+ {
68
+ type: "text",
69
+ text: initialMessage,
70
+ },
71
+ {
72
+ type: "text",
73
+ text: typeof result === "string" ? result : JSON.stringify(result),
74
+ },
75
+ ],
76
+ };
77
+ }