UNPKG

figma-mcp-pro

Version:

A Claude Desktop MCP server for Figma that retrieves design data, downloads assets, and processes designer comments for AI-powered design implementation

1,227 lines (1,224 loc) 216 kB
#!/usr/bin/env node // src/index.ts import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { CallToolRequestSchema, ErrorCode, ListToolsRequestSchema, McpError } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; import { Command } from "commander"; import dotenv from "dotenv"; import chalk from "chalk"; import path2 from "path"; import fs2 from "fs/promises"; // src/version.ts import { readFileSync } from "fs"; import { fileURLToPath } from "url"; import { dirname, join } from "path"; var __filename = fileURLToPath(import.meta.url); var __dirname = dirname(__filename); function getVersion() { try { const packageJsonPath = join(__dirname, "..", "package.json"); const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); return packageJson.version; } catch (error) { console.error("Error reading version from package.json:", error); return "0.0.0"; } } var VERSION = getVersion(); // src/services/figma-api.ts import axios from "axios"; import NodeCache from "node-cache"; import pRetry from "p-retry"; import pLimit from "p-limit"; import fs from "fs/promises"; import * as fsSync from "fs"; import path from "path"; import os from "os"; var FigmaApiError = class extends Error { constructor(message, status, code, details) { super(message); this.status = status; this.code = code; this.details = details; this.name = "FigmaApiError"; } }; var FigmaApiService = class _FigmaApiService { client; cache; rateLimiter; config; constructor(config) { this.config = { baseUrl: "https://api.figma.com/v1", timeout: 3e4, retryAttempts: 3, retryDelay: 1e3, cacheConfig: { ttl: 300, // 5 minutes maxSize: 1e3 }, rateLimitConfig: { requestsPerMinute: 60, burstSize: 10 }, ...config }; this.client = axios.create({ baseURL: this.config.baseUrl, timeout: this.config.timeout, headers: { "X-Figma-Token": this.config.apiKey, "Content-Type": "application/json", "User-Agent": "Custom-Figma-MCP-Server/1.0.0" } }); this.cache = new NodeCache({ stdTTL: this.config.cacheConfig.ttl, maxKeys: this.config.cacheConfig.maxSize, useClones: false }); this.rateLimiter = pLimit(this.config.rateLimitConfig.burstSize); this.setupInterceptors(); } setupInterceptors() { this.client.interceptors.request.use( (config) => { console.error(`[Figma API] ${config.method?.toUpperCase()} ${config.url}`); return config; }, (error) => { console.error("[Figma API] Request error:", error); return Promise.reject(error); } ); this.client.interceptors.response.use( (response) => { console.error(`[Figma API] Response ${response.status} for ${response.config.url}`); return response; }, (error) => { if (error.response) { const { status, data } = error.response; const figmaError = data; throw new FigmaApiError( figmaError.err || `HTTP ${status} error`, status, figmaError.err, data ); } else if (error.request) { throw new FigmaApiError( "Network error: No response received", 0, "NETWORK_ERROR", error.request ); } else { throw new FigmaApiError( `Request setup error: ${error.message}`, 0, "REQUEST_ERROR", error ); } } ); } async makeRequest(endpoint, options = {}, useCache = true) { const cacheKey = `${endpoint}:${JSON.stringify(options)}`; if (useCache) { const cached = this.cache.get(cacheKey); if (cached) { console.error(`[Figma API] Cache hit for ${endpoint}`); return cached; } } return this.rateLimiter(async () => { const response = await pRetry( async () => { const response2 = await this.client.get(endpoint, { params: this.buildParams(options) }); return response2; }, { retries: this.config.retryAttempts, minTimeout: this.config.retryDelay, factor: 2, onFailedAttempt: (error) => { console.warn( `[Figma API] Attempt ${error.attemptNumber} failed for ${endpoint}. ${error.retriesLeft} retries left.` ); } } ); const data = response.data; if (useCache) { this.cache.set(cacheKey, data); } return data; }); } buildParams(options) { const params = {}; if (options.version) params.version = options.version; if (options.ids) params.ids = options.ids.join(","); if (options.depth !== void 0) params.depth = options.depth.toString(); if (options.geometry) params.geometry = options.geometry; if (options.plugin_data) params.plugin_data = options.plugin_data; if (options.branch_data) params.branch_data = "true"; if (options.use_absolute_bounds) params.use_absolute_bounds = "true"; return params; } /** * Get a Figma file by its key */ async getFile(fileKey, options = {}) { if (!fileKey || typeof fileKey !== "string") { throw new FigmaApiError("File key is required and must be a string"); } try { return await this.makeRequest(`/files/${fileKey}`, options); } catch (error) { if (error instanceof FigmaApiError) { throw error; } throw new FigmaApiError(`Failed to get file ${fileKey}: ${error}`); } } /** * Get specific nodes from a Figma file */ async getFileNodes(fileKey, nodeIds, options = {}) { if (!fileKey || typeof fileKey !== "string") { throw new FigmaApiError("File key is required and must be a string"); } if (!nodeIds || !Array.isArray(nodeIds) || nodeIds.length === 0) { throw new FigmaApiError("Node IDs are required and must be a non-empty array"); } try { return await this.makeRequest( `/files/${fileKey}/nodes`, { ...options, ids: nodeIds } ); } catch (error) { if (error instanceof FigmaApiError) { throw error; } throw new FigmaApiError(`Failed to get nodes from file ${fileKey}: ${error}`); } } /** * Get images for specific nodes */ async getImages(fileKey, nodeIds, options = {}) { if (!fileKey || typeof fileKey !== "string") { throw new FigmaApiError("File key is required and must be a string"); } if (!nodeIds || !Array.isArray(nodeIds) || nodeIds.length === 0) { throw new FigmaApiError("Node IDs are required and must be a non-empty array"); } try { const params = { ids: nodeIds.join(","), format: options.format || "png" }; if (options.scale) params.scale = options.scale.toString(); if (options.svg_include_id) params.svg_include_id = "true"; if (options.svg_simplify_stroke) params.svg_simplify_stroke = "true"; if (options.use_absolute_bounds) params.use_absolute_bounds = "true"; if (options.version) params.version = options.version; const response = await this.client.get( `/images/${fileKey}`, { params } ); return response.data; } catch (error) { if (error instanceof FigmaApiError) { throw error; } throw new FigmaApiError(`Failed to get images from file ${fileKey}: ${error}`); } } /** * Get cache statistics */ getCacheStats() { const stats = this.cache.getStats(); return { keys: stats.keys, hits: stats.hits, misses: stats.misses, size: stats.ksize + stats.vsize }; } /** * Clear cache */ clearCache() { this.cache.flushAll(); console.error("[Figma API] Cache cleared"); } /** * Update API key */ updateApiKey(apiKey) { if (!apiKey || typeof apiKey !== "string") { throw new FigmaApiError("API key is required and must be a string"); } this.config.apiKey = apiKey; this.client.defaults.headers["X-Figma-Token"] = apiKey; console.error("[Figma API] API key updated"); } /** * Robust IDE-aware path resolution for universal compatibility */ static resolvePath(inputPath) { const normalizedPath = inputPath.trim().replace(/[^\x20-\x7E]/g, ""); console.error(`[Figma API] Input path: "${inputPath}" -> normalized: "${normalizedPath}"`); if (path.isAbsolute(normalizedPath) && !this.isSystemRoot(normalizedPath) && normalizedPath.length > 1) { if (!this.isDangerousPath(normalizedPath)) { console.error(`[Figma API] Using safe absolute path: ${normalizedPath}`); return normalizedPath; } else { console.error(`[Figma API] \u26A0\uFE0F Absolute path is dangerous, switching to relative: ${normalizedPath}`); } } const workspaceInfo = this.getActualWorkspaceDirectory(); console.error(`[Figma API] Using workspace directory: ${workspaceInfo.workspaceDir} (${workspaceInfo.confidence} confidence from ${workspaceInfo.source})`); if (this.isDangerousPath(workspaceInfo.workspaceDir) || this.isSystemRoot(workspaceInfo.workspaceDir)) { console.error(`[Figma API] \u{1F6A8} CRITICAL: Workspace directory is dangerous/root: ${workspaceInfo.workspaceDir}`); const userHome = os.homedir(); const safeFallbackWorkspace = path.join(userHome, "figma-mcp-workspace"); console.error(`[Figma API] \u{1F6E1}\uFE0F Using bulletproof safe workspace: ${safeFallbackWorkspace}`); workspaceInfo.workspaceDir = safeFallbackWorkspace; workspaceInfo.confidence = "low"; workspaceInfo.source = "Emergency Safe Fallback"; } let cleanPath = normalizedPath; if (cleanPath.startsWith("./")) { cleanPath = cleanPath.substring(2); } else if (cleanPath.startsWith("../")) { cleanPath = cleanPath; } else if (cleanPath.startsWith("/")) { cleanPath = cleanPath.substring(1); } if (!cleanPath || cleanPath === "." || cleanPath === "") { cleanPath = "figma-assets"; } const resolvedPath = path.resolve(workspaceInfo.workspaceDir, cleanPath); if (this.isDangerousPath(resolvedPath) || this.isSystemRoot(path.dirname(resolvedPath))) { console.error(`[Figma API] \u{1F6A8} EMERGENCY BLOCK: Resolved path is still dangerous: ${resolvedPath}`); console.error(`[Figma API] \u{1F6A8} This indicates a severe Cursor IDE workspace detection failure`); const userHome = os.homedir(); const emergencyPath = path.resolve(userHome, "figma-emergency-downloads", cleanPath); console.error(`[Figma API] \u{1F6E1}\uFE0F Using emergency safe path: ${emergencyPath}`); if (this.isDangerousPath(emergencyPath)) { console.error(`[Figma API] \u{1F4A5} CRITICAL SYSTEM ERROR: Even emergency path is dangerous!`); throw new FigmaApiError(`System error: Cannot create safe download path. Emergency path ${emergencyPath} is dangerous. Please check your system configuration.`); } return emergencyPath; } console.error(`[Figma API] \u2705 Path resolution: "${normalizedPath}" -> "${resolvedPath}"`); console.error(`[Figma API] Environment: workspace="${workspaceInfo.workspaceDir}", PWD="${process.env.PWD}", resolved="${resolvedPath}"`); return resolvedPath; } /** * Enhanced project directory detection by looking for common project markers * Specifically optimized for Cursor IDE environment */ static findProjectDirectoryByMarkers() { const candidates = []; const projectMarkers = [ { file: "package.json", score: 10 }, { file: ".git", score: 8 }, { file: "tsconfig.json", score: 7 }, { file: "yarn.lock", score: 6 }, { file: "package-lock.json", score: 6 }, { file: "pnpm-lock.yaml", score: 6 }, { file: "node_modules", score: 5 }, { file: "src", score: 4 }, { file: "dist", score: 3 }, { file: "README.md", score: 2 }, { file: ".gitignore", score: 3 }, { file: "index.js", score: 2 }, { file: "index.ts", score: 2 } ]; const startingPoints = []; if (process.env.PWD && !this.isSystemRoot(process.env.PWD)) { startingPoints.push(process.env.PWD); } if (process.env.INIT_CWD && !this.isSystemRoot(process.env.INIT_CWD)) { startingPoints.push(process.env.INIT_CWD); } if (!this.isSystemRoot(process.cwd())) { startingPoints.push(process.cwd()); } const userDirs = [ path.join(os.homedir(), "Desktop"), path.join(os.homedir(), "Documents"), path.join(os.homedir(), "Projects"), path.join(os.homedir(), "Development"), path.join(os.homedir(), "Code"), os.homedir() ]; startingPoints.push(...userDirs); const uniqueStartingPoints = [...new Set(startingPoints)]; console.error(`[Figma API] \u{1F50D} Project marker search starting from ${uniqueStartingPoints.length} locations`); for (const startDir of uniqueStartingPoints) { try { console.error(`[Figma API] \u{1F50D} Searching from: ${startDir}`); let currentDir = startDir; const maxLevels = 8; for (let level = 0; level < maxLevels; level++) { let totalScore = 0; for (const marker of projectMarkers) { const markerPath = path.join(currentDir, marker.file); try { fsSync.accessSync(markerPath); totalScore += marker.score; if (marker.file === "package.json") { try { const packageContent = fsSync.readFileSync(markerPath, "utf8"); const packageJson = JSON.parse(packageContent); if (packageJson.name && !packageJson.name.startsWith("figma-mcp-workspace")) { totalScore += 5; } } catch { } } } catch { } } if (totalScore >= 10 && !candidates.includes(currentDir)) { candidates.push(currentDir); console.error(`[Figma API] \u2705 Project found with score ${totalScore}: ${currentDir}`); } const parentDir = path.dirname(currentDir); if (parentDir === currentDir) break; currentDir = parentDir; } if (startDir !== os.homedir()) { try { const entries = fsSync.readdirSync(startDir, { withFileTypes: true }); for (const entry of entries.slice(0, 20)) { if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") { const subDir = path.join(startDir, entry.name); let totalScore = 0; for (const marker of projectMarkers) { const markerPath = path.join(subDir, marker.file); try { fsSync.accessSync(markerPath); totalScore += marker.score; } catch { } } if (totalScore >= 10 && !candidates.includes(subDir)) { candidates.push(subDir); console.error(`[Figma API] \u2705 Project found (subdirectory) with score ${totalScore}: ${subDir}`); } } } } catch { } } } catch (error) { console.error(`[Figma API] \u26A0\uFE0F Error searching from ${startDir}:`, error); } } console.error(`[Figma API] \u{1F4CA} Project marker search found ${candidates.length} candidates`); return candidates; } /** * Check if a directory looks like a valid project directory */ static isValidProjectDirectory(dir) { const projectIndicators = [ "package.json", "tsconfig.json", ".git", "src", "node_modules" ]; let indicatorCount = 0; for (const indicator of projectIndicators) { try { fsSync.accessSync(path.join(dir, indicator)); indicatorCount++; } catch { } } return indicatorCount >= 2; } /** * Create directory with enhanced verification and universal IDE compatibility */ static async createDirectorySafely(resolvedPath, originalPath) { if (!resolvedPath || resolvedPath.length === 0) { throw new Error("Invalid or empty path after resolution"); } if (this.isDangerousPath(resolvedPath)) { console.error(`[Figma API] SAFETY BLOCK: Refusing to create directory at dangerous location: ${resolvedPath}`); throw new FigmaApiError(`Blocked dangerous directory creation at: ${resolvedPath}. Original path: ${originalPath}`); } console.error(`[Figma API] Creating directory: "${originalPath}" -> "${resolvedPath}"`); try { await fs.mkdir(resolvedPath, { recursive: true, mode: 493 }); const stats = await fs.stat(resolvedPath); if (!stats.isDirectory()) { throw new Error("Path exists but is not a directory"); } const testFile = path.join(resolvedPath, ".figma-test-write"); try { await fs.writeFile(testFile, "test"); await fs.unlink(testFile); } catch (writeError) { throw new Error(`Directory exists but is not writable: ${writeError instanceof Error ? writeError.message : String(writeError)}`); } console.error(`[Figma API] \u2705 Directory verified: ${resolvedPath}`); } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); console.error(`[Figma API] \u274C Directory creation failed:`, { originalPath, resolvedPath, cwd: process.cwd(), environment: { PWD: process.env.PWD, INIT_CWD: process.env.INIT_CWD, PROJECT_ROOT: process.env.PROJECT_ROOT, WORKSPACE_ROOT: process.env.WORKSPACE_ROOT }, error: errorMessage }); throw new FigmaApiError(`Failed to create/verify directory: ${errorMessage}`); } } /** * Verify that assets exist in the expected location (universal IDE compatibility) */ static async verifyAssetsLocation(expectedPaths) { const verified = []; for (const expectedPath of expectedPaths) { try { const stat = await fs.stat(expectedPath); const relativePath = path.relative(process.cwd(), expectedPath); verified.push({ path: expectedPath, exists: true, size: stat.size, relativePath: relativePath.startsWith("..") ? expectedPath : relativePath }); } catch (error) { verified.push({ path: expectedPath, exists: false }); } } const summary = { total: verified.length, found: verified.filter((v) => v.exists).length, missing: verified.filter((v) => !v.exists).length }; return { verified, summary }; } /** * Advanced asset recovery system for IDE compatibility issues * Searches common alternative download locations and recovers assets to project folder */ static async findAndRecoverMissingAssets(expectedResults, targetDirectory) { const recovered = []; const missingAssets = expectedResults.filter((r) => r.success); console.error(`[Figma API] \u{1F50D} Searching for ${missingAssets.length} potentially misplaced assets...`); const searchLocations = this.getAssetSearchLocations(); const uniqueSearchLocations = [...new Set(searchLocations)].filter((loc) => loc !== targetDirectory); const recursiveSearchDirs = [ path.join(os.homedir(), "figma-workspace"), os.homedir() ]; for (const asset of missingAssets) { const expectedPath = asset.filePath; const filename = path.basename(expectedPath); try { await fs.access(expectedPath); continue; } catch { } console.error(`[Figma API] \u{1F50D} Searching for missing file: ${filename}`); let foundPath = null; for (const searchLoc of uniqueSearchLocations) { try { const candidatePath = path.join(searchLoc, filename); await fs.access(candidatePath); const stat = await fs.stat(candidatePath); if (stat.size > 0) { foundPath = candidatePath; console.error(`[Figma API] \u2705 Found ${filename} at: ${candidatePath} (${(stat.size / 1024).toFixed(1)}KB)`); break; } } catch { } } if (!foundPath) { foundPath = await this.searchFileRecursively(filename, recursiveSearchDirs); } if (foundPath) { try { await _FigmaApiService.createDirectorySafely(targetDirectory, targetDirectory); await fs.rename(foundPath, expectedPath); recovered.push({ nodeId: asset.nodeId, nodeName: asset.nodeName, oldPath: foundPath, newPath: expectedPath, success: true }); console.error(`[Figma API] \u2705 Recovered ${filename}: ${foundPath} \u2192 ${expectedPath}`); } catch (moveError) { try { await fs.copyFile(foundPath, expectedPath); await fs.unlink(foundPath); recovered.push({ nodeId: asset.nodeId, nodeName: asset.nodeName, oldPath: foundPath, newPath: expectedPath, success: true }); console.error(`[Figma API] \u2705 Recovered ${filename} via copy: ${foundPath} \u2192 ${expectedPath}`); } catch (copyError) { recovered.push({ nodeId: asset.nodeId, nodeName: asset.nodeName, oldPath: foundPath, newPath: expectedPath, success: false }); console.error(`[Figma API] \u274C Failed to recover ${filename}:`, copyError); } } } else { console.error(`[Figma API] \u274C Could not locate missing file: ${filename}`); } } const summary = { total: missingAssets.length, found: recovered.length, recovered: recovered.filter((r) => r.success).length, failed: recovered.filter((r) => !r.success).length }; if (summary.recovered > 0) { console.error(`[Figma API] \u{1F389} Recovery completed: ${summary.recovered}/${summary.total} assets recovered to project folder`); } else if (summary.total > 0) { console.error(`[Figma API] \u26A0\uFE0F No assets recovered - files may have been downloaded to an unknown location`); } return { recovered, summary }; } /** * Search for a file recursively in given directories (limited depth) */ static async searchFileRecursively(filename, searchDirs, maxDepth = 3) { for (const searchDir of searchDirs) { try { const found = await this.searchInDirectory(searchDir, filename, maxDepth); if (found) { return found; } } catch (error) { console.error(`[Figma API] Error searching in ${searchDir}:`, error); } } return null; } /** * Search for a file in a specific directory with depth limit */ static async searchInDirectory(dir, filename, maxDepth, currentDepth = 0) { if (currentDepth >= maxDepth) { return null; } try { const entries = await fs.readdir(dir, { withFileTypes: true }); for (const entry of entries) { if (entry.isFile() && entry.name === filename) { const filePath = path.join(dir, entry.name); const stat = await fs.stat(filePath); if (stat.size > 0) { return filePath; } } } for (const entry of entries) { if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") { const subDir = path.join(dir, entry.name); const found = await this.searchInDirectory(subDir, filename, maxDepth, currentDepth + 1); if (found) { return found; } } } } catch (error) { } return null; } /** * Download images for specific nodes directly (without requiring export settings) */ async downloadImages(fileKey, nodeIds, localPath, options = {}) { const resolvedPath = _FigmaApiService.resolvePath(localPath); await _FigmaApiService.createDirectorySafely(resolvedPath, localPath); const results = []; const usedFilenames = /* @__PURE__ */ new Set(); const filenameCounters = /* @__PURE__ */ new Map(); const contentHashes = /* @__PURE__ */ new Map(); try { const existingFiles = await fs.readdir(resolvedPath); existingFiles.forEach((file) => { usedFilenames.add(file); console.error(`[Figma API] \u{1F4C1} Existing file detected: ${file}`); }); } catch (error) { console.error(`[Figma API] \u{1F4C1} Target directory empty or doesn't exist yet`); } const generateContentHash = (node, format, scale) => { const hashComponents = [ node.type, format, scale.toString(), JSON.stringify(node.fills || []), JSON.stringify(node.strokes || []), JSON.stringify(node.effects || []), node.cornerRadius || 0, node.strokeWeight || 0, node.type === "TEXT" ? node.characters || "" : "", node.absoluteBoundingBox ? `${Math.round(node.absoluteBoundingBox.width)}x${Math.round(node.absoluteBoundingBox.height)}` : "" ]; return hashComponents.join("|").replace(/[^a-zA-Z0-9]/g, "").substring(0, 16); }; const isReusableAsset = (node, sanitizedName) => { const name = sanitizedName.toLowerCase(); const iconPatterns = [ "akar-icons-", "dashicons-", "ci-", "uis-", "mdi-", "ant-design-", "feather-", "heroicons-", "lucide-", "tabler-", "phosphor-", "icon-", "ico-" ]; const isIcon = iconPatterns.some((pattern) => name.includes(pattern)); const size = node.absoluteBoundingBox; const isSmallSize = size ? size.width <= 100 && size.height <= 100 : false; const isVectorType = node.type === "VECTOR" || node.type === "BOOLEAN_OPERATION" || node.type === "COMPONENT"; const shouldDeduplicate = isIcon || isSmallSize && isVectorType; if (shouldDeduplicate) { console.error(`[Figma API] \u{1F517} Detected reusable asset: "${name}" (icon: ${isIcon}, small: ${isSmallSize}, vector: ${isVectorType})`); } return shouldDeduplicate; }; const generateUniqueFilename = (node, baseName, extension, format, scale) => { const baseNameWithScale = scale === 1 ? `${baseName}-x1` : `${baseName}-x${scale}`; const baseFilename = `${baseNameWithScale}.${extension}`; if (isReusableAsset(node, baseName)) { const contentHash = generateContentHash(node, format, scale); if (contentHashes.has(contentHash)) { const existingAsset = contentHashes.get(contentHash); console.error(`[Figma API] \u{1F517} Content duplicate detected: "${baseName}" \u2192 reusing "${existingAsset.filename}" (same as ${existingAsset.nodeName})`); return existingAsset.filename; } contentHashes.set(contentHash, { filename: baseFilename, nodeId: node.id, nodeName: baseName }); } if (!usedFilenames.has(baseFilename)) { usedFilenames.add(baseFilename); return baseFilename; } const counter = filenameCounters.get(baseNameWithScale) || 1; let uniqueFilename; let currentCounter = counter + 1; do { uniqueFilename = `${baseNameWithScale}-${currentCounter}.${extension}`; currentCounter++; } while (usedFilenames.has(uniqueFilename)); filenameCounters.set(baseNameWithScale, currentCounter - 1); usedFilenames.add(uniqueFilename); console.error(`[Figma API] \u{1F504} Filename duplicate resolved: "${baseFilename}" \u2192 "${uniqueFilename}"`); return uniqueFilename; }; try { const nodeResponse = await this.getFileNodes(fileKey, nodeIds, { depth: 1, use_absolute_bounds: true }); const format = (options.format || "png").toLowerCase(); let scale = options.scale || 1; if (format === "svg") { scale = 1; } const imageResponse = await this.getImages(fileKey, nodeIds, { format, scale, use_absolute_bounds: true }); for (const nodeId of nodeIds) { const nodeWrapper = nodeResponse.nodes[nodeId]; const imageUrl = imageResponse.images[nodeId]; if (!nodeWrapper) { results.push({ nodeId, nodeName: "Unknown", filePath: "", success: false, error: `Node ${nodeId} not found` }); continue; } if (!imageUrl) { results.push({ nodeId, nodeName: nodeWrapper.document?.name || "Unknown", filePath: "", success: false, error: "No image URL returned from Figma API" }); continue; } const nodeName = nodeWrapper.document?.name || `node-${nodeId}`; const sanitizedNodeName = nodeName.replace(/[/\\:*?"<>|]/g, "-").replace(/\s+/g, " ").trim(); const extension = format; const filename = generateUniqueFilename(nodeWrapper.document, sanitizedNodeName, extension, format, scale); const filePath = path.join(resolvedPath, filename); console.error(`[Figma API] Debug - Node ID: ${nodeId}, Node Name: "${nodeName}", Filename: "${filename}"`); try { const downloadResponse = await axios.get(imageUrl, { responseType: "arraybuffer", timeout: 3e4, headers: { "User-Agent": "Custom-Figma-MCP-Server/1.0.0" } }); await fs.writeFile(filePath, Buffer.from(downloadResponse.data)); results.push({ nodeId, nodeName: sanitizedNodeName, filePath, success: true }); console.error(`[Figma API] Downloaded: ${filename} (${(downloadResponse.data.byteLength / 1024).toFixed(1)}KB)`); } catch (downloadError) { results.push({ nodeId, nodeName: sanitizedNodeName, filePath, success: false, error: `Download failed: ${downloadError instanceof Error ? downloadError.message : String(downloadError)}` }); console.error(`[Figma API] Failed to download ${filename}:`, downloadError); } } } catch (error) { for (const nodeId of nodeIds) { results.push({ nodeId, nodeName: "Unknown", filePath: "", success: false, error: `Failed to fetch node data: ${error instanceof Error ? error.message : String(error)}` }); } } const summary = { total: results.length, successful: results.filter((r) => r.success).length, failed: results.filter((r) => !r.success).length }; console.error(`[Figma API] Download completed: ${summary.successful}/${summary.total} successful`); let workspaceEnforcement = null; if (summary.successful > 0 && !options.skipWorkspaceEnforcement) { try { workspaceEnforcement = await _FigmaApiService.enforceWorkspaceLocation(results, localPath); console.error(`[Figma API] \u{1F3AF} Workspace enforcement: ${workspaceEnforcement.summary.moved} moved, ${workspaceEnforcement.summary.alreadyCorrect} already correct`); } catch (enforcementError) { console.error(`[Figma API] \u26A0\uFE0F Workspace enforcement failed, falling back to recovery:`, enforcementError); const expectedPaths = results.filter((r) => r.success).map((r) => r.filePath); if (expectedPaths.length > 0) { const verification = await _FigmaApiService.verifyAssetsLocation(expectedPaths); if (verification.summary.missing > 0) { console.error(`[Figma API] \u26A0\uFE0F ${verification.summary.missing} assets missing from expected location, attempting recovery...`); const recovery = await _FigmaApiService.findAndRecoverMissingAssets(results, resolvedPath); if (recovery.summary.recovered > 0) { console.error(`[Figma API] \u{1F389} Successfully recovered ${recovery.summary.recovered} assets to project directory!`); for (const recoveredAsset of recovery.recovered) { const resultIndex = results.findIndex((r) => r.nodeId === recoveredAsset.nodeId); if (resultIndex !== -1 && recoveredAsset.success && results[resultIndex]) { results[resultIndex].filePath = recoveredAsset.newPath; results[resultIndex].success = true; } } } } } } } return { downloaded: results, summary, workspaceEnforcement: workspaceEnforcement ? { finalLocation: workspaceEnforcement.finalLocation, moved: workspaceEnforcement.summary.moved, workspaceSource: workspaceEnforcement.workspaceInfo.source, confidence: workspaceEnforcement.workspaceInfo.confidence } : null }; } /** * Download images to local directory based on export settings */ async downloadImagesWithExportSettings(fileKey, nodes, localPath, options = {}) { const resolvedPath = _FigmaApiService.resolvePath(localPath); await _FigmaApiService.createDirectorySafely(resolvedPath, localPath); const results = []; const usedFilenames = /* @__PURE__ */ new Set(); const filenameCounters = /* @__PURE__ */ new Map(); const contentHashes = /* @__PURE__ */ new Map(); const existingFiles = /* @__PURE__ */ new Set(); try { const files = await fs.readdir(resolvedPath); files.forEach((file) => existingFiles.add(file)); if (options.overwriteExisting) { console.error(`[Figma API] \u{1F504} Overwrite mode: Will replace ${files.length} existing files if needed`); } else { files.forEach((file) => { usedFilenames.add(file); console.error(`[Figma API] \u{1F4C1} Existing file detected: ${file} (will increment if duplicate)`); }); } } catch (error) { console.error(`[Figma API] \u{1F4C1} Target directory empty or doesn't exist yet`); } const generateContentHash = (node, exportSetting) => { const hashComponents = [ node.type, // Don't include node.id for icons - we want to deduplicate identical icons regardless of their node ID // node.id, // REMOVED - this was preventing icon deduplication node.name, // Keep node name for uniqueness exportSetting.format, exportSetting.constraint?.type || "none", exportSetting.constraint?.value || 1, exportSetting.suffix || "", JSON.stringify(node.fills || []), JSON.stringify(node.strokes || []), JSON.stringify(node.effects || []), node.cornerRadius || 0, node.strokeWeight || 0, node.type === "TEXT" ? node.characters || "" : "", node.absoluteBoundingBox ? `${Math.round(node.absoluteBoundingBox.width)}x${Math.round(node.absoluteBoundingBox.height)}` : "", // Add more specific properties for better differentiation node.blendMode || "", node.opacity || 1, JSON.stringify(node.strokeDashes || []) ]; const hashString = hashComponents.join("|"); return hashString.replace(/[^a-zA-Z0-9]/g, "").substring(0, 32); }; const isReusableAsset = (node, sanitizedName) => { const name = sanitizedName.toLowerCase(); const iconPatterns = [ "akar-icons-", "dashicons-", "ci-", "uis-", "mdi-", "ant-design-", "feather-", "heroicons-", "lucide-", "tabler-", "phosphor-", "icon-", "ico-" ]; const isIcon = iconPatterns.some((pattern) => name.includes(pattern)); const size = node.absoluteBoundingBox; const isSmallSize = size ? size.width <= 100 && size.height <= 100 : false; const isVectorType = node.type === "VECTOR" || node.type === "BOOLEAN_OPERATION" || node.type === "COMPONENT"; const shouldDeduplicate = isIcon || isSmallSize && isVectorType; if (shouldDeduplicate) { console.error(`[Figma API] \u{1F517} Detected reusable asset: "${name}" (icon: ${isIcon}, small: ${isSmallSize}, vector: ${isVectorType})`); } return shouldDeduplicate; }; const generateUniqueFilename = (node, baseName, extension, exportSetting) => { const baseFilename = `${baseName}.${extension}`; if (isReusableAsset(node, baseName)) { const contentHash = generateContentHash(node, exportSetting); if (contentHashes.has(contentHash)) { const existingAsset = contentHashes.get(contentHash); console.error(`[Figma API] \u{1F517} Content duplicate detected: "${baseName}" \u2192 reusing "${existingAsset.filename}" (same as ${existingAsset.nodeName})`); return existingAsset.filename; } contentHashes.set(contentHash, { filename: baseFilename, nodeId: node.id, nodeName: baseName }); console.error(`[Figma API] \u{1F195} New unique icon registered: "${baseName}" with hash ${contentHash.substring(0, 8)}...`); } if (!usedFilenames.has(baseFilename)) { usedFilenames.add(baseFilename); return baseFilename; } const counter = filenameCounters.get(baseName) || 0; let uniqueFilename; let currentCounter = counter + 1; do { uniqueFilename = `${baseName}-${currentCounter}.${extension}`; currentCounter++; } while (usedFilenames.has(uniqueFilename)); filenameCounters.set(baseName, currentCounter - 1); usedFilenames.add(uniqueFilename); console.error(`[Figma API] \u{1F504} Filename duplicate resolved: "${baseFilename}" \u2192 "${uniqueFilename}"`); return uniqueFilename; }; const nodesToExport = []; const findExportableNodes = (node) => { const nodeName = node.name.toLowerCase(); const isIconName = ["uis:", "dashicons:", "ci:", "icon", "svg"].some((keyword) => nodeName.includes(keyword)); if (isIconName) { console.error(`[Figma API] \u{1F50D} DEBUG: Found potential icon "${node.name}" (${node.type})`); console.error(`[Figma API] \u{1F4CB} Export settings: ${node.exportSettings ? node.exportSettings.length : 0} found`); if (node.exportSettings && node.exportSettings.length > 0) { node.exportSettings.forEach((setting, index) => { const scale = setting.constraint?.type === "SCALE" ? setting.constraint.value : 1; console.error(`[Figma API] \u{1F4C4} Setting ${index}: format=${setting.format}, scale=${scale}x, suffix=${setting.suffix || "none"}`); }); } else { console.error(`[Figma API] \u26A0\uFE0F No export settings found for icon "${node.name}"`); } } if (node.exportSettings && node.exportSettings.length > 0) { for (const exportSetting of node.exportSettings) { nodesToExport.push({ node, exportSetting }); console.error(`[Figma API] \u2705 Added to export queue: "${node.name}" as ${exportSetting.format}`); } } if (node.children) { for (const child of node.children) { findExportableNodes(child); } } }; console.error(`[Figma API] \u{1F50D} Scanning ${nodes.length} root nodes for export settings...`); for (const node of nodes) { console.error(`[Figma API] \u{1F4C1} Scanning node: "${node.name}" (${node.type})`); findExportableNodes(node); } if (nodesToExport.length === 0) { console.error(`[Figma API] \u274C No nodes with export settings found!`); console.error(`[Figma API] \u{1F4A1} Make sure your icons have export settings configured in Figma:`); console.error(`[Figma API] 1. Select the icon in Figma`); console.error(`[Figma API] 2. In the right panel, scroll to "Export" section`); console.error(`[Figma API] 3. Click "+" to add export settings`); console.error(`[Figma API] 4. Choose SVG format for icons`); return { downloaded: [], summary: { total: 0, successful: 0, failed: 0, skipped: 0 } }; } console.error(`[Figma API] \u2705 Found ${nodesToExport.length} export tasks from ${nodes.length} nodes`); const exportGroups = /* @__PURE__ */ new Map(); for (const item of nodesToExport) { const { exportSetting } = item; let scale = 1; if (exportSetting.constraint) { if (exportSetting.constraint.type === "SCALE") { scale = exportSetting.constraint.value; } } const format = exportSetting.format.toLowerCase(); if (format === "svg") { scale = 1; } const groupKey = `${format}_${scale}`; if (!exportGroups.has(groupKey)) { exportGroups.set(groupKey, []); } exportGroups.get(groupKey).push(item); } console.error(`[Figma API] Grouped exports into ${exportGroups.size} batches by format/scale`); for (const [groupKey, groupItems] of exportGroups) { const [format, scaleStr] = groupKey.split("_"); const scale = parseFloat(scaleStr || "1"); console.error(`[Figma API] Processing group: ${format} at ${scale}x scale (${groupItems.length} items)`); const batchSize = 10; for (let i = 0; i < groupItems.length; i += batchSize) { const batch = groupItems.slice(i, i + batchSize); const nodeIds = batch.map((item) => item.node.id); try { const imageResponse = await this.getImages(fileKey, nodeIds, { format, scale, use_absolute_bounds: true }); for (const { node, exportSetting } of batch) { const imageUrl = imageResponse.images[node.id]; if (!imageUrl) { results.push({ nodeId: node.id, nodeName: node.name.replace(/[/\\:*?"<>|]/g, "-").replace(/\s+/g, " ").trim(), filePath: "", exportSetting, success: false, error: "No image URL returned from Figma API" }); continue; } const rawNodeName = node.name; const sanitizedNodeName = rawNodeName.replace(/[/\\:*?"<>|]/g, "-").replace(/\s+/g, " ").trim(); const suffix = exportSetting.suffix || ""; const extension = exportSetting.format.toLowerCase(); let baseFilename; if (suffix) { baseFilename = `${sanitizedNodeName}${suffix}`; } else { if (scale === 1) { baseFilename = `${sanitizedNodeName}-x1`; } else { baseFilename = `${sanitizedNodeName}-x${scale}`; } } const filename = generateUniqueFilename(node, baseFilename, extension, exportSetting); const filePath = path.join(resolvedPath, filename); try { const downloadResponse = await axios.get(imageUrl, { responseType: "arraybuffer", timeout: 3e4, headers: { "User-Agent": "Custom-Figma-MCP-Server/1.0.0" } }); await fs.writeFile(filePath, downloadResponse.data); results.push({ nodeId: node.id, nodeName: sanitizedNodeName, filePath, exportSetting, success: true }); console.error(`[Figma API] Downloaded: ${filename} (${(downloadResponse.data.byteLength / 1024).toFixed(1)}KB)`); } catch (downloadError) { results.push({ nodeId: node.id, nodeName: sanitizedNodeName, filePath, exportSetting, success: false, error: `Download failed: ${downloadError instanceof Error ? downloadError.message : String(downloadError)}` }); console.error(`[Figma API] Failed to download ${filename}:`, downloadError); } } } catch (batchError) { console.error(`[Figma API] Batch failed for group ${groupKey}:`, batchError); for (const { node, exportSetting } of batch) { results.push({ nodeId: node.id, nodeName: node.name.replace(/[/\\:*?"<>|]/g, "-").replace(/\s+/g, " ").trim(), filePath: "", exportSetting, success: false, error: `Batch API call failed: ${batchError instanceof Error ? batchError.message : String(batchError)}` }); } } if (i + batchSize < groupItems.length) { await new Promise((resolve) => setTimeout(resolve, 100)); } } } const summary = { total: results.length, successful: results.filter((r) => r.success).length, failed: results.filter((r) => !r.success).length, skipped: 0 // We process all nodes with export settings }; console.error(`[Figma API] Download completed: ${summary.successful}/${summary.total} successful`); let workspaceEnforcement = null; if (summary.successful > 0 && !options.skipWorkspaceEnforcement) { console.error(`[Figma API] \u{1F504} Starting workspace enforcement for ${summary.successful} successful downloads...`); try { workspaceEnforcement = await _FigmaApiService.enforceWorkspaceLocation(results, localPath); console.error(`[Figma API] \u{1F3AF} Export workspace enforcement completed successfully!`); console.error(`[Figma API] \u2705 ${workspaceEnforcement.summary.alreadyCorrect} already in correct location`); console.error(`[Figma API] \u{1F4E6} ${workspaceEnforcement.summary.moved} moved to workspace`); console.error(`[Figma API] \u274C ${workspaceEnforcement.summary.failed} failed to move`); console.error(`[Figma API] \u{1F4C1} Final location: ${workspaceEnforcement.finalLocation}`); } catch (enforcementError) { console.error(`[Figma API] \u274C Export workspace enforcement failed completely:`, enforcementError); console.error(`[Figma API] \u{1F504} Falling back to legacy recovery system...`); const expectedPaths = results.filter((r) => r.success).map((r) => r.filePath); if (expectedPaths.length > 0) { console.error(`[Figma API] \u{1F50D} Verifying ${expectedPaths.length} expected paths...`); const verification = await _FigmaApiService.verifyAssetsLocation(expectedPaths); console.error(`[Figma API] \u{1F4CA} Verification results: ${verification.summary.found} found, ${verification.summary.missing} missing`); if (verification.summary.missing > 0) { console.error(`[Figma API] \u26A0\uFE0F ${verification.summary.missing} export assets missing from expected location, attempting recovery...`); const recovery = await _FigmaApiService.findAndRecoverMissingAssets(results, resolvedPath); console.error(`[Figma API] \u{1F4CA} Recovery results: ${recovery.summary.recovered}/${recovery.summary.total} recovered`); if (recovery.summary.recovered > 0) { console.error(`[Figma API] \u{1F389} Successfully recovered ${recovery.summary.recovered} export assets to project directory!`); for (const recoveredAsset of recovery.recovered) { const resultIndex = results.findIndex((r) => r.nodeId === recoveredAsset.nodeId); if (resultIndex !== -1 && recoveredAsset.success && results[resultIndex]) { results[resultIndex].filePath = recoveredAsset.newPath; results[resultIndex].success = true; console.error(`[Figma API] \u{1F4E6} Updated result path: ${recoveredAsset.oldPath} \u2192 ${recoveredAsset.newPath}`); } } } else { console.error(`[Figma API] \u26A0\uFE0F Recovery system could not locate missing assets`); } } else { console.error(`[Figma API] \u2705 All assets verified at expected locations`); } } } } else { console.error(`[Figma API] \u23ED\uFE0F Skipping workspace enforcement - no successful downloads`); } return { downloaded: results, summary, workspaceEnforcement: workspaceEnforcement ? { finalLocation: workspaceEnforcement.finalLocation, moved: workspaceEnforcement.summary.moved, workspaceSource: workspaceEnforcement.workspaceInfo.source, confidence: workspaceEnforcement.workspaceInfo.confidence } : null }; } /** * Get comments for a Figma file */ async getComments(fileKey) { if (!fileKey || typeof fileKey !== "string") { throw new FigmaApiError("File key is required and must be a string"); } try { return await this.makeRequest(`/files/${fileKey}/comments`); } catch (error) { if (error instanceof FigmaApiError) { throw error; } throw new FigmaApiError(`Failed to