#
tokens: 47850/50000 51/52 files (page 1/2)
lines: on (toggle) GitHub
raw markdown copy reset
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 | [![Watch the video](https://img.youtube.com/vi/6G9yb-LrEqg/maxresdefault.jpg)](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 | 
```
Page 1/2FirstPrevNextLast