├── .env.example
├── .eslintrc
├── .github
├── FUNDING.yml
└── ISSUE_TEMPLATE
│ └── bug_report.md
├── .gitignore
├── .nvmrc
├── .prettierrc
├── LICENSE
├── README.ja.md
├── README.ko.md
├── README.md
├── README.zh.md
├── docs
├── cursor-MCP-settings.png
├── figma-copy-link.png
└── verify-connection.png
├── jest.config.js
├── package.json
├── pnpm-lock.yaml
├── src
├── cli.ts
├── config.ts
├── index.ts
├── mcp.ts
├── server.ts
├── services
│ ├── figma.ts
│ └── simplify-node-response.ts
├── tests
│ ├── benchmark.test.ts
│ └── integration.test.ts
├── transformers
│ ├── effects.ts
│ ├── layout.ts
│ └── style.ts
└── utils
│ ├── common.ts
│ └── identity.ts
├── tsconfig.json
└── tsup.config.ts
/.env.example:
--------------------------------------------------------------------------------
1 | # Your Figma API access token
2 | # Get it from your Figma account settings: https://www.figma.com/developers/api#access-tokens
3 | FIGMA_API_KEY=your_figma_api_key_here
4 |
5 | # Figma file key for testing
6 | # This is the ID in your Figma URL: https://www.figma.com/file/{FILE_KEY}/filename
7 | FIGMA_FILE_KEY=your_figma_file_key_here
8 |
9 | # Figma node ID for Testing
10 | # This is the node-id parameter in your Figma URL: ?node-id={NODE_ID}
11 | FIGMA_NODE_ID=your_figma_node_id_here
12 |
13 | # Server configuration
14 | PORT=3333
15 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "@typescript-eslint/parser",
3 | "extends": [
4 | "eslint:recommended",
5 | "plugin:@typescript-eslint/recommended",
6 | "prettier"
7 | ],
8 | "plugins": ["@typescript-eslint"],
9 | "parserOptions": {
10 | "ecmaVersion": 2022,
11 | "sourceType": "module"
12 | },
13 | "rules": {
14 | "@typescript-eslint/explicit-function-return-type": "warn",
15 | "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }],
16 | "@typescript-eslint/no-explicit-any": "warn"
17 | }
18 | }
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: GLips
4 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ""
5 | labels: bug
6 | assignees: ""
7 | ---
8 |
9 | **Describe the bug**
10 | A clear and concise description of what the bug is.
11 |
12 | **Software Versions**
13 |
14 | - Figma Developer MCP: Run the MCP with `--version`—either npx or locally, depending on how you're running it.
15 | - Node.js: `node --version`
16 | - NPM: `npm --version`
17 | - Operating System:
18 | - Client: e.g. Cursor, VSCode, Claude Desktop, etc.
19 | - Client Version:
20 |
21 | **To Reproduce**
22 | Steps to reproduce the behavior:
23 |
24 | 1. Go to '...'
25 | 2. Click on '....'
26 | 3. Scroll down to '....'
27 | 4. See error
28 |
29 | **Expected behavior**
30 | A clear and concise description of what you expected to happen.
31 |
32 | **Screenshots**
33 | If applicable, add screenshots to help explain your problem. Often a screenshot of your entire chat window where you're trying to trigger the MCP is helpful.
34 |
35 | **Server Configuration**
36 | Provide your MCP JSON configuration, if applicable. E.g.
37 |
38 | ```
39 | "figma-developer-mcp": {
40 | "command": "npx",
41 | "args": [
42 | "figma-developer-mcp",
43 | "--figma-api-key=REDACTED",
44 | "--stdio"
45 | ]
46 | }
47 | ```
48 |
49 | **Command Line Logs**
50 | If you're running the MCP locally on the command line, include all the logs for those like so:
51 |
52 | ```
53 | > npx figma-developer-mcp --figma-api-key=REDACTED
54 |
55 | Configuration:
56 | - FIGMA_API_KEY: ****8pXg (source: cli)
57 | - PORT: 3333 (source: default)
58 |
59 | Initializing Figma MCP Server in HTTP mode on port 3333...
60 | HTTP server listening on port 3333
61 | SSE endpoint available at http://localhost:3333/sse
62 | Message endpoint available at http://localhost:3333/messages
63 | New SSE connection established
64 | ```
65 |
66 | **MCP Logs**
67 | If you're running the MCP in a code editor like Cursor, there are MCP-specific logs that provide more context on any errors. In Cursor, you can find them by clicking `CMD + Shift + P` and looking for `Developer: Show Logs...`. Within the show logs window, you can find `Cursor MCP`—copy and paste the contents there into the bug report.
68 |
69 | ```
70 | 2025-03-18 11:36:22.251 [info] pnpx: Handling CreateClient action
71 | 2025-03-18 11:36:22.251 [info] pnpx: getOrCreateClient for stdio server. process.platform: darwin isElectron: true
72 | 2025-03-18 11:36:22.251 [info] pnpx: Starting new stdio process with command: pnpx figma-developer-mcp --figma-api-key=REDACTED --stdio
73 | 2025-03-18 11:36:23.987 [info] pnpx: Successfully connected to stdio server
74 | 2025-03-18 11:36:23.987 [info] pnpx: Storing stdio client
75 | 2025-03-18 11:36:23.988 [info] MCP: Handling ListOfferings action
76 | 2025-03-18 11:36:23.988 [error] MCP: No server info found
77 | 2025-03-18 11:36:23.988 [info] pnpx: Handling ListOfferings action
78 | 2025-03-18 11:36:23.988 [info] pnpx: Listing offerings
79 | 2025-03-18 11:36:23.988 [info] pnpx: getOrCreateClient for stdio server. process.platform: darwin isElectron: true
80 | 2025-03-18 11:36:23.988 [info] pnpx: Reusing existing stdio client
81 | 2025-03-18 11:36:23.988 [info] pnpx: Connected to stdio server, fetching offerings
82 | 2025-03-18 11:36:24.005 [info] listOfferings: Found 2 tools
83 | 2025-03-18 11:36:24.005 [info] pnpx: Found 2 tools, 0 resources, and 0 resource templates
84 | 2025-03-18 11:36:24.005 [info] npx: Handling ListOfferings action
85 | 2025-03-18 11:36:24.005 [error] npx: No server info found
86 | ```
87 |
88 | **Additional context**
89 | Add any other context about the problem here.
90 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Dependencies
2 | node_modules
3 | .pnpm-store
4 |
5 | # Build output
6 | dist
7 |
8 | # Environment variables
9 | .env
10 | .env.local
11 | .env.*.local
12 |
13 | # IDE
14 | .vscode/*
15 | !.vscode/extensions.json
16 | !.vscode/settings.json
17 | .idea
18 | *.suo
19 | *.ntvs*
20 | *.njsproj
21 | *.sln
22 | *.sw?
23 |
24 | # Logs
25 | logs
26 | *.log
27 | npm-debug.log*
28 | yarn-debug.log*
29 | yarn-error.log*
30 | pnpm-debug.log*
31 |
32 | # Testing
33 | coverage
34 | test-output
35 |
36 | # OS
37 | .DS_Store
38 | Thumbs.db
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | v20
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": true,
3 | "trailingComma": "all",
4 | "singleQuote": false,
5 | "printWidth": 100,
6 | "tabWidth": 2,
7 | "useTabs": false
8 | }
9 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Graham Lipsman
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.ja.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
31 |
32 |
33 |
34 | [Cursor](https://cursor.sh/)と他のAI搭載コーディングツールに、この[Model Context Protocol](https://modelcontextprotocol.io/introduction)サーバーを通じてFigmaファイルへのアクセスを提供します。
35 |
36 | CursorがFigmaデザインデータにアクセスできる場合、スクリーンショットを貼り付けるなどの代替アプローチよりも**はるかに**正確にワンショットでデザインを実装できます。
37 |
38 |
39 |
40 | ## デモ
41 |
42 | [FigmaデザインデータでCursorでUIを構築するデモを見る](https://youtu.be/6G9yb-LrEqg)
43 |
44 | [](https://youtu.be/6G9yb-LrEqg)
45 |
46 | ## 仕組み
47 |
48 | 1. IDEのチャットを開きます(例:Cursorのエージェントモード)。
49 | 2. Figmaファイル、フレーム、またはグループへのリンクを貼り付けます。
50 | 3. CursorにFigmaファイルで何かをするように依頼します(例:デザインの実装)。
51 | 4. CursorはFigmaから関連するメタデータを取得し、コードを書くために使用します。
52 |
53 | このMCPサーバーは、Cursorで使用するために特別に設計されています。[Figma API](https://www.figma.com/developers/api)からコンテキストを応答する前に、応答を簡素化して翻訳し、モデルに最も関連性の高いレイアウトとスタイリング情報のみを提供します。
54 |
55 | モデルに提供されるコンテキストの量を減らすことで、AIの精度を高め、応答をより関連性のあるものにするのに役立ちます。
56 |
57 | ## はじめに
58 |
59 | 多くのコードエディタやその他のAIクライアントは、MCPサーバーを管理するために設定ファイルを使用します。
60 |
61 | `figma-developer-mcp`サーバーは、以下を設定ファイルに追加することで設定できます。
62 |
63 | > 注:このサーバーを使用するには、Figmaアクセストークンを作成する必要があります。Figma APIアクセストークンの作成方法については[こちら](https://help.figma.com/hc/en-us/articles/8085703771159-Manage-personal-access-tokens)をご覧ください。
64 |
65 | ### MacOS / Linux
66 |
67 | ```json
68 | {
69 | "mcpServers": {
70 | "Framelink Figma MCP": {
71 | "command": "npx",
72 | "args": ["-y", "figma-developer-mcp", "--figma-api-key=YOUR-KEY", "--stdio"]
73 | }
74 | }
75 | }
76 | ```
77 |
78 | ### Windows
79 |
80 | ```json
81 | {
82 | "mcpServers": {
83 | "Framelink Figma MCP": {
84 | "command": "cmd",
85 | "args": ["/c", "npx", "-y", "figma-developer-mcp", "--figma-api-key=YOUR-KEY", "--stdio"]
86 | }
87 | }
88 | }
89 | ```
90 |
91 | または `env` フィールドに `FIGMA_API_KEY` と `PORT` を設定することもできます。
92 |
93 | Framelink Figma MCPサーバーの設定方法の詳細については、[Framelinkドキュメント](https://www.framelink.ai/docs/quickstart?utm_source=github&utm_medium=readme&utm_campaign=readme)を参照してください。
94 |
95 | ## スター履歴
96 |
97 |
98 |
99 | ## 詳細情報
100 |
101 | Framelink Figma MCPサーバーはシンプルですが強力です。[Framelink](https://framelink.ai?utm_source=github&utm_medium=readme&utm_campaign=readme)サイトで詳細情報をご覧ください。
102 |
--------------------------------------------------------------------------------
/README.ko.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
31 |
32 |
33 |
34 | [Cursor](https://cursor.sh/) 및 기타 AI 기반 코딩 도구에 [Model Context Protocol](https://modelcontextprotocol.io/introduction) 서버를 통해 Figma 파일에 대한 접근 권한을 부여하세요.
35 |
36 | Cursor가 Figma 디자인 데이터에 접근할 수 있을 때, 스크린샷을 붙여넣는 것과 같은 대안적인 접근 방식보다 **훨씬** 더 정확하게 디자인을 한 번에 구현할 수 있습니다.
37 |
38 |
39 |
40 | ## 데모
41 |
42 | [Figma 디자인 데이터로 Cursor에서 UI를 구축하는 데모 시청](https://youtu.be/6G9yb-LrEqg)
43 |
44 | [](https://youtu.be/6G9yb-LrEqg)
45 |
46 | ## 작동 방식
47 |
48 | 1. IDE의 채팅을 엽니다 (예: Cursor의 에이전트 모드).
49 | 2. Figma 파일, 프레임 또는 그룹에 대한 링크를 붙여넣습니다.
50 | 3. Cursor에게 Figma 파일로 무언가를 하도록 요청합니다 (예: 디자인 구현).
51 | 4. Cursor는 Figma에서 관련 메타데이터를 가져와 코드를 작성하는 데 사용합니다.
52 |
53 | 이 MCP 서버는 Cursor와 함께 사용하도록 특별히 설계되었습니다. [Figma API](https://www.figma.com/developers/api)에서 컨텍스트를 응답하기 전에, 응답을 단순화하고 번역하여 모델에 가장 관련성이 높은 레이아웃 및 스타일링 정보만 제공합니다.
54 |
55 | 모델에 제공되는 컨텍스트의 양을 줄이면 AI의 정확도를 높이고 응답을 더 관련성 있게 만드는 데 도움이 됩니다.
56 |
57 | ## 시작하기
58 |
59 | 많은 코드 편집기와 기타 AI 클라이언트는 MCP 서버를 관리하기 위해 구성 파일을 사용합니다.
60 |
61 | `figma-developer-mcp` 서버는 다음을 구성 파일에 추가하여 설정할 수 있습니다.
62 |
63 | > 참고: 이 서버를 사용하려면 Figma 액세스 토큰을 생성해야 합니다. Figma API 액세스 토큰을 생성하는 방법에 대한 지침은 [여기](https://help.figma.com/hc/en-us/articles/8085703771159-Manage-personal-access-tokens)에서 찾을 수 있습니다.
64 |
65 | ### MacOS / Linux
66 |
67 | ```json
68 | {
69 | "mcpServers": {
70 | "Framelink Figma MCP": {
71 | "command": "npx",
72 | "args": ["-y", "figma-developer-mcp", "--figma-api-key=YOUR-KEY", "--stdio"]
73 | }
74 | }
75 | }
76 | ```
77 |
78 | ### Windows
79 |
80 | ```json
81 | {
82 | "mcpServers": {
83 | "Framelink Figma MCP": {
84 | "command": "cmd",
85 | "args": ["/c", "npx", "-y", "figma-developer-mcp", "--figma-api-key=YOUR-KEY", "--stdio"]
86 | }
87 | }
88 | }
89 | ```
90 |
91 | 또는 `env` 필드에 `FIGMA_API_KEY`와 `PORT`를 넣을 수 있습니다.
92 |
93 | Framelink Figma MCP 서버를 구성하는 방법에 대한 자세한 정보가 필요하면 [Framelink 문서](https://www.framelink.ai/docs/quickstart?utm_source=github&utm_medium=readme&utm_campaign=readme)를 참조하세요.
94 |
95 | ## 스타 히스토리
96 |
97 |
98 |
99 | ## 더 알아보기
100 |
101 | Framelink Figma MCP 서버는 단순하지만 강력합니다. [Framelink](https://framelink.ai?utm_source=github&utm_medium=readme&utm_campaign=readme) 사이트에서 더 많은 정보를 얻으세요.
102 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
31 |
32 |
33 |
34 | Give [Cursor](https://cursor.sh/) and other AI-powered coding tools access to your Figma files with this [Model Context Protocol](https://modelcontextprotocol.io/introduction) server.
35 |
36 | When Cursor has access to Figma design data, it's **way** better at one-shotting designs accurately than alternative approaches like pasting screenshots.
37 |
38 |
39 |
40 | ## Demo
41 |
42 | [Watch a demo of building a UI in Cursor with Figma design data](https://youtu.be/6G9yb-LrEqg)
43 |
44 | [](https://youtu.be/6G9yb-LrEqg)
45 |
46 | ## How it works
47 |
48 | 1. Open your IDE's chat (e.g. agent mode in Cursor).
49 | 2. Paste a link to a Figma file, frame, or group.
50 | 3. Ask Cursor to do something with the Figma file—e.g. implement the design.
51 | 4. Cursor will fetch the relevant metadata from Figma and use it to write your code.
52 |
53 | This MCP server is specifically designed for use with Cursor. Before responding with context from the [Figma API](https://www.figma.com/developers/api), it simplifies and translates the response so only the most relevant layout and styling information is provided to the model.
54 |
55 | Reducing the amount of context provided to the model helps make the AI more accurate and the responses more relevant.
56 |
57 | ## Getting Started
58 |
59 | Many code editors and other AI clients use a configuration file to manage MCP servers.
60 |
61 | The `figma-developer-mcp` server can be configured by adding the following to your configuration file.
62 |
63 | > NOTE: You will need to create a Figma access token to use this server. Instructions on how to create a Figma API access token can be found [here](https://help.figma.com/hc/en-us/articles/8085703771159-Manage-personal-access-tokens).
64 |
65 | ### MacOS / Linux
66 |
67 | ```json
68 | {
69 | "mcpServers": {
70 | "Framelink Figma MCP": {
71 | "command": "npx",
72 | "args": ["-y", "figma-developer-mcp", "--figma-api-key=YOUR-KEY", "--stdio"]
73 | }
74 | }
75 | }
76 | ```
77 |
78 | ### Windows
79 |
80 | ```json
81 | {
82 | "mcpServers": {
83 | "Framelink Figma MCP": {
84 | "command": "cmd",
85 | "args": ["/c", "npx", "-y", "figma-developer-mcp", "--figma-api-key=YOUR-KEY", "--stdio"]
86 | }
87 | }
88 | }
89 | ```
90 |
91 | Or you can set `FIGMA_API_KEY` and `PORT` in the `env` field.
92 |
93 | If you need more information on how to configure the Framelink Figma MCP server, see the [Framelink docs](https://www.framelink.ai/docs/quickstart?utm_source=github&utm_medium=referral&utm_campaign=readme).
94 |
95 | ## Star History
96 |
97 |
98 |
99 | ## Learn More
100 |
101 | The Framelink Figma MCP server is simple but powerful. Get the most out of it by learning more at the [Framelink](https://framelink.ai?utm_source=github&utm_medium=referral&utm_campaign=readme) site.
102 |
103 |
104 |
105 |
106 |
107 | ## Sponsors
108 |
109 | ### 🥇 Gold Sponsors
110 |
111 |
116 |
117 | ### 🥈 Silver Sponsors
118 |
119 |
124 |
125 | ### 🥉 Bronze Sponsors
126 |
127 |
132 |
133 | ### 😻 Smaller Backers
134 |
135 |
136 |
137 |
138 |
139 |
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 |
--------------------------------------------------------------------------------
/README.zh.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
31 |
32 |
33 |
34 | 通过此 [Model Context Protocol](https://modelcontextprotocol.io/introduction) 服务器,为 [Cursor](https://cursor.sh/) 和其他 AI 驱动的编码工具提供 Figma 文件访问权限。
35 |
36 | 当 Cursor 可以访问 Figma 设计数据时,它比粘贴截图等替代方法**更**能一次性准确实现设计。
37 |
38 |
39 |
40 | ## 演示
41 |
42 | [观看使用 Figma 设计数据在 Cursor 中构建 UI 的演示](https://youtu.be/6G9yb-LrEqg)
43 |
44 | [](https://youtu.be/6G9yb-LrEqg)
45 |
46 | ## 工作原理
47 |
48 | 1. 打开 IDE 的聊天(例如:Cursor 的代理模式)。
49 | 2. 粘贴 Figma 文件、框架或组的链接。
50 | 3. 要求 Cursor 对 Figma 文件执行某些操作(例如:实现设计)。
51 | 4. Cursor 将从 Figma 获取相关元数据并使用它来编写代码。
52 |
53 | 此 MCP 服务器专为与 Cursor 一起使用而设计。在从 [Figma API](https://www.figma.com/developers/api) 响应上下文之前,它会简化和翻译响应,以便只向模型提供最相关的布局和样式信息。
54 |
55 | 减少提供给模型的上下文数量有助于提高 AI 的准确性并使响应更具相关性。
56 |
57 | ## 开始使用
58 |
59 | 许多代码编辑器和其他 AI 客户端使用配置文件来管理 MCP 服务器。
60 |
61 | 可以通过将以下内容添加到配置文件中来设置 `figma-developer-mcp` 服务器。
62 |
63 | > 注意:您需要创建 Figma 访问令牌才能使用此服务器。有关如何创建 Figma API 访问令牌的说明,请参见[此处](https://help.figma.com/hc/en-us/articles/8085703771159-Manage-personal-access-tokens)。
64 |
65 | ### MacOS / Linux
66 |
67 | ```json
68 | {
69 | "mcpServers": {
70 | "Framelink Figma MCP": {
71 | "command": "npx",
72 | "args": ["-y", "figma-developer-mcp", "--figma-api-key=YOUR-KEY", "--stdio"]
73 | }
74 | }
75 | }
76 | ```
77 |
78 | ### Windows
79 |
80 | ```json
81 | {
82 | "mcpServers": {
83 | "Framelink Figma MCP": {
84 | "command": "cmd",
85 | "args": ["/c", "npx", "-y", "figma-developer-mcp", "--figma-api-key=YOUR-KEY", "--stdio"]
86 | }
87 | }
88 | }
89 | ```
90 |
91 | 或者您可以在 `env` 字段中设置 `FIGMA_API_KEY` 和 `PORT`。
92 |
93 | 有关如何配置 Framelink Figma MCP 服务器的更多信息,请参阅 [Framelink 文档](https://www.framelink.ai/docs/quickstart?utm_source=github&utm_medium=readme&utm_campaign=readme)。
94 |
95 | ## 星标历史
96 |
97 |
98 |
99 | ## 了解更多
100 |
101 | Framelink Figma MCP 服务器简单但功能强大。在 [Framelink](https://framelink.ai?utm_source=github&utm_medium=readme&utm_campaign=readme) 网站上了解更多信息。
102 |
--------------------------------------------------------------------------------
/docs/cursor-MCP-settings.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GLips/Figma-Context-MCP/578363395ea0186a2b8ba809dcfeb3ba9d02e82a/docs/cursor-MCP-settings.png
--------------------------------------------------------------------------------
/docs/figma-copy-link.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GLips/Figma-Context-MCP/578363395ea0186a2b8ba809dcfeb3ba9d02e82a/docs/figma-copy-link.png
--------------------------------------------------------------------------------
/docs/verify-connection.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/GLips/Figma-Context-MCP/578363395ea0186a2b8ba809dcfeb3ba9d02e82a/docs/verify-connection.png
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | preset: 'ts-jest',
3 | testEnvironment: 'node',
4 | transform: {
5 | '^.+\\.tsx?$': ['ts-jest', {
6 | tsconfig: 'tsconfig.json',
7 | }],
8 | },
9 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'],
10 | moduleNameMapper: {
11 | '^~/(.*)$': '/src/$1'
12 | }
13 | };
14 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "figma-developer-mcp",
3 | "version": "0.2.0",
4 | "description": "Model Context Protocol server for Figma integration",
5 | "type": "module",
6 | "main": "dist/index.js",
7 | "bin": {
8 | "figma-developer-mcp": "dist/cli.js"
9 | },
10 | "files": [
11 | "dist",
12 | "README.md"
13 | ],
14 | "scripts": {
15 | "build": "tsup --dts",
16 | "type-check": "tsc --noEmit",
17 | "test": "jest",
18 | "start": "node dist/cli.js",
19 | "start:cli": "cross-env NODE_ENV=cli node dist/cli.js",
20 | "start:http": "node dist/cli.js",
21 | "dev": "cross-env NODE_ENV=development tsup --watch",
22 | "dev:cli": "cross-env NODE_ENV=development tsup --watch -- --stdio",
23 | "lint": "eslint . --ext .ts",
24 | "format": "prettier --write \"src/**/*.ts\"",
25 | "inspect": "pnpx @modelcontextprotocol/inspector",
26 | "prepack": "pnpm build",
27 | "pub:release": "pnpm build && npm publish"
28 | },
29 | "engines": {
30 | "node": ">=18.0.0"
31 | },
32 | "repository": {
33 | "type": "git",
34 | "url": "git+https://github.com/GLips/Figma-Context-MCP.git"
35 | },
36 | "keywords": [
37 | "figma",
38 | "mcp",
39 | "typescript"
40 | ],
41 | "author": "",
42 | "license": "MIT",
43 | "dependencies": {
44 | "@figma/rest-api-spec": "^0.24.0",
45 | "@modelcontextprotocol/sdk": "^1.10.2",
46 | "@types/yargs": "^17.0.33",
47 | "cross-env": "^7.0.3",
48 | "dotenv": "^16.4.7",
49 | "express": "^4.21.2",
50 | "js-yaml": "^4.1.0",
51 | "remeda": "^2.20.1",
52 | "yargs": "^17.7.2",
53 | "zod": "^3.24.2"
54 | },
55 | "devDependencies": {
56 | "@types/express": "^5.0.0",
57 | "@types/jest": "^29.5.14",
58 | "@types/js-yaml": "^4.0.9",
59 | "@types/node": "^20.17.0",
60 | "@typescript-eslint/eslint-plugin": "^8.24.0",
61 | "@typescript-eslint/parser": "^8.24.0",
62 | "eslint": "^9.20.1",
63 | "eslint-config-prettier": "^10.0.1",
64 | "jest": "^29.7.0",
65 | "prettier": "^3.5.0",
66 | "ts-jest": "^29.2.5",
67 | "tsup": "^8.4.0",
68 | "tsx": "^4.19.2",
69 | "typescript": "^5.7.3"
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/src/cli.ts:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4 | import { config } from "dotenv";
5 | import { resolve } from "path";
6 | import { getServerConfig } from "./config.js";
7 | import { startHttpServer } from "./server.js";
8 | import { FigmaMcpServer } from "./mcp.js";
9 |
10 | // Load .env from the current working directory
11 | config({ path: resolve(process.cwd(), ".env") });
12 |
13 | export async function startServer(): Promise {
14 | // Check if we're running in stdio mode (e.g., via CLI)
15 | const isStdioMode = process.env.NODE_ENV === "cli" || process.argv.includes("--stdio");
16 |
17 | const config = getServerConfig(isStdioMode);
18 |
19 | const server = new FigmaMcpServer(config.figmaApiKey);
20 |
21 | if (isStdioMode) {
22 | const transport = new StdioServerTransport();
23 | await server.connect(transport);
24 | } else {
25 | console.log(`Initializing Figma MCP Server in HTTP mode on port ${config.port}...`);
26 | await startHttpServer(config.port, server);
27 | }
28 | }
29 |
30 | // If we're being executed directly (not imported), start the server
31 | if (process.argv[1]) {
32 | startServer().catch((error) => {
33 | console.error("Failed to start server:", error);
34 | process.exit(1);
35 | });
36 | }
37 |
--------------------------------------------------------------------------------
/src/config.ts:
--------------------------------------------------------------------------------
1 | import { config } from "dotenv";
2 | import yargs from "yargs";
3 | import { hideBin } from "yargs/helpers";
4 |
5 | // Load environment variables from .env file
6 | config();
7 |
8 | interface ServerConfig {
9 | figmaApiKey: string;
10 | port: number;
11 | configSources: {
12 | figmaApiKey: "cli" | "env";
13 | port: "cli" | "env" | "default";
14 | };
15 | }
16 |
17 | function maskApiKey(key: string): string {
18 | if (key.length <= 4) return "****";
19 | return `****${key.slice(-4)}`;
20 | }
21 |
22 | interface CliArgs {
23 | "figma-api-key"?: string;
24 | port?: number;
25 | }
26 |
27 | export function getServerConfig(isStdioMode: boolean): ServerConfig {
28 | // Parse command line arguments
29 | const argv = yargs(hideBin(process.argv))
30 | .options({
31 | "figma-api-key": {
32 | type: "string",
33 | description: "Figma API key",
34 | },
35 | port: {
36 | type: "number",
37 | description: "Port to run the server on",
38 | },
39 | })
40 | .help()
41 | .version("0.2.0")
42 | .parseSync() as CliArgs;
43 |
44 | const config: ServerConfig = {
45 | figmaApiKey: "",
46 | port: 3333,
47 | configSources: {
48 | figmaApiKey: "env",
49 | port: "default",
50 | },
51 | };
52 |
53 | // Handle FIGMA_API_KEY
54 | if (argv["figma-api-key"]) {
55 | config.figmaApiKey = argv["figma-api-key"];
56 | config.configSources.figmaApiKey = "cli";
57 | } else if (process.env.FIGMA_API_KEY) {
58 | config.figmaApiKey = process.env.FIGMA_API_KEY;
59 | config.configSources.figmaApiKey = "env";
60 | }
61 |
62 | // Handle PORT
63 | if (argv.port) {
64 | config.port = argv.port;
65 | config.configSources.port = "cli";
66 | } else if (process.env.PORT) {
67 | config.port = parseInt(process.env.PORT, 10);
68 | config.configSources.port = "env";
69 | }
70 |
71 | // Validate configuration
72 | if (!config.figmaApiKey) {
73 | console.error("FIGMA_API_KEY is required (via CLI argument --figma-api-key or .env file)");
74 | process.exit(1);
75 | }
76 |
77 | // Log configuration sources
78 | if (!isStdioMode) {
79 | console.log("\nConfiguration:");
80 | console.log(
81 | `- FIGMA_API_KEY: ${maskApiKey(config.figmaApiKey)} (source: ${config.configSources.figmaApiKey})`,
82 | );
83 | console.log(`- PORT: ${config.port} (source: ${config.configSources.port})`);
84 | console.log(); // Empty line for better readability
85 | }
86 |
87 | return config;
88 | }
89 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | // Re-export the server and its types
2 | export { FigmaMcpServer } from "./mcp.js";
3 | export type { SimplifiedDesign } from "./services/simplify-node-response.js";
4 | export type { FigmaService } from "./services/figma.js";
5 | export { getServerConfig } from "./config.js";
6 | export { startServer } from "./cli.js";
7 |
8 | export const Logger = {
9 | log: (...args: any[]) => {
10 | console.error("[INFO]", ...args);
11 | },
12 | error: (...args: any[]) => {
13 | console.error("[ERROR]", ...args);
14 | },
15 | };
16 |
--------------------------------------------------------------------------------
/src/mcp.ts:
--------------------------------------------------------------------------------
1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2 | import { z } from "zod";
3 | import { FigmaService } from "./services/figma.js";
4 | import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
5 | import { SimplifiedDesign } from "./services/simplify-node-response.js";
6 | import yaml from "js-yaml";
7 | import { Logger } from "./index.js";
8 |
9 | const serverInfo = {
10 | name: "Figma MCP Server",
11 | version: "0.2.0",
12 | };
13 |
14 | const serverOptions = {
15 | capabilities: { logging: {}, tools: {} },
16 | };
17 |
18 | export class FigmaMcpServer extends McpServer {
19 | private readonly figmaService: FigmaService;
20 |
21 | constructor(figmaApiKey: string) {
22 | super(serverInfo, serverOptions);
23 | this.figmaService = new FigmaService(figmaApiKey);
24 | // this.server = new McpServer(this.serverInfo, this.serverOptions);
25 |
26 | this.registerTools();
27 | }
28 |
29 | private registerTools(): void {
30 | // Tool to get file information
31 | this.tool(
32 | "get_figma_data",
33 | "When the nodeId cannot be obtained, obtain the layout information about the entire Figma file",
34 | {
35 | fileKey: z
36 | .string()
37 | .describe(
38 | "The key of the Figma file to fetch, often found in a provided URL like figma.com/(file|design)//...",
39 | ),
40 | nodeId: z
41 | .string()
42 | .optional()
43 | .describe(
44 | "The ID of the node to fetch, often found as URL parameter node-id=, always use if provided",
45 | ),
46 | depth: z
47 | .number()
48 | .nullish()
49 | .describe(
50 | "How many levels deep to traverse the node tree, only use if explicitly requested by the user",
51 | ),
52 | },
53 | async ({ fileKey, nodeId, depth }) => {
54 | try {
55 | Logger.log(
56 | `Fetching ${
57 | depth ? `${depth} layers deep` : "all layers"
58 | } of ${nodeId ? `node ${nodeId} from file` : `full file`} ${fileKey}`,
59 | );
60 |
61 | let file: SimplifiedDesign;
62 | if (nodeId) {
63 | file = await this.figmaService.getNode(fileKey, nodeId, depth);
64 | } else {
65 | file = await this.figmaService.getFile(fileKey, depth);
66 | }
67 |
68 | Logger.log(`Successfully fetched file: ${file.name}`);
69 | const { nodes, globalVars, ...metadata } = file;
70 |
71 | const result = {
72 | metadata,
73 | nodes,
74 | globalVars,
75 | };
76 |
77 | Logger.log("Generating YAML result from file");
78 | const yamlResult = yaml.dump(result);
79 |
80 | Logger.log("Sending result to client");
81 | return {
82 | content: [{ type: "text", text: yamlResult }],
83 | };
84 | } catch (error) {
85 | const message = error instanceof Error ? error.message : JSON.stringify(error);
86 | Logger.error(`Error fetching file ${fileKey}:`, message);
87 | return {
88 | isError: true,
89 | content: [{ type: "text", text: `Error fetching file: ${message}` }],
90 | };
91 | }
92 | },
93 | );
94 |
95 | // TODO: Clean up all image download related code, particularly getImages in Figma service
96 | // Tool to download images
97 | this.tool(
98 | "download_figma_images",
99 | "Download SVG and PNG images used in a Figma file based on the IDs of image or icon nodes",
100 | {
101 | fileKey: z.string().describe("The key of the Figma file containing the node"),
102 | nodes: z
103 | .object({
104 | nodeId: z
105 | .string()
106 | .describe("The ID of the Figma image node to fetch, formatted as 1234:5678"),
107 | imageRef: z
108 | .string()
109 | .nullish()
110 | .describe(
111 | "If a node has an imageRef fill, you must include this variable. Leave blank when downloading Vector SVG images.",
112 | ),
113 | fileName: z.string().describe("The local name for saving the fetched file"),
114 | })
115 | .array()
116 | .describe("The nodes to fetch as images"),
117 | localPath: z
118 | .string()
119 | .describe(
120 | "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.",
121 | ),
122 | },
123 | async ({ fileKey, nodes, localPath }) => {
124 | try {
125 | const imageFills = nodes.filter(({ imageRef }) => !!imageRef) as {
126 | nodeId: string;
127 | imageRef: string;
128 | fileName: string;
129 | }[];
130 | const fillDownloads = this.figmaService.getImageFills(fileKey, imageFills, localPath);
131 | const renderRequests = nodes
132 | .filter(({ imageRef }) => !imageRef)
133 | .map(({ nodeId, fileName }) => ({
134 | nodeId,
135 | fileName,
136 | fileType: fileName.endsWith(".svg") ? ("svg" as const) : ("png" as const),
137 | }));
138 |
139 | const renderDownloads = this.figmaService.getImages(fileKey, renderRequests, localPath);
140 |
141 | const downloads = await Promise.all([fillDownloads, renderDownloads]).then(([f, r]) => [
142 | ...f,
143 | ...r,
144 | ]);
145 |
146 | // If any download fails, return false
147 | const saveSuccess = !downloads.find((success) => !success);
148 | return {
149 | content: [
150 | {
151 | type: "text",
152 | text: saveSuccess
153 | ? `Success, ${downloads.length} images downloaded: ${downloads.join(", ")}`
154 | : "Failed",
155 | },
156 | ],
157 | };
158 | } catch (error) {
159 | Logger.error(`Error downloading images from file ${fileKey}:`, error);
160 | return {
161 | isError: true,
162 | content: [{ type: "text", text: `Error downloading images: ${error}` }],
163 | };
164 | }
165 | },
166 | );
167 | }
168 |
169 | async connect(transport: Transport): Promise {
170 | await super.connect(transport);
171 |
172 | // Ensure stdout is only used for JSON messages
173 | const originalStdoutWrite = process.stdout.write.bind(process.stdout);
174 | process.stdout.write = (chunk: any, encoding?: any, callback?: any) => {
175 | // Only allow JSON messages to pass through
176 | if (typeof chunk === "string" && !chunk.startsWith("{")) {
177 | return true; // Silently skip non-JSON messages
178 | }
179 | return originalStdoutWrite(chunk, encoding, callback);
180 | };
181 |
182 | Logger.log("Server connected and ready to process requests");
183 | }
184 | }
185 |
--------------------------------------------------------------------------------
/src/server.ts:
--------------------------------------------------------------------------------
1 | import { randomUUID } from "node:crypto";
2 | import express, { Request, Response } from "express";
3 | import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
4 | import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
5 | import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
6 | import { Server } from "http";
7 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8 | import { Logger } from "./index.js";
9 |
10 | let httpServer: Server | null = null;
11 | const transports = {
12 | streamable: {} as Record,
13 | sse: {} as Record,
14 | };
15 |
16 | export async function startHttpServer(port: number, mcpServer: McpServer): Promise {
17 | const app = express();
18 |
19 | // Parse JSON requests for the Streamable HTTP endpoint only, will break SSE endpoint
20 | app.use("/mcp", express.json());
21 |
22 | // Modern Streamable HTTP endpoint
23 | app.post("/mcp", async (req, res) => {
24 | Logger.log("Received StreamableHTTP request");
25 | const sessionId = req.headers["mcp-session-id"] as string | undefined;
26 | // Logger.log("Session ID:", sessionId);
27 | // Logger.log("Headers:", req.headers);
28 | // Logger.log("Body:", req.body);
29 | // Logger.log("Is Initialize Request:", isInitializeRequest(req.body));
30 | let transport: StreamableHTTPServerTransport;
31 |
32 | if (sessionId && transports.streamable[sessionId]) {
33 | // Reuse existing transport
34 | Logger.log("Reusing existing StreamableHTTP transport for sessionId", sessionId);
35 | transport = transports.streamable[sessionId];
36 | } else if (!sessionId && isInitializeRequest(req.body)) {
37 | Logger.log("New initialization request for StreamableHTTP sessionId", sessionId);
38 | transport = new StreamableHTTPServerTransport({
39 | sessionIdGenerator: () => randomUUID(),
40 | onsessioninitialized: (sessionId) => {
41 | // Store the transport by session ID
42 | transports.streamable[sessionId] = transport;
43 | },
44 | });
45 | transport.onclose = () => {
46 | if (transport.sessionId) {
47 | delete transports.streamable[transport.sessionId];
48 | }
49 | };
50 | // TODO? There semes to be an issue—at least in Cursor—where after a connection is made to an HTTP Streamable endpoint, SSE connections to the same Express server fail with "Received a response for an unknown message ID"
51 | await mcpServer.connect(transport);
52 | } else {
53 | // Invalid request
54 | Logger.log("Invalid request:", req.body);
55 | res.status(400).json({
56 | jsonrpc: "2.0",
57 | error: {
58 | code: -32000,
59 | message: "Bad Request: No valid session ID provided",
60 | },
61 | id: null,
62 | });
63 | return;
64 | }
65 |
66 | let progressInterval: NodeJS.Timeout | null = null;
67 | const progressToken = req.body.params?._meta?.progressToken;
68 | // Logger.log("Progress token:", progressToken);
69 | let progress = 0;
70 | if (progressToken) {
71 | Logger.log(
72 | `Setting up progress notifications for token ${progressToken} on session ${sessionId}`,
73 | );
74 | progressInterval = setInterval(async () => {
75 | Logger.log("Sending progress notification", progress);
76 | await mcpServer.server.notification({
77 | method: "notifications/progress",
78 | params: {
79 | progress,
80 | progressToken,
81 | },
82 | });
83 | progress++;
84 | }, 1000);
85 | }
86 |
87 | Logger.log("Handling StreamableHTTP request");
88 | await transport.handleRequest(req, res, req.body);
89 |
90 | if (progressInterval) {
91 | clearInterval(progressInterval);
92 | }
93 | Logger.log("StreamableHTTP request handled");
94 | });
95 |
96 | // Handle GET requests for SSE streams (using built-in support from StreamableHTTP)
97 | const handleSessionRequest = async (req: Request, res: Response) => {
98 | const sessionId = req.headers["mcp-session-id"] as string | undefined;
99 | if (!sessionId || !transports.streamable[sessionId]) {
100 | res.status(400).send("Invalid or missing session ID");
101 | return;
102 | }
103 |
104 | console.log(`Received session termination request for session ${sessionId}`);
105 |
106 | try {
107 | const transport = transports.streamable[sessionId];
108 | await transport.handleRequest(req, res);
109 | } catch (error) {
110 | console.error("Error handling session termination:", error);
111 | if (!res.headersSent) {
112 | res.status(500).send("Error processing session termination");
113 | }
114 | }
115 | };
116 |
117 | // Handle GET requests for server-to-client notifications via SSE
118 | app.get("/mcp", handleSessionRequest);
119 |
120 | // Handle DELETE requests for session termination
121 | app.delete("/mcp", handleSessionRequest);
122 |
123 | app.get("/sse", async (req, res) => {
124 | Logger.log("Establishing new SSE connection");
125 | const transport = new SSEServerTransport("/messages", res);
126 | Logger.log(`New SSE connection established for sessionId ${transport.sessionId}`);
127 | Logger.log("/sse request headers:", req.headers);
128 | Logger.log("/sse request body:", req.body);
129 |
130 | transports.sse[transport.sessionId] = transport;
131 | res.on("close", () => {
132 | delete transports.sse[transport.sessionId];
133 | });
134 |
135 | await mcpServer.connect(transport);
136 | });
137 |
138 | app.post("/messages", async (req, res) => {
139 | const sessionId = req.query.sessionId as string;
140 | const transport = transports.sse[sessionId];
141 | if (transport) {
142 | Logger.log(`Received SSE message for sessionId ${sessionId}`);
143 | Logger.log("/messages request headers:", req.headers);
144 | Logger.log("/messages request body:", req.body);
145 | await transport.handlePostMessage(req, res);
146 | } else {
147 | res.status(400).send(`No transport found for sessionId ${sessionId}`);
148 | return;
149 | }
150 | });
151 |
152 | httpServer = app.listen(port, () => {
153 | Logger.log(`HTTP server listening on port ${port}`);
154 | Logger.log(`SSE endpoint available at http://localhost:${port}/sse`);
155 | Logger.log(`Message endpoint available at http://localhost:${port}/messages`);
156 | Logger.log(`StreamableHTTP endpoint available at http://localhost:${port}/mcp`);
157 | });
158 |
159 | process.on("SIGINT", async () => {
160 | Logger.log("Shutting down server...");
161 |
162 | // Close all active transports to properly clean up resources
163 | await closeTransports(transports.sse);
164 | await closeTransports(transports.streamable);
165 |
166 | Logger.log("Server shutdown complete");
167 | process.exit(0);
168 | });
169 | }
170 |
171 | async function closeTransports(
172 | transports: Record,
173 | ) {
174 | for (const sessionId in transports) {
175 | try {
176 | await transports[sessionId].close();
177 | delete transports[sessionId];
178 | } catch (error) {
179 | console.error(`Error closing transport for session ${sessionId}:`, error);
180 | }
181 | }
182 | }
183 |
184 | export async function stopHttpServer(): Promise {
185 | if (!httpServer) {
186 | throw new Error("HTTP server is not running");
187 | }
188 |
189 | return new Promise((resolve, reject) => {
190 | httpServer!.close((err: Error | undefined) => {
191 | if (err) {
192 | reject(err);
193 | return;
194 | }
195 | httpServer = null;
196 | const closing = Object.values(transports.sse).map((transport) => {
197 | return transport.close();
198 | });
199 | Promise.all(closing).then(() => {
200 | resolve();
201 | });
202 | });
203 | });
204 | }
205 |
--------------------------------------------------------------------------------
/src/services/figma.ts:
--------------------------------------------------------------------------------
1 | import fs from "fs";
2 | import { parseFigmaResponse, SimplifiedDesign } from "./simplify-node-response.js";
3 | import type {
4 | GetImagesResponse,
5 | GetFileResponse,
6 | GetFileNodesResponse,
7 | GetImageFillsResponse,
8 | } from "@figma/rest-api-spec";
9 | import { downloadFigmaImage } from "~/utils/common.js";
10 | import { Logger } from "~/index.js";
11 | import yaml from "js-yaml";
12 |
13 | export interface FigmaError {
14 | status: number;
15 | err: string;
16 | }
17 |
18 | type FetchImageParams = {
19 | /**
20 | * The Node in Figma that will either be rendered or have its background image downloaded
21 | */
22 | nodeId: string;
23 | /**
24 | * The local file name to save the image
25 | */
26 | fileName: string;
27 | /**
28 | * The file mimetype for the image
29 | */
30 | fileType: "png" | "svg";
31 | };
32 |
33 | type FetchImageFillParams = Omit & {
34 | /**
35 | * Required to grab the background image when an image is used as a fill
36 | */
37 | imageRef: string;
38 | };
39 |
40 | export class FigmaService {
41 | private readonly apiKey: string;
42 | private readonly baseUrl = "https://api.figma.com/v1";
43 |
44 | constructor(apiKey: string) {
45 | this.apiKey = apiKey;
46 | }
47 |
48 | private async request(endpoint: string): Promise {
49 | if (typeof fetch !== "function") {
50 | throw new Error(
51 | "The MCP server is being run in a Node.js environment where `fetch` is not available. You won't be able to access any data related to the Figma file.\n\nAs the coding AI assistant, PLEASE ABORT THE CURRENT REQUEST. No alternate approaches will work. Help the user fix this issue so you can proceed by letting them know that they need to run the MCP server with Node.js version 18 or higher.",
52 | );
53 | }
54 | try {
55 | Logger.log(`Calling ${this.baseUrl}${endpoint}`);
56 | const response = await fetch(`${this.baseUrl}${endpoint}`, {
57 | headers: {
58 | "X-Figma-Token": this.apiKey,
59 | },
60 | });
61 |
62 | if (!response.ok) {
63 | throw {
64 | status: response.status,
65 | err: response.statusText || "Unknown error",
66 | } as FigmaError;
67 | }
68 |
69 | return await response.json();
70 | } catch (error) {
71 | if ((error as FigmaError).status) {
72 | throw error;
73 | }
74 | if (error instanceof Error) {
75 | throw new Error(`Failed to make request to Figma API: ${error.message}`);
76 | }
77 | throw new Error(`Failed to make request to Figma API: ${error}`);
78 | }
79 | }
80 |
81 | async getImageFills(
82 | fileKey: string,
83 | nodes: FetchImageFillParams[],
84 | localPath: string,
85 | ): Promise {
86 | if (nodes.length === 0) return [];
87 |
88 | let promises: Promise[] = [];
89 | const endpoint = `/files/${fileKey}/images`;
90 | const file = await this.request(endpoint);
91 | const { images = {} } = file.meta;
92 | promises = nodes.map(async ({ imageRef, fileName }) => {
93 | const imageUrl = images[imageRef];
94 | if (!imageUrl) {
95 | return "";
96 | }
97 | return downloadFigmaImage(fileName, localPath, imageUrl);
98 | });
99 | return Promise.all(promises);
100 | }
101 |
102 | async getImages(
103 | fileKey: string,
104 | nodes: FetchImageParams[],
105 | localPath: string,
106 | ): Promise {
107 | const pngIds = nodes.filter(({ fileType }) => fileType === "png").map(({ nodeId }) => nodeId);
108 | const pngFiles =
109 | pngIds.length > 0
110 | ? this.request(
111 | `/images/${fileKey}?ids=${pngIds.join(",")}&scale=2&format=png`,
112 | ).then(({ images = {} }) => images)
113 | : ({} as GetImagesResponse["images"]);
114 |
115 | const svgIds = nodes.filter(({ fileType }) => fileType === "svg").map(({ nodeId }) => nodeId);
116 | const svgFiles =
117 | svgIds.length > 0
118 | ? this.request(
119 | `/images/${fileKey}?ids=${svgIds.join(",")}&format=svg`,
120 | ).then(({ images = {} }) => images)
121 | : ({} as GetImagesResponse["images"]);
122 |
123 | const files = await Promise.all([pngFiles, svgFiles]).then(([f, l]) => ({ ...f, ...l }));
124 |
125 | const downloads = nodes
126 | .map(({ nodeId, fileName }) => {
127 | const imageUrl = files[nodeId];
128 | if (imageUrl) {
129 | return downloadFigmaImage(fileName, localPath, imageUrl);
130 | }
131 | return false;
132 | })
133 | .filter((url) => !!url);
134 |
135 | return Promise.all(downloads);
136 | }
137 |
138 | async getFile(fileKey: string, depth?: number | null): Promise {
139 | try {
140 | const endpoint = `/files/${fileKey}${depth ? `?depth=${depth}` : ""}`;
141 | Logger.log(`Retrieving Figma file: ${fileKey} (depth: ${depth ?? "default"})`);
142 | const response = await this.request(endpoint);
143 | Logger.log("Got response");
144 | const simplifiedResponse = parseFigmaResponse(response);
145 | writeLogs("figma-raw.yml", response);
146 | writeLogs("figma-simplified.yml", simplifiedResponse);
147 | return simplifiedResponse;
148 | } catch (e) {
149 | console.error("Failed to get file:", e);
150 | throw e;
151 | }
152 | }
153 |
154 | async getNode(fileKey: string, nodeId: string, depth?: number | null): Promise {
155 | const endpoint = `/files/${fileKey}/nodes?ids=${nodeId}${depth ? `&depth=${depth}` : ""}`;
156 | const response = await this.request(endpoint);
157 | Logger.log("Got response from getNode, now parsing.");
158 | writeLogs("figma-raw.yml", response);
159 | const simplifiedResponse = parseFigmaResponse(response);
160 | writeLogs("figma-simplified.yml", simplifiedResponse);
161 | return simplifiedResponse;
162 | }
163 | }
164 |
165 | function writeLogs(name: string, value: any) {
166 | try {
167 | if (process.env.NODE_ENV !== "development") return;
168 |
169 | const logsDir = "logs";
170 |
171 | try {
172 | fs.accessSync(process.cwd(), fs.constants.W_OK);
173 | } catch (error) {
174 | Logger.log("Failed to write logs:", error);
175 | return;
176 | }
177 |
178 | if (!fs.existsSync(logsDir)) {
179 | fs.mkdirSync(logsDir);
180 | }
181 | fs.writeFileSync(`${logsDir}/${name}`, yaml.dump(value));
182 | } catch (error) {
183 | console.debug("Failed to write logs:", error);
184 | }
185 | }
186 |
--------------------------------------------------------------------------------
/src/services/simplify-node-response.ts:
--------------------------------------------------------------------------------
1 | import { SimplifiedLayout, buildSimplifiedLayout } from "~/transformers/layout.js";
2 | import type {
3 | GetFileNodesResponse,
4 | Node as FigmaDocumentNode,
5 | Paint,
6 | Vector,
7 | GetFileResponse,
8 | } from "@figma/rest-api-spec";
9 | import { hasValue, isRectangleCornerRadii, isTruthy } from "~/utils/identity.js";
10 | import { removeEmptyKeys, generateVarId, StyleId, parsePaint, isVisible } from "~/utils/common.js";
11 | import { buildSimplifiedStrokes, SimplifiedStroke } from "~/transformers/style.js";
12 | import { buildSimplifiedEffects, SimplifiedEffects } from "~/transformers/effects.js";
13 | /**
14 | * TODO ITEMS
15 | *
16 | * - Improve layout handling—translate from Figma vocabulary to CSS
17 | * - Pull image fills/vectors out to top level for better AI visibility
18 | * ? Implement vector parents again for proper downloads
19 | * ? Look up existing styles in new MCP endpoint—Figma supports individual lookups without enterprise /v1/styles/:key
20 | * ? Parse out and save .cursor/rules/design-tokens file on command
21 | **/
22 |
23 | // -------------------- SIMPLIFIED STRUCTURES --------------------
24 |
25 | export type TextStyle = Partial<{
26 | fontFamily: string;
27 | fontWeight: number;
28 | fontSize: number;
29 | lineHeight: string;
30 | letterSpacing: string;
31 | textCase: string;
32 | textAlignHorizontal: string;
33 | textAlignVertical: string;
34 | }>;
35 | export type StrokeWeights = {
36 | top: number;
37 | right: number;
38 | bottom: number;
39 | left: number;
40 | };
41 | type StyleTypes =
42 | | TextStyle
43 | | SimplifiedFill[]
44 | | SimplifiedLayout
45 | | SimplifiedStroke
46 | | SimplifiedEffects
47 | | string;
48 | type GlobalVars = {
49 | styles: Record;
50 | };
51 | export interface SimplifiedDesign {
52 | name: string;
53 | lastModified: string;
54 | thumbnailUrl: string;
55 | nodes: SimplifiedNode[];
56 | globalVars: GlobalVars;
57 | }
58 |
59 | export interface SimplifiedNode {
60 | id: string;
61 | name: string;
62 | type: string; // e.g. FRAME, TEXT, INSTANCE, RECTANGLE, etc.
63 | // geometry
64 | boundingBox?: BoundingBox;
65 | // text
66 | text?: string;
67 | textStyle?: string;
68 | // appearance
69 | fills?: string;
70 | styles?: string;
71 | strokes?: string;
72 | effects?: string;
73 | opacity?: number;
74 | borderRadius?: string;
75 | // layout & alignment
76 | layout?: string;
77 | // backgroundColor?: ColorValue; // Deprecated by Figma API
78 | // for rect-specific strokes, etc.
79 | // children
80 | children?: SimplifiedNode[];
81 | }
82 |
83 | export interface BoundingBox {
84 | x: number;
85 | y: number;
86 | width: number;
87 | height: number;
88 | }
89 |
90 | export type CSSRGBAColor = `rgba(${number}, ${number}, ${number}, ${number})`;
91 | export type CSSHexColor = `#${string}`;
92 | export type SimplifiedFill =
93 | | {
94 | type?: Paint["type"];
95 | hex?: string;
96 | rgba?: string;
97 | opacity?: number;
98 | imageRef?: string;
99 | scaleMode?: string;
100 | gradientHandlePositions?: Vector[];
101 | gradientStops?: {
102 | position: number;
103 | color: ColorValue | string;
104 | }[];
105 | }
106 | | CSSRGBAColor
107 | | CSSHexColor;
108 |
109 | export interface ColorValue {
110 | hex: string;
111 | opacity: number;
112 | }
113 |
114 | // ---------------------- PARSING ----------------------
115 | export function parseFigmaResponse(data: GetFileResponse | GetFileNodesResponse): SimplifiedDesign {
116 | const { name, lastModified, thumbnailUrl } = data;
117 | let nodes: FigmaDocumentNode[];
118 | if ("document" in data) {
119 | nodes = Object.values(data.document.children);
120 | } else {
121 | nodes = Object.values(data.nodes).map((n) => n.document);
122 | }
123 | let globalVars: GlobalVars = {
124 | styles: {},
125 | };
126 | const simplifiedNodes: SimplifiedNode[] = nodes
127 | .filter(isVisible)
128 | .map((n) => parseNode(globalVars, n))
129 | .filter((child) => child !== null && child !== undefined);
130 |
131 | return {
132 | name,
133 | lastModified,
134 | thumbnailUrl: thumbnailUrl || "",
135 | nodes: simplifiedNodes,
136 | globalVars,
137 | };
138 | }
139 |
140 | // Helper function to find node by ID
141 | const findNodeById = (id: string, nodes: SimplifiedNode[]): SimplifiedNode | undefined => {
142 | for (const node of nodes) {
143 | if (node?.id === id) {
144 | return node;
145 | }
146 |
147 | if (node?.children && node.children.length > 0) {
148 | const foundInChildren = findNodeById(id, node.children);
149 | if (foundInChildren) {
150 | return foundInChildren;
151 | }
152 | }
153 | }
154 |
155 | return undefined;
156 | };
157 |
158 | /**
159 | * Find or create global variables
160 | * @param globalVars - Global variables object
161 | * @param value - Value to store
162 | * @param prefix - Variable ID prefix
163 | * @returns Variable ID
164 | */
165 | function findOrCreateVar(globalVars: GlobalVars, value: any, prefix: string): StyleId {
166 | // Check if the same value already exists
167 | const [existingVarId] =
168 | Object.entries(globalVars.styles).find(
169 | ([_, existingValue]) => JSON.stringify(existingValue) === JSON.stringify(value),
170 | ) ?? [];
171 |
172 | if (existingVarId) {
173 | return existingVarId as StyleId;
174 | }
175 |
176 | // Create a new variable if it doesn't exist
177 | const varId = generateVarId(prefix);
178 | globalVars.styles[varId] = value;
179 | return varId;
180 | }
181 |
182 | function parseNode(
183 | globalVars: GlobalVars,
184 | n: FigmaDocumentNode,
185 | parent?: FigmaDocumentNode,
186 | ): SimplifiedNode | null {
187 | const { id, name, type } = n;
188 |
189 | const simplified: SimplifiedNode = {
190 | id,
191 | name,
192 | type,
193 | };
194 |
195 | // text
196 | if (hasValue("style", n) && Object.keys(n.style).length) {
197 | const style = n.style;
198 | const textStyle = {
199 | fontFamily: style.fontFamily,
200 | fontWeight: style.fontWeight,
201 | fontSize: style.fontSize,
202 | lineHeight:
203 | style.lineHeightPx && style.fontSize
204 | ? `${style.lineHeightPx / style.fontSize}em`
205 | : undefined,
206 | letterSpacing:
207 | style.letterSpacing && style.letterSpacing !== 0 && style.fontSize
208 | ? `${(style.letterSpacing / style.fontSize) * 100}%`
209 | : undefined,
210 | textCase: style.textCase,
211 | textAlignHorizontal: style.textAlignHorizontal,
212 | textAlignVertical: style.textAlignVertical,
213 | };
214 | simplified.textStyle = findOrCreateVar(globalVars, textStyle, "style");
215 | }
216 |
217 | // fills & strokes
218 | if (hasValue("fills", n) && Array.isArray(n.fills) && n.fills.length) {
219 | // const fills = simplifyFills(n.fills.map(parsePaint));
220 | const fills = n.fills.map(parsePaint);
221 | simplified.fills = findOrCreateVar(globalVars, fills, "fill");
222 | }
223 |
224 | const strokes = buildSimplifiedStrokes(n);
225 | if (strokes.colors.length) {
226 | simplified.strokes = findOrCreateVar(globalVars, strokes, "stroke");
227 | }
228 |
229 | const effects = buildSimplifiedEffects(n);
230 | if (Object.keys(effects).length) {
231 | simplified.effects = findOrCreateVar(globalVars, effects, "effect");
232 | }
233 |
234 | // Process layout
235 | const layout = buildSimplifiedLayout(n, parent);
236 | if (Object.keys(layout).length > 1) {
237 | simplified.layout = findOrCreateVar(globalVars, layout, "layout");
238 | }
239 |
240 | // Keep other simple properties directly
241 | if (hasValue("characters", n, isTruthy)) {
242 | simplified.text = n.characters;
243 | }
244 |
245 | // border/corner
246 |
247 | // opacity
248 | if (hasValue("opacity", n) && typeof n.opacity === "number" && n.opacity !== 1) {
249 | simplified.opacity = n.opacity;
250 | }
251 |
252 | if (hasValue("cornerRadius", n) && typeof n.cornerRadius === "number") {
253 | simplified.borderRadius = `${n.cornerRadius}px`;
254 | }
255 | if (hasValue("rectangleCornerRadii", n, isRectangleCornerRadii)) {
256 | simplified.borderRadius = `${n.rectangleCornerRadii[0]}px ${n.rectangleCornerRadii[1]}px ${n.rectangleCornerRadii[2]}px ${n.rectangleCornerRadii[3]}px`;
257 | }
258 |
259 | // Recursively process child nodes
260 | if (hasValue("children", n) && n.children.length > 0) {
261 | let children = n.children
262 | .filter(isVisible)
263 | .map((child) => parseNode(globalVars, child, n))
264 | .filter((child) => child !== null && child !== undefined);
265 | if (children.length) {
266 | simplified.children = children;
267 | }
268 | }
269 |
270 | // Convert VECTOR to IMAGE
271 | if (type === "VECTOR") {
272 | simplified.type = "IMAGE-SVG";
273 | }
274 |
275 | return removeEmptyKeys(simplified);
276 | }
277 |
--------------------------------------------------------------------------------
/src/tests/benchmark.test.ts:
--------------------------------------------------------------------------------
1 | import yaml from "js-yaml";
2 |
3 | describe("Benchmarks", () => {
4 | const data = {
5 | name: "John Doe",
6 | age: 30,
7 | email: "john.doe@example.com",
8 | };
9 |
10 | it("YAML should be token efficient", () => {
11 | const yamlResult = yaml.dump(data);
12 | const jsonResult = JSON.stringify(data);
13 |
14 | expect(yamlResult.length).toBeLessThan(jsonResult.length);
15 | });
16 | });
17 |
--------------------------------------------------------------------------------
/src/tests/integration.test.ts:
--------------------------------------------------------------------------------
1 | import { FigmaMcpServer } from "../mcp.js";
2 | import { config } from "dotenv";
3 | import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
4 | import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5 | import { CallToolResultSchema } from "@modelcontextprotocol/sdk/types.js";
6 | import yaml from "js-yaml";
7 |
8 | config();
9 |
10 | describe("Figma MCP Server Tests", () => {
11 | let server: FigmaMcpServer;
12 | let client: Client;
13 | let figmaApiKey: string;
14 | let figmaFileKey: string;
15 |
16 | beforeAll(async () => {
17 | figmaApiKey = process.env.FIGMA_API_KEY || "";
18 | if (!figmaApiKey) {
19 | throw new Error("FIGMA_API_KEY is not set in environment variables");
20 | }
21 |
22 | figmaFileKey = process.env.FIGMA_FILE_KEY || "";
23 | if (!figmaFileKey) {
24 | throw new Error("FIGMA_FILE_KEY is not set in environment variables");
25 | }
26 |
27 | server = new FigmaMcpServer(figmaApiKey);
28 |
29 | client = new Client(
30 | {
31 | name: "figma-test-client",
32 | version: "1.0.0",
33 | },
34 | {
35 | capabilities: {
36 | tools: {},
37 | },
38 | },
39 | );
40 |
41 | const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
42 |
43 | await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]);
44 | });
45 |
46 | afterAll(async () => {
47 | await client.close();
48 | });
49 |
50 | describe("Get Figma Data", () => {
51 | it("should be able to get Figma file data", async () => {
52 | const args: any = {
53 | fileKey: figmaFileKey,
54 | };
55 |
56 | const result = await client.request(
57 | {
58 | method: "tools/call",
59 | params: {
60 | name: "get_figma_data",
61 | arguments: args,
62 | },
63 | },
64 | CallToolResultSchema,
65 | );
66 |
67 | const content = result.content[0].text as string;
68 | const parsed = yaml.load(content);
69 |
70 | expect(parsed).toBeDefined();
71 | }, 60000);
72 | });
73 | });
74 |
--------------------------------------------------------------------------------
/src/transformers/effects.ts:
--------------------------------------------------------------------------------
1 | import {
2 | DropShadowEffect,
3 | InnerShadowEffect,
4 | BlurEffect,
5 | Node as FigmaDocumentNode,
6 | } from "@figma/rest-api-spec";
7 | import { formatRGBAColor } from "~/utils/common.js";
8 | import { hasValue } from "~/utils/identity.js";
9 |
10 | export type SimplifiedEffects = {
11 | boxShadow?: string;
12 | filter?: string;
13 | backdropFilter?: string;
14 | };
15 |
16 | export function buildSimplifiedEffects(n: FigmaDocumentNode): SimplifiedEffects {
17 | if (!hasValue("effects", n)) return {};
18 | const effects = n.effects.filter((e) => e.visible);
19 |
20 | // Handle drop and inner shadows (both go into CSS box-shadow)
21 | const dropShadows = effects
22 | .filter((e): e is DropShadowEffect => e.type === "DROP_SHADOW")
23 | .map(simplifyDropShadow);
24 |
25 | const innerShadows = effects
26 | .filter((e): e is InnerShadowEffect => e.type === "INNER_SHADOW")
27 | .map(simplifyInnerShadow);
28 |
29 | const boxShadow = [...dropShadows, ...innerShadows].join(", ");
30 |
31 | // Handle blur effects - separate by CSS property
32 | // Layer blurs use the CSS 'filter' property
33 | const filterBlurValues = effects
34 | .filter((e): e is BlurEffect => e.type === "LAYER_BLUR")
35 | .map(simplifyBlur)
36 | .join(" ");
37 |
38 | // Background blurs use the CSS 'backdrop-filter' property
39 | const backdropFilterValues = effects
40 | .filter((e): e is BlurEffect => e.type === "BACKGROUND_BLUR")
41 | .map(simplifyBlur)
42 | .join(" ");
43 |
44 | const result: SimplifiedEffects = {};
45 | if (boxShadow) result.boxShadow = boxShadow;
46 | if (filterBlurValues) result.filter = filterBlurValues;
47 | if (backdropFilterValues) result.backdropFilter = backdropFilterValues;
48 |
49 | return result;
50 | }
51 |
52 | function simplifyDropShadow(effect: DropShadowEffect) {
53 | return `${effect.offset.x}px ${effect.offset.y}px ${effect.radius}px ${effect.spread ?? 0}px ${formatRGBAColor(effect.color)}`;
54 | }
55 |
56 | function simplifyInnerShadow(effect: InnerShadowEffect) {
57 | return `inset ${effect.offset.x}px ${effect.offset.y}px ${effect.radius}px ${effect.spread ?? 0}px ${formatRGBAColor(effect.color)}`;
58 | }
59 |
60 | function simplifyBlur(effect: BlurEffect) {
61 | return `blur(${effect.radius}px)`;
62 | }
63 |
--------------------------------------------------------------------------------
/src/transformers/layout.ts:
--------------------------------------------------------------------------------
1 | import { isFrame, isLayout, isRectangle } from "~/utils/identity.js";
2 | import type {
3 | Node as FigmaDocumentNode,
4 | HasFramePropertiesTrait,
5 | HasLayoutTrait,
6 | } from "@figma/rest-api-spec";
7 | import { generateCSSShorthand } from "~/utils/common.js";
8 |
9 | export interface SimplifiedLayout {
10 | mode: "none" | "row" | "column";
11 | justifyContent?: "flex-start" | "flex-end" | "center" | "space-between" | "baseline" | "stretch";
12 | alignItems?: "flex-start" | "flex-end" | "center" | "space-between" | "baseline" | "stretch";
13 | alignSelf?: "flex-start" | "flex-end" | "center" | "stretch";
14 | wrap?: boolean;
15 | gap?: string;
16 | locationRelativeToParent?: {
17 | x: number;
18 | y: number;
19 | };
20 | dimensions?: {
21 | width?: number;
22 | height?: number;
23 | aspectRatio?: number;
24 | };
25 | padding?: string;
26 | sizing?: {
27 | horizontal?: "fixed" | "fill" | "hug";
28 | vertical?: "fixed" | "fill" | "hug";
29 | };
30 | overflowScroll?: ("x" | "y")[];
31 | position?: "absolute";
32 | }
33 |
34 | // Convert Figma's layout config into a more typical flex-like schema
35 | export function buildSimplifiedLayout(
36 | n: FigmaDocumentNode,
37 | parent?: FigmaDocumentNode,
38 | ): SimplifiedLayout {
39 | const frameValues = buildSimplifiedFrameValues(n);
40 | const layoutValues = buildSimplifiedLayoutValues(n, parent, frameValues.mode) || {};
41 |
42 | return { ...frameValues, ...layoutValues };
43 | }
44 |
45 | // For flex layouts, process alignment and sizing
46 | function convertAlign(
47 | axisAlign?:
48 | | HasFramePropertiesTrait["primaryAxisAlignItems"]
49 | | HasFramePropertiesTrait["counterAxisAlignItems"],
50 | stretch?: {
51 | children: FigmaDocumentNode[];
52 | axis: "primary" | "counter";
53 | mode: "row" | "column" | "none";
54 | },
55 | ) {
56 | if (stretch && stretch.mode !== "none") {
57 | const { children, mode, axis } = stretch;
58 |
59 | // Compute whether to check horizontally or vertically based on axis and direction
60 | const direction = getDirection(axis, mode);
61 |
62 | const shouldStretch =
63 | children.length > 0 &&
64 | children.reduce((shouldStretch, c) => {
65 | if (!shouldStretch) return false;
66 | if ("layoutPositioning" in c && c.layoutPositioning === "ABSOLUTE") return true;
67 | if (direction === "horizontal") {
68 | return "layoutSizingHorizontal" in c && c.layoutSizingHorizontal === "FILL";
69 | } else if (direction === "vertical") {
70 | return "layoutSizingVertical" in c && c.layoutSizingVertical === "FILL";
71 | }
72 | return false;
73 | }, true);
74 |
75 | if (shouldStretch) return "stretch";
76 | }
77 |
78 | switch (axisAlign) {
79 | case "MIN":
80 | // MIN, AKA flex-start, is the default alignment
81 | return undefined;
82 | case "MAX":
83 | return "flex-end";
84 | case "CENTER":
85 | return "center";
86 | case "SPACE_BETWEEN":
87 | return "space-between";
88 | case "BASELINE":
89 | return "baseline";
90 | default:
91 | return undefined;
92 | }
93 | }
94 |
95 | function convertSelfAlign(align?: HasLayoutTrait["layoutAlign"]) {
96 | switch (align) {
97 | case "MIN":
98 | // MIN, AKA flex-start, is the default alignment
99 | return undefined;
100 | case "MAX":
101 | return "flex-end";
102 | case "CENTER":
103 | return "center";
104 | case "STRETCH":
105 | return "stretch";
106 | default:
107 | return undefined;
108 | }
109 | }
110 |
111 | // interpret sizing
112 | function convertSizing(
113 | s?: HasLayoutTrait["layoutSizingHorizontal"] | HasLayoutTrait["layoutSizingVertical"],
114 | ) {
115 | if (s === "FIXED") return "fixed";
116 | if (s === "FILL") return "fill";
117 | if (s === "HUG") return "hug";
118 | return undefined;
119 | }
120 |
121 | function getDirection(
122 | axis: "primary" | "counter",
123 | mode: "row" | "column",
124 | ): "horizontal" | "vertical" {
125 | switch (axis) {
126 | case "primary":
127 | switch (mode) {
128 | case "row":
129 | return "horizontal";
130 | case "column":
131 | return "vertical";
132 | }
133 | case "counter":
134 | switch (mode) {
135 | case "row":
136 | return "horizontal";
137 | case "column":
138 | return "vertical";
139 | }
140 | }
141 | }
142 |
143 | function buildSimplifiedFrameValues(n: FigmaDocumentNode): SimplifiedLayout | { mode: "none" } {
144 | if (!isFrame(n)) {
145 | return { mode: "none" };
146 | }
147 |
148 | const frameValues: SimplifiedLayout = {
149 | mode:
150 | !n.layoutMode || n.layoutMode === "NONE"
151 | ? "none"
152 | : n.layoutMode === "HORIZONTAL"
153 | ? "row"
154 | : "column",
155 | };
156 |
157 | const overflowScroll: SimplifiedLayout["overflowScroll"] = [];
158 | if (n.overflowDirection?.includes("HORIZONTAL")) overflowScroll.push("x");
159 | if (n.overflowDirection?.includes("VERTICAL")) overflowScroll.push("y");
160 | if (overflowScroll.length > 0) frameValues.overflowScroll = overflowScroll;
161 |
162 | if (frameValues.mode === "none") {
163 | return frameValues;
164 | }
165 |
166 | // TODO: convertAlign should be two functions, one for justifyContent and one for alignItems
167 | frameValues.justifyContent = convertAlign(n.primaryAxisAlignItems ?? "MIN", {
168 | children: n.children,
169 | axis: "primary",
170 | mode: frameValues.mode,
171 | });
172 | frameValues.alignItems = convertAlign(n.counterAxisAlignItems ?? "MIN", {
173 | children: n.children,
174 | axis: "counter",
175 | mode: frameValues.mode,
176 | });
177 | frameValues.alignSelf = convertSelfAlign(n.layoutAlign);
178 |
179 | // Only include wrap if it's set to WRAP, since flex layouts don't default to wrapping
180 | frameValues.wrap = n.layoutWrap === "WRAP" ? true : undefined;
181 | frameValues.gap = n.itemSpacing ? `${n.itemSpacing ?? 0}px` : undefined;
182 | // gather padding
183 | if (n.paddingTop || n.paddingBottom || n.paddingLeft || n.paddingRight) {
184 | frameValues.padding = generateCSSShorthand({
185 | top: n.paddingTop ?? 0,
186 | right: n.paddingRight ?? 0,
187 | bottom: n.paddingBottom ?? 0,
188 | left: n.paddingLeft ?? 0,
189 | });
190 | }
191 |
192 | return frameValues;
193 | }
194 |
195 | function buildSimplifiedLayoutValues(
196 | n: FigmaDocumentNode,
197 | parent: FigmaDocumentNode | undefined,
198 | mode: "row" | "column" | "none",
199 | ): SimplifiedLayout | undefined {
200 | if (!isLayout(n)) return undefined;
201 |
202 | const layoutValues: SimplifiedLayout = { mode };
203 |
204 | layoutValues.sizing = {
205 | horizontal: convertSizing(n.layoutSizingHorizontal),
206 | vertical: convertSizing(n.layoutSizingVertical),
207 | };
208 |
209 | // Only include positioning-related properties if parent layout isn't flex or if the node is absolute
210 | if (isFrame(parent) && (parent?.layoutMode === "NONE" || n.layoutPositioning === "ABSOLUTE")) {
211 | if (n.layoutPositioning === "ABSOLUTE") {
212 | layoutValues.position = "absolute";
213 | }
214 | if (n.absoluteBoundingBox && parent.absoluteBoundingBox) {
215 | layoutValues.locationRelativeToParent = {
216 | x: n.absoluteBoundingBox.x - (parent?.absoluteBoundingBox?.x ?? n.absoluteBoundingBox.x),
217 | y: n.absoluteBoundingBox.y - (parent?.absoluteBoundingBox?.y ?? n.absoluteBoundingBox.y),
218 | };
219 | }
220 | return layoutValues;
221 | }
222 |
223 | // Handle dimensions based on layout growth and alignment
224 | if (isRectangle("absoluteBoundingBox", n) && isRectangle("absoluteBoundingBox", parent)) {
225 | const dimensions: { width?: number; height?: number; aspectRatio?: number } = {};
226 |
227 | // Only include dimensions that aren't meant to stretch
228 | if (mode === "row") {
229 | if (!n.layoutGrow && n.layoutSizingHorizontal == "FIXED")
230 | dimensions.width = n.absoluteBoundingBox.width;
231 | if (n.layoutAlign !== "STRETCH" && n.layoutSizingVertical == "FIXED")
232 | dimensions.height = n.absoluteBoundingBox.height;
233 | } else if (mode === "column") {
234 | // column
235 | if (n.layoutAlign !== "STRETCH" && n.layoutSizingHorizontal == "FIXED")
236 | dimensions.width = n.absoluteBoundingBox.width;
237 | if (!n.layoutGrow && n.layoutSizingVertical == "FIXED")
238 | dimensions.height = n.absoluteBoundingBox.height;
239 |
240 | if (n.preserveRatio) {
241 | dimensions.aspectRatio = n.absoluteBoundingBox?.width / n.absoluteBoundingBox?.height;
242 | }
243 | }
244 |
245 | if (Object.keys(dimensions).length > 0) {
246 | layoutValues.dimensions = dimensions;
247 | }
248 | }
249 |
250 | return layoutValues;
251 | }
252 |
--------------------------------------------------------------------------------
/src/transformers/style.ts:
--------------------------------------------------------------------------------
1 | import { Node as FigmaDocumentNode } from "@figma/rest-api-spec";
2 | import { SimplifiedFill } from "~/services/simplify-node-response.js";
3 | import { generateCSSShorthand, isVisible, parsePaint } from "~/utils/common.js";
4 | import { hasValue, isStrokeWeights } from "~/utils/identity.js";
5 | export type SimplifiedStroke = {
6 | colors: SimplifiedFill[];
7 | strokeWeight?: string;
8 | strokeDashes?: number[];
9 | strokeWeights?: string;
10 | };
11 | export function buildSimplifiedStrokes(n: FigmaDocumentNode): SimplifiedStroke {
12 | let strokes: SimplifiedStroke = { colors: [] };
13 | if (hasValue("strokes", n) && Array.isArray(n.strokes) && n.strokes.length) {
14 | strokes.colors = n.strokes.filter(isVisible).map(parsePaint);
15 | }
16 |
17 | if (hasValue("strokeWeight", n) && typeof n.strokeWeight === "number" && n.strokeWeight > 0) {
18 | strokes.strokeWeight = `${n.strokeWeight}px`;
19 | }
20 |
21 | if (hasValue("strokeDashes", n) && Array.isArray(n.strokeDashes) && n.strokeDashes.length) {
22 | strokes.strokeDashes = n.strokeDashes;
23 | }
24 |
25 | if (hasValue("individualStrokeWeights", n, isStrokeWeights)) {
26 | strokes.strokeWeight = generateCSSShorthand(n.individualStrokeWeights);
27 | }
28 |
29 | return strokes;
30 | }
31 |
--------------------------------------------------------------------------------
/src/utils/common.ts:
--------------------------------------------------------------------------------
1 | import fs from "fs";
2 | import path from "path";
3 |
4 | import type { Paint, RGBA } from "@figma/rest-api-spec";
5 | import { CSSHexColor, CSSRGBAColor, SimplifiedFill } from "~/services/simplify-node-response.js";
6 |
7 | export type StyleId = `${string}_${string}` & { __brand: "StyleId" };
8 |
9 | export interface ColorValue {
10 | hex: CSSHexColor;
11 | opacity: number;
12 | }
13 |
14 | /**
15 | * Download Figma image and save it locally
16 | * @param fileName - The filename to save as
17 | * @param localPath - The local path to save to
18 | * @param imageUrl - Image URL (images[nodeId])
19 | * @returns A Promise that resolves to the full file path where the image was saved
20 | * @throws Error if download fails
21 | */
22 | export async function downloadFigmaImage(
23 | fileName: string,
24 | localPath: string,
25 | imageUrl: string,
26 | ): Promise {
27 | try {
28 | // Ensure local path exists
29 | if (!fs.existsSync(localPath)) {
30 | fs.mkdirSync(localPath, { recursive: true });
31 | }
32 |
33 | // Build the complete file path
34 | const fullPath = path.join(localPath, fileName);
35 |
36 | // Use fetch to download the image
37 | const response = await fetch(imageUrl, {
38 | method: "GET",
39 | });
40 |
41 | if (!response.ok) {
42 | throw new Error(`Failed to download image: ${response.statusText}`);
43 | }
44 |
45 | // Create write stream
46 | const writer = fs.createWriteStream(fullPath);
47 |
48 | // Get the response as a readable stream and pipe it to the file
49 | const reader = response.body?.getReader();
50 | if (!reader) {
51 | throw new Error("Failed to get response body");
52 | }
53 |
54 | return new Promise((resolve, reject) => {
55 | // Process stream
56 | const processStream = async () => {
57 | try {
58 | while (true) {
59 | const { done, value } = await reader.read();
60 | if (done) {
61 | writer.end();
62 | break;
63 | }
64 | writer.write(value);
65 | }
66 | resolve(fullPath);
67 | } catch (err) {
68 | writer.end();
69 | fs.unlink(fullPath, () => {});
70 | reject(err);
71 | }
72 | };
73 |
74 | writer.on("error", (err) => {
75 | reader.cancel();
76 | fs.unlink(fullPath, () => {});
77 | reject(new Error(`Failed to write image: ${err.message}`));
78 | });
79 |
80 | processStream();
81 | });
82 | } catch (error) {
83 | const errorMessage = error instanceof Error ? error.message : String(error);
84 | throw new Error(`Error downloading image: ${errorMessage}`);
85 | }
86 | }
87 |
88 | /**
89 | * Remove keys with empty arrays or empty objects from an object.
90 | * @param input - The input object or value.
91 | * @returns The processed object or the original value.
92 | */
93 | export function removeEmptyKeys(input: T): T {
94 | // If not an object type or null, return directly
95 | if (typeof input !== "object" || input === null) {
96 | return input;
97 | }
98 |
99 | // Handle array type
100 | if (Array.isArray(input)) {
101 | return input.map((item) => removeEmptyKeys(item)) as T;
102 | }
103 |
104 | // Handle object type
105 | const result = {} as T;
106 | for (const key in input) {
107 | if (Object.prototype.hasOwnProperty.call(input, key)) {
108 | const value = input[key];
109 |
110 | // Recursively process nested objects
111 | const cleanedValue = removeEmptyKeys(value);
112 |
113 | // Skip empty arrays and empty objects
114 | if (
115 | cleanedValue !== undefined &&
116 | !(Array.isArray(cleanedValue) && cleanedValue.length === 0) &&
117 | !(
118 | typeof cleanedValue === "object" &&
119 | cleanedValue !== null &&
120 | Object.keys(cleanedValue).length === 0
121 | )
122 | ) {
123 | result[key] = cleanedValue;
124 | }
125 | }
126 | }
127 |
128 | return result;
129 | }
130 |
131 | /**
132 | * Convert hex color value and opacity to rgba format
133 | * @param hex - Hexadecimal color value (e.g., "#FF0000" or "#F00")
134 | * @param opacity - Opacity value (0-1)
135 | * @returns Color string in rgba format
136 | */
137 | export function hexToRgba(hex: string, opacity: number = 1): string {
138 | // Remove possible # prefix
139 | hex = hex.replace("#", "");
140 |
141 | // Handle shorthand hex values (e.g., #FFF)
142 | if (hex.length === 3) {
143 | hex = hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
144 | }
145 |
146 | // Convert hex to RGB values
147 | const r = parseInt(hex.substring(0, 2), 16);
148 | const g = parseInt(hex.substring(2, 4), 16);
149 | const b = parseInt(hex.substring(4, 6), 16);
150 |
151 | // Ensure opacity is in the 0-1 range
152 | const validOpacity = Math.min(Math.max(opacity, 0), 1);
153 |
154 | return `rgba(${r}, ${g}, ${b}, ${validOpacity})`;
155 | }
156 |
157 | /**
158 | * Convert color from RGBA to { hex, opacity }
159 | *
160 | * @param color - The color to convert, including alpha channel
161 | * @param opacity - The opacity of the color, if not included in alpha channel
162 | * @returns The converted color
163 | **/
164 | export function convertColor(color: RGBA, opacity = 1): ColorValue {
165 | const r = Math.round(color.r * 255);
166 | const g = Math.round(color.g * 255);
167 | const b = Math.round(color.b * 255);
168 |
169 | // Alpha channel defaults to 1. If opacity and alpha are both and < 1, their effects are multiplicative
170 | const a = Math.round(opacity * color.a * 100) / 100;
171 |
172 | const hex = ("#" +
173 | ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase()) as CSSHexColor;
174 |
175 | return { hex, opacity: a };
176 | }
177 |
178 | /**
179 | * Convert color from Figma RGBA to rgba(#, #, #, #) CSS format
180 | *
181 | * @param color - The color to convert, including alpha channel
182 | * @param opacity - The opacity of the color, if not included in alpha channel
183 | * @returns The converted color
184 | **/
185 | export function formatRGBAColor(color: RGBA, opacity = 1): CSSRGBAColor {
186 | const r = Math.round(color.r * 255);
187 | const g = Math.round(color.g * 255);
188 | const b = Math.round(color.b * 255);
189 | // Alpha channel defaults to 1. If opacity and alpha are both and < 1, their effects are multiplicative
190 | const a = Math.round(opacity * color.a * 100) / 100;
191 |
192 | return `rgba(${r}, ${g}, ${b}, ${a})`;
193 | }
194 |
195 | /**
196 | * Generate a 6-character random variable ID
197 | * @param prefix - ID prefix
198 | * @returns A 6-character random ID string with prefix
199 | */
200 | export function generateVarId(prefix: string = "var"): StyleId {
201 | const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
202 | let result = "";
203 |
204 | for (let i = 0; i < 6; i++) {
205 | const randomIndex = Math.floor(Math.random() * chars.length);
206 | result += chars[randomIndex];
207 | }
208 |
209 | return `${prefix}_${result}` as StyleId;
210 | }
211 |
212 | /**
213 | * Generate a CSS shorthand for values that come with top, right, bottom, and left
214 | *
215 | * input: { top: 10, right: 10, bottom: 10, left: 10 }
216 | * output: "10px"
217 | *
218 | * input: { top: 10, right: 20, bottom: 10, left: 20 }
219 | * output: "10px 20px"
220 | *
221 | * input: { top: 10, right: 20, bottom: 30, left: 40 }
222 | * output: "10px 20px 30px 40px"
223 | *
224 | * @param values - The values to generate the shorthand for
225 | * @returns The generated shorthand
226 | */
227 | export function generateCSSShorthand(
228 | values: {
229 | top: number;
230 | right: number;
231 | bottom: number;
232 | left: number;
233 | },
234 | {
235 | ignoreZero = true,
236 | suffix = "px",
237 | }: {
238 | /**
239 | * If true and all values are 0, return undefined. Defaults to true.
240 | */
241 | ignoreZero?: boolean;
242 | /**
243 | * The suffix to add to the shorthand. Defaults to "px".
244 | */
245 | suffix?: string;
246 | } = {},
247 | ) {
248 | const { top, right, bottom, left } = values;
249 | if (ignoreZero && top === 0 && right === 0 && bottom === 0 && left === 0) {
250 | return undefined;
251 | }
252 | if (top === right && right === bottom && bottom === left) {
253 | return `${top}${suffix}`;
254 | }
255 | if (right === left) {
256 | if (top === bottom) {
257 | return `${top}${suffix} ${right}${suffix}`;
258 | }
259 | return `${top}${suffix} ${right}${suffix} ${bottom}${suffix}`;
260 | }
261 | return `${top}${suffix} ${right}${suffix} ${bottom}${suffix} ${left}${suffix}`;
262 | }
263 |
264 | /**
265 | * Convert a Figma paint (solid, image, gradient) to a SimplifiedFill
266 | * @param raw - The Figma paint to convert
267 | * @returns The converted SimplifiedFill
268 | */
269 | export function parsePaint(raw: Paint): SimplifiedFill {
270 | if (raw.type === "IMAGE") {
271 | return {
272 | type: "IMAGE",
273 | imageRef: raw.imageRef,
274 | scaleMode: raw.scaleMode,
275 | };
276 | } else if (raw.type === "SOLID") {
277 | // treat as SOLID
278 | const { hex, opacity } = convertColor(raw.color!, raw.opacity);
279 | if (opacity === 1) {
280 | return hex;
281 | } else {
282 | return formatRGBAColor(raw.color!, opacity);
283 | }
284 | } else if (
285 | ["GRADIENT_LINEAR", "GRADIENT_RADIAL", "GRADIENT_ANGULAR", "GRADIENT_DIAMOND"].includes(
286 | raw.type,
287 | )
288 | ) {
289 | // treat as GRADIENT_LINEAR
290 | return {
291 | type: raw.type,
292 | gradientHandlePositions: raw.gradientHandlePositions,
293 | gradientStops: raw.gradientStops.map(({ position, color }) => ({
294 | position,
295 | color: convertColor(color),
296 | })),
297 | };
298 | } else {
299 | throw new Error(`Unknown paint type: ${raw.type}`);
300 | }
301 | }
302 |
303 | /**
304 | * Check if an element is visible
305 | * @param element - The item to check
306 | * @returns True if the item is visible, false otherwise
307 | */
308 | export function isVisible(element: { visible?: boolean }): boolean {
309 | return element.visible ?? true;
310 | }
311 |
--------------------------------------------------------------------------------
/src/utils/identity.ts:
--------------------------------------------------------------------------------
1 | import type {
2 | Rectangle,
3 | HasLayoutTrait,
4 | StrokeWeights,
5 | HasFramePropertiesTrait,
6 | } from "@figma/rest-api-spec";
7 | import { isTruthy } from "remeda";
8 | import { CSSHexColor, CSSRGBAColor } from "~/services/simplify-node-response.js";
9 |
10 | export { isTruthy };
11 |
12 | export function hasValue(
13 | key: K,
14 | obj: unknown,
15 | typeGuard?: (val: unknown) => val is T,
16 | ): obj is Record {
17 | const isObject = typeof obj === "object" && obj !== null;
18 | if (!isObject || !(key in obj)) return false;
19 | const val = (obj as Record)[key];
20 | return typeGuard ? typeGuard(val) : val !== undefined;
21 | }
22 |
23 | export function isFrame(val: unknown): val is HasFramePropertiesTrait {
24 | return (
25 | typeof val === "object" &&
26 | !!val &&
27 | "clipsContent" in val &&
28 | typeof val.clipsContent === "boolean"
29 | );
30 | }
31 |
32 | export function isLayout(val: unknown): val is HasLayoutTrait {
33 | return (
34 | typeof val === "object" &&
35 | !!val &&
36 | "absoluteBoundingBox" in val &&
37 | typeof val.absoluteBoundingBox === "object" &&
38 | !!val.absoluteBoundingBox &&
39 | "x" in val.absoluteBoundingBox &&
40 | "y" in val.absoluteBoundingBox &&
41 | "width" in val.absoluteBoundingBox &&
42 | "height" in val.absoluteBoundingBox
43 | );
44 | }
45 |
46 | export function isStrokeWeights(val: unknown): val is StrokeWeights {
47 | return (
48 | typeof val === "object" &&
49 | val !== null &&
50 | "top" in val &&
51 | "right" in val &&
52 | "bottom" in val &&
53 | "left" in val
54 | );
55 | }
56 |
57 | export function isRectangle(
58 | key: K,
59 | obj: T,
60 | ): obj is T & { [P in K]: Rectangle } {
61 | const recordObj = obj as Record;
62 | return (
63 | typeof obj === "object" &&
64 | !!obj &&
65 | key in recordObj &&
66 | typeof recordObj[key] === "object" &&
67 | !!recordObj[key] &&
68 | "x" in recordObj[key] &&
69 | "y" in recordObj[key] &&
70 | "width" in recordObj[key] &&
71 | "height" in recordObj[key]
72 | );
73 | }
74 |
75 | export function isRectangleCornerRadii(val: unknown): val is number[] {
76 | return Array.isArray(val) && val.length === 4 && val.every((v) => typeof v === "number");
77 | }
78 |
79 | export function isCSSColorValue(val: unknown): val is CSSRGBAColor | CSSHexColor {
80 | return typeof val === "string" && (val.startsWith("#") || val.startsWith("rgba"));
81 | }
82 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": "./",
4 | "paths": {
5 | "~/*": ["./src/*"]
6 | },
7 |
8 | "target": "ES2020",
9 | "lib": ["ES2021", "DOM"],
10 | "module": "NodeNext",
11 | "moduleResolution": "NodeNext",
12 | "resolveJsonModule": true,
13 | "allowJs": true,
14 | "checkJs": true,
15 |
16 | /* EMIT RULES */
17 | "outDir": "./dist",
18 | "declaration": true,
19 | "declarationMap": true,
20 | "sourceMap": true,
21 | "removeComments": true,
22 |
23 | "strict": true,
24 | "esModuleInterop": true,
25 | "skipLibCheck": true,
26 | "forceConsistentCasingInFileNames": true
27 | },
28 | "include": ["src/**/*"]
29 | }
30 |
--------------------------------------------------------------------------------
/tsup.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "tsup";
2 |
3 | const isDev = process.env.npm_lifecycle_event === "dev";
4 |
5 | export default defineConfig({
6 | clean: true,
7 | entry: ["src/index.ts", "src/cli.ts"],
8 | format: ["esm"],
9 | minify: !isDev,
10 | target: "esnext",
11 | outDir: "dist",
12 | outExtension: ({ format }) => ({
13 | js: ".js",
14 | }),
15 | onSuccess: isDev ? "node dist/cli.js" : undefined,
16 | });
17 |
--------------------------------------------------------------------------------