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
33 changes: 32 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,41 @@ The `figma-developer-mcp` server can be configured by adding the following to yo
}
```

Or you can set `FIGMA_API_KEY` and `PORT` in the `env` field.
If you prefer to manage credentials via environment variables (as recommended in the MCP client spec), place them in the `env` object alongside your server definition. Example Cursor configuration:

```jsonc
{
"mcpServers": {
"Framelink MCP for Figma": {
"command": "npx",
"args": ["-y", "figma-developer-mcp", "--stdio"],
"env": {
"FIGMA_API_KEY": "YOUR-KEY",
"FIGMA_CACHING": "{\"ttl\":{\"value\":30,\"unit\":\"d\"}}",
"PORT": "3333"
}
}
}
}
```

If you need more information on how to configure the Framelink MCP for Figma, see the [Framelink docs](https://www.framelink.ai/docs/quickstart?utm_source=github&utm_medium=referral&utm_campaign=readme).

### Support for free Figma accounts: Persistent caching (optional)

To avoid hitting Figma's heavy rate limits, you can tell the MCP server to cache full file responses on disk by setting a `FIGMA_CACHING` environment variable that contains a JSON object.

```bash
FIGMA_CACHING='{ "ttl": { "value": 30, "unit": "d" } }'
```

Put this var into your mcp config json, see example above.

- `cacheDir` (optional) controls where cached files are written. Relative paths are resolved against the current working directory and `~` expands to your home directory. If you omit it, the server defaults to `~/.cache/figma-mcp` on Linux, `~/Library/Caches/FigmaMcp` on macOS, and `%LOCALAPPDATA%/FigmaMcpCache` on Windows.
- `ttl` controls how long a cached file remains valid. It must contain a `value` (number) and a `unit` (`ms`, `s`, `m`, `h`, or `d`).

When caching is enabled the server always fetches the full Figma file once, stores it on disk, and serves subsequent `get_figma_data` / `get_raw_node` requests from the cached copy until it expires. Delete the files inside `cacheDir` if you need to force a refresh. Leaving `FIGMA_CACHING` unset keeps the default non-cached behavior.

## Star History

<a href="https://star-history.com/#GLips/Figma-Context-MCP"><img src="https://api.star-history.com/svg?repos=GLips/Figma-Context-MCP&type=Date" alt="Star History Chart" width="600" /></a>
Expand Down
107 changes: 106 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import { config as loadEnv } from "dotenv";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import { resolve } from "path";
import os from "os";
import { isAbsolute, join, resolve } from "path";
import type { FigmaAuthOptions } from "./services/figma.js";
import type { FigmaCachingOptions } from "./services/figma-file-cache.js";

interface ServerConfig {
auth: FigmaAuthOptions;
port: number;
host: string;
outputFormat: "yaml" | "json";
skipImageDownloads?: boolean;
caching?: FigmaCachingOptions;
configSources: {
figmaApiKey: "cli" | "env";
figmaOAuthToken: "cli" | "env" | "none";
Expand All @@ -18,6 +21,7 @@ interface ServerConfig {
outputFormat: "cli" | "env" | "default";
envFile: "cli" | "default";
skipImageDownloads?: "cli" | "env" | "default";
caching?: "env";
};
}

Expand All @@ -36,6 +40,16 @@ interface CliArgs {
"skip-image-downloads"?: boolean;
}

type DurationUnit = "ms" | "s" | "m" | "h" | "d";

const DURATION_IN_MS: Record<DurationUnit, number> = {
ms: 1,
s: 1000,
m: 60 * 1000,
h: 60 * 60 * 1000,
d: 24 * 60 * 60 * 1000,
};

export function getServerConfig(isStdioMode: boolean): ServerConfig {
// Parse command line arguments
const argv = yargs(hideBin(process.argv))
Expand Down Expand Up @@ -101,6 +115,7 @@ export function getServerConfig(isStdioMode: boolean): ServerConfig {
host: "127.0.0.1",
outputFormat: "yaml",
skipImageDownloads: false,
caching: undefined,
configSources: {
figmaApiKey: "env",
figmaOAuthToken: "none",
Expand All @@ -109,6 +124,7 @@ export function getServerConfig(isStdioMode: boolean): ServerConfig {
outputFormat: "default",
envFile: envFileSource,
skipImageDownloads: "default",
caching: undefined,
},
};

Expand Down Expand Up @@ -171,6 +187,13 @@ export function getServerConfig(isStdioMode: boolean): ServerConfig {
config.configSources.skipImageDownloads = "env";
}

// Handle FIGMA_CACHING
const cachingConfig = parseCachingConfig(process.env.FIGMA_CACHING);
if (cachingConfig) {
config.caching = cachingConfig;
config.configSources.caching = "env";
}

// Validate configuration
if (!auth.figmaApiKey && !auth.figmaOAuthToken) {
console.error(
Expand Down Expand Up @@ -202,6 +225,9 @@ export function getServerConfig(isStdioMode: boolean): ServerConfig {
console.log(
`- SKIP_IMAGE_DOWNLOADS: ${config.skipImageDownloads} (source: ${config.configSources.skipImageDownloads})`,
);
console.log(
`- FIGMA_CACHING: ${config.caching ? JSON.stringify({ cacheDir: config.caching.cacheDir, ttlMs: config.caching.ttlMs }) : "disabled"}`,
);
console.log(); // Empty line for better readability
}

Expand All @@ -210,3 +236,82 @@ export function getServerConfig(isStdioMode: boolean): ServerConfig {
auth,
};
}

function parseCachingConfig(rawValue: string | undefined): FigmaCachingOptions | undefined {
if (!rawValue) return undefined;

try {
const parsed = JSON.parse(rawValue) as {
cacheDir?: string;
ttl: {
value: number;
unit: DurationUnit;
};
};

if (!parsed || typeof parsed !== "object") {
throw new Error("FIGMA_CACHING must be a JSON object");
}

if (!parsed.ttl || typeof parsed.ttl.value !== "number" || parsed.ttl.value <= 0) {
throw new Error("FIGMA_CACHING.ttl.value must be a positive number");
}

if (!parsed.ttl.unit || !(parsed.ttl.unit in DURATION_IN_MS)) {
throw new Error("FIGMA_CACHING.ttl.unit must be one of ms, s, m, h, d");
}

const ttlMs = parsed.ttl.value * DURATION_IN_MS[parsed.ttl.unit];
const cacheDir = resolveCacheDir(parsed.cacheDir);

return {
cacheDir,
ttlMs,
};
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
console.error(`Failed to parse FIGMA_CACHING: ${message}`);
process.exit(1);
}
}

function resolveCacheDir(inputPath?: string): string {
const defaultDir = getDefaultCacheDir();
if (!inputPath) {
return defaultDir;
}

const expanded = expandHomeDir(inputPath.trim());
if (isAbsolute(expanded)) {
return expanded;
}
return resolve(process.cwd(), expanded);
}

function expandHomeDir(targetPath: string): string {
if (targetPath === "~") {
return os.homedir();
}

if (targetPath.startsWith("~/")) {
return resolve(os.homedir(), targetPath.slice(2));
}

return targetPath;
}

function getDefaultCacheDir(): string {
const platform = process.platform;
if (platform === "win32") {
const base = process.env.LOCALAPPDATA || resolve(os.homedir(), "AppData", "Local");
return join(base, "FigmaMcpCache");
}

if (platform === "darwin") {
return join(os.homedir(), "Library", "Caches", "FigmaMcp");
}

// linux and others -> use XDG cache dir
const xdgCache = process.env.XDG_CACHE_HOME || join(os.homedir(), ".cache");
return join(xdgCache, "figma-mcp");
}
11 changes: 9 additions & 2 deletions src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
type DownloadImagesParams,
type GetFigmaDataParams,
} from "./tools/index.js";
import type { FigmaCachingOptions } from "~/services/figma-file-cache.js";

const serverInfo = {
name: "Figma MCP Server",
Expand All @@ -19,14 +20,20 @@ type CreateServerOptions = {
isHTTP?: boolean;
outputFormat?: "yaml" | "json";
skipImageDownloads?: boolean;
caching?: FigmaCachingOptions;
};

function createServer(
authOptions: FigmaAuthOptions,
{ isHTTP = false, outputFormat = "yaml", skipImageDownloads = false }: CreateServerOptions = {},
{
isHTTP = false,
outputFormat = "yaml",
skipImageDownloads = false,
caching,
}: CreateServerOptions = {},
) {
const server = new McpServer(serverInfo);
const figmaService = new FigmaService(authOptions);
const figmaService = new FigmaService(authOptions, caching);
registerTools(server, figmaService, { outputFormat, skipImageDownloads });

Logger.isHTTP = isHTTP;
Expand Down
40 changes: 36 additions & 4 deletions src/mcp/tools/get-figma-data-tool.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { z } from "zod";
import type { GetFileResponse, GetFileNodesResponse } from "@figma/rest-api-spec";
import { FigmaService } from "~/services/figma.js";
import { FigmaService, type CacheInfo } from "~/services/figma.js";
import {
simplifyRawFigmaObject,
allExtractors,
Expand Down Expand Up @@ -37,6 +37,27 @@ const parameters = {
const parametersSchema = z.object(parameters);
export type GetFigmaDataParams = z.infer<typeof parametersSchema>;

// Format a human-readable cache notice
function formatCacheNotice(cachedAt: number, ttlMs: number): string {
const now = Date.now();
const age = now - cachedAt;
const remaining = ttlMs - age;

const formatDuration = (ms: number): string => {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);

if (days > 0) return `${days}d ${hours % 24}h`;
if (hours > 0) return `${hours}h ${minutes % 60}m`;
if (minutes > 0) return `${minutes}m`;
return `${seconds}s`;
};

return `ℹ️ Note: Using cached Figma data (fetched ${formatDuration(age)} ago, expires in ${formatDuration(remaining)}) due to FIGMA_CACHING environment variable.`;
}

// Simplified handler function
async function getFigmaData(
params: GetFigmaDataParams,
Expand All @@ -57,10 +78,15 @@ async function getFigmaData(

// Get raw Figma API response
let rawApiResponse: GetFileResponse | GetFileNodesResponse;
let cacheInfo: CacheInfo;
if (nodeId) {
rawApiResponse = await figmaService.getRawNode(fileKey, nodeId, depth);
const result = await figmaService.getRawNode(fileKey, nodeId, depth);
rawApiResponse = result.data;
cacheInfo = result.cacheInfo;
} else {
rawApiResponse = await figmaService.getRawFile(fileKey, depth);
const result = await figmaService.getRawFile(fileKey, depth);
rawApiResponse = result.data;
cacheInfo = result.cacheInfo;
}

// Use unified design extraction (handles nodes + components consistently)
Expand All @@ -85,9 +111,15 @@ async function getFigmaData(
};

Logger.log(`Generating ${outputFormat.toUpperCase()} result from extracted data`);
const formattedResult =
let formattedResult =
outputFormat === "json" ? JSON.stringify(result, null, 2) : yaml.dump(result);

// Prepend cache notice if data came from cache
if (cacheInfo.usedCache && cacheInfo.cachedAt && cacheInfo.ttlMs) {
const cacheNotice = formatCacheNotice(cacheInfo.cachedAt, cacheInfo.ttlMs);
formattedResult = `${cacheNotice}\n\n${formattedResult}`;
}

Logger.log("Sending result to client");
return {
content: [{ type: "text" as const, text: formattedResult }],
Expand Down
1 change: 1 addition & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export async function startServer(): Promise<void> {
isHTTP: !isStdioMode,
outputFormat: config.outputFormat,
skipImageDownloads: config.skipImageDownloads,
caching: config.caching,
});

if (isStdioMode) {
Expand Down
Loading