This is page 1 of 2. Use http://codebase.md/glips/figma-context-mcp?lines=true&page={x} to view the full context. # Directory Structure ``` ├── .changeset │ ├── config.json │ └── README.md ├── .env.example ├── .github │ ├── actions │ │ └── setup │ │ └── action.yml │ ├── changeset-beta-version.js │ ├── changeset-version.js │ ├── FUNDING.yml │ ├── ISSUE_TEMPLATE │ │ └── bug_report.md │ └── workflows │ ├── beta-release.yml │ └── release.yml ├── .gitignore ├── .nvmrc ├── .prettierrc ├── CHANGELOG.md ├── CONTRIBUTING.md ├── docs │ ├── cursor-MCP-settings.png │ ├── figma-copy-link.png │ └── verify-connection.png ├── eslint.config.js ├── jest.config.js ├── LICENSE ├── package.json ├── pnpm-lock.yaml ├── README.ja.md ├── README.ko.md ├── README.md ├── README.zh-cn.md ├── README.zh-tw.md ├── RELEASES.md ├── ROADMAP.md ├── server.json ├── src │ ├── bin.ts │ ├── config.ts │ ├── extractors │ │ ├── built-in.ts │ │ ├── design-extractor.ts │ │ ├── index.ts │ │ ├── node-walker.ts │ │ ├── README.md │ │ └── types.ts │ ├── index.ts │ ├── mcp │ │ ├── index.ts │ │ └── tools │ │ ├── download-figma-images-tool.ts │ │ ├── get-figma-data-tool.ts │ │ └── index.ts │ ├── mcp-server.ts │ ├── server.ts │ ├── services │ │ └── figma.ts │ ├── tests │ │ ├── benchmark.test.ts │ │ └── integration.test.ts │ ├── transformers │ │ ├── component.ts │ │ ├── effects.ts │ │ ├── layout.ts │ │ ├── style.ts │ │ └── text.ts │ └── utils │ ├── common.ts │ ├── fetch-with-retry.ts │ ├── identity.ts │ ├── image-processing.ts │ └── logger.ts ├── tsconfig.json └── tsup.config.ts ``` # Files -------------------------------------------------------------------------------- /.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 | ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Dependencies 2 | node_modules 3 | .pnpm-store 4 | package-lock.json 5 | 6 | # Build output 7 | dist 8 | 9 | # Environment variables 10 | .env 11 | .env.local 12 | .env.*.local 13 | 14 | # IDE 15 | .vscode/* 16 | !.vscode/extensions.json 17 | !.vscode/settings.json 18 | .idea 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | 25 | # Logs 26 | logs 27 | *.log 28 | npm-debug.log* 29 | yarn-debug.log* 30 | yarn-error.log* 31 | pnpm-debug.log* 32 | 33 | # Testing 34 | coverage 35 | test-output 36 | 37 | # OS 38 | .DS_Store 39 | Thumbs.db 40 | 41 | # mcp-publisher CLI tool files 42 | .mcpregistry* ``` -------------------------------------------------------------------------------- /.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 | 16 | # Output format can either be "yaml" or "json". Is YAML by default since it's 17 | # smaller, but JSON is understood by most LLMs better. 18 | # 19 | # OUTPUT_FORMAT="json" ``` -------------------------------------------------------------------------------- /.changeset/README.md: -------------------------------------------------------------------------------- ```markdown 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | ``` -------------------------------------------------------------------------------- /src/extractors/README.md: -------------------------------------------------------------------------------- ```markdown 1 | # Flexible Figma Data Extractors 2 | 3 | This module provides a flexible, single-pass system for extracting data from Figma design files. It allows you to compose different extractors based on your specific needs, making it perfect for different LLM use cases where you want to optimize context window usage. 4 | 5 | ## Architecture 6 | 7 | The system is built in clean layers: 8 | 9 | 1. **Strategy Layer**: Define what you want to extract 10 | 2. **Traversal Layer**: Single-pass tree walking with configurable extractors 11 | 3. **Extraction Layer**: Pure functions that transform individual node data 12 | 13 | ## Basic Usage 14 | 15 | ```typescript 16 | import { extractFromDesign, allExtractors, layoutAndText, contentOnly } from "figma-mcp/extractors"; 17 | 18 | // Extract everything (equivalent to current parseNode) 19 | const fullData = extractFromDesign(nodes, allExtractors); 20 | 21 | // Extract only layout + text for content planning 22 | const layoutData = extractFromDesign(nodes, layoutAndText, { 23 | maxDepth: 3, 24 | }); 25 | 26 | // Extract only text content for copy audits 27 | const textData = extractFromDesign(nodes, contentOnly, { 28 | nodeFilter: (node) => node.type === "TEXT", 29 | }); 30 | ``` 31 | 32 | ## Built-in Extractors 33 | 34 | ### Individual Extractors 35 | 36 | - `layoutExtractor` - Layout properties (positioning, sizing, flex properties) 37 | - `textExtractor` - Text content and typography styles 38 | - `visualsExtractor` - Visual appearance (fills, strokes, effects, opacity, borders) 39 | - `componentExtractor` - Component instance data 40 | 41 | ### Convenience Combinations 42 | 43 | - `allExtractors` - Everything (replicates current behavior) 44 | - `layoutAndText` - Layout + text (good for content analysis) 45 | - `contentOnly` - Text only (good for copy extraction) 46 | - `visualsOnly` - Visual styles only (good for design systems) 47 | - `layoutOnly` - Layout only (good for structure analysis) 48 | 49 | ## Creating Custom Extractors 50 | 51 | ```typescript 52 | import type { ExtractorFn } from "figma-mcp/extractors"; 53 | 54 | // Custom extractor that identifies design system components 55 | const designSystemExtractor: ExtractorFn = (node, result, context) => { 56 | if (node.name.startsWith("DS/")) { 57 | result.isDesignSystemComponent = true; 58 | result.dsCategory = node.name.split("/")[1]; 59 | } 60 | }; 61 | 62 | // Use it with other extractors 63 | const data = extractFromDesign(nodes, [layoutExtractor, designSystemExtractor]); 64 | ``` 65 | 66 | ## Filtering and Options 67 | 68 | ```typescript 69 | // Limit traversal depth 70 | const shallowData = extractFromDesign(nodes, allExtractors, { 71 | maxDepth: 2, 72 | }); 73 | 74 | // Filter to specific node types 75 | const frameData = extractFromDesign(nodes, layoutAndText, { 76 | nodeFilter: (node) => ["FRAME", "GROUP"].includes(node.type), 77 | }); 78 | 79 | // Custom filtering logic 80 | const buttonData = extractFromDesign(nodes, allExtractors, { 81 | nodeFilter: (node) => node.name.toLowerCase().includes("button"), 82 | }); 83 | ``` 84 | 85 | ## LLM Context Optimization 86 | 87 | The flexible system is designed for different LLM use cases: 88 | 89 | ```typescript 90 | // For large designs - extract incrementally 91 | function extractForLLM(nodes, phase) { 92 | switch (phase) { 93 | case "structure": 94 | return extractFromDesign(nodes, layoutOnly, { maxDepth: 3 }); 95 | 96 | case "content": 97 | return extractFromDesign(nodes, contentOnly); 98 | 99 | case "styling": 100 | return extractFromDesign(nodes, visualsOnly, { maxDepth: 2 }); 101 | 102 | case "full": 103 | return extractFromDesign(nodes, allExtractors); 104 | } 105 | } 106 | ``` 107 | 108 | ## Benefits 109 | 110 | 1. **Single Tree Walk** - Efficient processing, no matter how many extractors 111 | 2. **Composable** - Mix and match extractors for your specific needs 112 | 3. **Extensible** - Easy to add custom extractors for domain-specific logic 113 | 4. **Type Safe** - Full TypeScript support with proper inference 114 | 5. **Context Optimized** - Perfect for LLM context window management 115 | 6. **Backward Compatible** - Works alongside existing parsing logic 116 | 117 | ## Migration Path 118 | 119 | The new system works alongside the current `parseNode` function. You can: 120 | 121 | 1. Start using the new extractors for new use cases 122 | 2. Gradually migrate existing functionality 123 | 3. Keep the current API for general-purpose parsing 124 | 125 | The `allExtractors` combination provides equivalent functionality to the current `parseNode` behavior. 126 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | <a href="https://www.framelink.ai/?utm_source=github&utm_medium=referral&utm_campaign=readme" target="_blank" rel="noopener"> 2 | <picture> 3 | <source media="(prefers-color-scheme: dark)" srcset="https://www.framelink.ai/github/HeaderDark.png" /> 4 | <img alt="Framelink" src="https://www.framelink.ai/github/HeaderLight.png" /> 5 | </picture> 6 | </a> 7 | 8 | <div align="center"> 9 | <h1>Framelink Figma MCP Server</h1> 10 | <p> 11 | 🌐 Available in: 12 | <a href="README.ko.md">한국어 (Korean)</a> | 13 | <a href="README.ja.md">日本語 (Japanese)</a> | 14 | <a href="README.zh-cn.md">简体中文 (Simplified Chinese)</a> | 15 | <a href="README.zh-tw.md">繁體中文 (Traditional Chinese)</a> 16 | </p> 17 | <h3>Give your coding agent access to your Figma data.<br/>Implement designs in any framework in one-shot.</h3> 18 | <a href="https://npmcharts.com/compare/figma-developer-mcp?interval=30"> 19 | <img alt="weekly downloads" src="https://img.shields.io/npm/dm/figma-developer-mcp.svg"> 20 | </a> 21 | <a href="https://github.com/GLips/Figma-Context-MCP/blob/main/LICENSE"> 22 | <img alt="MIT License" src="https://img.shields.io/github/license/GLips/Figma-Context-MCP" /> 23 | </a> 24 | <a href="https://framelink.ai/discord"> 25 | <img alt="Discord" src="https://img.shields.io/discord/1352337336913887343?color=7389D8&label&logo=discord&logoColor=ffffff" /> 26 | </a> 27 | <br /> 28 | <a href="https://twitter.com/glipsman"> 29 | <img alt="Twitter" src="https://img.shields.io/twitter/url?url=https%3A%2F%2Fx.com%2Fglipsman&label=%40glipsman" /> 30 | </a> 31 | </div> 32 | 33 | <br/> 34 | 35 | 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. 36 | 37 | When Cursor has access to Figma design data, it's **way** better at one-shotting designs accurately than alternative approaches like pasting screenshots. 38 | 39 | <h3><a href="https://www.framelink.ai/docs/quickstart?utm_source=github&utm_medium=referral&utm_campaign=readme">See quickstart instructions →</a></h3> 40 | 41 | ## Demo 42 | 43 | [Watch a demo of building a UI in Cursor with Figma design data](https://youtu.be/6G9yb-LrEqg) 44 | 45 | [](https://youtu.be/6G9yb-LrEqg) 46 | 47 | ## How it works 48 | 49 | 1. Open your IDE's chat (e.g. agent mode in Cursor). 50 | 2. Paste a link to a Figma file, frame, or group. 51 | 3. Ask Cursor to do something with the Figma file—e.g. implement the design. 52 | 4. Cursor will fetch the relevant metadata from Figma and use it to write your code. 53 | 54 | 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. 55 | 56 | Reducing the amount of context provided to the model helps make the AI more accurate and the responses more relevant. 57 | 58 | ## Getting Started 59 | 60 | Many code editors and other AI clients use a configuration file to manage MCP servers. 61 | 62 | The `figma-developer-mcp` server can be configured by adding the following to your configuration file. 63 | 64 | > 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). 65 | 66 | ### MacOS / Linux 67 | 68 | ```json 69 | { 70 | "mcpServers": { 71 | "Framelink Figma MCP": { 72 | "command": "npx", 73 | "args": ["-y", "figma-developer-mcp", "--figma-api-key=YOUR-KEY", "--stdio"] 74 | } 75 | } 76 | } 77 | ``` 78 | 79 | ### Windows 80 | 81 | ```json 82 | { 83 | "mcpServers": { 84 | "Framelink Figma MCP": { 85 | "command": "cmd", 86 | "args": ["/c", "npx", "-y", "figma-developer-mcp", "--figma-api-key=YOUR-KEY", "--stdio"] 87 | } 88 | } 89 | } 90 | ``` 91 | 92 | Or you can set `FIGMA_API_KEY` and `PORT` in the `env` field. 93 | 94 | 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). 95 | 96 | ## Star History 97 | 98 | <a href="https://star-history.com/#GLips/Figma-Context-MCP"><img src="https://api.star-history.com/svg?repos=GLips/Figma-Context-MCP&type=Date" alt="Star History Chart" width="600" /></a> 99 | 100 | ## Learn More 101 | 102 | 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. 103 | ``` -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- ```markdown 1 | # Contributing to Framelink Figma MCP Server 2 | 3 | Thank you for your interest in contributing to the Framelink Figma MCP Server! This guide will help you get started with contributing to this project. 4 | 5 | ## Philosophy 6 | 7 | ### Unix Philosophy for Tools 8 | 9 | This project adheres to the Unix philosophy: tools should have one job and few arguments. We keep our tools as simple as possible to avoid confusing LLMs during calling. Configurable options that are more project-level (i.e., unlikely to change between requests for Figma data) are best set as command line arguments rather than being exposed as tool parameters. 10 | 11 | ### MCP Server Scope 12 | 13 | The MCP server should only focus on **ingesting designs for AI consumption**. This is our core responsibility and what we do best. Additional features are best handled externally by other specialized tools. Examples of features that would be out of scope include: 14 | 15 | - Image conversion, cropping, or other image manipulation 16 | - Syncing design data to CMSes or databases 17 | - Code generation or framework-specific output 18 | - Third-party integrations unrelated to design ingestion 19 | 20 | This focused approach ensures: 21 | 22 | - Clear boundaries and responsibilities 23 | - Better maintainability 24 | - Easier testing and debugging 25 | - More reliable integration with AI tools 26 | 27 | ## Getting Started 28 | 29 | ### Prerequisites 30 | 31 | - Node.js 18.0.0 or higher 32 | - pnpm (recommended package manager) 33 | - A Figma API access token ([how to create one](https://help.figma.com/hc/en-us/articles/8085703771159-Manage-personal-access-tokens)) 34 | 35 | ### Development Setup 36 | 37 | 1. **Clone the repository:** 38 | 39 | ```bash 40 | git clone https://github.com/GLips/Figma-Context-MCP.git 41 | cd Figma-Context-MCP 42 | ``` 43 | 44 | 2. **Install dependencies:** 45 | 46 | ```bash 47 | pnpm install 48 | ``` 49 | 50 | 3. **Set up environment variables:** 51 | Create a `.env` file in the root directory: 52 | 53 | ``` 54 | FIGMA_API_KEY=your_figma_api_key_here 55 | ``` 56 | 57 | 4. **Build the project:** 58 | 59 | ```bash 60 | pnpm build 61 | ``` 62 | 63 | 5. **Run tests:** 64 | 65 | ```bash 66 | pnpm test 67 | ``` 68 | 69 | 6. **Start development server:** 70 | 71 | ```bash 72 | pnpm dev 73 | ``` 74 | 75 | 7. **Test locally:** 76 | 77 | `pnpm dev` will start a local server you can connect to via Streamable HTTP. To connect to it, you can add the following configuration to your MCP JSON config file. Note, some MCP clients use a different format. [See the Framelink docs](https://www.framelink.ai/docs/quickstart#configure-ide) for more information on specific clients. 78 | 79 | ```bash 80 | "mcpServers": { 81 | "Framelink Figma MCP - Local StreamableHTTP": { 82 | "url": "http://localhost:3333/mcp" 83 | }, 84 | } 85 | ``` 86 | 87 | ### Development Commands 88 | 89 | - `pnpm dev` - Start development server with watch mode 90 | - `pnpm build` - Build the project 91 | - `pnpm type-check` - Run TypeScript type checking 92 | - `pnpm test` - Run tests 93 | - `pnpm lint` - Run ESLint 94 | - `pnpm format` - Format code with Prettier 95 | - `pnpm inspect` - Run MCP inspector for debugging 96 | 97 | ## Code Style and Standards 98 | 99 | ### TypeScript 100 | 101 | - Use TypeScript for all new code 102 | - Follow TypeScript settings as defined in `tsconfig.json` 103 | 104 | ### Code Formatting 105 | 106 | - Use Prettier for code formatting (run `pnpm format`) 107 | - Use ESLint for code linting (run `pnpm lint`) 108 | - Follow existing code patterns and conventions 109 | 110 | ## Project Structure 111 | 112 | ``` 113 | src/ 114 | ├── cli.ts # Command line interface 115 | ├── config.ts # Configuration management 116 | ├── index.ts # Main entry point 117 | ├── server.ts # MCP server implementation 118 | ├── mcp/ # MCP-specific code 119 | │ ├── index.ts 120 | │ └── tools/ # MCP tools 121 | ├── services/ # Core business logic 122 | ├── transformers/ # Data transformation logic 123 | ├── utils/ # Utility functions 124 | └── tests/ # Test files 125 | ``` 126 | 127 | ## Contributing Guidelines 128 | 129 | ### Before You Start 130 | 131 | 1. Check existing issues and PRs to avoid duplicates 132 | 2. For major changes, create an issue first to discuss the approach 133 | 3. Keep changes focused and atomic 134 | 135 | ### Pull Request Process 136 | 137 | 1. **Fork the repository** and create a feature branch 138 | 2. **Make your changes** following the code style guidelines 139 | 3. **Add tests** for new functionality 140 | 4. **Run the test suite** to ensure nothing is broken: 141 | ```bash 142 | pnpm test 143 | pnpm type-check 144 | pnpm lint 145 | ``` 146 | 5. **Update documentation** if needed 147 | 6. **Submit a pull request** with a clear description that includes context and motivation for the changes 148 | 149 | ### Commit Messages 150 | 151 | - Use clear, descriptive commit messages 152 | - Follow conventional commit format when possible 153 | - Reference issue numbers when applicable 154 | 155 | ### What We're Looking For 156 | 157 | - **New features** - Expand the server's capabilities to support more Figma features 158 | - **Bug fixes** - Help us improve reliability 159 | - **Performance improvements** - Make the server faster 160 | - **Documentation improvements** - Help others understand the project 161 | - **Test coverage** - Improve our test suite 162 | - **Code quality** - Refactoring and clean-up 163 | 164 | ### What We're Not Looking For 165 | 166 | - Features that go beyond design ingestion (see Philosophy section) 167 | - Breaking changes without discussion 168 | - Code that doesn't follow our style guidelines 169 | - Features without tests 170 | 171 | ## Getting Help 172 | 173 | - **Documentation**: Check the [Framelink docs](https://framelink.ai/docs) 174 | - **Issues**: Search existing issues or create a new one 175 | - **Discord**: Join our [Discord community](https://framelink.ai/discord) 176 | 177 | ## License 178 | 179 | By contributing to this project, you agree that your contributions will be licensed under the MIT License. 180 | ``` -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- ```yaml 1 | # These are supported funding model platforms 2 | 3 | github: GLips 4 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | export { getFigmaDataTool } from "./get-figma-data-tool.js"; 2 | export { downloadFigmaImagesTool } from "./download-figma-images-tool.js"; 3 | export type { DownloadImagesParams } from "./download-figma-images-tool.js"; 4 | export type { GetFigmaDataParams } from "./get-figma-data-tool.js"; 5 | ``` -------------------------------------------------------------------------------- /src/mcp-server.ts: -------------------------------------------------------------------------------- ```typescript 1 | // Re-export server-related functionality for users who want MCP server capabilities 2 | export { createServer } from "./mcp/index.js"; 3 | export type { FigmaService } from "./services/figma.js"; 4 | export { getServerConfig } from "./config.js"; 5 | export { startServer, startHttpServer, stopHttpServer } from "./server.js"; 6 | ``` -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "$schema": "https://unpkg.com/@changesets/[email protected]/schema.json", 3 | "changelog": [ 4 | "@changesets/changelog-github", 5 | { "repo": "GLips/Figma-Context-MCP" } 6 | ], 7 | "commit": true, 8 | "fixed": [], 9 | "linked": [], 10 | "access": "public", 11 | "baseBranch": "main", 12 | "updateInternalDependencies": "patch", 13 | "ignore": [] 14 | } 15 | ``` -------------------------------------------------------------------------------- /src/tests/benchmark.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import yaml from "js-yaml"; 2 | 3 | describe("Benchmarks", () => { 4 | const data = { 5 | name: "John Doe", 6 | age: 30, 7 | email: "[email protected]", 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/bin.ts: -------------------------------------------------------------------------------- ```typescript 1 | #!/usr/bin/env node 2 | 3 | import { config } from "dotenv"; 4 | import { resolve } from "path"; 5 | import { startServer } from "./server.js"; 6 | 7 | // Load .env from the current working directory 8 | config({ path: resolve(process.cwd(), ".env") }); 9 | 10 | // Start the server immediately - this file is only for execution 11 | startServer().catch((error) => { 12 | console.error("Failed to start server:", error); 13 | process.exit(1); 14 | }); 15 | ``` -------------------------------------------------------------------------------- /.github/actions/setup/action.yml: -------------------------------------------------------------------------------- ```yaml 1 | name: "Setup and install" 2 | description: "Common setup steps for Actions" 3 | 4 | runs: 5 | using: composite 6 | steps: 7 | - name: Install pnpm 8 | uses: pnpm/action-setup@v4 9 | with: 10 | version: 10.10.0 11 | - name: Install Node.js v20 12 | uses: actions/setup-node@v4 13 | with: 14 | node-version: 20.17.0 15 | cache: "pnpm" 16 | 17 | - name: Install PNPM Dependencies 18 | shell: bash 19 | run: pnpm install 20 | ``` -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | // Re-export extractor types only 2 | export type { SimplifiedDesign } from "./extractors/types.js"; 3 | 4 | // Flexible extractor system 5 | export type { 6 | ExtractorFn, 7 | TraversalContext, 8 | TraversalOptions, 9 | GlobalVars, 10 | StyleTypes, 11 | } from "./extractors/index.js"; 12 | 13 | export { 14 | extractFromDesign, 15 | simplifyRawFigmaObject, 16 | layoutExtractor, 17 | textExtractor, 18 | visualsExtractor, 19 | componentExtractor, 20 | allExtractors, 21 | layoutAndText, 22 | contentOnly, 23 | visualsOnly, 24 | layoutOnly, 25 | } from "./extractors/index.js"; 26 | ``` -------------------------------------------------------------------------------- /tsup.config.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { defineConfig } from "tsup"; 2 | 3 | const isDev = process.env.npm_lifecycle_event === "dev"; 4 | const packageVersion = process.env.npm_package_version; 5 | 6 | export default defineConfig({ 7 | clean: true, 8 | entry: ["src/index.ts", "src/bin.ts", "src/mcp-server.ts"], 9 | format: ["esm"], 10 | minify: !isDev, 11 | target: "esnext", 12 | outDir: "dist", 13 | outExtension: ({ format }) => ({ 14 | js: ".js", 15 | }), 16 | onSuccess: isDev ? "node dist/bin.js" : undefined, 17 | define: { 18 | "process.env.NPM_PACKAGE_VERSION": JSON.stringify(packageVersion), 19 | }, 20 | }); 21 | ``` -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- ```javascript 1 | export default { 2 | preset: "ts-jest/presets/default-esm", 3 | extensionsToTreatAsEsm: [".ts"], 4 | testEnvironment: "node", 5 | transform: { 6 | "^.+\\.tsx?$": [ 7 | "ts-jest", 8 | { 9 | useESM: true, 10 | tsconfig: { 11 | module: "ESNext", 12 | verbatimModuleSyntax: false, 13 | }, 14 | }, 15 | ], 16 | }, 17 | moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json", "node"], 18 | moduleNameMapper: { 19 | "^~/(.*)\.js$": "<rootDir>/src/$1.ts", 20 | "^~/(.*)$": "<rootDir>/src/$1", 21 | "^(\\.{1,2}/.*)\\.js$": "$1", 22 | }, 23 | modulePaths: ["<rootDir>/src"], 24 | }; 25 | ``` -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./", 4 | "rootDir": "src", 5 | "paths": { 6 | "~/*": ["./src/*"] 7 | }, 8 | 9 | "target": "ES2022", 10 | "lib": ["ES2022", "DOM"], 11 | "module": "NodeNext", 12 | "moduleResolution": "NodeNext", 13 | "resolveJsonModule": true, 14 | "verbatimModuleSyntax": true, 15 | "allowJs": true, 16 | "checkJs": true, 17 | 18 | /* EMIT RULES */ 19 | "outDir": "./dist", 20 | "declaration": true, 21 | "declarationMap": true, 22 | "sourceMap": true, 23 | "removeComments": true, 24 | 25 | "strict": true, 26 | "esModuleInterop": true, 27 | "skipLibCheck": true, 28 | "forceConsistentCasingInFileNames": true 29 | }, 30 | "include": ["src/**/*"] 31 | } 32 | ``` -------------------------------------------------------------------------------- /.github/changeset-version.js: -------------------------------------------------------------------------------- ```javascript 1 | // ORIGINALLY FROM CREATE-T3-APP: 2 | // https://github.com/t3-oss/create-t3-app/blob/main/.github/changeset-version.js 3 | 4 | import { execSync } from "child_process"; 5 | // This script is used by the `release.yml` workflow to update the version of the packages being released. 6 | // The standard step is only to run `changeset version` but this does not update the package-lock.json file. 7 | // So we also run `npm install`, which does this update. 8 | // This is a workaround until this is handled automatically by `changeset version`. 9 | // See https://github.com/changesets/changesets/issues/421. 10 | execSync("pnpm exec changeset version"); 11 | execSync("pnpm install --lockfile-only"); 12 | ``` -------------------------------------------------------------------------------- /.github/changeset-beta-version.js: -------------------------------------------------------------------------------- ```javascript 1 | // BASED ON CREATE-T3-APP APPROACH: 2 | // https://github.com/t3-oss/create-t3-app/blob/main/.github/changeset-version.js 3 | 4 | import { execSync } from "child_process"; 5 | 6 | // This script is used by the `beta-release.yml` workflow to update the version of packages for beta releases. 7 | // It enters prerelease mode, runs changeset version, and updates the package-lock.json file. 8 | // This ensures beta releases are properly tagged and don't interfere with main releases. 9 | 10 | // Enter prerelease mode for beta 11 | execSync("pnpm exec changeset pre enter beta"); 12 | // Version the packages 13 | execSync("pnpm exec changeset version"); 14 | // Update lockfile 15 | execSync("pnpm install --lockfile-only"); 16 | ``` -------------------------------------------------------------------------------- /src/extractors/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | // Types 2 | export type { 3 | ExtractorFn, 4 | TraversalContext, 5 | TraversalOptions, 6 | GlobalVars, 7 | StyleTypes, 8 | } from "./types.js"; 9 | 10 | // Core traversal function 11 | export { extractFromDesign } from "./node-walker.js"; 12 | 13 | // Design-level extraction (unified nodes + components) 14 | export { simplifyRawFigmaObject } from "./design-extractor.js"; 15 | 16 | // Built-in extractors and afterChildren helpers 17 | export { 18 | layoutExtractor, 19 | textExtractor, 20 | visualsExtractor, 21 | componentExtractor, 22 | // Convenience combinations 23 | allExtractors, 24 | layoutAndText, 25 | contentOnly, 26 | visualsOnly, 27 | layoutOnly, 28 | // afterChildren helpers 29 | collapseSvgContainers, 30 | SVG_ELIGIBLE_TYPES, 31 | } from "./built-in.js"; 32 | ``` -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- ```javascript 1 | import js from "@eslint/js"; 2 | import tseslint from "@typescript-eslint/eslint-plugin"; 3 | import tsparser from "@typescript-eslint/parser"; 4 | import prettier from "eslint-config-prettier"; 5 | 6 | export default [ 7 | js.configs.recommended, 8 | { 9 | files: ["**/*.ts", "**/*.tsx"], 10 | languageOptions: { 11 | parser: tsparser, 12 | parserOptions: { 13 | ecmaVersion: 2022, 14 | sourceType: "module", 15 | }, 16 | }, 17 | plugins: { 18 | "@typescript-eslint": tseslint, 19 | }, 20 | rules: { 21 | ...tseslint.configs.recommended.rules, 22 | "@typescript-eslint/explicit-function-return-type": "off", 23 | "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], 24 | "@typescript-eslint/no-explicit-any": "warn", 25 | }, 26 | }, 27 | { 28 | files: ["**/*.ts", "**/*.tsx"], 29 | rules: prettier.rules, 30 | }, 31 | { 32 | ignores: ["dist/**", "node_modules/**"], 33 | }, 34 | ]; 35 | ``` -------------------------------------------------------------------------------- /.github/workflows/beta-release.yml: -------------------------------------------------------------------------------- ```yaml 1 | # Beta Release Workflow 2 | # Triggers when the beta branch is pushed and publishes packages with a -beta tag 3 | 4 | name: Beta Release 5 | 6 | on: 7 | push: 8 | branches: 9 | - beta 10 | 11 | jobs: 12 | beta-release: 13 | if: ${{ github.repository_owner == 'GLips' }} 14 | name: Create a beta release 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | - uses: ./.github/actions/setup 21 | 22 | - name: Check for errors 23 | run: pnpm type-check 24 | 25 | - name: Build the package 26 | run: pnpm build 27 | 28 | - name: Create Version and Publish Beta 29 | id: changesets 30 | uses: changesets/action@v1 31 | with: 32 | commit: "chore(beta): version packages" 33 | title: "chore(beta): version packages" 34 | version: node .github/changeset-beta-version.js 35 | publish: npx changeset publish 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | NPM_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }} 39 | NODE_ENV: "production" 40 | ``` -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- ```yaml 1 | # Originally inspired by create-t3-app 2 | # https://github.com/t3-oss/create-t3-app/blob/main/.github/workflows/release.yml 3 | 4 | name: Release 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | release: 13 | if: ${{ github.repository_owner == 'GLips' }} 14 | name: Create a PR for release workflow 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v4 18 | with: 19 | fetch-depth: 0 20 | - uses: ./.github/actions/setup 21 | 22 | - name: Check for errors 23 | run: pnpm type-check 24 | 25 | - name: Build the package 26 | run: pnpm build 27 | 28 | - name: Create Version PR or Publish to NPM 29 | id: changesets 30 | uses: changesets/action@v1 31 | with: 32 | commit: "chore(release): version packages" 33 | title: "chore(release): version packages" 34 | version: node .github/changeset-version.js 35 | publish: npx changeset publish 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | NPM_TOKEN: ${{ secrets.NPM_ACCESS_TOKEN }} 39 | NODE_ENV: "production" 40 | ``` -------------------------------------------------------------------------------- /src/utils/logger.ts: -------------------------------------------------------------------------------- ```typescript 1 | import fs from "fs"; 2 | 3 | export const Logger = { 4 | isHTTP: false, 5 | log: (...args: any[]) => { 6 | if (Logger.isHTTP) { 7 | console.log("[INFO]", ...args); 8 | } else { 9 | console.error("[INFO]", ...args); 10 | } 11 | }, 12 | error: (...args: any[]) => { 13 | console.error("[ERROR]", ...args); 14 | }, 15 | }; 16 | 17 | export function writeLogs(name: string, value: any): void { 18 | if (process.env.NODE_ENV !== "development") return; 19 | 20 | try { 21 | const logsDir = "logs"; 22 | const logPath = `${logsDir}/${name}`; 23 | 24 | // Check if we can write to the current directory 25 | fs.accessSync(process.cwd(), fs.constants.W_OK); 26 | 27 | // Create logs directory if it doesn't exist 28 | if (!fs.existsSync(logsDir)) { 29 | fs.mkdirSync(logsDir, { recursive: true }); 30 | } 31 | 32 | fs.writeFileSync(logPath, JSON.stringify(value, null, 2)); 33 | Logger.log(`Debug log written to: ${logPath}`); 34 | } catch (error) { 35 | const errorMessage = error instanceof Error ? error.message : String(error); 36 | Logger.log(`Failed to write logs to ${name}: ${errorMessage}`); 37 | } 38 | } 39 | ``` -------------------------------------------------------------------------------- /server.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "$schema": "https://static.modelcontextprotocol.io/schemas/2025-07-09/server.schema.json", 3 | "name": "io.github.GLips/Figma-Context-MCP", 4 | "description": "Give your coding agent access to your Figma data. Implement designs in any framework in one-shot.", 5 | "status": "active", 6 | "repository": { 7 | "url": "https://github.com/GLips/Figma-Context-MCP", 8 | "source": "github" 9 | }, 10 | "version": "0.5.2", 11 | "packages": [ 12 | { 13 | "registry_type": "npm", 14 | "registry_base_url": "https://registry.npmjs.org", 15 | "identifier": "figma-developer-mcp", 16 | "version": "0.5.2", 17 | "transport": { 18 | "type": "stdio" 19 | }, 20 | "package_arguments": [ 21 | { 22 | "type": "positional", 23 | "value": "--stdio" 24 | } 25 | ], 26 | "environment_variables": [ 27 | { 28 | "description": "Your Figma Personal Access Token, learn more here: https://www.figma.com/developers/api#access-tokens", 29 | "is_required": true, 30 | "format": "string", 31 | "is_secret": true, 32 | "name": "FIGMA_API_KEY" 33 | }, 34 | { 35 | "name": "NODE_ENV", 36 | "description": "Start the server in stdio mode, keep as CLI", 37 | "default": "cli" 38 | } 39 | ] 40 | } 41 | ] 42 | } 43 | ``` -------------------------------------------------------------------------------- /src/transformers/component.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { Component, ComponentPropertyType, ComponentSet } from "@figma/rest-api-spec"; 2 | 3 | export interface ComponentProperties { 4 | name: string; 5 | value: string; 6 | type: ComponentPropertyType; 7 | } 8 | 9 | export interface SimplifiedComponentDefinition { 10 | id: string; 11 | key: string; 12 | name: string; 13 | componentSetId?: string; 14 | } 15 | 16 | export interface SimplifiedComponentSetDefinition { 17 | id: string; 18 | key: string; 19 | name: string; 20 | description?: string; 21 | } 22 | 23 | /** 24 | * Remove unnecessary component properties and convert to simplified format. 25 | */ 26 | export function simplifyComponents( 27 | aggregatedComponents: Record<string, Component>, 28 | ): Record<string, SimplifiedComponentDefinition> { 29 | return Object.fromEntries( 30 | Object.entries(aggregatedComponents).map(([id, comp]) => [ 31 | id, 32 | { 33 | id, 34 | key: comp.key, 35 | name: comp.name, 36 | componentSetId: comp.componentSetId, 37 | }, 38 | ]), 39 | ); 40 | } 41 | 42 | /** 43 | * Remove unnecessary component set properties and convert to simplified format. 44 | */ 45 | export function simplifyComponentSets( 46 | aggregatedComponentSets: Record<string, ComponentSet>, 47 | ): Record<string, SimplifiedComponentSetDefinition> { 48 | return Object.fromEntries( 49 | Object.entries(aggregatedComponentSets).map(([id, set]) => [ 50 | id, 51 | { 52 | id, 53 | key: set.key, 54 | name: set.name, 55 | description: set.description, 56 | }, 57 | ]), 58 | ); 59 | } 60 | ``` -------------------------------------------------------------------------------- /src/transformers/text.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { Node as FigmaDocumentNode } from "@figma/rest-api-spec"; 2 | import { hasValue, isTruthy } from "~/utils/identity.js"; 3 | 4 | export type SimplifiedTextStyle = Partial<{ 5 | fontFamily: string; 6 | fontWeight: number; 7 | fontSize: number; 8 | lineHeight: string; 9 | letterSpacing: string; 10 | textCase: string; 11 | textAlignHorizontal: string; 12 | textAlignVertical: string; 13 | }>; 14 | 15 | export function isTextNode( 16 | n: FigmaDocumentNode, 17 | ): n is Extract<FigmaDocumentNode, { type: "TEXT" }> { 18 | return n.type === "TEXT"; 19 | } 20 | 21 | export function hasTextStyle( 22 | n: FigmaDocumentNode, 23 | ): n is FigmaDocumentNode & { style: Extract<FigmaDocumentNode, { style: any }>["style"] } { 24 | return hasValue("style", n) && Object.keys(n.style).length > 0; 25 | } 26 | 27 | // Keep other simple properties directly 28 | export function extractNodeText(n: FigmaDocumentNode) { 29 | if (hasValue("characters", n, isTruthy)) { 30 | return n.characters; 31 | } 32 | } 33 | 34 | export function extractTextStyle(n: FigmaDocumentNode) { 35 | if (hasTextStyle(n)) { 36 | const style = n.style; 37 | const textStyle: SimplifiedTextStyle = { 38 | fontFamily: style.fontFamily, 39 | fontWeight: style.fontWeight, 40 | fontSize: style.fontSize, 41 | lineHeight: 42 | "lineHeightPx" in style && style.lineHeightPx && style.fontSize 43 | ? `${style.lineHeightPx / style.fontSize}em` 44 | : undefined, 45 | letterSpacing: 46 | style.letterSpacing && style.letterSpacing !== 0 && style.fontSize 47 | ? `${(style.letterSpacing / style.fontSize) * 100}%` 48 | : undefined, 49 | textCase: style.textCase, 50 | textAlignHorizontal: style.textAlignHorizontal, 51 | textAlignVertical: style.textAlignVertical, 52 | }; 53 | return textStyle; 54 | } 55 | } 56 | ``` -------------------------------------------------------------------------------- /src/mcp/index.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 | import { FigmaService, type FigmaAuthOptions } from "../services/figma.js"; 3 | import { Logger } from "../utils/logger.js"; 4 | import { 5 | downloadFigmaImagesTool, 6 | getFigmaDataTool, 7 | type DownloadImagesParams, 8 | type GetFigmaDataParams, 9 | } from "./tools/index.js"; 10 | 11 | const serverInfo = { 12 | name: "Figma MCP Server", 13 | version: process.env.NPM_PACKAGE_VERSION ?? "unknown", 14 | }; 15 | 16 | type CreateServerOptions = { 17 | isHTTP?: boolean; 18 | outputFormat?: "yaml" | "json"; 19 | skipImageDownloads?: boolean; 20 | }; 21 | 22 | function createServer( 23 | authOptions: FigmaAuthOptions, 24 | { isHTTP = false, outputFormat = "yaml", skipImageDownloads = false }: CreateServerOptions = {}, 25 | ) { 26 | const server = new McpServer(serverInfo); 27 | const figmaService = new FigmaService(authOptions); 28 | registerTools(server, figmaService, { outputFormat, skipImageDownloads }); 29 | 30 | Logger.isHTTP = isHTTP; 31 | 32 | return server; 33 | } 34 | 35 | function registerTools( 36 | server: McpServer, 37 | figmaService: FigmaService, 38 | options: { 39 | outputFormat: "yaml" | "json"; 40 | skipImageDownloads: boolean; 41 | }, 42 | ): void { 43 | // Register get_figma_data tool 44 | server.tool( 45 | getFigmaDataTool.name, 46 | getFigmaDataTool.description, 47 | getFigmaDataTool.parameters, 48 | (params: GetFigmaDataParams) => 49 | getFigmaDataTool.handler(params, figmaService, options.outputFormat), 50 | ); 51 | 52 | // Register download_figma_images tool if CLI flag or env var is not set 53 | if (!options.skipImageDownloads) { 54 | server.tool( 55 | downloadFigmaImagesTool.name, 56 | downloadFigmaImagesTool.description, 57 | downloadFigmaImagesTool.parameters, 58 | (params: DownloadImagesParams) => downloadFigmaImagesTool.handler(params, figmaService), 59 | ); 60 | } 61 | } 62 | 63 | export { createServer }; 64 | ``` -------------------------------------------------------------------------------- /src/tests/integration.test.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { createServer } from "../mcp/index.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 | import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 8 | 9 | config(); 10 | 11 | describe("Figma MCP Server Tests", () => { 12 | let server: McpServer; 13 | let client: Client; 14 | let figmaApiKey: string; 15 | let figmaFileKey: string; 16 | 17 | beforeAll(async () => { 18 | figmaApiKey = process.env.FIGMA_API_KEY || ""; 19 | if (!figmaApiKey) { 20 | throw new Error("FIGMA_API_KEY is not set in environment variables"); 21 | } 22 | 23 | figmaFileKey = process.env.FIGMA_FILE_KEY || ""; 24 | if (!figmaFileKey) { 25 | throw new Error("FIGMA_FILE_KEY is not set in environment variables"); 26 | } 27 | 28 | server = createServer({ 29 | figmaApiKey, 30 | figmaOAuthToken: "", 31 | useOAuth: false, 32 | }); 33 | 34 | client = new Client( 35 | { 36 | name: "figma-test-client", 37 | version: "1.0.0", 38 | }, 39 | { 40 | capabilities: { 41 | tools: {}, 42 | }, 43 | }, 44 | ); 45 | 46 | const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); 47 | 48 | await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]); 49 | }); 50 | 51 | afterAll(async () => { 52 | await client.close(); 53 | }); 54 | 55 | describe("Get Figma Data", () => { 56 | it("should be able to get Figma file data", async () => { 57 | const args: any = { 58 | fileKey: figmaFileKey, 59 | }; 60 | 61 | const result = await client.request( 62 | { 63 | method: "tools/call", 64 | params: { 65 | name: "get_figma_data", 66 | arguments: args, 67 | }, 68 | }, 69 | CallToolResultSchema, 70 | ); 71 | 72 | const content = result.content[0].text as string; 73 | const parsed = yaml.load(content); 74 | 75 | expect(parsed).toBeDefined(); 76 | }, 60000); 77 | }); 78 | }); 79 | ``` -------------------------------------------------------------------------------- /src/transformers/effects.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { 2 | DropShadowEffect, 3 | InnerShadowEffect, 4 | BlurEffect, 5 | Node as FigmaDocumentNode, 6 | } from "@figma/rest-api-spec"; 7 | import { formatRGBAColor } from "~/transformers/style.js"; 8 | import { hasValue } from "~/utils/identity.js"; 9 | 10 | export type SimplifiedEffects = { 11 | boxShadow?: string; 12 | filter?: string; 13 | backdropFilter?: string; 14 | textShadow?: string; 15 | }; 16 | 17 | export function buildSimplifiedEffects(n: FigmaDocumentNode): SimplifiedEffects { 18 | if (!hasValue("effects", n)) return {}; 19 | const effects = n.effects.filter((e) => e.visible); 20 | 21 | // Handle drop and inner shadows (both go into CSS box-shadow) 22 | const dropShadows = effects 23 | .filter((e): e is DropShadowEffect => e.type === "DROP_SHADOW") 24 | .map(simplifyDropShadow); 25 | 26 | const innerShadows = effects 27 | .filter((e): e is InnerShadowEffect => e.type === "INNER_SHADOW") 28 | .map(simplifyInnerShadow); 29 | 30 | const boxShadow = [...dropShadows, ...innerShadows].join(", "); 31 | 32 | // Handle blur effects - separate by CSS property 33 | // Layer blurs use the CSS 'filter' property 34 | const filterBlurValues = effects 35 | .filter((e): e is BlurEffect => e.type === "LAYER_BLUR") 36 | .map(simplifyBlur) 37 | .join(" "); 38 | 39 | // Background blurs use the CSS 'backdrop-filter' property 40 | const backdropFilterValues = effects 41 | .filter((e): e is BlurEffect => e.type === "BACKGROUND_BLUR") 42 | .map(simplifyBlur) 43 | .join(" "); 44 | 45 | const result: SimplifiedEffects = {}; 46 | 47 | if (boxShadow) { 48 | if (n.type === "TEXT") { 49 | result.textShadow = boxShadow; 50 | } else { 51 | result.boxShadow = boxShadow; 52 | } 53 | } 54 | if (filterBlurValues) result.filter = filterBlurValues; 55 | if (backdropFilterValues) result.backdropFilter = backdropFilterValues; 56 | 57 | return result; 58 | } 59 | 60 | function simplifyDropShadow(effect: DropShadowEffect) { 61 | return `${effect.offset.x}px ${effect.offset.y}px ${effect.radius}px ${effect.spread ?? 0}px ${formatRGBAColor(effect.color)}`; 62 | } 63 | 64 | function simplifyInnerShadow(effect: InnerShadowEffect) { 65 | return `inset ${effect.offset.x}px ${effect.offset.y}px ${effect.radius}px ${effect.spread ?? 0}px ${formatRGBAColor(effect.color)}`; 66 | } 67 | 68 | function simplifyBlur(effect: BlurEffect) { 69 | return `blur(${effect.radius}px)`; 70 | } 71 | ``` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- ```json 1 | { 2 | "name": "figma-developer-mcp", 3 | "version": "0.6.4", 4 | "mcpName": "io.github.GLips/Figma-Context-MCP", 5 | "description": "Give your coding agent access to your Figma data. Implement designs in any framework in one-shot.", 6 | "type": "module", 7 | "main": "dist/index.js", 8 | "bin": { 9 | "figma-developer-mcp": "dist/bin.js" 10 | }, 11 | "files": [ 12 | "dist", 13 | "README.md" 14 | ], 15 | "scripts": { 16 | "build": "tsup --dts", 17 | "type-check": "tsc --noEmit", 18 | "test": "jest", 19 | "start": "node dist/bin.js", 20 | "start:cli": "cross-env NODE_ENV=cli node dist/bin.js", 21 | "start:http": "node dist/bin.js", 22 | "dev": "cross-env NODE_ENV=development tsup --watch", 23 | "dev:cli": "cross-env NODE_ENV=development tsup --watch -- --stdio", 24 | "lint": "eslint .", 25 | "format": "prettier --write \"src/**/*.ts\"", 26 | "inspect": "pnpx @modelcontextprotocol/inspector", 27 | "prepack": "pnpm build", 28 | "changeset": "changeset add", 29 | "version": "changeset version && git add -A", 30 | "beta:start": "changeset pre enter beta", 31 | "beta:end": "changeset pre exit", 32 | "beta:version": "changeset version && pnpm install --lockfile-only", 33 | "beta:publish": "changeset publish", 34 | "prerelease": "pnpm build", 35 | "release": "changeset publish && git push --follow-tags", 36 | "pub:release": "pnpm build && npm publish", 37 | "pub:release:beta": "pnpm build && npm publish --tag beta" 38 | }, 39 | "engines": { 40 | "node": ">=18.0.0" 41 | }, 42 | "packageManager": "[email protected]", 43 | "repository": { 44 | "type": "git", 45 | "url": "git+https://github.com/GLips/Figma-Context-MCP.git" 46 | }, 47 | "homepage": "https://www.framelink.ai", 48 | "keywords": [ 49 | "figma", 50 | "mcp", 51 | "typescript" 52 | ], 53 | "author": "", 54 | "license": "MIT", 55 | "dependencies": { 56 | "@figma/rest-api-spec": "^0.33.0", 57 | "@modelcontextprotocol/sdk": "^1.10.2", 58 | "@types/yargs": "^17.0.33", 59 | "cross-env": "^7.0.3", 60 | "dotenv": "^16.4.7", 61 | "express": "^4.21.2", 62 | "js-yaml": "^4.1.0", 63 | "remeda": "^2.20.1", 64 | "sharp": "^0.34.3", 65 | "yargs": "^17.7.2", 66 | "zod": "^3.24.2" 67 | }, 68 | "devDependencies": { 69 | "@changesets/changelog-github": "^0.5.1", 70 | "@changesets/cli": "^2.29.2", 71 | "@eslint/js": "^9.33.0", 72 | "@types/express": "^5.0.0", 73 | "@types/jest": "^29.5.14", 74 | "@types/js-yaml": "^4.0.9", 75 | "@types/node": "^20.17.0", 76 | "@typescript-eslint/eslint-plugin": "^8.24.0", 77 | "@typescript-eslint/parser": "^8.24.0", 78 | "eslint": "^9.20.1", 79 | "eslint-config-prettier": "^10.0.1", 80 | "jest": "^29.7.0", 81 | "prettier": "^3.5.0", 82 | "ts-jest": "^29.2.5", 83 | "tsup": "^8.4.0", 84 | "tsx": "^4.19.2", 85 | "typescript": "^5.7.3" 86 | } 87 | } 88 | ``` -------------------------------------------------------------------------------- /src/utils/identity.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { 2 | Rectangle, 3 | HasLayoutTrait, 4 | StrokeWeights, 5 | HasFramePropertiesTrait, 6 | } from "@figma/rest-api-spec"; 7 | import { isTruthy } from "remeda"; 8 | import type { CSSHexColor, CSSRGBAColor } from "~/transformers/style.js"; 9 | 10 | export { isTruthy }; 11 | 12 | export function hasValue<K extends PropertyKey, T>( 13 | key: K, 14 | obj: unknown, 15 | typeGuard?: (val: unknown) => val is T, 16 | ): obj is Record<K, T> { 17 | const isObject = typeof obj === "object" && obj !== null; 18 | if (!isObject || !(key in obj)) return false; 19 | const val = (obj as Record<K, unknown>)[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 | /** 47 | * Checks if: 48 | * 1. A node is a child to an auto layout frame 49 | * 2. The child adheres to the auto layout rules—i.e. it's not absolutely positioned 50 | * 51 | * @param node - The node to check. 52 | * @param parent - The parent node. 53 | * @returns True if the node is a child of an auto layout frame, false otherwise. 54 | */ 55 | export function isInAutoLayoutFlow(node: unknown, parent: unknown): boolean { 56 | const autoLayoutModes = ["HORIZONTAL", "VERTICAL"]; 57 | return ( 58 | isFrame(parent) && 59 | autoLayoutModes.includes(parent.layoutMode ?? "NONE") && 60 | isLayout(node) && 61 | node.layoutPositioning !== "ABSOLUTE" 62 | ); 63 | } 64 | 65 | export function isStrokeWeights(val: unknown): val is StrokeWeights { 66 | return ( 67 | typeof val === "object" && 68 | val !== null && 69 | "top" in val && 70 | "right" in val && 71 | "bottom" in val && 72 | "left" in val 73 | ); 74 | } 75 | 76 | export function isRectangle<T, K extends string>( 77 | key: K, 78 | obj: T, 79 | ): obj is T & { [P in K]: Rectangle } { 80 | const recordObj = obj as Record<K, unknown>; 81 | return ( 82 | typeof obj === "object" && 83 | !!obj && 84 | key in recordObj && 85 | typeof recordObj[key] === "object" && 86 | !!recordObj[key] && 87 | "x" in recordObj[key] && 88 | "y" in recordObj[key] && 89 | "width" in recordObj[key] && 90 | "height" in recordObj[key] 91 | ); 92 | } 93 | 94 | export function isRectangleCornerRadii(val: unknown): val is number[] { 95 | return Array.isArray(val) && val.length === 4 && val.every((v) => typeof v === "number"); 96 | } 97 | 98 | export function isCSSColorValue(val: unknown): val is CSSRGBAColor | CSSHexColor { 99 | return typeof val === "string" && (val.startsWith("#") || val.startsWith("rgba")); 100 | } 101 | ``` -------------------------------------------------------------------------------- /src/extractors/design-extractor.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { 2 | GetFileResponse, 3 | GetFileNodesResponse, 4 | Node as FigmaDocumentNode, 5 | Component, 6 | ComponentSet, 7 | Style, 8 | } from "@figma/rest-api-spec"; 9 | import { simplifyComponents, simplifyComponentSets } from "~/transformers/component.js"; 10 | import { isVisible } from "~/utils/common.js"; 11 | import type { ExtractorFn, TraversalOptions, SimplifiedDesign, TraversalContext } from "./types.js"; 12 | import { extractFromDesign } from "./node-walker.js"; 13 | 14 | /** 15 | * Extract a complete SimplifiedDesign from raw Figma API response using extractors. 16 | */ 17 | export function simplifyRawFigmaObject( 18 | apiResponse: GetFileResponse | GetFileNodesResponse, 19 | nodeExtractors: ExtractorFn[], 20 | options: TraversalOptions = {}, 21 | ): SimplifiedDesign { 22 | // Extract components, componentSets, and raw nodes from API response 23 | const { metadata, rawNodes, components, componentSets, extraStyles } = 24 | parseAPIResponse(apiResponse); 25 | 26 | // Process nodes using the flexible extractor system 27 | const globalVars: TraversalContext["globalVars"] = { styles: {}, extraStyles }; 28 | const { nodes: extractedNodes, globalVars: finalGlobalVars } = extractFromDesign( 29 | rawNodes, 30 | nodeExtractors, 31 | options, 32 | globalVars, 33 | ); 34 | 35 | // Return complete design 36 | return { 37 | ...metadata, 38 | nodes: extractedNodes, 39 | components: simplifyComponents(components), 40 | componentSets: simplifyComponentSets(componentSets), 41 | globalVars: { styles: finalGlobalVars.styles }, 42 | }; 43 | } 44 | 45 | /** 46 | * Parse the raw Figma API response to extract metadata, nodes, and components. 47 | */ 48 | function parseAPIResponse(data: GetFileResponse | GetFileNodesResponse) { 49 | const aggregatedComponents: Record<string, Component> = {}; 50 | const aggregatedComponentSets: Record<string, ComponentSet> = {}; 51 | let extraStyles: Record<string, Style> = {}; 52 | let nodesToParse: Array<FigmaDocumentNode>; 53 | 54 | if ("nodes" in data) { 55 | // GetFileNodesResponse 56 | const nodeResponses = Object.values(data.nodes); 57 | nodeResponses.forEach((nodeResponse) => { 58 | if (nodeResponse.components) { 59 | Object.assign(aggregatedComponents, nodeResponse.components); 60 | } 61 | if (nodeResponse.componentSets) { 62 | Object.assign(aggregatedComponentSets, nodeResponse.componentSets); 63 | } 64 | if (nodeResponse.styles) { 65 | Object.assign(extraStyles, nodeResponse.styles); 66 | } 67 | }); 68 | nodesToParse = nodeResponses.map((n) => n.document).filter(isVisible); 69 | } else { 70 | // GetFileResponse 71 | Object.assign(aggregatedComponents, data.components); 72 | Object.assign(aggregatedComponentSets, data.componentSets); 73 | if (data.styles) { 74 | extraStyles = data.styles; 75 | } 76 | nodesToParse = data.document.children.filter(isVisible); 77 | } 78 | 79 | const { name } = data; 80 | 81 | return { 82 | metadata: { 83 | name, 84 | }, 85 | rawNodes: nodesToParse, 86 | extraStyles, 87 | components: aggregatedComponents, 88 | componentSets: aggregatedComponentSets, 89 | }; 90 | } 91 | ``` -------------------------------------------------------------------------------- /RELEASES.md: -------------------------------------------------------------------------------- ```markdown 1 | # Release Guide 2 | 3 | This project uses [Changesets](https://github.com/changesets/changesets) for automated versioning and publishing, with separate workflows for stable releases (main branch) and beta releases (beta branch). 4 | 5 | ## Main Branch Releases (Stable) 6 | 7 | 1. **Create feature branch from main**: 8 | 9 | ```bash 10 | git checkout main 11 | git pull 12 | git checkout -b feature/my-feature 13 | ``` 14 | 15 | 2. **Make your changes and add changeset**: 16 | 17 | ```bash 18 | # ... implement your feature ... 19 | pnpm changeset # Describe your changes and semver impact 20 | git push 21 | ``` 22 | 23 | 3. **Create PR to main**: Include the changeset in your PR 24 | 25 | 4. **After PR is merged**: The GitHub Action automatically handles versioning and publishing 26 | 27 | ### Why This Works 28 | 29 | - Changesets are created per-PR, ensuring each feature gets proper changelog entries 30 | - Automated workflow handles the complexity of versioning and publishing 31 | - Clean, linear history on main branch 32 | 33 | ## Beta Branch Releases (Testing) 34 | 35 | > **Note**: Beta release instructions are primarily for repo owner use. Contributors should use the main branch release instructions. 36 | 37 | 1. **Create feature branch and implement**: 38 | 39 | ```bash 40 | git checkout -b feature/experimental-thing 41 | # ... make changes (NO changeset yet) ... 42 | git commit -m "implement experimental feature" 43 | ``` 44 | 45 | 2. **Merge to beta and add changeset there if needed**: 46 | 47 | > **Note**: Changesets are consumed during beta release, so creating them multiple times on feature branches would lead to duplicate changelog entries when merging to main. 48 | 49 | ```bash 50 | git checkout beta 51 | git merge feature/experimental-thing 52 | pnpm changeset # Add changeset on beta branch 53 | git push # Triggers automated beta release 54 | ``` 55 | 56 | 3. **Keep beta updated with main**: 57 | 58 | ```bash 59 | git checkout beta 60 | git merge main # Bring in latest stable changes 61 | ``` 62 | 63 | 4. **When ready for stable release**: 64 | 65 | If a changeset already exists on the feature branch, it's ready to be merged into `main` once approved, otherwise: 66 | 67 | ```bash 68 | # Create PR from feature branch to main with changeset 69 | git checkout feature/experimental-thing 70 | pnpm changeset # Create changeset for stable release 71 | git push 72 | # Then merge PR to main - automated release happens after merge 73 | ``` 74 | 75 | ### Why Create Changesets on Beta Branch 76 | 77 | - **Prevents duplicates**: Changesets are consumed during beta release, so creating them on feature branches would lead to duplicate changelog entries when merging to main 78 | - **Better descriptions**: You can write changeset after the complete feature has been tested and refined in beta context 79 | 80 | ## Release Versions 81 | 82 | ### Stable Releases 83 | 84 | - Published to npm with `latest` tag 85 | - Follow semver: `1.2.3` 86 | - Users install with: `npm install figma-developer-mcp` 87 | 88 | ### Beta Releases 89 | 90 | - Published to npm with `beta` tag 91 | - Follow semver with prerelease suffix: `1.2.3-beta.0` 92 | - Users install with: `npm install figma-developer-mcp@beta` 93 | ``` -------------------------------------------------------------------------------- /src/extractors/types.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { Node as FigmaDocumentNode, Style } from "@figma/rest-api-spec"; 2 | import type { SimplifiedTextStyle } from "~/transformers/text.js"; 3 | import type { SimplifiedLayout } from "~/transformers/layout.js"; 4 | import type { SimplifiedFill, SimplifiedStroke } from "~/transformers/style.js"; 5 | import type { SimplifiedEffects } from "~/transformers/effects.js"; 6 | import type { 7 | ComponentProperties, 8 | SimplifiedComponentDefinition, 9 | SimplifiedComponentSetDefinition, 10 | } from "~/transformers/component.js"; 11 | 12 | export type StyleTypes = 13 | | SimplifiedTextStyle 14 | | SimplifiedFill[] 15 | | SimplifiedLayout 16 | | SimplifiedStroke 17 | | SimplifiedEffects 18 | | string; 19 | 20 | export type GlobalVars = { 21 | styles: Record<string, StyleTypes>; 22 | }; 23 | 24 | export interface TraversalContext { 25 | globalVars: GlobalVars & { extraStyles?: Record<string, Style> }; 26 | currentDepth: number; 27 | parent?: FigmaDocumentNode; 28 | } 29 | 30 | export interface TraversalOptions { 31 | maxDepth?: number; 32 | nodeFilter?: (node: FigmaDocumentNode) => boolean; 33 | /** 34 | * Called after children are processed, allowing modification of the parent node 35 | * and control over which children to include in the output. 36 | * 37 | * @param node - Original Figma node 38 | * @param result - SimplifiedNode being built (can be mutated) 39 | * @param children - Processed children 40 | * @returns Children to include (return empty array to omit children) 41 | */ 42 | afterChildren?: ( 43 | node: FigmaDocumentNode, 44 | result: SimplifiedNode, 45 | children: SimplifiedNode[], 46 | ) => SimplifiedNode[]; 47 | } 48 | 49 | /** 50 | * An extractor function that can modify a SimplifiedNode during traversal. 51 | * 52 | * @param node - The current Figma node being processed 53 | * @param result - SimplifiedNode object being built—this can be mutated inside the extractor 54 | * @param context - Traversal context including globalVars and parent info. This can also be mutated inside the extractor. 55 | */ 56 | export type ExtractorFn = ( 57 | node: FigmaDocumentNode, 58 | result: SimplifiedNode, 59 | context: TraversalContext, 60 | ) => void; 61 | 62 | export interface SimplifiedDesign { 63 | name: string; 64 | nodes: SimplifiedNode[]; 65 | components: Record<string, SimplifiedComponentDefinition>; 66 | componentSets: Record<string, SimplifiedComponentSetDefinition>; 67 | globalVars: GlobalVars; 68 | } 69 | 70 | export interface SimplifiedNode { 71 | id: string; 72 | name: string; 73 | type: string; // e.g. FRAME, TEXT, INSTANCE, RECTANGLE, etc. 74 | // text 75 | text?: string; 76 | textStyle?: string; 77 | // appearance 78 | fills?: string; 79 | styles?: string; 80 | strokes?: string; 81 | // Non-stylable stroke properties are kept on the node when stroke uses a named color style 82 | strokeWeight?: string; 83 | strokeDashes?: number[]; 84 | strokeWeights?: string; 85 | effects?: string; 86 | opacity?: number; 87 | borderRadius?: string; 88 | // layout & alignment 89 | layout?: string; 90 | // for rect-specific strokes, etc. 91 | componentId?: string; 92 | componentProperties?: ComponentProperties[]; 93 | // children 94 | children?: SimplifiedNode[]; 95 | } 96 | 97 | export interface BoundingBox { 98 | x: number; 99 | y: number; 100 | width: number; 101 | height: number; 102 | } 103 | ``` -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- ```markdown 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 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/get-figma-data-tool.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from "zod"; 2 | import type { GetFileResponse, GetFileNodesResponse } from "@figma/rest-api-spec"; 3 | import { FigmaService } from "~/services/figma.js"; 4 | import { 5 | simplifyRawFigmaObject, 6 | allExtractors, 7 | collapseSvgContainers, 8 | } from "~/extractors/index.js"; 9 | import yaml from "js-yaml"; 10 | import { Logger, writeLogs } from "~/utils/logger.js"; 11 | 12 | const parameters = { 13 | fileKey: z 14 | .string() 15 | .regex(/^[a-zA-Z0-9]+$/, "File key must be alphanumeric") 16 | .describe( 17 | "The key of the Figma file to fetch, often found in a provided URL like figma.com/(file|design)/<fileKey>/...", 18 | ), 19 | nodeId: z 20 | .string() 21 | .regex( 22 | /^I?\d+[:|-]\d+(?:;\d+[:|-]\d+)*$/, 23 | "Node ID must be like '1234:5678' or 'I5666:180910;1:10515;1:10336'", 24 | ) 25 | .optional() 26 | .describe( 27 | "The ID of the node to fetch, often found as URL parameter node-id=<nodeId>, always use if provided. Use format '1234:5678' or 'I5666:180910;1:10515;1:10336' for multiple nodes.", 28 | ), 29 | depth: z 30 | .number() 31 | .optional() 32 | .describe( 33 | "OPTIONAL. Do NOT use unless explicitly requested by the user. Controls how many levels deep to traverse the node tree.", 34 | ), 35 | }; 36 | 37 | const parametersSchema = z.object(parameters); 38 | export type GetFigmaDataParams = z.infer<typeof parametersSchema>; 39 | 40 | // Simplified handler function 41 | async function getFigmaData( 42 | params: GetFigmaDataParams, 43 | figmaService: FigmaService, 44 | outputFormat: "yaml" | "json", 45 | ) { 46 | try { 47 | const { fileKey, nodeId: rawNodeId, depth } = parametersSchema.parse(params); 48 | 49 | // Replace - with : in nodeId for our query—Figma API expects : 50 | const nodeId = rawNodeId?.replace(/-/g, ":"); 51 | 52 | Logger.log( 53 | `Fetching ${depth ? `${depth} layers deep` : "all layers"} of ${ 54 | nodeId ? `node ${nodeId} from file` : `full file` 55 | } ${fileKey}`, 56 | ); 57 | 58 | // Get raw Figma API response 59 | let rawApiResponse: GetFileResponse | GetFileNodesResponse; 60 | if (nodeId) { 61 | rawApiResponse = await figmaService.getRawNode(fileKey, nodeId, depth); 62 | } else { 63 | rawApiResponse = await figmaService.getRawFile(fileKey, depth); 64 | } 65 | 66 | // Use unified design extraction (handles nodes + components consistently) 67 | const simplifiedDesign = simplifyRawFigmaObject(rawApiResponse, allExtractors, { 68 | maxDepth: depth, 69 | afterChildren: collapseSvgContainers, 70 | }); 71 | 72 | writeLogs("figma-simplified.json", simplifiedDesign); 73 | 74 | Logger.log( 75 | `Successfully extracted data: ${simplifiedDesign.nodes.length} nodes, ${ 76 | Object.keys(simplifiedDesign.globalVars.styles).length 77 | } styles`, 78 | ); 79 | 80 | const { nodes, globalVars, ...metadata } = simplifiedDesign; 81 | const result = { 82 | metadata, 83 | nodes, 84 | globalVars, 85 | }; 86 | 87 | Logger.log(`Generating ${outputFormat.toUpperCase()} result from extracted data`); 88 | const formattedResult = 89 | outputFormat === "json" ? JSON.stringify(result, null, 2) : yaml.dump(result); 90 | 91 | Logger.log("Sending result to client"); 92 | return { 93 | content: [{ type: "text" as const, text: formattedResult }], 94 | }; 95 | } catch (error) { 96 | const message = error instanceof Error ? error.message : JSON.stringify(error); 97 | Logger.error(`Error fetching file ${params.fileKey}:`, message); 98 | return { 99 | isError: true, 100 | content: [{ type: "text" as const, text: `Error fetching file: ${message}` }], 101 | }; 102 | } 103 | } 104 | 105 | // Export tool configuration 106 | export const getFigmaDataTool = { 107 | name: "get_figma_data", 108 | description: 109 | "Get comprehensive Figma file data including layout, content, visuals, and component information", 110 | parameters, 111 | handler: getFigmaData, 112 | } as const; 113 | ``` -------------------------------------------------------------------------------- /src/extractors/node-walker.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { Node as FigmaDocumentNode } from "@figma/rest-api-spec"; 2 | import { isVisible } from "~/utils/common.js"; 3 | import { hasValue } from "~/utils/identity.js"; 4 | import type { 5 | ExtractorFn, 6 | TraversalContext, 7 | TraversalOptions, 8 | GlobalVars, 9 | SimplifiedNode, 10 | } from "./types.js"; 11 | 12 | /** 13 | * Extract data from Figma nodes using a flexible, single-pass approach. 14 | * 15 | * @param nodes - The Figma nodes to process 16 | * @param extractors - Array of extractor functions to apply during traversal 17 | * @param options - Traversal options (filtering, depth limits, etc.) 18 | * @param globalVars - Global variables for style deduplication 19 | * @returns Object containing processed nodes and updated global variables 20 | */ 21 | export function extractFromDesign( 22 | nodes: FigmaDocumentNode[], 23 | extractors: ExtractorFn[], 24 | options: TraversalOptions = {}, 25 | globalVars: GlobalVars = { styles: {} }, 26 | ): { nodes: SimplifiedNode[]; globalVars: GlobalVars } { 27 | const context: TraversalContext = { 28 | globalVars, 29 | currentDepth: 0, 30 | }; 31 | 32 | const processedNodes = nodes 33 | .filter((node) => shouldProcessNode(node, options)) 34 | .map((node) => processNodeWithExtractors(node, extractors, context, options)) 35 | .filter((node): node is SimplifiedNode => node !== null); 36 | 37 | return { 38 | nodes: processedNodes, 39 | globalVars: context.globalVars, 40 | }; 41 | } 42 | 43 | /** 44 | * Process a single node with all provided extractors in one pass. 45 | */ 46 | function processNodeWithExtractors( 47 | node: FigmaDocumentNode, 48 | extractors: ExtractorFn[], 49 | context: TraversalContext, 50 | options: TraversalOptions, 51 | ): SimplifiedNode | null { 52 | if (!shouldProcessNode(node, options)) { 53 | return null; 54 | } 55 | 56 | // Always include base metadata 57 | const result: SimplifiedNode = { 58 | id: node.id, 59 | name: node.name, 60 | type: node.type === "VECTOR" ? "IMAGE-SVG" : node.type, 61 | }; 62 | 63 | // Apply all extractors to this node in a single pass 64 | for (const extractor of extractors) { 65 | extractor(node, result, context); 66 | } 67 | 68 | // Handle children recursively 69 | if (shouldTraverseChildren(node, context, options)) { 70 | const childContext: TraversalContext = { 71 | ...context, 72 | currentDepth: context.currentDepth + 1, 73 | parent: node, 74 | }; 75 | 76 | // Use the same pattern as the existing parseNode function 77 | if (hasValue("children", node) && node.children.length > 0) { 78 | const children = node.children 79 | .filter((child) => shouldProcessNode(child, options)) 80 | .map((child) => processNodeWithExtractors(child, extractors, childContext, options)) 81 | .filter((child): child is SimplifiedNode => child !== null); 82 | 83 | if (children.length > 0) { 84 | // Allow custom logic to modify parent and control which children to include 85 | const childrenToInclude = options.afterChildren 86 | ? options.afterChildren(node, result, children) 87 | : children; 88 | 89 | if (childrenToInclude.length > 0) { 90 | result.children = childrenToInclude; 91 | } 92 | } 93 | } 94 | } 95 | 96 | return result; 97 | } 98 | 99 | /** 100 | * Determine if a node should be processed based on filters. 101 | */ 102 | function shouldProcessNode(node: FigmaDocumentNode, options: TraversalOptions): boolean { 103 | // Skip invisible nodes 104 | if (!isVisible(node)) { 105 | return false; 106 | } 107 | 108 | // Apply custom node filter if provided 109 | if (options.nodeFilter && !options.nodeFilter(node)) { 110 | return false; 111 | } 112 | 113 | return true; 114 | } 115 | 116 | /** 117 | * Determine if we should traverse into a node's children. 118 | */ 119 | function shouldTraverseChildren( 120 | node: FigmaDocumentNode, 121 | context: TraversalContext, 122 | options: TraversalOptions, 123 | ): boolean { 124 | // Check depth limit 125 | if (options.maxDepth !== undefined && context.currentDepth >= options.maxDepth) { 126 | return false; 127 | } 128 | 129 | return true; 130 | } 131 | ``` -------------------------------------------------------------------------------- /src/utils/fetch-with-retry.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { execFile } from "child_process"; 2 | import { promisify } from "util"; 3 | import { Logger } from "./logger.js"; 4 | 5 | const execFileAsync = promisify(execFile); 6 | 7 | type RequestOptions = RequestInit & { 8 | /** 9 | * Force format of headers to be a record of strings, e.g. { "Authorization": "Bearer 123" } 10 | * 11 | * Avoids complexity of needing to deal with `instanceof Headers`, which is not supported in some environments. 12 | */ 13 | headers?: Record<string, string>; 14 | }; 15 | 16 | export async function fetchWithRetry<T extends { status?: number }>( 17 | url: string, 18 | options: RequestOptions = {}, 19 | ): Promise<T> { 20 | try { 21 | const response = await fetch(url, options); 22 | 23 | if (!response.ok) { 24 | throw new Error(`Fetch failed with status ${response.status}: ${response.statusText}`); 25 | } 26 | return (await response.json()) as T; 27 | } catch (fetchError: any) { 28 | Logger.log( 29 | `[fetchWithRetry] Initial fetch failed for ${url}: ${fetchError.message}. Likely a corporate proxy or SSL issue. Attempting curl fallback.`, 30 | ); 31 | 32 | const curlHeaders = formatHeadersForCurl(options.headers); 33 | // Most options here are to ensure stderr only contains errors, so we can use it to confidently check if an error occurred. 34 | // -s: Silent mode—no progress bar in stderr 35 | // -S: Show errors in stderr 36 | // --fail-with-body: curl errors with code 22, and outputs body of failed request, e.g. "Fetch failed with status 404" 37 | // -L: Follow redirects 38 | const curlArgs = ["-s", "-S", "--fail-with-body", "-L", ...curlHeaders, url]; 39 | 40 | try { 41 | // Fallback to curl for corporate networks that have proxies that sometimes block fetch 42 | Logger.log(`[fetchWithRetry] Executing curl with args: ${JSON.stringify(curlArgs)}`); 43 | const { stdout, stderr } = await execFileAsync("curl", curlArgs); 44 | 45 | if (stderr) { 46 | // curl often outputs progress to stderr, so only treat as error if stdout is empty 47 | // or if stderr contains typical error keywords. 48 | if ( 49 | !stdout || 50 | stderr.toLowerCase().includes("error") || 51 | stderr.toLowerCase().includes("fail") 52 | ) { 53 | throw new Error(`Curl command failed with stderr: ${stderr}`); 54 | } 55 | Logger.log( 56 | `[fetchWithRetry] Curl command for ${url} produced stderr (but might be informational): ${stderr}`, 57 | ); 58 | } 59 | 60 | if (!stdout) { 61 | throw new Error("Curl command returned empty stdout."); 62 | } 63 | 64 | const result = JSON.parse(stdout) as T; 65 | 66 | // Successful Figma requests don't have a status property, and some endpoints return 200 with an 67 | // error status in the body, e.g. https://www.figma.com/developers/api#get-images-endpoint 68 | if (result.status && result.status !== 200) { 69 | throw new Error(`Curl command failed: ${result}`); 70 | } 71 | 72 | return result; 73 | } catch (curlError: any) { 74 | Logger.error(`[fetchWithRetry] Curl fallback also failed for ${url}: ${curlError.message}`); 75 | // Re-throw the original fetch error to give context about the initial failure 76 | // or throw a new error that wraps both, depending on desired error reporting. 77 | // For now, re-throwing the original as per the user example's spirit. 78 | throw fetchError; 79 | } 80 | } 81 | } 82 | 83 | /** 84 | * Converts HeadersInit to an array of curl header arguments for execFile. 85 | * @param headers Headers to convert. 86 | * @returns Array of strings for curl arguments: ["-H", "key: value", "-H", "key2: value2"] 87 | */ 88 | function formatHeadersForCurl(headers: Record<string, string> | undefined): string[] { 89 | if (!headers) { 90 | return []; 91 | } 92 | 93 | const headerArgs: string[] = []; 94 | for (const [key, value] of Object.entries(headers)) { 95 | headerArgs.push("-H", `${key}: ${value}`); 96 | } 97 | return headerArgs; 98 | } 99 | ``` -------------------------------------------------------------------------------- /ROADMAP.md: -------------------------------------------------------------------------------- ```markdown 1 | # Figma MCP Server Roadmap 2 | 3 | This roadmap outlines planned improvements and features for the Figma MCP Server project. Items are organized by development phases and effort levels. 4 | 5 | ## Overview 6 | 7 | The Figma MCP Server enables AI coding assistants to access Figma design data directly, improving the accuracy of design-to-code translations. This roadmap focuses on expanding capabilities, improving developer experience, and ensuring robust enterprise support. 8 | 9 | ## Core Feature Enhancements 🚀 10 | 11 | _High impact, foundational improvements_ 12 | 13 | ### Component & Prototype Support (High Priority) 14 | 15 | - [ ] **Add dedicated tool for component extraction** ([#124](https://github.com/GLips/Figma-Context-MCP/issues/124)) 16 | - [ ] Create `get_figma_components` tool for fetching full component/component set design data including variants and properties 17 | - [ ] **Improve INSTANCE support** 18 | - [ ] Return only overridden values 19 | - [ ] Hide children of INSTANCE except for slot type children or if full data is explicitly requested via new tool call parameter 20 | - [ ] **Prototype support** 21 | - [ ] Extract interactivity data (e.g. actions on hover, click, etc.) 22 | - [ ] Return data on animations / transitions 23 | - [?] State management hints 24 | 25 | ### Parsing Logic 26 | 27 | - [ ] Inline variables that only show up once, and keep global vars only for variables that are reused 28 | 29 | ### Image & Asset Handling 30 | 31 | - [ ] **Fix masked / cropped image exports** 32 | - [ ] Correctly export cropped images ([#162](https://github.com/GLips/Figma-Context-MCP/issues/162)) 33 | - [?] Support complex mask shapes and transformations 34 | - [?] Pull image fills/vectors out to top level for better AI visibility 35 | - [ ] **Improve SVG handling** 36 | - [ ] Better icon identification, e.g. if all components of a frame are VECTOR, download the full frame as an SVG 37 | - [?] Add support for raw path data in response—not sure if this is valuable yet 38 | 39 | ### Layout Improvements 40 | 41 | - [ ] **Smart wrapped layout detection** 42 | - [?] Detect and convert fixed-width children to percentage-based widths 43 | - [ ] Better flexbox wrap support 44 | - [ ] Grid layout detection for wrapped items 45 | - [ ] Support for Figma's new grid layout 46 | 47 | ### Advanced Styling 48 | 49 | - [ ] **Enhanced gradient support** 50 | - [ ] Make sure gradients are exported correctly in CSS syntax ([#152](https://github.com/GLips/Figma-Context-MCP/issues/152)) 51 | - [ ] **Grid system support** 52 | - [ ] Support for Figma's new grid autolayout (an addition to the long-existing flex autolayout) 53 | - [ ] Legacy "layout guide" grids 54 | - [ ] **Named styles extraction** 55 | - [ ] Export style names associated with different layouts, colors, text, etc. for easier identification by the LLM (can use `/v1/styles/:key` endpoint) 56 | 57 | ### Text & Typography 58 | 59 | - [ ] **Text styling** 60 | - [ ] Add support for formatted text in text fields ([#159](https://github.com/GLips/Figma-Context-MCP/issues/159)) 61 | - [ ] Add support for mixed text styles (e.g. multiple colors) ([#140](https://github.com/GLips/Figma-Context-MCP/issues/140)) 62 | 63 | ## Enterprise & Advanced Features 🏢 64 | 65 | _Features for scaling and enterprise adoption_ 66 | 67 | ### Enterprise Support 68 | 69 | - [ ] **Variable System Enhancements** 70 | - [ ] Port `deduceVariablesFromTokens` for non-Enterprise users (see [tothienbao6a0's fork](https://github.com/tothienbao6a0/Figma-Context-MCP/blob/d9b035de76f44c952382b8155a5d5bf938e52a77/src/services/variable-deduction.ts#L30) for inspiration?) 71 | - [ ] Add `getFigmaVariables` for Enterprise plans 72 | - [?] Export design tokens in standard formats 73 | 74 | ## Developer Experience 🛠️ 75 | 76 | _Improving usability and integration_ 77 | 78 | ### Performance & Reliability 79 | 80 | - [ ] **Better error handling** 81 | - [x] Retry logic for API failures 82 | - [ ] Detailed error messages which the LLM can expand on for users 83 | 84 | ### Documentation & Testing 85 | 86 | - [ ] **Test coverage improvements** 87 | - [ ] Unit tests for all transformers 88 | - [ ] Integration tests with mock Figma API 89 | - [ ] E2E tests to visually check the implementation of an LLM coding agent prompted with MCP server output—likely uses a custom test framework to kick off e.g. Claude Code in the background 90 | 91 | ## Quick Wins 🎪 92 | 93 | _Low effort, high impact_ 94 | 95 | - [ ] Better handling of text overflow (e.g. auto width, auto height, fixed width + truncate text setting) 96 | - [ ] Double check to make sure blend modes are forwarded properly in the simplified response 97 | 98 | ## Technical Debt 🧹 99 | 100 | _Code quality and maintenance_ 101 | 102 | - [ ] Clean up image download code (noted in mcp.ts) 103 | - [ ] Refactor `convertAlign` function (layout.ts) 104 | - [ ] Standardize error handling across services 105 | 106 | ## Research & Exploration 🔬 107 | 108 | _Investigate feasibility / value_ 109 | 110 | - [ ] Figma plugin companion 🚀🚀🚀 111 | - [ ] **Design System Integration** 112 | - [ ] Token extraction and mapping 113 | - [ ] Component dependency graphs 114 | - [ ] **Figma File Metadata** 115 | - [ ] Investigate how we can use frames that are marked "Ready for Dev" 116 | - [ ] Investigate feasibility of pulling in annotations via the Figma API 117 | - [ ] Investigate feasibility/value of using—and even modifying—"Dev Resources" links via Figma API 118 | 119 | ## Contributing 120 | 121 | We welcome contributions! Please check the issues labeled with "good first issue" or "help wanted". For major features, please open an issue first to discuss the implementation approach. 122 | 123 | --- 124 | 125 | _This roadmap is subject to change based on community feedback and priorities. Last updated: June 2025_ 126 | ``` -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { config as loadEnv } from "dotenv"; 2 | import yargs from "yargs"; 3 | import { hideBin } from "yargs/helpers"; 4 | import { resolve } from "path"; 5 | import type { FigmaAuthOptions } from "./services/figma.js"; 6 | 7 | interface ServerConfig { 8 | auth: FigmaAuthOptions; 9 | port: number; 10 | outputFormat: "yaml" | "json"; 11 | skipImageDownloads?: boolean; 12 | configSources: { 13 | figmaApiKey: "cli" | "env"; 14 | figmaOAuthToken: "cli" | "env" | "none"; 15 | port: "cli" | "env" | "default"; 16 | outputFormat: "cli" | "env" | "default"; 17 | envFile: "cli" | "default"; 18 | skipImageDownloads?: "cli" | "env" | "default"; 19 | }; 20 | } 21 | 22 | function maskApiKey(key: string): string { 23 | if (!key || key.length <= 4) return "****"; 24 | return `****${key.slice(-4)}`; 25 | } 26 | 27 | interface CliArgs { 28 | "figma-api-key"?: string; 29 | "figma-oauth-token"?: string; 30 | env?: string; 31 | port?: number; 32 | json?: boolean; 33 | "skip-image-downloads"?: boolean; 34 | } 35 | 36 | export function getServerConfig(isStdioMode: boolean): ServerConfig { 37 | // Parse command line arguments 38 | const argv = yargs(hideBin(process.argv)) 39 | .options({ 40 | "figma-api-key": { 41 | type: "string", 42 | description: "Figma API key (Personal Access Token)", 43 | }, 44 | "figma-oauth-token": { 45 | type: "string", 46 | description: "Figma OAuth Bearer token", 47 | }, 48 | env: { 49 | type: "string", 50 | description: "Path to custom .env file to load environment variables from", 51 | }, 52 | port: { 53 | type: "number", 54 | description: "Port to run the server on", 55 | }, 56 | json: { 57 | type: "boolean", 58 | description: "Output data from tools in JSON format instead of YAML", 59 | default: false, 60 | }, 61 | "skip-image-downloads": { 62 | type: "boolean", 63 | description: "Do not register the download_figma_images tool (skip image downloads)", 64 | default: false, 65 | }, 66 | }) 67 | .help() 68 | .version(process.env.NPM_PACKAGE_VERSION ?? "unknown") 69 | .parseSync() as CliArgs; 70 | 71 | // Load environment variables ASAP from custom path or default 72 | let envFilePath: string; 73 | let envFileSource: "cli" | "default"; 74 | 75 | if (argv["env"]) { 76 | envFilePath = resolve(argv["env"]); 77 | envFileSource = "cli"; 78 | } else { 79 | envFilePath = resolve(process.cwd(), ".env"); 80 | envFileSource = "default"; 81 | } 82 | 83 | // Override anything auto-loaded from .env if a custom file is provided. 84 | loadEnv({ path: envFilePath, override: true }); 85 | 86 | const auth: FigmaAuthOptions = { 87 | figmaApiKey: "", 88 | figmaOAuthToken: "", 89 | useOAuth: false, 90 | }; 91 | 92 | const config: Omit<ServerConfig, "auth"> = { 93 | port: 3333, 94 | outputFormat: "yaml", 95 | skipImageDownloads: false, 96 | configSources: { 97 | figmaApiKey: "env", 98 | figmaOAuthToken: "none", 99 | port: "default", 100 | outputFormat: "default", 101 | envFile: envFileSource, 102 | skipImageDownloads: "default", 103 | }, 104 | }; 105 | 106 | // Handle FIGMA_API_KEY 107 | if (argv["figma-api-key"]) { 108 | auth.figmaApiKey = argv["figma-api-key"]; 109 | config.configSources.figmaApiKey = "cli"; 110 | } else if (process.env.FIGMA_API_KEY) { 111 | auth.figmaApiKey = process.env.FIGMA_API_KEY; 112 | config.configSources.figmaApiKey = "env"; 113 | } 114 | 115 | // Handle FIGMA_OAUTH_TOKEN 116 | if (argv["figma-oauth-token"]) { 117 | auth.figmaOAuthToken = argv["figma-oauth-token"]; 118 | config.configSources.figmaOAuthToken = "cli"; 119 | auth.useOAuth = true; 120 | } else if (process.env.FIGMA_OAUTH_TOKEN) { 121 | auth.figmaOAuthToken = process.env.FIGMA_OAUTH_TOKEN; 122 | config.configSources.figmaOAuthToken = "env"; 123 | auth.useOAuth = true; 124 | } 125 | 126 | // Handle PORT 127 | if (argv.port) { 128 | config.port = argv.port; 129 | config.configSources.port = "cli"; 130 | } else if (process.env.PORT) { 131 | config.port = parseInt(process.env.PORT, 10); 132 | config.configSources.port = "env"; 133 | } 134 | 135 | // Handle JSON output format 136 | if (argv.json) { 137 | config.outputFormat = "json"; 138 | config.configSources.outputFormat = "cli"; 139 | } else if (process.env.OUTPUT_FORMAT) { 140 | config.outputFormat = process.env.OUTPUT_FORMAT as "yaml" | "json"; 141 | config.configSources.outputFormat = "env"; 142 | } 143 | 144 | // Handle skipImageDownloads 145 | if (argv["skip-image-downloads"]) { 146 | config.skipImageDownloads = true; 147 | config.configSources.skipImageDownloads = "cli"; 148 | } else if (process.env.SKIP_IMAGE_DOWNLOADS === "true") { 149 | config.skipImageDownloads = true; 150 | config.configSources.skipImageDownloads = "env"; 151 | } 152 | 153 | // Validate configuration 154 | if (!auth.figmaApiKey && !auth.figmaOAuthToken) { 155 | console.error( 156 | "Either FIGMA_API_KEY or FIGMA_OAUTH_TOKEN is required (via CLI argument or .env file)", 157 | ); 158 | process.exit(1); 159 | } 160 | 161 | // Log configuration sources 162 | if (!isStdioMode) { 163 | console.log("\nConfiguration:"); 164 | console.log(`- ENV_FILE: ${envFilePath} (source: ${config.configSources.envFile})`); 165 | if (auth.useOAuth) { 166 | console.log( 167 | `- FIGMA_OAUTH_TOKEN: ${maskApiKey(auth.figmaOAuthToken)} (source: ${config.configSources.figmaOAuthToken})`, 168 | ); 169 | console.log("- Authentication Method: OAuth Bearer Token"); 170 | } else { 171 | console.log( 172 | `- FIGMA_API_KEY: ${maskApiKey(auth.figmaApiKey)} (source: ${config.configSources.figmaApiKey})`, 173 | ); 174 | console.log("- Authentication Method: Personal Access Token (X-Figma-Token)"); 175 | } 176 | console.log(`- PORT: ${config.port} (source: ${config.configSources.port})`); 177 | console.log( 178 | `- OUTPUT_FORMAT: ${config.outputFormat} (source: ${config.configSources.outputFormat})`, 179 | ); 180 | console.log( 181 | `- SKIP_IMAGE_DOWNLOADS: ${config.skipImageDownloads} (source: ${config.configSources.skipImageDownloads})`, 182 | ); 183 | console.log(); // Empty line for better readability 184 | } 185 | 186 | return { 187 | ...config, 188 | auth, 189 | }; 190 | } 191 | ``` -------------------------------------------------------------------------------- /src/utils/common.ts: -------------------------------------------------------------------------------- ```typescript 1 | import fs from "fs"; 2 | import path from "path"; 3 | 4 | export type StyleId = `${string}_${string}` & { __brand: "StyleId" }; 5 | 6 | /** 7 | * Download Figma image and save it locally 8 | * @param fileName - The filename to save as 9 | * @param localPath - The local path to save to 10 | * @param imageUrl - Image URL (images[nodeId]) 11 | * @returns A Promise that resolves to the full file path where the image was saved 12 | * @throws Error if download fails 13 | */ 14 | export async function downloadFigmaImage( 15 | fileName: string, 16 | localPath: string, 17 | imageUrl: string, 18 | ): Promise<string> { 19 | try { 20 | // Ensure local path exists 21 | if (!fs.existsSync(localPath)) { 22 | fs.mkdirSync(localPath, { recursive: true }); 23 | } 24 | 25 | // Build the complete file path 26 | const fullPath = path.join(localPath, fileName); 27 | 28 | // Use fetch to download the image 29 | const response = await fetch(imageUrl, { 30 | method: "GET", 31 | }); 32 | 33 | if (!response.ok) { 34 | throw new Error(`Failed to download image: ${response.statusText}`); 35 | } 36 | 37 | // Create write stream 38 | const writer = fs.createWriteStream(fullPath); 39 | 40 | // Get the response as a readable stream and pipe it to the file 41 | const reader = response.body?.getReader(); 42 | if (!reader) { 43 | throw new Error("Failed to get response body"); 44 | } 45 | 46 | return new Promise((resolve, reject) => { 47 | // Process stream 48 | const processStream = async () => { 49 | try { 50 | while (true) { 51 | const { done, value } = await reader.read(); 52 | if (done) { 53 | writer.end(); 54 | break; 55 | } 56 | writer.write(value); 57 | } 58 | } catch (err) { 59 | writer.end(); 60 | fs.unlink(fullPath, () => {}); 61 | reject(err); 62 | } 63 | }; 64 | 65 | // Resolve only when the stream is fully written 66 | writer.on("finish", () => { 67 | resolve(fullPath); 68 | }); 69 | 70 | writer.on("error", (err) => { 71 | reader.cancel(); 72 | fs.unlink(fullPath, () => {}); 73 | reject(new Error(`Failed to write image: ${err.message}`)); 74 | }); 75 | 76 | processStream(); 77 | }); 78 | } catch (error) { 79 | const errorMessage = error instanceof Error ? error.message : String(error); 80 | throw new Error(`Error downloading image: ${errorMessage}`); 81 | } 82 | } 83 | 84 | /** 85 | * Remove keys with empty arrays or empty objects from an object. 86 | * @param input - The input object or value. 87 | * @returns The processed object or the original value. 88 | */ 89 | export function removeEmptyKeys<T>(input: T): T { 90 | // If not an object type or null, return directly 91 | if (typeof input !== "object" || input === null) { 92 | return input; 93 | } 94 | 95 | // Handle array type 96 | if (Array.isArray(input)) { 97 | return input.map((item) => removeEmptyKeys(item)) as T; 98 | } 99 | 100 | // Handle object type 101 | const result = {} as T; 102 | for (const key in input) { 103 | if (Object.prototype.hasOwnProperty.call(input, key)) { 104 | const value = input[key]; 105 | 106 | // Recursively process nested objects 107 | const cleanedValue = removeEmptyKeys(value); 108 | 109 | // Skip empty arrays and empty objects 110 | if ( 111 | cleanedValue !== undefined && 112 | !(Array.isArray(cleanedValue) && cleanedValue.length === 0) && 113 | !( 114 | typeof cleanedValue === "object" && 115 | cleanedValue !== null && 116 | Object.keys(cleanedValue).length === 0 117 | ) 118 | ) { 119 | result[key] = cleanedValue; 120 | } 121 | } 122 | } 123 | 124 | return result; 125 | } 126 | 127 | /** 128 | * Generate a 6-character random variable ID 129 | * @param prefix - ID prefix 130 | * @returns A 6-character random ID string with prefix 131 | */ 132 | export function generateVarId(prefix: string = "var"): StyleId { 133 | const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; 134 | let result = ""; 135 | 136 | for (let i = 0; i < 6; i++) { 137 | const randomIndex = Math.floor(Math.random() * chars.length); 138 | result += chars[randomIndex]; 139 | } 140 | 141 | return `${prefix}_${result}` as StyleId; 142 | } 143 | 144 | /** 145 | * Generate a CSS shorthand for values that come with top, right, bottom, and left 146 | * 147 | * input: { top: 10, right: 10, bottom: 10, left: 10 } 148 | * output: "10px" 149 | * 150 | * input: { top: 10, right: 20, bottom: 10, left: 20 } 151 | * output: "10px 20px" 152 | * 153 | * input: { top: 10, right: 20, bottom: 30, left: 40 } 154 | * output: "10px 20px 30px 40px" 155 | * 156 | * @param values - The values to generate the shorthand for 157 | * @returns The generated shorthand 158 | */ 159 | export function generateCSSShorthand( 160 | values: { 161 | top: number; 162 | right: number; 163 | bottom: number; 164 | left: number; 165 | }, 166 | { 167 | ignoreZero = true, 168 | suffix = "px", 169 | }: { 170 | /** 171 | * If true and all values are 0, return undefined. Defaults to true. 172 | */ 173 | ignoreZero?: boolean; 174 | /** 175 | * The suffix to add to the shorthand. Defaults to "px". 176 | */ 177 | suffix?: string; 178 | } = {}, 179 | ) { 180 | const { top, right, bottom, left } = values; 181 | if (ignoreZero && top === 0 && right === 0 && bottom === 0 && left === 0) { 182 | return undefined; 183 | } 184 | if (top === right && right === bottom && bottom === left) { 185 | return `${top}${suffix}`; 186 | } 187 | if (right === left) { 188 | if (top === bottom) { 189 | return `${top}${suffix} ${right}${suffix}`; 190 | } 191 | return `${top}${suffix} ${right}${suffix} ${bottom}${suffix}`; 192 | } 193 | return `${top}${suffix} ${right}${suffix} ${bottom}${suffix} ${left}${suffix}`; 194 | } 195 | 196 | /** 197 | * Check if an element is visible 198 | * @param element - The item to check 199 | * @returns True if the item is visible, false otherwise 200 | */ 201 | export function isVisible(element: { visible?: boolean }): boolean { 202 | return element.visible ?? true; 203 | } 204 | 205 | /** 206 | * Rounds a number to two decimal places, suitable for pixel value processing. 207 | * @param num The number to be rounded. 208 | * @returns The rounded number with two decimal places. 209 | * @throws TypeError If the input is not a valid number 210 | */ 211 | export function pixelRound(num: number): number { 212 | if (isNaN(num)) { 213 | throw new TypeError(`Input must be a valid number`); 214 | } 215 | return Number(Number(num).toFixed(2)); 216 | } 217 | ``` -------------------------------------------------------------------------------- /src/mcp/tools/download-figma-images-tool.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { z } from "zod"; 2 | import { FigmaService } from "../../services/figma.js"; 3 | import { Logger } from "../../utils/logger.js"; 4 | 5 | const parameters = { 6 | fileKey: z 7 | .string() 8 | .regex(/^[a-zA-Z0-9]+$/, "File key must be alphanumeric") 9 | .describe("The key of the Figma file containing the images"), 10 | nodes: z 11 | .object({ 12 | nodeId: z 13 | .string() 14 | .regex( 15 | /^I?\d+[:|-]\d+(?:;\d+[:|-]\d+)*$/, 16 | "Node ID must be like '1234:5678' or 'I5666:180910;1:10515;1:10336'", 17 | ) 18 | .describe("The ID of the Figma image node to fetch, formatted as 1234:5678"), 19 | imageRef: z 20 | .string() 21 | .optional() 22 | .describe( 23 | "If a node has an imageRef fill, you must include this variable. Leave blank when downloading Vector SVG images.", 24 | ), 25 | fileName: z 26 | .string() 27 | .regex( 28 | /^[a-zA-Z0-9_.-]+\.(png|svg)$/, 29 | "File names must contain only letters, numbers, underscores, dots, or hyphens, and end with .png or .svg.", 30 | ) 31 | .describe( 32 | "The local name for saving the fetched file, including extension. Either png or svg.", 33 | ), 34 | needsCropping: z 35 | .boolean() 36 | .optional() 37 | .describe("Whether this image needs cropping based on its transform matrix"), 38 | cropTransform: z 39 | .array(z.array(z.number())) 40 | .optional() 41 | .describe("Figma transform matrix for image cropping"), 42 | requiresImageDimensions: z 43 | .boolean() 44 | .optional() 45 | .describe("Whether this image requires dimension information for CSS variables"), 46 | filenameSuffix: z 47 | .string() 48 | .optional() 49 | .describe( 50 | "Suffix to add to filename for unique cropped images, provided in the Figma data (e.g., 'abc123')", 51 | ), 52 | }) 53 | .array() 54 | .describe("The nodes to fetch as images"), 55 | pngScale: z 56 | .number() 57 | .positive() 58 | .optional() 59 | .default(2) 60 | .describe( 61 | "Export scale for PNG images. Optional, defaults to 2 if not specified. Affects PNG images only.", 62 | ), 63 | localPath: z 64 | .string() 65 | .describe( 66 | "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.", 67 | ), 68 | }; 69 | 70 | const parametersSchema = z.object(parameters); 71 | export type DownloadImagesParams = z.infer<typeof parametersSchema>; 72 | 73 | // Enhanced handler function with image processing support 74 | async function downloadFigmaImages(params: DownloadImagesParams, figmaService: FigmaService) { 75 | try { 76 | const { fileKey, nodes, localPath, pngScale = 2 } = parametersSchema.parse(params); 77 | 78 | // Process nodes: collect unique downloads and track which requests they satisfy 79 | const downloadItems = []; 80 | const downloadToRequests = new Map<number, string[]>(); // download index -> requested filenames 81 | const seenDownloads = new Map<string, number>(); // uniqueKey -> download index 82 | 83 | for (const rawNode of nodes) { 84 | const { nodeId: rawNodeId, ...node } = rawNode; 85 | 86 | // Replace - with : in nodeId for our query—Figma API expects : 87 | const nodeId = rawNodeId?.replace(/-/g, ":"); 88 | 89 | // Apply filename suffix if provided 90 | let finalFileName = node.fileName; 91 | if (node.filenameSuffix && !finalFileName.includes(node.filenameSuffix)) { 92 | const ext = finalFileName.split(".").pop(); 93 | const nameWithoutExt = finalFileName.substring(0, finalFileName.lastIndexOf(".")); 94 | finalFileName = `${nameWithoutExt}-${node.filenameSuffix}.${ext}`; 95 | } 96 | 97 | const downloadItem = { 98 | fileName: finalFileName, 99 | needsCropping: node.needsCropping || false, 100 | cropTransform: node.cropTransform, 101 | requiresImageDimensions: node.requiresImageDimensions || false, 102 | }; 103 | 104 | if (node.imageRef) { 105 | // For imageRefs, check if we've already planned to download this 106 | const uniqueKey = `${node.imageRef}-${node.filenameSuffix || "none"}`; 107 | 108 | if (!node.filenameSuffix && seenDownloads.has(uniqueKey)) { 109 | // Already planning to download this, just add to the requests list 110 | const downloadIndex = seenDownloads.get(uniqueKey)!; 111 | const requests = downloadToRequests.get(downloadIndex)!; 112 | if (!requests.includes(finalFileName)) { 113 | requests.push(finalFileName); 114 | } 115 | 116 | // Update requiresImageDimensions if needed 117 | if (downloadItem.requiresImageDimensions) { 118 | downloadItems[downloadIndex].requiresImageDimensions = true; 119 | } 120 | } else { 121 | // New unique download 122 | const downloadIndex = downloadItems.length; 123 | downloadItems.push({ ...downloadItem, imageRef: node.imageRef }); 124 | downloadToRequests.set(downloadIndex, [finalFileName]); 125 | seenDownloads.set(uniqueKey, downloadIndex); 126 | } 127 | } else { 128 | // Rendered nodes are always unique 129 | const downloadIndex = downloadItems.length; 130 | downloadItems.push({ ...downloadItem, nodeId }); 131 | downloadToRequests.set(downloadIndex, [finalFileName]); 132 | } 133 | } 134 | 135 | const allDownloads = await figmaService.downloadImages(fileKey, localPath, downloadItems, { 136 | pngScale, 137 | }); 138 | 139 | const successCount = allDownloads.filter(Boolean).length; 140 | 141 | // Format results with aliases 142 | const imagesList = allDownloads 143 | .map((result, index) => { 144 | const fileName = result.filePath.split("/").pop() || result.filePath; 145 | const dimensions = `${result.finalDimensions.width}x${result.finalDimensions.height}`; 146 | const cropStatus = result.wasCropped ? " (cropped)" : ""; 147 | 148 | const dimensionInfo = result.cssVariables 149 | ? `${dimensions} | ${result.cssVariables}` 150 | : dimensions; 151 | 152 | // Show all the filenames that were requested for this download 153 | const requestedNames = downloadToRequests.get(index) || [fileName]; 154 | const aliasText = 155 | requestedNames.length > 1 156 | ? ` (also requested as: ${requestedNames.filter((name: string) => name !== fileName).join(", ")})` 157 | : ""; 158 | 159 | return `- ${fileName}: ${dimensionInfo}${cropStatus}${aliasText}`; 160 | }) 161 | .join("\n"); 162 | 163 | return { 164 | content: [ 165 | { 166 | type: "text" as const, 167 | text: `Downloaded ${successCount} images:\n${imagesList}`, 168 | }, 169 | ], 170 | }; 171 | } catch (error) { 172 | Logger.error(`Error downloading images from ${params.fileKey}:`, error); 173 | return { 174 | isError: true, 175 | content: [ 176 | { 177 | type: "text" as const, 178 | text: `Failed to download images: ${error instanceof Error ? error.message : String(error)}`, 179 | }, 180 | ], 181 | }; 182 | } 183 | } 184 | 185 | // Export tool configuration 186 | export const downloadFigmaImagesTool = { 187 | name: "download_figma_images", 188 | description: 189 | "Download SVG and PNG images used in a Figma file based on the IDs of image or icon nodes", 190 | parameters, 191 | handler: downloadFigmaImages, 192 | } as const; 193 | ``` -------------------------------------------------------------------------------- /src/utils/image-processing.ts: -------------------------------------------------------------------------------- ```typescript 1 | import fs from "fs"; 2 | import path from "path"; 3 | import sharp from "sharp"; 4 | import type { Transform } from "@figma/rest-api-spec"; 5 | 6 | /** 7 | * Apply crop transform to an image based on Figma's transformation matrix 8 | * @param imagePath - Path to the original image file 9 | * @param cropTransform - Figma transform matrix [[scaleX, skewX, translateX], [skewY, scaleY, translateY]] 10 | * @returns Promise<string> - Path to the cropped image 11 | */ 12 | export async function applyCropTransform( 13 | imagePath: string, 14 | cropTransform: Transform, 15 | ): Promise<string> { 16 | const { Logger } = await import("./logger.js"); 17 | 18 | try { 19 | // Extract transform values 20 | const scaleX = cropTransform[0]?.[0] ?? 1; 21 | const skewX = cropTransform[0]?.[1] ?? 0; 22 | const translateX = cropTransform[0]?.[2] ?? 0; 23 | const skewY = cropTransform[1]?.[0] ?? 0; 24 | const scaleY = cropTransform[1]?.[1] ?? 1; 25 | const translateY = cropTransform[1]?.[2] ?? 0; 26 | 27 | // Load the image and get metadata 28 | const image = sharp(imagePath); 29 | const metadata = await image.metadata(); 30 | 31 | if (!metadata.width || !metadata.height) { 32 | throw new Error(`Could not get image dimensions for ${imagePath}`); 33 | } 34 | 35 | const { width, height } = metadata; 36 | 37 | // Calculate crop region based on transform matrix 38 | // Figma's transform matrix represents how the image is positioned within its container 39 | // We need to extract the visible portion based on the scaling and translation 40 | 41 | // The transform matrix defines the visible area as: 42 | // - scaleX/scaleY: how much of the original image is visible (0-1) 43 | // - translateX/translateY: offset of the visible area (0-1, relative to image size) 44 | 45 | const cropLeft = Math.max(0, Math.round(translateX * width)); 46 | const cropTop = Math.max(0, Math.round(translateY * height)); 47 | const cropWidth = Math.min(width - cropLeft, Math.round(scaleX * width)); 48 | const cropHeight = Math.min(height - cropTop, Math.round(scaleY * height)); 49 | 50 | // Validate crop dimensions 51 | if (cropWidth <= 0 || cropHeight <= 0) { 52 | Logger.log(`Invalid crop dimensions for ${imagePath}, using original image`); 53 | return imagePath; 54 | } 55 | 56 | // Overwrite the original file with the cropped version 57 | const tempPath = imagePath + ".tmp"; 58 | 59 | // Apply crop transformation to temporary file first 60 | await image 61 | .extract({ 62 | left: cropLeft, 63 | top: cropTop, 64 | width: cropWidth, 65 | height: cropHeight, 66 | }) 67 | .toFile(tempPath); 68 | 69 | // Replace original file with cropped version 70 | fs.renameSync(tempPath, imagePath); 71 | 72 | Logger.log(`Cropped image saved (overwritten): ${imagePath}`); 73 | Logger.log( 74 | `Crop region: ${cropLeft}, ${cropTop}, ${cropWidth}x${cropHeight} from ${width}x${height}`, 75 | ); 76 | 77 | return imagePath; 78 | } catch (error) { 79 | Logger.error(`Error cropping image ${imagePath}:`, error); 80 | // Return original path if cropping fails 81 | return imagePath; 82 | } 83 | } 84 | 85 | /** 86 | * Get image dimensions from a file 87 | * @param imagePath - Path to the image file 88 | * @returns Promise<{width: number, height: number}> 89 | */ 90 | export async function getImageDimensions(imagePath: string): Promise<{ 91 | width: number; 92 | height: number; 93 | }> { 94 | const { Logger } = await import("./logger.js"); 95 | 96 | try { 97 | const metadata = await sharp(imagePath).metadata(); 98 | 99 | if (!metadata.width || !metadata.height) { 100 | throw new Error(`Could not get image dimensions for ${imagePath}`); 101 | } 102 | 103 | return { 104 | width: metadata.width, 105 | height: metadata.height, 106 | }; 107 | } catch (error) { 108 | Logger.error(`Error getting image dimensions for ${imagePath}:`, error); 109 | // Return default dimensions if reading fails 110 | return { width: 1000, height: 1000 }; 111 | } 112 | } 113 | 114 | export type ImageProcessingResult = { 115 | filePath: string; 116 | originalDimensions: { width: number; height: number }; 117 | finalDimensions: { width: number; height: number }; 118 | wasCropped: boolean; 119 | cropRegion?: { left: number; top: number; width: number; height: number }; 120 | cssVariables?: string; 121 | processingLog: string[]; 122 | }; 123 | 124 | /** 125 | * Enhanced image download with post-processing 126 | * @param fileName - The filename to save as 127 | * @param localPath - The local path to save to 128 | * @param imageUrl - Image URL 129 | * @param needsCropping - Whether to apply crop transform 130 | * @param cropTransform - Transform matrix for cropping 131 | * @param requiresImageDimensions - Whether to generate dimension metadata 132 | * @returns Promise<ImageProcessingResult> - Detailed processing information 133 | */ 134 | export async function downloadAndProcessImage( 135 | fileName: string, 136 | localPath: string, 137 | imageUrl: string, 138 | needsCropping: boolean = false, 139 | cropTransform?: Transform, 140 | requiresImageDimensions: boolean = false, 141 | ): Promise<ImageProcessingResult> { 142 | const { Logger } = await import("./logger.js"); 143 | const processingLog: string[] = []; 144 | 145 | // First download the original image 146 | const { downloadFigmaImage } = await import("./common.js"); 147 | const originalPath = await downloadFigmaImage(fileName, localPath, imageUrl); 148 | Logger.log(`Downloaded original image: ${originalPath}`); 149 | 150 | // Get original dimensions before any processing 151 | const originalDimensions = await getImageDimensions(originalPath); 152 | Logger.log(`Original dimensions: ${originalDimensions.width}x${originalDimensions.height}`); 153 | 154 | let finalPath = originalPath; 155 | let wasCropped = false; 156 | let cropRegion: { left: number; top: number; width: number; height: number } | undefined; 157 | 158 | // Apply crop transform if needed 159 | if (needsCropping && cropTransform) { 160 | Logger.log("Applying crop transform..."); 161 | 162 | // Extract crop region info before applying transform 163 | const scaleX = cropTransform[0]?.[0] ?? 1; 164 | const scaleY = cropTransform[1]?.[1] ?? 1; 165 | const translateX = cropTransform[0]?.[2] ?? 0; 166 | const translateY = cropTransform[1]?.[2] ?? 0; 167 | 168 | const cropLeft = Math.max(0, Math.round(translateX * originalDimensions.width)); 169 | const cropTop = Math.max(0, Math.round(translateY * originalDimensions.height)); 170 | const cropWidth = Math.min( 171 | originalDimensions.width - cropLeft, 172 | Math.round(scaleX * originalDimensions.width), 173 | ); 174 | const cropHeight = Math.min( 175 | originalDimensions.height - cropTop, 176 | Math.round(scaleY * originalDimensions.height), 177 | ); 178 | 179 | if (cropWidth > 0 && cropHeight > 0) { 180 | cropRegion = { left: cropLeft, top: cropTop, width: cropWidth, height: cropHeight }; 181 | finalPath = await applyCropTransform(originalPath, cropTransform); 182 | wasCropped = true; 183 | Logger.log(`Cropped to region: ${cropLeft}, ${cropTop}, ${cropWidth}x${cropHeight}`); 184 | } else { 185 | Logger.log("Invalid crop dimensions, keeping original image"); 186 | } 187 | } 188 | 189 | // Get final dimensions after processing 190 | const finalDimensions = await getImageDimensions(finalPath); 191 | Logger.log(`Final dimensions: ${finalDimensions.width}x${finalDimensions.height}`); 192 | 193 | // Generate CSS variables if required (for TILE mode) 194 | let cssVariables: string | undefined; 195 | if (requiresImageDimensions) { 196 | cssVariables = generateImageCSSVariables(finalDimensions); 197 | } 198 | 199 | return { 200 | filePath: finalPath, 201 | originalDimensions, 202 | finalDimensions, 203 | wasCropped, 204 | cropRegion, 205 | cssVariables, 206 | processingLog, 207 | }; 208 | } 209 | 210 | /** 211 | * Create CSS custom properties for image dimensions 212 | * @param imagePath - Path to the image file 213 | * @returns Promise<string> - CSS custom properties 214 | */ 215 | export function generateImageCSSVariables({ 216 | width, 217 | height, 218 | }: { 219 | width: number; 220 | height: number; 221 | }): string { 222 | return `--original-width: ${width}px; --original-height: ${height}px;`; 223 | } 224 | ``` -------------------------------------------------------------------------------- /src/extractors/built-in.ts: -------------------------------------------------------------------------------- ```typescript 1 | import type { 2 | ExtractorFn, 3 | GlobalVars, 4 | StyleTypes, 5 | TraversalContext, 6 | SimplifiedNode, 7 | } from "./types.js"; 8 | import { buildSimplifiedLayout } from "~/transformers/layout.js"; 9 | import { buildSimplifiedStrokes, parsePaint } from "~/transformers/style.js"; 10 | import { buildSimplifiedEffects } from "~/transformers/effects.js"; 11 | import { 12 | extractNodeText, 13 | extractTextStyle, 14 | hasTextStyle, 15 | isTextNode, 16 | } from "~/transformers/text.js"; 17 | import { hasValue, isRectangleCornerRadii } from "~/utils/identity.js"; 18 | import { generateVarId } from "~/utils/common.js"; 19 | import type { Node as FigmaDocumentNode } from "@figma/rest-api-spec"; 20 | 21 | /** 22 | * Helper function to find or create a global variable. 23 | */ 24 | function findOrCreateVar(globalVars: GlobalVars, value: StyleTypes, prefix: string): string { 25 | // Check if the same value already exists 26 | const [existingVarId] = 27 | Object.entries(globalVars.styles).find( 28 | ([_, existingValue]) => JSON.stringify(existingValue) === JSON.stringify(value), 29 | ) ?? []; 30 | 31 | if (existingVarId) { 32 | return existingVarId; 33 | } 34 | 35 | // Create a new variable if it doesn't exist 36 | const varId = generateVarId(prefix); 37 | globalVars.styles[varId] = value; 38 | return varId; 39 | } 40 | 41 | /** 42 | * Extracts layout-related properties from a node. 43 | */ 44 | export const layoutExtractor: ExtractorFn = (node, result, context) => { 45 | const layout = buildSimplifiedLayout(node, context.parent); 46 | if (Object.keys(layout).length > 1) { 47 | result.layout = findOrCreateVar(context.globalVars, layout, "layout"); 48 | } 49 | }; 50 | 51 | /** 52 | * Extracts text content and text styling from a node. 53 | */ 54 | export const textExtractor: ExtractorFn = (node, result, context) => { 55 | // Extract text content 56 | if (isTextNode(node)) { 57 | result.text = extractNodeText(node); 58 | } 59 | 60 | // Extract text style 61 | if (hasTextStyle(node)) { 62 | const textStyle = extractTextStyle(node); 63 | if (textStyle) { 64 | // Prefer Figma named style when available 65 | const styleName = getStyleName(node, context, ["text", "typography"]); 66 | if (styleName) { 67 | context.globalVars.styles[styleName] = textStyle; 68 | result.textStyle = styleName; 69 | } else { 70 | result.textStyle = findOrCreateVar(context.globalVars, textStyle, "style"); 71 | } 72 | } 73 | } 74 | }; 75 | 76 | /** 77 | * Extracts visual appearance properties (fills, strokes, effects, opacity, border radius). 78 | */ 79 | export const visualsExtractor: ExtractorFn = (node, result, context) => { 80 | // Check if node has children to determine CSS properties 81 | const hasChildren = 82 | hasValue("children", node) && Array.isArray(node.children) && node.children.length > 0; 83 | 84 | // fills 85 | if (hasValue("fills", node) && Array.isArray(node.fills) && node.fills.length) { 86 | const fills = node.fills.map((fill) => parsePaint(fill, hasChildren)).reverse(); 87 | const styleName = getStyleName(node, context, ["fill", "fills"]); 88 | if (styleName) { 89 | context.globalVars.styles[styleName] = fills; 90 | result.fills = styleName; 91 | } else { 92 | result.fills = findOrCreateVar(context.globalVars, fills, "fill"); 93 | } 94 | } 95 | 96 | // strokes 97 | const strokes = buildSimplifiedStrokes(node, hasChildren); 98 | if (strokes.colors.length) { 99 | const styleName = getStyleName(node, context, ["stroke", "strokes"]); 100 | if (styleName) { 101 | // Only colors are stylable; keep other stroke props on the node 102 | context.globalVars.styles[styleName] = strokes.colors; 103 | result.strokes = styleName; 104 | if (strokes.strokeWeight) result.strokeWeight = strokes.strokeWeight; 105 | if (strokes.strokeDashes) result.strokeDashes = strokes.strokeDashes; 106 | if (strokes.strokeWeights) result.strokeWeights = strokes.strokeWeights; 107 | } else { 108 | result.strokes = findOrCreateVar(context.globalVars, strokes, "stroke"); 109 | } 110 | } 111 | 112 | // effects 113 | const effects = buildSimplifiedEffects(node); 114 | if (Object.keys(effects).length) { 115 | const styleName = getStyleName(node, context, ["effect", "effects"]); 116 | if (styleName) { 117 | // Effects styles store only the effect values 118 | context.globalVars.styles[styleName] = effects; 119 | result.effects = styleName; 120 | } else { 121 | result.effects = findOrCreateVar(context.globalVars, effects, "effect"); 122 | } 123 | } 124 | 125 | // opacity 126 | if (hasValue("opacity", node) && typeof node.opacity === "number" && node.opacity !== 1) { 127 | result.opacity = node.opacity; 128 | } 129 | 130 | // border radius 131 | if (hasValue("cornerRadius", node) && typeof node.cornerRadius === "number") { 132 | result.borderRadius = `${node.cornerRadius}px`; 133 | } 134 | if (hasValue("rectangleCornerRadii", node, isRectangleCornerRadii)) { 135 | result.borderRadius = `${node.rectangleCornerRadii[0]}px ${node.rectangleCornerRadii[1]}px ${node.rectangleCornerRadii[2]}px ${node.rectangleCornerRadii[3]}px`; 136 | } 137 | }; 138 | 139 | /** 140 | * Extracts component-related properties from INSTANCE nodes. 141 | */ 142 | export const componentExtractor: ExtractorFn = (node, result, _context) => { 143 | if (node.type === "INSTANCE") { 144 | if (hasValue("componentId", node)) { 145 | result.componentId = node.componentId; 146 | } 147 | 148 | // Add specific properties for instances of components 149 | if (hasValue("componentProperties", node)) { 150 | result.componentProperties = Object.entries(node.componentProperties ?? {}).map( 151 | ([name, { value, type }]) => ({ 152 | name, 153 | value: value.toString(), 154 | type, 155 | }), 156 | ); 157 | } 158 | } 159 | }; 160 | 161 | // Helper to fetch a Figma style name for specific style keys on a node 162 | function getStyleName( 163 | node: FigmaDocumentNode, 164 | context: TraversalContext, 165 | keys: string[], 166 | ): string | undefined { 167 | if (!hasValue("styles", node)) return undefined; 168 | const styleMap = node.styles as Record<string, string>; 169 | for (const key of keys) { 170 | const styleId = styleMap[key]; 171 | if (styleId) { 172 | const meta = context.globalVars.extraStyles?.[styleId]; 173 | if (meta?.name) return meta.name; 174 | } 175 | } 176 | return undefined; 177 | } 178 | 179 | // -------------------- CONVENIENCE COMBINATIONS -------------------- 180 | 181 | /** 182 | * All extractors - replicates the current parseNode behavior. 183 | */ 184 | export const allExtractors = [layoutExtractor, textExtractor, visualsExtractor, componentExtractor]; 185 | 186 | /** 187 | * Layout and text only - useful for content analysis and layout planning. 188 | */ 189 | export const layoutAndText = [layoutExtractor, textExtractor]; 190 | 191 | /** 192 | * Text content only - useful for content audits and copy extraction. 193 | */ 194 | export const contentOnly = [textExtractor]; 195 | 196 | /** 197 | * Visuals only - useful for design system analysis and style extraction. 198 | */ 199 | export const visualsOnly = [visualsExtractor]; 200 | 201 | /** 202 | * Layout only - useful for structure analysis. 203 | */ 204 | export const layoutOnly = [layoutExtractor]; 205 | 206 | // -------------------- AFTER CHILDREN HELPERS -------------------- 207 | 208 | /** 209 | * Node types that can be exported as SVG images. 210 | * When a FRAME, GROUP, or INSTANCE contains only these types, we can collapse it to IMAGE-SVG. 211 | * Note: FRAME/GROUP/INSTANCE are NOT included here—they're only eligible if collapsed to IMAGE-SVG. 212 | */ 213 | export const SVG_ELIGIBLE_TYPES = new Set([ 214 | "IMAGE-SVG", // VECTOR nodes are converted to IMAGE-SVG, or containers that were collapsed 215 | "STAR", 216 | "LINE", 217 | "ELLIPSE", 218 | "REGULAR_POLYGON", 219 | "RECTANGLE", 220 | ]); 221 | 222 | /** 223 | * afterChildren callback that collapses SVG-heavy containers to IMAGE-SVG. 224 | * 225 | * If a FRAME, GROUP, or INSTANCE contains only SVG-eligible children, the parent 226 | * is marked as IMAGE-SVG and children are omitted, reducing payload size. 227 | * 228 | * @param node - Original Figma node 229 | * @param result - SimplifiedNode being built 230 | * @param children - Processed children 231 | * @returns Children to include (empty array if collapsed) 232 | */ 233 | export function collapseSvgContainers( 234 | node: FigmaDocumentNode, 235 | result: SimplifiedNode, 236 | children: SimplifiedNode[], 237 | ): SimplifiedNode[] { 238 | const allChildrenAreSvgEligible = children.every((child) => 239 | SVG_ELIGIBLE_TYPES.has(child.type), 240 | ); 241 | 242 | if ( 243 | (node.type === "FRAME" || node.type === "GROUP" || node.type === "INSTANCE") && 244 | allChildrenAreSvgEligible 245 | ) { 246 | // Collapse to IMAGE-SVG and omit children 247 | result.type = "IMAGE-SVG"; 248 | return []; 249 | } 250 | 251 | // Include all children normally 252 | return children; 253 | } 254 | ``` -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { randomUUID } from "node:crypto"; 2 | import express, { type Request, type 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 "./utils/logger.js"; 9 | import { createServer } from "./mcp/index.js"; 10 | import { getServerConfig } from "./config.js"; 11 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 12 | 13 | let httpServer: Server | null = null; 14 | const transports = { 15 | streamable: {} as Record<string, StreamableHTTPServerTransport>, 16 | sse: {} as Record<string, SSEServerTransport>, 17 | }; 18 | 19 | /** 20 | * Start the MCP server in either stdio or HTTP mode. 21 | */ 22 | export async function startServer(): Promise<void> { 23 | // Check if we're running in stdio mode (e.g., via CLI) 24 | const isStdioMode = process.env.NODE_ENV === "cli" || process.argv.includes("--stdio"); 25 | 26 | const config = getServerConfig(isStdioMode); 27 | 28 | const server = createServer(config.auth, { 29 | isHTTP: !isStdioMode, 30 | outputFormat: config.outputFormat, 31 | skipImageDownloads: config.skipImageDownloads, 32 | }); 33 | 34 | if (isStdioMode) { 35 | const transport = new StdioServerTransport(); 36 | await server.connect(transport); 37 | } else { 38 | console.log(`Initializing Figma MCP Server in HTTP mode on port ${config.port}...`); 39 | await startHttpServer(config.port, server); 40 | } 41 | } 42 | 43 | export async function startHttpServer(port: number, mcpServer: McpServer): Promise<void> { 44 | const app = express(); 45 | 46 | // Parse JSON requests for the Streamable HTTP endpoint only, will break SSE endpoint 47 | app.use("/mcp", express.json()); 48 | 49 | // Modern Streamable HTTP endpoint 50 | app.post("/mcp", async (req, res) => { 51 | Logger.log("Received StreamableHTTP request"); 52 | const sessionId = req.headers["mcp-session-id"] as string | undefined; 53 | // Logger.log("Session ID:", sessionId); 54 | // Logger.log("Headers:", req.headers); 55 | // Logger.log("Body:", req.body); 56 | // Logger.log("Is Initialize Request:", isInitializeRequest(req.body)); 57 | let transport: StreamableHTTPServerTransport; 58 | 59 | if (sessionId && transports.streamable[sessionId]) { 60 | // Reuse existing transport 61 | Logger.log("Reusing existing StreamableHTTP transport for sessionId", sessionId); 62 | transport = transports.streamable[sessionId]; 63 | } else if (!sessionId && isInitializeRequest(req.body)) { 64 | Logger.log("New initialization request for StreamableHTTP sessionId", sessionId); 65 | transport = new StreamableHTTPServerTransport({ 66 | sessionIdGenerator: () => randomUUID(), 67 | onsessioninitialized: (sessionId) => { 68 | // Store the transport by session ID 69 | transports.streamable[sessionId] = transport; 70 | }, 71 | }); 72 | transport.onclose = () => { 73 | if (transport.sessionId) { 74 | delete transports.streamable[transport.sessionId]; 75 | } 76 | }; 77 | // 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" 78 | await mcpServer.connect(transport); 79 | } else { 80 | // Invalid request 81 | Logger.log("Invalid request:", req.body); 82 | res.status(400).json({ 83 | jsonrpc: "2.0", 84 | error: { 85 | code: -32000, 86 | message: "Bad Request: No valid session ID provided", 87 | }, 88 | id: null, 89 | }); 90 | return; 91 | } 92 | 93 | let progressInterval: NodeJS.Timeout | null = null; 94 | const progressToken = req.body.params?._meta?.progressToken; 95 | // Logger.log("Progress token:", progressToken); 96 | let progress = 0; 97 | if (progressToken) { 98 | Logger.log( 99 | `Setting up progress notifications for token ${progressToken} on session ${sessionId}`, 100 | ); 101 | progressInterval = setInterval(async () => { 102 | Logger.log("Sending progress notification", progress); 103 | await mcpServer.server.notification({ 104 | method: "notifications/progress", 105 | params: { 106 | progress, 107 | progressToken, 108 | }, 109 | }); 110 | progress++; 111 | }, 1000); 112 | } 113 | 114 | Logger.log("Handling StreamableHTTP request"); 115 | await transport.handleRequest(req, res, req.body); 116 | 117 | if (progressInterval) { 118 | clearInterval(progressInterval); 119 | } 120 | Logger.log("StreamableHTTP request handled"); 121 | }); 122 | 123 | // Handle GET requests for SSE streams (using built-in support from StreamableHTTP) 124 | const handleSessionRequest = async (req: Request, res: Response) => { 125 | const sessionId = req.headers["mcp-session-id"] as string | undefined; 126 | if (!sessionId || !transports.streamable[sessionId]) { 127 | res.status(400).send("Invalid or missing session ID"); 128 | return; 129 | } 130 | 131 | console.log(`Received session termination request for session ${sessionId}`); 132 | 133 | try { 134 | const transport = transports.streamable[sessionId]; 135 | await transport.handleRequest(req, res); 136 | } catch (error) { 137 | console.error("Error handling session termination:", error); 138 | if (!res.headersSent) { 139 | res.status(500).send("Error processing session termination"); 140 | } 141 | } 142 | }; 143 | 144 | // Handle GET requests for server-to-client notifications via SSE 145 | app.get("/mcp", handleSessionRequest); 146 | 147 | // Handle DELETE requests for session termination 148 | app.delete("/mcp", handleSessionRequest); 149 | 150 | app.get("/sse", async (req, res) => { 151 | Logger.log("Establishing new SSE connection"); 152 | const transport = new SSEServerTransport("/messages", res); 153 | Logger.log(`New SSE connection established for sessionId ${transport.sessionId}`); 154 | Logger.log("/sse request headers:", req.headers); 155 | Logger.log("/sse request body:", req.body); 156 | 157 | transports.sse[transport.sessionId] = transport; 158 | res.on("close", () => { 159 | delete transports.sse[transport.sessionId]; 160 | }); 161 | 162 | await mcpServer.connect(transport); 163 | }); 164 | 165 | app.post("/messages", async (req, res) => { 166 | const sessionId = req.query.sessionId as string; 167 | const transport = transports.sse[sessionId]; 168 | if (transport) { 169 | Logger.log(`Received SSE message for sessionId ${sessionId}`); 170 | Logger.log("/messages request headers:", req.headers); 171 | Logger.log("/messages request body:", req.body); 172 | await transport.handlePostMessage(req, res); 173 | } else { 174 | res.status(400).send(`No transport found for sessionId ${sessionId}`); 175 | return; 176 | } 177 | }); 178 | 179 | httpServer = app.listen(port, "127.0.0.1", () => { 180 | Logger.log(`HTTP server listening on port ${port}`); 181 | Logger.log(`SSE endpoint available at http://localhost:${port}/sse`); 182 | Logger.log(`Message endpoint available at http://localhost:${port}/messages`); 183 | Logger.log(`StreamableHTTP endpoint available at http://localhost:${port}/mcp`); 184 | }); 185 | 186 | process.on("SIGINT", async () => { 187 | Logger.log("Shutting down server..."); 188 | 189 | // Close all active transports to properly clean up resources 190 | await closeTransports(transports.sse); 191 | await closeTransports(transports.streamable); 192 | 193 | Logger.log("Server shutdown complete"); 194 | process.exit(0); 195 | }); 196 | } 197 | 198 | async function closeTransports( 199 | transports: Record<string, SSEServerTransport | StreamableHTTPServerTransport>, 200 | ) { 201 | for (const sessionId in transports) { 202 | try { 203 | await transports[sessionId]?.close(); 204 | delete transports[sessionId]; 205 | } catch (error) { 206 | console.error(`Error closing transport for session ${sessionId}:`, error); 207 | } 208 | } 209 | } 210 | 211 | export async function stopHttpServer(): Promise<void> { 212 | if (!httpServer) { 213 | throw new Error("HTTP server is not running"); 214 | } 215 | 216 | return new Promise((resolve, reject) => { 217 | httpServer!.close((err: Error | undefined) => { 218 | if (err) { 219 | reject(err); 220 | return; 221 | } 222 | httpServer = null; 223 | const closing = Object.values(transports.sse).map((transport) => { 224 | return transport.close(); 225 | }); 226 | Promise.all(closing).then(() => { 227 | resolve(); 228 | }); 229 | }); 230 | }); 231 | } 232 | ``` -------------------------------------------------------------------------------- /src/transformers/layout.ts: -------------------------------------------------------------------------------- ```typescript 1 | import { isInAutoLayoutFlow, 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, pixelRound } 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 ( 211 | // If parent is a frame but not an AutoLayout, or if the node is absolute, include positioning-related properties 212 | isFrame(parent) && 213 | !isInAutoLayoutFlow(n, parent) 214 | ) { 215 | if (n.layoutPositioning === "ABSOLUTE") { 216 | layoutValues.position = "absolute"; 217 | } 218 | if (n.absoluteBoundingBox && parent.absoluteBoundingBox) { 219 | layoutValues.locationRelativeToParent = { 220 | x: pixelRound(n.absoluteBoundingBox.x - parent.absoluteBoundingBox.x), 221 | y: pixelRound(n.absoluteBoundingBox.y - parent.absoluteBoundingBox.y), 222 | }; 223 | } 224 | } 225 | 226 | // Handle dimensions based on layout growth and alignment 227 | if (isRectangle("absoluteBoundingBox", n)) { 228 | const dimensions: { width?: number; height?: number; aspectRatio?: number } = {}; 229 | 230 | // Only include dimensions that aren't meant to stretch 231 | if (mode === "row") { 232 | // AutoLayout row, only include dimensions if the node is not growing 233 | if (!n.layoutGrow && n.layoutSizingHorizontal == "FIXED") 234 | dimensions.width = n.absoluteBoundingBox.width; 235 | if (n.layoutAlign !== "STRETCH" && n.layoutSizingVertical == "FIXED") 236 | dimensions.height = n.absoluteBoundingBox.height; 237 | } else if (mode === "column") { 238 | // AutoLayout column, only include dimensions if the node is not growing 239 | if (n.layoutAlign !== "STRETCH" && n.layoutSizingHorizontal == "FIXED") 240 | dimensions.width = n.absoluteBoundingBox.width; 241 | if (!n.layoutGrow && n.layoutSizingVertical == "FIXED") 242 | dimensions.height = n.absoluteBoundingBox.height; 243 | 244 | if (n.preserveRatio) { 245 | dimensions.aspectRatio = n.absoluteBoundingBox?.width / n.absoluteBoundingBox?.height; 246 | } 247 | } else { 248 | // Node is not an AutoLayout. Include dimensions if the node is not growing (which it should never be) 249 | if (!n.layoutSizingHorizontal || n.layoutSizingHorizontal === "FIXED") { 250 | dimensions.width = n.absoluteBoundingBox.width; 251 | } 252 | if (!n.layoutSizingVertical || n.layoutSizingVertical === "FIXED") { 253 | dimensions.height = n.absoluteBoundingBox.height; 254 | } 255 | } 256 | 257 | if (Object.keys(dimensions).length > 0) { 258 | if (dimensions.width) { 259 | dimensions.width = pixelRound(dimensions.width); 260 | } 261 | if (dimensions.height) { 262 | dimensions.height = pixelRound(dimensions.height); 263 | } 264 | layoutValues.dimensions = dimensions; 265 | } 266 | } 267 | 268 | return layoutValues; 269 | } 270 | ``` -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- ```markdown 1 | # figma-developer-mcp 2 | 3 | ## 0.6.4 4 | 5 | ### Patch Changes 6 | 7 | - [#250](https://github.com/GLips/Figma-Context-MCP/pull/250) [`9966623`](https://github.com/GLips/Figma-Context-MCP/commit/996662352cdeaa8e6d4a6f64154d6135c00a35ee) Thanks [@GLips](https://github.com/GLips)! - Collapse containers that only have vector children to better handle SVG image downloads and also make output size smaller. 8 | 9 | ## 0.6.3 10 | 11 | ### Patch Changes 12 | 13 | - [#246](https://github.com/GLips/Figma-Context-MCP/pull/246) [`7f4b585`](https://github.com/GLips/Figma-Context-MCP/commit/7f4b5859454b0567c2121ff22c69a0344680b124) Thanks [@GLips](https://github.com/GLips)! - Updates to validate user input, run HTTP server on localhost only 14 | 15 | ## 0.6.2 16 | 17 | ### Patch Changes 18 | 19 | - [#244](https://github.com/GLips/Figma-Context-MCP/pull/244) [`8277424`](https://github.com/GLips/Figma-Context-MCP/commit/8277424205e6421a133ac38086f6eb7ac124ea65) Thanks [@GLips](https://github.com/GLips)! - Support imports without starting server or looking for env vars. 20 | 21 | ## 0.6.1 22 | 23 | ### Patch Changes 24 | 25 | - [#240](https://github.com/GLips/Figma-Context-MCP/pull/240) [`2b1923d`](https://github.com/GLips/Figma-Context-MCP/commit/2b1923dcf50275a3d4daf9279265d27c6fadb2f7) Thanks [@GLips](https://github.com/GLips)! - Fix issue where importing package triggered config check. 26 | 27 | - [#239](https://github.com/GLips/Figma-Context-MCP/pull/239) [`00bad7d`](https://github.com/GLips/Figma-Context-MCP/commit/00bad7dae48a6d0cc55d78560cc691a39271f151) Thanks [@Hengkai-Ye](https://github.com/Hengkai-Ye)! - Fix: Make sure LLM provides a filename extension when calling download_figma_images 28 | 29 | ## 0.6.0 30 | 31 | ### Minor Changes 32 | 33 | - [#233](https://github.com/GLips/Figma-Context-MCP/pull/233) [`26a048b`](https://github.com/GLips/Figma-Context-MCP/commit/26a048bbd09db2b7e5265b5777609fb619617068) Thanks [@scarf005](https://github.com/scarf005)! - Return named styles from Figma instead of auto-generated IDs when they exist. 34 | 35 | ## 0.5.2 36 | 37 | ### Patch Changes 38 | 39 | - [#227](https://github.com/GLips/Figma-Context-MCP/pull/227) [`68fbc87`](https://github.com/GLips/Figma-Context-MCP/commit/68fbc87645d25c57252d4d9bec5f43ee4238b09f) Thanks [@fightZy](https://github.com/fightZy)! - Update Node ID regex to support additional formats, e.g. multiple nodes. 40 | 41 | ## 0.5.1 42 | 43 | ### Patch Changes 44 | 45 | - [#205](https://github.com/GLips/Figma-Context-MCP/pull/205) [`618bbe9`](https://github.com/GLips/Figma-Context-MCP/commit/618bbe98c49428e617de0240f0e9c2842867ae9b) Thanks [@GLips](https://github.com/GLips)! - Calculate gradient values instead of passing raw Figma data. 46 | 47 | ## 0.5.0 48 | 49 | ### Minor Changes 50 | 51 | - [#197](https://github.com/GLips/Figma-Context-MCP/pull/197) [`d67ff14`](https://github.com/GLips/Figma-Context-MCP/commit/d67ff143347bb1dbc152157b75d6e8b290dabb0f) Thanks [@GLips](https://github.com/GLips)! - Improve structure of MCP files, change strategy used for parsing Figma files to make it more flexible and extensible. 52 | 53 | - [#199](https://github.com/GLips/Figma-Context-MCP/pull/199) [`a8b59bf`](https://github.com/GLips/Figma-Context-MCP/commit/a8b59bf079128c9dba0bf6d8cd1601b8a6654b88) Thanks [@GLips](https://github.com/GLips)! - Add support for pattern fills in Figma. 54 | 55 | - [#203](https://github.com/GLips/Figma-Context-MCP/pull/203) [`edf4182`](https://github.com/GLips/Figma-Context-MCP/commit/edf41826f5bd4ebe6ea353a9c9b8be669f0ae659) Thanks [@GLips](https://github.com/GLips)! - Add support for Fill, Fit, Crop and Tile image types in Figma. Adds image post-processing step. 56 | 57 | ### Patch Changes 58 | 59 | - [#202](https://github.com/GLips/Figma-Context-MCP/pull/202) [`4a44681`](https://github.com/GLips/Figma-Context-MCP/commit/4a44681903f1c071c5892454d19370ed89ecd0a3) Thanks [@GLips](https://github.com/GLips)! - Add --skip-image-downloads option to CLI args and SKIP_IMAGE_DOWNLOADS env var to hide the download image tool when set. 60 | 61 | ## 0.4.3 62 | 63 | ### Patch Changes 64 | 65 | - [#179](https://github.com/GLips/Figma-Context-MCP/pull/179) [`17988a0`](https://github.com/GLips/Figma-Context-MCP/commit/17988a0b5543330c6b8f7f24baa33b65a0da7957) Thanks [@GLips](https://github.com/GLips)! - Update curl command in fetchWithRetry to include error handling options, ensure errors are actually caught properly and returned to users. 66 | 67 | ## 0.4.2 68 | 69 | ### Patch Changes 70 | 71 | - [#170](https://github.com/GLips/Figma-Context-MCP/pull/170) [`d560252`](https://github.com/GLips/Figma-Context-MCP/commit/d56025286e8c3c24d75f170974c12f96d32fda8b) Thanks [@GLips](https://github.com/GLips)! - Add support for custom .env files. 72 | 73 | ## 0.4.1 74 | 75 | ### Patch Changes 76 | 77 | - [#161](https://github.com/GLips/Figma-Context-MCP/pull/161) [`8d34c6c`](https://github.com/GLips/Figma-Context-MCP/commit/8d34c6c23df3b2be5d5366723aeefdc2cca0a904) Thanks [@YossiSaadi](https://github.com/YossiSaadi)! - Add --json CLI flag and OUTPUT_FORMAT env var to support JSON output format in addition to YAML. 78 | 79 | ## 0.4.0 80 | 81 | ### Minor Changes 82 | 83 | - [#126](https://github.com/GLips/Figma-Context-MCP/pull/126) [`6e99226`](https://github.com/GLips/Figma-Context-MCP/commit/6e9922693dcff70b69be6b505e24062a89e821f0) Thanks [@habakan](https://github.com/habakan)! - Add SVG export options to control text outlining, id inclusion, and whether strokes should be simplified. 84 | 85 | ### Patch Changes 86 | 87 | - [#153](https://github.com/GLips/Figma-Context-MCP/pull/153) [`4d58e83`](https://github.com/GLips/Figma-Context-MCP/commit/4d58e83d2e56e2bc1a4799475f29ffe2a18d6868) Thanks [@miraclehen](https://github.com/miraclehen)! - Refactor layout positioning logic and add pixel rounding. 88 | 89 | - [#112](https://github.com/GLips/Figma-Context-MCP/pull/112) [`c48b802`](https://github.com/GLips/Figma-Context-MCP/commit/c48b802ff653cfc46fe6077a8dc96bd4a15edb40) Thanks [@dgxyzw](https://github.com/dgxyzw)! - Change format of component properties in simplified response. 90 | 91 | - [#150](https://github.com/GLips/Figma-Context-MCP/pull/150) [`4a4318f`](https://github.com/GLips/Figma-Context-MCP/commit/4a4318faa6c2eb91a08e6cc2e41e3f9e2f499a41) Thanks [@GLips](https://github.com/GLips)! - Add curl fallback to make API requests more robust in corporate environments 92 | 93 | - [#149](https://github.com/GLips/Figma-Context-MCP/pull/149) [`46550f9`](https://github.com/GLips/Figma-Context-MCP/commit/46550f91340969cf3683f4537aefc87d807f1b64) Thanks [@miraclehen](https://github.com/miraclehen)! - Resolve promise in image downloading function only after file is finished writing. 94 | 95 | ## 0.3.1 96 | 97 | ### Patch Changes 98 | 99 | - [#133](https://github.com/GLips/Figma-Context-MCP/pull/133) [`983375d`](https://github.com/GLips/Figma-Context-MCP/commit/983375d3fe7f2c4b48ce770b13e5b8cb06b162d0) Thanks [@dgomez-orangeloops](https://github.com/dgomez-orangeloops)! - Auto-update package version in code. 100 | 101 | ## 0.3.0 102 | 103 | ### Minor Changes 104 | 105 | - [#122](https://github.com/GLips/Figma-Context-MCP/pull/122) [`60c663e`](https://github.com/GLips/Figma-Context-MCP/commit/60c663e6a83886b03eb2cde7c60433439e2cedd0) Thanks [@YossiSaadi](https://github.com/YossiSaadi)! - Include component and component set names to help LLMs find pre-existing components in code 106 | 107 | - [#109](https://github.com/GLips/Figma-Context-MCP/pull/109) [`64a1b10`](https://github.com/GLips/Figma-Context-MCP/commit/64a1b10fb62e4ccb5d456d4701ab1fac82084af3) Thanks [@jonmabe](https://github.com/jonmabe)! - Add OAuth token support using Authorization Bearer method for alternate Figma auth. 108 | 109 | - [#128](https://github.com/GLips/Figma-Context-MCP/pull/128) [`3761a70`](https://github.com/GLips/Figma-Context-MCP/commit/3761a70db57b3f038335a5fb568c2ca5ff45ad21) Thanks [@miraclehen](https://github.com/miraclehen)! - Handle size calculations for non-AutoLayout elements and absolutely positioned elements. 110 | 111 | ### Patch Changes 112 | 113 | - [#106](https://github.com/GLips/Figma-Context-MCP/pull/106) [`4237a53`](https://github.com/GLips/Figma-Context-MCP/commit/4237a5363f696dcf7abe046940180b6861bdcf22) Thanks [@saharis9988](https://github.com/saharis9988)! - Remove empty keys from simplified design output. 114 | 115 | - [#119](https://github.com/GLips/Figma-Context-MCP/pull/119) [`d69d96f`](https://github.com/GLips/Figma-Context-MCP/commit/d69d96fd8a99c9b59111d9c89613a74c1ac7aa7d) Thanks [@cooliceman](https://github.com/cooliceman)! - Add scale support for PNG images pulled via download_figma_images tool. 116 | 117 | - [#129](https://github.com/GLips/Figma-Context-MCP/pull/129) [`56f968c`](https://github.com/GLips/Figma-Context-MCP/commit/56f968cd944cbf3058f71f3285c363e895dcf91d) Thanks [@fightZy](https://github.com/fightZy)! - Make shadows on text nodes apply text-shadow rather than box-shadow 118 | 119 | ## 0.2.2 120 | 121 | ### Patch Changes 122 | 123 | - fd10a46: - Update HTTP server creation method to no longer subclass McpServer 124 | - Change logging behavior on HTTP server 125 | - 6e2c8f5: Minor bump, testing fix for hanging CF DOs 126 | 127 | ## 0.2.2-beta.1 128 | 129 | ### Patch Changes 130 | 131 | - 6e2c8f5: Minor bump, testing fix for hanging CF DOs 132 | 133 | ## 0.2.2-beta.0 134 | 135 | ### Patch Changes 136 | 137 | - fd10a46: - Update HTTP server creation method to no longer subclass McpServer 138 | - Change logging behavior on HTTP server 139 | ``` -------------------------------------------------------------------------------- /src/services/figma.ts: -------------------------------------------------------------------------------- ```typescript 1 | import path from "path"; 2 | import type { 3 | GetImagesResponse, 4 | GetFileResponse, 5 | GetFileNodesResponse, 6 | GetImageFillsResponse, 7 | } from "@figma/rest-api-spec"; 8 | import { downloadFigmaImage } from "~/utils/common.js"; 9 | import { downloadAndProcessImage, type ImageProcessingResult } from "~/utils/image-processing.js"; 10 | import { Logger, writeLogs } from "~/utils/logger.js"; 11 | import { fetchWithRetry } from "~/utils/fetch-with-retry.js"; 12 | 13 | export type FigmaAuthOptions = { 14 | figmaApiKey: string; 15 | figmaOAuthToken: string; 16 | useOAuth: boolean; 17 | }; 18 | 19 | type SvgOptions = { 20 | outlineText: boolean; 21 | includeId: boolean; 22 | simplifyStroke: boolean; 23 | }; 24 | 25 | export class FigmaService { 26 | private readonly apiKey: string; 27 | private readonly oauthToken: string; 28 | private readonly useOAuth: boolean; 29 | private readonly baseUrl = "https://api.figma.com/v1"; 30 | 31 | constructor({ figmaApiKey, figmaOAuthToken, useOAuth }: FigmaAuthOptions) { 32 | this.apiKey = figmaApiKey || ""; 33 | this.oauthToken = figmaOAuthToken || ""; 34 | this.useOAuth = !!useOAuth && !!this.oauthToken; 35 | } 36 | 37 | private getAuthHeaders(): Record<string, string> { 38 | if (this.useOAuth) { 39 | Logger.log("Using OAuth Bearer token for authentication"); 40 | return { Authorization: `Bearer ${this.oauthToken}` }; 41 | } else { 42 | Logger.log("Using Personal Access Token for authentication"); 43 | return { "X-Figma-Token": this.apiKey }; 44 | } 45 | } 46 | 47 | /** 48 | * Filters out null values from Figma image responses. This ensures we only work with valid image URLs. 49 | */ 50 | private filterValidImages( 51 | images: { [key: string]: string | null } | undefined, 52 | ): Record<string, string> { 53 | if (!images) return {}; 54 | return Object.fromEntries(Object.entries(images).filter(([, value]) => !!value)) as Record< 55 | string, 56 | string 57 | >; 58 | } 59 | 60 | private async request<T>(endpoint: string): Promise<T> { 61 | try { 62 | Logger.log(`Calling ${this.baseUrl}${endpoint}`); 63 | const headers = this.getAuthHeaders(); 64 | 65 | return await fetchWithRetry<T & { status?: number }>(`${this.baseUrl}${endpoint}`, { 66 | headers, 67 | }); 68 | } catch (error) { 69 | const errorMessage = error instanceof Error ? error.message : String(error); 70 | throw new Error( 71 | `Failed to make request to Figma API endpoint '${endpoint}': ${errorMessage}`, 72 | ); 73 | } 74 | } 75 | 76 | /** 77 | * Builds URL query parameters for SVG image requests. 78 | */ 79 | private buildSvgQueryParams(svgIds: string[], svgOptions: SvgOptions): string { 80 | const params = new URLSearchParams({ 81 | ids: svgIds.join(","), 82 | format: "svg", 83 | svg_outline_text: String(svgOptions.outlineText), 84 | svg_include_id: String(svgOptions.includeId), 85 | svg_simplify_stroke: String(svgOptions.simplifyStroke), 86 | }); 87 | return params.toString(); 88 | } 89 | 90 | /** 91 | * Gets download URLs for image fills without downloading them. 92 | * 93 | * @returns Map of imageRef to download URL 94 | */ 95 | async getImageFillUrls(fileKey: string): Promise<Record<string, string>> { 96 | const endpoint = `/files/${fileKey}/images`; 97 | const response = await this.request<GetImageFillsResponse>(endpoint); 98 | return response.meta.images || {}; 99 | } 100 | 101 | /** 102 | * Gets download URLs for rendered nodes without downloading them. 103 | * 104 | * @returns Map of node ID to download URL 105 | */ 106 | async getNodeRenderUrls( 107 | fileKey: string, 108 | nodeIds: string[], 109 | format: "png" | "svg", 110 | options: { pngScale?: number; svgOptions?: SvgOptions } = {}, 111 | ): Promise<Record<string, string>> { 112 | if (nodeIds.length === 0) return {}; 113 | 114 | if (format === "png") { 115 | const scale = options.pngScale || 2; 116 | const endpoint = `/images/${fileKey}?ids=${nodeIds.join(",")}&format=png&scale=${scale}`; 117 | const response = await this.request<GetImagesResponse>(endpoint); 118 | return this.filterValidImages(response.images); 119 | } else { 120 | const svgOptions = options.svgOptions || { 121 | outlineText: true, 122 | includeId: false, 123 | simplifyStroke: true, 124 | }; 125 | const params = this.buildSvgQueryParams(nodeIds, svgOptions); 126 | const endpoint = `/images/${fileKey}?${params}`; 127 | const response = await this.request<GetImagesResponse>(endpoint); 128 | return this.filterValidImages(response.images); 129 | } 130 | } 131 | 132 | /** 133 | * Download images method with post-processing support for cropping and returning image dimensions. 134 | * 135 | * Supports: 136 | * - Image fills vs rendered nodes (based on imageRef vs nodeId) 137 | * - PNG vs SVG format (based on filename extension) 138 | * - Image cropping based on transform matrices 139 | * - CSS variable generation for image dimensions 140 | * 141 | * @returns Array of local file paths for successfully downloaded images 142 | */ 143 | async downloadImages( 144 | fileKey: string, 145 | localPath: string, 146 | items: Array<{ 147 | imageRef?: string; 148 | nodeId?: string; 149 | fileName: string; 150 | needsCropping?: boolean; 151 | cropTransform?: any; 152 | requiresImageDimensions?: boolean; 153 | }>, 154 | options: { pngScale?: number; svgOptions?: SvgOptions } = {}, 155 | ): Promise<ImageProcessingResult[]> { 156 | if (items.length === 0) return []; 157 | 158 | const sanitizedPath = path.normalize(localPath).replace(/^(\.\.(\/|\\|$))+/, ""); 159 | const resolvedPath = path.resolve(sanitizedPath); 160 | if (!resolvedPath.startsWith(path.resolve(process.cwd()))) { 161 | throw new Error("Invalid path specified. Directory traversal is not allowed."); 162 | } 163 | 164 | const { pngScale = 2, svgOptions } = options; 165 | const downloadPromises: Promise<ImageProcessingResult[]>[] = []; 166 | 167 | // Separate items by type 168 | const imageFills = items.filter( 169 | (item): item is typeof item & { imageRef: string } => !!item.imageRef, 170 | ); 171 | const renderNodes = items.filter( 172 | (item): item is typeof item & { nodeId: string } => !!item.nodeId, 173 | ); 174 | 175 | // Download image fills with processing 176 | if (imageFills.length > 0) { 177 | const fillUrls = await this.getImageFillUrls(fileKey); 178 | const fillDownloads = imageFills 179 | .map(({ imageRef, fileName, needsCropping, cropTransform, requiresImageDimensions }) => { 180 | const imageUrl = fillUrls[imageRef]; 181 | return imageUrl 182 | ? downloadAndProcessImage( 183 | fileName, 184 | resolvedPath, 185 | imageUrl, 186 | needsCropping, 187 | cropTransform, 188 | requiresImageDimensions, 189 | ) 190 | : null; 191 | }) 192 | .filter((promise): promise is Promise<ImageProcessingResult> => promise !== null); 193 | 194 | if (fillDownloads.length > 0) { 195 | downloadPromises.push(Promise.all(fillDownloads)); 196 | } 197 | } 198 | 199 | // Download rendered nodes with processing 200 | if (renderNodes.length > 0) { 201 | const pngNodes = renderNodes.filter((node) => !node.fileName.toLowerCase().endsWith(".svg")); 202 | const svgNodes = renderNodes.filter((node) => node.fileName.toLowerCase().endsWith(".svg")); 203 | 204 | // Download PNG renders 205 | if (pngNodes.length > 0) { 206 | const pngUrls = await this.getNodeRenderUrls( 207 | fileKey, 208 | pngNodes.map((n) => n.nodeId), 209 | "png", 210 | { pngScale }, 211 | ); 212 | const pngDownloads = pngNodes 213 | .map(({ nodeId, fileName, needsCropping, cropTransform, requiresImageDimensions }) => { 214 | const imageUrl = pngUrls[nodeId]; 215 | return imageUrl 216 | ? downloadAndProcessImage( 217 | fileName, 218 | resolvedPath, 219 | imageUrl, 220 | needsCropping, 221 | cropTransform, 222 | requiresImageDimensions, 223 | ) 224 | : null; 225 | }) 226 | .filter((promise): promise is Promise<ImageProcessingResult> => promise !== null); 227 | 228 | if (pngDownloads.length > 0) { 229 | downloadPromises.push(Promise.all(pngDownloads)); 230 | } 231 | } 232 | 233 | // Download SVG renders 234 | if (svgNodes.length > 0) { 235 | const svgUrls = await this.getNodeRenderUrls( 236 | fileKey, 237 | svgNodes.map((n) => n.nodeId), 238 | "svg", 239 | { svgOptions }, 240 | ); 241 | const svgDownloads = svgNodes 242 | .map(({ nodeId, fileName, needsCropping, cropTransform, requiresImageDimensions }) => { 243 | const imageUrl = svgUrls[nodeId]; 244 | return imageUrl 245 | ? downloadAndProcessImage( 246 | fileName, 247 | resolvedPath, 248 | imageUrl, 249 | needsCropping, 250 | cropTransform, 251 | requiresImageDimensions, 252 | ) 253 | : null; 254 | }) 255 | .filter((promise): promise is Promise<ImageProcessingResult> => promise !== null); 256 | 257 | if (svgDownloads.length > 0) { 258 | downloadPromises.push(Promise.all(svgDownloads)); 259 | } 260 | } 261 | } 262 | 263 | const results = await Promise.all(downloadPromises); 264 | return results.flat(); 265 | } 266 | 267 | /** 268 | * Get raw Figma API response for a file (for use with flexible extractors) 269 | */ 270 | async getRawFile(fileKey: string, depth?: number | null): Promise<GetFileResponse> { 271 | const endpoint = `/files/${fileKey}${depth ? `?depth=${depth}` : ""}`; 272 | Logger.log(`Retrieving raw Figma file: ${fileKey} (depth: ${depth ?? "default"})`); 273 | 274 | const response = await this.request<GetFileResponse>(endpoint); 275 | writeLogs("figma-raw.json", response); 276 | 277 | return response; 278 | } 279 | 280 | /** 281 | * Get raw Figma API response for specific nodes (for use with flexible extractors) 282 | */ 283 | async getRawNode( 284 | fileKey: string, 285 | nodeId: string, 286 | depth?: number | null, 287 | ): Promise<GetFileNodesResponse> { 288 | const endpoint = `/files/${fileKey}/nodes?ids=${nodeId}${depth ? `&depth=${depth}` : ""}`; 289 | Logger.log( 290 | `Retrieving raw Figma node: ${nodeId} from ${fileKey} (depth: ${depth ?? "default"})`, 291 | ); 292 | 293 | const response = await this.request<GetFileNodesResponse>(endpoint); 294 | writeLogs("figma-raw.json", response); 295 | 296 | return response; 297 | } 298 | } 299 | ```