From d514b1b3205dd6b9590e5020a5318c42b8c6a1f4 Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Wed, 15 Apr 2026 16:48:10 -0300 Subject: [PATCH 1/2] feat(figma): add Figma MCP with 17 tools via REST API Implements a Figma MCP server with OAuth authentication and full coverage of the Figma REST API: files, nodes, images, comments, reactions, version history, team projects, components, and styles. Co-Authored-By: Claude Opus 4.6 --- bun.lock | 17 +++ deploy.json | 9 ++ figma/app.json | 19 +++ figma/package.json | 23 +++ figma/server/constants.ts | 36 +++++ figma/server/lib/env.ts | 15 ++ figma/server/lib/figma-client.ts | 246 +++++++++++++++++++++++++++++++ figma/server/lib/types.ts | 149 +++++++++++++++++++ figma/server/main.ts | 107 ++++++++++++++ figma/server/tools/comments.ts | 150 +++++++++++++++++++ figma/server/tools/files.ts | 177 ++++++++++++++++++++++ figma/server/tools/index.ts | 12 ++ figma/server/tools/teams.ts | 94 ++++++++++++ figma/server/tools/user.ts | 17 +++ figma/tsconfig.json | 29 ++++ package.json | 1 + 16 files changed, 1101 insertions(+) create mode 100644 figma/app.json create mode 100644 figma/package.json create mode 100644 figma/server/constants.ts create mode 100644 figma/server/lib/env.ts create mode 100644 figma/server/lib/figma-client.ts create mode 100644 figma/server/lib/types.ts create mode 100644 figma/server/main.ts create mode 100644 figma/server/tools/comments.ts create mode 100644 figma/server/tools/files.ts create mode 100644 figma/server/tools/index.ts create mode 100644 figma/server/tools/teams.ts create mode 100644 figma/server/tools/user.ts create mode 100644 figma/tsconfig.json diff --git a/bun.lock b/bun.lock index 186b7f30..9c017f31 100644 --- a/bun.lock +++ b/bun.lock @@ -186,6 +186,19 @@ "typescript": "^5.7.2", }, }, + "figma": { + "name": "@decocms/figma", + "version": "1.0.0", + "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", + }, + }, "flux": { "name": "flux", "version": "1.0.0", @@ -1198,6 +1211,8 @@ "@decocms/bindings": ["@decocms/bindings@1.0.7", "", { "dependencies": { "@modelcontextprotocol/sdk": "1.25.1", "zod": "^4.0.0", "zod-from-json-schema": "^0.5.2" } }, "sha512-NPYv4+VpI6XQbfMewy307Q1jp9QZc8a6lsC2g9Z/DCewKqFOCqAKsRrhBSGaujKEzHqxNLSqXhFx8/Vn3ODVJA=="], + "@decocms/figma": ["@decocms/figma@workspace:figma"], + "@decocms/mcps-shared": ["@decocms/mcps-shared@workspace:shared"], "@decocms/openrouter": ["@decocms/openrouter@workspace:openrouter"], @@ -3526,6 +3541,8 @@ "@decocms/bindings/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@decocms/figma/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@decocms/mcps-shared/@decocms/runtime": ["@decocms/runtime@1.1.3", "", { "dependencies": { "@ai-sdk/provider": "^3.0.0", "@cloudflare/workers-types": "^4.20250617.0", "@decocms/bindings": "^1.0.7", "@modelcontextprotocol/sdk": "1.25.1", "hono": "^4.10.7", "jose": "^6.0.11", "zod": "^4.0.0" } }, "sha512-pj6OccmpWulMjyOt9FHTNX41xHk800uzhmoYK/xq9h1f+DfDBMqyOZnt70Zmwrab7dMDO2w3VQD+NlgFKtrw1w=="], "@decocms/mcps-shared/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], diff --git a/deploy.json b/deploy.json index 82861f09..c2812513 100644 --- a/deploy.json +++ b/deploy.json @@ -430,5 +430,14 @@ "veo/**", "shared/**" ] + }, + "figma": { + "site": "figma", + "entrypoint": "./dist/server/main.js", + "platformName": "kubernetes-bun", + "watch": [ + "figma/**", + "shared/**" + ] } } diff --git a/figma/app.json b/figma/app.json new file mode 100644 index 00000000..67b227f9 --- /dev/null +++ b/figma/app.json @@ -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." + } +} diff --git a/figma/package.json b/figma/package.json new file mode 100644 index 00000000..914f1402 --- /dev/null +++ b/figma/package.json @@ -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" + } +} diff --git a/figma/server/constants.ts b/figma/server/constants.ts new file mode 100644 index 00000000..855e16ee --- /dev/null +++ b/figma/server/constants.ts @@ -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", +]; diff --git a/figma/server/lib/env.ts b/figma/server/lib/env.ts new file mode 100644 index 00000000..d9c3218b --- /dev/null +++ b/figma/server/lib/env.ts @@ -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); + if (!match || !match[1].trim()) { + throw new Error("Invalid authorization header. Expected Bearer token."); + } + return match[1].trim(); +} diff --git a/figma/server/lib/figma-client.ts b/figma/server/lib/figma-client.ts new file mode 100644 index 00000000..1ce0350a --- /dev/null +++ b/figma/server/lib/figma-client.ts @@ -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( + path: string, + options: RequestInit = {}, + ): Promise { + 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; + } + + // --- File methods --- + + async getFile( + key: string, + params?: { + version?: string; + ids?: string[]; + depth?: number; + geometry?: string; + plugin_data?: string; + }, + ): Promise { + 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 { + 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 { + 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 { + return this.request(ENDPOINTS.IMAGE_FILLS(key)); + } + + async getFileMetadata(key: string): Promise { + 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 { + const body: Record = { 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 { + await this.request(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 { + return this.request(ENDPOINTS.COMMENT_REACTIONS(fileKey, commentId), { + method: "POST", + body: JSON.stringify({ emoji }), + }); + } + + async deleteCommentReaction( + fileKey: string, + commentId: string, + emoji: string, + ): Promise { + await this.request(ENDPOINTS.COMMENT_REACTIONS(fileKey, commentId), { + method: "DELETE", + body: JSON.stringify({ emoji }), + }); + } + + // --- 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> { + 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> { + 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 { + return this.request(ENDPOINTS.ME); + } +} diff --git a/figma/server/lib/types.ts b/figma/server/lib/types.ts new file mode 100644 index 00000000..f261aeeb --- /dev/null +++ b/figma/server/lib/types.ts @@ -0,0 +1,149 @@ +export interface FigmaUser { + id: string; + handle: string; + img_url: string; + email: string; +} + +export interface FigmaFile { + name: string; + role: string; + lastModified: string; + editorType: string; + thumbnailUrl: string; + version: string; + document: Record; + components: Record; + componentSets: Record; + schemaVersion: number; + styles: Record; + mainFileKey?: string; +} + +export interface FigmaFileNodes { + name: string; + role: string; + lastModified: string; + thumbnailUrl: string; + version: string; + nodes: Record< + string, + { + document: Record; + components: Record; + styles: Record; + } | null + >; +} + +export interface FigmaImageResponse { + err: string | null; + images: Record; +} + +export interface FigmaImageFillsResponse { + error: boolean; + status: number; + meta: { + images: Record; + }; +} + +export interface FigmaFileMetaResponse { + file: { + key: string; + name: string; + thumbnail_url: string; + last_modified: string; + version: string; + role: string; + editor_type: string; + link_access: string; + folder_name?: string; + creator?: FigmaUser; + }; +} + +export interface FigmaComment { + id: string; + file_key: string; + parent_id: string; + user: FigmaUser; + created_at: string; + resolved_at: string | null; + message: string; + client_meta: { + x?: number; + y?: number; + node_id?: string; + node_offset?: { x: number; y: number }; + } | null; + order_id: string; + reactions: FigmaCommentReaction[]; +} + +export interface FigmaCommentReaction { + emoji: string; + user: FigmaUser; + created_at: string; +} + +export interface FigmaVersion { + id: string; + created_at: string; + label: string; + description: string; + user: FigmaUser; +} + +export interface FigmaProject { + id: number; + name: string; +} + +export interface FigmaProjectFile { + key: string; + name: string; + thumbnail_url: string; + last_modified: string; +} + +export interface FigmaComponentMeta { + key: string; + file_key: string; + node_id: string; + thumbnail_url: string; + name: string; + description: string; + created_at: string; + updated_at: string; + containing_frame: { + name: string; + nodeId: string; + pageId: string; + pageName: string; + }; +} + +export interface FigmaStyleMeta { + key: string; + file_key: string; + node_id: string; + style_type: string; + thumbnail_url: string; + name: string; + description: string; + created_at: string; + updated_at: string; + sort_position: string; +} + +export interface FigmaPaginatedResponse { + status: number; + error: boolean; + meta: { + components?: T[]; + styles?: T[]; + cursor: Record; + }; +} diff --git a/figma/server/main.ts b/figma/server/main.ts new file mode 100644 index 00000000..4fe1cc6f --- /dev/null +++ b/figma/server/main.ts @@ -0,0 +1,107 @@ +import { type DefaultEnv, withRuntime } from "@decocms/runtime"; +import type { Registry } from "@decocms/mcps-shared/registry"; +import { serve } from "@decocms/mcps-shared/serve"; +import { z } from "zod"; +import { tools } from "./tools/index.ts"; +import { FIGMA_SCOPES } from "./constants.ts"; + +const StateSchema = z.object({}); + +export type Env = DefaultEnv; + +const FIGMA_CLIENT_ID = process.env.FIGMA_CLIENT_ID ?? ""; +const FIGMA_CLIENT_SECRET = process.env.FIGMA_CLIENT_SECRET ?? ""; + +const runtime = withRuntime({ + oauth: { + mode: "PKCE", + authorizationServer: "https://www.figma.com", + + authorizationUrl: (callbackUrl) => { + const callback = new URL(callbackUrl); + const state = callback.searchParams.get("state"); + callback.searchParams.delete("state"); + + const url = new URL("https://www.figma.com/oauth"); + url.searchParams.set("client_id", FIGMA_CLIENT_ID); + url.searchParams.set("redirect_uri", callback.toString()); + url.searchParams.set("scope", FIGMA_SCOPES.join(",")); + url.searchParams.set("response_type", "code"); + if (state) url.searchParams.set("state", state); + return url.toString(); + }, + + exchangeCode: async ({ code, redirect_uri }) => { + const response = await fetch("https://api.figma.com/v1/oauth/token", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: FIGMA_CLIENT_ID, + client_secret: FIGMA_CLIENT_SECRET, + redirect_uri: redirect_uri ?? "", + code, + grant_type: "authorization_code", + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error( + `Figma OAuth token exchange failed: ${response.status} - ${error}`, + ); + } + + const data = (await response.json()) as { + access_token: string; + refresh_token?: string; + token_type: string; + expires_in?: number; + }; + + return { + access_token: data.access_token, + token_type: data.token_type, + refresh_token: data.refresh_token, + expires_in: data.expires_in, + }; + }, + + refreshToken: async (refreshToken: string) => { + const response = await fetch("https://api.figma.com/v1/oauth/refresh", { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ + client_id: FIGMA_CLIENT_ID, + client_secret: FIGMA_CLIENT_SECRET, + refresh_token: refreshToken, + }), + }); + + if (!response.ok) { + const error = await response.text(); + throw new Error( + `Figma token refresh failed: ${response.status} - ${error}`, + ); + } + + const data = (await response.json()) as { + access_token: string; + refresh_token?: string; + token_type: string; + expires_in?: number; + }; + + return { + access_token: data.access_token, + token_type: data.token_type, + refresh_token: data.refresh_token ?? refreshToken, + expires_in: data.expires_in, + }; + }, + }, + + tools, + prompts: [], +}); + +serve(runtime.fetch); diff --git a/figma/server/tools/comments.ts b/figma/server/tools/comments.ts new file mode 100644 index 00000000..f098cec0 --- /dev/null +++ b/figma/server/tools/comments.ts @@ -0,0 +1,150 @@ +import { createPrivateTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import type { Env } from "../main.ts"; +import { getAccessToken } from "../lib/env.ts"; +import { FigmaClient } from "../lib/figma-client.ts"; + +export const createGetCommentsTool = (env: Env) => + createPrivateTool({ + id: "figma_get_comments", + description: + "Get all comments on a Figma file. Returns comment threads with user info, timestamps, and positions.", + inputSchema: z.object({ + file_key: z.string().describe("The key of the Figma file."), + as_md: z + .boolean() + .optional() + .describe("If true, returns comment messages in Markdown format."), + }), + execute: async ({ context }) => { + const { file_key, ...params } = context; + const client = new FigmaClient(getAccessToken(env)); + return await client.getComments(file_key, params); + }, + }); + +export const createPostCommentTool = (env: Env) => + createPrivateTool({ + id: "figma_post_comment", + description: + "Post a comment on a Figma file. Can be a top-level comment or a reply to an existing comment.", + inputSchema: z.object({ + file_key: z.string().describe("The key of the Figma file."), + message: z.string().describe("The comment message text."), + comment_id: z + .string() + .optional() + .describe( + "The ID of the comment to reply to. If omitted, creates a top-level comment.", + ), + client_meta: z + .object({ + x: z.number().describe("X coordinate of the comment pin."), + y: z.number().describe("Y coordinate of the comment pin."), + node_id: z + .string() + .optional() + .describe("The node ID to attach the comment to."), + node_offset: z + .object({ + x: z.number(), + y: z.number(), + }) + .optional() + .describe("Offset from the node origin."), + }) + .optional() + .describe("Position metadata for the comment pin on the canvas."), + }), + execute: async ({ context }) => { + const { file_key, message, ...options } = context; + const client = new FigmaClient(getAccessToken(env)); + return await client.postComment(file_key, message, options); + }, + }); + +export const createDeleteCommentTool = (env: Env) => + createPrivateTool({ + id: "figma_delete_comment", + description: "Delete a comment from a Figma file.", + inputSchema: z.object({ + file_key: z.string().describe("The key of the Figma file."), + comment_id: z.string().describe("The ID of the comment to delete."), + }), + execute: async ({ context }) => { + const client = new FigmaClient(getAccessToken(env)); + await client.deleteComment(context.file_key, context.comment_id); + return { success: true }; + }, + }); + +export const createGetCommentReactionsTool = (env: Env) => + createPrivateTool({ + id: "figma_get_comment_reactions", + description: "Get all emoji reactions on a specific comment.", + inputSchema: z.object({ + file_key: z.string().describe("The key of the Figma file."), + comment_id: z.string().describe("The ID of the comment."), + }), + execute: async ({ context }) => { + const client = new FigmaClient(getAccessToken(env)); + return await client.getCommentReactions( + context.file_key, + context.comment_id, + ); + }, + }); + +export const createPostCommentReactionTool = (env: Env) => + createPrivateTool({ + id: "figma_post_comment_reaction", + description: "Add an emoji reaction to a comment.", + inputSchema: z.object({ + file_key: z.string().describe("The key of the Figma file."), + comment_id: z.string().describe("The ID of the comment to react to."), + emoji: z + .string() + .describe( + "The emoji shortcode to react with (e.g., ':heart:', ':+1:').", + ), + }), + execute: async ({ context }) => { + const client = new FigmaClient(getAccessToken(env)); + return await client.postCommentReaction( + context.file_key, + context.comment_id, + context.emoji, + ); + }, + }); + +export const createDeleteCommentReactionTool = (env: Env) => + createPrivateTool({ + id: "figma_delete_comment_reaction", + description: "Remove an emoji reaction from a comment.", + inputSchema: z.object({ + file_key: z.string().describe("The key of the Figma file."), + comment_id: z.string().describe("The ID of the comment."), + emoji: z + .string() + .describe("The emoji shortcode to remove (e.g., ':heart:', ':+1:')."), + }), + execute: async ({ context }) => { + const client = new FigmaClient(getAccessToken(env)); + await client.deleteCommentReaction( + context.file_key, + context.comment_id, + context.emoji, + ); + return { success: true }; + }, + }); + +export const commentTools = [ + createGetCommentsTool, + createPostCommentTool, + createDeleteCommentTool, + createGetCommentReactionsTool, + createPostCommentReactionTool, + createDeleteCommentReactionTool, +]; diff --git a/figma/server/tools/files.ts b/figma/server/tools/files.ts new file mode 100644 index 00000000..50d265b1 --- /dev/null +++ b/figma/server/tools/files.ts @@ -0,0 +1,177 @@ +import { createPrivateTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import type { Env } from "../main.ts"; +import { getAccessToken } from "../lib/env.ts"; +import { FigmaClient } from "../lib/figma-client.ts"; + +export const createGetFileTool = (env: Env) => + createPrivateTool({ + id: "figma_get_file", + description: + "Get a Figma file's full JSON representation including the document tree, components, and styles. Use the 'depth' parameter to limit response size for large files.", + inputSchema: z.object({ + file_key: z + .string() + .describe("The key of the Figma file (from the file URL)."), + version: z + .string() + .optional() + .describe("Specific version ID to retrieve."), + ids: z + .array(z.string()) + .optional() + .describe( + "List of node IDs to retrieve. If specified, only those nodes and their children are returned.", + ), + depth: z + .number() + .optional() + .describe( + "Positive integer specifying how deep into the document tree to traverse. Recommended to limit for large files.", + ), + geometry: z + .string() + .optional() + .describe("Set to 'paths' to include vector path data."), + plugin_data: z + .string() + .optional() + .describe( + "Comma-separated list of plugin IDs or 'shared' to include plugin data.", + ), + }), + execute: async ({ context }) => { + const { file_key, ...params } = context; + const client = new FigmaClient(getAccessToken(env)); + return await client.getFile(file_key, params); + }, + }); + +export const createGetFileNodesTool = (env: Env) => + createPrivateTool({ + id: "figma_get_file_nodes", + description: + "Get specific nodes from a Figma file by their IDs. Returns only the requested nodes and their children.", + inputSchema: z.object({ + file_key: z.string().describe("The key of the Figma file."), + ids: z + .array(z.string()) + .describe("List of node IDs to retrieve (e.g., ['1:2', '3:4'])."), + version: z + .string() + .optional() + .describe("Specific version ID to retrieve."), + depth: z + .number() + .optional() + .describe("How deep into the node tree to traverse."), + geometry: z + .string() + .optional() + .describe("Set to 'paths' to include vector path data."), + plugin_data: z + .string() + .optional() + .describe( + "Comma-separated list of plugin IDs or 'shared' to include plugin data.", + ), + }), + execute: async ({ context }) => { + const { file_key, ids, ...params } = context; + const client = new FigmaClient(getAccessToken(env)); + return await client.getFileNodes(file_key, ids, params); + }, + }); + +export const createGetImagesTool = (env: Env) => + createPrivateTool({ + id: "figma_get_images", + description: + "Render specific nodes from a Figma file as images. Returns URLs to the rendered images in the requested format.", + inputSchema: z.object({ + file_key: z.string().describe("The key of the Figma file."), + ids: z + .array(z.string()) + .describe("List of node IDs to render as images."), + scale: z + .number() + .min(0.01) + .max(4) + .optional() + .describe("Image scale factor (0.01 to 4). Default is 1."), + format: z + .enum(["jpg", "png", "svg", "pdf"]) + .optional() + .describe("Image output format. Default is 'png'."), + svg_include_id: z + .boolean() + .optional() + .describe("Whether to include node IDs in SVG output."), + svg_simplify_stroke: z + .boolean() + .optional() + .describe("Whether to simplify strokes in SVG output."), + use_absolute_bounds: z + .boolean() + .optional() + .describe( + "Use absolute bounds for rendering (includes effects outside the node).", + ), + }), + execute: async ({ context }) => { + const { file_key, ids, ...params } = context; + const client = new FigmaClient(getAccessToken(env)); + return await client.getImages(file_key, ids, params); + }, + }); + +export const createGetImageFillsTool = (env: Env) => + createPrivateTool({ + id: "figma_get_image_fills", + description: + "Get download URLs for all images used as fills in a Figma file.", + inputSchema: z.object({ + file_key: z.string().describe("The key of the Figma file."), + }), + execute: async ({ context }) => { + const client = new FigmaClient(getAccessToken(env)); + return await client.getImageFills(context.file_key); + }, + }); + +export const createGetFileMetadataTool = (env: Env) => + createPrivateTool({ + id: "figma_get_file_metadata", + description: + "Get metadata for a Figma file including name, last modified date, version, thumbnail URL, and creator info. Lighter than get_file as it does not return the document tree.", + inputSchema: z.object({ + file_key: z.string().describe("The key of the Figma file."), + }), + execute: async ({ context }) => { + const client = new FigmaClient(getAccessToken(env)); + return await client.getFileMetadata(context.file_key); + }, + }); + +export const createGetFileVersionsTool = (env: Env) => + createPrivateTool({ + id: "figma_get_file_versions", + description: + "Get the version history of a Figma file. Returns a list of versions with IDs, timestamps, labels, descriptions, and the user who created each version.", + inputSchema: z.object({ + file_key: z.string().describe("The key of the Figma file."), + }), + execute: async ({ context }) => { + const client = new FigmaClient(getAccessToken(env)); + return await client.getFileVersions(context.file_key); + }, + }); + +export const fileTools = [ + createGetFileTool, + createGetFileNodesTool, + createGetImagesTool, + createGetImageFillsTool, + createGetFileMetadataTool, + createGetFileVersionsTool, +]; diff --git a/figma/server/tools/index.ts b/figma/server/tools/index.ts new file mode 100644 index 00000000..d691e420 --- /dev/null +++ b/figma/server/tools/index.ts @@ -0,0 +1,12 @@ +import type { Env } from "../main.ts"; +import { fileTools } from "./files.ts"; +import { commentTools } from "./comments.ts"; +import { teamTools } from "./teams.ts"; +import { createWhoamiTool } from "./user.ts"; + +export const tools = (env: Env) => [ + createWhoamiTool(env), + ...fileTools.map((createTool) => createTool(env)), + ...commentTools.map((createTool) => createTool(env)), + ...teamTools.map((createTool) => createTool(env)), +]; diff --git a/figma/server/tools/teams.ts b/figma/server/tools/teams.ts new file mode 100644 index 00000000..c31254f0 --- /dev/null +++ b/figma/server/tools/teams.ts @@ -0,0 +1,94 @@ +import { createPrivateTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import type { Env } from "../main.ts"; +import { getAccessToken } from "../lib/env.ts"; +import { FigmaClient } from "../lib/figma-client.ts"; + +export const createGetTeamProjectsTool = (env: Env) => + createPrivateTool({ + id: "figma_get_team_projects", + description: + "List all projects within a Figma team. Returns project IDs and names.", + inputSchema: z.object({ + team_id: z.string().describe("The ID of the Figma team."), + }), + execute: async ({ context }) => { + const client = new FigmaClient(getAccessToken(env)); + return await client.getTeamProjects(context.team_id); + }, + }); + +export const createGetProjectFilesTool = (env: Env) => + createPrivateTool({ + id: "figma_get_project_files", + description: + "List all files in a Figma project. Returns file keys, names, thumbnails, and last modified dates.", + inputSchema: z.object({ + project_id: z.string().describe("The ID of the Figma project."), + }), + execute: async ({ context }) => { + const client = new FigmaClient(getAccessToken(env)); + return await client.getProjectFiles(context.project_id); + }, + }); + +export const createGetTeamComponentsTool = (env: Env) => + createPrivateTool({ + id: "figma_get_team_components", + description: + "Get all published components in a Figma team library. Returns component metadata including names, descriptions, thumbnails, and containing frames. Supports cursor-based pagination.", + inputSchema: z.object({ + team_id: z.string().describe("The ID of the Figma team."), + page_size: z + .number() + .optional() + .describe("Number of items per page (max 30)."), + after: z + .number() + .optional() + .describe("Cursor for forward pagination (from previous response)."), + before: z + .number() + .optional() + .describe("Cursor for backward pagination (from previous response)."), + }), + execute: async ({ context }) => { + const { team_id, ...params } = context; + const client = new FigmaClient(getAccessToken(env)); + return await client.getTeamComponents(team_id, params); + }, + }); + +export const createGetTeamStylesTool = (env: Env) => + createPrivateTool({ + id: "figma_get_team_styles", + description: + "Get all published styles in a Figma team library. Returns style metadata including names, descriptions, types (FILL, TEXT, EFFECT, GRID), and thumbnails. Supports cursor-based pagination.", + inputSchema: z.object({ + team_id: z.string().describe("The ID of the Figma team."), + page_size: z + .number() + .optional() + .describe("Number of items per page (max 30)."), + after: z + .number() + .optional() + .describe("Cursor for forward pagination (from previous response)."), + before: z + .number() + .optional() + .describe("Cursor for backward pagination (from previous response)."), + }), + execute: async ({ context }) => { + const { team_id, ...params } = context; + const client = new FigmaClient(getAccessToken(env)); + return await client.getTeamStyles(team_id, params); + }, + }); + +export const teamTools = [ + createGetTeamProjectsTool, + createGetProjectFilesTool, + createGetTeamComponentsTool, + createGetTeamStylesTool, +]; diff --git a/figma/server/tools/user.ts b/figma/server/tools/user.ts new file mode 100644 index 00000000..3b2c3700 --- /dev/null +++ b/figma/server/tools/user.ts @@ -0,0 +1,17 @@ +import { createPrivateTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import type { Env } from "../main.ts"; +import { getAccessToken } from "../lib/env.ts"; +import { FigmaClient } from "../lib/figma-client.ts"; + +export const createWhoamiTool = (env: Env) => + createPrivateTool({ + id: "figma_whoami", + description: + "Get the current authenticated Figma user's ID, handle, email, and profile image URL.", + inputSchema: z.object({}), + execute: async () => { + const client = new FigmaClient(getAccessToken(env)); + return await client.whoami(); + }, + }); diff --git a/figma/tsconfig.json b/figma/tsconfig.json new file mode 100644 index 00000000..77ed350b --- /dev/null +++ b/figma/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2023", "ES2024"], + "module": "ESNext", + "skipLibCheck": true, + + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "verbatimModuleSyntax": false, + "moduleDetection": "force", + "noEmit": true, + "allowJs": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true, + + "baseUrl": ".", + "paths": { + "server/*": ["./server/*"] + } + }, + "include": ["server"] +} diff --git a/package.json b/package.json index 53dea628..da1e8c7a 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "deco-llm", "deco-news-weekly-digest", "discord-read", + "figma", "farmrio-reorder-collection-db", "flux", "gemini-pro-vision", From 7894b5247a6669332dacb8b404e9e80aca9f1623 Mon Sep 17 00:00:00 2001 From: Jonas Jesus Date: Wed, 15 Apr 2026 16:50:11 -0300 Subject: [PATCH 2/2] fix(figma): remove deprecated baseUrl from tsconfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TypeScript 5.9+ deprecates baseUrl — not needed since all imports use relative paths. Co-Authored-By: Claude Opus 4.6 --- figma/tsconfig.json | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/figma/tsconfig.json b/figma/tsconfig.json index 77ed350b..17cfa1f2 100644 --- a/figma/tsconfig.json +++ b/figma/tsconfig.json @@ -18,12 +18,7 @@ "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true, - - "baseUrl": ".", - "paths": { - "server/*": ["./server/*"] - } + "noUncheckedSideEffectImports": true }, "include": ["server"] }