-
Notifications
You must be signed in to change notification settings - Fork 2
feat(figma): add Figma MCP with 17 tools via REST API #387
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| 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." | ||
| } | ||
| } |
| 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" | ||
| } | ||
| } |
| 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", | ||
| "library_content:read", | ||
| ]; | ||
| 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); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| if (!match || !match[1].trim()) { | ||
| throw new Error("Invalid authorization header. Expected Bearer token."); | ||
| } | ||
| return match[1].trim(); | ||
| } | ||
| 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: Prompt for AI agents
Suggested change
|
||||||||||||||||||||||
| } | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
| // --- 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); | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
| } | ||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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:readOAuth scope; otherwise file version requests may be unauthorized.Prompt for AI agents