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
17 changes: 17 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions deploy.json
Original file line number Diff line number Diff line change
Expand Up @@ -430,5 +430,14 @@
"veo/**",
"shared/**"
]
},
"figma": {
"site": "figma",
"entrypoint": "./dist/server/main.js",
"platformName": "kubernetes-bun",
"watch": [
"figma/**",
"shared/**"
]
}
}
19 changes: 19 additions & 0 deletions figma/app.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"scopeName": "deco",
"name": "figma",
"friendlyName": "Figma",
"connection": {
"type": "HTTP",
"url": "https://sites-figma.decocache.com/mcp"
},
"description": "Access Figma design files, comments, components, and styles via the Figma REST API.",
"icon": "https://static.figma.com/app/icon/1/favicon.png",
"unlisted": false,
"metadata": {
"categories": ["Design", "Developer Tools"],
"official": false,
"tags": ["figma", "design", "ui", "components", "styles", "prototyping", "collaboration", "design-systems"],
"short_description": "Access Figma design files, comments, components, and styles via the Figma REST API.",
"mesh_description": "The Figma MCP provides comprehensive read and comment access to Figma design files through the Figma REST API. It enables AI agents to retrieve file structures and node trees, export rendered images in multiple formats, read and post comments on designs, browse version history, list team projects and files, and access published component and style libraries."
}
}
23 changes: 23 additions & 0 deletions figma/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "@decocms/figma",
"version": "1.0.0",
"description": "Figma MCP for design file access, comments, components, and styles",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run --hot server/main.ts",
"build:server": "NODE_ENV=production bun build server/main.ts --target=bun --outfile=dist/server/main.js",
"build": "bun run build:server",
"publish": "cat app.json | deco registry publish -w /shared/deco -y",
"check": "tsc --noEmit"
},
"dependencies": {
"@decocms/runtime": "1.3.1",
"zod": "^4.0.0"
},
"devDependencies": {
"@decocms/mcps-shared": "1.0.0",
"deco-cli": "^0.28.0",
"typescript": "^5.7.2"
}
}
36 changes: 36 additions & 0 deletions figma/server/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export const FIGMA_API_BASE = "https://api.figma.com";

export const ENDPOINTS = {
// Files
FILE: (key: string) => `/v1/files/${key}`,
FILE_NODES: (key: string) => `/v1/files/${key}/nodes`,
IMAGES: (key: string) => `/v1/images/${key}`,
IMAGE_FILLS: (key: string) => `/v1/files/${key}/images`,
FILE_META: (key: string) => `/v1/files/${key}/meta`,
FILE_VERSIONS: (key: string) => `/v1/files/${key}/versions`,

// Comments
COMMENTS: (fileKey: string) => `/v1/files/${fileKey}/comments`,
COMMENT: (fileKey: string, commentId: string) =>
`/v1/files/${fileKey}/comments/${commentId}`,
COMMENT_REACTIONS: (fileKey: string, commentId: string) =>
`/v1/files/${fileKey}/comments/${commentId}/reactions`,

// Teams & Projects
TEAM_PROJECTS: (teamId: string) => `/v1/teams/${teamId}/projects`,
PROJECT_FILES: (projectId: string) => `/v1/projects/${projectId}/files`,

// Components & Styles
TEAM_COMPONENTS: (teamId: string) => `/v1/teams/${teamId}/components`,
TEAM_STYLES: (teamId: string) => `/v1/teams/${teamId}/styles`,

// User
ME: "/v1/me",
};

export const FIGMA_SCOPES = [
"files:read",
"file_comments:write",
"file_dev_resources:read",
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Add the file_versions:read OAuth scope; otherwise file version requests may be unauthorized.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At figma/server/constants.ts, line 34:

<comment>Add the `file_versions:read` OAuth scope; otherwise file version requests may be unauthorized.</comment>

<file context>
@@ -0,0 +1,36 @@
+export const FIGMA_SCOPES = [
+  "files:read",
+  "file_comments:write",
+  "file_dev_resources:read",
+  "library_content:read",
+];
</file context>
Fix with Cubic

"library_content:read",
];
15 changes: 15 additions & 0 deletions figma/server/lib/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Env } from "../main.ts";

