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
JavaScript
#!/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