@seed-design/mcp 1.3.0 → 1.3.2

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/bin/index.mjs CHANGED
@@ -10,7 +10,7 @@ import WebSocket$1 from 'ws';
10
10
  import { createRestNormalizer, figma, react, getFigmaColorVariableNames } from '@seed-design/figma';
11
11
  import { z } from 'zod';
12
12
 
13
- var version = "1.3.0";
13
+ var version = "1.3.2";
14
14
 
15
15
  /**
16
16
  * Custom logging module that writes to stderr instead of stdout
@@ -0,0 +1,46 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { CreatePipelineConfig } from '@seed-design/figma/codegen/targets/react';
3
+ import { GetFileNodesResponse } from '@figma/rest-api-spec';
4
+
5
+ interface McpConfig {
6
+ extend?: CreatePipelineConfig["extend"];
7
+ }
8
+
9
+ interface FigmaRestClient {
10
+ getFileNodes(fileKey: string, nodeIds: string[]): Promise<GetFileNodesResponse>;
11
+ }
12
+ declare function createFigmaRestClient(personalAccessToken: string): FigmaRestClient;
13
+ /**
14
+ * https://www.figma.com/:file_type/:file_key/:file_name?node-id=:id
15
+ *
16
+ * file_type:
17
+ * - design
18
+ * - file (legacy)
19
+ *
20
+ * Note: While node-id is separated by hyphens ('-') in the URL,
21
+ * it must be converted to colons (':') when making API calls.
22
+ * e.g. URL "node-id=794-1987" → API "794:1987"
23
+ */
24
+ declare function parseFigmaUrl(url: string): {
25
+ fileKey: string;
26
+ nodeId: string;
27
+ };
28
+
29
+ /**
30
+ * Command types supported by the Figma plugin
31
+ */
32
+ type FigmaCommand = "get_document_info" | "get_selection" | "get_node_info" | "get_nodes_info" | "export_node_as_image" | "join" | "clone_node" | "add_annotations" | "get_annotations" | "set_fill_color" | "set_stroke_color" | "set_auto_layout" | "set_size";
33
+
34
+ interface FigmaWebSocketClient {
35
+ connectToFigma: (port?: number) => void;
36
+ joinChannel: (channelName: string) => Promise<void>;
37
+ sendCommandToFigma: (command: FigmaCommand, params?: unknown, timeoutMs?: number) => Promise<unknown>;
38
+ }
39
+
40
+ type ToolMode = "rest" | "websocket" | "all";
41
+
42
+ declare function registerTools(server: McpServer, figmaClient: FigmaWebSocketClient | null, restClient: FigmaRestClient | null, config: McpConfig | null, mode: ToolMode): void;
43
+
44
+ export { createFigmaRestClient, parseFigmaUrl, registerTools };
45
+ export type { FigmaRestClient, ToolMode };
46
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sources":["../src/config.ts","../src/figma-rest-client.ts","../src/types.ts","../src/websocket.ts","../src/tools-helpers.ts","../src/tools.ts"],"sourcesContent":["import type { CreatePipelineConfig } from \"@seed-design/figma/codegen/targets/react\";\nimport fs from \"node:fs\";\nimport path from \"node:path\";\nimport { logger } from \"./logger\";\n\n// Define config type\nexport interface McpConfig {\n extend?: CreatePipelineConfig[\"extend\"];\n}\n\n// Config loader\nexport async function loadConfig(configPath: string) {\n try {\n const resolvedPath = path.resolve(process.cwd(), configPath);\n\n if (!fs.existsSync(resolvedPath)) {\n logger.error(`Config file not found: ${resolvedPath}`);\n return null;\n }\n\n // Handle different file types\n if (resolvedPath.endsWith(\".json\")) {\n const content = fs.readFileSync(resolvedPath, \"utf-8\");\n return JSON.parse(content);\n }\n\n if (\n resolvedPath.endsWith(\".js\") ||\n resolvedPath.endsWith(\".mjs\") ||\n resolvedPath.endsWith(\".ts\") ||\n resolvedPath.endsWith(\".mts\")\n ) {\n // For JS/MJS/TS/MTS files, we can dynamically import with Bun\n // Bun has built-in TypeScript support without requiring transpilation\n const config = await import(resolvedPath);\n return config.default || config;\n }\n\n logger.error(`Unsupported config file format: ${resolvedPath}`);\n return null;\n } catch (error) {\n logger.error(\n `Failed to load config file: ${error instanceof Error ? error.message : String(error)}`,\n );\n return null;\n }\n}\n","import { Api as FigmaApi } from \"figma-api\";\nimport type { GetFileNodesResponse } from \"@figma/rest-api-spec\";\n\nexport interface FigmaRestClient {\n getFileNodes(fileKey: string, nodeIds: string[]): Promise<GetFileNodesResponse>;\n}\n\nexport function createFigmaRestClient(personalAccessToken: string): FigmaRestClient {\n const api = new FigmaApi({ personalAccessToken });\n\n return {\n async getFileNodes(fileKey: string, nodeIds: string[]): Promise<GetFileNodesResponse> {\n const response = await api.getFileNodes({ file_key: fileKey }, { ids: nodeIds.join(\",\") });\n\n return response;\n },\n };\n}\n\n/**\n * https://www.figma.com/:file_type/:file_key/:file_name?node-id=:id\n *\n * file_type:\n * - design\n * - file (legacy)\n *\n * Note: While node-id is separated by hyphens ('-') in the URL,\n * it must be converted to colons (':') when making API calls.\n * e.g. URL \"node-id=794-1987\" → API \"794:1987\"\n */\nexport function parseFigmaUrl(url: string): { fileKey: string; nodeId: string } {\n const __url: URL = (() => {\n try {\n return new URL(url);\n } catch {\n throw new Error(`Invalid URL format: ${url}`);\n }\n })();\n\n const pathMatch = __url.pathname.match(/^\\/(design|file)\\/([A-Za-z0-9]+)/);\n\n const rawNodeId = __url.searchParams.get(\"node-id\");\n\n if (!pathMatch)\n throw new Error(\n \"Invalid Figma URL: Expected format https://www.figma.com/design/{fileKey}/... or /file/{fileKey}/...\",\n );\n\n if (!rawNodeId) throw new Error(\"Invalid Figma URL: Missing node-id query parameter\");\n\n return {\n fileKey: pathMatch[2],\n nodeId: rawNodeId.replace(/-/g, \":\"),\n };\n}\n","// TypeScript interfaces for Figma API communication\n\n/**\n * Basic response from Figma WebSocket API\n */\nexport interface FigmaResponse {\n id: string;\n result?: any;\n error?: string;\n}\n\n/**\n * Progress update for long-running commands\n */\nexport interface CommandProgressUpdate {\n type: \"command_progress\";\n commandId: string;\n commandType: string;\n status: \"started\" | \"in_progress\" | \"completed\" | \"error\";\n progress: number;\n totalItems: number;\n processedItems: number;\n currentChunk?: number;\n totalChunks?: number;\n chunkSize?: number;\n message: string;\n payload?: any;\n timestamp: number;\n}\n\n/**\n * Command types supported by the Figma plugin\n */\nexport type FigmaCommand =\n | \"get_document_info\"\n | \"get_selection\"\n | \"get_node_info\"\n | \"get_nodes_info\"\n | \"export_node_as_image\"\n | \"join\"\n | \"clone_node\"\n | \"add_annotations\"\n | \"get_annotations\"\n | \"set_fill_color\"\n | \"set_stroke_color\"\n | \"set_auto_layout\"\n | \"set_size\";\n\n/**\n * Pending request information\n */\nexport interface PendingRequest {\n resolve: (value: unknown) => void;\n reject: (reason: unknown) => void;\n timeout: ReturnType<typeof setTimeout>;\n lastActivity: number;\n}\n\n/**\n * WebSocket message from Figma\n */\nexport interface FigmaWebSocketMessage {\n message: FigmaResponse | any;\n type?: string;\n id?: string;\n [key: string]: any;\n}\n","import { v4 as uuidv4 } from \"uuid\";\nimport WebSocket from \"ws\";\nimport { logger } from \"./logger\";\nimport type { CommandProgressUpdate, FigmaCommand, FigmaResponse } from \"./types\";\n\nexport interface FigmaWebSocketClient {\n connectToFigma: (port?: number) => void;\n joinChannel: (channelName: string) => Promise<void>;\n sendCommandToFigma: (\n command: FigmaCommand,\n params?: unknown,\n timeoutMs?: number,\n ) => Promise<unknown>;\n}\n\n// Define a more specific type with an index signature to allow any property access\ninterface ProgressMessage {\n message: FigmaResponse | any;\n type?: string;\n id?: string;\n [key: string]: any; // Allow any other properties\n}\n\nexport function createFigmaWebSocketClient(serverUrl: string) {\n const WS_URL = serverUrl === \"localhost\" ? `ws://${serverUrl}` : `wss://${serverUrl}`;\n\n // Track which channel each client is in\n let currentChannel: string | null = null;\n\n // WebSocket connection and request tracking\n let ws: WebSocket | null = null;\n const pendingRequests = new Map<\n string,\n {\n resolve: (value: unknown) => void;\n reject: (reason: unknown) => void;\n timeout: ReturnType<typeof setTimeout>;\n lastActivity: number; // Add timestamp for last activity\n }\n >();\n\n // Update the connectToFigma function\n function connectToFigma(port = 3055) {\n // If already connected, do nothing\n if (ws && ws.readyState === WebSocket.OPEN) {\n logger.info(\"Already connected to Figma\");\n return;\n }\n\n const wsUrl = serverUrl === \"localhost\" ? `${WS_URL}:${port}` : WS_URL;\n logger.info(`Connecting to Figma socket server at ${wsUrl}...`);\n ws = new WebSocket(wsUrl);\n\n ws.on(\"open\", () => {\n logger.info(\"Connected to Figma socket server\");\n // Reset channel on new connection\n joinChannel(\"local-default\");\n });\n\n ws.on(\"message\", (data: any) => {\n try {\n const json = JSON.parse(data) as ProgressMessage;\n\n // Handle progress updates\n if (json.type === \"progress_update\") {\n const progressData = json.message.data as CommandProgressUpdate;\n const requestId = json.id || \"\";\n\n if (requestId && pendingRequests.has(requestId)) {\n const request = pendingRequests.get(requestId)!;\n\n // Update last activity timestamp\n request.lastActivity = Date.now();\n\n // Reset the timeout to prevent timeouts during long-running operations\n clearTimeout(request.timeout);\n\n // Create a new timeout\n request.timeout = setTimeout(() => {\n if (pendingRequests.has(requestId)) {\n logger.error(`Request ${requestId} timed out after extended period of inactivity`);\n pendingRequests.delete(requestId);\n request.reject(new Error(\"Request to Figma timed out\"));\n }\n }, 60000); // 60 second timeout for inactivity\n\n // Log progress\n logger.info(\n `Progress update for ${progressData.commandType}: ${progressData.progress}% - ${progressData.message}`,\n );\n\n // For completed updates, we could resolve the request early if desired\n if (progressData.status === \"completed\" && progressData.progress === 100) {\n // Optionally resolve early with partial data\n // request.resolve(progressData.payload);\n // pendingRequests.delete(requestId);\n\n // Instead, just log the completion, wait for final result from Figma\n logger.info(\n `Operation ${progressData.commandType} completed, waiting for final result`,\n );\n }\n }\n return;\n }\n\n // Handle regular responses\n const myResponse = json.message;\n logger.debug(`Received message: ${JSON.stringify(myResponse)}`);\n logger.log(\"myResponse\" + JSON.stringify(myResponse));\n\n // Handle response to a request\n if (\n myResponse.id &&\n pendingRequests.has(myResponse.id) &&\n (myResponse.result || myResponse.error)\n ) {\n const request = pendingRequests.get(myResponse.id)!;\n clearTimeout(request.timeout);\n\n if (myResponse.error) {\n logger.error(`Error from Figma: ${myResponse.error}`);\n request.reject(new Error(myResponse.error));\n } else {\n if (myResponse.result) {\n request.resolve(myResponse.result);\n }\n }\n\n pendingRequests.delete(myResponse.id);\n } else {\n // Handle broadcast messages or events not associated with a request ID\n logger.info(`Received broadcast message: ${JSON.stringify(myResponse)}`);\n }\n } catch (error) {\n logger.error(\n `Error parsing message: ${error instanceof Error ? error.message : String(error)}`,\n );\n }\n });\n\n ws.on(\"error\", (error) => {\n logger.error(`Socket error: ${error}`);\n });\n\n ws.on(\"close\", () => {\n logger.info(\"Disconnected from Figma socket server\");\n ws = null;\n\n // Reject all pending requests\n for (const [id, request] of pendingRequests.entries()) {\n clearTimeout(request.timeout);\n request.reject(new Error(\"Connection closed\"));\n pendingRequests.delete(id);\n }\n\n // Attempt to reconnect\n logger.info(\"Attempting to reconnect in 2 seconds...\");\n setTimeout(() => connectToFigma(port), 2000);\n });\n }\n\n // Function to join a channel\n async function joinChannel(channelName: string): Promise<void> {\n if (!ws || ws.readyState !== WebSocket.OPEN) {\n throw new Error(\"Not connected to Figma\");\n }\n\n try {\n await sendCommandToFigma(\"join\", { channel: channelName });\n currentChannel = channelName;\n logger.info(`Joined channel: ${channelName}`);\n } catch (error) {\n logger.error(\n `Failed to join channel: ${error instanceof Error ? error.message : String(error)}`,\n );\n throw error;\n }\n }\n\n // Function to send commands to Figma\n function sendCommandToFigma(\n command: FigmaCommand,\n params: unknown = {},\n timeoutMs = 30000,\n ): Promise<unknown> {\n return new Promise((resolve, reject) => {\n // If not connected, try to connect first\n if (!ws || ws.readyState !== WebSocket.OPEN) {\n connectToFigma();\n reject(new Error(\"Not connected to Figma. Attempting to connect...\"));\n return;\n }\n\n // Check if we need a channel for this command\n const requiresChannel = command !== \"join\";\n if (requiresChannel && !currentChannel) {\n reject(new Error(\"Must join a channel before sending commands\"));\n return;\n }\n\n const id = uuidv4();\n const request = {\n id,\n type: command === \"join\" ? \"join\" : \"message\",\n ...(command === \"join\"\n ? { channel: (params as any).channel }\n : { channel: currentChannel }),\n message: {\n id,\n command,\n params: {\n ...(params as any),\n commandId: id, // Include the command ID in params\n },\n },\n };\n\n // Set timeout for request\n const timeout = setTimeout(() => {\n if (pendingRequests.has(id)) {\n pendingRequests.delete(id);\n logger.error(`Request ${id} to Figma timed out after ${timeoutMs / 1000} seconds`);\n reject(new Error(\"Request to Figma timed out\"));\n }\n }, timeoutMs);\n\n // Store the promise callbacks to resolve/reject later\n pendingRequests.set(id, {\n resolve,\n reject,\n timeout,\n lastActivity: Date.now(),\n });\n\n // Send the request\n logger.info(`Sending command to Figma: ${command}`);\n logger.debug(`Request details: ${JSON.stringify(request)}`);\n ws.send(JSON.stringify(request));\n });\n }\n\n return {\n connectToFigma,\n joinChannel,\n sendCommandToFigma,\n };\n}\n","import type { GetFileNodesResponse } from \"@figma/rest-api-spec\";\nimport type { FigmaRestClient } from \"./figma-rest-client\";\nimport { createFigmaRestClient } from \"./figma-rest-client\";\nimport type { FigmaWebSocketClient } from \"./websocket\";\nimport type { McpConfig } from \"./config\";\n\nexport type ToolMode = \"rest\" | \"websocket\" | \"all\";\n\nexport interface ToolContext {\n sendCommandToFigma: FigmaWebSocketClient[\"sendCommandToFigma\"] | null;\n restClient: FigmaRestClient | null;\n mode: ToolMode;\n extend?: McpConfig[\"extend\"];\n}\n\nexport function createToolContext(\n figmaClient: FigmaWebSocketClient | null,\n restClient: FigmaRestClient | null,\n config: McpConfig | null,\n mode: ToolMode,\n): ToolContext {\n return {\n sendCommandToFigma: figmaClient?.sendCommandToFigma ?? null,\n restClient,\n mode,\n extend: config?.extend,\n };\n}\n\nfunction resolveRestClient(\n personalAccessToken: string | undefined,\n context: ToolContext,\n): FigmaRestClient | null {\n if (context.mode === \"websocket\") {\n return null;\n }\n\n if (personalAccessToken) {\n return createFigmaRestClient(personalAccessToken);\n }\n\n return context.restClient;\n}\n\nexport async function fetchNodeData(\n params: { fileKey?: string; nodeId: string; personalAccessToken?: string },\n context: ToolContext,\n): Promise<GetFileNodesResponse[\"nodes\"][string]> {\n const { fileKey, nodeId, personalAccessToken } = params;\n const restClient = resolveRestClient(personalAccessToken, context);\n const { sendCommandToFigma } = context;\n\n if (restClient && fileKey) {\n const response = await restClient.getFileNodes(fileKey, [nodeId]);\n const nodeData = response.nodes[nodeId];\n\n if (!nodeData) throw new Error(`Node ${nodeId} not found in file ${fileKey}`);\n\n return nodeData;\n }\n\n if (sendCommandToFigma) {\n return (await sendCommandToFigma(\"get_node_info\", {\n nodeId,\n })) as GetFileNodesResponse[\"nodes\"][string];\n }\n\n throw new Error(\n \"No connection available. Provide figmaUrl/fileKey with personalAccessToken or FIGMA_PERSONAL_ACCESS_TOKEN, or use WebSocket mode with Figma Plugin.\",\n );\n}\n\nexport async function fetchMultipleNodesData(\n params: { fileKey?: string; nodeIds: string[]; personalAccessToken?: string },\n context: ToolContext,\n): Promise<GetFileNodesResponse[\"nodes\"]> {\n const { fileKey, nodeIds, personalAccessToken } = params;\n const restClient = resolveRestClient(personalAccessToken, context);\n const { sendCommandToFigma } = context;\n\n if (restClient && fileKey) {\n const response = await restClient.getFileNodes(fileKey, nodeIds);\n\n return response.nodes;\n }\n\n if (sendCommandToFigma) {\n const results: GetFileNodesResponse[\"nodes\"] = {};\n\n await Promise.all(\n nodeIds.map(async (nodeId) => {\n const data = (await sendCommandToFigma(\"get_node_info\", {\n nodeId,\n })) as GetFileNodesResponse[\"nodes\"][string];\n\n results[nodeId] = data;\n }),\n );\n\n return results;\n }\n\n throw new Error(\n \"No connection available. Provide figmaUrl/fileKey with personalAccessToken or FIGMA_PERSONAL_ACCESS_TOKEN, or use WebSocket mode with Figma Plugin.\",\n );\n}\n\nexport function requireWebSocket(context: ToolContext): asserts context is ToolContext & {\n sendCommandToFigma: NonNullable<ToolContext[\"sendCommandToFigma\"]>;\n} {\n if (!context.sendCommandToFigma)\n throw new Error(\"WebSocket not available. This tool requires Figma Plugin connection.\");\n}\n","import type { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\";\nimport { createRestNormalizer, figma, getFigmaColorVariableNames, react } from \"@seed-design/figma\";\nimport { z } from \"zod\";\nimport type { McpConfig } from \"./config\";\nimport { parseFigmaUrl } from \"./figma-rest-client\";\nimport { formatError } from \"./logger\";\nimport {\n formatErrorResponse,\n formatImageResponse,\n formatObjectResponse,\n formatTextResponse,\n} from \"./responses\";\nimport type { FigmaRestClient } from \"./figma-rest-client\";\nimport {\n createToolContext,\n fetchMultipleNodesData,\n fetchNodeData,\n requireWebSocket,\n type ToolMode,\n} from \"./tools-helpers\";\nimport type { FigmaWebSocketClient } from \"./websocket\";\n\nconst singleNodeBaseSchema = z.object({\n figmaUrl: z\n .url()\n .optional()\n .describe(\"Figma node URL. Extracts fileKey and nodeId automatically when provided.\"),\n fileKey: z\n .string()\n .optional()\n .describe(\"Figma file key. Use with nodeId when not using figmaUrl.\"),\n nodeId: z.string().optional().describe(\"Node ID (e.g., '0:1').\"),\n personalAccessToken: z\n .string()\n .optional()\n .describe(\"Figma PAT. Falls back to FIGMA_PERSONAL_ACCESS_TOKEN env when not provided.\"),\n});\n\nconst multiNodeBaseSchema = z.object({\n fileKey: z\n .string()\n .optional()\n .describe(\"Figma file key. Required when WebSocket connection is not available.\"),\n nodeIds: z.array(z.string()).describe(\"Array of node IDs (e.g., ['0:1', '0:2'])\"),\n personalAccessToken: z\n .string()\n .optional()\n .describe(\n \"Figma PAT. Falls back to FIGMA_PERSONAL_ACCESS_TOKEN env when not provided to be used when WebSocket connection is not available.\",\n ),\n});\n\nfunction getSingleNodeParamsSchema(mode: ToolMode) {\n switch (mode) {\n case \"websocket\":\n return singleNodeBaseSchema.pick({ nodeId: true }).required();\n default:\n return singleNodeBaseSchema;\n }\n}\n\nfunction getMultiNodeParamsSchema(mode: ToolMode) {\n switch (mode) {\n case \"websocket\":\n return multiNodeBaseSchema.pick({ nodeIds: true });\n case \"rest\":\n return multiNodeBaseSchema.required({ fileKey: true });\n default:\n return multiNodeBaseSchema;\n }\n}\n\nfunction resolveSingleNodeParams(params: z.infer<typeof singleNodeBaseSchema>): {\n fileKey: string | undefined;\n nodeId: string;\n personalAccessToken: string | undefined;\n} {\n if (params.figmaUrl) {\n const parsed = parseFigmaUrl(params.figmaUrl);\n\n return {\n fileKey: parsed.fileKey,\n nodeId: parsed.nodeId,\n personalAccessToken: params.personalAccessToken,\n };\n }\n\n if (!params.nodeId) {\n throw new Error(\n \"Either figmaUrl or nodeId must be provided. Use figmaUrl for automatic parsing, or provide fileKey + nodeId directly.\",\n );\n }\n\n return {\n fileKey: params.fileKey,\n nodeId: params.nodeId,\n personalAccessToken: params.personalAccessToken,\n };\n}\n\nfunction getSingleNodeDescription(baseDescription: string, mode: ToolMode): string {\n switch (mode) {\n case \"rest\":\n return `${baseDescription} Provide either: (1) figmaUrl (e.g., https://www.figma.com/design/ABC/Name?node-id=0-1), or (2) fileKey + nodeId.`;\n case \"websocket\":\n return `${baseDescription} Provide nodeId. Requires WebSocket connection with Figma Plugin.`;\n case \"all\":\n 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.`;\n }\n}\n\nfunction getMultiNodeDescription(baseDescription: string, mode: ToolMode): string {\n switch (mode) {\n case \"rest\":\n return `${baseDescription} Provide fileKey + nodeIds.`;\n case \"websocket\":\n return `${baseDescription} Provide nodeIds. Requires WebSocket connection with Figma Plugin.`;\n case \"all\":\n 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.`;\n }\n}\n\nexport function registerTools(\n server: McpServer,\n figmaClient: FigmaWebSocketClient | null,\n restClient: FigmaRestClient | null,\n config: McpConfig | null,\n mode: ToolMode,\n): void {\n const context = createToolContext(figmaClient, restClient, config, mode);\n const singleNodeParamsSchema = getSingleNodeParamsSchema(mode);\n const multiNodeParamsSchema = getMultiNodeParamsSchema(mode);\n\n const shouldRegisterWebSocketOnlyTools = mode === \"websocket\" || mode === \"all\";\n\n // REST API + WebSocket Tools (hybrid)\n // These tools support both REST API and WebSocket modes\n\n // Component Info Tool (REST API + WebSocket)\n server.registerTool(\n \"get_component_info\",\n {\n description: getSingleNodeDescription(\n \"Get detailed information about a specific component node in Figma.\",\n mode,\n ),\n inputSchema: singleNodeParamsSchema,\n },\n async (params: z.infer<typeof singleNodeBaseSchema>) => {\n try {\n const { fileKey, nodeId, personalAccessToken } = resolveSingleNodeParams(params);\n const result = await fetchNodeData({ fileKey, nodeId, personalAccessToken }, context);\n\n const node = result.document;\n if (node.type !== \"COMPONENT\" && node.type !== \"COMPONENT_SET\") {\n return formatErrorResponse(\n \"get_component_info\",\n new Error(`Node with ID ${nodeId} is not a component node`),\n );\n }\n\n const key = result.componentSets[nodeId]?.key ?? result.components[nodeId]?.key;\n if (!key) {\n return formatErrorResponse(\n \"get_component_info\",\n new Error(`${nodeId} is not present in exported component data`),\n );\n }\n\n return formatObjectResponse({\n name: node.name,\n key,\n componentPropertyDefinitions: node.componentPropertyDefinitions,\n });\n } catch (error) {\n return formatErrorResponse(\"get_component_info\", error);\n }\n },\n );\n\n // Node Info Tool (REST API + WebSocket)\n server.registerTool(\n \"get_node_info\",\n {\n description: getSingleNodeDescription(\n \"Get detailed information about a specific node in Figma.\",\n mode,\n ),\n inputSchema: singleNodeParamsSchema,\n },\n async (params: z.infer<typeof singleNodeBaseSchema>) => {\n try {\n const { fileKey, nodeId, personalAccessToken } = resolveSingleNodeParams(params);\n const result = await fetchNodeData({ fileKey, nodeId, personalAccessToken }, context);\n\n const normalizer = createRestNormalizer(result);\n const node = normalizer(result.document);\n\n const noInferPipeline = figma.createPipeline({\n shouldInferAutoLayout: false,\n shouldInferVariableName: false,\n });\n const inferPipeline = figma.createPipeline({\n shouldInferAutoLayout: true,\n shouldInferVariableName: true,\n });\n const original =\n noInferPipeline.generateCode(node, { shouldPrintSource: true })?.jsx ??\n \"Failed to generate summarized node info\";\n const inferred =\n inferPipeline.generateCode(node, { shouldPrintSource: true })?.jsx ??\n \"Failed to generate summarized node info\";\n\n return formatObjectResponse({\n original: { data: original, description: \"Original Figma node info\" },\n inferred: { data: inferred, description: \"AutoLayout Inferred Figma node info\" },\n });\n } catch (error) {\n return formatTextResponse(\n `Error in get_node_info: ${formatError(error)}\\n\\n⚠️ Please make sure you have the latest version of the Figma library.`,\n );\n }\n },\n );\n\n // Nodes Info Tool (REST API + WebSocket)\n server.registerTool(\n \"get_nodes_info\",\n {\n description: getMultiNodeDescription(\n \"Get detailed information about multiple nodes in Figma.\",\n mode,\n ),\n inputSchema: multiNodeParamsSchema,\n },\n async ({ fileKey, nodeIds, personalAccessToken }: z.infer<typeof multiNodeBaseSchema>) => {\n try {\n if (nodeIds.length === 0) {\n return formatErrorResponse(\"get_nodes_info\", new Error(\"No node IDs provided\"));\n }\n\n const nodesData = await fetchMultipleNodesData(\n { fileKey, nodeIds, personalAccessToken },\n context,\n );\n\n const results = nodeIds.map((nodeId) => {\n const nodeData = nodesData[nodeId];\n if (!nodeData) {\n return { nodeId, error: `Node ${nodeId} not found` };\n }\n\n const normalizer = createRestNormalizer(nodeData);\n const node = normalizer(nodeData.document);\n\n const noInferPipeline = figma.createPipeline({\n shouldInferAutoLayout: false,\n shouldInferVariableName: false,\n });\n const inferPipeline = figma.createPipeline({\n shouldInferAutoLayout: true,\n shouldInferVariableName: true,\n });\n const original =\n noInferPipeline.generateCode(node, { shouldPrintSource: true })?.jsx ??\n \"Failed to generate summarized node info\";\n const inferred =\n inferPipeline.generateCode(node, { shouldPrintSource: true })?.jsx ??\n \"Failed to generate summarized node info\";\n\n return {\n nodeId,\n original: { data: original, description: \"Original Figma node info\" },\n inferred: { data: inferred, description: \"AutoLayout Inferred Figma node info\" },\n };\n });\n\n return formatObjectResponse(results);\n } catch (error) {\n return formatTextResponse(\n `Error in get_nodes_info: ${formatError(error)}\\n\\n⚠️ Please make sure you have the latest version of the Figma library.`,\n );\n }\n },\n );\n\n // Get Node React Code Tool (REST API + WebSocket)\n server.registerTool(\n \"get_node_react_code\",\n {\n description: getSingleNodeDescription(\n \"Get the React code for a specific node in Figma.\",\n mode,\n ),\n inputSchema: singleNodeParamsSchema,\n },\n async (params: z.infer<typeof singleNodeBaseSchema>) => {\n try {\n const { fileKey, nodeId, personalAccessToken } = resolveSingleNodeParams(params);\n const result = await fetchNodeData({ fileKey, nodeId, personalAccessToken }, context);\n\n const normalizer = createRestNormalizer(result);\n const pipeline = react.createPipeline({\n shouldInferAutoLayout: true,\n shouldInferVariableName: true,\n extend: context.extend,\n });\n const generated = pipeline.generateCode(normalizer(result.document), {\n shouldPrintSource: false,\n });\n\n if (!generated) {\n return formatTextResponse(\n \"Failed to generate code\\n\\n⚠️ Please make sure you have the latest version of the Figma library.\",\n );\n }\n\n return formatTextResponse(`${generated.imports}\\n\\n${generated.jsx}`);\n } catch (error) {\n return formatTextResponse(\n `Error in get_node_react_code: ${formatError(error)}\\n\\n⚠️ Please make sure you have the latest version of the Figma library.`,\n );\n }\n },\n );\n\n // Utility Tools (No Figma connection required)\n\n // Retrieve Color Variable Names Tool\n server.registerTool(\n \"retrieve_color_variable_names\",\n {\n description:\n \"Retrieve available SEED Design color variable names by scope. No Figma connection required.\",\n inputSchema: z.object({\n scope: z\n .enum([\"fg\", \"bg\", \"stroke\", \"palette\"])\n .array()\n .describe(\"The scope of the color variable names to retrieve\"),\n }),\n },\n async ({ scope }) => {\n try {\n const result = getFigmaColorVariableNames(scope);\n\n return formatObjectResponse(result);\n } catch (error) {\n return formatErrorResponse(\"retrieve_color_variable_names\", error);\n }\n },\n );\n\n if (shouldRegisterWebSocketOnlyTools) {\n // WebSocket Only Tools\n\n server.registerTool(\n \"join_channel\",\n {\n description: \"Join a specific channel to communicate with Figma (WebSocket mode only)\",\n inputSchema: z.object({\n channel: z.string().describe(\"The name of the channel to join\").default(\"\"),\n }),\n },\n async ({ channel }) => {\n try {\n if (!figmaClient)\n return formatErrorResponse(\n \"join_channel\",\n new Error(\"WebSocket not available. This tool requires Figma Plugin connection.\"),\n );\n\n if (!channel)\n // If no channel provided, ask the user for input\n return {\n ...formatTextResponse(\"Please provide a channel name to join:\"),\n followUp: {\n tool: \"join_channel\",\n description: \"Join the specified channel\",\n },\n };\n\n await figmaClient.joinChannel(channel);\n\n return formatTextResponse(`Successfully joined channel: ${channel}`);\n } catch (error) {\n return formatErrorResponse(\"join_channel\", error);\n }\n },\n );\n\n // Document Info Tool\n server.registerTool(\n \"get_document_info\",\n {\n description:\n \"Get detailed information about the current Figma document (WebSocket mode only)\",\n },\n async () => {\n try {\n requireWebSocket(context);\n const result = await context.sendCommandToFigma(\"get_document_info\");\n\n return formatObjectResponse(result);\n } catch (error) {\n return formatErrorResponse(\"get_document_info\", error);\n }\n },\n );\n\n // Selection Tool\n server.registerTool(\n \"get_selection\",\n {\n description: \"Get information about the current selection in Figma (WebSocket mode only)\",\n },\n async () => {\n try {\n requireWebSocket(context);\n const result = await context.sendCommandToFigma(\"get_selection\");\n\n return formatObjectResponse(result);\n } catch (error) {\n return formatErrorResponse(\"get_selection\", error);\n }\n },\n );\n\n // Annotation Tool\n server.registerTool(\n \"add_annotations\",\n {\n description: \"Add annotations to multiple nodes in Figma (WebSocket mode only)\",\n inputSchema: z.object({\n annotations: z.array(\n z.object({\n nodeId: z.string().describe(\"The ID of the node to add an annotation to\"),\n labelMarkdown: z\n .string()\n .describe(\"The markdown label for the annotation, do not escape newlines\"),\n }),\n ),\n }),\n },\n async ({ annotations }) => {\n try {\n requireWebSocket(context);\n await context.sendCommandToFigma(\"add_annotations\", { annotations });\n\n return formatTextResponse(\n `Annotations added to nodes ${annotations.map((annotation) => annotation.nodeId).join(\", \")}`,\n );\n } catch (error) {\n return formatErrorResponse(\"add_annotations\", error);\n }\n },\n );\n\n // Get Annotations Tool\n server.registerTool(\n \"get_annotations\",\n {\n description: \"Get annotations for a specific node in Figma (WebSocket mode only)\",\n inputSchema: z.object({\n nodeId: z.string().describe(\"The ID of the node to get annotations for\"),\n }),\n },\n async ({ nodeId }) => {\n try {\n requireWebSocket(context);\n const result = await context.sendCommandToFigma(\"get_annotations\", { nodeId });\n\n return formatObjectResponse(result);\n } catch (error) {\n return formatErrorResponse(\"get_annotations\", error);\n }\n },\n );\n\n // Export Node as Image Tool\n server.registerTool(\n \"export_node_as_image\",\n {\n description: \"Export a node as an image from Figma (WebSocket mode only)\",\n inputSchema: z.object({\n nodeId: z.string().describe(\"The ID of the node to export\"),\n format: z.enum([\"PNG\", \"JPG\", \"SVG\", \"PDF\"]).optional().describe(\"Export format\"),\n scale: z.number().positive().optional().describe(\"Export scale\"),\n }),\n },\n async ({ nodeId, format, scale }) => {\n try {\n requireWebSocket(context);\n const result = await context.sendCommandToFigma(\"export_node_as_image\", {\n nodeId,\n format: format || \"PNG\",\n scale: scale || 1,\n });\n\n const typedResult = result as { base64: string; mimeType: string };\n return formatImageResponse(typedResult.base64, typedResult.mimeType || \"image/png\");\n } catch (error) {\n return formatErrorResponse(\"export_node_as_image\", error);\n }\n },\n );\n }\n}\n\n// editing tools require WebSocket client\n\nexport function registerEditingTools(server: McpServer, figmaClient: FigmaWebSocketClient): void {\n const { sendCommandToFigma } = figmaClient;\n\n // Clone Node Tool\n server.registerTool(\n \"clone_node\",\n {\n description: \"Clone an existing node in Figma (WebSocket mode only)\",\n inputSchema: z.object({\n nodeId: z.string().describe(\"The ID of the node to clone\"),\n x: z.number().optional().describe(\"New X position for the clone\"),\n y: z.number().optional().describe(\"New Y position for the clone\"),\n }),\n },\n async ({ nodeId, x, y }) => {\n try {\n const result = await sendCommandToFigma(\"clone_node\", { nodeId, x, y });\n const typedResult = result as {\n id: string;\n originalId: string;\n x?: number;\n y?: number;\n success: boolean;\n };\n\n return formatTextResponse(\n `Cloned node with new ID: ${typedResult.id}${x !== undefined && y !== undefined ? ` at position (${x}, ${y})` : \"\"}`,\n );\n } catch (error) {\n return formatErrorResponse(\"clone_node\", error);\n }\n },\n );\n\n server.registerTool(\n \"set_fill_color\",\n {\n description: \"Set the fill color of a node (WebSocket mode only)\",\n inputSchema: z.object({\n nodeId: z.string().describe(\"The ID of the node to set the fill color of\"),\n colorToken: z\n .string()\n .describe(\n \"The color token to set the fill color to. Format: `{category}/{name}`. Example: `bg/brand`\",\n ),\n }),\n },\n async ({ nodeId, colorToken }) => {\n try {\n await sendCommandToFigma(\"set_fill_color\", { nodeId, colorToken });\n\n return formatTextResponse(`Fill color set to ${colorToken}`);\n } catch (error) {\n return formatErrorResponse(\"set_fill_color\", error);\n }\n },\n );\n\n server.registerTool(\n \"set_stroke_color\",\n {\n description: \"Set the stroke color of a node (WebSocket mode only)\",\n inputSchema: z.object({\n nodeId: z.string().describe(\"The ID of the node to set the stroke color of\"),\n colorToken: z\n .string()\n .describe(\n \"The color token to set the stroke color to. Format: `{category}/{name}`. Example: `stroke/neutral`\",\n ),\n }),\n },\n async ({ nodeId, colorToken }) => {\n try {\n await sendCommandToFigma(\"set_stroke_color\", { nodeId, colorToken });\n\n return formatTextResponse(`Stroke color set to ${colorToken}`);\n } catch (error) {\n return formatErrorResponse(\"set_stroke_color\", error);\n }\n },\n );\n\n server.registerTool(\n \"set_auto_layout\",\n {\n description: \"Set the auto layout of a node (WebSocket mode only)\",\n inputSchema: z.object({\n nodeId: z.string().describe(\"The ID of the node to set the auto layout of\"),\n layoutMode: z\n .enum([\"HORIZONTAL\", \"VERTICAL\"])\n .optional()\n .describe(\"The layout mode to set\"),\n layoutWrap: z.enum([\"NO_WRAP\", \"WRAP\"]).optional().describe(\"The layout wrap to set\"),\n primaryAxisAlignItems: z\n .enum([\"MIN\", \"MAX\", \"CENTER\", \"SPACE_BETWEEN\"])\n .optional()\n .describe(\"The primary axis align items to set\"),\n counterAxisAlignItems: z\n .enum([\"MIN\", \"MAX\", \"CENTER\", \"BASELINE\"])\n .optional()\n .describe(\"The counter axis align items to set\"),\n itemSpacing: z.number().optional().describe(\"The item spacing to set\"),\n horizontalPadding: z.number().optional().describe(\"The horizontal padding to set\"),\n verticalPadding: z.number().optional().describe(\"The vertical padding to set\"),\n paddingLeft: z.number().optional().describe(\"The padding left to set (when left != right)\"),\n paddingRight: z\n .number()\n .optional()\n .describe(\"The padding right to set (when left != right)\"),\n paddingTop: z.number().optional().describe(\"The padding top to set (when top != bottom)\"),\n paddingBottom: z\n .number()\n .optional()\n .describe(\"The padding bottom to set (when top != bottom)\"),\n }),\n },\n async ({\n nodeId,\n layoutMode,\n layoutWrap,\n primaryAxisAlignItems,\n counterAxisAlignItems,\n itemSpacing,\n horizontalPadding,\n verticalPadding,\n paddingLeft,\n paddingRight,\n paddingTop,\n paddingBottom,\n }) => {\n try {\n await sendCommandToFigma(\"set_auto_layout\", {\n nodeId,\n layoutMode,\n layoutWrap,\n primaryAxisAlignItems,\n counterAxisAlignItems,\n itemSpacing,\n horizontalPadding,\n verticalPadding,\n paddingLeft,\n paddingRight,\n paddingTop,\n paddingBottom,\n });\n\n return formatTextResponse(`Layout set to ${layoutMode}`);\n } catch (error) {\n return formatErrorResponse(\"set_auto_layout\", error);\n }\n },\n );\n\n server.registerTool(\n \"set_size\",\n {\n description: \"Set the size of a node (WebSocket mode only)\",\n inputSchema: z.object({\n nodeId: z.string().describe(\"The ID of the node to set the size of\"),\n layoutSizingHorizontal: z\n .enum([\"HUG\", \"FILL\"])\n .optional()\n .describe(\"The horizontal layout sizing to set (exclusive with width)\"),\n layoutSizingVertical: z\n .enum([\"HUG\", \"FILL\"])\n .optional()\n .describe(\"The vertical layout sizing to set (exclusive with height)\"),\n width: z.number().optional().describe(\"The width to set (raw value)\"),\n height: z.number().optional().describe(\"The height to set (raw value)\"),\n }),\n },\n async ({ nodeId, layoutSizingHorizontal, layoutSizingVertical, width, height }) => {\n try {\n await sendCommandToFigma(\"set_size\", {\n nodeId,\n layoutSizingHorizontal,\n layoutSizingVertical,\n width,\n height,\n });\n\n return formatTextResponse(\n `Size set to ${width ?? layoutSizingHorizontal}x${height ?? layoutSizingVertical}`,\n );\n } catch (error) {\n return formatErrorResponse(\"set_size\", error);\n }\n },\n );\n}\n"],"names":[],"mappings":";;;;AACO;AACP;AACA;;ACFO;AACP;AACA;AACO;AACP;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACA;AACO;AACP;AACA;AACA;;ACOA;AACA;AACA;AACO;;AC5BA;AACP;AACA;AACA;AACA;;ACDO;;ACCA;;;"}
package/dist/index.js ADDED
@@ -0,0 +1,537 @@
1
+ import { createRestNormalizer, figma, react, getFigmaColorVariableNames } from '@seed-design/figma';
2
+ import { z } from 'zod';
3
+ import { Api } from 'figma-api';
4
+
5
+ function createFigmaRestClient(personalAccessToken) {
6
+ const api = new Api({
7
+ personalAccessToken
8
+ });
9
+ return {
10
+ async getFileNodes (fileKey, nodeIds) {
11
+ const response = await api.getFileNodes({
12
+ file_key: fileKey
13
+ }, {
14
+ ids: nodeIds.join(",")
15
+ });
16
+ return response;
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
+ */ function parseFigmaUrl(url) {
31
+ const __url = (()=>{
32
+ try {
33
+ return new URL(url);
34
+ } catch {
35
+ throw new Error(`Invalid URL format: ${url}`);
36
+ }
37
+ })();
38
+ const pathMatch = __url.pathname.match(/^\/(design|file)\/([A-Za-z0-9]+)/);
39
+ const rawNodeId = __url.searchParams.get("node-id");
40
+ if (!pathMatch) throw new Error("Invalid Figma URL: Expected format https://www.figma.com/design/{fileKey}/... or /file/{fileKey}/...");
41
+ if (!rawNodeId) throw new Error("Invalid Figma URL: Missing node-id query parameter");
42
+ return {
43
+ fileKey: pathMatch[2],
44
+ nodeId: rawNodeId.replace(/-/g, ":")
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Format an error for logging
50
+ */ function formatError(error) {
51
+ if (error instanceof Error) {
52
+ return error.message;
53
+ }
54
+ return String(error);
55
+ }
56
+
57
+ /**
58
+ * Format an object response
59
+ */ function formatObjectResponse(result) {
60
+ return {
61
+ content: [
62
+ {
63
+ type: "text",
64
+ text: JSON.stringify(result)
65
+ }
66
+ ]
67
+ };
68
+ }
69
+ /**
70
+ * Format a text response
71
+ */ function formatTextResponse(text) {
72
+ return {
73
+ content: [
74
+ {
75
+ type: "text",
76
+ text
77
+ }
78
+ ]
79
+ };
80
+ }
81
+ /**
82
+ * Format an image response
83
+ */ function formatImageResponse(imageData, mimeType = "image/png") {
84
+ return {
85
+ content: [
86
+ {
87
+ type: "image",
88
+ data: imageData,
89
+ mimeType
90
+ }
91
+ ]
92
+ };
93
+ }
94
+ /**
95
+ * Format an error response
96
+ */ function formatErrorResponse(toolName, error) {
97
+ return {
98
+ content: [
99
+ {
100
+ type: "text",
101
+ text: `Error in ${toolName}: ${formatError(error)}`
102
+ }
103
+ ]
104
+ };
105
+ }
106
+
107
+ function createToolContext(figmaClient, restClient, config, mode) {
108
+ return {
109
+ sendCommandToFigma: figmaClient?.sendCommandToFigma ?? null,
110
+ restClient,
111
+ mode,
112
+ extend: config?.extend
113
+ };
114
+ }
115
+ function resolveRestClient(personalAccessToken, context) {
116
+ if (context.mode === "websocket") {
117
+ return null;
118
+ }
119
+ if (personalAccessToken) {
120
+ return createFigmaRestClient(personalAccessToken);
121
+ }
122
+ return context.restClient;
123
+ }
124
+ async function fetchNodeData(params, context) {
125
+ const { fileKey, nodeId, personalAccessToken } = params;
126
+ const restClient = resolveRestClient(personalAccessToken, context);
127
+ const { sendCommandToFigma } = context;
128
+ if (restClient && fileKey) {
129
+ const response = await restClient.getFileNodes(fileKey, [
130
+ nodeId
131
+ ]);
132
+ const nodeData = response.nodes[nodeId];
133
+ if (!nodeData) throw new Error(`Node ${nodeId} not found in file ${fileKey}`);
134
+ return nodeData;
135
+ }
136
+ if (sendCommandToFigma) {
137
+ return await sendCommandToFigma("get_node_info", {
138
+ nodeId
139
+ });
140
+ }
141
+ throw new Error("No connection available. Provide figmaUrl/fileKey with personalAccessToken or FIGMA_PERSONAL_ACCESS_TOKEN, or use WebSocket mode with Figma Plugin.");
142
+ }
143
+ async function fetchMultipleNodesData(params, context) {
144
+ const { fileKey, nodeIds, personalAccessToken } = params;
145
+ const restClient = resolveRestClient(personalAccessToken, context);
146
+ const { sendCommandToFigma } = context;
147
+ if (restClient && fileKey) {
148
+ const response = await restClient.getFileNodes(fileKey, nodeIds);
149
+ return response.nodes;
150
+ }
151
+ if (sendCommandToFigma) {
152
+ const results = {};
153
+ await Promise.all(nodeIds.map(async (nodeId)=>{
154
+ const data = await sendCommandToFigma("get_node_info", {
155
+ nodeId
156
+ });
157
+ results[nodeId] = data;
158
+ }));
159
+ return results;
160
+ }
161
+ throw new Error("No connection available. Provide figmaUrl/fileKey with personalAccessToken or FIGMA_PERSONAL_ACCESS_TOKEN, or use WebSocket mode with Figma Plugin.");
162
+ }
163
+ function requireWebSocket(context) {
164
+ if (!context.sendCommandToFigma) throw new Error("WebSocket not available. This tool requires Figma Plugin connection.");
165
+ }
166
+
167
+ const singleNodeBaseSchema = z.object({
168
+ figmaUrl: z.url().optional().describe("Figma node URL. Extracts fileKey and nodeId automatically when provided."),
169
+ fileKey: z.string().optional().describe("Figma file key. Use with nodeId when not using figmaUrl."),
170
+ nodeId: z.string().optional().describe("Node ID (e.g., '0:1')."),
171
+ personalAccessToken: z.string().optional().describe("Figma PAT. Falls back to FIGMA_PERSONAL_ACCESS_TOKEN env when not provided.")
172
+ });
173
+ const multiNodeBaseSchema = z.object({
174
+ fileKey: z.string().optional().describe("Figma file key. Required when WebSocket connection is not available."),
175
+ nodeIds: z.array(z.string()).describe("Array of node IDs (e.g., ['0:1', '0:2'])"),
176
+ 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.")
177
+ });
178
+ function getSingleNodeParamsSchema(mode) {
179
+ switch(mode){
180
+ case "websocket":
181
+ return singleNodeBaseSchema.pick({
182
+ nodeId: true
183
+ }).required();
184
+ default:
185
+ return singleNodeBaseSchema;
186
+ }
187
+ }
188
+ function getMultiNodeParamsSchema(mode) {
189
+ switch(mode){
190
+ case "websocket":
191
+ return multiNodeBaseSchema.pick({
192
+ nodeIds: true
193
+ });
194
+ case "rest":
195
+ return multiNodeBaseSchema.required({
196
+ fileKey: true
197
+ });
198
+ default:
199
+ return multiNodeBaseSchema;
200
+ }
201
+ }
202
+ function resolveSingleNodeParams(params) {
203
+ if (params.figmaUrl) {
204
+ const parsed = parseFigmaUrl(params.figmaUrl);
205
+ return {
206
+ fileKey: parsed.fileKey,
207
+ nodeId: parsed.nodeId,
208
+ personalAccessToken: params.personalAccessToken
209
+ };
210
+ }
211
+ if (!params.nodeId) {
212
+ throw new Error("Either figmaUrl or nodeId must be provided. Use figmaUrl for automatic parsing, or provide fileKey + nodeId directly.");
213
+ }
214
+ return {
215
+ fileKey: params.fileKey,
216
+ nodeId: params.nodeId,
217
+ personalAccessToken: params.personalAccessToken
218
+ };
219
+ }
220
+ function getSingleNodeDescription(baseDescription, mode) {
221
+ switch(mode){
222
+ case "rest":
223
+ return `${baseDescription} Provide either: (1) figmaUrl (e.g., https://www.figma.com/design/ABC/Name?node-id=0-1), or (2) fileKey + nodeId.`;
224
+ case "websocket":
225
+ return `${baseDescription} Provide nodeId. Requires WebSocket connection with Figma Plugin.`;
226
+ case "all":
227
+ 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.`;
228
+ }
229
+ }
230
+ function getMultiNodeDescription(baseDescription, mode) {
231
+ switch(mode){
232
+ case "rest":
233
+ return `${baseDescription} Provide fileKey + nodeIds.`;
234
+ case "websocket":
235
+ return `${baseDescription} Provide nodeIds. Requires WebSocket connection with Figma Plugin.`;
236
+ case "all":
237
+ 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.`;
238
+ }
239
+ }
240
+ function registerTools(server, figmaClient, restClient, config, mode) {
241
+ const context = createToolContext(figmaClient, restClient, config, mode);
242
+ const singleNodeParamsSchema = getSingleNodeParamsSchema(mode);
243
+ const multiNodeParamsSchema = getMultiNodeParamsSchema(mode);
244
+ const shouldRegisterWebSocketOnlyTools = mode === "websocket" || mode === "all";
245
+ // REST API + WebSocket Tools (hybrid)
246
+ // These tools support both REST API and WebSocket modes
247
+ // Component Info Tool (REST API + WebSocket)
248
+ server.registerTool("get_component_info", {
249
+ description: getSingleNodeDescription("Get detailed information about a specific component node in Figma.", mode),
250
+ inputSchema: singleNodeParamsSchema
251
+ }, async (params)=>{
252
+ try {
253
+ const { fileKey, nodeId, personalAccessToken } = resolveSingleNodeParams(params);
254
+ const result = await fetchNodeData({
255
+ fileKey,
256
+ nodeId,
257
+ personalAccessToken
258
+ }, context);
259
+ const node = result.document;
260
+ if (node.type !== "COMPONENT" && node.type !== "COMPONENT_SET") {
261
+ return formatErrorResponse("get_component_info", new Error(`Node with ID ${nodeId} is not a component node`));
262
+ }
263
+ const key = result.componentSets[nodeId]?.key ?? result.components[nodeId]?.key;
264
+ if (!key) {
265
+ return formatErrorResponse("get_component_info", new Error(`${nodeId} is not present in exported component data`));
266
+ }
267
+ return formatObjectResponse({
268
+ name: node.name,
269
+ key,
270
+ componentPropertyDefinitions: node.componentPropertyDefinitions
271
+ });
272
+ } catch (error) {
273
+ return formatErrorResponse("get_component_info", error);
274
+ }
275
+ });
276
+ // Node Info Tool (REST API + WebSocket)
277
+ server.registerTool("get_node_info", {
278
+ description: getSingleNodeDescription("Get detailed information about a specific node in Figma.", mode),
279
+ inputSchema: singleNodeParamsSchema
280
+ }, async (params)=>{
281
+ try {
282
+ const { fileKey, nodeId, personalAccessToken } = resolveSingleNodeParams(params);
283
+ const result = await fetchNodeData({
284
+ fileKey,
285
+ nodeId,
286
+ personalAccessToken
287
+ }, context);
288
+ const normalizer = createRestNormalizer(result);
289
+ const node = normalizer(result.document);
290
+ const noInferPipeline = figma.createPipeline({
291
+ shouldInferAutoLayout: false,
292
+ shouldInferVariableName: false
293
+ });
294
+ const inferPipeline = figma.createPipeline({
295
+ shouldInferAutoLayout: true,
296
+ shouldInferVariableName: true
297
+ });
298
+ const original = noInferPipeline.generateCode(node, {
299
+ shouldPrintSource: true
300
+ })?.jsx ?? "Failed to generate summarized node info";
301
+ const inferred = inferPipeline.generateCode(node, {
302
+ shouldPrintSource: true
303
+ })?.jsx ?? "Failed to generate summarized node info";
304
+ return formatObjectResponse({
305
+ original: {
306
+ data: original,
307
+ description: "Original Figma node info"
308
+ },
309
+ inferred: {
310
+ data: inferred,
311
+ description: "AutoLayout Inferred Figma node info"
312
+ }
313
+ });
314
+ } catch (error) {
315
+ return formatTextResponse(`Error in get_node_info: ${formatError(error)}\n\n⚠️ Please make sure you have the latest version of the Figma library.`);
316
+ }
317
+ });
318
+ // Nodes Info Tool (REST API + WebSocket)
319
+ server.registerTool("get_nodes_info", {
320
+ description: getMultiNodeDescription("Get detailed information about multiple nodes in Figma.", mode),
321
+ inputSchema: multiNodeParamsSchema
322
+ }, async ({ fileKey, nodeIds, personalAccessToken })=>{
323
+ try {
324
+ if (nodeIds.length === 0) {
325
+ return formatErrorResponse("get_nodes_info", new Error("No node IDs provided"));
326
+ }
327
+ const nodesData = await fetchMultipleNodesData({
328
+ fileKey,
329
+ nodeIds,
330
+ personalAccessToken
331
+ }, context);
332
+ const results = nodeIds.map((nodeId)=>{
333
+ const nodeData = nodesData[nodeId];
334
+ if (!nodeData) {
335
+ return {
336
+ nodeId,
337
+ error: `Node ${nodeId} not found`
338
+ };
339
+ }
340
+ const normalizer = createRestNormalizer(nodeData);
341
+ const node = normalizer(nodeData.document);
342
+ const noInferPipeline = figma.createPipeline({
343
+ shouldInferAutoLayout: false,
344
+ shouldInferVariableName: false
345
+ });
346
+ const inferPipeline = figma.createPipeline({
347
+ shouldInferAutoLayout: true,
348
+ shouldInferVariableName: true
349
+ });
350
+ const original = noInferPipeline.generateCode(node, {
351
+ shouldPrintSource: true
352
+ })?.jsx ?? "Failed to generate summarized node info";
353
+ const inferred = inferPipeline.generateCode(node, {
354
+ shouldPrintSource: true
355
+ })?.jsx ?? "Failed to generate summarized node info";
356
+ return {
357
+ nodeId,
358
+ original: {
359
+ data: original,
360
+ description: "Original Figma node info"
361
+ },
362
+ inferred: {
363
+ data: inferred,
364
+ description: "AutoLayout Inferred Figma node info"
365
+ }
366
+ };
367
+ });
368
+ return formatObjectResponse(results);
369
+ } catch (error) {
370
+ return formatTextResponse(`Error in get_nodes_info: ${formatError(error)}\n\n⚠️ Please make sure you have the latest version of the Figma library.`);
371
+ }
372
+ });
373
+ // Get Node React Code Tool (REST API + WebSocket)
374
+ server.registerTool("get_node_react_code", {
375
+ description: getSingleNodeDescription("Get the React code for a specific node in Figma.", mode),
376
+ inputSchema: singleNodeParamsSchema
377
+ }, async (params)=>{
378
+ try {
379
+ const { fileKey, nodeId, personalAccessToken } = resolveSingleNodeParams(params);
380
+ const result = await fetchNodeData({
381
+ fileKey,
382
+ nodeId,
383
+ personalAccessToken
384
+ }, context);
385
+ const normalizer = createRestNormalizer(result);
386
+ const pipeline = react.createPipeline({
387
+ shouldInferAutoLayout: true,
388
+ shouldInferVariableName: true,
389
+ extend: context.extend
390
+ });
391
+ const generated = pipeline.generateCode(normalizer(result.document), {
392
+ shouldPrintSource: false
393
+ });
394
+ if (!generated) {
395
+ return formatTextResponse("Failed to generate code\n\n⚠️ Please make sure you have the latest version of the Figma library.");
396
+ }
397
+ return formatTextResponse(`${generated.imports}\n\n${generated.jsx}`);
398
+ } catch (error) {
399
+ return formatTextResponse(`Error in get_node_react_code: ${formatError(error)}\n\n⚠️ Please make sure you have the latest version of the Figma library.`);
400
+ }
401
+ });
402
+ // Utility Tools (No Figma connection required)
403
+ // Retrieve Color Variable Names Tool
404
+ server.registerTool("retrieve_color_variable_names", {
405
+ description: "Retrieve available SEED Design color variable names by scope. No Figma connection required.",
406
+ inputSchema: z.object({
407
+ scope: z.enum([
408
+ "fg",
409
+ "bg",
410
+ "stroke",
411
+ "palette"
412
+ ]).array().describe("The scope of the color variable names to retrieve")
413
+ })
414
+ }, async ({ scope })=>{
415
+ try {
416
+ const result = getFigmaColorVariableNames(scope);
417
+ return formatObjectResponse(result);
418
+ } catch (error) {
419
+ return formatErrorResponse("retrieve_color_variable_names", error);
420
+ }
421
+ });
422
+ if (shouldRegisterWebSocketOnlyTools) {
423
+ // WebSocket Only Tools
424
+ server.registerTool("join_channel", {
425
+ description: "Join a specific channel to communicate with Figma (WebSocket mode only)",
426
+ inputSchema: z.object({
427
+ channel: z.string().describe("The name of the channel to join").default("")
428
+ })
429
+ }, async ({ channel })=>{
430
+ try {
431
+ if (!figmaClient) return formatErrorResponse("join_channel", new Error("WebSocket not available. This tool requires Figma Plugin connection."));
432
+ if (!channel) // If no channel provided, ask the user for input
433
+ return {
434
+ ...formatTextResponse("Please provide a channel name to join:"),
435
+ followUp: {
436
+ tool: "join_channel",
437
+ description: "Join the specified channel"
438
+ }
439
+ };
440
+ await figmaClient.joinChannel(channel);
441
+ return formatTextResponse(`Successfully joined channel: ${channel}`);
442
+ } catch (error) {
443
+ return formatErrorResponse("join_channel", error);
444
+ }
445
+ });
446
+ // Document Info Tool
447
+ server.registerTool("get_document_info", {
448
+ description: "Get detailed information about the current Figma document (WebSocket mode only)"
449
+ }, async ()=>{
450
+ try {
451
+ requireWebSocket(context);
452
+ const result = await context.sendCommandToFigma("get_document_info");
453
+ return formatObjectResponse(result);
454
+ } catch (error) {
455
+ return formatErrorResponse("get_document_info", error);
456
+ }
457
+ });
458
+ // Selection Tool
459
+ server.registerTool("get_selection", {
460
+ description: "Get information about the current selection in Figma (WebSocket mode only)"
461
+ }, async ()=>{
462
+ try {
463
+ requireWebSocket(context);
464
+ const result = await context.sendCommandToFigma("get_selection");
465
+ return formatObjectResponse(result);
466
+ } catch (error) {
467
+ return formatErrorResponse("get_selection", error);
468
+ }
469
+ });
470
+ // Annotation Tool
471
+ server.registerTool("add_annotations", {
472
+ description: "Add annotations to multiple nodes in Figma (WebSocket mode only)",
473
+ inputSchema: z.object({
474
+ annotations: z.array(z.object({
475
+ nodeId: z.string().describe("The ID of the node to add an annotation to"),
476
+ labelMarkdown: z.string().describe("The markdown label for the annotation, do not escape newlines")
477
+ }))
478
+ })
479
+ }, async ({ annotations })=>{
480
+ try {
481
+ requireWebSocket(context);
482
+ await context.sendCommandToFigma("add_annotations", {
483
+ annotations
484
+ });
485
+ return formatTextResponse(`Annotations added to nodes ${annotations.map((annotation)=>annotation.nodeId).join(", ")}`);
486
+ } catch (error) {
487
+ return formatErrorResponse("add_annotations", error);
488
+ }
489
+ });
490
+ // Get Annotations Tool
491
+ server.registerTool("get_annotations", {
492
+ description: "Get annotations for a specific node in Figma (WebSocket mode only)",
493
+ inputSchema: z.object({
494
+ nodeId: z.string().describe("The ID of the node to get annotations for")
495
+ })
496
+ }, async ({ nodeId })=>{
497
+ try {
498
+ requireWebSocket(context);
499
+ const result = await context.sendCommandToFigma("get_annotations", {
500
+ nodeId
501
+ });
502
+ return formatObjectResponse(result);
503
+ } catch (error) {
504
+ return formatErrorResponse("get_annotations", error);
505
+ }
506
+ });
507
+ // Export Node as Image Tool
508
+ server.registerTool("export_node_as_image", {
509
+ description: "Export a node as an image from Figma (WebSocket mode only)",
510
+ inputSchema: z.object({
511
+ nodeId: z.string().describe("The ID of the node to export"),
512
+ format: z.enum([
513
+ "PNG",
514
+ "JPG",
515
+ "SVG",
516
+ "PDF"
517
+ ]).optional().describe("Export format"),
518
+ scale: z.number().positive().optional().describe("Export scale")
519
+ })
520
+ }, async ({ nodeId, format, scale })=>{
521
+ try {
522
+ requireWebSocket(context);
523
+ const result = await context.sendCommandToFigma("export_node_as_image", {
524
+ nodeId,
525
+ format: format || "PNG",
526
+ scale: scale || 1
527
+ });
528
+ const typedResult = result;
529
+ return formatImageResponse(typedResult.base64, typedResult.mimeType || "image/png");
530
+ } catch (error) {
531
+ return formatErrorResponse("export_node_as_image", error);
532
+ }
533
+ });
534
+ }
535
+ }
536
+
537
+ export { createFigmaRestClient, parseFigmaUrl, registerTools };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seed-design/mcp",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/daangn/seed-design.git",
@@ -12,18 +12,25 @@
12
12
  "sideEffects": false,
13
13
  "files": [
14
14
  "bin",
15
+ "dist",
15
16
  "src",
16
17
  "package.json"
17
18
  ],
18
19
  "bin": "./bin/index.mjs",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "import": "./dist/index.js"
24
+ }
25
+ },
19
26
  "scripts": {
20
- "clean": "rm -rf lib",
27
+ "clean": "rm -rf dist bin",
21
28
  "build": "bunchee",
22
29
  "lint:publish": "bun publint"
23
30
  },
24
31
  "dependencies": {
25
32
  "@modelcontextprotocol/sdk": "^1.25.3",
26
- "@seed-design/figma": "1.2.1",
33
+ "@seed-design/figma": "1.3.2",
27
34
  "cac": "^6.7.14",
28
35
  "figma-api": "^2.1.0-beta",
29
36
  "uuid": "^13.0.0",
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";