export function getAccessToken(env: Env): string {
const auth = env.MESH_REQUEST_CONTEXT?.authorization;
if (!auth) {
throw new Error(
"Missing authorization header. Please authenticate with Figma first.",
);
}
const match = auth.match(/^Bearer\s+(.+)$/i);
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Bearer token parsing is too permissive; it accepts whitespace inside the token value.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At figma/server/lib/env.ts, line 10:

<comment>Bearer token parsing is too permissive; it accepts whitespace inside the token value.</comment>

<file context>
@@ -0,0 +1,15 @@
+      "Missing authorization header. Please authenticate with Figma first.",
+    );
+  }
+  const match = auth.match(/^Bearer\s+(.+)$/i);
+  if (!match || !match[1].trim()) {
+    throw new Error("Invalid authorization header. Expected Bearer token.");
</file context>
Fix with Cubic

if (!match || !match[1].trim()) {
throw new Error("Invalid authorization header. Expected Bearer token.");
}
return match[1].trim();
}
246 changes: 246 additions & 0 deletions figma/server/lib/figma-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import { FIGMA_API_BASE, ENDPOINTS } from "../constants.ts";
import type {
FigmaComment,
FigmaCommentReaction,
FigmaComponentMeta,
FigmaFile,
FigmaFileMetaResponse,
FigmaFileNodes,
FigmaImageFillsResponse,
FigmaImageResponse,
FigmaPaginatedResponse,
FigmaProject,
FigmaProjectFile,
FigmaStyleMeta,
FigmaUser,
FigmaVersion,
} from "./types.ts";

export class FigmaClient {
private accessToken: string;

constructor(accessToken: string) {
this.accessToken = accessToken;
}

private async request<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
const url = `${FIGMA_API_BASE}${path}`;
const response = await fetch(url, {
...options,
headers: {
Authorization: `Bearer ${this.accessToken}`,
"Content-Type": "application/json",
...options.headers,
},
});

if (!response.ok) {
const body = await response.text();
throw new Error(`Figma API error ${response.status}: ${body}`);
}

if (response.status === 204) return {} as T;
return response.json() as Promise<T>;
}

// --- File methods ---

async getFile(
key: string,
params?: {
version?: string;
ids?: string[];
depth?: number;
geometry?: string;
plugin_data?: string;
},
): Promise<FigmaFile> {
const qs = new URLSearchParams();
if (params?.version) qs.set("version", params.version);
if (params?.ids) qs.set("ids", params.ids.join(","));
if (params?.depth != null) qs.set("depth", String(params.depth));
if (params?.geometry) qs.set("geometry", params.geometry);
if (params?.plugin_data) qs.set("plugin_data", params.plugin_data);
const query = qs.toString();
return this.request(`${ENDPOINTS.FILE(key)}${query ? `?${query}` : ""}`);
}

async getFileNodes(
key: string,
ids: string[],
params?: {
version?: string;
depth?: number;
geometry?: string;
plugin_data?: string;
},
): Promise<FigmaFileNodes> {
const qs = new URLSearchParams();
qs.set("ids", ids.join(","));
if (params?.version) qs.set("version", params.version);
if (params?.depth != null) qs.set("depth", String(params.depth));
if (params?.geometry) qs.set("geometry", params.geometry);
if (params?.plugin_data) qs.set("plugin_data", params.plugin_data);
return this.request(`${ENDPOINTS.FILE_NODES(key)}?${qs.toString()}`);
}

async getImages(
key: string,
ids: string[],
params?: {
scale?: number;
format?: string;
svg_include_id?: boolean;
svg_simplify_stroke?: boolean;
use_absolute_bounds?: boolean;
},
): Promise<FigmaImageResponse> {
const qs = new URLSearchParams();
qs.set("ids", ids.join(","));
if (params?.scale != null) qs.set("scale", String(params.scale));
if (params?.format) qs.set("format", params.format);
if (params?.svg_include_id != null)
qs.set("svg_include_id", String(params.svg_include_id));
if (params?.svg_simplify_stroke != null)
qs.set("svg_simplify_stroke", String(params.svg_simplify_stroke));
if (params?.use_absolute_bounds != null)
qs.set("use_absolute_bounds", String(params.use_absolute_bounds));
return this.request(`${ENDPOINTS.IMAGES(key)}?${qs.toString()}`);
}

async getImageFills(key: string): Promise<FigmaImageFillsResponse> {
return this.request(ENDPOINTS.IMAGE_FILLS(key));
}

async getFileMetadata(key: string): Promise<FigmaFileMetaResponse> {
return this.request(ENDPOINTS.FILE_META(key));
}

async getFileVersions(key: string): Promise<{ versions: FigmaVersion[] }> {
return this.request(ENDPOINTS.FILE_VERSIONS(key));
}

// --- Comment methods ---

