@seed-design/mcp 0.0.6 → 0.0.16

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,248 @@
1
+ import { v4 as uuidv4 } from "uuid";
2
+ import WebSocket from "ws";
3
+ import { logger } from "./logger";
4
+ import type { CommandProgressUpdate, FigmaCommand, FigmaResponse } from "./types";
5
+
6
+ export interface FigmaWebSocketClient {
7
+ connectToFigma: (port?: number) => void;
8
+ joinChannel: (channelName: string) => Promise<void>;
9
+ sendCommandToFigma: (
10
+ command: FigmaCommand,
11
+ params?: unknown,
12
+ timeoutMs?: number,
13
+ ) => Promise<unknown>;
14
+ }
15
+
16
+ // Define a more specific type with an index signature to allow any property access
17
+ interface ProgressMessage {
18
+ message: FigmaResponse | any;
19
+ type?: string;
20
+ id?: string;
21
+ [key: string]: any; // Allow any other properties
22
+ }
23
+
24
+ export function createFigmaWebSocketClient(serverUrl: string) {
25
+ const WS_URL = serverUrl === "localhost" ? `ws://${serverUrl}` : `wss://${serverUrl}`;
26
+
27
+ // Track which channel each client is in
28
+ let currentChannel: string | null = null;
29
+
30
+ // WebSocket connection and request tracking
31
+ let ws: WebSocket | null = null;
32
+ const pendingRequests = new Map<
33
+ string,
34
+ {
35
+ resolve: (value: unknown) => void;
36
+ reject: (reason: unknown) => void;
37
+ timeout: ReturnType<typeof setTimeout>;
38
+ lastActivity: number; // Add timestamp for last activity
39
+ }
40
+ >();
41
+
42
+ // Update the connectToFigma function
43
+ function connectToFigma(port = 3055) {
44
+ // If already connected, do nothing
45
+ if (ws && ws.readyState === WebSocket.OPEN) {
46
+ logger.info("Already connected to Figma");
47
+ return;
48
+ }
49
+
50
+ const wsUrl = serverUrl === "localhost" ? `${WS_URL}:${port}` : WS_URL;
51
+ logger.info(`Connecting to Figma socket server at ${wsUrl}...`);
52
+ ws = new WebSocket(wsUrl);
53
+
54
+ ws.on("open", () => {
55
+ logger.info("Connected to Figma socket server");
56
+ // Reset channel on new connection
57
+ currentChannel = null;
58
+ });
59
+
60
+ ws.on("message", (data: any) => {
61
+ try {
62
+ const json = JSON.parse(data) as ProgressMessage;
63
+
64
+ // Handle progress updates
65
+ if (json.type === "progress_update") {
66
+ const progressData = json.message.data as CommandProgressUpdate;
67
+ const requestId = json.id || "";
68
+
69
+ if (requestId && pendingRequests.has(requestId)) {
70
+ const request = pendingRequests.get(requestId)!;
71
+
72
+ // Update last activity timestamp
73
+ request.lastActivity = Date.now();
74
+
75
+ // Reset the timeout to prevent timeouts during long-running operations
76
+ clearTimeout(request.timeout);
77
+
78
+ // Create a new timeout
79
+ request.timeout = setTimeout(() => {
80
+ if (pendingRequests.has(requestId)) {
81
+ logger.error(`Request ${requestId} timed out after extended period of inactivity`);
82
+ pendingRequests.delete(requestId);
83
+ request.reject(new Error("Request to Figma timed out"));
84
+ }
85
+ }, 60000); // 60 second timeout for inactivity
86
+
87
+ // Log progress
88
+ logger.info(
89
+ `Progress update for ${progressData.commandType}: ${progressData.progress}% - ${progressData.message}`,
90
+ );
91
+
92
+ // For completed updates, we could resolve the request early if desired
93
+ if (progressData.status === "completed" && progressData.progress === 100) {
94
+ // Optionally resolve early with partial data
95
+ // request.resolve(progressData.payload);
96
+ // pendingRequests.delete(requestId);
97
+
98
+ // Instead, just log the completion, wait for final result from Figma
99
+ logger.info(
100
+ `Operation ${progressData.commandType} completed, waiting for final result`,
101
+ );
102
+ }
103
+ }
104
+ return;
105
+ }
106
+
107
+ // Handle regular responses
108
+ const myResponse = json.message;
109
+ logger.debug(`Received message: ${JSON.stringify(myResponse)}`);
110
+ logger.log("myResponse" + JSON.stringify(myResponse));
111
+
112
+ // Handle response to a request
113
+ if (
114
+ myResponse.id &&
115
+ pendingRequests.has(myResponse.id) &&
116
+ (myResponse.result || myResponse.error)
117
+ ) {
118
+ const request = pendingRequests.get(myResponse.id)!;
119
+ clearTimeout(request.timeout);
120
+
121
+ if (myResponse.error) {
122
+ logger.error(`Error from Figma: ${myResponse.error}`);
123
+ request.reject(new Error(myResponse.error));
124
+ } else {
125
+ if (myResponse.result) {
126
+ request.resolve(myResponse.result);
127
+ }
128
+ }
129
+
130
+ pendingRequests.delete(myResponse.id);
131
+ } else {
132
+ // Handle broadcast messages or events not associated with a request ID
133
+ logger.info(`Received broadcast message: ${JSON.stringify(myResponse)}`);
134
+ }
135
+ } catch (error) {
136
+ logger.error(
137
+ `Error parsing message: ${error instanceof Error ? error.message : String(error)}`,
138
+ );
139
+ }
140
+ });
141
+
142
+ ws.on("error", (error) => {
143
+ logger.error(`Socket error: ${error}`);
144
+ });
145
+
146
+ ws.on("close", () => {
147
+ logger.info("Disconnected from Figma socket server");
148
+ ws = null;
149
+
150
+ // Reject all pending requests
151
+ for (const [id, request] of pendingRequests.entries()) {
152
+ clearTimeout(request.timeout);
153
+ request.reject(new Error("Connection closed"));
154
+ pendingRequests.delete(id);
155
+ }
156
+
157
+ // Attempt to reconnect
158
+ logger.info("Attempting to reconnect in 2 seconds...");
159
+ setTimeout(() => connectToFigma(port), 2000);
160
+ });
161
+ }
162
+
163
+ // Function to join a channel
164
+ async function joinChannel(channelName: string): Promise<void> {
165
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
166
+ throw new Error("Not connected to Figma");
167
+ }
168
+
169
+ try {
170
+ await sendCommandToFigma("join", { channel: channelName });
171
+ currentChannel = channelName;
172
+ logger.info(`Joined channel: ${channelName}`);
173
+ } catch (error) {
174
+ logger.error(
175
+ `Failed to join channel: ${error instanceof Error ? error.message : String(error)}`,
176
+ );
177
+ throw error;
178
+ }
179
+ }
180
+
181
+ // Function to send commands to Figma
182
+ function sendCommandToFigma(
183
+ command: FigmaCommand,
184
+ params: unknown = {},
185
+ timeoutMs = 30000,
186
+ ): Promise<unknown> {
187
+ return new Promise((resolve, reject) => {
188
+ // If not connected, try to connect first
189
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
190
+ connectToFigma();
191
+ reject(new Error("Not connected to Figma. Attempting to connect..."));
192
+ return;
193
+ }
194
+
195
+ // Check if we need a channel for this command
196
+ const requiresChannel = command !== "join";
197
+ if (requiresChannel && !currentChannel) {
198
+ reject(new Error("Must join a channel before sending commands"));
199
+ return;
200
+ }
201
+
202
+ const id = uuidv4();
203
+ const request = {
204
+ id,
205
+ type: command === "join" ? "join" : "message",
206
+ ...(command === "join"
207
+ ? { channel: (params as any).channel }
208
+ : { channel: currentChannel }),
209
+ message: {
210
+ id,
211
+ command,
212
+ params: {
213
+ ...(params as any),
214
+ commandId: id, // Include the command ID in params
215
+ },
216
+ },
217
+ };
218
+
219
+ // Set timeout for request
220
+ const timeout = setTimeout(() => {
221
+ if (pendingRequests.has(id)) {
222
+ pendingRequests.delete(id);
223
+ logger.error(`Request ${id} to Figma timed out after ${timeoutMs / 1000} seconds`);
224
+ reject(new Error("Request to Figma timed out"));
225
+ }
226
+ }, timeoutMs);
227
+
228
+ // Store the promise callbacks to resolve/reject later
229
+ pendingRequests.set(id, {
230
+ resolve,
231
+ reject,
232
+ timeout,
233
+ lastActivity: Date.now(),
234
+ });
235
+
236
+ // Send the request
237
+ logger.info(`Sending command to Figma: ${command}`);
238
+ logger.debug(`Request details: ${JSON.stringify(request)}`);
239
+ ws.send(JSON.stringify(request));
240
+ });
241
+ }
242
+
243
+ return {
244
+ connectToFigma,
245
+ joinChannel,
246
+ sendCommandToFigma,
247
+ };
248
+ }
package/src/config.ts DELETED
@@ -1,166 +0,0 @@
1
- import yargs from "yargs";
2
- import { hideBin } from "yargs/helpers";
3
- import type { Logger } from "./logger";
4
- import { NoOpLogger } from "./logger";
5
-
6
- /**
7
- * Configuration for the Figma MCP Server
8
- */
9
- export interface ServerConfig {
10
- figmaApiKey: string;
11
- port: number;
12
- configSources: {
13
- figmaApiKey: ConfigSource;
14
- port: ConfigSource;
15
- };
16
- }
17
-
18
- /**
19
- * Source of configuration value
20
- */
21
- type ConfigSource = "cli" | "env" | "default";
22
-
23
- /**
24
- * Command line arguments
25
- */
26
- interface CliArgs {
27
- "figma-api-key"?: string;
28
- port?: number;
29
- }
30
-
31
- /**
32
- * Configuration manager for the Figma MCP Server
33
- */
34
- export class ConfigManager {
35
- private readonly isStdioMode: boolean;
36
- private readonly logger: Logger;
37
-
38
- /**
39
- * Creates a new ConfigManager instance
40
- */
41
- constructor(options: { isStdioMode: boolean; logger?: Logger }) {
42
- this.isStdioMode = options.isStdioMode;
43
- this.logger = options.logger || NoOpLogger;
44
- }
45
-
46
- /**
47
- * Gets the server configuration from command line arguments and environment variables
48
- */
49
- getServerConfig(): ServerConfig {
50
- // Parse command line arguments
51
- const argv = this.parseCommandLineArgs();
52
-
53
- // Initialize config with default values
54
- const config: ServerConfig = {
55
- figmaApiKey: "",
56
- port: 3333,
57
- configSources: {
58
- figmaApiKey: "env",
59
- port: "default",
60
- },
61
- };
62
-
63
- // Handle FIGMA_API_KEY
64
- this.configureFigmaApiKey(config, argv);
65
-
66
- // Handle PORT
67
- this.configurePort(config, argv);
68
-
69
- // Validate configuration
70
- this.validateConfig(config);
71
-
72
- // Log configuration sources
73
- this.logConfig(config);
74
-
75
- return config;
76
- }
77
-
78
- /**
79
- * Parses command line arguments
80
- */
81
- private parseCommandLineArgs(): CliArgs {
82
- return yargs(hideBin(process.argv))
83
- .options({
84
- "figma-api-key": {
85
- type: "string",
86
- description: "Figma API key",
87
- },
88
- port: {
89
- type: "number",
90
- description: "Port to run the server on",
91
- },
92
- })
93
- .help()
94
- .parseSync() as CliArgs;
95
- }
96
-
97
- /**
98
- * Configures the Figma API key
99
- */
100
- private configureFigmaApiKey(config: ServerConfig, argv: CliArgs): void {
101
- if (argv["figma-api-key"]) {
102
- config.figmaApiKey = argv["figma-api-key"];
103
- config.configSources.figmaApiKey = "cli";
104
- } else if (process.env["FIGMA_API_KEY"]) {
105
- config.figmaApiKey = process.env["FIGMA_API_KEY"];
106
- config.configSources.figmaApiKey = "env";
107
- }
108
- }
109
-
110
- /**
111
- * Configures the server port
112
- */
113
- private configurePort(config: ServerConfig, argv: CliArgs): void {
114
- if (argv.port) {
115
- config.port = argv.port;
116
- config.configSources.port = "cli";
117
- } else if (process.env["PORT"]) {
118
- config.port = Number.parseInt(process.env["PORT"], 10);
119
- config.configSources.port = "env";
120
- }
121
- }
122
-
123
- /**
124
- * Validates the configuration
125
- */
126
- private validateConfig(config: ServerConfig): void {
127
- if (!config.figmaApiKey) {
128
- this.logger.error(
129
- "FIGMA_API_KEY is required (via CLI argument --figma-api-key or .env file)",
130
- );
131
- process.exit(1);
132
- }
133
- }
134
-
135
- /**
136
- * Logs the configuration
137
- */
138
- private logConfig(config: ServerConfig): void {
139
- if (this.isStdioMode) {
140
- return;
141
- }
142
-
143
- this.logger.log("\nConfiguration:");
144
- this.logger.log(
145
- `- FIGMA_API_KEY: ${this.maskApiKey(config.figmaApiKey)} (source: ${config.configSources.figmaApiKey})`,
146
- );
147
- this.logger.log(`- PORT: ${config.port} (source: ${config.configSources.port})`);
148
- this.logger.log(""); // Empty line for better readability
149
- }
150
-
151
- /**
152
- * Masks an API key for secure logging
153
- */
154
- private maskApiKey(key: string): string {
155
- if (key.length <= 4) return "****";
156
- return `****${key.slice(-4)}`;
157
- }
158
- }
159
-
160
- /**
161
- * Gets the server configuration
162
- */
163
- export function getServerConfig(isStdioMode: boolean): ServerConfig {
164
- const configManager = new ConfigManager({ isStdioMode });
165
- return configManager.getServerConfig();
166
- }
package/src/figma.ts DELETED
@@ -1,211 +0,0 @@
1
- import type { GetFileNodesResponse, GetImagesResponse } from "@figma/rest-api-spec";
2
- import fs from "fs";
3
- import { generateCode, createRestNormalizer } from "@seed-design/figma";
4
- import type { Logger } from "./logger";
5
- import { NoOpLogger } from "./logger";
6
-
7
- export interface FigmaError {
8
- status: number;
9
- err: string;
10
- }
11
-
12
- export interface FetchImageParams {
13
- /**
14
- * The Node in Figma that will either be rendered or have its background image downloaded
15
- */
16
- nodeId: string;
17
- /**
18
- * The file mimetype for the image
19
- */
20
- fileType: "png" | "svg";
21
- /**
22
- * The filename to save the image as
23
- */
24
- name: string;
25
- }
26
-
27
- export interface FigmaImage {
28
- nodeId: string;
29
- name: string;
30
- blob: Buffer;
31
- fileType: "png" | "svg";
32
- }
33
-
34
- export interface SimplifiedDesign {
35
- name: string;
36
- lastModified: string;
37
- code: string;
38
- }
39
-
40
- interface FigmaServiceOptions {
41
- apiKey: string;
42
- baseUrl?: string;
43
- logger?: Logger;
44
- }
45
-
46
- export class FigmaService {
47
- private readonly apiKey: string;
48
- private readonly baseUrl: string;
49
- private readonly logger: Logger;
50
-
51
- constructor(options: FigmaServiceOptions) {
52
- this.apiKey = options.apiKey;
53
- this.baseUrl = options.baseUrl || "https://api.figma.com/v1";
54
- this.logger = options.logger || NoOpLogger;
55
- }
56
-
57
- private async request<T>(endpoint: string): Promise<T> {
58
- try {
59
- this.logger.log(`Calling ${this.baseUrl}${endpoint}`);
60
- const response = await fetch(`${this.baseUrl}${endpoint}`, {
61
- headers: {
62
- "X-Figma-Token": this.apiKey,
63
- },
64
- });
65
-
66
- if (!response.ok) {
67
- const errorData = (await response.json().catch(() => ({}))) as { err?: string };
68
- throw {
69
- status: response.status,
70
- err: errorData.err || "Unknown error",
71
- } as FigmaError;
72
- }
73
-
74
- return response.json() as Promise<T>;
75
- } catch (error) {
76
- if (error instanceof Error) {
77
- throw new Error(`Failed to make request to Figma API: ${error.message}`);
78
- }
79
- throw error;
80
- }
81
- }
82
-
83
- async getImages(fileKey: string, nodes: FetchImageParams[]): Promise<FigmaImage[]> {
84
- const pngNodes = nodes.filter(({ fileType }) => fileType === "png");
85
- const svgNodes = nodes.filter(({ fileType }) => fileType === "svg");
86
-
87
- const imagesMap = await this.fetchImageUrls(fileKey, pngNodes, svgNodes);
88
-
89
- const images = await this.fetchImage(nodes, imagesMap);
90
-
91
- return images;
92
- }
93
-
94
- private async fetchImageUrls(
95
- fileKey: string,
96
- pngNodes: FetchImageParams[],
97
- svgNodes: FetchImageParams[],
98
- ): Promise<Record<string, string>> {
99
- const pngIds = pngNodes.map(({ nodeId }) => nodeId);
100
- const pngFiles =
101
- pngIds.length > 0
102
- ? this.request<GetImagesResponse>(
103
- `/images/${fileKey}?ids=${pngIds.join(",")}&scale=2&format=png`,
104
- ).then(({ images = {} }) => images)
105
- : ({} as GetImagesResponse["images"]);
106
-
107
- const svgIds = svgNodes.map(({ nodeId }) => nodeId);
108
- const svgFiles =
109
- svgIds.length > 0
110
- ? this.request<GetImagesResponse>(
111
- `/images/${fileKey}?ids=${svgIds.join(",")}&format=svg`,
112
- ).then(({ images = {} }) => images)
113
- : ({} as GetImagesResponse["images"]);
114
-
115
- const [pngImages, svgImages] = await Promise.all([pngFiles, svgFiles]);
116
- const combinedImages: Record<string, string> = {};
117
-
118
- Object.entries({ ...pngImages, ...svgImages }).forEach(([key, value]) => {
119
- if (value !== null) {
120
- combinedImages[key] = value;
121
- }
122
- });
123
-
124
- return combinedImages;
125
- }
126
-
127
- private async fetchImage(
128
- nodes: FetchImageParams[],
129
- imagesMap: Record<string, string>,
130
- ): Promise<FigmaImage[]> {
131
- const fetchPromises = nodes
132
- .map(({ nodeId, name, fileType }) => {
133
- const imageUrl = imagesMap[nodeId];
134
- if (imageUrl) {
135
- return fetch(imageUrl)
136
- .then((response) => response.arrayBuffer())
137
- .then((arrayBuffer) => Buffer.from(arrayBuffer))
138
- .then((buffer) => {
139
- return {
140
- nodeId,
141
- name,
142
- blob: buffer,
143
- fileType,
144
- };
145
- })
146
- .catch((error) => {
147
- this.logger.error(`Failed to fetch image for ${nodeId}:`, error);
148
- return undefined;
149
- });
150
- }
151
- return undefined;
152
- })
153
- .filter((x): x is Promise<FigmaImage> => x !== undefined);
154
-
155
- return Promise.all(fetchPromises);
156
- }
157
-
158
- async getGeneratedCode(
159
- fileKey: string,
160
- nodeId: string,
161
- depth?: number,
162
- ): Promise<SimplifiedDesign> {
163
- const endpoint = `/files/${fileKey}/nodes?ids=${nodeId}${depth ? `&depth=${depth}` : ""}`;
164
- const response = await this.request<GetFileNodesResponse>(endpoint);
165
-
166
- this.writeDebugLogs("figma-raw.json", response);
167
-
168
- const node = Object.values(response.nodes)[0]!;
169
-
170
- const normalizer = createRestNormalizer({
171
- styles: node.styles,
172
- components: node.components,
173
- componentSets: node.componentSets,
174
- });
175
-
176
- const normalizedNode = normalizer(node.document);
177
- const code = generateCode(normalizedNode) ?? "";
178
-
179
- const result = {
180
- name: node.document.name,
181
- lastModified: response.lastModified,
182
- code,
183
- };
184
-
185
- this.writeDebugLogs("figma-result.json", result);
186
- return result;
187
- }
188
-
189
- private writeDebugLogs(name: string, value: any): void {
190
- try {
191
- if (process.env["NODE_ENV"] !== "development") return;
192
-
193
- const logsDir = "logs";
194
-
195
- try {
196
- fs.accessSync(process.cwd(), fs.constants.W_OK);
197
- } catch (error) {
198
- this.logger.error("Failed to write logs:", error);
199
- return;
200
- }
201
-
202
- if (!fs.existsSync(logsDir)) {
203
- fs.mkdirSync(logsDir);
204
- }
205
-
206
- fs.writeFileSync(`${logsDir}/${name}`, JSON.stringify(value, null, 2));
207
- } catch (error) {
208
- this.logger.error("Failed to write logs:", error);
209
- }
210
- }
211
- }
package/src/index.ts DELETED
@@ -1,36 +0,0 @@
1
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
2
- import { FigmaMcpServer } from "./server";
3
- import { getServerConfig } from "./config";
4
- import { ConsoleLogger } from "./logger";
5
-
6
- /**
7
- * Determines if the server should run in stdio mode
8
- */
9
- function isRunningInStdioMode(): boolean {
10
- return process.env["NODE_ENV"] === "cli" || process.argv.includes("--stdio");
11
- }
12
-
13
- /**
14
- * Starts the Figma MCP server
15
- */
16
- export async function startServer(): Promise<void> {
17
- // Determine server mode (stdio or HTTP)
18
- const stdioMode = isRunningInStdioMode();
19
-
20
- // Get configuration
21
- const config = getServerConfig(stdioMode);
22
-
23
- // Create server instance
24
- const server = new FigmaMcpServer(config.figmaApiKey);
25
-
26
- // Start in appropriate mode
27
- if (stdioMode) {
28
- // Connect to stdio transport for CLI mode
29
- const transport = new StdioServerTransport();
30
- await server.connect(transport);
31
- } else {
32
- // Start HTTP server for web mode
33
- ConsoleLogger.log(`Initializing Figma MCP Server in HTTP mode on port ${config.port}...`);
34
- await server.startHttpServer(config.port);
35
- }
36
- }
package/src/save-image.ts DELETED
@@ -1,16 +0,0 @@
1
- import fs from "node:fs";
2
- import * as path from "node:path";
3
- import * as os from "node:os";
4
-
5
- const defaultDownloadsPath = path.join(os.homedir(), "Downloads");
6
-
7
- export function saveImage(image: Buffer, name: string, type: "png" | "svg") {
8
- if (!fs.existsSync(defaultDownloadsPath)) {
9
- fs.mkdirSync(defaultDownloadsPath, { recursive: true });
10
- }
11
-
12
- const filePath = path.join(defaultDownloadsPath, `${name}.${type}`);
13
- fs.writeFileSync(filePath, image);
14
-
15
- return filePath;
16
- }