Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
177 changes: 71 additions & 106 deletions src/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ function createServer(
) {
const server = new McpServer(serverInfo);
// const figmaService = new FigmaService(figmaApiKey);
const figmaService = new FigmaService(authOptions);
const figmaService = FigmaService.getInstance(authOptions);
registerTools(server, figmaService);

Logger.isHTTP = isHTTP;
Expand Down Expand Up @@ -92,111 +92,76 @@ function registerTools(server: McpServer, figmaService: FigmaService): void {

// TODO: Clean up all image download related code, particularly getImages in Figma service
// Tool to download images
server.tool(
"download_figma_images",
"Download SVG and PNG images used in a Figma file based on the IDs of image or icon nodes",
{
fileKey: z.string().describe("The key of the Figma file containing the node"),
nodes: z
.object({
nodeId: z
.string()
.describe("The ID of the Figma image node to fetch, formatted as 1234:5678"),
imageRef: z
.string()
.optional()
.describe(
"If a node has an imageRef fill, you must include this variable. Leave blank when downloading Vector SVG images.",
),
fileName: z.string().describe("The local name for saving the fetched file"),
})
.array()
.describe("The nodes to fetch as images"),
pngScale: z
.number()
.positive()
.optional()
.default(2)
.describe(
"Export scale for PNG images. Optional, defaults to 2 if not specified. Affects PNG images only.",
),
localPath: z
.string()
.describe(
"The absolute path to the directory where images are stored in the project. If the directory does not exist, it will be created. The format of this path should respect the directory format of the operating system you are running on. Don't use any special character escaping in the path name either.",
),
svgOptions: z
.object({
outlineText: z
.boolean()
.optional()
.default(true)
.describe("Whether to outline text in SVG exports. Default is true."),
includeId: z
.boolean()
.optional()
.default(false)
.describe("Whether to include IDs in SVG exports. Default is false."),
simplifyStroke: z
.boolean()
.optional()
.default(true)
.describe("Whether to simplify strokes in SVG exports. Default is true."),
})
.optional()
.default({})
.describe("Options for SVG export"),
},
async ({ fileKey, nodes, localPath, svgOptions, pngScale }) => {
try {
const imageFills = nodes.filter(({ imageRef }) => !!imageRef) as {
nodeId: string;
imageRef: string;
fileName: string;
}[];
const fillDownloads = figmaService.getImageFills(fileKey, imageFills, localPath);
const renderRequests = nodes
.filter(({ imageRef }) => !imageRef)
.map(({ nodeId, fileName }) => ({
nodeId,
fileName,
fileType: fileName.endsWith(".svg") ? ("svg" as const) : ("png" as const),
}));

const renderDownloads = figmaService.getImages(
fileKey,
renderRequests,
localPath,
pngScale,
svgOptions,
);

const downloads = await Promise.all([fillDownloads, renderDownloads]).then(([f, r]) => [
...f,
...r,
]);

// If any download fails, return false
const saveSuccess = !downloads.find((success) => !success);
return {
content: [
{
type: "text",
text: saveSuccess
? `Success, ${downloads.length} images downloaded: ${downloads.join(", ")}`
: "Failed",
},
],
};
} catch (error) {
Logger.error(`Error downloading images from file ${fileKey}:`, error);
return {
isError: true,
content: [{ type: "text", text: `Error downloading images: ${error}` }],
};
}
},
);
// server.tool(
// "download_figma_images",
// "Download SVG and PNG images used in a Figma file based on the IDs of image or icon nodes",
// {
// fileKey: z.string().describe("The key of the Figma file containing the node"),
// nodes: z
// .object({
// nodeId: z
// .string()
// .describe("The ID of the Figma image node to fetch, formatted as 1234:5678"),
// imageRef: z
// .string()
// .optional()
// .describe(
// "If a node has an imageRef fill, you must include this variable. Leave blank when downloading Vector SVG images.",
// ),
// fileName: z.string().describe("The local name for saving the fetched file"),
// })
// .array()
// .describe("The nodes to fetch as images"),
// localPath: z
// .string()
// .describe(
// "The absolute path to the directory where images are stored in the project. If the directory does not exist, it will be created. The format of this path should respect the directory format of the operating system you are running on. Don't use any special character escaping in the path name either.",
// ),
// },
// async ({ fileKey, nodes, localPath }) => {
// try {
// const imageFills = nodes.filter(({ imageRef }) => !!imageRef) as {
// nodeId: string;
// imageRef: string;
// fileName: string;
// }[];
// const fillDownloads = figmaService.getImageFills(fileKey, imageFills, localPath);
// const renderRequests = nodes
// .filter(({ imageRef }) => !imageRef)
// .map(({ nodeId, fileName }) => ({
// nodeId,
// fileName,
// fileType: fileName.endsWith(".svg") ? ("svg" as const) : ("png" as const),
// }));
//
// const renderDownloads = figmaService.getImages(fileKey, renderRequests, localPath);
//
// const downloads = await Promise.all([fillDownloads, renderDownloads]).then(([f, r]) => [
// ...f,
// ...r,
// ]);
//
// // If any download fails, return false
// const saveSuccess = !downloads.find((success) => !success);
// return {
// content: [
// {
// type: "text",
// text: saveSuccess
// ? `Success, ${downloads.length} images downloaded: ${downloads.join(", ")}`
// : "Failed",
// },
// ],
// };
// } catch (error) {
// Logger.error(`Error downloading images from file ${fileKey}:`, error);
// return {
// isError: true,
// content: [{ type: "text", text: `Error downloading images: ${error}` }],
// };
// }
// },
// );
}

export { createServer };
61 changes: 49 additions & 12 deletions src/services/figma.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,15 @@ import { downloadFigmaImage } from "~/utils/common.js";
import { Logger } from "~/utils/logger.js";
import { fetchWithRetry } from "~/utils/fetch-with-retry.js";
import yaml from "js-yaml";
import path from "path";

export type FigmaAuthOptions = {
figmaApiKey: string;
figmaOAuthToken: string;
useOAuth: boolean;
};

type FetchImageParams = {
export type FetchImageParams = {
/**
* The Node in Figma that will either be rendered or have its background image downloaded
*/
Expand All @@ -39,18 +40,47 @@ type FetchImageFillParams = Omit<FetchImageParams, "fileType"> & {
imageRef: string;
};

type GetImagesParams = {
/**
* Whether text elements are rendered as outlines (vector paths) or as <text> elements in SVGs.
*/
outlineText?: boolean;

/**
* Whether to include id attributes for all SVG elements. Adds the layer name to the id attribute of an svg element.
*/
includeId?: boolean;

/**
* Whether to simplify inside/outside strokes and use stroke attribute if possible instead of <mask>.
*/
simplifyStroke?: boolean;
};

export class FigmaService {
// Static property to store the singleton instance
private static instance: FigmaService | null = null;

private readonly apiKey: string;
private readonly oauthToken: string;
private readonly useOAuth: boolean;
private readonly baseUrl = "https://api.figma.com/v1";

constructor({ figmaApiKey, figmaOAuthToken, useOAuth }: FigmaAuthOptions) {
private constructor({ figmaApiKey, figmaOAuthToken, useOAuth }: FigmaAuthOptions) {
this.apiKey = figmaApiKey || "";
this.oauthToken = figmaOAuthToken || "";
this.useOAuth = !!useOAuth && !!this.oauthToken;
}

// Static method to retrieve the singleton instance
public static getInstance(options?: FigmaAuthOptions): FigmaService {
if (!FigmaService.instance && options) {
const {figmaApiKey, figmaOAuthToken, useOAuth} = options
FigmaService.instance = new FigmaService({ figmaApiKey, figmaOAuthToken, useOAuth });
}
return <FigmaService>FigmaService.instance;
}

private async request<T>(endpoint: string): Promise<T> {
try {
Logger.log(`Calling ${this.baseUrl}${endpoint}`);
Expand Down Expand Up @@ -105,11 +135,7 @@ export class FigmaService {
nodes: FetchImageParams[],
localPath: string,
pngScale: number,
svgOptions: {
outlineText: boolean;
includeId: boolean;
simplifyStroke: boolean;
},
{ outlineText = true, includeId = false, simplifyStroke = true }: GetImagesParams = {},
): Promise<string[]> {
const pngIds = nodes.filter(({ fileType }) => fileType === "png").map(({ nodeId }) => nodeId);
const pngFiles =
Expand All @@ -123,9 +149,9 @@ export class FigmaService {
const svgParams = [
`ids=${svgIds.join(",")}`,
"format=svg",
`svg_outline_text=${svgOptions.outlineText}`,
`svg_include_id=${svgOptions.includeId}`,
`svg_simplify_stroke=${svgOptions.simplifyStroke}`,
`svg_outline_text=${outlineText}`,
`svg_include_id=${includeId}`,
`svg_simplify_stroke=${simplifyStroke}`,
].join("&");

const svgFiles =
Expand All @@ -141,6 +167,17 @@ export class FigmaService {
.map(({ nodeId, fileName }) => {
const imageUrl = files[nodeId];
if (imageUrl) {
// Build the complete file path
const fullPath = path.join(localPath, fileName);

if (!fs.existsSync(localPath)) {
fs.mkdirSync(localPath, { recursive: true });
}

if (fs.existsSync(fullPath)) {
fs.unlinkSync(fullPath)
}

return downloadFigmaImage(fileName, localPath, imageUrl);
}
return false;
Expand All @@ -156,7 +193,7 @@ export class FigmaService {
Logger.log(`Retrieving Figma file: ${fileKey} (depth: ${depth ?? "default"})`);
const response = await this.request<GetFileResponse>(endpoint);
Logger.log("Got response");
const simplifiedResponse = parseFigmaResponse(response);
const simplifiedResponse = await parseFigmaResponse(fileKey, response);
writeLogs("figma-raw.yml", response);
writeLogs("figma-simplified.yml", simplifiedResponse);
return simplifiedResponse;
Expand All @@ -171,7 +208,7 @@ export class FigmaService {
const response = await this.request<GetFileNodesResponse>(endpoint);
Logger.log("Got response from getNode, now parsing.");
writeLogs("figma-raw.yml", response);
const simplifiedResponse = parseFigmaResponse(response);
const simplifiedResponse = await parseFigmaResponse(fileKey, response);
writeLogs("figma-simplified.yml", simplifiedResponse);
return simplifiedResponse;
}
Expand Down
Loading