async getComments(
fileKey: string,
params?: { as_md?: boolean },
): Promise<{ comments: FigmaComment[] }> {
const qs = new URLSearchParams();
if (params?.as_md != null) qs.set("as_md", String(params.as_md));
const query = qs.toString();
return this.request(
`${ENDPOINTS.COMMENTS(fileKey)}${query ? `?${query}` : ""}`,
);
}

async postComment(
fileKey: string,
message: string,
options?: {
comment_id?: string;
client_meta?: {
x: number;
y: number;
node_id?: string;
node_offset?: { x: number; y: number };
};
},
): Promise<FigmaComment> {
const body: Record<string, unknown> = { message };
if (options?.comment_id) body.comment_id = options.comment_id;
if (options?.client_meta) body.client_meta = options.client_meta;
return this.request(ENDPOINTS.COMMENTS(fileKey), {
method: "POST",
body: JSON.stringify(body),
});
}

async deleteComment(fileKey: string, commentId: string): Promise<void> {
await this.request<void>(ENDPOINTS.COMMENT(fileKey, commentId), {
method: "DELETE",
});
}

async getCommentReactions(
fileKey: string,
commentId: string,
): Promise<{ reactions: FigmaCommentReaction[] }> {
return this.request(ENDPOINTS.COMMENT_REACTIONS(fileKey, commentId));
}

async postCommentReaction(
fileKey: string,
commentId: string,
emoji: string,
): Promise<FigmaCommentReaction> {
return this.request(ENDPOINTS.COMMENT_REACTIONS(fileKey, commentId), {
method: "POST",
body: JSON.stringify({ emoji }),
});
}

async deleteCommentReaction(
fileKey: string,
commentId: string,
emoji: string,
): Promise<void> {
await this.request<void>(ENDPOINTS.COMMENT_REACTIONS(fileKey, commentId), {
method: "DELETE",
body: JSON.stringify({ emoji }),
});
Comment on lines +191 to +194
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai Bot Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: deleteCommentReaction sends emoji in the DELETE body, but the Figma API requires emoji as a query parameter.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At figma/server/lib/figma-client.ts, line 191:

<comment>`deleteCommentReaction` sends `emoji` in the DELETE body, but the Figma API requires `emoji` as a query parameter.</comment>

<file context>
@@ -0,0 +1,246 @@
+    commentId: string,
+    emoji: string,
+  ): Promise<void> {
+    await this.request<void>(ENDPOINTS.COMMENT_REACTIONS(fileKey, commentId), {
+      method: "DELETE",
+      body: JSON.stringify({ emoji }),
</file context>
Suggested change
await this.request<void>(ENDPOINTS.COMMENT_REACTIONS(fileKey, commentId), {
method: "DELETE",
body: JSON.stringify({ emoji }),
});
await this.request<void>(
`${ENDPOINTS.COMMENT_REACTIONS(fileKey, commentId)}?emoji=${encodeURIComponent(emoji)}`,
{
method: "DELETE",
},
);
Fix with Cubic

}

// --- Team & Project methods ---

async getTeamProjects(
teamId: string,
): Promise<{ name: string; projects: FigmaProject[] }> {
return this.request(ENDPOINTS.TEAM_PROJECTS(teamId));
}

async getProjectFiles(
projectId: string,
): Promise<{ name: string; files: FigmaProjectFile[] }> {
return this.request(ENDPOINTS.PROJECT_FILES(projectId));
}

async getTeamComponents(
teamId: string,
params?: { page_size?: number; after?: number; before?: number },
): Promise<FigmaPaginatedResponse<FigmaComponentMeta>> {
const qs = new URLSearchParams();
if (params?.page_size != null)
qs.set("page_size", String(params.page_size));
if (params?.after != null) qs.set("after", String(params.after));
if (params?.before != null) qs.set("before", String(params.before));
const query = qs.toString();
return this.request(
`${ENDPOINTS.TEAM_COMPONENTS(teamId)}${query ? `?${query}` : ""}`,
);
}

async getTeamStyles(
teamId: string,
params?: { page_size?: number; after?: number; before?: number },
): Promise<FigmaPaginatedResponse<FigmaStyleMeta>> {
const qs = new URLSearchParams();
if (params?.page_size != null)
qs.set("page_size", String(params.page_size));
if (params?.after != null) qs.set("after", String(params.after));
if (params?.before != null) qs.set("before", String(params.before));
const query = qs.toString();
return this.request(
`${ENDPOINTS.TEAM_STYLES(teamId)}${query ? `?${query}` : ""}`,
);
}

// --- User methods ---

async whoami(): Promise<FigmaUser> {
return this.request(ENDPOINTS.ME);
}
}
Loading
Loading